ARTICLE · 1351488
UGUI源码学习笔记_第八篇_Mask和RectMask2D

之前提到过,给一个Image加上Mask后,Inspector里的Material看起来没变,CanvasRenderer实际拿到的却可能已经不是它。这是为什么呢?现在我们就把它理清楚。其实UGUI没有直接改用户设置的base Material,而是在提交渲染材质时,让同对象上的 IMaterialModifier 依次加工。Mask 的写模板材质、子元素的读模板材质,以及退出子树时用的 pop material,都从这里接进来。
materialForRendering 是一条 modifier 链
Graphic.materialForRendering 是只读属性:
publicvirtual Material materialForRendering{get {var components = ListPool<IMaterialModifier>.Get(); GetComponents<IMaterialModifier>(components);var currentMat = material;for (var i = 0; i < components.Count; i++) currentMat = (components[i] as IMaterialModifier).GetModifiedMaterial(currentMat); ListPool<IMaterialModifier>.Release(components);return currentMat; }}Graphic.UpdateMaterial()最后把这个结果交给 CanvasRenderer。Mask 和 MaskableGraphic 都实现了IMaterialModifier,但在 Mask 自己的 GameObject 上,它们并不是一写一读地叠两层 stencil。Mask 启用时会把同对象的MaskableGraphic.isMaskingGraphic设为 true,而普通读模板分支要求!isMaskingGraphic`。因此这张 Graphic 由 Mask 负责写模板,不会再套一份子元素使用的读模板材质。
materialForRendering 这条 modifier 链,本质是"把同对象上的 IMaterialModifier 串起来依次加工",顺序就是组件在 Inspector 里的排列顺序:

Mask 写入,pop material 恢复
Mask 先通过 GetStencilDepth 统计有效的 Mask 祖先。最外层 depth=0,使用 bit 0;内层依次使用更高位。最外层的关键参数是:
// 逻辑示意:合并最外层 Mask 创建和注册材质的关键调用var maskMaterial = StencilMaterial.Add( baseMaterial, 1, StencilOp.Replace, CompareFunction.Always, m_ShowMaskGraphic ? ColorWriteMask.All : 0);var unmaskMaterial = StencilMaterial.Add( baseMaterial, 1, StencilOp.Zero, CompareFunction.Always, 0);graphic.canvasRenderer.popMaterialCount = 1;graphic.canvasRenderer.SetPopMaterial(unmaskMaterial, 0);Mask Graphic 先把 stencil 写成 1;整个子树画完后,pop material 用 Zero 清回 0。showMaskGraphic=false 时只是把 ColorWriteMask 设为 0,模板仍然会写,所以能得到一张不可见的遮罩图形。
嵌套层不能简单说成“模板值递增”。假设当前 desiredStencilBit = 1 << stencilDepth,写入值和 writeMask 都是:
// 逻辑示意:fullMask 是为说明位掩码引入的局部名int fullMask = desiredStencilBit | (desiredStencilBit - 1);当前 Mask 先用 CompareFunction.Equal 检查 desiredStencilBit - 1 这些祖先低位,再用 Replace 写入累计 bit mask。退出内层子树时,unmask material 不是清成 0,而是 Replace 回 desiredStencilBit - 1,恢复外层 Mask 已经写好的低位。否则内层一结束,外层后续内容也会失去模板状态。
Stencil 只有 8 位。depth 0~7 可以使用八个 bit;当一个 Mask 已经有 8 个有效 Mask 祖先,也就是第九层 Mask 时,stencilDepth >= 8,源码发 warning,并让这个 Mask 的 GetModifiedMaterial 返回 base Material,不再生成自己的写模板材质。
Mask 的写/读模板材质与 pop 恢复,可以用下面这条流程串起来:

// 逻辑示意:省略字段置空等语句graphic.canvasRenderer.hasPopInstruction = false;graphic.canvasRenderer.popMaterialCount = 0;StencilMaterial.Remove(m_MaskMaterial);StencilMaterial.Remove(m_UnmaskMaterial);子 Graphic 只读取祖先位
普通 MaskableGraphic 重新计算出祖先 stencil 深度后,使用所有祖先低位组成的掩码:
// 逻辑示意:stencilMask 是为展开重复表达式引入的局部名int stencilMask = (1 << m_StencilValue) - 1;var maskMat = StencilMaterial.Add( toUse, stencilMask, StencilOp.Keep, CompareFunction.Equal, ColorWriteMask.All, stencilMask, 0);Equal + Keep + writeMask=0表示只测试、不改写。像素只有在所有祖先 Mask 对应的 bit 都满足时才通过。maskable=false会让m_StencilValue` 归零,不再创建这份读模板材质。
StencilMaterial 先查缓存,再克隆
StencilMaterial.Add 不是一进来就 new Material(baseMat)。它先判断是否需要处理,再检查 base Material 是否包含 _Stencil、_StencilOp、_StencilComp、_StencilReadMask、_StencilWriteMask 和 _ColorMask。缺任意属性都会警告并返回原材质。
属性齐全后,它遍历静态 m_List。缓存键包含 baseMat 引用、stencilId、operation、compareFunction、readMask、writeMask 和 colorMask;命中时只增加引用计数并返回已有 customMat。找不到才执行:
newEnt.customMat = new Material(baseMat);随后把 stencil 参数写进副本。副本沿用同一个 Shader,也会继承贴图等材质数据,但拥有独立的材质属性状态。Remove 把引用计数减一,归零后销毁 customMat 并移出缓存。
通过 Graphic.material 属性切换 base 材质时,setter 本身会调用 SetMaterialDirty(),正常重建会更新 stencil 材质引用。不要直接销毁 UGUI 返回的 customMat,也别把一个 Graphic 的 materialForRendering 塞给另一个 Graphic:它可能是共享缓存,且已经携带特定 Mask 层级的 stencil 参数,转手后结果取决于双方层级和绘制顺序,可能错裁,但不一定总是整块消失。
materialForRendering 只有 getter,也不该拿它的返回值直接 SetFloat。相同配置的多个 Graphic 可能正共享这份材质。需要运行时参数时,我会给 Graphic 设置独立的 base Material,修改后标记材质脏;更复杂的情况则写自己的 IMaterialModifier,并把实例创建、缓存和释放配对处理。
共享同一个 Shader 对象本身没有问题。Stencil 参数存放在 Material 上,不会因为一个元素启用 Mask 就改掉其他材质的 _Stencil。真正要检查的是 Shader 是否接好了这些属性和渲染状态。
自定义 UI Shader 不能只 include 一个文件
仅写:
#include "UnityUI.cginc"并不会自动获得 Mask 支持。自定义 Shader 至少要在 Properties 中声明 UGUI 检查的 stencil/color-mask 属性,并在 ShaderLab Pass 中接入对应状态。下面是逻辑示意,不是包内逐字源码:
// 逻辑示意Properties{ _StencilComp ("Stencil Comparison", Float) = 8 _Stencil ("Stencil ID", Float) = 0 _StencilOp ("Stencil Operation", Float) = 0 _StencilWriteMask ("Stencil Write Mask", Float) = 255 _StencilReadMask ("Stencil Read Mask", Float) = 255 _ColorMask ("Color Mask", Float) = 15}Stencil{ Ref [_Stencil] Comp [_StencilComp] Pass [_StencilOp] ReadMask [_StencilReadMask] WriteMask [_StencilWriteMask]}ColorMask [_ColorMask]若还要支持 RectMask2D 的软裁剪或 UI alpha clip,再补对应的 clip rect、keyword 和片元逻辑。最稳妥的做法仍是从兼容版本的 UI/Default 结构出发修改。
RectMask2D 不是逐顶点裁网格
RectangularVertexClipper 这个名字很容易误导带偏。它只读取 RectTransform 的四个世界角,转换到 Canvas 空间并返回一个 Rect,没有改写 Graphic 顶点。
RectMask2D.PerformClipping() 会收集对当前节点生效的 RectMask2D,把它们的矩形和 padding 求交集,然后对后代调用 SetClipRect。MaskableGraphic.SetClipRect 最终进入:
canvasRenderer.EnableRectClipping(clipRect);RectMask2D 还会用 Graphic 的包围矩形做 CPU 粗粒度判断,完全落在外部时设置 canvasRenderer.cull;softness 则传给 canvasRenderer.clippingSoftness。所以它的准确描述是“计算矩形交集 + CanvasRenderer 矩形裁剪 + 整体 cull”,不是 C# 逐顶点切网格。
Mask 会引入 stencil 状态材质、遮罩绘制和 pop material,通常增加绘制与批次成本。RectMask2D 不需要 stencil,也不额外绘制一张 Mask 图形,矩形列表和 ScrollView 通常更适合它。不过最终是否合批还受材质、纹理、Canvas、绘制顺序和裁剪状态影响,不能写成“Mask 必然拆批、RectMask2D 永不拆批”。
到这里,这个系列的八个核心子系统就串起来了:Canvas 负责组织提交,RectTransform 定义矩形,Graphic 生成绘制数据,交互与布局各自维护状态,Mask 再在提交前改造材质或下发矩形裁剪。以后再碰到 UI 消失、点击错位或批次异常,我还是会回到这几条真实调用链上查,而不是先猜 Inspector 里哪个勾选框失灵。系列收尾——但源码层面还有不少值得扒的细节,下面是一份逐文件的深度补充。
RectMask2D 先算裁剪矩形(布局后、渲染前),Mask 后改材质(渲染阶段)
Mask 的 GetModifiedMaterial 和 RectMask2D 的 PerformClipping根本不在同一个时机执行。这点就是"为什么有的改动要等一帧才生效"的关键。
UGUI 的重建循环在 CanvasUpdateRegistry.PerformUpdate()(CanvasUpdateRegistry.cs)里,顺序是:
布局阶段 Prelayout → Layout → PostLayout,依次调用各ILayoutElement/ILayoutController的Rebuild。布局结束后、渲染前,调用 ClipperRegistry.instance.Cull()——这正是 RectMask2D 的舞台。ClipperRegistry.Cull()(ClipperRegistry.cs)遍历所有已注册的IClipper,逐个调PerformClipping()。之后才是 Graphic 的 PreRender阶段,Graphic.Rebuild(CanvasUpdate.PreRender)(Graphic.cs)里才会走UpdateMaterial()→materialForRendering→ 各IMaterialModifier.GetModifiedMaterial。Mask 的写/读模板材质正是在这一步算出来的。
RectMask2D 圈定的 clipRect 早早就下发给后代了,Mask 的 stencil 材质要等到 Graphic 真正提交材质时才拼好。两者互不依赖、各跑各的阶段。这也是为什么在 Editor 里拖动一个被 Mask 包裹的 ScrollView,裁剪矩形往往比模板状态"反应更快"。
下图把整条重建/渲染流水线画出来,RectMask2D 在 Cull 阶段、Mask 在 PreRender 阶段,两者不在同一时机:

