Studio/StudioBackdrop.cs

UI panel used as a still backdrop for the character editor plaza. It captures the plaza camera to a GPU render target texture, suppresses skinned model renderers while capturing, stores the snapshot as the panel background, and restores renderer state when done or deleted.

Native InteropFile Access
using System;
using Sandbox.UI;

namespace CharacterEditor;

/// <summary>The plaza is a still backdrop while editing, and resumes rendering when opened.</summary>
public sealed class StudioBackdrop : Panel
{
	public StudioSession Session { get; set; }
	readonly Dictionary<SkinnedModelRenderer, bool> visibility = new();
	Texture snapshot;
	Vector2 size;
	int revision = -1;
	float retryAt;
	float finishAt;
	bool finishPending;
	ulong captureFrame = ulong.MaxValue;

	public override void Tick()
	{
		base.Tick();
		var plaza = Session?.Plaza;
		if (!plaza.IsValid() || Session.Embedded || !plaza.Camera.IsValid()) return;
		// Keep the actors visible for the frame that captures them, then suppress
		// them even while the remaining resources load. The opaque collection has
		// no visible backdrop and does not need a capture at all.
		if (captureFrame == Application.FrameCount) return;
		SuppressCurrentRenderers(plaza);
		if (Session.View == "library") return;
		var currentSize = Box.Rect.Size;
		if (currentSize.x < 1 || currentSize.y < 1) return;
		if (snapshot is not null && size == currentSize && revision == Session.Library.Revision && (!finishPending || RealTime.Now < finishAt))
		{
			return;
		}
		if (RealTime.Now < retryAt) return;
		if (plaza.Residents.Any(a => !a.Body.IsValid() || a.Error is not null)) return;
		Restore();
		// Keep the capture on the GPU. RenderToBitmap forced a synchronous GPU
		// readback and then uploaded another texture during UI Tick. Reuse one
		// render target instead, with UI excluded to avoid capturing this panel.
		snapshot = Texture.CreateRenderTarget("Character studio backdrop", ImageFormat.RGBA8888, currentSize, snapshot);
		var setup = new Sandbox.Rendering.ViewSetup { EnableUI = false, EnableDebugOverlays = false };
		if (!plaza.Camera.RenderToTexture(snapshot, setup))
		{
			SuppressCurrentRenderers(plaza);
			retryAt = RealTime.Now + .5f;
			return;
		}
		Style.BackgroundImage = snapshot;
		captureFrame = Application.FrameCount;
		var changed = revision != Session.Library.Revision || size != currentSize;
		size = currentSize;
		revision = Session.Library.Revision;
		if (changed || !finishPending && finishAt == 0)
		{
			finishPending = true;
			finishAt = RealTime.Now + 3.5f;
		}
		else finishPending = false;
		retryAt = RealTime.Now + .5f;
	}

	void SuppressCurrentRenderers(CharacterPlaza plaza)
	{
		foreach (var renderer in visibility.Keys.Where(r => !r.IsValid()).ToArray()) visibility.Remove(renderer);
		foreach (var avatar in plaza.Residents)
			foreach (var renderer in avatar.GameObject.GetComponentsInChildren<SkinnedModelRenderer>(true, true))
			{
				if (!visibility.ContainsKey(renderer)) visibility[renderer] = renderer.Enabled;
				if (renderer.Enabled) renderer.Enabled = false;
			}
	}

	void Restore()
	{
		foreach (var pair in visibility)
			if (pair.Key.IsValid()) pair.Key.Enabled = pair.Value;
		visibility.Clear();
	}

	public override void Delete(bool immediate = false)
	{
		Restore();
		Style.BackgroundImage = null;
		snapshot?.Dispose();
		base.Delete(immediate);
	}
}