Unity UGUI RectMask2D 原理

RectMask2D 原理

1. 分析环境

  • Unity:2022.3.62f3
  • UGUI Package:com.unity.ugui@1.0.0
  • UGUI 源码根目录:Library/PackageCache/com.unity.ugui@1.0.0/Runtime/UI
  • 核心文件:
    • Core/RectMask2D.cs
    • Core/MaskUtilities.cs
    • Core/MaskableGraphic.cs
    • Core/Culling/Clipping.cs
    • Core/Culling/ClipperRegistry.cs
    • Core/Culling/IClipRegion.cs
    • Core/Culling/RectangularVertexClipper.cs
    • Core/CanvasUpdateRegistry.cs

本文基于当前项目实际使用的 UGUI 源码,分析 RectMask2D 如何计算矩形裁剪区域、管理子 MaskableGraphic、执行完全不可见对象的 Cull,以及如何把裁剪矩形和软边参数提交给 CanvasRenderer

CanvasRenderer.EnableRectClipping()DisableRectClipping()clippingSoftness 的最终执行位于 Unity 原生 UI 渲染层。当前 UGUI Package 可以确认 C# 侧的区域计算和状态提交,但看不到 Unity C++ 内部如何把这些状态加入最终 Canvas Batch 和 GPU 指令。

2. 核心结论

  • RectMask2D 不使用 Stencil Buffer。
  • RectMask2D 不实现 IMaterialModifier,也不会调用 StencilMaterial.Add()
  • RectMask2D 不需要同对象存在 Graphic,只要求 RectTransform
  • 裁剪区域来自 RectMask2D.rectTransform 在 Canvas 空间中的矩形。
  • 存在多个嵌套 RectMask2D 时,最终区域是所有有效父级矩形与自身矩形的交集。
  • padding 会直接收缩或扩展参与求交的矩形。
  • 最终矩形通过 CanvasRenderer.EnableRectClipping(clipRect) 提交给原生 UI 渲染层。
  • softness 通过 CanvasRenderer.clippingSoftness 单独提交,不改变硬裁剪矩形的求交结果。
  • 完全不与裁剪区域相交的 MaskableGraphic 会被设置为 canvasRenderer.cull = true
  • 部分相交的 Graphic 不会修改 Mesh;区域外像素在后续矩形裁剪阶段被丢弃。
  • RectMask2D 在 Canvas 更新流程中位于 Layout 之后、Graphic PreRender 重建之前执行。
  • RectMask2D 不需要像 Mask 那样增加 Writer/Pop Draw Call,但不同裁剪状态仍可能影响合批。
  • RectMask2D 只能提供矩形裁剪,不能根据 Sprite Alpha 产生圆形或任意形状遮罩。

3. RectMask2D 的类型定义

源码:Runtime/UI/Core/RectMask2D.cs:8-24

csharp 复制代码
[AddComponentMenu("UI/Rect Mask 2D", 14)]
[ExecuteAlways]
[DisallowMultipleComponent]
[RequireComponent(typeof(RectTransform))]
public class RectMask2D : UIBehaviour,
    IClipper,
    ICanvasRaycastFilter

Mask 相比,RectMask2D 没有实现:

text 复制代码
IMaterialModifier

所以它不会沿着下面的链路生成 Stencil Material:

text 复制代码
Graphic.material
    ↓
IMaterialModifier.GetModifiedMaterial()
    ↓
StencilMaterial.Add()

当前源码注释也明确说明 RectMask2D

text 复制代码
Only works in the 2D plane
Requires elements on the mask to be coplanar
Does not require stencil buffer / extra draw calls
Requires fewer draw calls
Culls elements that are outside the mask area

它的核心身份是 IClipper:负责在 Canvas 更新阶段计算裁剪状态并通知所有 IClippable

4. IClipper 与 IClippable

源码:Runtime/UI/Core/Culling/IClipRegion.cs:6-58

csharp 复制代码
public interface IClipper
{
    void PerformClipping();
}

public interface IClippable
{
    GameObject gameObject { get; }

    void RecalculateClipping();

    RectTransform rectTransform { get; }

    void Cull(Rect clipRect, bool validRect);

    void SetClipRect(Rect value, bool validRect);

    void SetClipSoftness(Vector2 clipSoftness);
}

职责关系为:

text 复制代码
RectMask2D : IClipper
    负责计算最终矩形
    负责通知被裁剪目标

MaskableGraphic : IClippable
    接收裁剪矩形
    接收 Softness
    判断自己是否完全不可见
    把状态提交给 CanvasRenderer

ImageRawImage 和旧版 Text 继承 MaskableGraphic,所以默认可以响应 RectMask2D

5. RectMask2D 的核心字段

源码:Runtime/UI/Core/RectMask2D.cs:26-52

csharp 复制代码
[NonSerialized]
private readonly RectangularVertexClipper
    m_VertexClipper =
        new RectangularVertexClipper();

[NonSerialized]
private RectTransform m_RectTransform;

[NonSerialized]
private HashSet<MaskableGraphic>
    m_MaskableTargets =
        new HashSet<MaskableGraphic>();

[NonSerialized]
private HashSet<IClippable>
    m_ClipTargets =
        new HashSet<IClippable>();

[NonSerialized]
private bool m_ShouldRecalculateClipRects;

[NonSerialized]
private List<RectMask2D> m_Clippers =
    new List<RectMask2D>();

[NonSerialized]
private Rect m_LastClipRectCanvasSpace;

[NonSerialized]
private bool m_ForceClip;

[SerializeField]
private Vector4 m_Padding = new Vector4();

