From ff153db525a327dee25573eff8c7ee0fc29873b0 Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 27 Oct 2026 11:31:21 +0900 Subject: [PATCH 1/4] fix: prefab screenshots of UI parts leave out what masks and the root Canvas hide A UI-part prefab was framed to the union of every active RectTransform, so content that the game never shows still widened the image. A World Space Canvas root with a child image larger than the Canvas came out at the child's size with the Canvas content small in the middle, and overflow under a RectMask2D widened it too. The frame now leaves out what uGUI hides: the part of each element outside an enabled RectMask2D or a Mask with an active Graphic, and anything outside a root that is itself a World Space Canvas with an area. Masks follow uGUI's reach rules, so an unmaskable Graphic or one behind a sorting-override Canvas keeps its full rect. A zero-sized root Canvas does not bound anything, and zero-sized rects that are not clipped away still count as before. --- .../references/prefab-capture.md | 2 +- .../references/prefab-capture.md | 2 +- .../Tests/Editor/PrefabCaptureFramingTests.cs | 227 ++++++++++++++++++ .../Screenshot/PrefabCaptureFraming.cs | 146 ++++++++++- .../Skill/references/prefab-capture.md | 2 +- 5 files changed, 365 insertions(+), 14 deletions(-) diff --git a/.agents/skills/uloop-screenshot/references/prefab-capture.md b/.agents/skills/uloop-screenshot/references/prefab-capture.md index 11f348b14..4c4e26031 100644 --- a/.agents/skills/uloop-screenshot/references/prefab-capture.md +++ b/.agents/skills/uloop-screenshot/references/prefab-capture.md @@ -11,7 +11,7 @@ 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. The temporary Canvas that hosts the part sits on the built-in UI layer. +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. Overflow that an enabled RectMask2D, or a Mask with a Graphic, clips is left out (the mask's rect is used; RectMask2D padding is ignored). When the root is itself an enabled World Space Canvas with a non-zero size, the image is the Canvas's own size and content outside it is cut off. 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. diff --git a/.claude/skills/uloop-screenshot/references/prefab-capture.md b/.claude/skills/uloop-screenshot/references/prefab-capture.md index 11f348b14..4c4e26031 100644 --- a/.claude/skills/uloop-screenshot/references/prefab-capture.md +++ b/.claude/skills/uloop-screenshot/references/prefab-capture.md @@ -11,7 +11,7 @@ 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. The temporary Canvas that hosts the part sits on the built-in UI layer. +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. Overflow that an enabled RectMask2D, or a Mask with a Graphic, clips is left out (the mask's rect is used; RectMask2D padding is ignored). When the root is itself an enabled World Space Canvas with a non-zero size, the image is the Canvas's own size and content outside it is cut off. 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. diff --git a/Assets/Tests/Editor/PrefabCaptureFramingTests.cs b/Assets/Tests/Editor/PrefabCaptureFramingTests.cs index 18565466e..65c19b94a 100644 --- a/Assets/Tests/Editor/PrefabCaptureFramingTests.cs +++ b/Assets/Tests/Editor/PrefabCaptureFramingTests.cs @@ -2,6 +2,7 @@ using UnityEditor.SceneManagement; using UnityEngine; using UnityEngine.SceneManagement; +using UnityEngine.UI; using io.github.hatayama.UnityCliLoop.FirstPartyTools; using io.github.hatayama.UnityCliLoop.ToolContracts; @@ -323,6 +324,205 @@ public void Arrange_WithAScaledRootOfAnAwkwardWidth_DoesNotAddAPixel() Assert.That(frame.ImageSize, Is.EqualTo(new Vector2Int(109, 100))); } + /// + /// Verifies a root that is its own world-space Canvas is captured at the Canvas size, not widened by a child that overflows it. + /// + [Test] + public void Arrange_WithAWorldSpaceCanvasRootAndAnOverflowingChild_SizesToTheCanvas() + { + GameObject root = CreateUiPartWithOverflowingChild(out GameObject _); + root.AddComponent().renderMode = RenderMode.WorldSpace; + + 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))); + Assert.That(_camera.transform.position.x, Is.EqualTo(0f).Within(Tolerance)); + } + + /// + /// Verifies a zero-sized world-space Canvas root does not hide its children: they still decide the image size. + /// + [Test] + public void Arrange_WithAZeroSizedWorldSpaceCanvasRoot_SizesToItsChildren() + { + GameObject root = CreateUiPartWithOverflowingChild(out GameObject child); + ((RectTransform)root.transform).sizeDelta = Vector2.zero; + // The child covers the root's pivot, so the zero-sized root adds nothing beyond the child. + ((RectTransform)child.transform).anchoredPosition = new Vector2(-10f, 0f); + root.AddComponent().renderMode = RenderMode.WorldSpace; + + PrefabCaptureFrame frame = PrefabCaptureFraming.Arrange(root, _camera, GameViewSize); + + Assert.That(frame.ImageSize, Is.EqualTo(new Vector2Int(40, 20))); + Assert.That(_camera.transform.position.x, Is.EqualTo(-10f).Within(Tolerance)); + } + + /// + /// Verifies a disabled Canvas on the root does not bound the capture, so an overflowing child still widens it. + /// + [Test] + public void Arrange_WithADisabledCanvasRootAndAnOverflowingChild_IncludesTheChild() + { + GameObject root = CreateUiPartWithOverflowingChild(out GameObject _); + Canvas canvas = root.AddComponent(); + canvas.renderMode = RenderMode.WorldSpace; + canvas.enabled = false; + + PrefabCaptureFrame frame = PrefabCaptureFraming.Arrange(root, _camera, GameViewSize); + + Assert.That(frame.ImageSize, Is.EqualTo(new Vector2Int(140, 100))); + } + + /// + /// Verifies the part of a child that a RectMask2D clips away does not widen the capture. + /// + [Test] + public void Arrange_WithAChildPartlyClippedByARectMask2D_IgnoresTheClippedPart() + { + GameObject viewport = CreateUiPartWithMaskedChild(new Vector2(-60f, 0f), out GameObject root); + viewport.AddComponent(); + + PrefabCaptureFrame frame = PrefabCaptureFraming.Arrange(root, _camera, GameViewSize); + + Assert.That(frame.ImageSize, Is.EqualTo(new Vector2Int(100, 100))); + Assert.That(_camera.transform.position.x, Is.EqualTo(0f).Within(Tolerance)); + } + + /// + /// Verifies a child that lies entirely outside a RectMask2D adds nothing to the capture. + /// + [Test] + public void Arrange_WithAChildEntirelyOutsideARectMask2D_IgnoresTheChild() + { + GameObject viewport = CreateUiPartWithMaskedChild(new Vector2(-100f, 0f), out GameObject root); + viewport.AddComponent(); + + PrefabCaptureFrame frame = PrefabCaptureFraming.Arrange(root, _camera, GameViewSize); + + Assert.That(frame.ImageSize, Is.EqualTo(new Vector2Int(100, 100))); + } + + /// + /// Verifies a disabled RectMask2D clips nothing, so the overflowing child still widens the capture. + /// + [Test] + public void Arrange_WithADisabledRectMask2D_IncludesTheOverflowingChild() + { + GameObject viewport = CreateUiPartWithMaskedChild(new Vector2(-60f, 0f), out GameObject root); + viewport.AddComponent().enabled = false; + + PrefabCaptureFrame frame = PrefabCaptureFraming.Arrange(root, _camera, GameViewSize); + + Assert.That(frame.ImageSize, Is.EqualTo(new Vector2Int(130, 100))); + } + + /// + /// Verifies a stencil Mask with a Graphic clips its children like a RectMask2D does. + /// + [Test] + public void Arrange_WithAChildClippedByAMaskWithAGraphic_IgnoresTheClippedPart() + { + GameObject viewport = CreateUiPartWithMaskedChild(new Vector2(-60f, 0f), out GameObject root); + viewport.AddComponent(); + viewport.AddComponent(); + + PrefabCaptureFrame frame = PrefabCaptureFraming.Arrange(root, _camera, GameViewSize); + + Assert.That(frame.ImageSize, Is.EqualTo(new Vector2Int(100, 100))); + } + + /// + /// Verifies a Mask without a Graphic masks nothing, so the overflowing child still widens the capture. + /// + [Test] + public void Arrange_WithAMaskWithoutAGraphic_IncludesTheOverflowingChild() + { + GameObject viewport = CreateUiPartWithMaskedChild(new Vector2(-60f, 0f), out GameObject root); + viewport.AddComponent(); + + PrefabCaptureFrame frame = PrefabCaptureFraming.Arrange(root, _camera, GameViewSize); + + Assert.That(frame.ImageSize, Is.EqualTo(new Vector2Int(130, 100))); + } + + /// + /// Verifies a Mask whose Graphic is disabled masks nothing, as uGUI skips it when counting stencil depth. + /// + [Test] + public void Arrange_WithAMaskWhoseGraphicIsDisabled_IncludesTheOverflowingChild() + { + GameObject viewport = CreateUiPartWithMaskedChild(new Vector2(-60f, 0f), out GameObject root); + viewport.AddComponent().enabled = false; + viewport.AddComponent(); + + PrefabCaptureFrame frame = PrefabCaptureFraming.Arrange(root, _camera, GameViewSize); + + Assert.That(frame.ImageSize, Is.EqualTo(new Vector2Int(130, 100))); + } + + /// + /// Verifies a Graphic with maskable turned off is not clipped, because no mask reaches it in the game. + /// + [Test] + public void Arrange_WithAnUnmaskableGraphicUnderARectMask2D_IncludesTheGraphic() + { + GameObject viewport = CreateUiPartWithMaskedChild(new Vector2(-60f, 0f), out GameObject root); + viewport.AddComponent(); + viewport.transform.GetChild(0).gameObject.AddComponent().maskable = false; + + PrefabCaptureFrame frame = PrefabCaptureFraming.Arrange(root, _camera, GameViewSize); + + Assert.That(frame.ImageSize, Is.EqualTo(new Vector2Int(130, 100))); + } + + /// + /// Verifies a sorting-override Canvas on the masked element shields it from the mask above, as uGUI does. + /// + [Test] + public void Arrange_WithASortingOverrideCanvasBelowTheMask_IncludesTheOverflowingChild() + { + GameObject viewport = CreateUiPartWithMaskedChild(new Vector2(-60f, 0f), out GameObject root); + viewport.AddComponent(); + Canvas canvas = AddSortingOverrideCanvas(viewport.transform.GetChild(0).gameObject, root); + + PrefabCaptureFrame frame = PrefabCaptureFraming.Arrange(root, _camera, GameViewSize); + + Assert.That(canvas.overrideSorting, Is.True); + Assert.That(frame.ImageSize, Is.EqualTo(new Vector2Int(130, 100))); + } + + /// + /// Verifies a mask on the same object as a sorting-override Canvas still clips the children below it. + /// + [Test] + public void Arrange_WithASortingOverrideCanvasOnTheMaskObject_StillClips() + { + GameObject viewport = CreateUiPartWithMaskedChild(new Vector2(-60f, 0f), out GameObject root); + viewport.AddComponent(); + Canvas canvas = AddSortingOverrideCanvas(viewport, root); + + PrefabCaptureFrame frame = PrefabCaptureFraming.Arrange(root, _camera, GameViewSize); + + Assert.That(canvas.overrideSorting, Is.True); + Assert.That(frame.ImageSize, Is.EqualTo(new Vector2Int(100, 100))); + } + + /// + /// Verifies a RectMask2D on the root itself limits the capture to the root. + /// + [Test] + public void Arrange_WithARectMask2DOnTheRoot_SizesToTheRoot() + { + GameObject root = CreateUiPartWithOverflowingChild(out GameObject _); + root.AddComponent(); + + PrefabCaptureFrame frame = PrefabCaptureFraming.Arrange(root, _camera, GameViewSize); + + Assert.That(frame.ImageSize, Is.EqualTo(new Vector2Int(100, 100))); + Assert.That(_camera.transform.position.x, Is.EqualTo(0f).Within(Tolerance)); + } + /// /// Verifies disabled screen-space Canvases do not make a prefab screen UI. /// @@ -472,6 +672,33 @@ private GameObject CreateUiPartWithOverflowingChild(out GameObject child) return root; } + // Root 100x100 with a 100x100 viewport child; a 40x20 grandchild under the viewport sits at childPosition. + private GameObject CreateUiPartWithMaskedChild(Vector2 childPosition, out GameObject root) + { + root = CreateRectInScene("Root"); + ((RectTransform)root.transform).sizeDelta = new Vector2(100f, 100f); + GameObject viewport = new GameObject("Viewport", typeof(RectTransform)); + viewport.transform.SetParent(root.transform, false); + ((RectTransform)viewport.transform).sizeDelta = new Vector2(100f, 100f); + GameObject child = new GameObject("Child", typeof(RectTransform)); + child.transform.SetParent(viewport.transform, false); + RectTransform childRect = (RectTransform)child.transform; + childRect.sizeDelta = new Vector2(40f, 20f); + childRect.anchoredPosition = childPosition; + return viewport; + } + + // Unity keeps overrideSorting only on a nested Canvas, so a temporary Canvas on the root makes the target + // nested while the flag is set; the stored flag survives once the root Canvas is gone. + private static Canvas AddSortingOverrideCanvas(GameObject target, GameObject root) + { + Canvas temporaryRootCanvas = root.AddComponent(); + Canvas canvas = target.AddComponent(); + canvas.overrideSorting = true; + Object.DestroyImmediate(temporaryRootCanvas); + return canvas; + } + private static RectTransform CreateWorldSpaceCanvasChild(GameObject parent) { GameObject canvasObject = CreateChild(parent, "Canvas"); diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabCaptureFraming.cs b/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabCaptureFraming.cs index b731d5e11..592312c5e 100644 --- a/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabCaptureFraming.cs +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabCaptureFraming.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using UnityEngine; using UnityEngine.SceneManagement; +using UnityEngine.UI; using io.github.hatayama.UnityCliLoop.ToolContracts; @@ -197,7 +198,7 @@ private static PrefabCaptureFrame ArrangeUiPart(GameObject root, Camera camera, // LayoutGroup and ContentSizeFitter sizes must settle before the rects are measured. Canvas.ForceUpdateCanvases(); - Rect union = UnionOfWorldRects(root); + Rect union = VisibleUiArea(root); Vector3 rootScale = root.transform.lossyScale; float unitsPerPixelX = Mathf.Abs(rootScale.x); float unitsPerPixelY = Mathf.Abs(rootScale.y); @@ -244,28 +245,151 @@ private static int UiLayer() return layer; } - private static Rect UnionOfWorldRects(GameObject root) + // The union of what the game can show of every active RectTransform. Overflow that a RectMask2D or Mask + // hides is left out, and a root that is its own Canvas bounds its content to the surface its author laid out. + private static Rect VisibleUiArea(GameObject root) + { + RectTransform rootRect = (RectTransform)root.transform; + Rect? rootBound = RootCanvasBound(root); + // The root's own rect is never clipped: a mask clips its children, not itself. + Rect union = WorldRectOf(rootRect); + foreach (RectTransform rect in root.GetComponentsInChildren(false)) + { + if (rect == rootRect) + { + continue; + } + + Rect visible = ClipByAncestors(WorldRectOf(rect), rect, rootRect); + if (rootBound != null) + { + visible = Intersect(visible, rootBound.Value); + } + + // A negative extent means the rect lies wholly outside a clip; a zero extent is still a point + // inside it, which the union kept before masks were considered. + if (visible.width < 0f || visible.height < 0f) + { + continue; + } + + union = UnionOf(union, visible); + } + + return union; + } + + // Walks from the rect's parent up to and including the root, so a mask on the root clips its children too. + // Follows uGUI's own rules for which masks reach an element (MaskUtilities and MaskableGraphic), so the + // capture does not cut off content the game still draws. + private static Rect ClipByAncestors(Rect visible, RectTransform rect, RectTransform rootRect) + { + if (!IsMaskable(rect) || HasSortingOverrideCanvas(rect)) + { + return visible; + } + + for (Transform ancestor = rect.parent; ancestor != null; ancestor = ancestor.parent) + { + if (ClipsChildren(ancestor)) + { + visible = Intersect(visible, WorldRectOf((RectTransform)ancestor)); + } + + // A Canvas that overrides sorting starts a new mask scope: masks on it still apply, masks above it do not. + if (ancestor == rootRect || HasSortingOverrideCanvas(ancestor)) + { + break; + } + } + + return visible; + } + + // Masks reach only MaskableGraphics with maskable on. A RectTransform with no Graphic draws nothing itself, + // so clipping it only keeps empty containers from widening the capture. + private static bool IsMaskable(RectTransform rect) + { + if (!rect.TryGetComponent(out Graphic graphic)) + { + return true; + } + + return graphic is MaskableGraphic maskableGraphic && maskableGraphic.maskable; + } + + private static bool HasSortingOverrideCanvas(Transform transform) + { + return transform.TryGetComponent(out Canvas canvas) && canvas.overrideSorting; + } + + // A zero-sized root Canvas lays nothing out itself, so bounding by it would hide content that the + // capture showed before; only a Canvas with an area marks the authored surface. + private static Rect? RootCanvasBound(GameObject root) + { + if (!HasEnabledCanvas(root)) + { + return null; + } + + Rect rootRect = WorldRectOf((RectTransform)root.transform); + if (rootRect.width <= 0f || rootRect.height <= 0f) + { + return null; + } + + return rootRect; + } + + // A Mask clips only while its Graphic is active, because uGUI counts the stencil depth from those masks alone. + private static bool ClipsChildren(Transform transform) + { + if (transform.TryGetComponent(out RectMask2D rectMask) && rectMask.isActiveAndEnabled) + { + return true; + } + + return transform.TryGetComponent(out Mask mask) && mask.MaskEnabled() && mask.graphic.IsActive(); + } + + private static Rect WorldRectOf(RectTransform rect) { Vector3[] corners = new Vector3[4]; + rect.GetWorldCorners(corners); float minX = float.PositiveInfinity; float minY = float.PositiveInfinity; float maxX = float.NegativeInfinity; float maxY = float.NegativeInfinity; - foreach (RectTransform rect in root.GetComponentsInChildren(false)) + foreach (Vector3 corner in corners) { - 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); - } + 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); } + // Keeps negative extents when the rects do not overlap, so callers can tell "outside" from "a point". + private static Rect Intersect(Rect a, Rect b) + { + return Rect.MinMaxRect( + Mathf.Max(a.xMin, b.xMin), + Mathf.Max(a.yMin, b.yMin), + Mathf.Min(a.xMax, b.xMax), + Mathf.Min(a.yMax, b.yMax)); + } + + private static Rect UnionOf(Rect a, Rect b) + { + return Rect.MinMaxRect( + Mathf.Min(a.xMin, b.xMin), + Mathf.Min(a.yMin, b.yMin), + Mathf.Max(a.xMax, b.xMax), + Mathf.Max(a.yMax, b.yMax)); + } + private static PrefabCaptureFrame ArrangeObject(GameObject root, Camera camera, Vector2Int gameViewSize) { Bounds? rendererBounds = CollectRendererBounds(root); 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 11f348b14..4c4e26031 100644 --- a/Packages/src/Editor/FirstPartyTools/Screenshot/Skill/references/prefab-capture.md +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/Skill/references/prefab-capture.md @@ -11,7 +11,7 @@ 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. The temporary Canvas that hosts the part sits on the built-in UI layer. +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. Overflow that an enabled RectMask2D, or a Mask with a Graphic, clips is left out (the mask's rect is used; RectMask2D padding is ignored). When the root is itself an enabled World Space Canvas with a non-zero size, the image is the Canvas's own size and content outside it is cut off. 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. From 3e94b05aff03e91f0319b80bced843557999d16d Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 27 Oct 2026 11:35:50 +0900 Subject: [PATCH 2/4] test: pin that a sorting-override Canvas between a mask and an element stops the mask The existing sorting-override tests put the Canvas on the element itself or on the mask object, so dropping the stop at an override Canvas in between went unnoticed. --- .../Tests/Editor/PrefabCaptureFramingTests.cs | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/Assets/Tests/Editor/PrefabCaptureFramingTests.cs b/Assets/Tests/Editor/PrefabCaptureFramingTests.cs index 65c19b94a..adf8a5b33 100644 --- a/Assets/Tests/Editor/PrefabCaptureFramingTests.cs +++ b/Assets/Tests/Editor/PrefabCaptureFramingTests.cs @@ -492,6 +492,27 @@ public void Arrange_WithASortingOverrideCanvasBelowTheMask_IncludesTheOverflowin Assert.That(frame.ImageSize, Is.EqualTo(new Vector2Int(130, 100))); } + /// + /// Verifies a sorting-override Canvas between the mask and an element stops the mask from reaching the element. + /// + [Test] + public void Arrange_WithASortingOverrideCanvasBetweenTheMaskAndTheChild_IncludesTheOverflowingChild() + { + GameObject viewport = CreateUiPartWithMaskedChild(new Vector2(-60f, 0f), out GameObject root); + viewport.AddComponent(); + Transform child = viewport.transform.GetChild(0); + GameObject middle = new GameObject("Middle", typeof(RectTransform)); + middle.transform.SetParent(viewport.transform, false); + ((RectTransform)middle.transform).sizeDelta = new Vector2(100f, 100f); + child.SetParent(middle.transform, false); + Canvas canvas = AddSortingOverrideCanvas(middle, root); + + PrefabCaptureFrame frame = PrefabCaptureFraming.Arrange(root, _camera, GameViewSize); + + Assert.That(canvas.overrideSorting, Is.True); + Assert.That(frame.ImageSize, Is.EqualTo(new Vector2Int(130, 100))); + } + /// /// Verifies a mask on the same object as a sorting-override Canvas still clips the children below it. /// From ddd121b778b1e44cc9a8ce9add51052dcbececa2 Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 27 Oct 2026 11:41:06 +0900 Subject: [PATCH 3/4] fix: the UI-part prefab capture message no longer claims every RectTransform sets the size Masks and a World Space root Canvas now cut the frame down, so the old wording described a rule the capture no longer follows. --- .../FirstPartyTools/Screenshot/PrefabScreenshotUseCase.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabScreenshotUseCase.cs b/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabScreenshotUseCase.cs index a7013789d..d4125c204 100644 --- a/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabScreenshotUseCase.cs +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabScreenshotUseCase.cs @@ -166,7 +166,7 @@ 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.UiPart => $"Rendered {path} as a UI part, sized to the visible area of its RectTransforms (masks and a World Space root Canvas cut off what they hide).", 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.", From e4cc425185704f676a2b931ee8197105251b6c61 Mon Sep 17 00:00:00 2001 From: hatayama Date: Tue, 27 Oct 2026 11:49:33 +0900 Subject: [PATCH 4/4] fix: prefab captures move a RectMask2D's clip by its padding The clip used the RectMask2D's rect as is, but uGUI moves the edges by the padding, so a negative padding lets children draw past the rect. The capture cut that part off; it now applies the padding the way uGUI's Clipping does. --- .../references/prefab-capture.md | 2 +- .../references/prefab-capture.md | 2 +- .../Tests/Editor/PrefabCaptureFramingTests.cs | 15 ++++++++++ .../Screenshot/PrefabCaptureFraming.cs | 29 +++++++++++++++---- .../Skill/references/prefab-capture.md | 2 +- 5 files changed, 41 insertions(+), 9 deletions(-) diff --git a/.agents/skills/uloop-screenshot/references/prefab-capture.md b/.agents/skills/uloop-screenshot/references/prefab-capture.md index 4c4e26031..691bfaace 100644 --- a/.agents/skills/uloop-screenshot/references/prefab-capture.md +++ b/.agents/skills/uloop-screenshot/references/prefab-capture.md @@ -11,7 +11,7 @@ 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. Overflow that an enabled RectMask2D, or a Mask with a Graphic, clips is left out (the mask's rect is used; RectMask2D padding is ignored). When the root is itself an enabled World Space Canvas with a non-zero size, the image is the Canvas's own size and content outside it is cut off. 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. +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. Overflow that an enabled RectMask2D, or a Mask with a Graphic, clips is left out (the mask's rect, moved by RectMask2D padding as uGUI does; softness is ignored). When the root is itself an enabled World Space Canvas with a non-zero size, the image is the Canvas's own size and content outside it is cut off. 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. diff --git a/.claude/skills/uloop-screenshot/references/prefab-capture.md b/.claude/skills/uloop-screenshot/references/prefab-capture.md index 4c4e26031..691bfaace 100644 --- a/.claude/skills/uloop-screenshot/references/prefab-capture.md +++ b/.claude/skills/uloop-screenshot/references/prefab-capture.md @@ -11,7 +11,7 @@ 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. Overflow that an enabled RectMask2D, or a Mask with a Graphic, clips is left out (the mask's rect is used; RectMask2D padding is ignored). When the root is itself an enabled World Space Canvas with a non-zero size, the image is the Canvas's own size and content outside it is cut off. 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. +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. Overflow that an enabled RectMask2D, or a Mask with a Graphic, clips is left out (the mask's rect, moved by RectMask2D padding as uGUI does; softness is ignored). When the root is itself an enabled World Space Canvas with a non-zero size, the image is the Canvas's own size and content outside it is cut off. 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. diff --git a/Assets/Tests/Editor/PrefabCaptureFramingTests.cs b/Assets/Tests/Editor/PrefabCaptureFramingTests.cs index adf8a5b33..6f5cef40d 100644 --- a/Assets/Tests/Editor/PrefabCaptureFramingTests.cs +++ b/Assets/Tests/Editor/PrefabCaptureFramingTests.cs @@ -389,6 +389,21 @@ public void Arrange_WithAChildPartlyClippedByARectMask2D_IgnoresTheClippedPart() Assert.That(_camera.transform.position.x, Is.EqualTo(0f).Within(Tolerance)); } + /// + /// Verifies a negative RectMask2D padding widens the clip past the mask's rect, as uGUI draws there. + /// + [Test] + public void Arrange_WithANegativeRectMask2DPadding_KeepsWhatThePaddingLetsThrough() + { + GameObject viewport = CreateUiPartWithMaskedChild(new Vector2(-60f, 0f), out GameObject root); + viewport.AddComponent().padding = new Vector4(-20f, 0f, 0f, 0f); + + PrefabCaptureFrame frame = PrefabCaptureFraming.Arrange(root, _camera, GameViewSize); + + Assert.That(frame.ImageSize, Is.EqualTo(new Vector2Int(120, 100))); + Assert.That(_camera.transform.position.x, Is.EqualTo(-10f).Within(Tolerance)); + } + /// /// Verifies a child that lies entirely outside a RectMask2D adds nothing to the capture. /// diff --git a/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabCaptureFraming.cs b/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabCaptureFraming.cs index 592312c5e..5b29488b1 100644 --- a/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabCaptureFraming.cs +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/PrefabCaptureFraming.cs @@ -291,9 +291,10 @@ private static Rect ClipByAncestors(Rect visible, RectTransform rect, RectTransf for (Transform ancestor = rect.parent; ancestor != null; ancestor = ancestor.parent) { - if (ClipsChildren(ancestor)) + Rect? clip = ClipRectOf(ancestor); + if (clip != null) { - visible = Intersect(visible, WorldRectOf((RectTransform)ancestor)); + visible = Intersect(visible, clip.Value); } // A Canvas that overrides sorting starts a new mask scope: masks on it still apply, masks above it do not. @@ -341,15 +342,31 @@ private static bool HasSortingOverrideCanvas(Transform transform) return rootRect; } - // A Mask clips only while its Graphic is active, because uGUI counts the stencil depth from those masks alone. - private static bool ClipsChildren(Transform transform) + // The area an object lets its children show through, or null when it clips nothing. A Mask clips only while + // its Graphic is active, because uGUI counts the stencil depth from those masks alone. RectMask2D padding + // moves the edges as uGUI's Clipping does, so a negative padding lets children draw past the rect. Padding is + // in the outermost Canvas's units, which here is the capture Canvas at the world origin and scale 1. + private static Rect? ClipRectOf(Transform transform) { + Rect? clip = null; if (transform.TryGetComponent(out RectMask2D rectMask) && rectMask.isActiveAndEnabled) { - return true; + Rect rect = WorldRectOf((RectTransform)transform); + Vector4 padding = rectMask.padding; + clip = Rect.MinMaxRect( + rect.xMin + padding.x, + rect.yMin + padding.y, + rect.xMax - padding.z, + rect.yMax - padding.w); + } + + if (transform.TryGetComponent(out Mask mask) && mask.MaskEnabled() && mask.graphic.IsActive()) + { + Rect rect = WorldRectOf((RectTransform)transform); + clip = clip == null ? rect : Intersect(clip.Value, rect); } - return transform.TryGetComponent(out Mask mask) && mask.MaskEnabled() && mask.graphic.IsActive(); + return clip; } private static Rect WorldRectOf(RectTransform rect) 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 4c4e26031..691bfaace 100644 --- a/Packages/src/Editor/FirstPartyTools/Screenshot/Skill/references/prefab-capture.md +++ b/Packages/src/Editor/FirstPartyTools/Screenshot/Skill/references/prefab-capture.md @@ -11,7 +11,7 @@ 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. Overflow that an enabled RectMask2D, or a Mask with a Graphic, clips is left out (the mask's rect is used; RectMask2D padding is ignored). When the root is itself an enabled World Space Canvas with a non-zero size, the image is the Canvas's own size and content outside it is cut off. 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. +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. Overflow that an enabled RectMask2D, or a Mask with a Graphic, clips is left out (the mask's rect, moved by RectMask2D padding as uGUI does; softness is ignored). When the root is itself an enabled World Space Canvas with a non-zero size, the image is the Canvas's own size and content outside it is cut off. 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.