From ba5fb2387a06ee2b9e69262952db97d32d6c5e44 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 22 Oct 2026 18:31:02 +0900 Subject: [PATCH 01/18] feat: validate prefab screenshot parameters Add PrefabPath and PrefabBackgroundColor to the screenshot schema and the validation a prefab capture needs before any asset is loaded. - ValidatePrefab rejects options that only make sense for window or rendering captures (explicit CaptureMode, annotate options, RaycastLayerMask) and checks the resolution scale and color format. - The background color accepts only #RRGGBB / #RRGGBBAA; a regex guards the format because ColorUtility alone would also accept color names. - The non-prefab path rejects a background color without PrefabPath so a silently ignored option is reported instead. - The resolution scale range check is extracted so both paths share the same message. --- ...ScreenshotParameterValidatorPrefabTests.cs | 209 ++++++++++++++++++ ...nshotParameterValidatorPrefabTests.cs.meta | 11 + .../Screenshot/PrefabBackgroundColorParser.cs | 40 ++++ .../PrefabBackgroundColorParser.cs.meta | 11 + .../ScreenshotParameterValidator.cs | 66 +++++- .../Editor/ToolContracts/ScreenshotSchema.cs | 2 + .../ToolContracts/UnityCliLoopConstants.cs | 5 + 7 files changed, 339 insertions(+), 5 deletions(-) create mode 100644 Assets/Tests/Editor/ScreenshotParameterValidatorPrefabTests.cs create mode 100644 Assets/Tests/Editor/ScreenshotParameterValidatorPrefabTests.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/Screenshot/PrefabBackgroundColorParser.cs create mode 100644 Packages/src/Editor/FirstPartyTools/Screenshot/PrefabBackgroundColorParser.cs.meta diff --git a/Assets/Tests/Editor/ScreenshotParameterValidatorPrefabTests.cs b/Assets/Tests/Editor/ScreenshotParameterValidatorPrefabTests.cs new file mode 100644 index 0000000000..3d792acd36 --- /dev/null +++ b/Assets/Tests/Editor/ScreenshotParameterValidatorPrefabTests.cs @@ -0,0 +1,209 @@ +using NUnit.Framework; +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Verifies which screenshot parameter combinations are rejected for prefab captures before any asset is loaded. + /// + public sealed class ScreenshotParameterValidatorPrefabTests + { + private const string AnyPrefabPath = "Assets/Any.prefab"; + + /// + /// Verifies an explicit capture mode cannot be combined with a prefab path. + /// + [TestCase(CaptureMode.window, TestName = "ValidatePrefab_WithWindowCaptureMode_Throws")] + [TestCase(CaptureMode.rendering, TestName = "ValidatePrefab_WithRenderingCaptureMode_Throws")] + [TestCase(CaptureMode.GameView, TestName = "ValidatePrefab_WithGameViewCaptureMode_Throws")] + public void ValidatePrefab_WithAnExplicitCaptureMode_Throws(CaptureMode captureMode) + { + ScreenshotSchema request = CreatePrefabRequest(); + request.CaptureMode = captureMode; + + AssertPrefabRejected(request, "CaptureMode"); + } + + /// + /// Verifies the default auto capture mode is accepted for a prefab path. + /// + [Test] + public void ValidatePrefab_WithAutoCaptureMode_Accepts() + { + ScreenshotSchema request = CreatePrefabRequest(); + + Assert.DoesNotThrow(() => ScreenshotParameterValidator.ValidatePrefab(request)); + } + + /// + /// Verifies AnnotateElements cannot be combined with a prefab path. + /// + [Test] + public void ValidatePrefab_WithAnnotateElements_Throws() + { + ScreenshotSchema request = CreatePrefabRequest(); + request.AnnotateElements = true; + + AssertPrefabRejected(request, "AnnotateElements"); + } + + /// + /// Verifies ElementsOnly cannot be combined with a prefab path. + /// + [Test] + public void ValidatePrefab_WithElementsOnly_Throws() + { + ScreenshotSchema request = CreatePrefabRequest(); + request.ElementsOnly = true; + + AssertPrefabRejected(request, "ElementsOnly"); + } + + /// + /// Verifies AnnotateRaycastGrid cannot be combined with a prefab path. + /// + [Test] + public void ValidatePrefab_WithAnnotateRaycastGrid_Throws() + { + ScreenshotSchema request = CreatePrefabRequest(); + request.AnnotateRaycastGrid = true; + + AssertPrefabRejected(request, "AnnotateRaycastGrid"); + } + + /// + /// Verifies RaycastLayerMask cannot be combined with a prefab path. + /// + [Test] + public void ValidatePrefab_WithRaycastLayerMask_Throws() + { + ScreenshotSchema request = CreatePrefabRequest(); + request.RaycastLayerMask = "Default"; + + AssertPrefabRejected(request, "RaycastLayerMask"); + } + + /// + /// Verifies prefab resolution scales outside 0.1 to 1.0 are rejected and the bounds themselves are accepted. + /// + [TestCase(0.09f, false, TestName = "ValidatePrefab_WithAScaleBelowTheMinimum_Throws")] + [TestCase(0.1f, true, TestName = "ValidatePrefab_WithTheMinimumScale_Accepts")] + [TestCase(1.0f, true, TestName = "ValidatePrefab_WithTheMaximumScale_Accepts")] + [TestCase(1.01f, false, TestName = "ValidatePrefab_WithAScaleAboveTheMaximum_Throws")] + public void ValidatePrefab_ChecksTheResolutionScaleRange(float scale, bool accepted) + { + ScreenshotSchema request = CreatePrefabRequest(); + request.ResolutionScale = scale; + + if (accepted) + { + Assert.DoesNotThrow(() => ScreenshotParameterValidator.ValidatePrefab(request)); + return; + } + + AssertPrefabRejected(request, "ResolutionScale must be between 0.1 and 1.0"); + } + + /// + /// Verifies empty, #RRGGBB, and #RRGGBBAA background colors are accepted. + /// + [TestCase("", TestName = "ValidatePrefab_WithAnEmptyBackgroundColor_Accepts")] + [TestCase("#336699", TestName = "ValidatePrefab_WithAnRgbBackgroundColor_Accepts")] + [TestCase("#33669980", TestName = "ValidatePrefab_WithAnRgbaBackgroundColor_Accepts")] + [TestCase("#00000000", TestName = "ValidatePrefab_WithATransparentBackgroundColor_Accepts")] + public void ValidatePrefab_WithAWellFormedBackgroundColor_Accepts(string color) + { + ScreenshotSchema request = CreatePrefabRequest(); + request.PrefabBackgroundColor = color; + + Assert.DoesNotThrow(() => ScreenshotParameterValidator.ValidatePrefab(request)); + } + + /// + /// Verifies background colors outside the #RRGGBB / #RRGGBBAA forms are rejected, including color names. + /// + [TestCase("#FFF", TestName = "ValidatePrefab_WithAShortHexBackgroundColor_Throws")] + [TestCase("red", TestName = "ValidatePrefab_WithAColorNameBackgroundColor_Throws")] + [TestCase("#GG0000", TestName = "ValidatePrefab_WithANonHexBackgroundColor_Throws")] + [TestCase("336699", TestName = "ValidatePrefab_WithABackgroundColorMissingTheHash_Throws")] + public void ValidatePrefab_WithAMalformedBackgroundColor_Throws(string color) + { + ScreenshotSchema request = CreatePrefabRequest(); + request.PrefabBackgroundColor = color; + + AssertPrefabRejected(request, "PrefabBackgroundColor"); + } + + /// + /// Verifies WindowName and MatchMode are ignored for a prefab capture. + /// + [Test] + public void ValidatePrefab_WithAWindowNameAndMatchMode_Accepts() + { + ScreenshotSchema request = CreatePrefabRequest(); + request.WindowName = "Scene"; + request.MatchMode = WindowMatchMode.contains; + + Assert.DoesNotThrow(() => ScreenshotParameterValidator.ValidatePrefab(request)); + } + + /// + /// Verifies a background color without a prefab path is rejected on the non-prefab path. + /// + [Test] + public void Validate_WithABackgroundColorAndNoPrefabPath_Throws() + { + ScreenshotSchema request = new ScreenshotSchema { PrefabBackgroundColor = "#336699" }; + + UnityCliLoopToolParameterValidationException exception = + Assert.Throws(() => ScreenshotParameterValidator.Validate(request)); + Assert.That(exception.Message, Does.Contain("PrefabBackgroundColor requires PrefabPath")); + } + + /// + /// Verifies an empty background color resolves to the documented #808080 default. + /// + [Test] + public void Resolve_WithAnEmptyValue_ReturnsTheDefaultGray() + { + Color color = PrefabBackgroundColorParser.Resolve(""); + + AssertColor(new Color(0x80 / 255f, 0x80 / 255f, 0x80 / 255f, 1f), color); + } + + /// + /// Verifies a #RRGGBBAA value resolves to its channels including alpha. + /// + [Test] + public void Resolve_WithAnRgbaValue_ReturnsItsChannels() + { + Color color = PrefabBackgroundColorParser.Resolve("#33669980"); + + AssertColor(new Color(0x33 / 255f, 0x66 / 255f, 0x99 / 255f, 0x80 / 255f), color); + } + + private static ScreenshotSchema CreatePrefabRequest() + { + return new ScreenshotSchema { PrefabPath = AnyPrefabPath }; + } + + private static void AssertPrefabRejected(ScreenshotSchema request, string expectedMessagePart) + { + UnityCliLoopToolParameterValidationException exception = + Assert.Throws(() => ScreenshotParameterValidator.ValidatePrefab(request)); + Assert.That(exception.Message, Does.Contain(expectedMessagePart)); + } + + private static void AssertColor(Color expected, Color actual) + { + const float tolerance = 0.5f / 255f; + Assert.That(actual.r, Is.EqualTo(expected.r).Within(tolerance)); + Assert.That(actual.g, Is.EqualTo(expected.g).Within(tolerance)); + Assert.That(actual.b, Is.EqualTo(expected.b).Within(tolerance)); + Assert.That(actual.a, Is.EqualTo(expected.a).Within(tolerance)); + } + } +} diff --git a/Assets/Tests/Editor/ScreenshotParameterValidatorPrefabTests.cs.meta b/Assets/Tests/Editor/ScreenshotParameterValidatorPrefabTests.cs.meta new file mode 100644 index 0000000000..7210292cca --- /dev/null +++ b/Assets/Tests/Editor/ScreenshotParameterValidatorPrefabTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9cdd3aa8dffb44b20a50b53805e066be +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabBackgroundColorParser.cs b/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabBackgroundColorParser.cs new file mode 100644 index 0000000000..504f0e435a --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabBackgroundColorParser.cs @@ -0,0 +1,40 @@ +using System; +using System.Text.RegularExpressions; +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Checks and converts the background color accepted by prefab screenshots. + /// + internal static class PrefabBackgroundColorParser + { + // Accepts only #RRGGBB or #RRGGBBAA so the documented form is the whole contract; + // ColorUtility alone would also accept color names such as "red". + private static readonly Regex HexColor = new("^#([0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$"); + + internal static bool IsValid(string value) + { + return string.IsNullOrEmpty(value) || HexColor.IsMatch(value); + } + + /// + /// Returns the color for a value that already passed IsValid; empty means the default gray. + /// + internal static Color Resolve(string value) + { + string source = string.IsNullOrEmpty(value) + ? UnityCliLoopConstants.SCREENSHOT_PREFAB_DEFAULT_BACKGROUND_COLOR + : value; + if (!HexColor.IsMatch(source)) + { + throw new ArgumentException($"Background color must be validated before it is resolved, got: {value}", nameof(value)); + } + + ColorUtility.TryParseHtmlString(source, out Color color); + return color; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabBackgroundColorParser.cs.meta b/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabBackgroundColorParser.cs.meta new file mode 100644 index 0000000000..42e8f15b9c --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabBackgroundColorParser.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 36cd95f58c93843a8b96140e2742c0f9 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/ScreenshotParameterValidator.cs b/Packages/src/Editor/FirstPartyTools/Screenshot/ScreenshotParameterValidator.cs index 4191d755ec..24018d8f6e 100644 --- a/Packages/src/Editor/FirstPartyTools/Screenshot/ScreenshotParameterValidator.cs +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/ScreenshotParameterValidator.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using UnityEngine; @@ -12,17 +13,18 @@ internal static class ScreenshotParameterValidator { internal static void Validate(ScreenshotSchema request) { + if (!string.IsNullOrEmpty(request.PrefabBackgroundColor)) + { + throw new UnityCliLoopToolParameterValidationException("PrefabBackgroundColor requires PrefabPath"); + } + if (request.CaptureMode != CaptureMode.rendering && string.IsNullOrEmpty(request.WindowName)) { throw new UnityCliLoopToolParameterValidationException("WindowName cannot be null or empty"); } - if (request.ResolutionScale < 0.1f || request.ResolutionScale > 1.0f) - { - throw new UnityCliLoopToolParameterValidationException( - $"ResolutionScale must be between 0.1 and 1.0, got: {request.ResolutionScale}"); - } + ValidateResolutionScale(request); // AnnotateElements, ElementsOnly, and AnnotateRaycastGrid rely on PlayMode rendering pipeline if (request.CaptureMode != CaptureMode.rendering) @@ -65,6 +67,60 @@ internal static void Validate(ScreenshotSchema request) } } + /// + /// Validates a prefab capture request; callers route here only when PrefabPath is set. + /// + internal static void ValidatePrefab(ScreenshotSchema request) + { + if (string.IsNullOrEmpty(request.PrefabPath)) + { + throw new ArgumentException("ValidatePrefab requires PrefabPath", nameof(request)); + } + + if (request.CaptureMode != CaptureMode.auto) + { + throw new UnityCliLoopToolParameterValidationException( + "CaptureMode cannot be combined with PrefabPath; prefab screenshots always render the asset in a preview scene"); + } + + if (request.AnnotateElements) + { + throw new UnityCliLoopToolParameterValidationException("AnnotateElements cannot be combined with PrefabPath"); + } + + if (request.ElementsOnly) + { + throw new UnityCliLoopToolParameterValidationException("ElementsOnly cannot be combined with PrefabPath"); + } + + if (request.AnnotateRaycastGrid) + { + throw new UnityCliLoopToolParameterValidationException("AnnotateRaycastGrid cannot be combined with PrefabPath"); + } + + if (!string.IsNullOrEmpty(request.RaycastLayerMask)) + { + throw new UnityCliLoopToolParameterValidationException("RaycastLayerMask cannot be combined with PrefabPath"); + } + + ValidateResolutionScale(request); + + if (!PrefabBackgroundColorParser.IsValid(request.PrefabBackgroundColor)) + { + throw new UnityCliLoopToolParameterValidationException( + $"PrefabBackgroundColor must be #RRGGBB or #RRGGBBAA, got: {request.PrefabBackgroundColor}"); + } + } + + private static void ValidateResolutionScale(ScreenshotSchema request) + { + if (request.ResolutionScale < 0.1f || request.ResolutionScale > 1.0f) + { + throw new UnityCliLoopToolParameterValidationException( + $"ResolutionScale must be between 0.1 and 1.0, got: {request.ResolutionScale}"); + } + } + internal static RaycastLayerMaskResolution ResolveRaycastLayerMask(ScreenshotSchema request) { string raycastLayerMask = request.RaycastLayerMask ?? ""; diff --git a/Packages/src/Editor/ToolContracts/ScreenshotSchema.cs b/Packages/src/Editor/ToolContracts/ScreenshotSchema.cs index 0ef41565f9..09969817d1 100644 --- a/Packages/src/Editor/ToolContracts/ScreenshotSchema.cs +++ b/Packages/src/Editor/ToolContracts/ScreenshotSchema.cs @@ -14,5 +14,7 @@ public class ScreenshotSchema : UnityCliLoopToolSchema public bool ElementsOnly { get; set; } = false; public bool AnnotateRaycastGrid { get; set; } = false; public string RaycastLayerMask { get; set; } = ""; + public string PrefabPath { get; set; } = ""; + public string PrefabBackgroundColor { get; set; } = ""; } } diff --git a/Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs b/Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs index 3cf254040d..a5db7b48d3 100644 --- a/Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs +++ b/Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs @@ -123,6 +123,11 @@ public static UnityEditor.PackageManager.PackageInfo PackageInfo public const string SCREENSHOT_SIMULATOR_WINDOW_NAME = "Simulator"; public const string SCREENSHOT_RESOLVED_CAPTURE_MODE_WINDOW = "window"; public const string SCREENSHOT_RESOLVED_CAPTURE_MODE_RENDERING = "rendering"; + public const string SCREENSHOT_RESOLVED_CAPTURE_MODE_PREFAB = "prefab"; + public const string COORDINATE_SYSTEM_TOP_LEFT_PREFAB_IMAGE = "top-left-prefab-image"; + public const string SCREENSHOT_PREFAB_TO_INPUT_FORMULA_UNAVAILABLE = + "unavailable: prefab screenshots render the asset in an isolated preview scene, not the Game View"; + public const string SCREENSHOT_PREFAB_DEFAULT_BACKGROUND_COLOR = "#808080"; public const int CORRELATION_ID_LENGTH = 8; public const string GUID_FORMAT_NO_HYPHENS = "N"; From dd65bc96598af9135db789ed0cfe6e31eb920de5 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 22 Oct 2026 18:35:29 +0900 Subject: [PATCH 02/18] feat: classify and frame a prefab instance for screenshots Decide what a prefab screenshot renders as and aim a temporary camera at an instance placed in a preview scene, without touching the asset. - Screen UI (an enabled screen-space Canvas) is checked first and keeps the in-game layout at the Game View size by switching the outermost Canvases to the capture camera. - A RectTransform root is a UI part sized to the union of all active RectTransforms, so children that overflow the root are not cut off, and counted in UI units so a scaled world-space Canvas keeps its size. - Other prefabs are framed as a 3D object from the front-left, or straight on when they have no depth; a directional light is added when the prefab has no enabled light. - Prefabs with nothing to render are rejected as a parameter error. --- .../Tests/Editor/PrefabCaptureFramingTests.cs | 458 ++++++++++++++++++ .../Editor/PrefabCaptureFramingTests.cs.meta | 11 + .../Screenshot/PrefabCaptureFraming.cs | 335 +++++++++++++ .../Screenshot/PrefabCaptureFraming.cs.meta | 11 + 4 files changed, 815 insertions(+) create mode 100644 Assets/Tests/Editor/PrefabCaptureFramingTests.cs create mode 100644 Assets/Tests/Editor/PrefabCaptureFramingTests.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/Screenshot/PrefabCaptureFraming.cs create mode 100644 Packages/src/Editor/FirstPartyTools/Screenshot/PrefabCaptureFraming.cs.meta diff --git a/Assets/Tests/Editor/PrefabCaptureFramingTests.cs b/Assets/Tests/Editor/PrefabCaptureFramingTests.cs new file mode 100644 index 0000000000..087447810e --- /dev/null +++ b/Assets/Tests/Editor/PrefabCaptureFramingTests.cs @@ -0,0 +1,458 @@ +using NUnit.Framework; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; +using UnityEngine.SceneManagement; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Verifies how a prefab instance in a preview scene is classified, sized, and framed before rendering. + /// + public sealed class PrefabCaptureFramingTests + { + private static readonly Vector2Int GameViewSize = new(800, 600); + private const float Tolerance = 0.001f; + + private Scene _scene; + private Camera _camera; + + [SetUp] + public void SetUp() + { + _scene = EditorSceneManager.NewPreviewScene(); + GameObject cameraObject = CreateInScene("Camera"); + _camera = cameraObject.AddComponent(); + _camera.scene = _scene; + } + + [TearDown] + public void TearDown() + { + EditorSceneManager.ClosePreviewScene(_scene); + } + + /// + /// Verifies a UI part is sized to the union of all RectTransforms, including a child that overflows the root. + /// + [Test] + public void Arrange_WithAnOverflowingChild_SizesToTheUnionOfRectTransforms() + { + GameObject root = CreateUiPartWithOverflowingChild(out GameObject _); + + PrefabCaptureFrame frame = PrefabCaptureFraming.Arrange(root, _camera, GameViewSize); + + Assert.That(frame.Kind, Is.EqualTo(PrefabCaptureKind.UiPart)); + Assert.That(frame.ImageSize, Is.EqualTo(new Vector2Int(140, 100))); + Assert.That(_camera.orthographic, Is.True); + Assert.That(_camera.orthographicSize, Is.EqualTo(50f).Within(Tolerance)); + Assert.That(_camera.transform.position.x, Is.EqualTo(-20f).Within(Tolerance)); + Assert.That(_camera.transform.position.y, Is.EqualTo(0f).Within(Tolerance)); + } + + /// + /// Verifies an inactive child does not widen the UI part. + /// + [Test] + public void Arrange_WithAnInactiveOverflowingChild_IgnoresTheChild() + { + GameObject root = CreateUiPartWithOverflowingChild(out GameObject child); + child.SetActive(false); + + PrefabCaptureFrame frame = PrefabCaptureFraming.Arrange(root, _camera, GameViewSize); + + Assert.That(frame.ImageSize, Is.EqualTo(new Vector2Int(100, 100))); + } + + /// + /// Verifies a stretched UI root fills the Game View size. + /// + [Test] + public void Arrange_WithAStretchedUiRoot_UsesTheGameViewSize() + { + GameObject root = CreateRectInScene("Root"); + RectTransform rect = (RectTransform)root.transform; + rect.anchorMin = Vector2.zero; + rect.anchorMax = Vector2.one; + rect.sizeDelta = Vector2.zero; + + PrefabCaptureFrame frame = PrefabCaptureFraming.Arrange(root, _camera, GameViewSize); + + Assert.That(frame.Kind, Is.EqualTo(PrefabCaptureKind.UiPart)); + Assert.That(frame.ImageSize, Is.EqualTo(GameViewSize)); + } + + /// + /// Verifies a UI root with no area is rejected as having nothing to capture. + /// + [Test] + public void Arrange_WithAZeroSizedUiRoot_Throws() + { + GameObject root = CreateRectInScene("Root"); + ((RectTransform)root.transform).sizeDelta = Vector2.zero; + + Assert.Throws( + () => PrefabCaptureFraming.Arrange(root, _camera, GameViewSize)); + } + + /// + /// Verifies a root screen-space Canvas is switched to the capture camera while a nested Canvas is left alone. + /// + [Test] + public void Arrange_WithAnOverlayCanvasRoot_SwitchesOnlyTheOutermostCanvas() + { + GameObject root = CreateInScene("Root"); + Canvas rootCanvas = root.AddComponent(); + rootCanvas.renderMode = RenderMode.ScreenSpaceOverlay; + Canvas nestedCanvas = CreateChild(root, "Nested").AddComponent(); + nestedCanvas.overrideSorting = true; + + PrefabCaptureFrame frame = PrefabCaptureFraming.Arrange(root, _camera, GameViewSize); + + Assert.That(frame.Kind, Is.EqualTo(PrefabCaptureKind.ScreenUi)); + Assert.That(frame.ImageSize, Is.EqualTo(GameViewSize)); + Assert.That(rootCanvas.renderMode, Is.EqualTo(RenderMode.ScreenSpaceCamera)); + Assert.That(rootCanvas.worldCamera, Is.SameAs(_camera)); + // A nested Canvas's worldCamera getter returns the root Canvas's camera, so the stored value is read instead. + Assert.That(new SerializedObject(nestedCanvas).FindProperty("m_Camera").objectReferenceValue, Is.Null); + } + + /// + /// Verifies a screen-space-camera Canvas under a plain Transform root renders through the capture camera. + /// + [Test] + public void Arrange_WithAScreenSpaceCameraCanvasChild_AssignsTheCaptureCamera() + { + GameObject root = CreateInScene("Root"); + Canvas canvas = CreateChild(root, "Canvas").AddComponent(); + canvas.renderMode = RenderMode.ScreenSpaceCamera; + + PrefabCaptureFrame frame = PrefabCaptureFraming.Arrange(root, _camera, GameViewSize); + + Assert.That(frame.Kind, Is.EqualTo(PrefabCaptureKind.ScreenUi)); + Assert.That(canvas.worldCamera, Is.SameAs(_camera)); + } + + /// + /// Verifies a RectTransform root with a world-space Canvas is treated as a UI part, not screen UI. + /// + [Test] + public void Arrange_WithAWorldSpaceCanvasRoot_TreatsItAsAUiPart() + { + GameObject root = CreateRectInScene("Root"); + Canvas canvas = root.AddComponent(); + canvas.renderMode = RenderMode.WorldSpace; + ((RectTransform)root.transform).sizeDelta = new Vector2(100f, 100f); + + PrefabCaptureFrame frame = PrefabCaptureFraming.Arrange(root, _camera, GameViewSize); + + Assert.That(frame.Kind, Is.EqualTo(PrefabCaptureKind.UiPart)); + } + + /// + /// Verifies a 3D object is framed by a perspective camera that sees every corner of its bounds. + /// + [Test] + public void Arrange_WithACube_FramesAllBoundsCornersInPerspective() + { + GameObject root = CreateInScene("Root"); + Renderer cube = CreatePrimitiveChild(root, PrimitiveType.Cube); + + PrefabCaptureFrame frame = PrefabCaptureFraming.Arrange(root, _camera, GameViewSize); + + Assert.That(frame.Kind, Is.EqualTo(PrefabCaptureKind.Object3D)); + Assert.That(frame.ImageSize, Is.EqualTo(GameViewSize)); + Assert.That(_camera.orthographic, Is.False); + AssertCornersInView(BoundsCorners(cube.bounds)); + } + + /// + /// Verifies a directional light is added when the 3D prefab has no light of its own. + /// + [Test] + public void Arrange_WithACubeAndNoLight_AddsADirectionalLight() + { + GameObject root = CreateInScene("Root"); + CreatePrimitiveChild(root, PrimitiveType.Cube); + int lightsBefore = CountLights(); + + PrefabCaptureFraming.Arrange(root, _camera, GameViewSize); + + Assert.That(CountLights(), Is.EqualTo(lightsBefore + 1)); + Assert.That(CountDirectionalLightsOutside(root), Is.EqualTo(1)); + } + + /// + /// Verifies no light is added when the 3D prefab carries an enabled light. + /// + [Test] + public void Arrange_WithACubeAndAnEnabledLight_AddsNoLight() + { + GameObject root = CreateInScene("Root"); + CreatePrimitiveChild(root, PrimitiveType.Cube); + CreateChild(root, "Light").AddComponent(); + int lightsBefore = CountLights(); + + PrefabCaptureFraming.Arrange(root, _camera, GameViewSize); + + Assert.That(CountLights(), Is.EqualTo(lightsBefore)); + } + + /// + /// Verifies a flat object is framed straight on by an unrotated orthographic camera. + /// + [Test] + public void Arrange_WithAQuad_FramesItStraightOn() + { + GameObject root = CreateInScene("Root"); + Renderer quad = CreatePrimitiveChild(root, PrimitiveType.Quad); + + PrefabCaptureFrame frame = PrefabCaptureFraming.Arrange(root, _camera, GameViewSize); + + Assert.That(frame.Kind, Is.EqualTo(PrefabCaptureKind.Flat2D)); + Assert.That(_camera.orthographic, Is.True); + Assert.That(_camera.transform.rotation, Is.EqualTo(Quaternion.identity)); + AssertCornersInView(BoundsCorners(quad.bounds)); + } + + /// + /// Verifies a prefab with neither a Renderer nor a Canvas is rejected. + /// + [Test] + public void Arrange_WithNothingToRender_Throws() + { + GameObject root = CreateInScene("Root"); + + Assert.Throws( + () => PrefabCaptureFraming.Arrange(root, _camera, GameViewSize)); + } + + /// + /// Verifies a prefab saved with an inactive root is activated so its content can be captured. + /// + [Test] + public void Arrange_WithAnInactiveRoot_ActivatesTheRoot() + { + GameObject root = CreateInScene("Root"); + CreatePrimitiveChild(root, PrimitiveType.Cube); + root.SetActive(false); + + PrefabCaptureFrame frame = PrefabCaptureFraming.Arrange(root, _camera, GameViewSize); + + Assert.That(root.activeSelf, Is.True); + Assert.That(frame.Kind, Is.EqualTo(PrefabCaptureKind.Object3D)); + } + + /// + /// Verifies screen UI takes precedence over 3D content in the same prefab. + /// + [Test] + public void Arrange_WithAnOverlayCanvasAndACube_PrefersScreenUi() + { + GameObject root = CreateInScene("Root"); + CreateChild(root, "Canvas").AddComponent().renderMode = RenderMode.ScreenSpaceOverlay; + CreatePrimitiveChild(root, PrimitiveType.Cube); + + PrefabCaptureFrame frame = PrefabCaptureFraming.Arrange(root, _camera, GameViewSize); + + Assert.That(frame.Kind, Is.EqualTo(PrefabCaptureKind.ScreenUi)); + } + + /// + /// Verifies a world-space Canvas under a plain Transform root is framed straight on with all four corners visible. + /// + [Test] + public void Arrange_WithOnlyAWorldSpaceCanvasChild_FramesItAsFlat() + { + GameObject root = CreateInScene("Root"); + GameObject canvasObject = CreateChild(root, "Canvas"); + Canvas canvas = canvasObject.AddComponent(); + canvas.renderMode = RenderMode.WorldSpace; + RectTransform canvasRect = (RectTransform)canvasObject.transform; + canvasRect.sizeDelta = new Vector2(100f, 100f); + + PrefabCaptureFrame frame = PrefabCaptureFraming.Arrange(root, _camera, GameViewSize); + + Assert.That(frame.Kind, Is.EqualTo(PrefabCaptureKind.Flat2D)); + Vector3[] corners = new Vector3[4]; + canvasRect.GetWorldCorners(corners); + AssertCornersInView(corners); + } + + /// + /// Verifies a scaled UI root is sized in UI units rather than world units. + /// + [Test] + public void Arrange_WithAScaledWorldSpaceCanvasRoot_SizesInUiUnits() + { + GameObject root = CreateRectInScene("Root"); + Canvas canvas = root.AddComponent(); + canvas.renderMode = RenderMode.WorldSpace; + RectTransform rect = (RectTransform)root.transform; + rect.sizeDelta = new Vector2(100f, 100f); + rect.localScale = new Vector3(0.01f, 0.01f, 1f); + + PrefabCaptureFrame frame = PrefabCaptureFraming.Arrange(root, _camera, GameViewSize); + + Assert.That(frame.Kind, Is.EqualTo(PrefabCaptureKind.UiPart)); + Assert.That(frame.ImageSize, Is.EqualTo(new Vector2Int(100, 100))); + } + + /// + /// Verifies disabled screen-space Canvases do not make a prefab screen UI. + /// + [Test] + public void Arrange_WithDisabledOverlayCanvases_TreatsTheRectRootAsAUiPart() + { + GameObject root = CreateInScene("Root"); + Canvas rootCanvas = root.AddComponent(); + rootCanvas.renderMode = RenderMode.ScreenSpaceOverlay; + Canvas nestedCanvas = CreateChild(root, "Nested").AddComponent(); + nestedCanvas.overrideSorting = true; + rootCanvas.enabled = false; + nestedCanvas.enabled = false; + ((RectTransform)root.transform).sizeDelta = new Vector2(100f, 100f); + + PrefabCaptureFrame frame = PrefabCaptureFraming.Arrange(root, _camera, GameViewSize); + + Assert.That(frame.Kind, Is.EqualTo(PrefabCaptureKind.UiPart)); + } + + /// + /// Verifies a disabled Renderer is not counted, so a prefab with nothing else is rejected. + /// + [Test] + public void Arrange_WithOnlyADisabledRenderer_Throws() + { + GameObject root = CreateInScene("Root"); + CreatePrimitiveChild(root, PrimitiveType.Cube).enabled = false; + + Assert.Throws( + () => PrefabCaptureFraming.Arrange(root, _camera, GameViewSize)); + } + + /// + /// Verifies a disabled light in the prefab does not count, so a directional light is still added. + /// + [Test] + public void Arrange_WithACubeAndADisabledLight_AddsADirectionalLight() + { + GameObject root = CreateInScene("Root"); + CreatePrimitiveChild(root, PrimitiveType.Cube); + CreateChild(root, "Light").AddComponent().enabled = false; + int lightsBefore = CountLights(); + + PrefabCaptureFraming.Arrange(root, _camera, GameViewSize); + + Assert.That(CountLights(), Is.EqualTo(lightsBefore + 1)); + Assert.That(CountDirectionalLightsOutside(root), Is.EqualTo(1)); + } + + private GameObject CreateUiPartWithOverflowingChild(out GameObject child) + { + GameObject root = CreateRectInScene("Root"); + ((RectTransform)root.transform).sizeDelta = new Vector2(100f, 100f); + child = new GameObject("Child", typeof(RectTransform)); + child.transform.SetParent(root.transform, false); + RectTransform childRect = (RectTransform)child.transform; + childRect.sizeDelta = new Vector2(40f, 20f); + childRect.anchoredPosition = new Vector2(-70f, 0f); + return root; + } + + private GameObject CreateInScene(string name) + { + GameObject gameObject = new GameObject(name); + SceneManager.MoveGameObjectToScene(gameObject, _scene); + return gameObject; + } + + private GameObject CreateRectInScene(string name) + { + GameObject gameObject = new GameObject(name, typeof(RectTransform)); + SceneManager.MoveGameObjectToScene(gameObject, _scene); + return gameObject; + } + + private static GameObject CreateChild(GameObject parent, string name) + { + GameObject child = new GameObject(name); + child.transform.SetParent(parent.transform, false); + return child; + } + + private Renderer CreatePrimitiveChild(GameObject parent, PrimitiveType type) + { + GameObject primitive = GameObject.CreatePrimitive(type); + SceneManager.MoveGameObjectToScene(primitive, _scene); + primitive.transform.SetParent(parent.transform, false); + return primitive.GetComponent(); + } + + private int CountLights() + { + int count = 0; + foreach (GameObject sceneRoot in _scene.GetRootGameObjects()) + { + count += sceneRoot.GetComponentsInChildren(true).Length; + } + + return count; + } + + private int CountDirectionalLightsOutside(GameObject prefabRoot) + { + int count = 0; + foreach (GameObject sceneRoot in _scene.GetRootGameObjects()) + { + if (sceneRoot == prefabRoot) + { + continue; + } + + foreach (Light light in sceneRoot.GetComponentsInChildren(true)) + { + if (light.type == LightType.Directional) + { + count++; + } + } + } + + return count; + } + + private static Vector3[] BoundsCorners(Bounds bounds) + { + Vector3[] corners = new Vector3[8]; + int index = 0; + for (int x = -1; x <= 1; x += 2) + { + for (int y = -1; y <= 1; y += 2) + { + for (int z = -1; z <= 1; z += 2) + { + corners[index++] = bounds.center + Vector3.Scale(bounds.extents, new Vector3(x, y, z)); + } + } + } + + return corners; + } + + private void AssertCornersInView(Vector3[] corners) + { + _camera.aspect = GameViewSize.x / (float)GameViewSize.y; + foreach (Vector3 corner in corners) + { + Vector3 viewport = _camera.WorldToViewportPoint(corner); + Assert.That(viewport.x, Is.InRange(0f, 1f), $"corner {corner} x"); + Assert.That(viewport.y, Is.InRange(0f, 1f), $"corner {corner} y"); + Assert.That(viewport.z, Is.GreaterThan(0f), $"corner {corner} z"); + } + } + } +} diff --git a/Assets/Tests/Editor/PrefabCaptureFramingTests.cs.meta b/Assets/Tests/Editor/PrefabCaptureFramingTests.cs.meta new file mode 100644 index 0000000000..7117ae84fd --- /dev/null +++ b/Assets/Tests/Editor/PrefabCaptureFramingTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 591a88dba19594a27b95ccda0cdbc090 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabCaptureFraming.cs b/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabCaptureFraming.cs new file mode 100644 index 0000000000..3dcc91e792 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabCaptureFraming.cs @@ -0,0 +1,335 @@ +using System; +using System.Collections.Generic; +using UnityEngine; +using UnityEngine.SceneManagement; + +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// What a prefab screenshot is rendered as, which decides its image size and camera. + /// + internal enum PrefabCaptureKind + { + UiPart, + ScreenUi, + Object3D, + Flat2D + } + + /// + /// The classification and image size decided for one prefab capture. + /// + internal readonly struct PrefabCaptureFrame + { + public PrefabCaptureKind Kind { get; } + public Vector2Int ImageSize { get; } + + public PrefabCaptureFrame(PrefabCaptureKind kind, Vector2Int imageSize) + { + if (imageSize.x < 1 || imageSize.y < 1) + { + throw new ArgumentException($"Image size must be at least 1x1, got: {imageSize}", nameof(imageSize)); + } + + Kind = kind; + ImageSize = imageSize; + } + } + + /// + /// Classifies a prefab instance in a preview scene and points the capture camera at it. + /// + internal static class PrefabCaptureFraming + { + private const float WorldUiCameraDistance = 100f; + private const float ObjectFieldOfView = 30f; + private static readonly Quaternion ObjectViewRotation = Quaternion.Euler(20f, -30f, 0f); + private static readonly Quaternion AddedLightRotation = Quaternion.Euler(50f, -30f, 0f); + private const float ObjectFitMargin = 1.1f; + private const float FlatFitMargin = 1.05f; + private const float FlatDepthRatio = 0.001f; + private const float FlatCameraDistance = 10f; + + /// + /// Rearranges the instance and camera for rendering and returns what was decided; rejects prefabs with nothing to capture. + /// + internal static PrefabCaptureFrame Arrange(GameObject instanceRoot, Camera camera, Vector2Int gameViewSize) + { + if (instanceRoot == null) + { + throw new ArgumentNullException(nameof(instanceRoot)); + } + + if (camera == null) + { + throw new ArgumentNullException(nameof(camera)); + } + + if (gameViewSize.x < 1 || gameViewSize.y < 1) + { + throw new ArgumentException($"Game View size must be at least 1x1, got: {gameViewSize}", nameof(gameViewSize)); + } + + // A camera bound to another scene would render the user's scene instead of the instance. + if (camera.scene != instanceRoot.scene) + { + throw new ArgumentException("Camera must render the scene that holds the prefab instance", nameof(camera)); + } + + // A prefab saved inactive still has content worth capturing; children keep their saved state. + if (!instanceRoot.activeSelf) + { + instanceRoot.SetActive(true); + } + + // Screen UI is checked first so a RectTransform root with a screen-space Canvas keeps its in-game layout. + List screenCanvases = CollectTopCanvases(instanceRoot, false); + if (screenCanvases.Count > 0) + { + return ArrangeScreenUi(screenCanvases, camera, gameViewSize); + } + + if (instanceRoot.transform is RectTransform) + { + return ArrangeUiPart(instanceRoot, camera, gameViewSize); + } + + return ArrangeObject(instanceRoot, camera, gameViewSize); + } + + private static PrefabCaptureFrame ArrangeScreenUi(List screenCanvases, Camera camera, Vector2Int gameViewSize) + { + foreach (Canvas canvas in screenCanvases) + { + canvas.renderMode = RenderMode.ScreenSpaceCamera; + canvas.worldCamera = camera; + canvas.planeDistance = 1f; + } + + camera.orthographic = true; + camera.transform.SetPositionAndRotation(Vector3.zero, Quaternion.identity); + camera.nearClipPlane = 0.1f; + camera.farClipPlane = 10f; + return new PrefabCaptureFrame(PrefabCaptureKind.ScreenUi, gameViewSize); + } + + // Returns only outermost Canvases; nested ones follow their parent and must not be switched. + private static List CollectTopCanvases(GameObject root, bool includeWorldSpace) + { + List canvases = new(); + foreach (Canvas canvas in root.GetComponentsInChildren(false)) + { + if (!canvas.enabled) + { + continue; + } + + if (HasCanvasAncestorWithin(canvas.transform, root.transform)) + { + continue; + } + + bool isWorld = canvas.renderMode == RenderMode.WorldSpace; + if (isWorld != includeWorldSpace) + { + continue; + } + + canvases.Add(canvas); + } + + return canvases; + } + + private static bool HasCanvasAncestorWithin(Transform transform, Transform root) + { + if (transform == root) + { + return false; + } + + for (Transform parent = transform.parent; parent != null; parent = parent.parent) + { + if (parent.GetComponent() != null) + { + return true; + } + + if (parent == root) + { + break; + } + } + + return false; + } + + private static PrefabCaptureFrame ArrangeUiPart(GameObject root, Camera camera, Vector2Int gameViewSize) + { + GameObject wrapper = new("PrefabCaptureCanvas", typeof(RectTransform), typeof(Canvas)); + SceneManager.MoveGameObjectToScene(wrapper, root.scene); + Canvas wrapperCanvas = wrapper.GetComponent(); + wrapperCanvas.renderMode = RenderMode.WorldSpace; + wrapperCanvas.worldCamera = camera; + RectTransform wrapperRect = (RectTransform)wrapper.transform; + // A stretched UI root spreads to the Game View size, as it would under a full-screen Canvas. + wrapperRect.sizeDelta = gameViewSize; + wrapperRect.position = Vector3.zero; + root.transform.SetParent(wrapper.transform, false); + // LayoutGroup and ContentSizeFitter sizes must settle before the rects are measured. + Canvas.ForceUpdateCanvases(); + + Rect union = UnionOfWorldRects(root); + Vector3 rootScale = root.transform.lossyScale; + float unitsPerPixelX = Mathf.Abs(rootScale.x); + float unitsPerPixelY = Mathf.Abs(rootScale.y); + // World corners include the root's scale, so pixels are counted in UI units (the size at scale 1). + // A 100x100 world-space Canvas saved at localScale 0.01 is 1x1 in the world but captured at 100x100 px. + if (unitsPerPixelX <= 0f || unitsPerPixelY <= 0f) + { + throw new UnityCliLoopToolParameterValidationException("Prefab has no visible UI area to capture"); + } + + Vector2 pixelSize = new(union.width / unitsPerPixelX, union.height / unitsPerPixelY); + if (pixelSize.x < 1f || pixelSize.y < 1f) + { + throw new UnityCliLoopToolParameterValidationException("Prefab has no visible UI area to capture"); + } + + camera.orthographic = true; + camera.orthographicSize = union.height / 2f; + camera.transform.SetPositionAndRotation( + new Vector3(union.center.x, union.center.y, -WorldUiCameraDistance), + Quaternion.identity); + camera.nearClipPlane = 0.1f; + camera.farClipPlane = WorldUiCameraDistance * 2f; + return new PrefabCaptureFrame( + PrefabCaptureKind.UiPart, + new Vector2Int(Mathf.CeilToInt(pixelSize.x), Mathf.CeilToInt(pixelSize.y))); + } + + private static Rect UnionOfWorldRects(GameObject root) + { + Vector3[] corners = new Vector3[4]; + float minX = float.PositiveInfinity; + float minY = float.PositiveInfinity; + float maxX = float.NegativeInfinity; + float maxY = float.NegativeInfinity; + foreach (RectTransform rect in root.GetComponentsInChildren(false)) + { + rect.GetWorldCorners(corners); + foreach (Vector3 corner in corners) + { + minX = Mathf.Min(minX, corner.x); + minY = Mathf.Min(minY, corner.y); + maxX = Mathf.Max(maxX, corner.x); + maxY = Mathf.Max(maxY, corner.y); + } + } + + return Rect.MinMaxRect(minX, minY, maxX, maxY); + } + + private static PrefabCaptureFrame ArrangeObject(GameObject root, Camera camera, Vector2Int gameViewSize) + { + Bounds? bounds = CollectRenderableBounds(root); + if (bounds == null) + { + throw new UnityCliLoopToolParameterValidationException("Prefab has no Renderer or UI to capture"); + } + + if (!HasEnabledLight(root)) + { + AddDirectionalLight(root.scene); + } + + float aspect = gameViewSize.x / (float)gameViewSize.y; + Bounds b = bounds.Value; + // Things without depth, such as sprites, are framed straight on so they are not flattened by an angled view. + if (b.size.z <= FlatDepthRatio * Mathf.Max(b.size.x, b.size.y)) + { + camera.orthographic = true; + camera.orthographicSize = Mathf.Max(b.size.y / 2f, b.size.x / (2f * aspect)) * FlatFitMargin; + camera.transform.SetPositionAndRotation(b.center - Vector3.forward * FlatCameraDistance, Quaternion.identity); + camera.nearClipPlane = 0.01f; + camera.farClipPlane = FlatCameraDistance * 2f; + return new PrefabCaptureFrame(PrefabCaptureKind.Flat2D, gameViewSize); + } + + float radius = b.extents.magnitude; + float vertical = ObjectFieldOfView * Mathf.Deg2Rad; + float horizontal = 2f * Mathf.Atan(Mathf.Tan(vertical / 2f) * aspect); + float distance = radius / Mathf.Sin(Mathf.Min(vertical, horizontal) / 2f) * ObjectFitMargin; + Vector3 direction = ObjectViewRotation * Vector3.forward; + camera.orthographic = false; + camera.fieldOfView = ObjectFieldOfView; + camera.transform.SetPositionAndRotation(b.center - direction * distance, Quaternion.LookRotation(direction)); + camera.nearClipPlane = Mathf.Max(0.01f, distance - radius * 2f); + camera.farClipPlane = distance + radius * 2f; + return new PrefabCaptureFrame(PrefabCaptureKind.Object3D, gameViewSize); + } + + private static Bounds? CollectRenderableBounds(GameObject root) + { + Bounds? bounds = null; + foreach (Renderer renderer in root.GetComponentsInChildren(false)) + { + if (!renderer.enabled) + { + continue; + } + + bounds = Encapsulate(bounds, renderer.bounds); + } + + Vector3[] corners = new Vector3[4]; + foreach (Canvas canvas in CollectTopCanvases(root, true)) + { + ((RectTransform)canvas.transform).GetWorldCorners(corners); + foreach (Vector3 corner in corners) + { + bounds = Encapsulate(bounds, new Bounds(corner, Vector3.zero)); + } + } + + return bounds; + } + + private static Bounds Encapsulate(Bounds? current, Bounds addition) + { + if (current == null) + { + return addition; + } + + Bounds result = current.Value; + result.Encapsulate(addition); + return result; + } + + private static bool HasEnabledLight(GameObject root) + { + foreach (Light light in root.GetComponentsInChildren(false)) + { + if (light.enabled) + { + return true; + } + } + + return false; + } + + private static void AddDirectionalLight(Scene scene) + { + GameObject lightObject = new("PrefabCaptureLight"); + SceneManager.MoveGameObjectToScene(lightObject, scene); + lightObject.transform.rotation = AddedLightRotation; + Light light = lightObject.AddComponent(); + light.type = LightType.Directional; + light.intensity = 1f; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabCaptureFraming.cs.meta b/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabCaptureFraming.cs.meta new file mode 100644 index 0000000000..d7d1bb4218 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabCaptureFraming.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5c7af59dd5ac64babb7ae42f682ca096 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From 73f598ad92e16b2486f68b72a6fe43282fb7a981 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 22 Oct 2026 18:36:50 +0900 Subject: [PATCH 03/18] test: stop pinning the nested Canvas exclusion that Unity makes unobservable Unity ignores renderMode and worldCamera assignments on a nested Canvas, so removing the exclusion leaves every assertion passing. The screen UI test now checks only the root Canvas switch, and the exclusion's comment states why it stays. --- Assets/Tests/Editor/PrefabCaptureFramingTests.cs | 10 +++------- .../FirstPartyTools/Screenshot/PrefabCaptureFraming.cs | 3 ++- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/Assets/Tests/Editor/PrefabCaptureFramingTests.cs b/Assets/Tests/Editor/PrefabCaptureFramingTests.cs index 087447810e..26128d5884 100644 --- a/Assets/Tests/Editor/PrefabCaptureFramingTests.cs +++ b/Assets/Tests/Editor/PrefabCaptureFramingTests.cs @@ -1,5 +1,4 @@ using NUnit.Framework; -using UnityEditor; using UnityEditor.SceneManagement; using UnityEngine; using UnityEngine.SceneManagement; @@ -99,16 +98,15 @@ public void Arrange_WithAZeroSizedUiRoot_Throws() } /// - /// Verifies a root screen-space Canvas is switched to the capture camera while a nested Canvas is left alone. + /// Verifies a root screen-space Canvas with a nested Canvas is captured as screen UI through the capture camera. /// [Test] - public void Arrange_WithAnOverlayCanvasRoot_SwitchesOnlyTheOutermostCanvas() + public void Arrange_WithAnOverlayCanvasRoot_SwitchesTheRootCanvasToTheCaptureCamera() { GameObject root = CreateInScene("Root"); Canvas rootCanvas = root.AddComponent(); rootCanvas.renderMode = RenderMode.ScreenSpaceOverlay; - Canvas nestedCanvas = CreateChild(root, "Nested").AddComponent(); - nestedCanvas.overrideSorting = true; + CreateChild(root, "Nested").AddComponent().overrideSorting = true; PrefabCaptureFrame frame = PrefabCaptureFraming.Arrange(root, _camera, GameViewSize); @@ -116,8 +114,6 @@ public void Arrange_WithAnOverlayCanvasRoot_SwitchesOnlyTheOutermostCanvas() Assert.That(frame.ImageSize, Is.EqualTo(GameViewSize)); Assert.That(rootCanvas.renderMode, Is.EqualTo(RenderMode.ScreenSpaceCamera)); Assert.That(rootCanvas.worldCamera, Is.SameAs(_camera)); - // A nested Canvas's worldCamera getter returns the root Canvas's camera, so the stored value is read instead. - Assert.That(new SerializedObject(nestedCanvas).FindProperty("m_Camera").objectReferenceValue, Is.Null); } /// diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabCaptureFraming.cs b/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabCaptureFraming.cs index 3dcc91e792..f8abebc035 100644 --- a/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabCaptureFraming.cs +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabCaptureFraming.cs @@ -115,7 +115,8 @@ private static PrefabCaptureFrame ArrangeScreenUi(List screenCanvases, C return new PrefabCaptureFrame(PrefabCaptureKind.ScreenUi, gameViewSize); } - // Returns only outermost Canvases; nested ones follow their parent and must not be switched. + // Returns only outermost Canvases. Nested canvases follow their root canvas and Unity ignores + // renderMode/worldCamera assignments on them; skipping them keeps only the canvases that decide how the UI renders. private static List CollectTopCanvases(GameObject root, bool includeWorldSpace) { List canvases = new(); From d13e9063a1780c1d46a009fe3f184cb798b8aafd Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 22 Oct 2026 18:37:42 +0900 Subject: [PATCH 04/18] test: add prefab fixtures for prefab screenshot tests UiPartWithOverflowingChild has a white 100x100 panel with a red badge that overflows to the left, so the capture must size to all of its RectTransforms. EmptyRoot has nothing to render and must be rejected. --- .../Editor/PrefabScreenshotFixtures.meta | 8 + .../PrefabScreenshotFixtures/EmptyRoot.prefab | 33 ++++ .../EmptyRoot.prefab.meta | 7 + .../UiPartWithOverflowingChild.prefab | 153 ++++++++++++++++++ .../UiPartWithOverflowingChild.prefab.meta | 7 + 5 files changed, 208 insertions(+) create mode 100644 Assets/Tests/Editor/PrefabScreenshotFixtures.meta create mode 100644 Assets/Tests/Editor/PrefabScreenshotFixtures/EmptyRoot.prefab create mode 100644 Assets/Tests/Editor/PrefabScreenshotFixtures/EmptyRoot.prefab.meta create mode 100644 Assets/Tests/Editor/PrefabScreenshotFixtures/UiPartWithOverflowingChild.prefab create mode 100644 Assets/Tests/Editor/PrefabScreenshotFixtures/UiPartWithOverflowingChild.prefab.meta diff --git a/Assets/Tests/Editor/PrefabScreenshotFixtures.meta b/Assets/Tests/Editor/PrefabScreenshotFixtures.meta new file mode 100644 index 0000000000..cc151a0177 --- /dev/null +++ b/Assets/Tests/Editor/PrefabScreenshotFixtures.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 8c07776f5510d409d8d251d29287c770 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/PrefabScreenshotFixtures/EmptyRoot.prefab b/Assets/Tests/Editor/PrefabScreenshotFixtures/EmptyRoot.prefab new file mode 100644 index 0000000000..ee699c3ca0 --- /dev/null +++ b/Assets/Tests/Editor/PrefabScreenshotFixtures/EmptyRoot.prefab @@ -0,0 +1,33 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!1 &1189744403743977954 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 9152590177948676354} + m_Layer: 0 + m_Name: EmptyRoot + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!4 &9152590177948676354 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1189744403743977954} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} diff --git a/Assets/Tests/Editor/PrefabScreenshotFixtures/EmptyRoot.prefab.meta b/Assets/Tests/Editor/PrefabScreenshotFixtures/EmptyRoot.prefab.meta new file mode 100644 index 0000000000..2a457f5ab3 --- /dev/null +++ b/Assets/Tests/Editor/PrefabScreenshotFixtures/EmptyRoot.prefab.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: c761a60ffb70d467a93f08687034d0b3 +PrefabImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/PrefabScreenshotFixtures/UiPartWithOverflowingChild.prefab b/Assets/Tests/Editor/PrefabScreenshotFixtures/UiPartWithOverflowingChild.prefab new file mode 100644 index 0000000000..d9a094b869 --- /dev/null +++ b/Assets/Tests/Editor/PrefabScreenshotFixtures/UiPartWithOverflowingChild.prefab @@ -0,0 +1,153 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!1 &488878777344455649 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 8046216622164244746} + - component: {fileID: 6718508094473520953} + - component: {fileID: 2818173992894152336} + m_Layer: 0 + m_Name: UiPartWithOverflowingChild + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &8046216622164244746 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 488878777344455649} + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: + - {fileID: 4081199270652062503} + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0.5, y: 0.5} + m_AnchorMax: {x: 0.5, y: 0.5} + m_AnchoredPosition: {x: 0, y: 0} + m_SizeDelta: {x: 100, y: 100} + m_Pivot: {x: 0.5, y: 0.5} +--- !u!222 &6718508094473520953 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 488878777344455649} + m_CullTransparentMesh: 1 +--- !u!114 &2818173992894152336 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 488878777344455649} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 0} + m_Color: {r: 1, g: 1, b: 1, a: 1} + m_RaycastTarget: 1 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_Sprite: {fileID: 0} + m_Type: 0 + m_PreserveAspect: 0 + m_FillCenter: 1 + m_FillMethod: 4 + m_FillAmount: 1 + m_FillClockwise: 1 + m_FillOrigin: 0 + m_UseSpriteMesh: 0 + m_PixelsPerUnitMultiplier: 1 +--- !u!1 &849394645349412386 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 4081199270652062503} + - component: {fileID: 6268043233289662047} + - component: {fileID: 6281631284369086990} + m_Layer: 0 + m_Name: Badge + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &4081199270652062503 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 849394645349412386} + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 8046216622164244746} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0.5, y: 0.5} + m_AnchorMax: {x: 0.5, y: 0.5} + m_AnchoredPosition: {x: -70, y: 0} + m_SizeDelta: {x: 40, y: 20} + m_Pivot: {x: 0.5, y: 0.5} +--- !u!222 &6268043233289662047 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 849394645349412386} + m_CullTransparentMesh: 1 +--- !u!114 &6281631284369086990 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 849394645349412386} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3} + m_Name: + m_EditorClassIdentifier: + m_Material: {fileID: 0} + m_Color: {r: 1, g: 0, b: 0, a: 1} + m_RaycastTarget: 1 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_Sprite: {fileID: 0} + m_Type: 0 + m_PreserveAspect: 0 + m_FillCenter: 1 + m_FillMethod: 4 + m_FillAmount: 1 + m_FillClockwise: 1 + m_FillOrigin: 0 + m_UseSpriteMesh: 0 + m_PixelsPerUnitMultiplier: 1 diff --git a/Assets/Tests/Editor/PrefabScreenshotFixtures/UiPartWithOverflowingChild.prefab.meta b/Assets/Tests/Editor/PrefabScreenshotFixtures/UiPartWithOverflowingChild.prefab.meta new file mode 100644 index 0000000000..e605d8110c --- /dev/null +++ b/Assets/Tests/Editor/PrefabScreenshotFixtures/UiPartWithOverflowingChild.prefab.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 1e33132b3f9c8449ca2dbfbf839cca4a +PrefabImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: From 208361ba5b9e3031258eccd24cdf839573eebb05 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 22 Oct 2026 18:40:06 +0900 Subject: [PATCH 05/18] feat: render a prefab asset in a preview scene and save it as a PNG Add the prefab screenshot use case and the off-screen renderer behind it. - Input is validated and the asset loaded before the preview scene is created, so rejected requests leave no scene, texture, or file behind. - The instance and a temporary camera live only in the preview scene, which is closed in finally on every exit; the asset is never modified. - The renderer writes through an sRGB RenderTexture so PNG values match the requested background color in Linear projects. - ResolutionScale shrinks the image after rendering, reusing the existing scaling helper, which becomes internal for this. - The renderer sits behind an interface so tests can verify the camera settings and cleanup without a GPU; one test uses the real renderer and is ignored on devices whose sRGB writes do not match a real GPU. --- .../Editor/PrefabScreenshotUseCaseTests.cs | 325 ++++++++++++++++++ .../PrefabScreenshotUseCaseTests.cs.meta | 11 + .../Screenshot/EditorWindowCaptureUtility.cs | 2 +- .../Screenshot/PrefabPreviewRenderer.cs | 46 +++ .../Screenshot/PrefabPreviewRenderer.cs.meta | 11 + .../Screenshot/PrefabScreenshotUseCase.cs | 142 ++++++++ .../PrefabScreenshotUseCase.cs.meta | 11 + 7 files changed, 547 insertions(+), 1 deletion(-) create mode 100644 Assets/Tests/Editor/PrefabScreenshotUseCaseTests.cs create mode 100644 Assets/Tests/Editor/PrefabScreenshotUseCaseTests.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/Screenshot/PrefabPreviewRenderer.cs create mode 100644 Packages/src/Editor/FirstPartyTools/Screenshot/PrefabPreviewRenderer.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/Screenshot/PrefabScreenshotUseCase.cs create mode 100644 Packages/src/Editor/FirstPartyTools/Screenshot/PrefabScreenshotUseCase.cs.meta diff --git a/Assets/Tests/Editor/PrefabScreenshotUseCaseTests.cs b/Assets/Tests/Editor/PrefabScreenshotUseCaseTests.cs new file mode 100644 index 0000000000..0e07f8be06 --- /dev/null +++ b/Assets/Tests/Editor/PrefabScreenshotUseCaseTests.cs @@ -0,0 +1,325 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; +using UnityEngine.Rendering; +using UnityEngine.SceneManagement; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Verifies the prefab screenshot use case: its response, its output file, and that it leaves no preview scene or texture behind. + /// + public sealed class PrefabScreenshotUseCaseTests + { + private const string FixtureFolder = "Assets/Tests/Editor/PrefabScreenshotFixtures"; + private const string UiPartPath = FixtureFolder + "/UiPartWithOverflowingChild.prefab"; + private const string EmptyRootPath = FixtureFolder + "/EmptyRoot.prefab"; + private const string MissingPath = FixtureFolder + "/Missing.prefab"; + private const string NonGameObjectPath = "Assets/Tests/Editor/PrefabScreenshotUseCaseTests.cs"; + + private string _outputDirectory; + + [SetUp] + public void SetUp() + { + _outputDirectory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_outputDirectory); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_outputDirectory)) + { + Directory.Delete(_outputDirectory, true); + } + } + + /// + /// Verifies a UI part prefab produces one PNG with prefab metadata and cleans up its scene and texture. + /// + [Test] + public void CaptureAsync_WithAUiPartPrefab_ReturnsOnePrefabScreenshotAndCleansUp() + { + RecordingRenderer renderer = new(); + int previewScenesBefore = EditorSceneManager.previewSceneCount; + + ScreenshotResponse response = Capture(renderer, CreateRequest(UiPartPath)); + + Assert.That(response.Success, Is.True); + Assert.That(response.ResolvedCaptureMode, Is.EqualTo("prefab")); + Assert.That(response.Message, Does.Contain("UI part")); + Assert.That(response.Screenshots.Count, Is.EqualTo(1)); + ScreenshotInfo info = response.Screenshots[0]; + Assert.That(info.Width, Is.EqualTo(140)); + Assert.That(info.Height, Is.EqualTo(100)); + Assert.That(File.Exists(info.ImagePath), Is.True); + Assert.That(Path.GetFileName(info.ImagePath), Does.StartWith("Prefab_UiPartWithOverflowingChild_")); + Assert.That(info.ImageCoordinateSystem, Is.EqualTo("top-left-prefab-image")); + Assert.That(info.ScreenshotToInputFormula, Is.EqualTo(UnityCliLoopConstants.SCREENSHOT_PREFAB_TO_INPUT_FORMULA_UNAVAILABLE)); + Assert.That(EditorSceneManager.previewSceneCount, Is.EqualTo(previewScenesBefore)); + Assert.That(renderer.LastReturnedTexture == null, Is.True, "the rendered texture must be destroyed"); + Assert.That(renderer.CameraSceneWasPreview, Is.True); + } + + /// + /// Verifies ResolutionScale shrinks the rendered image after rendering. + /// + [Test] + public void CaptureAsync_WithHalfResolutionScale_HalvesTheImage() + { + if (SystemInfo.graphicsDeviceType == GraphicsDeviceType.Null) + { + Assert.Ignore("Resolution scaling blits on the GPU; skipped on the Null device."); + } + + ScreenshotSchema request = CreateRequest(UiPartPath); + request.ResolutionScale = 0.5f; + + ScreenshotResponse response = Capture(new RecordingRenderer(), request); + + Assert.That(response.Screenshots[0].Width, Is.EqualTo(70)); + Assert.That(response.Screenshots[0].Height, Is.EqualTo(50)); + } + + /// + /// Verifies WindowName is ignored for a prefab capture. + /// + [Test] + public void CaptureAsync_WithAWindowName_IgnoresIt() + { + ScreenshotSchema request = CreateRequest(UiPartPath); + request.WindowName = "Scene"; + + ScreenshotResponse response = Capture(new RecordingRenderer(), request); + + Assert.That(response.Success, Is.True); + } + + /// + /// Verifies the camera clears to the default #808080 background when no color is given. + /// + [Test] + public void CaptureAsync_WithoutABackgroundColor_ClearsToTheDefaultGray() + { + RecordingRenderer renderer = new(); + + Capture(renderer, CreateRequest(UiPartPath)); + + Assert.That(renderer.CameraClearFlags, Is.EqualTo(CameraClearFlags.SolidColor)); + AssertColor(new Color(0x80 / 255f, 0x80 / 255f, 0x80 / 255f, 1f), renderer.CameraBackgroundColor); + } + + /// + /// Verifies the camera clears to the requested background color including alpha. + /// + [Test] + public void CaptureAsync_WithABackgroundColor_ClearsToThatColor() + { + RecordingRenderer renderer = new(); + ScreenshotSchema request = CreateRequest(UiPartPath); + request.PrefabBackgroundColor = "#33669980"; + + Capture(renderer, request); + + Assert.That(renderer.CameraClearFlags, Is.EqualTo(CameraClearFlags.SolidColor)); + AssertColor(new Color(0x33 / 255f, 0x66 / 255f, 0x99 / 255f, 0x80 / 255f), renderer.CameraBackgroundColor); + } + + /// + /// Verifies a malformed background color is rejected before any scene is created or file written. + /// + [Test] + public void CaptureAsync_WithAMalformedBackgroundColor_RejectsWithoutSideEffects() + { + ScreenshotSchema request = CreateRequest(UiPartPath); + request.PrefabBackgroundColor = "#FFF"; + + AssertRejectedWithoutSideEffects(request, CancellationToken.None); + } + + /// + /// Verifies a cancelled token stops the capture before any scene is created or file written. + /// + [Test] + public void CaptureAsync_WithACancelledToken_ThrowsWithoutSideEffects() + { + using CancellationTokenSource cancellation = new(); + cancellation.Cancel(); + + AssertRejectedWithoutSideEffects(CreateRequest(UiPartPath), cancellation.Token); + } + + /// + /// Verifies a path with no asset is rejected without rendering or writing a file. + /// + [Test] + public void CaptureAsync_WithAMissingAsset_RejectsWithoutSideEffects() + { + AssertRejectedWithoutSideEffects(CreateRequest(MissingPath), CancellationToken.None); + } + + /// + /// Verifies a path to an asset that is not a GameObject is rejected without rendering or writing a file. + /// + [Test] + public void CaptureAsync_WithANonGameObjectAsset_RejectsWithoutSideEffects() + { + AssertRejectedWithoutSideEffects(CreateRequest(NonGameObjectPath), CancellationToken.None); + } + + /// + /// Verifies a prefab with nothing to render is rejected and its preview scene is closed. + /// + [Test] + public void CaptureAsync_WithAnEmptyPrefab_RejectsAndClosesThePreviewScene() + { + AssertRejectedWithoutSideEffects(CreateRequest(EmptyRootPath), CancellationToken.None); + } + + /// + /// Verifies a rendering failure propagates unchanged and leaves no file or preview scene behind. + /// + [Test] + public void CaptureAsync_WhenRenderingFails_PropagatesAndCleansUp() + { + RecordingRenderer renderer = new() { ThrowOnRender = true }; + int previewScenesBefore = EditorSceneManager.previewSceneCount; + PrefabScreenshotUseCase useCase = CreateUseCase(renderer); + + Assert.Throws(() => useCase.CaptureAsync(CreateRequest(UiPartPath), CancellationToken.None)); + + Assert.That(Directory.GetFiles(_outputDirectory), Is.Empty); + Assert.That(EditorSceneManager.previewSceneCount, Is.EqualTo(previewScenesBefore)); + } + + /// + /// Verifies capturing does not modify the prefab asset. + /// + [Test] + public void CaptureAsync_WithAUiPartPrefab_LeavesTheAssetUnchanged() + { + Capture(new RecordingRenderer(), CreateRequest(UiPartPath)); + + GameObject asset = AssetDatabase.LoadAssetAtPath(UiPartPath); + Assert.That(EditorUtility.IsDirty(asset), Is.False); + Assert.That(asset.GetComponent().rect.size, Is.EqualTo(new Vector2(100f, 100f))); + } + + /// + /// Verifies the real renderer draws the panel, the overflowing badge, and the gray background into the PNG. + /// + [Test] + public void CaptureAsync_WithTheRealRenderer_DrawsTheWholeUiPart() + { + GraphicsDeviceType deviceType = SystemInfo.graphicsDeviceType; + if (deviceType == GraphicsDeviceType.Null || deviceType == GraphicsDeviceType.OpenGLCore) + { + Assert.Ignore($"Pixel values need a real GPU with matching sRGB writes; skipped on {deviceType}."); + } + + PrefabScreenshotUseCase useCase = new(null, () => new Vector2(800f, 600f)); + ScreenshotResponse response = useCase.CaptureAsync(CreateRequest(UiPartPath), CancellationToken.None).Result; + + Texture2D image = new(2, 2); + try + { + Assert.That(image.LoadImage(File.ReadAllBytes(response.Screenshots[0].ImagePath)), Is.True); + Assert.That(image.width, Is.EqualTo(140)); + Assert.That(image.height, Is.EqualTo(100)); + AssertColor32Near(image.GetPixel(2, 2), new Color32(128, 128, 128, 255), 1); + AssertColor32Near(image.GetPixel(90, 50), new Color32(255, 255, 255, 255), 1); + AssertColor32Near(image.GetPixel(20, 50), new Color32(255, 0, 0, 255), 1); + } + finally + { + UnityEngine.Object.DestroyImmediate(image); + } + } + + private void AssertRejectedWithoutSideEffects(ScreenshotSchema request, CancellationToken ct) + where TException : Exception + { + RecordingRenderer renderer = new(); + int previewScenesBefore = EditorSceneManager.previewSceneCount; + PrefabScreenshotUseCase useCase = CreateUseCase(renderer); + + Assert.Throws(() => useCase.CaptureAsync(request, ct)); + + Assert.That(renderer.RenderCount, Is.EqualTo(0)); + Assert.That(Directory.GetFiles(_outputDirectory), Is.Empty); + Assert.That(EditorSceneManager.previewSceneCount, Is.EqualTo(previewScenesBefore)); + } + + private ScreenshotResponse Capture(RecordingRenderer renderer, ScreenshotSchema request) + { + Task task = CreateUseCase(renderer).CaptureAsync(request, CancellationToken.None); + Assert.That(task.IsCompleted, Is.True); + return task.Result; + } + + private static PrefabScreenshotUseCase CreateUseCase(RecordingRenderer renderer) + { + return new PrefabScreenshotUseCase(renderer, () => new Vector2(800f, 600f)); + } + + private ScreenshotSchema CreateRequest(string prefabPath) + { + return new ScreenshotSchema { PrefabPath = prefabPath, OutputDirectory = _outputDirectory }; + } + + private static void AssertColor(Color expected, Color actual) + { + const float tolerance = 0.5f / 255f; + Assert.That(actual.r, Is.EqualTo(expected.r).Within(tolerance)); + Assert.That(actual.g, Is.EqualTo(expected.g).Within(tolerance)); + Assert.That(actual.b, Is.EqualTo(expected.b).Within(tolerance)); + Assert.That(actual.a, Is.EqualTo(expected.a).Within(tolerance)); + } + + private static void AssertColor32Near(Color32 actual, Color32 expected, int tolerance) + { + Assert.That(actual.r, Is.InRange(expected.r - tolerance, expected.r + tolerance), "r"); + Assert.That(actual.g, Is.InRange(expected.g - tolerance, expected.g + tolerance), "g"); + Assert.That(actual.b, Is.InRange(expected.b - tolerance, expected.b + tolerance), "b"); + Assert.That(actual.a, Is.InRange(expected.a - tolerance, expected.a + tolerance), "a"); + } + + /// + /// Stands in for the GPU renderer and copies the camera settings it saw, because the camera is destroyed with the preview scene. + /// + private sealed class RecordingRenderer : IPrefabPreviewRenderer + { + public bool ThrowOnRender { get; set; } + public int RenderCount { get; private set; } + public Texture2D LastReturnedTexture { get; private set; } + public Color CameraBackgroundColor { get; private set; } + public CameraClearFlags CameraClearFlags { get; private set; } + public bool CameraSceneWasPreview { get; private set; } + + public Texture2D Render(Camera camera, Vector2Int size) + { + RenderCount++; + CameraBackgroundColor = camera.backgroundColor; + CameraClearFlags = camera.clearFlags; + Scene cameraScene = camera.scene; + CameraSceneWasPreview = EditorSceneManager.IsPreviewScene(cameraScene); + if (ThrowOnRender) + { + throw new InvalidOperationException("Simulated rendering failure"); + } + + LastReturnedTexture = new Texture2D(size.x, size.y, TextureFormat.RGBA32, false); + return LastReturnedTexture; + } + } + } +} diff --git a/Assets/Tests/Editor/PrefabScreenshotUseCaseTests.cs.meta b/Assets/Tests/Editor/PrefabScreenshotUseCaseTests.cs.meta new file mode 100644 index 0000000000..42254257bb --- /dev/null +++ b/Assets/Tests/Editor/PrefabScreenshotUseCaseTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 01fce0e9f9cd84db6b3c8622c69c686c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/EditorWindowCaptureUtility.cs b/Packages/src/Editor/FirstPartyTools/Screenshot/EditorWindowCaptureUtility.cs index a903bc1329..0988d51c19 100644 --- a/Packages/src/Editor/FirstPartyTools/Screenshot/EditorWindowCaptureUtility.cs +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/EditorWindowCaptureUtility.cs @@ -358,7 +358,7 @@ internal static int CalculateImageToInputOffsetY(Vector2 gameViewSize, int rende return Mathf.RoundToInt(gameViewSize.y) - renderTextureHeight; } - private static Texture2D ApplyResolutionScaling(Texture2D originalTexture, float scale) + internal static Texture2D ApplyResolutionScaling(Texture2D originalTexture, float scale) { int newWidth = Mathf.RoundToInt(originalTexture.width * scale); int newHeight = Mathf.RoundToInt(originalTexture.height * scale); diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabPreviewRenderer.cs b/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabPreviewRenderer.cs new file mode 100644 index 0000000000..a94896192d --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabPreviewRenderer.cs @@ -0,0 +1,46 @@ +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Renders a camera into a new texture of the given size; the caller owns and destroys the texture. + /// + internal interface IPrefabPreviewRenderer + { + Texture2D Render(Camera camera, Vector2Int size); + } + + /// + /// Renders a preview-scene camera off screen through a temporary RenderTexture. + /// + internal sealed class PrefabPreviewRenderer : IPrefabPreviewRenderer + { + public Texture2D Render(Camera camera, Vector2Int size) + { + RenderTextureDescriptor descriptor = new(size.x, size.y, RenderTextureFormat.ARGB32, 24); + // Keeps PNG values equal to the requested sRGB colors in Linear projects; Gamma projects ignore it. + descriptor.sRGB = true; + RenderTexture target = new(descriptor); + RenderTexture previousActive = RenderTexture.active; + try + { + camera.targetTexture = target; + // Screen-space-camera Canvases lay out against the camera's pixel size, so they update after the target is set. + Canvas.ForceUpdateCanvases(); + camera.Render(); + RenderTexture.active = target; + Texture2D texture = new(size.x, size.y, TextureFormat.RGBA32, false); + texture.ReadPixels(new Rect(0, 0, size.x, size.y), 0, 0); + texture.Apply(); + return texture; + } + finally + { + RenderTexture.active = previousActive; + camera.targetTexture = null; + target.Release(); + Object.DestroyImmediate(target); + } + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabPreviewRenderer.cs.meta b/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabPreviewRenderer.cs.meta new file mode 100644 index 0000000000..48a38472e2 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabPreviewRenderer.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7187fce72ca45421aa1b3671a06984e8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabScreenshotUseCase.cs b/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabScreenshotUseCase.cs new file mode 100644 index 0000000000..c043022624 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabScreenshotUseCase.cs @@ -0,0 +1,142 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; +using UnityEngine.SceneManagement; + +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Renders a prefab asset alone in a temporary preview scene and saves it as a PNG, + /// leaving open scenes, Prefab Mode, and the asset untouched. + /// + internal sealed class PrefabScreenshotUseCase + { + private readonly IPrefabPreviewRenderer _renderer; + private readonly Func _gameViewSizeReader; + + internal PrefabScreenshotUseCase(IPrefabPreviewRenderer renderer = null, Func gameViewSizeReader = null) + { + _renderer = renderer ?? new PrefabPreviewRenderer(); + _gameViewSizeReader = gameViewSizeReader ?? Handles.GetMainGameViewSize; + } + + /// + /// Completes synchronously; invalid input is rejected before a preview scene is created. + /// + internal Task CaptureAsync(ScreenshotSchema request, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + ScreenshotParameterValidator.ValidatePrefab(request); + GameObject asset = AssetDatabase.LoadAssetAtPath(request.PrefabPath); + if (asset == null) + { + throw new UnityCliLoopToolParameterValidationException( + $"PrefabPath does not point to a prefab or model asset: {request.PrefabPath}"); + } + + Color background = PrefabBackgroundColorParser.Resolve(request.PrefabBackgroundColor); + Vector2 rawSize = _gameViewSizeReader(); + Vector2Int gameViewSize = new( + Mathf.Max(1, Mathf.RoundToInt(rawSize.x)), + Mathf.Max(1, Mathf.RoundToInt(rawSize.y))); + string correlationId = UnityCliLoopConstants.GenerateCorrelationId(); + VibeLogger.LogInfo( + "screenshot_prefab_start", + "Prefab screenshot started", + new { request.PrefabPath, GameViewWidth = gameViewSize.x, GameViewHeight = gameViewSize.y }, + correlationId: correlationId); + + Scene scene = EditorSceneManager.NewPreviewScene(); + Texture2D texture = null; + try + { + Camera camera = CreateCamera(scene, background); + GameObject instance = (GameObject)PrefabUtility.InstantiatePrefab(asset, scene); + PrefabCaptureFrame frame = PrefabCaptureFraming.Arrange(instance, camera, gameViewSize); + texture = _renderer.Render(camera, frame.ImageSize); + if (!Mathf.Approximately(request.ResolutionScale, 1f)) + { + // ApplyResolutionScaling destroys the original only after it succeeds, so finally destroys exactly one texture. + texture = EditorWindowCaptureUtility.ApplyResolutionScaling(texture, request.ResolutionScale); + } + + ScreenshotInfo info = SavePng(texture, asset.name, request); + VibeLogger.LogInfo( + "screenshot_prefab_complete", + $"Rendered prefab ({info.Width}x{info.Height})", + new { Kind = frame.Kind.ToString(), info.Width, info.Height }, + correlationId: correlationId); + return Task.FromResult(new ScreenshotResponse + { + Success = true, + ResolvedCaptureMode = UnityCliLoopConstants.SCREENSHOT_RESOLVED_CAPTURE_MODE_PREFAB, + Message = DescribeFrame(request.PrefabPath, frame), + Screenshots = new List { info } + }); + } + finally + { + if (texture != null) + { + UnityEngine.Object.DestroyImmediate(texture); + } + + EditorSceneManager.ClosePreviewScene(scene); + } + } + + private static Camera CreateCamera(Scene scene, Color background) + { + GameObject cameraObject = new("PrefabCaptureCamera"); + SceneManager.MoveGameObjectToScene(cameraObject, scene); + Camera camera = cameraObject.AddComponent(); + camera.scene = scene; + camera.clearFlags = CameraClearFlags.SolidColor; + camera.backgroundColor = background; + return camera; + } + + private static ScreenshotInfo SavePng(Texture2D texture, string assetName, ScreenshotSchema request) + { + string outputDirectory = ScreenshotFileWriter.EnsureOutputDirectoryExists(request.OutputDirectory); + string fileName = $"Prefab_{ScreenshotFileWriter.SanitizeFileName(assetName)}_{DateTime.Now:yyyyMMdd_HHmmss_fff}.png"; + string path = Path.Combine(outputDirectory, fileName); + ScreenshotFileWriter.SaveTextureAsPng(texture, path); + // Only the package default folder is pruned; files in a user-specified OutputDirectory are never deleted. + if (string.IsNullOrEmpty(request.OutputDirectory)) + { + OutputFileRetention.DeleteOldestBeyondLimit(outputDirectory, "*.png"); + } + + return new ScreenshotInfo + { + ImagePath = path, + FileSizeBytes = new FileInfo(path).Length, + Width = texture.width, + Height = texture.height, + ResolutionScale = request.ResolutionScale, + ImageCoordinateSystem = UnityCliLoopConstants.COORDINATE_SYSTEM_TOP_LEFT_PREFAB_IMAGE, + ScreenshotToInputFormula = UnityCliLoopConstants.SCREENSHOT_PREFAB_TO_INPUT_FORMULA_UNAVAILABLE + }; + } + + private static string DescribeFrame(string path, PrefabCaptureFrame frame) + { + return frame.Kind switch + { + PrefabCaptureKind.UiPart => $"Rendered {path} as a UI part, sized to all of its RectTransforms.", + PrefabCaptureKind.ScreenUi => $"Rendered {path} as screen UI at the Game View size.", + PrefabCaptureKind.Object3D => $"Rendered {path} as a 3D object framed from the front-left at the Game View size.", + PrefabCaptureKind.Flat2D => $"Rendered {path} as a flat object framed straight on at the Game View size.", + _ => throw new ArgumentOutOfRangeException(nameof(frame), frame.Kind, "Unknown prefab capture kind") + }; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabScreenshotUseCase.cs.meta b/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabScreenshotUseCase.cs.meta new file mode 100644 index 0000000000..eb56c2fe88 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabScreenshotUseCase.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 056eafe231da34534bd6e9d29169a8e6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From 9c34f90e9a8bc36c1e433f5f368148db09594520 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 22 Oct 2026 18:41:38 +0900 Subject: [PATCH 06/18] feat: route screenshot requests with a prefab path to the prefab capture ScreenshotUseCase resolves auto to rendering in Play Mode before it validates, which would apply rendering-only checks to a prefab request and is already at the file-length limit. The tool entry point now sends any request with PrefabPath straight to PrefabScreenshotUseCase. --- .../ScreenshotToolPrefabRoutingTests.cs | 33 +++++++++++++++++++ .../ScreenshotToolPrefabRoutingTests.cs.meta | 11 +++++++ .../Screenshot/ScreenshotTool.cs | 7 ++++ 3 files changed, 51 insertions(+) create mode 100644 Assets/Tests/Editor/ScreenshotToolPrefabRoutingTests.cs create mode 100644 Assets/Tests/Editor/ScreenshotToolPrefabRoutingTests.cs.meta diff --git a/Assets/Tests/Editor/ScreenshotToolPrefabRoutingTests.cs b/Assets/Tests/Editor/ScreenshotToolPrefabRoutingTests.cs new file mode 100644 index 0000000000..c112e975d7 --- /dev/null +++ b/Assets/Tests/Editor/ScreenshotToolPrefabRoutingTests.cs @@ -0,0 +1,33 @@ +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Verifies the screenshot tool sends requests with a prefab path to the prefab capture. + /// + public sealed class ScreenshotToolPrefabRoutingTests + { + /// + /// Verifies a request with PrefabPath reaches the prefab capture, which rejects a missing asset before rendering. + /// + [Test] + public void ExecuteAsync_WithAPrefabPath_RoutesToThePrefabCapture() + { + ScreenshotTool tool = new(); + + Task task = tool.ExecuteAsync( + JObject.Parse("{\"PrefabPath\":\"Assets/Nope.prefab\"}"), + CancellationToken.None); + + Assert.That(task.IsFaulted, Is.True); + Assert.That(task.Exception.InnerException, Is.TypeOf()); + Assert.That(task.Exception.InnerException.Message, Does.Contain("PrefabPath does not point to a prefab or model asset")); + } + } +} diff --git a/Assets/Tests/Editor/ScreenshotToolPrefabRoutingTests.cs.meta b/Assets/Tests/Editor/ScreenshotToolPrefabRoutingTests.cs.meta new file mode 100644 index 0000000000..4f08970bf4 --- /dev/null +++ b/Assets/Tests/Editor/ScreenshotToolPrefabRoutingTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8be7934a411fb472c8c7956a57b6c64b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/ScreenshotTool.cs b/Packages/src/Editor/FirstPartyTools/Screenshot/ScreenshotTool.cs index 6d983f9b31..1cd657a4c7 100644 --- a/Packages/src/Editor/FirstPartyTools/Screenshot/ScreenshotTool.cs +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/ScreenshotTool.cs @@ -15,6 +15,13 @@ public class ScreenshotTool : UnityCliLoopTool ExecuteAsync(ScreenshotSchema parameters, CancellationToken ct) { + // Prefab captures bypass ScreenshotUseCase, which resolves auto to rendering in Play Mode before validating. + if (!string.IsNullOrEmpty(parameters.PrefabPath)) + { + PrefabScreenshotUseCase prefabUseCase = new(); + return await prefabUseCase.CaptureAsync(parameters, ct).ConfigureAwait(false); + } + ScreenshotUseCase useCase = new(); return await useCase.CaptureAsync(parameters, ct).ConfigureAwait(false); } From 1ebc2234a8bb9ba53f9edb238bbff49ffdaf4f7f Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 22 Oct 2026 18:44:14 +0900 Subject: [PATCH 07/18] docs: document screenshot --prefab-path and --prefab-background-color Add the two options to the screenshot skill and the generated tool catalog, and restamp the shared release inputs because the catalog gained new properties. - The parameter rows stay short because they also feed --help; framing rules, ignored and rejected options, the default gray, transparency under URP, the added light, and Play Mode behavior live in references/prefab-capture.md. - The Match Modes table moves to references/match-modes.md to keep SKILL.md well under the 8,000-byte injection cap. --- .agents/skills/uloop-screenshot/SKILL.md | 15 ++++--- .../references/match-modes.md | 7 ++++ .../references/prefab-capture.md | 39 +++++++++++++++++++ .claude/skills/uloop-screenshot/SKILL.md | 15 ++++--- .../references/match-modes.md | 7 ++++ .../references/prefab-capture.md | 39 +++++++++++++++++++ .../FirstPartyTools/Screenshot/Skill/SKILL.md | 15 ++++--- .../Skill/references/match-modes.md | 7 ++++ .../Skill/references/match-modes.md.meta | 7 ++++ .../Skill/references/prefab-capture.md | 39 +++++++++++++++++++ .../Skill/references/prefab-capture.md.meta | 7 ++++ cli/common/tools/default-tools.json | 10 +++++ cli/dispatcher/shared-inputs-stamp.json | 2 +- cli/project-runner/shared-inputs-stamp.json | 2 +- 14 files changed, 185 insertions(+), 26 deletions(-) create mode 100644 .agents/skills/uloop-screenshot/references/match-modes.md create mode 100644 .agents/skills/uloop-screenshot/references/prefab-capture.md create mode 100644 .claude/skills/uloop-screenshot/references/match-modes.md create mode 100644 .claude/skills/uloop-screenshot/references/prefab-capture.md create mode 100644 Packages/src/Editor/FirstPartyTools/Screenshot/Skill/references/match-modes.md create mode 100644 Packages/src/Editor/FirstPartyTools/Screenshot/Skill/references/match-modes.md.meta create mode 100644 Packages/src/Editor/FirstPartyTools/Screenshot/Skill/references/prefab-capture.md create mode 100644 Packages/src/Editor/FirstPartyTools/Screenshot/Skill/references/prefab-capture.md.meta diff --git a/.agents/skills/uloop-screenshot/SKILL.md b/.agents/skills/uloop-screenshot/SKILL.md index 23d3068c7f..db17bef886 100644 --- a/.agents/skills/uloop-screenshot/SKILL.md +++ b/.agents/skills/uloop-screenshot/SKILL.md @@ -11,7 +11,7 @@ Take a screenshot of any Unity EditorWindow by name and save as PNG. ## Usage ```bash -uloop screenshot [--window-name ] [--resolution-scale ] [--match-mode ] [--capture-mode ] [--annotate-elements] [--annotate-raycast-grid] [--raycast-layer-mask ] [--elements-only] [--output-directory ] +uloop screenshot [--window-name ] [--resolution-scale ] [--match-mode ] [--capture-mode ] [--annotate-elements] [--annotate-raycast-grid] [--raycast-layer-mask ] [--elements-only] [--output-directory ] [--prefab-path ] [--prefab-background-color ] ``` ## Parameters @@ -27,14 +27,12 @@ uloop screenshot [--window-name ] [--resolution-scale ] [--match-mo | `--annotate-raycast-grid` | flag | - | Annotate clustered 3D physics collider candidates as `PhysicsCollider` entries in `AnnotatedElements`. Uses `Camera.main` visibility and the same top-left Game View coordinates as `simulate-mouse-input`. Only works when the resolved capture mode is rendering (auto during Play Mode resolves to rendering) in PlayMode. | | `--raycast-layer-mask` | string | `""` | Comma-separated physics layer names to narrow which layers `--annotate-raycast-grid` clusters. Hits are limited to layers also visible to `Camera.main.cullingMask`. When omitted, clusters against `Physics.DefaultRaycastLayers`. | | `--elements-only` | flag | - | Return only annotated element JSON without capturing a screenshot image. Requires `--annotate-elements` or `--annotate-raycast-grid`, and the resolved capture mode to be rendering (auto during Play Mode resolves to rendering) in PlayMode. | +| `--prefab-path` | string | `""` | Prefab or model asset path to render alone in an isolated preview scene, in Edit or Play Mode. Open scenes, Prefab Mode, and the asset stay untouched. | +| `--prefab-background-color` | string | `""` | Background for `--prefab-path` as `#RRGGBB` or `#RRGGBBAA`. Empty uses `#808080`. | ## Match Modes -| Mode | Description | Example | -|------|-------------|---------| -| `exact` | Window name must match exactly (case-insensitive) | "Project" matches "Project" only | -| `prefix` | Window name must start with the input | "Project" matches "Project" and "Project Settings" | -| `contains` | Window name must contain the input anywhere | "set" matches "Project Settings" | +See [references/match-modes.md](references/match-modes.md) for exact / prefix / contains examples. ## Window Name @@ -51,14 +49,14 @@ The window name is the text displayed in the window's title bar (tab). Common na Returns JSON with: - `ScreenshotCount`: Number of windows captured -- `ResolvedCaptureMode`: `"window"` or `"rendering"` — the mode actually used after resolving `auto` +- `ResolvedCaptureMode`: `"window"`, `"rendering"`, or `"prefab"` — the mode actually used after resolving `auto` - `Warning`: Appears for window captures taken while Play Mode is running with at least one image, and for any image captured while Play Mode is paused (UGUI/canvas content may show the frame rendered before the pause; run `uloop control-play-mode --action Step` once and capture again to refresh it). - `Screenshots`: Array of screenshot info, each containing: - `ImagePath`: Absolute path to the saved PNG file. Empty (`""`) when `--elements-only` is used because no image file is written; `FileSizeBytes`, `Width`, and `Height` are then `0`, while `GameViewWidth`/`GameViewHeight` and `AnnotatedElements` still carry real values. When it is non-empty, always open the file named here — the output directory accumulates every past capture, so guessing the newest file with directory listing (`ls -t` or similar) can silently pick a stale screenshot from an earlier run. - `FileSizeBytes`: Size of the saved file in bytes - `Width`: Captured image width in pixels - `Height`: Captured image height in pixels - - `ImageCoordinateSystem`: `"top-left-game-view"` or `"top-left-window"` + - `ImageCoordinateSystem`: `"top-left-game-view"`, `"top-left-window"`, or `"top-left-prefab-image"` - `ResolutionScale`: Resolution scale used for capture - `ImageToInputOffsetY`: Y offset used for top-left-game-view coordinate conversion - `GameViewWidth`, `GameViewHeight`: Game View size in input coordinates at capture time — the basis for `SimX`/`SimY` and for the Y flip inside `simulate-mouse-input`. Populated for rendering captures, including `--elements-only` @@ -75,3 +73,4 @@ When multiple windows match (e.g., multiple Inspector windows or when using `con - Use `uloop focus-window` first if needed - Target window must be open in Unity Editor - Window name matching is always case-insensitive +- Before using --prefab-path, read [references/prefab-capture.md](references/prefab-capture.md). diff --git a/.agents/skills/uloop-screenshot/references/match-modes.md b/.agents/skills/uloop-screenshot/references/match-modes.md new file mode 100644 index 0000000000..f2bf44f887 --- /dev/null +++ b/.agents/skills/uloop-screenshot/references/match-modes.md @@ -0,0 +1,7 @@ +# Window Name Match Modes + +| Mode | Description | Example | +|------|-------------|---------| +| `exact` | Window name must match exactly (case-insensitive) | "Project" matches "Project" only | +| `prefix` | Window name must start with the input | "Project" matches "Project" and "Project Settings" | +| `contains` | Window name must contain the input anywhere | "set" matches "Project Settings" | diff --git a/.agents/skills/uloop-screenshot/references/prefab-capture.md b/.agents/skills/uloop-screenshot/references/prefab-capture.md new file mode 100644 index 0000000000..cb34d0eff9 --- /dev/null +++ b/.agents/skills/uloop-screenshot/references/prefab-capture.md @@ -0,0 +1,39 @@ +# Prefab Capture (`--prefab-path`) + +`uloop screenshot --prefab-path ` instantiates the prefab or model asset in a temporary preview scene, renders it with a temporary camera, and returns one PNG. No window opens, and open scenes, Prefab Mode, and the asset itself are left untouched. It works in Edit Mode and Play Mode. + +```bash +uloop screenshot --prefab-path Assets/UI/Panel.prefab +uloop screenshot --prefab-path Assets/UI/Panel.prefab --prefab-background-color "#00000000" +``` + +## How the prefab is framed + +The first matching rule decides the image (the response `Message` says which one was used): + +1. **Screen UI** — the prefab has an enabled Screen Space Canvas (Overlay or Camera). The outermost Canvases are switched to the capture camera and the image is the Game View size, so the layout matches the game. +2. **UI part** — the root is a RectTransform. The image is sized to the union of all active RectTransforms, so children that overflow the root are included. Sizes are counted in UI units, so a world-space Canvas saved at a small scale keeps its pixel size. A stretched root fills the Game View size. +3. **3D object** — anything else with an enabled Renderer or a world-space Canvas is viewed from the front-left with a perspective camera at the Game View size, with the whole object in frame. +4. **Flat object** — content with no depth (sprites, quads, a lone world-space Canvas) is viewed straight on with an orthographic camera at the Game View size. + +A prefab with nothing to render is rejected. When a prefab has both Screen Space UI and 3D content, the UI rule wins and the 3D content may not appear. + +The Game View size comes from the current Game View; change it with `uloop set-game-view-size`. + +## Options + +- `--window-name` and `--match-mode` are ignored. +- `--capture-mode` other than `auto`, `--annotate-elements`, `--annotate-raycast-grid`, `--raycast-layer-mask`, and `--elements-only` are rejected. +- `--resolution-scale` shrinks the image after it is rendered. +- `--prefab-background-color` takes `#RRGGBB` or `#RRGGBBAA`. The default `#808080` keeps both dark and white text readable. `#00000000` gives a transparent background with URP; whether alpha survives depends on the render pipeline. +- `--prefab-background-color` without `--prefab-path` is rejected. + +## What happens to the instance + +- If the prefab has no enabled Light, one directional light is added to the preview scene. +- A root saved inactive is activated so it can be captured; children keep their saved state. +- Inside the preview scene, scripts other than `[ExecuteAlways]` ones do not run, even in Play Mode. uGUI draws normally. + +## Output + +`ResolvedCaptureMode` is `"prefab"` and `ImageCoordinateSystem` is `"top-left-prefab-image"`. Prefab images are not Game View captures, so `ScreenshotToInputFormula` reports that mouse-input coordinates are unavailable. diff --git a/.claude/skills/uloop-screenshot/SKILL.md b/.claude/skills/uloop-screenshot/SKILL.md index 23d3068c7f..db17bef886 100644 --- a/.claude/skills/uloop-screenshot/SKILL.md +++ b/.claude/skills/uloop-screenshot/SKILL.md @@ -11,7 +11,7 @@ Take a screenshot of any Unity EditorWindow by name and save as PNG. ## Usage ```bash -uloop screenshot [--window-name ] [--resolution-scale ] [--match-mode ] [--capture-mode ] [--annotate-elements] [--annotate-raycast-grid] [--raycast-layer-mask ] [--elements-only] [--output-directory ] +uloop screenshot [--window-name ] [--resolution-scale ] [--match-mode ] [--capture-mode ] [--annotate-elements] [--annotate-raycast-grid] [--raycast-layer-mask ] [--elements-only] [--output-directory ] [--prefab-path ] [--prefab-background-color ] ``` ## Parameters @@ -27,14 +27,12 @@ uloop screenshot [--window-name ] [--resolution-scale ] [--match-mo | `--annotate-raycast-grid` | flag | - | Annotate clustered 3D physics collider candidates as `PhysicsCollider` entries in `AnnotatedElements`. Uses `Camera.main` visibility and the same top-left Game View coordinates as `simulate-mouse-input`. Only works when the resolved capture mode is rendering (auto during Play Mode resolves to rendering) in PlayMode. | | `--raycast-layer-mask` | string | `""` | Comma-separated physics layer names to narrow which layers `--annotate-raycast-grid` clusters. Hits are limited to layers also visible to `Camera.main.cullingMask`. When omitted, clusters against `Physics.DefaultRaycastLayers`. | | `--elements-only` | flag | - | Return only annotated element JSON without capturing a screenshot image. Requires `--annotate-elements` or `--annotate-raycast-grid`, and the resolved capture mode to be rendering (auto during Play Mode resolves to rendering) in PlayMode. | +| `--prefab-path` | string | `""` | Prefab or model asset path to render alone in an isolated preview scene, in Edit or Play Mode. Open scenes, Prefab Mode, and the asset stay untouched. | +| `--prefab-background-color` | string | `""` | Background for `--prefab-path` as `#RRGGBB` or `#RRGGBBAA`. Empty uses `#808080`. | ## Match Modes -| Mode | Description | Example | -|------|-------------|---------| -| `exact` | Window name must match exactly (case-insensitive) | "Project" matches "Project" only | -| `prefix` | Window name must start with the input | "Project" matches "Project" and "Project Settings" | -| `contains` | Window name must contain the input anywhere | "set" matches "Project Settings" | +See [references/match-modes.md](references/match-modes.md) for exact / prefix / contains examples. ## Window Name @@ -51,14 +49,14 @@ The window name is the text displayed in the window's title bar (tab). Common na Returns JSON with: - `ScreenshotCount`: Number of windows captured -- `ResolvedCaptureMode`: `"window"` or `"rendering"` — the mode actually used after resolving `auto` +- `ResolvedCaptureMode`: `"window"`, `"rendering"`, or `"prefab"` — the mode actually used after resolving `auto` - `Warning`: Appears for window captures taken while Play Mode is running with at least one image, and for any image captured while Play Mode is paused (UGUI/canvas content may show the frame rendered before the pause; run `uloop control-play-mode --action Step` once and capture again to refresh it). - `Screenshots`: Array of screenshot info, each containing: - `ImagePath`: Absolute path to the saved PNG file. Empty (`""`) when `--elements-only` is used because no image file is written; `FileSizeBytes`, `Width`, and `Height` are then `0`, while `GameViewWidth`/`GameViewHeight` and `AnnotatedElements` still carry real values. When it is non-empty, always open the file named here — the output directory accumulates every past capture, so guessing the newest file with directory listing (`ls -t` or similar) can silently pick a stale screenshot from an earlier run. - `FileSizeBytes`: Size of the saved file in bytes - `Width`: Captured image width in pixels - `Height`: Captured image height in pixels - - `ImageCoordinateSystem`: `"top-left-game-view"` or `"top-left-window"` + - `ImageCoordinateSystem`: `"top-left-game-view"`, `"top-left-window"`, or `"top-left-prefab-image"` - `ResolutionScale`: Resolution scale used for capture - `ImageToInputOffsetY`: Y offset used for top-left-game-view coordinate conversion - `GameViewWidth`, `GameViewHeight`: Game View size in input coordinates at capture time — the basis for `SimX`/`SimY` and for the Y flip inside `simulate-mouse-input`. Populated for rendering captures, including `--elements-only` @@ -75,3 +73,4 @@ When multiple windows match (e.g., multiple Inspector windows or when using `con - Use `uloop focus-window` first if needed - Target window must be open in Unity Editor - Window name matching is always case-insensitive +- Before using --prefab-path, read [references/prefab-capture.md](references/prefab-capture.md). diff --git a/.claude/skills/uloop-screenshot/references/match-modes.md b/.claude/skills/uloop-screenshot/references/match-modes.md new file mode 100644 index 0000000000..f2bf44f887 --- /dev/null +++ b/.claude/skills/uloop-screenshot/references/match-modes.md @@ -0,0 +1,7 @@ +# Window Name Match Modes + +| Mode | Description | Example | +|------|-------------|---------| +| `exact` | Window name must match exactly (case-insensitive) | "Project" matches "Project" only | +| `prefix` | Window name must start with the input | "Project" matches "Project" and "Project Settings" | +| `contains` | Window name must contain the input anywhere | "set" matches "Project Settings" | diff --git a/.claude/skills/uloop-screenshot/references/prefab-capture.md b/.claude/skills/uloop-screenshot/references/prefab-capture.md new file mode 100644 index 0000000000..cb34d0eff9 --- /dev/null +++ b/.claude/skills/uloop-screenshot/references/prefab-capture.md @@ -0,0 +1,39 @@ +# Prefab Capture (`--prefab-path`) + +`uloop screenshot --prefab-path ` instantiates the prefab or model asset in a temporary preview scene, renders it with a temporary camera, and returns one PNG. No window opens, and open scenes, Prefab Mode, and the asset itself are left untouched. It works in Edit Mode and Play Mode. + +```bash +uloop screenshot --prefab-path Assets/UI/Panel.prefab +uloop screenshot --prefab-path Assets/UI/Panel.prefab --prefab-background-color "#00000000" +``` + +## How the prefab is framed + +The first matching rule decides the image (the response `Message` says which one was used): + +1. **Screen UI** — the prefab has an enabled Screen Space Canvas (Overlay or Camera). The outermost Canvases are switched to the capture camera and the image is the Game View size, so the layout matches the game. +2. **UI part** — the root is a RectTransform. The image is sized to the union of all active RectTransforms, so children that overflow the root are included. Sizes are counted in UI units, so a world-space Canvas saved at a small scale keeps its pixel size. A stretched root fills the Game View size. +3. **3D object** — anything else with an enabled Renderer or a world-space Canvas is viewed from the front-left with a perspective camera at the Game View size, with the whole object in frame. +4. **Flat object** — content with no depth (sprites, quads, a lone world-space Canvas) is viewed straight on with an orthographic camera at the Game View size. + +A prefab with nothing to render is rejected. When a prefab has both Screen Space UI and 3D content, the UI rule wins and the 3D content may not appear. + +The Game View size comes from the current Game View; change it with `uloop set-game-view-size`. + +## Options + +- `--window-name` and `--match-mode` are ignored. +- `--capture-mode` other than `auto`, `--annotate-elements`, `--annotate-raycast-grid`, `--raycast-layer-mask`, and `--elements-only` are rejected. +- `--resolution-scale` shrinks the image after it is rendered. +- `--prefab-background-color` takes `#RRGGBB` or `#RRGGBBAA`. The default `#808080` keeps both dark and white text readable. `#00000000` gives a transparent background with URP; whether alpha survives depends on the render pipeline. +- `--prefab-background-color` without `--prefab-path` is rejected. + +## What happens to the instance + +- If the prefab has no enabled Light, one directional light is added to the preview scene. +- A root saved inactive is activated so it can be captured; children keep their saved state. +- Inside the preview scene, scripts other than `[ExecuteAlways]` ones do not run, even in Play Mode. uGUI draws normally. + +## Output + +`ResolvedCaptureMode` is `"prefab"` and `ImageCoordinateSystem` is `"top-left-prefab-image"`. Prefab images are not Game View captures, so `ScreenshotToInputFormula` reports that mouse-input coordinates are unavailable. diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/Screenshot/Skill/SKILL.md index 23d3068c7f..db17bef886 100644 --- a/Packages/src/Editor/FirstPartyTools/Screenshot/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/Skill/SKILL.md @@ -11,7 +11,7 @@ Take a screenshot of any Unity EditorWindow by name and save as PNG. ## Usage ```bash -uloop screenshot [--window-name ] [--resolution-scale ] [--match-mode ] [--capture-mode ] [--annotate-elements] [--annotate-raycast-grid] [--raycast-layer-mask ] [--elements-only] [--output-directory ] +uloop screenshot [--window-name ] [--resolution-scale ] [--match-mode ] [--capture-mode ] [--annotate-elements] [--annotate-raycast-grid] [--raycast-layer-mask ] [--elements-only] [--output-directory ] [--prefab-path ] [--prefab-background-color ] ``` ## Parameters @@ -27,14 +27,12 @@ uloop screenshot [--window-name ] [--resolution-scale ] [--match-mo | `--annotate-raycast-grid` | flag | - | Annotate clustered 3D physics collider candidates as `PhysicsCollider` entries in `AnnotatedElements`. Uses `Camera.main` visibility and the same top-left Game View coordinates as `simulate-mouse-input`. Only works when the resolved capture mode is rendering (auto during Play Mode resolves to rendering) in PlayMode. | | `--raycast-layer-mask` | string | `""` | Comma-separated physics layer names to narrow which layers `--annotate-raycast-grid` clusters. Hits are limited to layers also visible to `Camera.main.cullingMask`. When omitted, clusters against `Physics.DefaultRaycastLayers`. | | `--elements-only` | flag | - | Return only annotated element JSON without capturing a screenshot image. Requires `--annotate-elements` or `--annotate-raycast-grid`, and the resolved capture mode to be rendering (auto during Play Mode resolves to rendering) in PlayMode. | +| `--prefab-path` | string | `""` | Prefab or model asset path to render alone in an isolated preview scene, in Edit or Play Mode. Open scenes, Prefab Mode, and the asset stay untouched. | +| `--prefab-background-color` | string | `""` | Background for `--prefab-path` as `#RRGGBB` or `#RRGGBBAA`. Empty uses `#808080`. | ## Match Modes -| Mode | Description | Example | -|------|-------------|---------| -| `exact` | Window name must match exactly (case-insensitive) | "Project" matches "Project" only | -| `prefix` | Window name must start with the input | "Project" matches "Project" and "Project Settings" | -| `contains` | Window name must contain the input anywhere | "set" matches "Project Settings" | +See [references/match-modes.md](references/match-modes.md) for exact / prefix / contains examples. ## Window Name @@ -51,14 +49,14 @@ The window name is the text displayed in the window's title bar (tab). Common na Returns JSON with: - `ScreenshotCount`: Number of windows captured -- `ResolvedCaptureMode`: `"window"` or `"rendering"` — the mode actually used after resolving `auto` +- `ResolvedCaptureMode`: `"window"`, `"rendering"`, or `"prefab"` — the mode actually used after resolving `auto` - `Warning`: Appears for window captures taken while Play Mode is running with at least one image, and for any image captured while Play Mode is paused (UGUI/canvas content may show the frame rendered before the pause; run `uloop control-play-mode --action Step` once and capture again to refresh it). - `Screenshots`: Array of screenshot info, each containing: - `ImagePath`: Absolute path to the saved PNG file. Empty (`""`) when `--elements-only` is used because no image file is written; `FileSizeBytes`, `Width`, and `Height` are then `0`, while `GameViewWidth`/`GameViewHeight` and `AnnotatedElements` still carry real values. When it is non-empty, always open the file named here — the output directory accumulates every past capture, so guessing the newest file with directory listing (`ls -t` or similar) can silently pick a stale screenshot from an earlier run. - `FileSizeBytes`: Size of the saved file in bytes - `Width`: Captured image width in pixels - `Height`: Captured image height in pixels - - `ImageCoordinateSystem`: `"top-left-game-view"` or `"top-left-window"` + - `ImageCoordinateSystem`: `"top-left-game-view"`, `"top-left-window"`, or `"top-left-prefab-image"` - `ResolutionScale`: Resolution scale used for capture - `ImageToInputOffsetY`: Y offset used for top-left-game-view coordinate conversion - `GameViewWidth`, `GameViewHeight`: Game View size in input coordinates at capture time — the basis for `SimX`/`SimY` and for the Y flip inside `simulate-mouse-input`. Populated for rendering captures, including `--elements-only` @@ -75,3 +73,4 @@ When multiple windows match (e.g., multiple Inspector windows or when using `con - Use `uloop focus-window` first if needed - Target window must be open in Unity Editor - Window name matching is always case-insensitive +- Before using --prefab-path, read [references/prefab-capture.md](references/prefab-capture.md). diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/Skill/references/match-modes.md b/Packages/src/Editor/FirstPartyTools/Screenshot/Skill/references/match-modes.md new file mode 100644 index 0000000000..f2bf44f887 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/Skill/references/match-modes.md @@ -0,0 +1,7 @@ +# Window Name Match Modes + +| Mode | Description | Example | +|------|-------------|---------| +| `exact` | Window name must match exactly (case-insensitive) | "Project" matches "Project" only | +| `prefix` | Window name must start with the input | "Project" matches "Project" and "Project Settings" | +| `contains` | Window name must contain the input anywhere | "set" matches "Project Settings" | diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/Skill/references/match-modes.md.meta b/Packages/src/Editor/FirstPartyTools/Screenshot/Skill/references/match-modes.md.meta new file mode 100644 index 0000000000..d6d47bc05a --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/Skill/references/match-modes.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 24551ed296561408f9fa0cdce1334adf +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/Skill/references/prefab-capture.md b/Packages/src/Editor/FirstPartyTools/Screenshot/Skill/references/prefab-capture.md new file mode 100644 index 0000000000..cb34d0eff9 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/Skill/references/prefab-capture.md @@ -0,0 +1,39 @@ +# Prefab Capture (`--prefab-path`) + +`uloop screenshot --prefab-path ` instantiates the prefab or model asset in a temporary preview scene, renders it with a temporary camera, and returns one PNG. No window opens, and open scenes, Prefab Mode, and the asset itself are left untouched. It works in Edit Mode and Play Mode. + +```bash +uloop screenshot --prefab-path Assets/UI/Panel.prefab +uloop screenshot --prefab-path Assets/UI/Panel.prefab --prefab-background-color "#00000000" +``` + +## How the prefab is framed + +The first matching rule decides the image (the response `Message` says which one was used): + +1. **Screen UI** — the prefab has an enabled Screen Space Canvas (Overlay or Camera). The outermost Canvases are switched to the capture camera and the image is the Game View size, so the layout matches the game. +2. **UI part** — the root is a RectTransform. The image is sized to the union of all active RectTransforms, so children that overflow the root are included. Sizes are counted in UI units, so a world-space Canvas saved at a small scale keeps its pixel size. A stretched root fills the Game View size. +3. **3D object** — anything else with an enabled Renderer or a world-space Canvas is viewed from the front-left with a perspective camera at the Game View size, with the whole object in frame. +4. **Flat object** — content with no depth (sprites, quads, a lone world-space Canvas) is viewed straight on with an orthographic camera at the Game View size. + +A prefab with nothing to render is rejected. When a prefab has both Screen Space UI and 3D content, the UI rule wins and the 3D content may not appear. + +The Game View size comes from the current Game View; change it with `uloop set-game-view-size`. + +## Options + +- `--window-name` and `--match-mode` are ignored. +- `--capture-mode` other than `auto`, `--annotate-elements`, `--annotate-raycast-grid`, `--raycast-layer-mask`, and `--elements-only` are rejected. +- `--resolution-scale` shrinks the image after it is rendered. +- `--prefab-background-color` takes `#RRGGBB` or `#RRGGBBAA`. The default `#808080` keeps both dark and white text readable. `#00000000` gives a transparent background with URP; whether alpha survives depends on the render pipeline. +- `--prefab-background-color` without `--prefab-path` is rejected. + +## What happens to the instance + +- If the prefab has no enabled Light, one directional light is added to the preview scene. +- A root saved inactive is activated so it can be captured; children keep their saved state. +- Inside the preview scene, scripts other than `[ExecuteAlways]` ones do not run, even in Play Mode. uGUI draws normally. + +## Output + +`ResolvedCaptureMode` is `"prefab"` and `ImageCoordinateSystem` is `"top-left-prefab-image"`. Prefab images are not Game View captures, so `ScreenshotToInputFormula` reports that mouse-input coordinates are unavailable. diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/Skill/references/prefab-capture.md.meta b/Packages/src/Editor/FirstPartyTools/Screenshot/Skill/references/prefab-capture.md.meta new file mode 100644 index 0000000000..f991bc1685 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/Skill/references/prefab-capture.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: e31c96b2fb14b41c7a033b4ac4402fea +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/cli/common/tools/default-tools.json b/cli/common/tools/default-tools.json index 13646d60a0..840e31f318 100644 --- a/cli/common/tools/default-tools.json +++ b/cli/common/tools/default-tools.json @@ -306,6 +306,16 @@ "type": "string", "description": "Comma-separated physics layer names to narrow which layers --annotate-raycast-grid clusters. Hits are limited to layers also visible to Camera.main.cullingMask. When omitted, clusters against Physics.DefaultRaycastLayers.", "default": "" + }, + "PrefabPath": { + "type": "string", + "description": "Prefab or model asset path to render alone in an isolated preview scene, in Edit or Play Mode. Open scenes, Prefab Mode, and the asset stay untouched.", + "default": "" + }, + "PrefabBackgroundColor": { + "type": "string", + "description": "Background for --prefab-path as #RRGGBB or #RRGGBBAA. Empty uses #808080.", + "default": "" } } } diff --git a/cli/dispatcher/shared-inputs-stamp.json b/cli/dispatcher/shared-inputs-stamp.json index ee93f86a18..40ae1d7507 100644 --- a/cli/dispatcher/shared-inputs-stamp.json +++ b/cli/dispatcher/shared-inputs-stamp.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "sharedInputsHash": "84de69207e7c4aacf17afe371cfbb2df358d6074" + "sharedInputsHash": "b453636d0586a882f075ef2d0e77f2da79354454" } diff --git a/cli/project-runner/shared-inputs-stamp.json b/cli/project-runner/shared-inputs-stamp.json index a85a3e4727..44a5818d18 100644 --- a/cli/project-runner/shared-inputs-stamp.json +++ b/cli/project-runner/shared-inputs-stamp.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "sharedInputsHash": "afb925d8e21cf6a9d5b57fc047cb882adc6bab7c" + "sharedInputsHash": "7b1a205dce89310e512997eef8defeb7ecc4e533" } From 275ace8686737874854f65da058e9ec725984535 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 22 Oct 2026 19:04:38 +0900 Subject: [PATCH 08/18] fix: reject prefabs whose renderers have empty bounds instead of returning a blank image A prefab with only a particle system that has not emitted, or a MeshRenderer without a mesh, has zero-sized renderer bounds. Framing treated it as flat and rendered with a zero-sized orthographic camera, returning success with a blank image. Empty bounds are now rejected as a parameter error, and the skill reference says so. --- .../references/prefab-capture.md | 2 +- .../references/prefab-capture.md | 2 +- .../Tests/Editor/PrefabCaptureFramingTests.cs | 26 +++++++++++++++++++ .../Screenshot/PrefabCaptureFraming.cs | 9 +++++++ .../Skill/references/prefab-capture.md | 2 +- 5 files changed, 38 insertions(+), 3 deletions(-) diff --git a/.agents/skills/uloop-screenshot/references/prefab-capture.md b/.agents/skills/uloop-screenshot/references/prefab-capture.md index cb34d0eff9..041ad1a026 100644 --- a/.agents/skills/uloop-screenshot/references/prefab-capture.md +++ b/.agents/skills/uloop-screenshot/references/prefab-capture.md @@ -16,7 +16,7 @@ The first matching rule decides the image (the response `Message` says which one 3. **3D object** — anything else with an enabled Renderer or a world-space Canvas is viewed from the front-left with a perspective camera at the Game View size, with the whole object in frame. 4. **Flat object** — content with no depth (sprites, quads, a lone world-space Canvas) is viewed straight on with an orthographic camera at the Game View size. -A prefab with nothing to render is rejected. When a prefab has both Screen Space UI and 3D content, the UI rule wins and the 3D content may not appear. +A prefab with nothing to render is rejected, including one whose renderers have empty bounds, such as a particle system that has not emitted. When a prefab has both Screen Space UI and 3D content, the UI rule wins and the 3D content may not appear. The Game View size comes from the current Game View; change it with `uloop set-game-view-size`. diff --git a/.claude/skills/uloop-screenshot/references/prefab-capture.md b/.claude/skills/uloop-screenshot/references/prefab-capture.md index cb34d0eff9..041ad1a026 100644 --- a/.claude/skills/uloop-screenshot/references/prefab-capture.md +++ b/.claude/skills/uloop-screenshot/references/prefab-capture.md @@ -16,7 +16,7 @@ The first matching rule decides the image (the response `Message` says which one 3. **3D object** — anything else with an enabled Renderer or a world-space Canvas is viewed from the front-left with a perspective camera at the Game View size, with the whole object in frame. 4. **Flat object** — content with no depth (sprites, quads, a lone world-space Canvas) is viewed straight on with an orthographic camera at the Game View size. -A prefab with nothing to render is rejected. When a prefab has both Screen Space UI and 3D content, the UI rule wins and the 3D content may not appear. +A prefab with nothing to render is rejected, including one whose renderers have empty bounds, such as a particle system that has not emitted. When a prefab has both Screen Space UI and 3D content, the UI rule wins and the 3D content may not appear. The Game View size comes from the current Game View; change it with `uloop set-game-view-size`. diff --git a/Assets/Tests/Editor/PrefabCaptureFramingTests.cs b/Assets/Tests/Editor/PrefabCaptureFramingTests.cs index 26128d5884..d7b56f52a9 100644 --- a/Assets/Tests/Editor/PrefabCaptureFramingTests.cs +++ b/Assets/Tests/Editor/PrefabCaptureFramingTests.cs @@ -330,6 +330,32 @@ public void Arrange_WithOnlyADisabledRenderer_Throws() () => PrefabCaptureFraming.Arrange(root, _camera, GameViewSize)); } + /// + /// Verifies a MeshRenderer without a mesh has empty bounds and is rejected instead of rendering a blank image. + /// + [Test] + public void Arrange_WithOnlyAMeshRendererWithoutAMesh_Throws() + { + GameObject root = CreateInScene("Root"); + CreateChild(root, "Renderer").AddComponent(); + + Assert.Throws( + () => PrefabCaptureFraming.Arrange(root, _camera, GameViewSize)); + } + + /// + /// Verifies a particle system that has not emitted has empty bounds and is rejected instead of rendering a blank image. + /// + [Test] + public void Arrange_WithOnlyAParticleSystem_Throws() + { + GameObject root = CreateInScene("Root"); + CreateChild(root, "Particles").AddComponent(); + + Assert.Throws( + () => PrefabCaptureFraming.Arrange(root, _camera, GameViewSize)); + } + /// /// Verifies a disabled light in the prefab does not count, so a directional light is still added. /// diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabCaptureFraming.cs b/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabCaptureFraming.cs index f8abebc035..90d8e8f712 100644 --- a/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabCaptureFraming.cs +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabCaptureFraming.cs @@ -51,6 +51,7 @@ internal static class PrefabCaptureFraming private const float FlatFitMargin = 1.05f; private const float FlatDepthRatio = 0.001f; private const float FlatCameraDistance = 10f; + private const float MinimumVisibleExtent = 1e-4f; /// /// Rearranges the instance and camera for rendering and returns what was decided; rejects prefabs with nothing to capture. @@ -241,6 +242,14 @@ private static PrefabCaptureFrame ArrangeObject(GameObject root, Camera camera, throw new UnityCliLoopToolParameterValidationException("Prefab has no Renderer or UI to capture"); } + // Empty bounds would give a zero-sized orthographic camera and a blank image reported as success. + Vector3 size = bounds.Value.size; + if (Mathf.Max(size.x, size.y, size.z) <= MinimumVisibleExtent) + { + throw new UnityCliLoopToolParameterValidationException( + "Prefab has nothing with a visible size to capture; renderer bounds are empty (for example, a particle system that has not emitted)"); + } + if (!HasEnabledLight(root)) { AddDirectionalLight(root.scene); diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/Skill/references/prefab-capture.md b/Packages/src/Editor/FirstPartyTools/Screenshot/Skill/references/prefab-capture.md index cb34d0eff9..041ad1a026 100644 --- a/Packages/src/Editor/FirstPartyTools/Screenshot/Skill/references/prefab-capture.md +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/Skill/references/prefab-capture.md @@ -16,7 +16,7 @@ The first matching rule decides the image (the response `Message` says which one 3. **3D object** — anything else with an enabled Renderer or a world-space Canvas is viewed from the front-left with a perspective camera at the Game View size, with the whole object in frame. 4. **Flat object** — content with no depth (sprites, quads, a lone world-space Canvas) is viewed straight on with an orthographic camera at the Game View size. -A prefab with nothing to render is rejected. When a prefab has both Screen Space UI and 3D content, the UI rule wins and the 3D content may not appear. +A prefab with nothing to render is rejected, including one whose renderers have empty bounds, such as a particle system that has not emitted. When a prefab has both Screen Space UI and 3D content, the UI rule wins and the 3D content may not appear. The Game View size comes from the current Game View; change it with `uloop set-game-view-size`. From e5715f424d250a7e097ef6dcbd8b388b6f2d8d20 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 22 Oct 2026 19:16:25 +0900 Subject: [PATCH 09/18] fix: a UI part with a nested overlay Canvas is captured as the part, not as screen UI A Canvas created from the UI menu and moved under a part keeps its Screen Space Overlay mode. With no Canvas on the part's root, that nested Canvas looked like an outermost one, so the whole prefab was captured at the Game View size and the overlay covered the part. A RectTransform root without an enabled Canvas of its own now always goes to the UI part path, where its nested canvases render under the temporary world-space Canvas. --- .../Tests/Editor/PrefabCaptureFramingTests.cs | 25 +++++++++++++++++++ .../Screenshot/PrefabCaptureFraming.cs | 13 +++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/Assets/Tests/Editor/PrefabCaptureFramingTests.cs b/Assets/Tests/Editor/PrefabCaptureFramingTests.cs index d7b56f52a9..83cf2d2c47 100644 --- a/Assets/Tests/Editor/PrefabCaptureFramingTests.cs +++ b/Assets/Tests/Editor/PrefabCaptureFramingTests.cs @@ -330,6 +330,31 @@ public void Arrange_WithOnlyADisabledRenderer_Throws() () => PrefabCaptureFraming.Arrange(root, _camera, GameViewSize)); } + /// + /// Verifies an overlay Canvas nested in a Canvas-less UI part does not turn the part into screen UI. + /// + [Test] + public void Arrange_WithAnOverlayCanvasInsideACanvaslessUiRoot_TreatsItAsAUiPart() + { + GameObject root = CreateRectInScene("Root"); + ((RectTransform)root.transform).sizeDelta = new Vector2(100f, 100f); + GameObject child = new GameObject("Child", typeof(RectTransform)); + child.transform.SetParent(root.transform, false); + Canvas childCanvas = child.AddComponent(); + childCanvas.renderMode = RenderMode.ScreenSpaceOverlay; + childCanvas.overrideSorting = true; + RectTransform childRect = (RectTransform)child.transform; + childRect.anchorMin = new Vector2(0.5f, 0.5f); + childRect.anchorMax = new Vector2(0.5f, 0.5f); + childRect.sizeDelta = new Vector2(40f, 20f); + childRect.anchoredPosition = new Vector2(-70f, 0f); + + PrefabCaptureFrame frame = PrefabCaptureFraming.Arrange(root, _camera, GameViewSize); + + Assert.That(frame.Kind, Is.EqualTo(PrefabCaptureKind.UiPart)); + Assert.That(frame.ImageSize, Is.EqualTo(new Vector2Int(140, 100))); + } + /// /// Verifies a MeshRenderer without a mesh has empty bounds and is rejected instead of rendering a blank image. /// diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabCaptureFraming.cs b/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabCaptureFraming.cs index 90d8e8f712..a3d584bd71 100644 --- a/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabCaptureFraming.cs +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabCaptureFraming.cs @@ -85,7 +85,13 @@ internal static PrefabCaptureFrame Arrange(GameObject instanceRoot, Camera camer instanceRoot.SetActive(true); } - // Screen UI is checked first so a RectTransform root with a screen-space Canvas keeps its in-game layout. + // A UI part is placed under a Canvas in the game, so its own nested canvases must not be treated as screen UI. + if (instanceRoot.transform is RectTransform && !HasEnabledCanvas(instanceRoot)) + { + return ArrangeUiPart(instanceRoot, camera, gameViewSize); + } + + // Screen UI is checked before the remaining UI part case so a root with its own screen-space Canvas keeps its in-game layout. List screenCanvases = CollectTopCanvases(instanceRoot, false); if (screenCanvases.Count > 0) { @@ -100,6 +106,11 @@ internal static PrefabCaptureFrame Arrange(GameObject instanceRoot, Camera camer return ArrangeObject(instanceRoot, camera, gameViewSize); } + private static bool HasEnabledCanvas(GameObject gameObject) + { + return gameObject.TryGetComponent(out Canvas canvas) && canvas.enabled; + } + private static PrefabCaptureFrame ArrangeScreenUi(List screenCanvases, Camera camera, Vector2Int gameViewSize) { foreach (Canvas canvas in screenCanvases) From e5a6357a017dd23741d4e2eb12ba6edbcef10402 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 22 Oct 2026 19:18:07 +0900 Subject: [PATCH 10/18] test: run the real prefab renderer in CI instead of only on a real GPU The only test through the real renderer was ignored on both the Null and OpenGLCore devices, so CI never exercised it. The image size check now runs on every device except Null, leaving only the pixel check limited to a real GPU, and a direct renderer test checks the returned size and that the active RenderTexture and camera target are restored. --- .../Editor/PrefabPreviewRendererTests.cs | 59 +++++++++++++++++++ .../Editor/PrefabPreviewRendererTests.cs.meta | 11 ++++ .../Editor/PrefabScreenshotUseCaseTests.cs | 45 +++++++++++--- 3 files changed, 108 insertions(+), 7 deletions(-) create mode 100644 Assets/Tests/Editor/PrefabPreviewRendererTests.cs create mode 100644 Assets/Tests/Editor/PrefabPreviewRendererTests.cs.meta diff --git a/Assets/Tests/Editor/PrefabPreviewRendererTests.cs b/Assets/Tests/Editor/PrefabPreviewRendererTests.cs new file mode 100644 index 0000000000..fffb563362 --- /dev/null +++ b/Assets/Tests/Editor/PrefabPreviewRendererTests.cs @@ -0,0 +1,59 @@ +using NUnit.Framework; +using UnityEditor.SceneManagement; +using UnityEngine; +using UnityEngine.Rendering; +using UnityEngine.SceneManagement; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Verifies the off-screen prefab renderer returns a texture of the requested size and restores the render state it borrowed. + /// + public sealed class PrefabPreviewRendererTests + { + /// + /// Verifies Render returns a texture of the requested size and restores the active RenderTexture and the camera target. + /// + [Test] + public void Render_ReturnsTheRequestedSizeAndRestoresRenderState() + { + if (SystemInfo.graphicsDeviceType == GraphicsDeviceType.Null) + { + Assert.Ignore("Rendering needs a graphics device; skipped on the Null device."); + } + + Scene scene = EditorSceneManager.NewPreviewScene(); + RenderTexture callerTarget = RenderTexture.GetTemporary(8, 8); + RenderTexture previousActive = RenderTexture.active; + Texture2D texture = null; + try + { + GameObject cameraObject = new("Camera"); + SceneManager.MoveGameObjectToScene(cameraObject, scene); + Camera camera = cameraObject.AddComponent(); + camera.scene = scene; + RenderTexture.active = callerTarget; + + texture = new PrefabPreviewRenderer().Render(camera, new Vector2Int(64, 32)); + + Assert.That(texture.width, Is.EqualTo(64)); + Assert.That(texture.height, Is.EqualTo(32)); + Assert.That(RenderTexture.active, Is.SameAs(callerTarget)); + Assert.That(camera.targetTexture, Is.Null); + } + finally + { + RenderTexture.active = previousActive; + RenderTexture.ReleaseTemporary(callerTarget); + if (texture != null) + { + Object.DestroyImmediate(texture); + } + + EditorSceneManager.ClosePreviewScene(scene); + } + } + } +} diff --git a/Assets/Tests/Editor/PrefabPreviewRendererTests.cs.meta b/Assets/Tests/Editor/PrefabPreviewRendererTests.cs.meta new file mode 100644 index 0000000000..39de4d9657 --- /dev/null +++ b/Assets/Tests/Editor/PrefabPreviewRendererTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: fb1dc409a59c24c22af9f08c86332c8b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/PrefabScreenshotUseCaseTests.cs b/Assets/Tests/Editor/PrefabScreenshotUseCaseTests.cs index 0e07f8be06..5967b195ca 100644 --- a/Assets/Tests/Editor/PrefabScreenshotUseCaseTests.cs +++ b/Assets/Tests/Editor/PrefabScreenshotUseCaseTests.cs @@ -214,6 +214,29 @@ public void CaptureAsync_WithAUiPartPrefab_LeavesTheAssetUnchanged() Assert.That(asset.GetComponent().rect.size, Is.EqualTo(new Vector2(100f, 100f))); } + /// + /// Verifies the real renderer produces a PNG sized to the whole UI part on any device with a GPU path. + /// + [Test] + public void CaptureAsync_WithTheRealRenderer_WritesAnImageSizedToTheUiPart() + { + if (SystemInfo.graphicsDeviceType == GraphicsDeviceType.Null) + { + Assert.Ignore("Rendering needs a graphics device; skipped on the Null device."); + } + + Texture2D image = CaptureWithTheRealRenderer(); + try + { + Assert.That(image.width, Is.EqualTo(140)); + Assert.That(image.height, Is.EqualTo(100)); + } + finally + { + UnityEngine.Object.DestroyImmediate(image); + } + } + /// /// Verifies the real renderer draws the panel, the overflowing badge, and the gray background into the PNG. /// @@ -226,15 +249,9 @@ public void CaptureAsync_WithTheRealRenderer_DrawsTheWholeUiPart() Assert.Ignore($"Pixel values need a real GPU with matching sRGB writes; skipped on {deviceType}."); } - PrefabScreenshotUseCase useCase = new(null, () => new Vector2(800f, 600f)); - ScreenshotResponse response = useCase.CaptureAsync(CreateRequest(UiPartPath), CancellationToken.None).Result; - - Texture2D image = new(2, 2); + Texture2D image = CaptureWithTheRealRenderer(); try { - Assert.That(image.LoadImage(File.ReadAllBytes(response.Screenshots[0].ImagePath)), Is.True); - Assert.That(image.width, Is.EqualTo(140)); - Assert.That(image.height, Is.EqualTo(100)); AssertColor32Near(image.GetPixel(2, 2), new Color32(128, 128, 128, 255), 1); AssertColor32Near(image.GetPixel(90, 50), new Color32(255, 255, 255, 255), 1); AssertColor32Near(image.GetPixel(20, 50), new Color32(255, 0, 0, 255), 1); @@ -245,6 +262,20 @@ public void CaptureAsync_WithTheRealRenderer_DrawsTheWholeUiPart() } } + private Texture2D CaptureWithTheRealRenderer() + { + PrefabScreenshotUseCase useCase = new(null, () => new Vector2(800f, 600f)); + ScreenshotResponse response = useCase.CaptureAsync(CreateRequest(UiPartPath), CancellationToken.None).Result; + Texture2D image = new(2, 2); + if (!image.LoadImage(File.ReadAllBytes(response.Screenshots[0].ImagePath))) + { + UnityEngine.Object.DestroyImmediate(image); + Assert.Fail("The saved PNG could not be decoded."); + } + + return image; + } + private void AssertRejectedWithoutSideEffects(ScreenshotSchema request, CancellationToken ct) where TException : Exception { From ed5a92613a613263131830a8facc86620fd0adf3 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 22 Oct 2026 19:18:38 +0900 Subject: [PATCH 11/18] docs: list prefab framing rules in the order they are applied The reference said the first matching rule wins but listed screen UI before UI parts and 3D before flat content, so a Canvas-less UI part or a sprite read as matching the wrong rule. The rules now follow the implementation order and state which roots count as a UI part. --- .../skills/uloop-screenshot/references/prefab-capture.md | 8 ++++---- .../skills/uloop-screenshot/references/prefab-capture.md | 8 ++++---- .../Screenshot/Skill/references/prefab-capture.md | 8 ++++---- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.agents/skills/uloop-screenshot/references/prefab-capture.md b/.agents/skills/uloop-screenshot/references/prefab-capture.md index 041ad1a026..1f2a20e7dd 100644 --- a/.agents/skills/uloop-screenshot/references/prefab-capture.md +++ b/.agents/skills/uloop-screenshot/references/prefab-capture.md @@ -11,10 +11,10 @@ uloop screenshot --prefab-path Assets/UI/Panel.prefab --prefab-background-color The first matching rule decides the image (the response `Message` says which one was used): -1. **Screen UI** — the prefab has an enabled Screen Space Canvas (Overlay or Camera). The outermost Canvases are switched to the capture camera and the image is the Game View size, so the layout matches the game. -2. **UI part** — the root is a RectTransform. The image is sized to the union of all active RectTransforms, so children that overflow the root are included. Sizes are counted in UI units, so a world-space Canvas saved at a small scale keeps its pixel size. A stretched root fills the Game View size. -3. **3D object** — anything else with an enabled Renderer or a world-space Canvas is viewed from the front-left with a perspective camera at the Game View size, with the whole object in frame. -4. **Flat object** — content with no depth (sprites, quads, a lone world-space Canvas) is viewed straight on with an orthographic camera at the Game View size. +1. **UI part** — the root is a RectTransform with no enabled Canvas of its own, or its own Canvas is World Space. The image is sized to the union of all active RectTransforms, so children that overflow the root are included, and nested Canvases inside the part are drawn with it. Sizes are counted in UI units, so a world-space Canvas saved at a small scale keeps its pixel size. A stretched root fills the Game View size. +2. **Screen UI** — the root itself has an enabled Screen Space Canvas (Overlay or Camera), or the root is not a RectTransform and contains one. The outermost Canvases are switched to the capture camera and the image is the Game View size, so the layout matches the game. +3. **Flat object** — content with no depth (sprites, quads, a lone world-space Canvas) is viewed straight on with an orthographic camera at the Game View size. +4. **3D object** — anything else with an enabled Renderer is viewed from the front-left with a perspective camera at the Game View size, with the whole object in frame. A prefab with nothing to render is rejected, including one whose renderers have empty bounds, such as a particle system that has not emitted. When a prefab has both Screen Space UI and 3D content, the UI rule wins and the 3D content may not appear. diff --git a/.claude/skills/uloop-screenshot/references/prefab-capture.md b/.claude/skills/uloop-screenshot/references/prefab-capture.md index 041ad1a026..1f2a20e7dd 100644 --- a/.claude/skills/uloop-screenshot/references/prefab-capture.md +++ b/.claude/skills/uloop-screenshot/references/prefab-capture.md @@ -11,10 +11,10 @@ uloop screenshot --prefab-path Assets/UI/Panel.prefab --prefab-background-color The first matching rule decides the image (the response `Message` says which one was used): -1. **Screen UI** — the prefab has an enabled Screen Space Canvas (Overlay or Camera). The outermost Canvases are switched to the capture camera and the image is the Game View size, so the layout matches the game. -2. **UI part** — the root is a RectTransform. The image is sized to the union of all active RectTransforms, so children that overflow the root are included. Sizes are counted in UI units, so a world-space Canvas saved at a small scale keeps its pixel size. A stretched root fills the Game View size. -3. **3D object** — anything else with an enabled Renderer or a world-space Canvas is viewed from the front-left with a perspective camera at the Game View size, with the whole object in frame. -4. **Flat object** — content with no depth (sprites, quads, a lone world-space Canvas) is viewed straight on with an orthographic camera at the Game View size. +1. **UI part** — the root is a RectTransform with no enabled Canvas of its own, or its own Canvas is World Space. The image is sized to the union of all active RectTransforms, so children that overflow the root are included, and nested Canvases inside the part are drawn with it. Sizes are counted in UI units, so a world-space Canvas saved at a small scale keeps its pixel size. A stretched root fills the Game View size. +2. **Screen UI** — the root itself has an enabled Screen Space Canvas (Overlay or Camera), or the root is not a RectTransform and contains one. The outermost Canvases are switched to the capture camera and the image is the Game View size, so the layout matches the game. +3. **Flat object** — content with no depth (sprites, quads, a lone world-space Canvas) is viewed straight on with an orthographic camera at the Game View size. +4. **3D object** — anything else with an enabled Renderer is viewed from the front-left with a perspective camera at the Game View size, with the whole object in frame. A prefab with nothing to render is rejected, including one whose renderers have empty bounds, such as a particle system that has not emitted. When a prefab has both Screen Space UI and 3D content, the UI rule wins and the 3D content may not appear. diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/Skill/references/prefab-capture.md b/Packages/src/Editor/FirstPartyTools/Screenshot/Skill/references/prefab-capture.md index 041ad1a026..1f2a20e7dd 100644 --- a/Packages/src/Editor/FirstPartyTools/Screenshot/Skill/references/prefab-capture.md +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/Skill/references/prefab-capture.md @@ -11,10 +11,10 @@ uloop screenshot --prefab-path Assets/UI/Panel.prefab --prefab-background-color The first matching rule decides the image (the response `Message` says which one was used): -1. **Screen UI** — the prefab has an enabled Screen Space Canvas (Overlay or Camera). The outermost Canvases are switched to the capture camera and the image is the Game View size, so the layout matches the game. -2. **UI part** — the root is a RectTransform. The image is sized to the union of all active RectTransforms, so children that overflow the root are included. Sizes are counted in UI units, so a world-space Canvas saved at a small scale keeps its pixel size. A stretched root fills the Game View size. -3. **3D object** — anything else with an enabled Renderer or a world-space Canvas is viewed from the front-left with a perspective camera at the Game View size, with the whole object in frame. -4. **Flat object** — content with no depth (sprites, quads, a lone world-space Canvas) is viewed straight on with an orthographic camera at the Game View size. +1. **UI part** — the root is a RectTransform with no enabled Canvas of its own, or its own Canvas is World Space. The image is sized to the union of all active RectTransforms, so children that overflow the root are included, and nested Canvases inside the part are drawn with it. Sizes are counted in UI units, so a world-space Canvas saved at a small scale keeps its pixel size. A stretched root fills the Game View size. +2. **Screen UI** — the root itself has an enabled Screen Space Canvas (Overlay or Camera), or the root is not a RectTransform and contains one. The outermost Canvases are switched to the capture camera and the image is the Game View size, so the layout matches the game. +3. **Flat object** — content with no depth (sprites, quads, a lone world-space Canvas) is viewed straight on with an orthographic camera at the Game View size. +4. **3D object** — anything else with an enabled Renderer is viewed from the front-left with a perspective camera at the Game View size, with the whole object in frame. A prefab with nothing to render is rejected, including one whose renderers have empty bounds, such as a particle system that has not emitted. When a prefab has both Screen Space UI and 3D content, the UI rule wins and the 3D content may not appear. From dce8af3925db0e7064ef6bf4ad0b7946d23f81fe Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 22 Oct 2026 20:27:32 +0900 Subject: [PATCH 12/18] fix: prefab screenshots draw renderers and UI parts in pipelines that filter game cameras Some custom render pipelines draw game cameras only on certain layers and skip their clear, so UI parts, models, and sprites came back as empty images even though the capture reported success. - Prefabs with visible renderers now render through a preview camera, the camera type Inspector previews use, which pipelines draw plainly. Preview cameras do not draw uGUI, so a prefab whose only content is a world-space Canvas (or one next to renderers with empty bounds) keeps a game camera. - The temporary Canvas that hosts a UI part sits on the built-in UI layer, which decides whether uGUI is drawn; the prefab's own layers are left as saved. --- .../Tests/Editor/PrefabCaptureFramingTests.cs | 53 +++++++++++++++++++ .../Screenshot/PrefabCaptureFraming.cs | 51 ++++++++++++++++-- 2 files changed, 100 insertions(+), 4 deletions(-) diff --git a/Assets/Tests/Editor/PrefabCaptureFramingTests.cs b/Assets/Tests/Editor/PrefabCaptureFramingTests.cs index 83cf2d2c47..1b1a72e278 100644 --- a/Assets/Tests/Editor/PrefabCaptureFramingTests.cs +++ b/Assets/Tests/Editor/PrefabCaptureFramingTests.cs @@ -50,6 +50,8 @@ public void Arrange_WithAnOverflowingChild_SizesToTheUnionOfRectTransforms() Assert.That(_camera.orthographicSize, Is.EqualTo(50f).Within(Tolerance)); Assert.That(_camera.transform.position.x, Is.EqualTo(-20f).Within(Tolerance)); Assert.That(_camera.transform.position.y, Is.EqualTo(0f).Within(Tolerance)); + Assert.That(_camera.cameraType, Is.EqualTo(CameraType.Game)); + Assert.That(root.transform.parent.gameObject.layer, Is.EqualTo(LayerMask.NameToLayer("UI"))); } /// @@ -112,6 +114,7 @@ public void Arrange_WithAnOverlayCanvasRoot_SwitchesTheRootCanvasToTheCaptureCam Assert.That(frame.Kind, Is.EqualTo(PrefabCaptureKind.ScreenUi)); Assert.That(frame.ImageSize, Is.EqualTo(GameViewSize)); + Assert.That(_camera.cameraType, Is.EqualTo(CameraType.Game)); Assert.That(rootCanvas.renderMode, Is.EqualTo(RenderMode.ScreenSpaceCamera)); Assert.That(rootCanvas.worldCamera, Is.SameAs(_camera)); } @@ -162,6 +165,7 @@ public void Arrange_WithACube_FramesAllBoundsCornersInPerspective() Assert.That(frame.Kind, Is.EqualTo(PrefabCaptureKind.Object3D)); Assert.That(frame.ImageSize, Is.EqualTo(GameViewSize)); Assert.That(_camera.orthographic, Is.False); + Assert.That(_camera.cameraType, Is.EqualTo(CameraType.Preview)); AssertCornersInView(BoundsCorners(cube.bounds)); } @@ -211,6 +215,7 @@ public void Arrange_WithAQuad_FramesItStraightOn() Assert.That(frame.Kind, Is.EqualTo(PrefabCaptureKind.Flat2D)); Assert.That(_camera.orthographic, Is.True); Assert.That(_camera.transform.rotation, Is.EqualTo(Quaternion.identity)); + Assert.That(_camera.cameraType, Is.EqualTo(CameraType.Preview)); AssertCornersInView(BoundsCorners(quad.bounds)); } @@ -255,6 +260,7 @@ public void Arrange_WithAnOverlayCanvasAndACube_PrefersScreenUi() PrefabCaptureFrame frame = PrefabCaptureFraming.Arrange(root, _camera, GameViewSize); Assert.That(frame.Kind, Is.EqualTo(PrefabCaptureKind.ScreenUi)); + Assert.That(_camera.cameraType, Is.EqualTo(CameraType.Game)); } /// @@ -273,6 +279,7 @@ public void Arrange_WithOnlyAWorldSpaceCanvasChild_FramesItAsFlat() PrefabCaptureFrame frame = PrefabCaptureFraming.Arrange(root, _camera, GameViewSize); Assert.That(frame.Kind, Is.EqualTo(PrefabCaptureKind.Flat2D)); + Assert.That(_camera.cameraType, Is.EqualTo(CameraType.Game)); Vector3[] corners = new Vector3[4]; canvasRect.GetWorldCorners(corners); AssertCornersInView(corners); @@ -295,6 +302,7 @@ public void Arrange_WithAScaledWorldSpaceCanvasRoot_SizesInUiUnits() Assert.That(frame.Kind, Is.EqualTo(PrefabCaptureKind.UiPart)); Assert.That(frame.ImageSize, Is.EqualTo(new Vector2Int(100, 100))); + Assert.That(root.transform.parent.gameObject.layer, Is.EqualTo(LayerMask.NameToLayer("UI"))); } /// @@ -355,6 +363,42 @@ public void Arrange_WithAnOverlayCanvasInsideACanvaslessUiRoot_TreatsItAsAUiPart Assert.That(frame.ImageSize, Is.EqualTo(new Vector2Int(140, 100))); } + /// + /// Verifies a prefab with renderers and a world-space Canvas uses a preview camera and frames both. + /// + [Test] + public void Arrange_WithACubeAndAWorldSpaceCanvas_UsesAPreviewCameraAndFramesBoth() + { + GameObject root = CreateInScene("Root"); + Renderer cube = CreatePrimitiveChild(root, PrimitiveType.Cube); + RectTransform canvasRect = CreateWorldSpaceCanvasChild(root); + + PrefabCaptureFrame frame = PrefabCaptureFraming.Arrange(root, _camera, GameViewSize); + + Assert.That(frame.Kind, Is.EqualTo(PrefabCaptureKind.Object3D)); + Assert.That(_camera.cameraType, Is.EqualTo(CameraType.Preview)); + AssertCornersInView(BoundsCorners(cube.bounds)); + Vector3[] corners = new Vector3[4]; + canvasRect.GetWorldCorners(corners); + AssertCornersInView(corners); + } + + /// + /// Verifies a renderer with empty bounds does not switch a world-space Canvas prefab to a preview camera, which would hide the Canvas. + /// + [Test] + public void Arrange_WithAnEmptyParticleSystemAndAWorldSpaceCanvas_KeepsAGameCamera() + { + GameObject root = CreateInScene("Root"); + CreateChild(root, "Particles").AddComponent(); + CreateWorldSpaceCanvasChild(root); + + PrefabCaptureFrame frame = PrefabCaptureFraming.Arrange(root, _camera, GameViewSize); + + Assert.That(frame.Kind, Is.EqualTo(PrefabCaptureKind.Flat2D)); + Assert.That(_camera.cameraType, Is.EqualTo(CameraType.Game)); + } + /// /// Verifies a MeshRenderer without a mesh has empty bounds and is rejected instead of rendering a blank image. /// @@ -410,6 +454,15 @@ private GameObject CreateUiPartWithOverflowingChild(out GameObject child) return root; } + private static RectTransform CreateWorldSpaceCanvasChild(GameObject parent) + { + GameObject canvasObject = CreateChild(parent, "Canvas"); + canvasObject.AddComponent().renderMode = RenderMode.WorldSpace; + RectTransform canvasRect = (RectTransform)canvasObject.transform; + canvasRect.sizeDelta = new Vector2(100f, 100f); + return canvasRect; + } + private GameObject CreateInScene(string name) { GameObject gameObject = new GameObject(name); diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabCaptureFraming.cs b/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabCaptureFraming.cs index a3d584bd71..02d27957de 100644 --- a/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabCaptureFraming.cs +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabCaptureFraming.cs @@ -182,6 +182,8 @@ private static bool HasCanvasAncestorWithin(Transform transform, Transform root) private static PrefabCaptureFrame ArrangeUiPart(GameObject root, Camera camera, Vector2Int gameViewSize) { GameObject wrapper = new("PrefabCaptureCanvas", typeof(RectTransform), typeof(Canvas)); + // Some pipelines draw game cameras only on certain layers; uGUI follows the outermost Canvas's layer. + wrapper.layer = UiLayer(); SceneManager.MoveGameObjectToScene(wrapper, root.scene); Canvas wrapperCanvas = wrapper.GetComponent(); wrapperCanvas.renderMode = RenderMode.WorldSpace; @@ -223,6 +225,18 @@ private static PrefabCaptureFrame ArrangeUiPart(GameObject root, Camera camera, new Vector2Int(Mathf.CeilToInt(pixelSize.x), Mathf.CeilToInt(pixelSize.y))); } + private static int UiLayer() + { + int layer = LayerMask.NameToLayer("UI"); + if (layer < 0) + { + // Continuing would return an image the pipeline never drew into. + throw new InvalidOperationException("The built-in UI layer is missing"); + } + + return layer; + } + private static Rect UnionOfWorldRects(GameObject root) { Vector3[] corners = new Vector3[4]; @@ -247,20 +261,27 @@ private static Rect UnionOfWorldRects(GameObject root) private static PrefabCaptureFrame ArrangeObject(GameObject root, Camera camera, Vector2Int gameViewSize) { - Bounds? bounds = CollectRenderableBounds(root); + Bounds? rendererBounds = CollectRendererBounds(root); + Bounds? bounds = Union(rendererBounds, CollectWorldCanvasBounds(root)); if (bounds == null) { throw new UnityCliLoopToolParameterValidationException("Prefab has no Renderer or UI to capture"); } // Empty bounds would give a zero-sized orthographic camera and a blank image reported as success. - Vector3 size = bounds.Value.size; - if (Mathf.Max(size.x, size.y, size.z) <= MinimumVisibleExtent) + if (MaxExtent(bounds.Value) <= MinimumVisibleExtent) { throw new UnityCliLoopToolParameterValidationException( "Prefab has nothing with a visible size to capture; renderer bounds are empty (for example, a particle system that has not emitted)"); } + // uGUI is not drawn by preview cameras, so only prefabs with visible renderers switch to one; + // every pipeline draws preview cameras plainly because Inspector previews depend on them. + if (rendererBounds != null && MaxExtent(rendererBounds.Value) > MinimumVisibleExtent) + { + camera.cameraType = CameraType.Preview; + } + if (!HasEnabledLight(root)) { AddDirectionalLight(root.scene); @@ -292,7 +313,7 @@ private static PrefabCaptureFrame ArrangeObject(GameObject root, Camera camera, return new PrefabCaptureFrame(PrefabCaptureKind.Object3D, gameViewSize); } - private static Bounds? CollectRenderableBounds(GameObject root) + private static Bounds? CollectRendererBounds(GameObject root) { Bounds? bounds = null; foreach (Renderer renderer in root.GetComponentsInChildren(false)) @@ -305,6 +326,12 @@ private static PrefabCaptureFrame ArrangeObject(GameObject root, Camera camera, bounds = Encapsulate(bounds, renderer.bounds); } + return bounds; + } + + private static Bounds? CollectWorldCanvasBounds(GameObject root) + { + Bounds? bounds = null; Vector3[] corners = new Vector3[4]; foreach (Canvas canvas in CollectTopCanvases(root, true)) { @@ -318,6 +345,22 @@ private static PrefabCaptureFrame ArrangeObject(GameObject root, Camera camera, return bounds; } + private static Bounds? Union(Bounds? first, Bounds? second) + { + if (second == null) + { + return first; + } + + return Encapsulate(first, second.Value); + } + + private static float MaxExtent(Bounds bounds) + { + Vector3 size = bounds.size; + return Mathf.Max(size.x, size.y, size.z); + } + private static Bounds Encapsulate(Bounds? current, Bounds addition) { if (current == null) From a465ccf48c075fa52368ae1174355d3bca49fa92 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 22 Oct 2026 20:31:24 +0900 Subject: [PATCH 13/18] fix: prefab screenshots reject empty renders and fill an opaque background themselves A pipeline that draws nothing for the capture camera produced an empty PNG reported as success, and a pipeline that skips the clear left the background transparent even when an opaque color was requested. - An image with no visible pixel other than the clear color (within one step) is rejected as a parameter error before anything is saved; the message suggests another background color for single-color prefabs. - An opaque background is composited under every pixel that is not fully opaque after rendering. Pixels drawn over a transparent clear are premultiplied, so this is a premultiplied over, done in linear space in Linear projects to match what the GPU draws. Translucent and transparent backgrounds are left to the pipeline, since compositing them again would double a background it already drew. - The test renderer now returns explicit pixels so the empty-image check cannot reject every use case test by accident. --- Assets/Tests/Editor/PrefabImagePixelsTests.cs | 149 ++++++++++++++++++ .../Editor/PrefabImagePixelsTests.cs.meta | 11 ++ .../Editor/PrefabScreenshotUseCaseTests.cs | 104 ++++++++++++ .../Screenshot/PrefabImagePixels.cs | 89 +++++++++++ .../Screenshot/PrefabImagePixels.cs.meta | 11 ++ .../Screenshot/PrefabScreenshotUseCase.cs | 22 +++ 6 files changed, 386 insertions(+) create mode 100644 Assets/Tests/Editor/PrefabImagePixelsTests.cs create mode 100644 Assets/Tests/Editor/PrefabImagePixelsTests.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/Screenshot/PrefabImagePixels.cs create mode 100644 Packages/src/Editor/FirstPartyTools/Screenshot/PrefabImagePixels.cs.meta diff --git a/Assets/Tests/Editor/PrefabImagePixelsTests.cs b/Assets/Tests/Editor/PrefabImagePixelsTests.cs new file mode 100644 index 0000000000..2b42cd0755 --- /dev/null +++ b/Assets/Tests/Editor/PrefabImagePixelsTests.cs @@ -0,0 +1,149 @@ +using System; +using NUnit.Framework; +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Verifies how a rendered prefab image is judged empty and how an opaque background is composited under it. + /// + public sealed class PrefabImagePixelsTests + { + private static readonly Color32 Gray = new(128, 128, 128, 255); + private static readonly Color32 Transparent = new(0, 0, 0, 0); + + /// + /// Verifies an image whose pixels are all fully transparent counts as nothing drawn. + /// + [Test] + public void HasDrawnPixel_WithOnlyTransparentPixels_ReturnsFalse() + { + Color32[] pixels = Filled(Transparent, 4); + + Assert.That(PrefabImagePixels.HasDrawnPixel(pixels, Gray), Is.False); + } + + /// + /// Verifies pixels within one step of the clear color count as background, not as drawn content. + /// + [Test] + public void HasDrawnPixel_WithOnlyTheClearColorWithinOneStep_ReturnsFalse() + { + Color32[] pixels = Filled(Gray, 4); + pixels[1] = new Color32(129, 128, 128, 255); + + Assert.That(PrefabImagePixels.HasDrawnPixel(pixels, Gray), Is.False); + } + + /// + /// Verifies one opaque pixel that differs from the clear color counts as drawn. + /// + [Test] + public void HasDrawnPixel_WithOneDrawnPixel_ReturnsTrue() + { + Color32[] pixels = Filled(Transparent, 4); + pixels[2] = new Color32(255, 0, 0, 255); + + Assert.That(PrefabImagePixels.HasDrawnPixel(pixels, Gray), Is.True); + } + + /// + /// Verifies a fully transparent pixel becomes the background color in gamma blending. + /// + [Test] + public void FillBackground_WithATransparentPixel_UsesTheBackground() + { + Color32[] pixels = { Transparent }; + + PrefabImagePixels.FillBackground(pixels, Gray, false); + + AssertColor32Near(pixels[0], Gray, 0); + } + + /// + /// Verifies an opaque pixel is left unchanged. + /// + [Test] + public void FillBackground_WithAnOpaquePixel_LeavesItUnchanged() + { + Color32 red = new(255, 0, 0, 255); + Color32[] pixels = { red }; + + PrefabImagePixels.FillBackground(pixels, Gray, false); + + AssertColor32Near(pixels[0], red, 0); + } + + /// + /// Verifies a premultiplied translucent pixel is composited over the background in gamma space. + /// + [Test] + public void FillBackground_WithATranslucentPixelInGamma_CompositesPremultipliedOver() + { + Color32[] pixels = { new Color32(100, 50, 25, 128) }; + + PrefabImagePixels.FillBackground(pixels, Gray, false); + + AssertColor32Near(pixels[0], new Color32(164, 114, 89, 255), 0); + } + + /// + /// Verifies linear blending reproduces what the GPU draws for translucent white over opaque gray in a Linear project. + /// + [Test] + public void FillBackground_WithATranslucentPixelInLinear_MatchesTheGpuComposite() + { + Color32[] pixels = { new Color32(188, 188, 188, 128) }; + + PrefabImagePixels.FillBackground(pixels, Gray, true); + + AssertColor32Near(pixels[0], new Color32(205, 205, 205, 255), 1); + } + + /// + /// Verifies a fully transparent pixel becomes the background color in linear blending. + /// + [Test] + public void FillBackground_WithATransparentPixelInLinear_UsesTheBackground() + { + Color32[] pixels = { Transparent }; + + PrefabImagePixels.FillBackground(pixels, Gray, true); + + AssertColor32Near(pixels[0], Gray, 1); + } + + /// + /// Verifies a background that is not fully opaque is refused, since compositing it would double a pipeline-drawn background. + /// + [Test] + public void FillBackground_WithATranslucentBackground_Throws() + { + Color32[] pixels = { Transparent }; + + Assert.Throws( + () => PrefabImagePixels.FillBackground(pixels, new Color32(128, 128, 128, 254), false)); + } + + private static Color32[] Filled(Color32 color, int count) + { + Color32[] pixels = new Color32[count]; + for (int i = 0; i < count; i++) + { + pixels[i] = color; + } + + return pixels; + } + + private static void AssertColor32Near(Color32 actual, Color32 expected, int tolerance) + { + Assert.That(actual.r, Is.InRange(expected.r - tolerance, expected.r + tolerance), "r"); + Assert.That(actual.g, Is.InRange(expected.g - tolerance, expected.g + tolerance), "g"); + Assert.That(actual.b, Is.InRange(expected.b - tolerance, expected.b + tolerance), "b"); + Assert.That(actual.a, Is.InRange(expected.a - tolerance, expected.a + tolerance), "a"); + } + } +} diff --git a/Assets/Tests/Editor/PrefabImagePixelsTests.cs.meta b/Assets/Tests/Editor/PrefabImagePixelsTests.cs.meta new file mode 100644 index 0000000000..42591f765f --- /dev/null +++ b/Assets/Tests/Editor/PrefabImagePixelsTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 51b3e8d42821b4ac0ad7f4d6bcde2545 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/PrefabScreenshotUseCaseTests.cs b/Assets/Tests/Editor/PrefabScreenshotUseCaseTests.cs index 5967b195ca..c25c25d9c7 100644 --- a/Assets/Tests/Editor/PrefabScreenshotUseCaseTests.cs +++ b/Assets/Tests/Editor/PrefabScreenshotUseCaseTests.cs @@ -201,6 +201,83 @@ public void CaptureAsync_WhenRenderingFails_PropagatesAndCleansUp() Assert.That(EditorSceneManager.previewSceneCount, Is.EqualTo(previewScenesBefore)); } + /// + /// Verifies an image with nothing drawn is rejected, and no file, texture, or preview scene is left behind. + /// + [Test] + public void CaptureAsync_WhenNothingIsDrawn_RejectsWithoutWritingAFile() + { + RecordingRenderer renderer = new() { ReturnBlank = true }; + int previewScenesBefore = EditorSceneManager.previewSceneCount; + PrefabScreenshotUseCase useCase = CreateUseCase(renderer); + + Assert.Throws( + () => useCase.CaptureAsync(CreateRequest(UiPartPath), CancellationToken.None)); + + Assert.That(Directory.GetFiles(_outputDirectory), Is.Empty); + Assert.That(EditorSceneManager.previewSceneCount, Is.EqualTo(previewScenesBefore)); + Assert.That(renderer.LastReturnedTexture == null, Is.True, "the rendered texture must be destroyed"); + } + + /// + /// Verifies an image showing only the camera's background color is rejected as nothing drawn. + /// + [Test] + public void CaptureAsync_WhenOnlyTheBackgroundIsDrawn_RejectsWithoutWritingAFile() + { + RecordingRenderer renderer = new() { FillWithCameraBackground = true }; + int previewScenesBefore = EditorSceneManager.previewSceneCount; + PrefabScreenshotUseCase useCase = CreateUseCase(renderer); + + Assert.Throws( + () => useCase.CaptureAsync(CreateRequest(UiPartPath), CancellationToken.None)); + + Assert.That(Directory.GetFiles(_outputDirectory), Is.Empty); + Assert.That(EditorSceneManager.previewSceneCount, Is.EqualTo(previewScenesBefore)); + } + + /// + /// Verifies pixels the pipeline left transparent are filled with the default opaque gray while drawn pixels stay. + /// + [Test] + public void CaptureAsync_WithTheDefaultBackground_FillsUndrawnPixelsWithGray() + { + ScreenshotResponse response = Capture(new RecordingRenderer(), CreateRequest(UiPartPath)); + + Texture2D image = LoadSavedImage(response); + try + { + AssertColor32Near(image.GetPixel(0, 0), new Color32(128, 128, 128, 255), 1); + AssertColor32Near(image.GetPixel(image.width / 2, image.height / 2), new Color32(255, 0, 0, 255), 0); + } + finally + { + UnityEngine.Object.DestroyImmediate(image); + } + } + + /// + /// Verifies a transparent background is not composited, so undrawn pixels stay transparent. + /// + [Test] + public void CaptureAsync_WithATransparentBackground_KeepsUndrawnPixelsTransparent() + { + ScreenshotSchema request = CreateRequest(UiPartPath); + request.PrefabBackgroundColor = "#00000000"; + + ScreenshotResponse response = Capture(new RecordingRenderer(), request); + + Texture2D image = LoadSavedImage(response); + try + { + Assert.That(((Color32)image.GetPixel(0, 0)).a, Is.EqualTo(0)); + } + finally + { + UnityEngine.Object.DestroyImmediate(image); + } + } + /// /// Verifies capturing does not modify the prefab asset. /// @@ -266,6 +343,11 @@ private Texture2D CaptureWithTheRealRenderer() { PrefabScreenshotUseCase useCase = new(null, () => new Vector2(800f, 600f)); ScreenshotResponse response = useCase.CaptureAsync(CreateRequest(UiPartPath), CancellationToken.None).Result; + return LoadSavedImage(response); + } + + private static Texture2D LoadSavedImage(ScreenshotResponse response) + { Texture2D image = new(2, 2); if (!image.LoadImage(File.ReadAllBytes(response.Screenshots[0].ImagePath))) { @@ -330,6 +412,8 @@ private static void AssertColor32Near(Color32 actual, Color32 expected, int tole private sealed class RecordingRenderer : IPrefabPreviewRenderer { public bool ThrowOnRender { get; set; } + public bool ReturnBlank { get; set; } + public bool FillWithCameraBackground { get; set; } public int RenderCount { get; private set; } public Texture2D LastReturnedTexture { get; private set; } public Color CameraBackgroundColor { get; private set; } @@ -349,8 +433,28 @@ public Texture2D Render(Camera camera, Vector2Int size) } LastReturnedTexture = new Texture2D(size.x, size.y, TextureFormat.RGBA32, false); + LastReturnedTexture.SetPixels32(CreatePixels(camera, size)); + LastReturnedTexture.Apply(); return LastReturnedTexture; } + + // Transparent everywhere except one opaque red pixel in the middle, unless a blank or background-only image is asked for. + private Color32[] CreatePixels(Camera camera, Vector2Int size) + { + Color32 fill = FillWithCameraBackground ? (Color32)camera.backgroundColor : new Color32(0, 0, 0, 0); + Color32[] pixels = new Color32[size.x * size.y]; + for (int i = 0; i < pixels.Length; i++) + { + pixels[i] = fill; + } + + if (!ReturnBlank && !FillWithCameraBackground) + { + pixels[size.y / 2 * size.x + size.x / 2] = new Color32(255, 0, 0, 255); + } + + return pixels; + } } } } diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabImagePixels.cs b/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabImagePixels.cs new file mode 100644 index 0000000000..e108706e27 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabImagePixels.cs @@ -0,0 +1,89 @@ +using System; +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Pixel checks and background compositing applied to a rendered prefab image before it is saved. + /// + internal static class PrefabImagePixels + { + private const int ClearColorTolerance = 1; + + /// + /// Returns true when at least one visible pixel differs from the clear color by more than one step. + /// + internal static bool HasDrawnPixel(Color32[] pixels, Color32 clearColor) + { + foreach (Color32 pixel in pixels) + { + if (pixel.a == 0) + { + continue; + } + + if (DiffersFrom(pixel, clearColor)) + { + return true; + } + } + + return false; + } + + /// + /// Composites an opaque background under every pixel that is not fully opaque. + /// Pixels drawn over a transparent clear hold premultiplied color, so this is a premultiplied over; + /// linearBlending blends in linear space, matching how the GPU blends in a Linear project. + /// + internal static void FillBackground(Color32[] pixels, Color32 background, bool linearBlending) + { + if (background.a != 255) + { + throw new ArgumentException($"Background must be opaque, got alpha {background.a}", nameof(background)); + } + + for (int i = 0; i < pixels.Length; i++) + { + Color32 pixel = pixels[i]; + if (pixel.a == 255) + { + continue; + } + + float coverage = pixel.a / 255f; + pixels[i] = new Color32( + Over(pixel.r, background.r, coverage, linearBlending), + Over(pixel.g, background.g, coverage, linearBlending), + Over(pixel.b, background.b, coverage, linearBlending), + 255); + } + } + + private static byte Over(byte source, byte background, float coverage, bool linearBlending) + { + float s = ToBlend(source / 255f, linearBlending); + float b = ToBlend(background / 255f, linearBlending); + float blended = Mathf.Min(1f, s + b * (1f - coverage)); + return (byte)Mathf.RoundToInt(FromBlend(blended, linearBlending) * 255f); + } + + private static float ToBlend(float value, bool linearBlending) + { + return linearBlending ? Mathf.GammaToLinearSpace(value) : value; + } + + private static float FromBlend(float value, bool linearBlending) + { + return linearBlending ? Mathf.LinearToGammaSpace(value) : value; + } + + private static bool DiffersFrom(Color32 pixel, Color32 clearColor) + { + return Mathf.Abs(pixel.r - clearColor.r) > ClearColorTolerance + || Mathf.Abs(pixel.g - clearColor.g) > ClearColorTolerance + || Mathf.Abs(pixel.b - clearColor.b) > ClearColorTolerance + || Mathf.Abs(pixel.a - clearColor.a) > ClearColorTolerance; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabImagePixels.cs.meta b/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabImagePixels.cs.meta new file mode 100644 index 0000000000..191cb8d371 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabImagePixels.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 76efeaf3ac4224927a760025838c9c57 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabScreenshotUseCase.cs b/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabScreenshotUseCase.cs index c043022624..24ca3f49fa 100644 --- a/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabScreenshotUseCase.cs +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabScreenshotUseCase.cs @@ -61,6 +61,7 @@ internal Task CaptureAsync(ScreenshotSchema request, Cancell GameObject instance = (GameObject)PrefabUtility.InstantiatePrefab(asset, scene); PrefabCaptureFrame frame = PrefabCaptureFraming.Arrange(instance, camera, gameViewSize); texture = _renderer.Render(camera, frame.ImageSize); + CheckAndFillBackground(texture, background, request.PrefabPath); if (!Mathf.Approximately(request.ResolutionScale, 1f)) { // ApplyResolutionScaling destroys the original only after it succeeds, so finally destroys exactly one texture. @@ -92,6 +93,27 @@ internal Task CaptureAsync(ScreenshotSchema request, Cancell } } + private static void CheckAndFillBackground(Texture2D texture, Color background, string prefabPath) + { + Color32[] pixels = texture.GetPixels32(); + if (!PrefabImagePixels.HasDrawnPixel(pixels, background)) + { + throw new UnityCliLoopToolParameterValidationException( + $"Nothing was drawn for {prefabPath}; the project's render pipeline did not draw this content. If the prefab is a single flat color, pass a different --prefab-background-color."); + } + + // Some pipelines do not clear game cameras, so an opaque background is filled in afterwards; + // a translucent background is left to the pipeline because compositing it again would double it. + if (background.a < 1f) + { + return; + } + + PrefabImagePixels.FillBackground(pixels, background, QualitySettings.activeColorSpace == ColorSpace.Linear); + texture.SetPixels32(pixels); + texture.Apply(); + } + private static Camera CreateCamera(Scene scene, Color background) { GameObject cameraObject = new("PrefabCaptureCamera"); From 39aeccbdabf0b54157f7f7eb37b45fc3d551b324 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 22 Oct 2026 20:33:39 +0900 Subject: [PATCH 14/18] fix: a scaled UI part is no longer captured one pixel too wide Dividing world corners by the root scale leaves float noise such as 109.0000076, which rounding up turned into an extra pixel. Pixel counts now ignore excess below 0.01 UI units before rounding up. --- .../Tests/Editor/PrefabCaptureFramingTests.cs | 18 ++++++++++++++++++ .../Screenshot/PrefabCaptureFraming.cs | 9 ++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/Assets/Tests/Editor/PrefabCaptureFramingTests.cs b/Assets/Tests/Editor/PrefabCaptureFramingTests.cs index 1b1a72e278..18565466e3 100644 --- a/Assets/Tests/Editor/PrefabCaptureFramingTests.cs +++ b/Assets/Tests/Editor/PrefabCaptureFramingTests.cs @@ -305,6 +305,24 @@ public void Arrange_WithAScaledWorldSpaceCanvasRoot_SizesInUiUnits() Assert.That(root.transform.parent.gameObject.layer, Is.EqualTo(LayerMask.NameToLayer("UI"))); } + /// + /// Verifies float noise from dividing by the root scale does not add a pixel to a scaled UI part. + /// + [Test] + public void Arrange_WithAScaledRootOfAnAwkwardWidth_DoesNotAddAPixel() + { + GameObject root = CreateRectInScene("Root"); + Canvas canvas = root.AddComponent(); + canvas.renderMode = RenderMode.WorldSpace; + RectTransform rect = (RectTransform)root.transform; + rect.sizeDelta = new Vector2(109f, 100f); + rect.localScale = new Vector3(0.01f, 0.01f, 1f); + + PrefabCaptureFrame frame = PrefabCaptureFraming.Arrange(root, _camera, GameViewSize); + + Assert.That(frame.ImageSize, Is.EqualTo(new Vector2Int(109, 100))); + } + /// /// Verifies disabled screen-space Canvases do not make a prefab screen UI. /// diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabCaptureFraming.cs b/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabCaptureFraming.cs index 02d27957de..b731d5e113 100644 --- a/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabCaptureFraming.cs +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabCaptureFraming.cs @@ -52,6 +52,7 @@ internal static class PrefabCaptureFraming private const float FlatDepthRatio = 0.001f; private const float FlatCameraDistance = 10f; private const float MinimumVisibleExtent = 1e-4f; + private const float PixelSnapTolerance = 0.01f; /// /// Rearranges the instance and camera for rendering and returns what was decided; rejects prefabs with nothing to capture. @@ -222,7 +223,13 @@ private static PrefabCaptureFrame ArrangeUiPart(GameObject root, Camera camera, camera.farClipPlane = WorldUiCameraDistance * 2f; return new PrefabCaptureFrame( PrefabCaptureKind.UiPart, - new Vector2Int(Mathf.CeilToInt(pixelSize.x), Mathf.CeilToInt(pixelSize.y))); + new Vector2Int(ToPixelCount(pixelSize.x), ToPixelCount(pixelSize.y))); + } + + // Dividing world corners by the root scale leaves float noise such as 109.0000076, which must not add a pixel. + private static int ToPixelCount(float uiUnits) + { + return Mathf.CeilToInt(uiUnits - PixelSnapTolerance); } private static int UiLayer() From 2888200b6e3d01475245b14b1e652a1dbff78349 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 22 Oct 2026 20:34:01 +0900 Subject: [PATCH 15/18] docs: explain how prefab screenshots behave in render pipelines that filter game cameras The prefab capture reference now says that prefabs with renderers use a preview camera (which does not draw uGUI), that the temporary UI part Canvas sits on the UI layer, that the prefab's own Canvases keep their layers and may be rejected when a pipeline skips them, that an opaque background is composited after rendering, and that renders with nothing visible are rejected. --- .../uloop-screenshot/references/prefab-capture.md | 12 +++++++----- .../uloop-screenshot/references/prefab-capture.md | 12 +++++++----- .../Screenshot/Skill/references/prefab-capture.md | 12 +++++++----- 3 files changed, 21 insertions(+), 15 deletions(-) diff --git a/.agents/skills/uloop-screenshot/references/prefab-capture.md b/.agents/skills/uloop-screenshot/references/prefab-capture.md index 1f2a20e7dd..7faf2e5f07 100644 --- a/.agents/skills/uloop-screenshot/references/prefab-capture.md +++ b/.agents/skills/uloop-screenshot/references/prefab-capture.md @@ -11,12 +11,14 @@ uloop screenshot --prefab-path Assets/UI/Panel.prefab --prefab-background-color The first matching rule decides the image (the response `Message` says which one was used): -1. **UI part** — the root is a RectTransform with no enabled Canvas of its own, or its own Canvas is World Space. The image is sized to the union of all active RectTransforms, so children that overflow the root are included, and nested Canvases inside the part are drawn with it. Sizes are counted in UI units, so a world-space Canvas saved at a small scale keeps its pixel size. A stretched root fills the Game View size. -2. **Screen UI** — the root itself has an enabled Screen Space Canvas (Overlay or Camera), or the root is not a RectTransform and contains one. The outermost Canvases are switched to the capture camera and the image is the Game View size, so the layout matches the game. -3. **Flat object** — content with no depth (sprites, quads, a lone world-space Canvas) is viewed straight on with an orthographic camera at the Game View size. +1. **UI part** — the root is a RectTransform with no enabled Canvas of its own, or its own Canvas is World Space. The image is sized to the union of all active RectTransforms, so children that overflow the root are included, and nested Canvases inside the part are drawn with it. Sizes are counted in UI units, so a world-space Canvas saved at a small scale keeps its pixel size. A stretched root fills the Game View size. The temporary Canvas that hosts the part sits on the built-in UI layer. +2. **Screen UI** — the root itself has an enabled Screen Space Canvas (Overlay or Camera), or the root is not a RectTransform and contains one. The outermost Canvases are switched to the capture camera and the image is the Game View size, so the layout matches the game. The prefab's own Canvases keep their layers, so a pipeline whose game cameras skip that layer draws nothing and the capture is rejected. +3. **Flat object** — content with no depth (sprites, quads, a lone world-space Canvas) is viewed straight on with an orthographic camera at the Game View size. A lone world-space Canvas is drawn on its own layer, so a pipeline whose game cameras skip that layer draws nothing and the capture is rejected. 4. **3D object** — anything else with an enabled Renderer is viewed from the front-left with a perspective camera at the Game View size, with the whole object in frame. -A prefab with nothing to render is rejected, including one whose renderers have empty bounds, such as a particle system that has not emitted. When a prefab has both Screen Space UI and 3D content, the UI rule wins and the 3D content may not appear. +Prefabs with visible renderers (rules 3 and 4) are drawn with a preview camera, the kind Inspector previews use, so they appear even in pipelines whose game cameras draw only certain layers. Preview cameras do not draw uGUI, so in a prefab that mixes renderers and a world-space Canvas, the Canvas does not appear. + +A prefab with nothing to render is rejected, including one whose renderers have empty bounds, such as a particle system that has not emitted. A render in which nothing appears (every pixel transparent or the background color) is rejected too; for a prefab that is a single flat color, pass a different `--prefab-background-color`. When a prefab has both Screen Space UI and 3D content, the UI rule wins and the 3D content may not appear. The Game View size comes from the current Game View; change it with `uloop set-game-view-size`. @@ -25,7 +27,7 @@ The Game View size comes from the current Game View; change it with `uloop set-g - `--window-name` and `--match-mode` are ignored. - `--capture-mode` other than `auto`, `--annotate-elements`, `--annotate-raycast-grid`, `--raycast-layer-mask`, and `--elements-only` are rejected. - `--resolution-scale` shrinks the image after it is rendered. -- `--prefab-background-color` takes `#RRGGBB` or `#RRGGBBAA`. The default `#808080` keeps both dark and white text readable. `#00000000` gives a transparent background with URP; whether alpha survives depends on the render pipeline. +- `--prefab-background-color` takes `#RRGGBB` or `#RRGGBBAA`. The default `#808080` keeps both dark and white text readable. An opaque color is also composited after rendering under pixels the pipeline left unpainted. `#00000000` gives a transparent background with URP; transparent and translucent backgrounds depend on the render pipeline. - `--prefab-background-color` without `--prefab-path` is rejected. ## What happens to the instance diff --git a/.claude/skills/uloop-screenshot/references/prefab-capture.md b/.claude/skills/uloop-screenshot/references/prefab-capture.md index 1f2a20e7dd..7faf2e5f07 100644 --- a/.claude/skills/uloop-screenshot/references/prefab-capture.md +++ b/.claude/skills/uloop-screenshot/references/prefab-capture.md @@ -11,12 +11,14 @@ uloop screenshot --prefab-path Assets/UI/Panel.prefab --prefab-background-color The first matching rule decides the image (the response `Message` says which one was used): -1. **UI part** — the root is a RectTransform with no enabled Canvas of its own, or its own Canvas is World Space. The image is sized to the union of all active RectTransforms, so children that overflow the root are included, and nested Canvases inside the part are drawn with it. Sizes are counted in UI units, so a world-space Canvas saved at a small scale keeps its pixel size. A stretched root fills the Game View size. -2. **Screen UI** — the root itself has an enabled Screen Space Canvas (Overlay or Camera), or the root is not a RectTransform and contains one. The outermost Canvases are switched to the capture camera and the image is the Game View size, so the layout matches the game. -3. **Flat object** — content with no depth (sprites, quads, a lone world-space Canvas) is viewed straight on with an orthographic camera at the Game View size. +1. **UI part** — the root is a RectTransform with no enabled Canvas of its own, or its own Canvas is World Space. The image is sized to the union of all active RectTransforms, so children that overflow the root are included, and nested Canvases inside the part are drawn with it. Sizes are counted in UI units, so a world-space Canvas saved at a small scale keeps its pixel size. A stretched root fills the Game View size. The temporary Canvas that hosts the part sits on the built-in UI layer. +2. **Screen UI** — the root itself has an enabled Screen Space Canvas (Overlay or Camera), or the root is not a RectTransform and contains one. The outermost Canvases are switched to the capture camera and the image is the Game View size, so the layout matches the game. The prefab's own Canvases keep their layers, so a pipeline whose game cameras skip that layer draws nothing and the capture is rejected. +3. **Flat object** — content with no depth (sprites, quads, a lone world-space Canvas) is viewed straight on with an orthographic camera at the Game View size. A lone world-space Canvas is drawn on its own layer, so a pipeline whose game cameras skip that layer draws nothing and the capture is rejected. 4. **3D object** — anything else with an enabled Renderer is viewed from the front-left with a perspective camera at the Game View size, with the whole object in frame. -A prefab with nothing to render is rejected, including one whose renderers have empty bounds, such as a particle system that has not emitted. When a prefab has both Screen Space UI and 3D content, the UI rule wins and the 3D content may not appear. +Prefabs with visible renderers (rules 3 and 4) are drawn with a preview camera, the kind Inspector previews use, so they appear even in pipelines whose game cameras draw only certain layers. Preview cameras do not draw uGUI, so in a prefab that mixes renderers and a world-space Canvas, the Canvas does not appear. + +A prefab with nothing to render is rejected, including one whose renderers have empty bounds, such as a particle system that has not emitted. A render in which nothing appears (every pixel transparent or the background color) is rejected too; for a prefab that is a single flat color, pass a different `--prefab-background-color`. When a prefab has both Screen Space UI and 3D content, the UI rule wins and the 3D content may not appear. The Game View size comes from the current Game View; change it with `uloop set-game-view-size`. @@ -25,7 +27,7 @@ The Game View size comes from the current Game View; change it with `uloop set-g - `--window-name` and `--match-mode` are ignored. - `--capture-mode` other than `auto`, `--annotate-elements`, `--annotate-raycast-grid`, `--raycast-layer-mask`, and `--elements-only` are rejected. - `--resolution-scale` shrinks the image after it is rendered. -- `--prefab-background-color` takes `#RRGGBB` or `#RRGGBBAA`. The default `#808080` keeps both dark and white text readable. `#00000000` gives a transparent background with URP; whether alpha survives depends on the render pipeline. +- `--prefab-background-color` takes `#RRGGBB` or `#RRGGBBAA`. The default `#808080` keeps both dark and white text readable. An opaque color is also composited after rendering under pixels the pipeline left unpainted. `#00000000` gives a transparent background with URP; transparent and translucent backgrounds depend on the render pipeline. - `--prefab-background-color` without `--prefab-path` is rejected. ## What happens to the instance diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/Skill/references/prefab-capture.md b/Packages/src/Editor/FirstPartyTools/Screenshot/Skill/references/prefab-capture.md index 1f2a20e7dd..7faf2e5f07 100644 --- a/Packages/src/Editor/FirstPartyTools/Screenshot/Skill/references/prefab-capture.md +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/Skill/references/prefab-capture.md @@ -11,12 +11,14 @@ uloop screenshot --prefab-path Assets/UI/Panel.prefab --prefab-background-color The first matching rule decides the image (the response `Message` says which one was used): -1. **UI part** — the root is a RectTransform with no enabled Canvas of its own, or its own Canvas is World Space. The image is sized to the union of all active RectTransforms, so children that overflow the root are included, and nested Canvases inside the part are drawn with it. Sizes are counted in UI units, so a world-space Canvas saved at a small scale keeps its pixel size. A stretched root fills the Game View size. -2. **Screen UI** — the root itself has an enabled Screen Space Canvas (Overlay or Camera), or the root is not a RectTransform and contains one. The outermost Canvases are switched to the capture camera and the image is the Game View size, so the layout matches the game. -3. **Flat object** — content with no depth (sprites, quads, a lone world-space Canvas) is viewed straight on with an orthographic camera at the Game View size. +1. **UI part** — the root is a RectTransform with no enabled Canvas of its own, or its own Canvas is World Space. The image is sized to the union of all active RectTransforms, so children that overflow the root are included, and nested Canvases inside the part are drawn with it. Sizes are counted in UI units, so a world-space Canvas saved at a small scale keeps its pixel size. A stretched root fills the Game View size. The temporary Canvas that hosts the part sits on the built-in UI layer. +2. **Screen UI** — the root itself has an enabled Screen Space Canvas (Overlay or Camera), or the root is not a RectTransform and contains one. The outermost Canvases are switched to the capture camera and the image is the Game View size, so the layout matches the game. The prefab's own Canvases keep their layers, so a pipeline whose game cameras skip that layer draws nothing and the capture is rejected. +3. **Flat object** — content with no depth (sprites, quads, a lone world-space Canvas) is viewed straight on with an orthographic camera at the Game View size. A lone world-space Canvas is drawn on its own layer, so a pipeline whose game cameras skip that layer draws nothing and the capture is rejected. 4. **3D object** — anything else with an enabled Renderer is viewed from the front-left with a perspective camera at the Game View size, with the whole object in frame. -A prefab with nothing to render is rejected, including one whose renderers have empty bounds, such as a particle system that has not emitted. When a prefab has both Screen Space UI and 3D content, the UI rule wins and the 3D content may not appear. +Prefabs with visible renderers (rules 3 and 4) are drawn with a preview camera, the kind Inspector previews use, so they appear even in pipelines whose game cameras draw only certain layers. Preview cameras do not draw uGUI, so in a prefab that mixes renderers and a world-space Canvas, the Canvas does not appear. + +A prefab with nothing to render is rejected, including one whose renderers have empty bounds, such as a particle system that has not emitted. A render in which nothing appears (every pixel transparent or the background color) is rejected too; for a prefab that is a single flat color, pass a different `--prefab-background-color`. When a prefab has both Screen Space UI and 3D content, the UI rule wins and the 3D content may not appear. The Game View size comes from the current Game View; change it with `uloop set-game-view-size`. @@ -25,7 +27,7 @@ The Game View size comes from the current Game View; change it with `uloop set-g - `--window-name` and `--match-mode` are ignored. - `--capture-mode` other than `auto`, `--annotate-elements`, `--annotate-raycast-grid`, `--raycast-layer-mask`, and `--elements-only` are rejected. - `--resolution-scale` shrinks the image after it is rendered. -- `--prefab-background-color` takes `#RRGGBB` or `#RRGGBBAA`. The default `#808080` keeps both dark and white text readable. `#00000000` gives a transparent background with URP; whether alpha survives depends on the render pipeline. +- `--prefab-background-color` takes `#RRGGBB` or `#RRGGBBAA`. The default `#808080` keeps both dark and white text readable. An opaque color is also composited after rendering under pixels the pipeline left unpainted. `#00000000` gives a transparent background with URP; transparent and translucent backgrounds depend on the render pipeline. - `--prefab-background-color` without `--prefab-path` is rejected. ## What happens to the instance From 2003d1bdc29c635e13576652542c2aa4d4ea852b Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 22 Oct 2026 21:04:17 +0900 Subject: [PATCH 16/18] feat: prefab renderer can measure whether the pipeline clears the capture camera A probe render with a culling mask of 0 shows only what the render pipeline itself paints. When every pixel comes back opaque, the pipeline clears the camera to its background color, which decides how translucent pixels must be treated afterwards. The render target is now cleared to transparent before rendering because a new RenderTexture holds undefined contents. The culling mask is restored in finally so the real render still draws the prefab. --- .../Editor/PrefabPreviewRendererTests.cs | 65 +++++++++++++++++++ .../Editor/PrefabScreenshotUseCaseTests.cs | 5 ++ .../Screenshot/PrefabPreviewRenderer.cs | 44 +++++++++++++ 3 files changed, 114 insertions(+) diff --git a/Assets/Tests/Editor/PrefabPreviewRendererTests.cs b/Assets/Tests/Editor/PrefabPreviewRendererTests.cs index fffb563362..71e64a5aab 100644 --- a/Assets/Tests/Editor/PrefabPreviewRendererTests.cs +++ b/Assets/Tests/Editor/PrefabPreviewRendererTests.cs @@ -55,5 +55,70 @@ public void Render_ReturnsTheRequestedSizeAndRestoresRenderState() EditorSceneManager.ClosePreviewScene(scene); } } + + /// + /// Verifies a camera cleared to an opaque solid color is reported as cleared by the pipeline. + /// + [Test] + public void ClearsBackground_WithACameraClearedToAnOpaqueColor_ReturnsTrue() + { + IgnoreWithoutARealGpu(); + Scene scene = EditorSceneManager.NewPreviewScene(); + try + { + Camera camera = CreateSolidColorCamera(scene); + + bool clears = new PrefabPreviewRenderer().ClearsBackground(camera, new Vector2Int(16, 16)); + + Assert.That(clears, Is.True); + } + finally + { + EditorSceneManager.ClosePreviewScene(scene); + } + } + + /// + /// Verifies the probe render leaves the camera's culling mask as it found it, so the real render still draws the prefab. + /// + [Test] + public void ClearsBackground_RestoresTheCullingMask() + { + IgnoreWithoutARealGpu(); + Scene scene = EditorSceneManager.NewPreviewScene(); + try + { + Camera camera = CreateSolidColorCamera(scene); + camera.cullingMask = 1 << 5; + + new PrefabPreviewRenderer().ClearsBackground(camera, new Vector2Int(16, 16)); + + Assert.That(camera.cullingMask, Is.EqualTo(1 << 5)); + } + finally + { + EditorSceneManager.ClosePreviewScene(scene); + } + } + + private static void IgnoreWithoutARealGpu() + { + GraphicsDeviceType device = SystemInfo.graphicsDeviceType; + if (device == GraphicsDeviceType.Null || device == GraphicsDeviceType.OpenGLCore) + { + Assert.Ignore("Reading rendered pixels needs a real GPU; skipped on the Null and OpenGLCore devices."); + } + } + + private static Camera CreateSolidColorCamera(Scene scene) + { + GameObject cameraObject = new("Camera"); + SceneManager.MoveGameObjectToScene(cameraObject, scene); + Camera camera = cameraObject.AddComponent(); + camera.scene = scene; + camera.clearFlags = CameraClearFlags.SolidColor; + camera.backgroundColor = new Color(0.5f, 0.5f, 0.5f, 1f); + return camera; + } } } diff --git a/Assets/Tests/Editor/PrefabScreenshotUseCaseTests.cs b/Assets/Tests/Editor/PrefabScreenshotUseCaseTests.cs index c25c25d9c7..0f7add7e8d 100644 --- a/Assets/Tests/Editor/PrefabScreenshotUseCaseTests.cs +++ b/Assets/Tests/Editor/PrefabScreenshotUseCaseTests.cs @@ -438,6 +438,11 @@ public Texture2D Render(Camera camera, Vector2Int size) return LastReturnedTexture; } + public bool ClearsBackground(Camera camera, Vector2Int size) + { + return false; + } + // Transparent everywhere except one opaque red pixel in the middle, unless a blank or background-only image is asked for. private Color32[] CreatePixels(Camera camera, Vector2Int size) { diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabPreviewRenderer.cs b/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabPreviewRenderer.cs index a94896192d..dd029e0026 100644 --- a/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabPreviewRenderer.cs +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabPreviewRenderer.cs @@ -8,6 +8,12 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools internal interface IPrefabPreviewRenderer { Texture2D Render(Camera camera, Vector2Int size); + + /// + /// Renders the camera with nothing visible and returns true when the pipeline painted every pixel opaque, + /// which means it clears this camera to its background color. + /// + bool ClearsBackground(Camera camera, Vector2Int size); } /// @@ -16,6 +22,41 @@ internal interface IPrefabPreviewRenderer internal sealed class PrefabPreviewRenderer : IPrefabPreviewRenderer { public Texture2D Render(Camera camera, Vector2Int size) + { + return RenderToTexture(camera, size); + } + + public bool ClearsBackground(Camera camera, Vector2Int size) + { + int savedMask = camera.cullingMask; + // A culling mask of 0 hides the prefab, so the probe shows only what the pipeline itself paints. + camera.cullingMask = 0; + Texture2D probe = null; + try + { + probe = RenderToTexture(camera, size); + foreach (Color32 pixel in probe.GetPixels32()) + { + if (pixel.a != 255) + { + return false; + } + } + + return true; + } + finally + { + // Restored so the real render that follows draws the prefab. + camera.cullingMask = savedMask; + if (probe != null) + { + Object.DestroyImmediate(probe); + } + } + } + + private static Texture2D RenderToTexture(Camera camera, Vector2Int size) { RenderTextureDescriptor descriptor = new(size.x, size.y, RenderTextureFormat.ARGB32, 24); // Keeps PNG values equal to the requested sRGB colors in Linear projects; Gamma projects ignore it. @@ -24,6 +65,9 @@ public Texture2D Render(Camera camera, Vector2Int size) RenderTexture previousActive = RenderTexture.active; try { + // A new RenderTexture holds undefined contents, so pixels the pipeline does not paint are made alpha 0 first. + RenderTexture.active = target; + GL.Clear(true, true, Color.clear); camera.targetTexture = target; // Screen-space-camera Canvases lay out against the camera's pixel size, so they update after the target is set. Canvas.ForceUpdateCanvases(); From 960b62aaa23520c7a5c4e0903136e1fcd305aefa Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 22 Oct 2026 21:07:44 +0900 Subject: [PATCH 17/18] fix: prefab screenshots no longer double the background in pipelines that clear the camera In a pipeline that clears the capture camera to its background color, a shader without a separate alpha blend leaves pixels whose color is already blended over the background but whose alpha stays below 255. Compositing the background again brightened them. Whether the pipeline clears cannot be read from the image, because a full-screen translucent dimmer leaves no transparent pixel either way, so it is measured with a probe render before the real one: when the pipeline clears, the drawn colors are only made opaque; otherwise the background is composited as before. The probe runs only for an opaque background, the only case that composites. Tests also pin that the use case passes the project's color space to the compositing step. --- Assets/Tests/Editor/PrefabImagePixelsTests.cs | 15 +++++ .../Editor/PrefabScreenshotUseCaseTests.cs | 65 ++++++++++++++++++- .../Screenshot/PrefabImagePixels.cs | 11 ++++ .../Screenshot/PrefabScreenshotUseCase.cs | 19 +++++- 4 files changed, 105 insertions(+), 5 deletions(-) diff --git a/Assets/Tests/Editor/PrefabImagePixelsTests.cs b/Assets/Tests/Editor/PrefabImagePixelsTests.cs index 2b42cd0755..0d0fb22653 100644 --- a/Assets/Tests/Editor/PrefabImagePixelsTests.cs +++ b/Assets/Tests/Editor/PrefabImagePixelsTests.cs @@ -127,6 +127,21 @@ public void FillBackground_WithATranslucentBackground_Throws() () => PrefabImagePixels.FillBackground(pixels, new Color32(128, 128, 128, 254), false)); } + /// + /// Verifies MakeOpaque raises alpha to 255 without changing color, and leaves an opaque pixel unchanged. + /// + [Test] + public void MakeOpaque_KeepsColorAndSetsAlphaTo255() + { + Color32 red = new(255, 0, 0, 255); + Color32[] pixels = { new Color32(192, 192, 192, 191), red }; + + PrefabImagePixels.MakeOpaque(pixels); + + AssertColor32Near(pixels[0], new Color32(192, 192, 192, 255), 0); + AssertColor32Near(pixels[1], red, 0); + } + private static Color32[] Filled(Color32 color, int count) { Color32[] pixels = new Color32[count]; diff --git a/Assets/Tests/Editor/PrefabScreenshotUseCaseTests.cs b/Assets/Tests/Editor/PrefabScreenshotUseCaseTests.cs index 0f7add7e8d..272edc3b19 100644 --- a/Assets/Tests/Editor/PrefabScreenshotUseCaseTests.cs +++ b/Assets/Tests/Editor/PrefabScreenshotUseCaseTests.cs @@ -265,12 +265,63 @@ public void CaptureAsync_WithATransparentBackground_KeepsUndrawnPixelsTransparen ScreenshotSchema request = CreateRequest(UiPartPath); request.PrefabBackgroundColor = "#00000000"; - ScreenshotResponse response = Capture(new RecordingRenderer(), request); + RecordingRenderer renderer = new(); + + ScreenshotResponse response = Capture(renderer, request); Texture2D image = LoadSavedImage(response); try { Assert.That(((Color32)image.GetPixel(0, 0)).a, Is.EqualTo(0)); + Assert.That(renderer.ClearsBackgroundCount, Is.EqualTo(0)); + } + finally + { + UnityEngine.Object.DestroyImmediate(image); + } + } + + /// + /// Verifies that when the pipeline does not clear, a translucent pixel is composited over the opaque background in linear space. + /// + [Test] + public void CaptureAsync_WhenThePipelineDoesNotClear_CompositesInLinearSpace() + { + Assume.That(QualitySettings.activeColorSpace, Is.EqualTo(ColorSpace.Linear)); + RecordingRenderer renderer = new() { ExtraPixel = new Color32(188, 188, 188, 128) }; + + ScreenshotResponse response = Capture(renderer, CreateRequest(UiPartPath)); + + Texture2D image = LoadSavedImage(response); + try + { + AssertColor32Near(image.GetPixel(1, 1), new Color32(205, 205, 205, 255), 1); + } + finally + { + UnityEngine.Object.DestroyImmediate(image); + } + } + + /// + /// Verifies that when the pipeline clears to the background, drawn colors are kept as they are and only made opaque. + /// + [Test] + public void CaptureAsync_WhenThePipelineClears_KeepsTheDrawnColorAndMakesItOpaque() + { + RecordingRenderer renderer = new() + { + ClearsBackgroundResult = true, + ExtraPixel = new Color32(192, 192, 192, 191) + }; + + ScreenshotResponse response = Capture(renderer, CreateRequest(UiPartPath)); + + Texture2D image = LoadSavedImage(response); + try + { + AssertColor32Near(image.GetPixel(1, 1), new Color32(192, 192, 192, 255), 0); + AssertColor32Near(image.GetPixel(image.width / 2, image.height / 2), new Color32(255, 0, 0, 255), 0); } finally { @@ -414,7 +465,10 @@ private sealed class RecordingRenderer : IPrefabPreviewRenderer public bool ThrowOnRender { get; set; } public bool ReturnBlank { get; set; } public bool FillWithCameraBackground { get; set; } + public bool ClearsBackgroundResult { get; set; } + public Color32? ExtraPixel { get; set; } public int RenderCount { get; private set; } + public int ClearsBackgroundCount { get; private set; } public Texture2D LastReturnedTexture { get; private set; } public Color CameraBackgroundColor { get; private set; } public CameraClearFlags CameraClearFlags { get; private set; } @@ -438,9 +492,11 @@ public Texture2D Render(Camera camera, Vector2Int size) return LastReturnedTexture; } + // A renderer that paints the camera background stands for a pipeline that clears, so it always reports true. public bool ClearsBackground(Camera camera, Vector2Int size) { - return false; + ClearsBackgroundCount++; + return ClearsBackgroundResult || FillWithCameraBackground; } // Transparent everywhere except one opaque red pixel in the middle, unless a blank or background-only image is asked for. @@ -458,6 +514,11 @@ private Color32[] CreatePixels(Camera camera, Vector2Int size) pixels[size.y / 2 * size.x + size.x / 2] = new Color32(255, 0, 0, 255); } + if (ExtraPixel.HasValue) + { + pixels[1 * size.x + 1] = ExtraPixel.Value; + } + return pixels; } } diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabImagePixels.cs b/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabImagePixels.cs index e108706e27..a68a9fcf87 100644 --- a/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabImagePixels.cs +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabImagePixels.cs @@ -60,6 +60,17 @@ internal static void FillBackground(Color32[] pixels, Color32 background, bool l } } + /// + /// Sets every pixel fully opaque without changing its color; used when the pipeline already blended everything over the background. + /// + internal static void MakeOpaque(Color32[] pixels) + { + for (int i = 0; i < pixels.Length; i++) + { + pixels[i].a = 255; + } + } + private static byte Over(byte source, byte background, float coverage, bool linearBlending) { float s = ToBlend(source / 255f, linearBlending); diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabScreenshotUseCase.cs b/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabScreenshotUseCase.cs index 24ca3f49fa..a7013789de 100644 --- a/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabScreenshotUseCase.cs +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabScreenshotUseCase.cs @@ -60,8 +60,11 @@ internal Task CaptureAsync(ScreenshotSchema request, Cancell Camera camera = CreateCamera(scene, background); GameObject instance = (GameObject)PrefabUtility.InstantiatePrefab(asset, scene); PrefabCaptureFrame frame = PrefabCaptureFraming.Arrange(instance, camera, gameViewSize); + // Only an opaque background is composited, so only then is the extra probe render worth it. + // The probe runs before the real render so the decision rests on the pipeline, never on the image content. + bool pipelineClears = background.a >= 1f && _renderer.ClearsBackground(camera, frame.ImageSize); texture = _renderer.Render(camera, frame.ImageSize); - CheckAndFillBackground(texture, background, request.PrefabPath); + CheckAndFillBackground(texture, background, request.PrefabPath, pipelineClears); if (!Mathf.Approximately(request.ResolutionScale, 1f)) { // ApplyResolutionScaling destroys the original only after it succeeds, so finally destroys exactly one texture. @@ -93,7 +96,7 @@ internal Task CaptureAsync(ScreenshotSchema request, Cancell } } - private static void CheckAndFillBackground(Texture2D texture, Color background, string prefabPath) + private static void CheckAndFillBackground(Texture2D texture, Color background, string prefabPath, bool pipelineClears) { Color32[] pixels = texture.GetPixels32(); if (!PrefabImagePixels.HasDrawnPixel(pixels, background)) @@ -109,7 +112,17 @@ private static void CheckAndFillBackground(Texture2D texture, Color background, return; } - PrefabImagePixels.FillBackground(pixels, background, QualitySettings.activeColorSpace == ColorSpace.Linear); + if (pipelineClears) + { + // The GPU already blended every pixel over the background; only alpha can be off, + // because shaders without a separate alpha blend leave it below 255. + PrefabImagePixels.MakeOpaque(pixels); + } + else + { + PrefabImagePixels.FillBackground(pixels, background, QualitySettings.activeColorSpace == ColorSpace.Linear); + } + texture.SetPixels32(pixels); texture.Apply(); } From 1cf1c46cb05ee8aa8c13b09ab0f0b610253bf8dd Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 22 Oct 2026 21:10:35 +0900 Subject: [PATCH 18/18] docs: explain prefab background handling per pipeline and what a prefab capture cannot show The background paragraph now says that drawn colors are only made opaque when the pipeline clears the capture camera, and that the background is composited under unpainted and translucent pixels otherwise. A new limitation notes that the prefab is captured right after instantiation, so content scripts assign later and post-processing such as bloom do not appear. --- .agents/skills/uloop-screenshot/references/prefab-capture.md | 3 ++- .claude/skills/uloop-screenshot/references/prefab-capture.md | 3 ++- .../Screenshot/Skill/references/prefab-capture.md | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.agents/skills/uloop-screenshot/references/prefab-capture.md b/.agents/skills/uloop-screenshot/references/prefab-capture.md index 7faf2e5f07..11f348b143 100644 --- a/.agents/skills/uloop-screenshot/references/prefab-capture.md +++ b/.agents/skills/uloop-screenshot/references/prefab-capture.md @@ -27,7 +27,7 @@ The Game View size comes from the current Game View; change it with `uloop set-g - `--window-name` and `--match-mode` are ignored. - `--capture-mode` other than `auto`, `--annotate-elements`, `--annotate-raycast-grid`, `--raycast-layer-mask`, and `--elements-only` are rejected. - `--resolution-scale` shrinks the image after it is rendered. -- `--prefab-background-color` takes `#RRGGBB` or `#RRGGBBAA`. The default `#808080` keeps both dark and white text readable. An opaque color is also composited after rendering under pixels the pipeline left unpainted. `#00000000` gives a transparent background with URP; transparent and translucent backgrounds depend on the render pipeline. +- `--prefab-background-color` takes `#RRGGBB` or `#RRGGBBAA`. The default `#808080` keeps both dark and white text readable. When the render pipeline clears the capture camera to the background color, the drawn colors are kept and only made opaque. When it does not, the background is composited after rendering under unpainted and translucent pixels. `#00000000` gives a transparent background with URP; transparent and translucent backgrounds depend on the render pipeline. - `--prefab-background-color` without `--prefab-path` is rejected. ## What happens to the instance @@ -35,6 +35,7 @@ The Game View size comes from the current Game View; change it with `uloop set-g - If the prefab has no enabled Light, one directional light is added to the preview scene. - A root saved inactive is activated so it can be captured; children keep their saved state. - Inside the preview scene, scripts other than `[ExecuteAlways]` ones do not run, even in Play Mode. uGUI draws normally. +- The prefab is rendered right after it is instantiated, so content that scripts assign later (asynchronous loads, Start, or Update) is missing, and post-processing such as bloom is not applied. ## Output diff --git a/.claude/skills/uloop-screenshot/references/prefab-capture.md b/.claude/skills/uloop-screenshot/references/prefab-capture.md index 7faf2e5f07..11f348b143 100644 --- a/.claude/skills/uloop-screenshot/references/prefab-capture.md +++ b/.claude/skills/uloop-screenshot/references/prefab-capture.md @@ -27,7 +27,7 @@ The Game View size comes from the current Game View; change it with `uloop set-g - `--window-name` and `--match-mode` are ignored. - `--capture-mode` other than `auto`, `--annotate-elements`, `--annotate-raycast-grid`, `--raycast-layer-mask`, and `--elements-only` are rejected. - `--resolution-scale` shrinks the image after it is rendered. -- `--prefab-background-color` takes `#RRGGBB` or `#RRGGBBAA`. The default `#808080` keeps both dark and white text readable. An opaque color is also composited after rendering under pixels the pipeline left unpainted. `#00000000` gives a transparent background with URP; transparent and translucent backgrounds depend on the render pipeline. +- `--prefab-background-color` takes `#RRGGBB` or `#RRGGBBAA`. The default `#808080` keeps both dark and white text readable. When the render pipeline clears the capture camera to the background color, the drawn colors are kept and only made opaque. When it does not, the background is composited after rendering under unpainted and translucent pixels. `#00000000` gives a transparent background with URP; transparent and translucent backgrounds depend on the render pipeline. - `--prefab-background-color` without `--prefab-path` is rejected. ## What happens to the instance @@ -35,6 +35,7 @@ The Game View size comes from the current Game View; change it with `uloop set-g - If the prefab has no enabled Light, one directional light is added to the preview scene. - A root saved inactive is activated so it can be captured; children keep their saved state. - Inside the preview scene, scripts other than `[ExecuteAlways]` ones do not run, even in Play Mode. uGUI draws normally. +- The prefab is rendered right after it is instantiated, so content that scripts assign later (asynchronous loads, Start, or Update) is missing, and post-processing such as bloom is not applied. ## Output diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/Skill/references/prefab-capture.md b/Packages/src/Editor/FirstPartyTools/Screenshot/Skill/references/prefab-capture.md index 7faf2e5f07..11f348b143 100644 --- a/Packages/src/Editor/FirstPartyTools/Screenshot/Skill/references/prefab-capture.md +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/Skill/references/prefab-capture.md @@ -27,7 +27,7 @@ The Game View size comes from the current Game View; change it with `uloop set-g - `--window-name` and `--match-mode` are ignored. - `--capture-mode` other than `auto`, `--annotate-elements`, `--annotate-raycast-grid`, `--raycast-layer-mask`, and `--elements-only` are rejected. - `--resolution-scale` shrinks the image after it is rendered. -- `--prefab-background-color` takes `#RRGGBB` or `#RRGGBBAA`. The default `#808080` keeps both dark and white text readable. An opaque color is also composited after rendering under pixels the pipeline left unpainted. `#00000000` gives a transparent background with URP; transparent and translucent backgrounds depend on the render pipeline. +- `--prefab-background-color` takes `#RRGGBB` or `#RRGGBBAA`. The default `#808080` keeps both dark and white text readable. When the render pipeline clears the capture camera to the background color, the drawn colors are kept and only made opaque. When it does not, the background is composited after rendering under unpainted and translucent pixels. `#00000000` gives a transparent background with URP; transparent and translucent backgrounds depend on the render pipeline. - `--prefab-background-color` without `--prefab-path` is rejected. ## What happens to the instance @@ -35,6 +35,7 @@ The Game View size comes from the current Game View; change it with `uloop set-g - If the prefab has no enabled Light, one directional light is added to the preview scene. - A root saved inactive is activated so it can be captured; children keep their saved state. - Inside the preview scene, scripts other than `[ExecuteAlways]` ones do not run, even in Play Mode. uGUI draws normally. +- The prefab is rendered right after it is instantiated, so content that scripts assign later (asynchronous loads, Start, or Update) is missing, and post-processing such as bloom is not applied. ## Output