[SerializeField]
private Vector2Int m_Softness;

字段职责:

字段 作用
m_VertexClipper 把 RectTransform 四角转换为 Canvas 空间矩形
m_MaskableTargets MaskableGraphic 实现的裁剪目标
m_ClipTargets 其他自定义 IClippable 目标
m_Clippers 对当前 RectMask2D 生效的所有父级 RectMask2D,包含自身
m_ShouldRecalculateClipRects 是否需要重新查找父级 RectMask2D 列表
m_LastClipRectCanvasSpace 上一轮提交的最终裁剪矩形
m_ForceClip 即使矩形没变化,也强制重新提交裁剪状态
m_Padding 左、下、右、上四个方向的区域偏移
m_Softness 水平和垂直方向的软边像素宽度

m_MaskableTargetsm_ClipTargets 分开保存,是因为 MaskableGraphic 除了接收裁剪矩形,还可以直接执行 Cull();普通自定义 IClippableRectMask2D 中只会收到 SetClipRect()SetClipSoftness()

6. 完整调用链

text 复制代码
RectMask2D.OnEnable()
        ↓
ClipperRegistry.Register(this)
        ↓
Canvas.willRenderCanvases
        ↓
CanvasUpdateRegistry.PerformUpdate()
        ↓
完成 Prelayout / Layout / PostLayout
        ↓
ClipperRegistry.instance.Cull()
        ↓
RectMask2D.PerformClipping()
        ↓
MaskUtilities.GetRectMasksForClip()
        ↓
RectangularVertexClipper.GetCanvasRect()
        ↓
Clipping.FindCullAndClipWorldRect()
        ↓
计算所有 RectMask2D 的矩形交集
        ↓
MaskableGraphic.SetClipRect()
        ↓
CanvasRenderer.EnableRectClipping()
        ↓
MaskableGraphic.Cull()
        ↓
完全不可见时 canvasRenderer.cull = true
        ↓
MaskableGraphic.SetClipSoftness()
        ↓
CanvasRenderer.clippingSoftness = softness
        ↓
Graphic PreRender 重建
        ↓
Unity 原生 Canvas 渲染与矩形裁剪

7. RectMask2D 的注册与生命周期

启用时注册到全局 ClipperRegistry

源码:Runtime/UI/Core/RectMask2D.cs:131-137

csharp 复制代码
protected override void OnEnable()
{
    base.OnEnable();
    m_ShouldRecalculateClipRects = true;
    ClipperRegistry.Register(this);
    MaskUtilities.Notify2DMaskStateChanged(this);
}

启用过程完成三件事:

  1. 标记需要重新搜索父级 RectMask2D。
  2. 把自身作为 IClipper 注册到全局列表。
  3. 通知子 IClippable 重新寻找自己的父裁剪器。

禁用时:

源码:Runtime/UI/Core/RectMask2D.cs:139-151

csharp 复制代码
protected override void OnDisable()
{
    base.OnDisable();

    m_ClipTargets.Clear();
    m_MaskableTargets.Clear();
    m_Clippers.Clear();

    ClipperRegistry.Disable(this);
    MaskUtilities.Notify2DMaskStateChanged(this);
}

Disable() 使用 IndexedSet.DisableItem() 禁用注册项。真正销毁时才彻底注销:

csharp 复制代码
protected override void OnDestroy()
{
    ClipperRegistry.Unregister(this);
    base.OnDestroy();
}

父级或 Canvas 层级变化时清除 Canvas 缓存并重新查找裁剪关系:

csharp 复制代码
protected override void OnTransformParentChanged()
{
    m_Canvas = null;
    base.OnTransformParentChanged();
    m_ShouldRecalculateClipRects = true;
}

protected override void OnCanvasHierarchyChanged()
{
    m_Canvas = null;
    base.OnCanvasHierarchyChanged();
    m_ShouldRecalculateClipRects = true;
}

8. ClipperRegistry 是全局裁剪器列表

源码:Runtime/UI/Core/Culling/ClipperRegistry.cs:12-48

csharp 复制代码
public class ClipperRegistry
{
    static ClipperRegistry s_Instance;

    readonly IndexedSet<IClipper> m_Clippers =
        new IndexedSet<IClipper>();

    public static ClipperRegistry instance
    {
        get
        {
            if (s_Instance == null)
                s_Instance = new ClipperRegistry();

            return s_Instance;
        }
    }

    public void Cull()
    {
        var clippersCount = m_Clippers.Count;

        for (var i = 0;
             i < clippersCount;
             ++i)
        {
            m_Clippers[i].PerformClipping();
        }
    }
}

注册使用 AddUnique(),避免同一个 RectMask2D 重复加入:

csharp 复制代码
public static void Register(IClipper c)
{
    if (c == null)
        return;

    instance.m_Clippers.AddUnique(c);
}

这和 CanvasUpdateRegistry 一样是全局单例,不是每个 Canvas 独立拥有一个 ClipperRegistry

9. 裁剪发生在 Canvas 更新的哪个阶段

CanvasUpdateRegistry 监听:

csharp 复制代码
Canvas.willRenderCanvases += PerformUpdate;

源码:Runtime/UI/Core/CanvasUpdateRegistry.cs:161-206

csharp 复制代码
private void PerformUpdate()
{
    // 先执行 Layout 队列:
    // Prelayout、Layout、PostLayout。

    m_LayoutRebuildQueue.Clear();
    m_PerformingLayoutUpdate = false;

    // Layout 完成后执行裁剪。
    ClipperRegistry.instance.Cull();

    m_PerformingGraphicUpdate = true;

    // 然后执行 Graphic 队列:
    // PreRender、LatePreRender。
}

准确顺序为:

text 复制代码
Prelayout
    ↓
Layout
    ↓
PostLayout
    ↓
ClipperRegistry.Cull()
    ↓
RectMask2D.PerformClipping()
    ↓
Graphic PreRender
    ↓
Graphic LatePreRender

必须在 Layout 之后计算裁剪,因为 Layout 可能刚刚改变 RectTransform 的位置和大小;必须在 Graphic 渲染前提交裁剪状态,才能影响当前帧的 UI 绘制。

10. Canvas 空间矩形如何生成

RectMask2D.canvasRect 使用 RectangularVertexClipper

源码:Runtime/UI/Core/RectMask2D.cs:109-117

csharp 复制代码
public Rect canvasRect
{
    get
    {
        return m_VertexClipper.GetCanvasRect(
            rectTransform,
            Canvas
        );
    }
}

具体转换源码:Runtime/UI/Core/Culling/RectangularVertexClipper.cs:8-19

csharp 复制代码
public Rect GetCanvasRect(
    RectTransform t,
    Canvas c)
{
    if (c == null)
        return new Rect();

    t.GetWorldCorners(m_WorldCorners);

    var canvasTransform =
        c.GetComponent<Transform>();

    for (int i = 0; i < 4; ++i)
    {
        m_CanvasCorners[i] =
            canvasTransform.InverseTransformPoint(
                m_WorldCorners[i]
            );
    }

    return new Rect(
        m_CanvasCorners[0].x,
        m_CanvasCorners[0].y,
        m_CanvasCorners[2].x -
            m_CanvasCorners[0].x,
        m_CanvasCorners[2].y -
            m_CanvasCorners[0].y
    );
}

转换过程:

text 复制代码
RectTransform 本地矩形
        ↓
GetWorldCorners()
        ↓
四个世界空间角点
        ↓
Canvas.Transform.InverseTransformPoint()
        ↓
Canvas 局部空间角点
        ↓
构造 canvasRect

所以 RectMask2D 不是直接用 rectTransform.rect。它先考虑 Transform 层级的位置和缩放,再把世界角点统一转换到 Canvas 空间,确保多个 RectMask2D 可以在同一坐标系中求交。

源码实现使用角点 02 构造矩形,结合类注释中"2D plane"和"coplanar"的限制,可以看出它面向共面矩形 UI,而不是任意三维裁剪体或任意旋转多边形。

11. RectMask2D 如何缓存 Canvas

源码:Runtime/UI/Core/RectMask2D.cs:82-106

csharp 复制代码
[NonSerialized]
private Canvas m_Canvas;

internal Canvas Canvas
{
    get
    {
        if (m_Canvas == null)
        {
            var list = ListPool<Canvas>.Get();

            gameObject.GetComponentsInParent(
                false,
                list
            );

            if (list.Count > 0)
                m_Canvas = list[list.Count - 1];
            else
                m_Canvas = null;

            ListPool<Canvas>.Release(list);
        }

        return m_Canvas;
    }
}

父级或 Canvas 层级变化时,m_Canvas 被重置为 null,下次访问再重新查找。

12. 子 Graphic 如何找到父 RectMask2D

MaskableGraphic 启用、父级改变或 Canvas 层级改变时都会执行 UpdateClipParent()

源码:Runtime/UI/Core/MaskableGraphic.cs:265-281

csharp 复制代码
private void UpdateClipParent()
{
    var newParent =
        (maskable && IsActive())
            ? MaskUtilities
                .GetRectMaskForClippable(this)
            : null;

    if (m_ParentMask != null &&
        (newParent != m_ParentMask ||
         !newParent.IsActive()))
    {
        m_ParentMask.RemoveClippable(this);
        UpdateCull(false);
    }

    if (newParent != null &&
        newParent.IsActive())
    {
        newParent.AddClippable(this);
    }

    m_ParentMask = newParent;
}

这里有一个重要设计:每个 MaskableGraphic 只登记到一个直接生效的父 RectMask2D,并不是登记到层级中的全部 RectMask2D。

完整的嵌套矩形交集由这个父 RectMask2D 自己在 PerformClipping() 中收集全部祖先 Mask 后统一计算。

关系可以表示为:

text 复制代码
RectMask2D A
└── RectMask2D B
    └── Image C

Image C
    只登记到 RectMask2D B

RectMask2D B.PerformClipping()
    收集 A + B
    计算 A ∩ B
    把交集提交给 Image C

13. GetRectMaskForClippable 的搜索规则

源码:Runtime/UI/Core/MaskUtilities.cs:140-180

csharp 复制代码
public static RectMask2D GetRectMaskForClippable(
    IClippable clippable)
{
    List<RectMask2D> rectMaskComponents =
        ListPool<RectMask2D>.Get();

    List<Canvas> canvasComponents =
        ListPool<Canvas>.Get();

    RectMask2D componentToReturn = null;

    clippable.gameObject
        .GetComponentsInParent(
            false,
            rectMaskComponents
        );

    if (rectMaskComponents.Count > 0)
    {
        for (int rmi = 0;
             rmi < rectMaskComponents.Count;
             rmi++)
        {
            componentToReturn =
                rectMaskComponents[rmi];

            if (componentToReturn.gameObject ==
                clippable.gameObject)
            {
                componentToReturn = null;
                continue;
            }

            if (!componentToReturn
                .isActiveAndEnabled)
            {
                componentToReturn = null;
                continue;
            }

            // 检查 overrideSorting Canvas 边界。
            // 符合条件后返回最近的有效 RectMask2D。
            break;
        }
    }

    ListPool<RectMask2D>.Release(
        rectMaskComponents
    );

    ListPool<Canvas>.Release(
        canvasComponents
    );

    return componentToReturn;
}

搜索时会排除:

  • 与目标位于同一个 GameObject 上的 RectMask2D。
  • 未启用的 RectMask2D。
  • 被不兼容的 Canvas.overrideSorting 边界隔开的 RectMask2D。

overrideSorting Canvas 可以形成新的排序边界,因此并非所有 Transform 祖先上的 RectMask2D 都必然作用于当前 Graphic。

14. AddClippable 与 RemoveClippable

父 RectMask2D 保存需要接收裁剪状态的目标。

源码:Runtime/UI/Core/RectMask2D.cs:308-321

csharp 复制代码
public void AddClippable(IClippable clippable)
{
    if (clippable == null)
        return;

    m_ShouldRecalculateClipRects = true;

    MaskableGraphic maskable =
        clippable as MaskableGraphic;

    if (maskable == null)
        m_ClipTargets.Add(clippable);
    else
        m_MaskableTargets.Add(maskable);

    m_ForceClip = true;
}

移除时先关闭目标的矩形裁剪状态:

源码:Runtime/UI/Core/RectMask2D.cs:327-343

csharp 复制代码
public void RemoveClippable(IClippable clippable)
{
    if (clippable == null)
        return;

    m_ShouldRecalculateClipRects = true;

    clippable.SetClipRect(
        new Rect(),
        false
    );

    MaskableGraphic maskable =
        clippable as MaskableGraphic;

    if (maskable == null)
        m_ClipTargets.Remove(clippable);
    else
        m_MaskableTargets.Remove(maskable);

    m_ForceClip = true;
}

validRect = false 最终会让 MaskableGraphic 调用 CanvasRenderer.DisableRectClipping()

15. RectMask2D 如何收集所有父级矩形

m_ShouldRecalculateClipRectstrue 时:

csharp 复制代码
MaskUtilities.GetRectMasksForClip(
    this,
    m_Clippers
);

源码:Runtime/UI/Core/MaskUtilities.cs:187-218

csharp 复制代码
public static void GetRectMasksForClip(
    RectMask2D clipper,
    List<RectMask2D> masks)
{
    masks.Clear();

    List<Canvas> canvasComponents =
        ListPool<Canvas>.Get();

    List<RectMask2D> rectMaskComponents =
        ListPool<RectMask2D>.Get();

    clipper.transform.GetComponentsInParent(
        false,
        rectMaskComponents
    );

    if (rectMaskComponents.Count > 0)
    {
        clipper.transform.GetComponentsInParent(
            false,
            canvasComponents
        );

        for (int i = rectMaskComponents.Count - 1;
             i >= 0;
             i--)
        {
            if (!rectMaskComponents[i].IsActive())
                continue;

            bool shouldAdd = true;

            // 检查 overrideSorting Canvas 边界。

            if (shouldAdd)
                masks.Add(rectMaskComponents[i]);
        }
    }

    ListPool<RectMask2D>.Release(
        rectMaskComponents
    );

    ListPool<Canvas>.Release(
        canvasComponents
    );
}

最终 m_Clippers 包含:

text 复制代码
最外层有效 RectMask2D
    ↓
中间层有效 RectMask2D
    ↓
当前 RectMask2D 自身

这个列表只在层级或裁剪关系变化时重新查找。每一帧仍会读取这些 RectMask2D 当前的 canvasRect,所以 RectTransform 尺寸或位置变化可以反映到最终交集。

16. 多个矩形如何求交

最终裁剪矩形由 Clipping.FindCullAndClipWorldRect() 计算。

源码:Runtime/UI/Core/Culling/Clipping.cs:17-49

csharp 复制代码
public static Rect FindCullAndClipWorldRect(
    List<RectMask2D> rectMaskParents,
    out bool validRect)
{
    if (rectMaskParents.Count == 0)
    {
        validRect = false;
        return new Rect();
    }

    Rect current =
        rectMaskParents[0].canvasRect;

    Vector4 offset =
        rectMaskParents[0].padding;

    float xMin = current.xMin + offset.x;
    float xMax = current.xMax - offset.z;
    float yMin = current.yMin + offset.y;
    float yMax = current.yMax - offset.w;

    for (var i = 1;
         i < rectMaskParents.Count;
         ++i)
    {
        current = rectMaskParents[i].canvasRect;
        offset = rectMaskParents[i].padding;

        if (xMin < current.xMin + offset.x)
            xMin = current.xMin + offset.x;

        if (yMin < current.yMin + offset.y)
            yMin = current.yMin + offset.y;

        if (xMax > current.xMax - offset.z)
            xMax = current.xMax - offset.z;

        if (yMax > current.yMax - offset.w)
            yMax = current.yMax - offset.w;
    }

    validRect =
        xMax > xMin &&
        yMax > yMin;

    return validRect
        ? new Rect(
            xMin,
            yMin,
            xMax - xMin,
            yMax - yMin
        )
        : new Rect();
}

矩形交集的数学关系:

text 复制代码
最终 xMin = 所有矩形有效 xMin 的最大值
最终 yMin = 所有矩形有效 yMin 的最大值
最终 xMax = 所有矩形有效 xMax 的最小值
最终 yMax = 所有矩形有效 yMax 的最小值

只有满足:

text 复制代码
xMax > xMin
yMax > yMin

最终矩形才有效。

