Skip to content

Instantly share code, notes, and snippets.

@rtlsilva
Created November 29, 2023 22:09
Show Gist options
  • Save rtlsilva/79aa29fc97e3282a005d2bc3090c2fe0 to your computer and use it in GitHub Desktop.
Save rtlsilva/79aa29fc97e3282a005d2bc3090c2fe0 to your computer and use it in GitHub Desktop.
A utility class that takes snapshots of GameObjects by rendering them to textures
using System;
using System.Linq;
using UniRx;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.UI;
using MyNamespace.Extensions;
namespace MyNamespace.Utils
{
/// <summary>
/// Utility class that takes snapshots of content by rendering it to textures.
/// </summary>
public static class GameObjectSnapshotRenderer
{
/// <summary>
/// Returns an observable that directly renders the given <see cref="Camera"/> to a screen-sized <see cref="Texture2D"/>.
/// </summary>
/// <param name="camera">The camera to use for rendering.</param>
/// <param name="captureStage">The position in the camera's rendering pipeline where the content will be captured to a texture.
/// Consult Unity's documentation on built-in rendering pipelines and command buffers to verify which steps are valid./param>
public static IObservable<Texture2D> RenderToTexture(Camera camera, CameraEvent captureStage = CameraEvent.AfterEverything)
{
return RenderToTexture(camera, Screen.width, Screen.height, captureStage);
}
/// <summary>
/// Returns an observable that directly renders the given <see cref="Camera"/> to a <see cref="Texture2D"/> of the given dimensions.
/// </summary>
/// <param name="camera">The camera to use for rendering.</param>
/// <param name="textureWidth">The pixel width of the capture.</param>
/// <param name="textureHeight">The pixel height of the capture.</param>
/// <param name="captureStage">The position in the camera's rendering pipeline where the content will be captured to a texture.
/// Consult Unity's documentation on built-in rendering pipelines and command buffers to verify which steps are valid./param>
public static IObservable<Texture2D> RenderToTexture(Camera camera, int textureWidth, int textureHeight, CameraEvent captureStage = CameraEvent.AfterEverything)
{
return Observable.NextFrame()
.Select(_ => RenderCameraToTexture(camera, textureWidth, textureHeight, captureStage));
}
/// <summary>
/// Returns an observable that renders the given <see cref="RectTransform"/> to a screen-sized texture.
/// <para>The rendering is done off-screen and the target is given a frame to perform layout updates before rendering.</para>
/// </summary>
/// <param name="renderSetup"> An action that executes before rendering. Takes the root transform that is created for this operation
/// so elements may be added to it and configured.</param>
public static IObservable<Texture2D> RenderToTexture(Action<RectTransform> renderSetup = null)
{
// Set up the studio
var photoStudio = new GameObject(nameof(GameObjectSnapshotRenderer), typeof(RectTransform), typeof(Canvas));
var photoStudioTransform = photoStudio.RectTransform();
var photoStudioCanvas = photoStudio.GetComponent<Canvas>();
var photoStudioCanvasScaler = photoStudioCanvas.GetOrAddComponent<CanvasScaler>();
var screenshotCamera = SetupCamera("Screenshot Camera", photoStudioTransform, Color.clear, cullingMask: LayerMask.GetMask("OffscreenRendering"));
photoStudioTransform.SetParent(null);
photoStudioTransform.Stretch();
photoStudioTransform.anchoredPosition3D = Vector3.zero.WithX(10000);
photoStudioTransform.SetDefaultScale();
// we need something assigned here so the canvas doesn't act as an overlay canvas in the frame before rendering,
// as that would cause it to draw over everything and not respect its offscreen position
photoStudioCanvas.worldCamera = screenshotCamera;
photoStudioCanvas.renderMode = RenderMode.ScreenSpaceCamera;
photoStudioCanvas.planeDistance = 0;
photoStudioCanvasScaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
photoStudioCanvasScaler.referenceResolution = new Vector2(Screen.width, Screen.height);
photoStudioCanvasScaler.screenMatchMode = CanvasScaler.ScreenMatchMode.MatchWidthOrHeight;
photoStudioCanvasScaler.matchWidthOrHeight = 1f;
renderSetup?.Invoke(photoStudioTransform);
photoStudio.SelfAndDescendants().ForEach(go => go.layer = LayerMask.NameToLayer("OffscreenRendering"));
// this fits the camera to the canvas so we can render non-screen-sized canvases to non-screen-sized textures
// (in other words, the canvas' width and height directly correspond to the output size when the uniform scale of the canvas is 1)
var renderWidth = photoStudioTransform.GetWidth();
var renderHeight = photoStudioTransform.GetHeight();
screenshotCamera.aspect = renderWidth / renderHeight;
screenshotCamera.orthographicSize = renderHeight * 0.5f;
return RenderToTexture(screenshotCamera, (int)renderWidth, (int)renderHeight)
.Finally(() => GameObject.Destroy(photoStudio));
}
/// <summary>
/// Renders the specified <see cref="Camera"/> to a texture.
/// </summary>
/// <param name="camera">The camera that will render the content that will be captured to a texture.</param>
/// <param name="captureStage">The position in the camera's rendering pipeline where the content will be captured to a texture.
/// Consult Unity's documentation on built-in rendering pipelines and command buffers to verify which steps are valid./param>
/// <returns>A <see cref="Texture2D"/> with the camera's rendered contents.</returns>
private static Texture2D RenderCameraToTexture(Camera camera, int textureWidth, int textureHeight, CameraEvent captureStage = CameraEvent.AfterEverything)
{
// Prep the film
var screenCopyTexture = RenderTexture.GetTemporary(textureWidth, textureHeight, 32, RenderTextureFormat.ARGB32);
if (screenCopyTexture == null)
Debug.LogError("Could not obtain temporary render texture");
var outputTextureId = new RenderTargetIdentifier(screenCopyTexture);
var activeRenderTexture = RenderTexture.active;
RenderTexture.active = screenCopyTexture;
GL.Clear(true, true, Color.clear);
RenderTexture.active = activeRenderTexture;
// Snap a shot
camera.forceIntoRenderTexture = true;
if (captureStage == CameraEvent.AfterEverything)
{
// if we want the final rendering result, simply assign the output to the camera
if (camera.targetTexture != null) camera.targetTexture.Release();
camera.targetTexture = screenCopyTexture;
camera.Render();
camera.targetTexture = null;
}
else
{
// for captures of intermediate rendering steps, use command buffers
var commandBuffer = BuildCameraCopyCommandBuffer(camera, outputTextureId);
camera.AddCommandBuffer(captureStage, commandBuffer);
camera.Render();
camera.RemoveCommandBuffer(captureStage, commandBuffer);
commandBuffer.Release();
}
camera.forceIntoRenderTexture = false;
// Develop the film into a texture
var output = screenCopyTexture.ToTexture2D();
RenderTexture.ReleaseTemporary(screenCopyTexture);
return output;
}
/// <summary>
/// Creates a <see cref="Camera"/> with the given name, parent transform and background color and other predefined configurations.
/// </summary>
/// <param name="cameraName">The name of the game object that will hold the camera component.</param>
/// <param name="parent">The transform that the camera will be parented to.</param>
/// <param name="backgroundColor">The clear color of the camera's background.</param>
/// <param name="cameraClearFlags">The camera's clear flags. Defaults to <see cref="CameraClearFlags.SolidColor"/>.</param>
/// <param name="isOrthographic">Whether this is an ortographic camera.</param>
private static Camera SetupCamera(string cameraName, Transform parent,
Color backgroundColor,
LayerMask cullingMask = default,
CameraClearFlags cameraClearFlags = CameraClearFlags.SolidColor,
bool isOrthographic = true)
{
var camera = new GameObject(cameraName).AddComponent<Camera>();
if (parent != null)
camera.transform.SetParent(parent, false);
camera.cullingMask = cullingMask;
camera.clearFlags = cameraClearFlags;
camera.backgroundColor = backgroundColor;
camera.orthographic = isOrthographic;
camera.nearClipPlane = 0;
camera.depth = -100;
camera.allowMSAA = false;
camera.forceIntoRenderTexture = true;
camera.enabled = false; // we'll call Render() manually, so the component can be disabled
return camera;
}
/// <summary>
/// Constructs a <see cref="CommandBuffer"/> that will directly copy the given <see cref="Camera"/>'s rendered content
/// to the output render target.
/// </summary>
/// <param name="camera">The camera that will provide input.</param>
/// <param name="outputRenderTarget">The output render target.</param>
/// <returns>A command buffer to be executed or added to a camera.</returns>
private static CommandBuffer BuildCameraCopyCommandBuffer(Camera camera, RenderTargetIdentifier outputRenderTarget)
{
var sourceTexture = camera.allowMSAA ? BuiltinRenderTextureType.CameraTarget : BuiltinRenderTextureType.CurrentActive;
int screenCopyId = Shader.PropertyToID("_MainTex");
var commandBuffer = new CommandBuffer();
commandBuffer.name = "Copy Screen";
// Command buffers do not appear to set _MainTex properly on the default blit shader when using a temporary RT, so we blit the screen to _MainTex first
commandBuffer.Blit(sourceTexture, screenCopyId);
commandBuffer.Blit(screenCopyId, outputRenderTarget, new Material(Shader.Find("Hidden/BlitCopy")));
return commandBuffer;
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment