diff --git a/.agents/skills/uloop-screenshot/references/prefab-capture.md b/.agents/skills/uloop-screenshot/references/prefab-capture.md index 11f348b14..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. 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 11f348b14..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. 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 18565466e..6f5cef40d 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,241 @@ 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 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. + /// + [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 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. + /// + [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 +708,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..5b29488b1 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,168 @@ 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) + { + Rect? clip = ClipRectOf(ancestor); + if (clip != null) + { + 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. + 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; + } + + // 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) + { + 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 clip; + } + + 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/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.", 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..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. 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.