示例:

text 复制代码
Rect A:x = [0, 100],y = [0, 100]
Rect B:x = [40, 140],y = [20, 80]

交集:
x = [40, 100]
y = [20, 80]

因此 RectMask2D 嵌套不需要 Stencil Bit。所有层级最终被压缩成一个矩形交集,再提交给子 Graphic。

17. padding 如何改变裁剪区域

padding 的字段含义:

text 复制代码
X = Left
Y = Bottom
Z = Right
W = Top

源码中的应用方式:

csharp 复制代码
xMin = current.xMin + padding.x;
yMin = current.yMin + padding.y;
xMax = current.xMax - padding.z;
yMax = current.yMax - padding.w;

正数会向内部收缩:

text 复制代码
Left   > 0:左边界向右移动
Bottom > 0:下边界向上移动
Right  > 0:右边界向左移动
Top    > 0:上边界向下移动

负数会产生相反效果,使对应边界向外扩展。源码没有像 Softness 那样把 Padding 限制为非负值。

每一层 RectMask2D 都先应用自己的 Padding,再参与最终交集:

text 复制代码
(Rect A - Padding A)
        ∩
(Rect B - Padding B)
        ∩
(Rect C - Padding C)

修改 Padding 后:

csharp 复制代码
MaskUtilities.Notify2DMaskStateChanged(this);

子目标会重新计算父裁剪器关系并强制刷新裁剪状态。

18. PerformClipping 主流程

源码:Runtime/UI/Core/RectMask2D.cs:204-284

主逻辑可以精简为:

csharp 复制代码
public virtual void PerformClipping()
{
    if (ReferenceEquals(Canvas, null))
        return;

    if (m_ShouldRecalculateClipRects)
    {
        MaskUtilities.GetRectMasksForClip(
            this,
            m_Clippers
        );

        m_ShouldRecalculateClipRects = false;
    }

    bool validRect = true;

    Rect clipRect =
        Clipping.FindCullAndClipWorldRect(
            m_Clippers,
            out validRect
        );

    // 检查 Mask 是否在根 Canvas 之外。

    // 根据矩形是否变化,向目标提交:
    // SetClipRect、Cull、Softness。

    m_LastClipRectCanvasSpace = clipRect;
    m_ForceClip = false;

    UpdateClipSoftness();
}

它不会执行:

text 复制代码
SetVerticesDirty()
OnPopulateMesh()
StencilMaterial.Add()
CanvasRenderer.SetMesh()

也就是说,RectMask2D 不是通过生成一个遮罩 Mesh 来裁剪,而是计算一个 Canvas 空间矩形并设置 CanvasRenderer 的裁剪状态。

19. 根 Canvas 外部的 Mask 优化

对于 ScreenSpaceCameraScreenSpaceOverlay

csharp 复制代码
RenderMode renderMode =
    Canvas.rootCanvas.renderMode;

bool maskIsCulled =
    (renderMode == RenderMode.ScreenSpaceCamera ||
     renderMode == RenderMode.ScreenSpaceOverlay) &&
    !clipRect.Overlaps(rootCanvasRect, true);

如果整个 RectMask2D 区域都不与根 Canvas 相交:

csharp 复制代码
clipRect = Rect.zero;
validRect = false;

之后所有子 MaskableGraphic.Cull() 都会得到无效矩形,从而被整体 Cull。

这个优化只在源码指定的两种屏幕空间 RenderMode 下执行。WorldSpace Canvas 没有使用这里的根 Canvas 屏幕范围判断。

20. 裁剪矩形如何提交给 CanvasRenderer

当最终 clipRect 与上一轮不同:

csharp 复制代码
foreach (IClippable clipTarget
         in m_ClipTargets)
{
    clipTarget.SetClipRect(
        clipRect,
        validRect
    );
}

foreach (MaskableGraphic maskableTarget
         in m_MaskableTargets)
{
    maskableTarget.SetClipRect(
        clipRect,
        validRect
    );

    maskableTarget.Cull(
        clipRect,
        validRect
    );
}

MaskableGraphic.SetClipRect()

源码:Runtime/UI/Core/MaskableGraphic.cs:155-161

csharp 复制代码
public virtual void SetClipRect(
    Rect clipRect,
    bool validRect)
{
    if (validRect)
        canvasRenderer.EnableRectClipping(
            clipRect
        );
    else
        canvasRenderer.DisableRectClipping();
}

托管层边界为:

text 复制代码
RectMask2D 计算 Canvas 空间 clipRect
        ↓
MaskableGraphic.SetClipRect()
        ↓
CanvasRenderer.EnableRectClipping()
        ↓
Unity C++ 原生 UI 渲染层
        ↓
最终 Batch 和 Shader 裁剪

EnableRectClipping() 的内部实现不在 com.unity.ugui Package 源码中。

21. 完全不可见对象如何 Cull

MaskableGraphic.Cull() 不检查每个像素,而是比较最终裁剪矩形和当前 Graphic 在根 Canvas 空间中的包围矩形。

源码:Runtime/UI/Core/MaskableGraphic.cs:133-150

csharp 复制代码
public virtual void Cull(
    Rect clipRect,
    bool validRect)
{
    var cull =
        !validRect ||
        !clipRect.Overlaps(
            rootCanvasRect,
            true
        );

    UpdateCull(cull);
}

private void UpdateCull(bool cull)
{
    if (canvasRenderer.cull != cull)
    {
        canvasRenderer.cull = cull;

        UISystemProfilerApi.AddMarker(
            "MaskableGraphic.cullingChanged",
            this
        );

        m_OnCullStateChanged.Invoke(cull);
        OnCullingChanged();
    }
}