Mask 的启用/禁用如何通知整棵子树
Mask.OnEnable()(Mask.cs)做了四件事:
graphic.canvasRenderer.hasPopInstruction = true——告诉 CanvasRenderer"我画完要弹回模板"。 graphic.SetMaterialDirty()——标记材质需要重建。 若同对象的 graphic is MaskableGraphic,把它的isMaskingGraphic设为true——这就是原稿说的"同对象只写不读"的来源。最后 MaskUtilities.NotifyStencilStateChanged(this)。
NotifyStencilStateChanged(MaskUtilities.cs)是对 GetComponentsInChildren 拿到的每个 IMaskable 调 RecalculateMasking(),但跳过 Mask 自身的 GameObject( gameObject == mask.gameObject 判断)。而 MaskableGraphic.RecalculateMasking()(MaskableGraphic.cs)会:先 StencilMaterial.Remove(m_MaskMaterial) 释放旧材质(避免 Graphic 被反复开关 Mask 后残留旧 stencil 材质),再把 m_ShouldRecalculateStencil 置 true,最后 SetMaterialDirty()。
OnDisable(Mask.cs)顺序相反,且先调 base.OnDisable() 让 IsActive() 先变错,确保通知子树时状态正确(注释明说)。它还会把 hasPopInstruction=false、popMaterialCount=0,并 Remove 两层缓存材质。注意:OnDisable 里是先清 pop 指令、再 Remove 材质——顺序不能反,否则 CanvasRenderer 还指着一份即将销毁的材质。
Stencil 深度到底怎么数,以及它为什么会在嵌套 Canvas 处"清零"
Mask.GetModifiedMaterial里:
var rootSortCanvas = MaskUtilities.FindRootSortOverrideCanvas(transform);var stencilDepth = MaskUtilities.GetStencilDepth(transform, rootSortCanvas);GetStencilDepth(MaskUtilities.cs)从transform.parent往上爬,每遇到一个MaskEnabled()且graphic.IsActive()的 Mask 就++depth(Mask.cs里MaskEnabled() = IsActive() && graphic != null`)。两个隐藏条件:
- 只数"有效"的 Mask
:Mask 的 Graphic 被关掉( graphic.IsActive()为 false)时,它不占 stencil 深度。所以关掉某个中间层 Mask 的 Image 显示,内层 Mask 的 bit 会整体往前挪。 - 爬到
overrideSorting的 Canvas 就停( FindRootSortOverrideCanvas.cs)。意思是:一旦遇到一个勾了overrideSorting的 Canvas(比如嵌套了一个独立排序的 Canvas),stencil 深度从这里重新计数。一个 Canvas 内部的 Mask 深度链,不会跨过"排序覆写 Canvas"继续累加。
这就是为什么有时候在深层嵌套 Canvas 里加 Mask,模板居然还能用——深度被 Canvas 边界"截"断了,不会轻易撞上 8 位上限。
MaskableGraphic 的脏标记与缓存时机
MaskableGraphic 不是每次都重算 stencil 值,而是用 m_ShouldRecalculateStencil 当"脏标志"(MaskableGraphic.cs)。GetModifiedMaterial(MaskableGraphic.cs)里:
if (m_ShouldRecalculateStencil) {if (maskable) m_StencilValue = MaskUtilities.GetStencilDepth(transform, rootCanvas);else m_StencilValue = 0; m_ShouldRecalculateStencil = false;}只有这个标志为真时才会重新数深度。m_ShouldRecalculateStencil 在以下时机被重新置 true(MaskableGraphic.cs):maskable 属性被改、OnEnable、OnDisable、OnTransformParentChanged、OnCanvasHierarchyChanged、以及 RecalculateMasking。
关键推论:把一个普通 Image 在运行期拖到某个 Mask 下面(改父节点),会触发 OnTransformParentChanged → 标脏 → 下一轮重建时重数深度 → 拿到正确的读模板材质。反之只是改了它的本地坐标(没改父链),标志不变,直接复用缓存的 m_MaskMaterial,不会重算。这是性能上的有意设计,但也意味着"深度相关属性"只在那几个生命周期点刷新。
Cull 用的包围盒来自 rootCanvasRect(MaskableGraphic.cs):取 RectTransform 四个世界角,用 rootCanvas.transform.worldToLocalMatrix 变换到根 Canvas 空间,再取四角的 min/max 求 AABB(注释提到它的修正为基于所有角点而非仅 0、2 两角)。注意这里是根 Canvas 空间,和 RectMask2D 的 canvasRect 用的"最近 Canvas"空间不同。
RectMask2D 的精确执行流程
RectMask2D.PerformClipping()(RectMask2D.cs)每帧(经 ClipperRegistry)被调一次,逻辑分三块:
1. 决定这一帧要不要重算父裁剪链(m_ShouldRecalculateClipRects)。只有这个标志为真才重新 GetRectMasksForClip 收集祖先 RectMask2D 列表 m_Clippers。所以"哪些 RectMask2D 罩着我"也是缓存的,不是每帧重新爬树。
2. 求交并判定自身是否被剔除(RectMask2D.cs):
Clipping.FindCullAndClipWorldRect(m_Clippers, out validRect)( Clipping.cs)把所有生效父 RectMask2D 的canvasRect逐个求交集,并各自减去padding。交集为空时validRect=false。padding 的语义: X=Left, Y=Bottom, Z=Right, W=Top(RectMask2D.cs),在求交时xMin+offset.x、yMax-offset.w这样扣(Clipping.cs)。maskIsCulled优化( RectMask2D.cs):在ScreenSpaceCamera/Overlay模式下,若本 RectMask2D 的矩形根本不和根 Canvas 矩形重叠,就直接把clipRect置零、标记validRect=false,让后代整体被剔除——省掉后续处理。
3. 下发给后代(RectMask2D.cs),这里有三条分支:
- clipRect 相对上次变了
( != m_LastClipRectCanvasSpace):对所有IClippable调SetClipRect,对所有MaskableGraphic调SetClipRect+Cull。 - 没变但
m_ForceClip为真(新加入的 clippable 会置此标志):同样下发 SetClipRect,并且仅当maskableTarget.canvasRenderer.hasMoved才重算Cull——避免对静止内容重复做剔除(绕 pivot 动画时hasMoved不够用)。 - 都没变
:只对 MaskableGraphic重新Cull,因为 cull 状态可能独立于 clipRect 变化。
最后 m_LastClipRectCanvasSpace = clipRect、m_ForceClip=false,并 UpdateClipSoftness()。
softness 是 Vector2Int 且被强制 Mathf.Max(0, …) 钳到非负(RectMask2D.cs、),经 SetClipSoftness(MaskableGraphic.cs)落到 canvasRenderer.clippingSoftness。下方是 PerformClipping 的完整分支流水线:

RectMask2D 的矩形空间——是"最近 Canvas"而非"根 Canvas"
RectMask2D.canvasRect(RectMask2D.cs)调用:
return m_VertexClipper.GetCanvasRect(rectTransform, Canvas);注意第二个参数是 Canvas 属性(RectMask2D.cs),也就是离它最近的那个 Canvas,不是根 Canvas。RectangularVertexClipper.GetCanvasRect(RectangularVertexClipper.cs)取 RectTransform 四世界角,再用 c.GetComponent<Transform>().InverseTransformPoint 变换到该 Canvas 本地空间。
这和 MaskableGraphic.rootCanvasRect 用根 Canvas 空间形成对比(补充四)。两者的裁剪/剔除计算因此各用各的参考系:RectMask2D 的 clipRect 在"最近 Canvas"空间,下发给 CanvasRenderer 时由它按所属 Canvas 解释;而 MaskableGraphic 的 Cull 包围盒在根 Canvas 空间。绝大多数情况两者重合(没有嵌套 Canvas),但一旦在 RectMask2D 子树里又套了一层普通 Canvas,这两个空间的差异就会显现——这也正是 GetRectMaskForClippable / GetRectMasksForClip(MaskUtilities.cs、187)里大段 IsDescendantOrSelf + overrideSorting 判断要处理的事:一个 RectMask2D 若处在某个"排序覆写 Canvas"的兄弟分支之外,会被排除出裁剪链(、)。
IClippable 被分成了两堆
RectMask2D 维护了两个集合(RectMask2D.cs):m_ClipTargets(HashSet<IClippable>)和 m_MaskableTargets(HashSet<MaskableGraphic>)。AddClippable(RectMask2D.cs)按 clippable as MaskableGraphic 是否为空分流。
为什么要分?因为 MaskableGraphic 既要实现 SetClipRect,又要单独响应 Cull;而一个普通的 IClippable(比如自定义裁剪器)可能只关心 SetClipRect。PerformClipping 里对 MaskableGraphic 多调了一次 Cull,正是靠这第二堆来区分。RemoveClippable还会先 clippable.SetClipRect(new Rect(), false) 把裁剪矩形清空(无效矩形 → 后代不再被裁),再把对象移出集合。
Mask和RectMask2D都实现了ICanvasRaycastFilter
其实 Mask 和 RectMask2D 都实现了 ICanvasRaycastFilter,这意味着挂了它们的 GameObject 会限制其自身及子节点的可点击范围:
Mask.IsRaycastLocationValid( Mask.cs):若未启用就返回true(不拦截),否则用RectangleContainsScreenPoint(rectTransform, sp, eventCamera)——不含 padding。RectMask2D.IsRaycastLocationValid( RectMask2D.cs):同样,但多了m_Padding参数,即点击区域会按 padding 再收紧一圈。
实践含义:给一个按钮套了 RectMask2D,按钮露出部分能点、被裁掉的部分点不动——这不只是"看不见",而是"点不到"。排查"明明元素还在、却点不中"时,要想到 Mask/RectMask2D 把射线命中区域也切了。
StencilMaterial 的几个藏得深的细节
往"先查缓存再克隆"再往下挖:
- 早退条件
( StencilMaterial.cs):if ((stencilID <= 0 && colorWriteMask == ColorWriteMask.All) || baseMat == null) return baseMat;。也就是说,stencilId 为 0 且颜色全写时直接返回原材质、不登记缓存——这类调用纯粹是"没意义地想改材质",直接跳过。 - 属性检查顺序
: _Stencil→_StencilOp→_StencilComp→_StencilReadMask→_StencilWriteMask→_ColorMask,缺任一就LogWarningWhenNotInBatchmode后返回原材质。注意用了Application.isBatchMode判断。 useAlphaClip规则( StencilMaterial.cs):operation != StencilOp.Keep && writeMask > 0。只有"真的在写模板"且写掩码非零时才开UNITY_UI_ALPHACLIPkeyword。所以 Mask 的读材质(Keep)永远不开 alphaclip,而写材质会开——这直接解释了为什么自定义 UI Shader 必须处理UNITY_UI_ALPHACLIP,否则被 Mask 包裹且需要按 alpha 裁的元素会出错。- 材质是运行时幽灵
: newEnt.customMat.hideFlags = HideFlags.HideAndDontSave。这些克隆材质不进序列化、不会出现在 Project 面板、改场景也不会保存,纯粹随会话存在。ClearAll()在域重载之类场景下整体销毁。 - 缓存键用引用相等
: ent.baseMat == baseMat。两套即使 shader 完全相同、只有Material实例不同的 base,也会各占一条缓存——这其实是对的,因为 stencil 参数要写进副本,base 不同副本状态也该独立。 与 Graphic.material联动:改material会触发SetMaterialDirty()(Graphic.cs),下一轮UpdateMaterial重新走materialForRendering→GetModifiedMaterial→StencilMaterial.Add,自然刷新引用。
性能与合批
Mask 与 RectMask2D 都会打断合批,但原因和代价完全不同。Mask 靠"模板缓冲 + 额外遮罩几何体"制造双重开销,且 stencil 位随嵌套深度变化,导致实例间几乎无法共享材质;RectMask2D 则靠"矩形裁剪 + 视口外剔除"省掉网格重建,代价仅是 clipRect 切换可能断批。所以,可滚动 / 裁切类场景优先 RectMask2D,只有需要圆形、异形等任意形状遮罩时才上 Mask。下面拆开说。
Mask 是"材质 + 几何"双重开销。 除了 stencil 参数让每个 Mask 实例的 IMaterialModifier 产物各不相同(Mask.GetModifiedMaterial 里 desiredStencilBit 随深度变,所以即便同层也基本不共享缓存),Mask 还会额外绘制它本身那张 Graphic 作为遮罩几何体(写入模板的那一张)。嵌套越深,stencil 位不同 → 写/读材质全部独立 → 合批几乎不可能。
RectMask2D 的真正性能优势是会"剔除"。RectMask2D.PerformClipping 对后代调 Cull(MaskableGraphic.cs),被 cull 的 Graphic 在 Graphic.Rebuild 开头就 return(Graphic.cs:if (canvasRenderer.cull) return;)——连网格都不重建。ScrollView 场景下,视口外的大量 Item 因此被整块跳过几何构建。而 Mask 不做 cull(它只是模板裁切,几何照建),所以 ScrollView 官方才建议用 RectMask2D。
RectMask2D 也会改 CanvasRenderer 的裁剪状态,这同样是合批的潜在断点。 连续元素之间 clipRect 一变,批次就可能断开;它与 Mask 的区别是:不额外画遮罩几何、不占 stencil buffer、不弹 pop 指令。所以两者都可能拆批,只是 Mask 还多背了"多画一张 + 模板状态 + pop"三笔账。
组件顺序敏感的 IMaterialModifier 链
Graphic.materialForRendering(Graphic.cs)用 GetComponents<IMaterialModifier> 拿到同对象上所有 modifier 后顺序执行。Mask 和 MaskableGraphic 都实现它,但如前所述在 Mask 对象上不会"一写一读叠两层";可如果又挂了一个自定义 IMaterialModifier(比如运行时换色/溶解),它的 GetModifiedMaterial 会按其组件顺序插在这条链里。链的顺序 = Inspector 里组件排列顺序,顺序不同,最终材质也不同。这是个容易踩的坑:同一个自定义 modifier,挂在 Mask 上方还是下方,加工到的"输入材质"已经不一样了(一个是原始 base,一个可能已是 stencil 变种)。
它会影响材质的哪些属性、开不开宏、会不会改本地的资源文件
1. Mask 动的是"运行时克隆出来的那份材质",不是本地资产
Graphic.materialForRendering 是只读 getter,Mask 借 IMaterialModifier.GetModifiedMaterial 让 StencilMaterial.Add 克隆出一份 new Material(baseMat)(StencilMaterial.cs)。这份克隆被标记:
newEnt.customMat.hideFlags = HideFlags.HideAndDontSave; HideAndDontSave意味着它**不进序列化、不写 .meta、不出现在 Project 面板、改场景也不保存**,纯内存对象。Inspector里设的Graphic.material(即 base material)**原封不动**——所有SetFloat都打在克隆体上(StencilMaterial.cs`)。所以 Mask 从不写原始材质。
2. 具体动哪些属性:只动 stencil / 颜色掩码 / alpha 裁切
克隆体上被写入的只有这 7 个:
_Stencil | |
_StencilOp | |
_StencilComp | |
_StencilReadMask | |
_StencilWriteMask | |
_ColorMask | |
_UseUIAlphaClip |
注意几点:
- 前提
:base 材质必须同时声明前 6 个( _Stencil/_StencilOp/_StencilComp/_StencilReadMask/_StencilWriteMask/_ColorMask),缺任意一个就LogWarning并直接返回 base(StencilMaterial.cs)——Mask 静默失效。这呼应原稿"自定义 UI Shader 不能只 include 一个文件"那节:这些属性要在 Shader 的Properties块声明,并在 ShaderLab 的Stencil{...}/ColorMask [...]里接到渲染状态。 - 别的属性不碰
:贴图( _MainTex)、颜色、uv 等都从 base 继承,克隆只改上面这一组。 克隆沿用 base 的同一个 Shader,所以 Shader 资产本身没有任何改动。 - 但如果以变更过材质属性的gameObject作为模板进行实例化或者clone,那么后续的gameObject可能会因为开启了可被裁剪属性且在某些屏幕的Mask区域外而导致误裁剪。
(曾被这事坑过)
3. 在"真写模板"时会开宏/关键字
newEnt.useAlphaClip = operation != StencilOp.Keep && writeMask > 0; if (newEnt.useAlphaClip) newEnt.customMat.EnableKeyword("UNITY_UI_ALPHACLIP"); // else newEnt.customMat.DisableKeyword("UNITY_UI_ALPHACLIP"); // UNITY_UI_ALPHACLIP这个 keyword 只在"真的在写模板且写掩码非零"时才开。Mask 的写材质( Replace/Zero,writeMask 非零)会开;读材质(Keep)不会开,反而被显式DisableKeyword。这是材质级关键字(per-material variant),不是全局 shader keyword,因此不会改 .shader 资产。它只决定这份克隆材质走不走片元 alpha 裁切分支。所以你的自定义 UI Shader 必须包含 UNITY_UI_ALPHACLIP变体,否则被 Mask 包裹又需要按 alpha 裁的元素会裁错。简言之:Mask 可能给运行时克隆材质开 UNITY_UI_ALPHACLIP这个 keyword,但永远不碰本地 Shader 源文件。
4. RectMask2D 对材质和 Shader 的影响是零
RectMask2D 走的是 canvasRenderer.EnableRectClipping(clipRect) + clippingSoftness(MaskableGraphic.cs),全是 CanvasRenderer 的运行时状态。它:
- 不创建任何材质
、不改任何 shader 属性、不开任何宏; 靠的是引擎 native 层在提交时按 clipRect 做矩形裁剪(GPU 端),和 stencil 是两码事。
所以,Mask vs RectMask2D 对材质/Shader 的影响:
new Material) | ||
UNITY_UI_ALPHACLIP,按条件) | ||
5. 会不会导致 Editor 里的资源变更——不会
Mask 的克隆材质是 HideAndDontSave,不会触发 AssetDatabase 写回,也不会改磁盘上的 Material / Shader 资产;Mask 与 RectMask2D 的源码都从不调用EditorUtility.SetDirty/AssetDatabase之类的写回。你可能在运行时看到 Hierarchy/Inspector 里 CanvasRenderer 的 clip rect / cull / pop 指令在变——那都是组件运行时字段,不序列化回 prefab 或资产,Play 停止后复原。 因为材质是运行时克隆且 HideAndDontSave,在 Editor 非运行态(比如直接看场景)是看不到这些克隆材质的;它们只在运行时由StencilMaterial的静态缓存m_List持有(StencilMaterial.cs),ClearAll()会在域重载等场景整体销毁。
总结
- Mask 走模板缓冲(stencil)
:它在运行时克隆材质,写入一组 stencil / 颜色掩码属性,且仅在真正写模板时给克隆材质开启 UNITY_UI_ALPHACLIP宏;用 pop 指令在子树画完后把模板位恢复。它额外画一张遮罩几何体,占用 stencil buffer 与批次。 - RectMask2D 走矩形裁剪(clip rect)
:它不改任何材质、不开任何宏,只在 CanvasRenderer 上设置裁剪矩形与 softness,并在 CPU 端做粗粒度 cull。ScrollView 等场景更推荐它,因为被裁掉的元素连网格都不用重建。 - 两者都实现
ICanvasRaycastFilter,会顺带把可点击区域也收窄到遮罩矩形内。 - 对工程资产零侵入
:Mask 的克隆材质标记为 HideAndDontSave,只存在于运行时内存,磁盘上的 Material / Shader 资产从不被修改。
—— 与君共勉~ ——















































































