Editor/Drivers/MultiViewSnapshotDriver.cs

Editor utility that captures multiple camera views from the active scene and composites them into a single PNG. It resolves a scene, creates temporary camera objects per viewport preset, invokes an editor screenshot helper, draws sub-bitmaps onto a canvas, writes the PNG to a .sbox/captures folder and returns metadata about the capture.

File AccessNetworkingExternal Download
#nullable enable
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Nodes;
using Sandbox;

namespace Editor.Mcp
{
	internal static class MultiViewSnapshotDriver
	{
		private static Scene? ResolveScene()
		{
			if ( Game.ActiveScene != null ) return Game.ActiveScene;
			var session = EditorRuntime.GetStaticProperty( "Editor.SceneEditorSession", "Active" );
			if ( session != null )
			{
				return EditorRuntime.GetMember( session, "Scene" ) as Scene;
			}
			return null;
		}

		public struct ViewportSpec
		{
			public string Label;
			public string TargetName;
			public string Preset;
			public Rect Rect;
		}

		public static ToolOutcome<object> CaptureMultiView(
			string layout = "quad_4view",
			string? primaryTarget = null,
			string? v0Target = null,
			string? v0Preset = null,
			string? v1Target = null,
			string? v1Preset = null,
			string? v2Target = null,
			string? v2Preset = null,
			string? v3Target = null,
			string? v3Preset = null,
			int totalWidth = 1024,
			int totalHeight = 1024,
			string? fileName = null,
			bool includeUi = false )
		{
			var scene = ResolveScene();
			if ( scene == null )
			{
				return ToolOutcome<object>.Failure( null, ToolStatus.Unavailable, "multiview.no_scene", "No active scene loaded." );
			}

			var normLayout = layout.ToLowerInvariant().Trim();
			var w = Math.Clamp( totalWidth, 256, 4096 );
			var h = Math.Clamp( totalHeight, 256, 4096 );

			var specs = new List<ViewportSpec>();

			if ( normLayout == "split_vertical" )
			{
				var halfW = w / 2f;
				specs.Add( new ViewportSpec
				{
					Label = "LEFT [P1 / View 0]",
					TargetName = v0Target ?? primaryTarget ?? "Camera",
					Preset = v0Preset ?? "fps",
					Rect = new Rect( 0, 0, halfW, h )
				} );
				specs.Add( new ViewportSpec
				{
					Label = "RIGHT [P2 / View 1]",
					TargetName = v1Target ?? primaryTarget ?? "Camera",
					Preset = v1Preset ?? "third_person",
					Rect = new Rect( halfW, 0, halfW, h )
				} );
			}
			else if ( normLayout == "split_horizontal" )
			{
				var halfH = h / 2f;
				specs.Add( new ViewportSpec
				{
					Label = "TOP [P1 / View 0]",
					TargetName = v0Target ?? primaryTarget ?? "Camera",
					Preset = v0Preset ?? "fps",
					Rect = new Rect( 0, 0, w, halfH )
				} );
				specs.Add( new ViewportSpec
				{
					Label = "BOTTOM [P2 / View 1]",
					TargetName = v1Target ?? primaryTarget ?? "Camera",
					Preset = v1Preset ?? "third_person",
					Rect = new Rect( 0, halfH, w, halfH )
				} );
			}
			else // quad_4view (default)
			{
				normLayout = "quad_4view";
				var halfW = w / 2f;
				var halfH = h / 2f;
				specs.Add( new ViewportSpec
				{
					Label = "TOP-LEFT [FPS / Target POV]",
					TargetName = v0Target ?? primaryTarget ?? "Camera",
					Preset = v0Preset ?? "fps",
					Rect = new Rect( 0, 0, halfW, halfH )
				} );
				specs.Add( new ViewportSpec
				{
					Label = "TOP-RIGHT [Third-Person Chase]",
					TargetName = v1Target ?? primaryTarget ?? "Camera",
					Preset = v1Preset ?? "third_person",
					Rect = new Rect( halfW, 0, halfW, halfH )
				} );
				specs.Add( new ViewportSpec
				{
					Label = "BOTTOM-LEFT [Isometric ARPG]",
					TargetName = v2Target ?? primaryTarget ?? "Camera",
					Preset = v2Preset ?? "isometric",
					Rect = new Rect( 0, halfH, halfW, halfH )
				} );
				specs.Add( new ViewportSpec
				{
					Label = "BOTTOM-RIGHT [Tactical RTS / Overhead]",
					TargetName = v3Target ?? primaryTarget ?? "Camera",
					Preset = v3Preset ?? "rts",
					Rect = new Rect( halfW, halfH, halfW, halfH )
				} );
			}

			// Composite Bitmap Canvas
			var canvas = new Sandbox.Bitmap( w, h );
			canvas.Clear( new Color( 0.08f, 0.08f, 0.10f, 1f ) );

			var capturedViewports = new List<object>();
			var temporaryCameras = new List<GameObject>();

			try
			{
				for ( int i = 0; i < specs.Count; i++ )
				{
					var spec = specs[i];
					var go = scene.Directory.FindByGuid( Guid.TryParse( spec.TargetName, out var guid ) ? guid : Guid.Empty )
						?? scene.GetAllObjects( false ).FirstOrDefault( o => string.Equals( o.Name, spec.TargetName, StringComparison.OrdinalIgnoreCase ) )
						?? scene.GetAllObjects( false ).FirstOrDefault( o => o.GetComponent<CameraComponent>() != null );

					var center = go?.WorldPosition ?? Vector3.Zero;
					var forward = go?.WorldRotation.Forward ?? Vector3.Forward;
					var right = go?.WorldRotation.Right ?? Vector3.Right;
					var up = Vector3.Up;

					Vector3 camPos;
					Rotation camRot;
					float camFov = 80f;

					switch ( spec.Preset.ToLowerInvariant().Trim() )
					{
						case "fps":
							camPos = center + up * 64f + forward * 6f;
							camRot = go?.WorldRotation ?? Rotation.Identity;
							camFov = 90f;
							break;

						case "third_person":
						case "tps":
							camPos = center + (forward * -85f) + (right * 22f) + (up * 55f);
							camRot = Rotation.LookAt( (center + up * 42f) - camPos );
							camFov = 75f;
							break;

						case "rts":
						case "overhead":
							camPos = center + new Vector3( 0f, -160f, 380f );
							camRot = Rotation.LookAt( center - camPos );
							camFov = 65f;
							break;

						case "isometric":
						case "dimetric":
							// Pitch ~35.264°, Yaw 45°
							var isoRadPitch = (float)(35.264 * Math.PI / 180.0);
							var isoRadYaw = (float)(45.0 * Math.PI / 180.0);
							var isoDist = 320f;
							var ox = isoDist * (float)Math.Cos( isoRadPitch ) * (float)Math.Sin( isoRadYaw );
							var oy = isoDist * (float)Math.Cos( isoRadPitch ) * (float)Math.Cos( isoRadYaw );
							var oz = isoDist * (float)Math.Sin( isoRadPitch );
							camPos = center + new Vector3( ox, oy, oz );
							camRot = Rotation.LookAt( center - camPos );
							camFov = 45f;
							break;

						case "sidescroller":
						case "2.5d":
							camPos = center + new Vector3( 0f, -320f, 35f );
							camRot = Rotation.LookAt( (center + up * 30f) - camPos );
							camFov = 60f;
							break;

						default: // front / default
							camPos = center + forward * 160f + up * 40f;
							camRot = Rotation.LookAt( center - camPos );
							camFov = 75f;
							break;
					}

					var camGo = scene.CreateObject( true );
					camGo.Name = $"__temp_multiview_cam_{i}__";
					camGo.WorldPosition = camPos;
					camGo.WorldRotation = camRot;
					var cam = camGo.Components.Create<CameraComponent>();
					cam.FieldOfView = camFov;
					temporaryCameras.Add( camGo );

					var subW = (int)spec.Rect.Width;
					var subH = (int)spec.Rect.Height;

					var screenshotResult = EditorRuntime.InvokeStatic( "Editor.Mcp.SceneTools", "CameraScreenshot", cam.Id.ToString(), subW, subH, includeUi );

					if ( screenshotResult is Sandbox.Bitmap subBmp )
					{
						canvas.DrawBitmap( subBmp, spec.Rect );
						subBmp.Dispose();
					}
					else if ( screenshotResult != null )
					{
						var json = JsonSerializer.Serialize( screenshotResult );
						var node = JsonNode.Parse( json );
						var base64Data = (node as JsonArray)?[0]?["data"]?.GetValue<string>() ?? (node as JsonObject)?["data"]?.GetValue<string>();
						if ( !string.IsNullOrEmpty( base64Data ) )
						{
							var bytes = Convert.FromBase64String( base64Data );
							var importedBmp = Sandbox.Bitmap.CreateFromBytes( bytes );
							if ( importedBmp != null )
							{
								canvas.DrawBitmap( importedBmp, spec.Rect );
								importedBmp.Dispose();
							}
						}
					}

					capturedViewports.Add( new
					{
						Index = i,
						spec.Label,
						Target = go?.Name ?? spec.TargetName,
						spec.Preset,
						CameraPosition = camPos,
						CameraRotation = camRot.Angles(),
						FOV = camFov,
						Rect = new { spec.Rect.Left, spec.Rect.Top, spec.Rect.Width, spec.Rect.Height }
					} );
				}

				// Draw dividing grid lines
				canvas.SetPen( new Color( 0.25f, 0.45f, 0.75f, 0.9f ), 2f );
				if ( normLayout == "split_vertical" )
				{
					canvas.DrawLine( new Vector2( w / 2f, 0 ), new Vector2( w / 2f, h ) );
				}
				else if ( normLayout == "split_horizontal" )
				{
					canvas.DrawLine( new Vector2( 0, h / 2f ), new Vector2( w, h / 2f ) );
				}
				else if ( normLayout == "quad_4view" )
				{
					canvas.DrawLine( new Vector2( w / 2f, 0 ), new Vector2( w / 2f, h ) );
					canvas.DrawLine( new Vector2( 0, h / 2f ), new Vector2( w, h / 2f ) );
				}

				// Write artifact
				var projectRoot = Project.Current?.GetRootPath() ?? Environment.CurrentDirectory;
				var artifactDir = Path.Combine( projectRoot, ".sbox", "captures" );
				Directory.CreateDirectory( artifactDir );

				var outName = string.IsNullOrWhiteSpace( fileName )
					? $"multiview_{normLayout}_{DateTime.UtcNow:yyyyMMdd_HHmmss}.png"
					: fileName.Trim();

				var fullPath = Path.Combine( artifactDir, outName );
				var pngBytes = canvas.ToPng();
				File.WriteAllBytes( fullPath, pngBytes );

				return ToolOutcome<object>.Ready( new
				{
					Layout = normLayout,
					TotalWidth = w,
					TotalHeight = h,
					ViewportCount = specs.Count,
					ArtifactPath = fullPath,
					FileSizeBytes = pngBytes.Length,
					Viewports = capturedViewports
				} );
			}
			finally
			{
				foreach ( var c in temporaryCameras )
				{
					c.Destroy();
				}
				canvas.Dispose();
			}
		}
	}
}