结果分为三种情况:

text 复制代码
完全在裁剪区域内
    → cull = false
    → 正常绘制

部分与裁剪区域相交
    → cull = false
    → 保留完整 Graphic
    → 区域外像素由 Rect Clipping 处理

完全在裁剪区域外
    → cull = true
    → CanvasRenderer 跳过该 Graphic

所以 RectMask2D 同时包含两层优化:

  1. 粗粒度:完全不相交时直接 Cull 整个 Graphic。
  2. 细粒度:部分相交时使用 clipRect 丢弃区域外像素。

22. Graphic 的 rootCanvasRect 如何计算

源码:Runtime/UI/Core/MaskableGraphic.cs:235-262

csharp 复制代码
readonly Vector3[] m_Corners = new Vector3[4];

private Rect rootCanvasRect
{
    get
    {
        rectTransform.GetWorldCorners(m_Corners);

        if (canvas)
        {
            Matrix4x4 mat =
                canvas.rootCanvas.transform
                    .worldToLocalMatrix;

            for (int i = 0; i < 4; ++i)
            {
                m_Corners[i] =
                    mat.MultiplyPoint(m_Corners[i]);
            }
        }

        Vector2 min = m_Corners[0];
        Vector2 max = m_Corners[0];

        for (int i = 1; i < 4; i++)
        {
            min.x = Mathf.Min(
                m_Corners[i].x,
                min.x
            );
            min.y = Mathf.Min(
                m_Corners[i].y,
                min.y
            );
            max.x = Mathf.Max(
                m_Corners[i].x,
                max.x
            );
            max.y = Mathf.Max(
                m_Corners[i].y,
                max.y
            );
        }

        return new Rect(min, max - min);
    }
}

这里使用四个角的最小值和最大值构造轴对齐包围盒。它用于快速判断整个 Graphic 是否与裁剪区域相交,不代表最终逐像素裁剪形状。

23. canvasRenderer.cull 对 Graphic 重建的影响

Graphic.Rebuild() 开头会检查:

csharp 复制代码
if (canvasRenderer == null ||
    canvasRenderer.cull)
{
    return;
}

所以完全被 RectMask2D Cull 的 Graphic 不会继续执行当前轮的 Mesh 和 Material 更新。

如果 Graphic 在 Cull 期间产生了 Dirty,重新可见时:

csharp 复制代码
public virtual void OnCullingChanged()
{
    if (!canvasRenderer.cull &&
        (m_VertsDirty || m_MaterialDirty))
    {
        CanvasUpdateRegistry
            .RegisterCanvasElementForGraphicRebuild(
                this
            );
    }
}

因此重新进入可见区域后,未完成的顶点或材质更新会重新进入 Graphic 重建队列。

24. 为什么 clipRect 没变化时仍然 Cull

PerformClipping() 对三种状态分别处理:

text 复制代码
clipRect 变化
    → 所有目标重新 SetClipRect
    → 所有 MaskableGraphic 重新 Cull

clipRect 未变化但 m_ForceClip = true
    → 所有目标重新 SetClipRect
    → 移动过的 MaskableGraphic 重新 Cull

clipRect 未变化且不强制
    → 所有 MaskableGraphic 仍然执行 Cull

源码最后一个分支:

csharp 复制代码
foreach (MaskableGraphic maskableTarget
         in m_MaskableTargets)
{
    // hasMoved is not a valid check when
    // animating on pivot of the object.
    maskableTarget.Cull(
        clipRect,
        validRect
    );
}

原因是即使裁剪矩形没有变化,子 Graphic 自身也可能因为位置、Pivot 或动画变化而进入或离开裁剪区域。

25. Softness 的实现路径

softness 会被限制为非负数:

源码:Runtime/UI/Core/RectMask2D.cs:73-87

csharp 复制代码
public Vector2Int softness
{
    get { return m_Softness; }
    set
    {
        m_Softness.x = Mathf.Max(0, value.x);
        m_Softness.y = Mathf.Max(0, value.y);

        MaskUtilities.Notify2DMaskStateChanged(
            this
        );
    }
}

每轮 PerformClipping() 最后执行:

csharp 复制代码
UpdateClipSoftness();

源码:Runtime/UI/Core/RectMask2D.cs:286-302

csharp 复制代码
public virtual void UpdateClipSoftness()
{
    if (ReferenceEquals(Canvas, null))
        return;

    foreach (IClippable clipTarget
             in m_ClipTargets)
    {
        clipTarget.SetClipSoftness(
            m_Softness
        );
    }

    foreach (MaskableGraphic maskableTarget
             in m_MaskableTargets)
    {
        maskableTarget.SetClipSoftness(
            m_Softness
        );
    }
}

MaskableGraphic 最终提交:

csharp 复制代码
public virtual void SetClipSoftness(
    Vector2 clipSoftness)
{
    canvasRenderer.clippingSoftness =
        clipSoftness;
}

IClippable 接口注释定义 Softness 为:

text 复制代码
The softness is a linear alpha falloff
over clipSoftness pixels.

所以 Softness 是边缘 Alpha 渐变宽度,不会改变 Clipping.FindCullAndClipWorldRect() 得到的硬矩形交集,也不会产生圆角或任意形状裁剪。

26. RectMask2D 的 Shader/原生层边界

当前 UGUI C# 源码止于:

csharp 复制代码
canvasRenderer.EnableRectClipping(clipRect);
canvasRenderer.clippingSoftness = clipSoftness;

