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.csCore/MaskUtilities.csCore/MaskableGraphic.csCore/Culling/Clipping.csCore/Culling/ClipperRegistry.csCore/Culling/IClipRegion.csCore/Culling/RectangularVertexClipper.csCore/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 之后、GraphicPreRender重建之前执行。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
Image、RawImage 和旧版 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_MaskableTargets 和 m_ClipTargets 分开保存,是因为 MaskableGraphic 除了接收裁剪矩形,还可以直接执行 Cull();普通自定义 IClippable 在 RectMask2D 中只会收到 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);
}
启用过程完成三件事:
- 标记需要重新搜索父级 RectMask2D。
- 把自身作为
IClipper注册到全局列表。 - 通知子
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 可以在同一坐标系中求交。
源码实现使用角点 0 和 2 构造矩形,结合类注释中"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_ShouldRecalculateClipRects 为 true 时:
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 优化
对于 ScreenSpaceCamera 和 ScreenSpaceOverlay:
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 同时包含两层优化:
- 粗粒度:完全不相交时直接 Cull 整个 Graphic。
- 细粒度:部分相交时使用 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 更适合:
ScrollRectViewport。- 矩形列表、背包和滚动内容。
- 不需要 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。
常见优化建议:
- 矩形滚动区域优先使用
RectMask2D。 - 不需要 Softness 时保持为
0。 - 避免在大量嵌套 RectMask2D 下每帧频繁改变层级。
- 避免让大量子 Graphic 每帧跨越裁剪边界。
- 自定义 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 矩形吗
不会。当前源码中矩形求交只使用 canvasRect 和 padding。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 |