标准 UI Shader 通常需要支持矩形裁剪相关输入,例如:

text 复制代码
_ClipRect
UNITY_UI_CLIP_RECT
UnityGet2DClipping()

概念上的片元逻辑类似:

hlsl 复制代码
// 这是标准工作关系的示意,
// 不是当前 UGUI Package 中直接包含的 Shader 源码。
#ifdef UNITY_UI_CLIP_RECT
    color.a *= UnityGet2DClipping(
        position.xy,
        _ClipRect
    );
#endif

当前 com.unity.ugui Package 没有包含 Unity 内置 UI/Default Shader 的完整源码,因此不能仅从 Package 继续查看 CanvasRenderer 如何设置 Shader 常量、Keyword,以及原生 Canvas 如何处理不同 ClipRect 的 Batch。

自定义 UI Shader 如果忽略 Rect Clipping 相关状态,可能出现 ScrollRect 内容越过 Viewport 显示的问题。

27. Raycast 裁剪

RectMask2D 同时实现 ICanvasRaycastFilter

源码:Runtime/UI/Core/RectMask2D.cs:176-183

csharp 复制代码
public virtual bool IsRaycastLocationValid(
    Vector2 sp,
    Camera eventCamera)
{
    if (!isActiveAndEnabled)
        return true;

    return RectTransformUtility
        .RectangleContainsScreenPoint(
            rectTransform,
            sp,
            eventCamera,
            m_Padding
        );
}

因此指针必须位于当前 RectMask2D 的带 Padding 矩形中,才能继续通过这一层射线过滤。

嵌套 RectMask2D 时,Graphic.Raycast 会沿父级收集 ICanvasRaycastFilter,所以指针需要依次通过每一层有效 RectMask2D 的矩形检查。

需要区分:

text 复制代码
视觉裁剪
    → 最终复合 clipRect
    → CanvasRenderer.EnableRectClipping()

射线过滤
    → 每层 RectMask2D.IsRaycastLocationValid()
    → RectangleContainsScreenPoint()

两者都使用矩形和 Padding,但调用链不同。

28. RectMask2D 与 Mask 的根本区别

项目 RectMask2D Mask
核心机制 Canvas Rect Clipping Stencil Buffer
是否实现 IMaterialModifier
是否需要同对象 Graphic
区域来源 RectTransform 矩形 Graphic Mesh 与 Alpha
非矩形遮罩 不支持 支持
是否写 Stencil
是否创建 Stencil Material
是否需要 Writer/Pop Draw
嵌套方式 多矩形求交 多 Stencil Bit
嵌套层数 不受 8 位 Stencil 限制 通常最多 8 层
完全不可见对象 Cull 源码明确执行 主要依赖 Canvas/Mask 渲染流程
Softness 内置属性支持 标准 Mask 无相同 Rect Softness

RectMask2D 更适合:

  • ScrollRect Viewport。
  • 矩形列表、背包和滚动内容。
  • 不需要 Sprite Alpha 形状的裁剪。
  • 希望避免 Stencil Writer/Pop Draw Call 的 UI。

Mask 更适合:

  • 圆形头像。
  • 不规则 Sprite 形状。
  • 需要根据纹理 Alpha 定义区域的 UI。

29. 对合批和性能的影响

RectMask2D 不会像 Mask 那样额外绘制 Mask Writer 和 Pop,因此源码注释称其不需要额外 Draw Call,并且需要更少 Draw Call。

但这不代表 RectMask2D 对 Batch 完全没有影响:

  • 不同的 ClipRect 属于不同裁剪状态。
  • 裁剪状态变化可能导致 Canvas 原生层分开处理批次。
  • Softness 不同也属于不同 CanvasRenderer 裁剪状态。
  • 多个 RectMask2D 每轮都会执行矩形计算和目标 Cull。
  • 子 Graphic 频繁移动会重复进行包围盒相交判断。

性能优势主要来自:

  • 不写 Stencil Buffer。
  • 不生成 Stencil 派生 Material。
  • 不需要 Mask Writer Draw。
  • 不需要 Pop Draw。
  • 完全在外部的 Graphic 可以直接 canvasRenderer.cull = true

常见优化建议:

  1. 矩形滚动区域优先使用 RectMask2D
  2. 不需要 Softness 时保持为 0
  3. 避免在大量嵌套 RectMask2D 下每帧频繁改变层级。
  4. 避免让大量子 Graphic 每帧跨越裁剪边界。
  5. 自定义 UI Shader 必须保留 Rect Clipping 支持。

30. 常见问题

30.1 RectMask2D 会修改子物体 Mesh 吗

不会。部分相交时子 Graphic 仍然提交自己的完整 Mesh,区域外片元由矩形裁剪状态处理。

30.2 RectMask2D 会创建 Material 吗

不会因为裁剪调用 StencilMaterial.Add()。它直接向 CanvasRenderer 设置 ClipRect 和 Softness。

30.3 RectMask2D 使用 Stencil Buffer 吗

不使用。当前类也没有 Stencil ID、ReadMask、WriteMask 或 Pop Material。

30.4 嵌套 RectMask2D 怎么工作

收集所有有效父级 RectMask2D 和自身,在 Canvas 空间计算矩形交集,然后只提交一个最终 ClipRect。

30.5 为什么子 Graphic 只登记到最近的 RectMask2D

最近的 RectMask2D 会收集全部祖先矩形并统一计算交集,因此不需要让每个 Graphic 同时登记到所有父 Mask。

30.6 完全在外部和部分在外部有什么区别

完全在外部会设置 canvasRenderer.cull = true;部分在外部保持 cull = false,再执行细粒度矩形裁剪。

30.7 Padding 是扩大还是缩小区域

正数向内部收缩,负数可以向外扩展。

30.8 Softness 会改变 Cull 矩形吗

不会。当前源码中矩形求交只使用 canvasRectpadding。Softness 单独传给 CanvasRenderer.clippingSoftness

30.9 RectMask2D 能实现圆形头像吗

不能。它只提供矩形裁剪。圆形或任意 Sprite Alpha 形状应使用 Mask 或自定义 Shader。

30.10 为什么 ScrollRect 常使用 RectMask2D

ScrollRect 的 Viewport 通常是矩形,RectMask2D 不需要 Stencil Writer/Pop,并且能 Cull 完全离开 Viewport 的子 Graphic。

30.11 禁用 RectMask2D 后为什么子物体恢复显示

禁用时会通知子 IClippable 重新寻找父裁剪器。旧父级通过 RemoveClippable() 调用 SetClipRect(..., false),最终关闭 CanvasRenderer 的矩形裁剪,并恢复 Cull 状态。

31. 最终工作模型

单层 RectMask2D:

text 复制代码
RectTransform 四个世界角点
        ↓
转换到 Canvas 空间
        ↓
应用 Left/Bottom/Right/Top Padding
        ↓
得到最终 clipRect
        ↓
CanvasRenderer.EnableRectClipping(clipRect)
        ↓
完全不相交的 Graphic 设置 cull = true
        ↓
部分相交的 Graphic 由原生 Rect Clipping 裁掉区域外像素

嵌套 RectMask2D:

text 复制代码
父 RectMask2D A.canvasRect
        ∩
父 RectMask2D B.canvasRect
        ∩
当前 RectMask2D C.canvasRect
        ↓
Clipping.FindCullAndClipWorldRect()
        ↓
得到一个最终矩形
        ↓
提交给当前子树的所有 IClippable

完整更新时序:

text 复制代码
Layout 完成
        ↓
ClipperRegistry.Cull()
        ↓
RectMask2D.PerformClipping()
        ↓
计算复合矩形、Cull 和 Softness
        ↓
Graphic PreRender 重建
        ↓
Canvas 原生层执行矩形裁剪和合批

RectMask2D 的本质是:在 Layout 完成后把所有有效父级矩形压缩成一个 Canvas 空间交集,将该矩形作为 CanvasRenderer 的裁剪状态提交给子 Graphic,同时用包围盒相交测试跳过完全不可见的对象。

32. 当前版本源码索引

内容 文件与行号
RectMask2D 定义 Core/RectMask2D.cs:8-24
核心字段 Core/RectMask2D.cs:26-52
padding Core/RectMask2D.cs:54-68
softness Core/RectMask2D.cs:70-87
Canvas 缓存 Core/RectMask2D.cs:89-107
canvasRect Core/RectMask2D.cs:109-117
生命周期与注册 Core/RectMask2D.cs:131-170
射线过滤 Core/RectMask2D.cs:176-183
rootCanvasRect Core/RectMask2D.cs:187-202
PerformClipping() Core/RectMask2D.cs:204-284
UpdateClipSoftness() Core/RectMask2D.cs:286-302
AddClippable() Core/RectMask2D.cs:308-321
RemoveClippable() Core/RectMask2D.cs:327-343
父级与 Canvas 变化 Core/RectMask2D.cs:345-359
IClipper / IClippable Core/Culling/IClipRegion.cs:6-58
ClipperRegistry Core/Culling/ClipperRegistry.cs:12-78
Canvas 空间矩形转换 Core/Culling/RectangularVertexClipper.cs:8-19
多矩形交集 Core/Culling/Clipping.cs:17-49
子目标寻找父 Mask Core/MaskUtilities.cs:140-180
收集嵌套 RectMask2D Core/MaskUtilities.cs:187-218
MaskableGraphic.Cull() Core/MaskableGraphic.cs:133-150
SetClipRect() Core/MaskableGraphic.cs:155-161
SetClipSoftness() Core/MaskableGraphic.cs:163-166
UpdateClipParent() Core/MaskableGraphic.cs:265-281
Canvas 裁剪更新时序 Core/CanvasUpdateRegistry.cs:161-231
相关推荐
hashiqimiya1 小时前
unity---unity编译手机apk人物角色移动
unity·游戏引擎
LONGZETECH2 小时前
传统实训 vs 仿真实训:从技术架构拆解新能源汽车教学的范式之变
c语言·3d·unity·架构·汽车
℡枫叶℡1 天前
Unity - 图集大小与内存占用
unity·图集内存
_ZHOURUI_H_1 天前
Unity MyFramework 用法说明(二十五):使用 AtlasManager 统一管理图集与 Sprite 引用
ui·unity·游戏引擎·unity3d·游戏开发
新手unity自用笔记1 天前
Unity网络基础_2
unity·游戏引擎
音视频牛哥1 天前
从数字孪生到机器人操控:Android Unity3D下RTMP/RTSP多路低延迟播放实践
android·unity·音视频·unity rtsp播放器·unity rtmp播放器·rtsp player·rtmp player
派葛穆1 天前
Unity-原生 JsonUtility 实现 JSON 与TMP UI 交互实战
unity
chenzhou__1 天前
独立游戏开发日志 ①:从信号博弈到涂色对战——一次玩法重构始
笔记·后端·学习·unity·go·独立游戏
五仁烧饼1 天前
Unity Addressables 资源依赖治理:规则约束、自动修复与全链路防护
java·unity·游戏引擎