夜雨聆风学习资料网

ARTICLE · 1351488

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

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 恢复,可以用下面这条流程串起来:

OnDisable 会同时撤销 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)里,顺序是:

  1. 布局阶段 Prelayout → Layout → PostLayout,依次调用各 ILayoutElement/ILayoutController 的 Rebuild。
  2. 布局结束后、渲染前,调用 ClipperRegistry.instance.Cull()——这正是 RectMask2D 的舞台。ClipperRegistry.Cull()(ClipperRegistry.cs)遍历所有已注册的 IClipper,逐个调 PerformClipping()。
  3. 之后才是 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_ALPHACLIP keyword。所以 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
模板参考值(stencilId)
_StencilOp
模板操作(Replace / Keep / Zero …)
_StencilComp
模板比较函数(Always / Equal …)
_StencilReadMask
读掩码
_StencilWriteMask
写掩码
_ColorMask
颜色写入掩码(控制是否真的画颜色)
_UseUIAlphaClip
是否启用 alpha 裁切(0/1)

注意几点:

  • 前提
    :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 的影响:

维度
Mask
RectMask2D
是否克隆材质
是(运行时 new Material)
否
改写材质属性
是(stencil/colormask/alphaclip 7 项)
否
是否开 shader keyword
是(UNITY_UI_ALPHACLIP,按条件)
否
是否改 .shader / .mat 资产
否(只动内存克隆体)
否
是否触达 CanvasRenderer 运行时字段
是(pop 指令、材质)
是(clipRect、softness、cull)

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 资产从不被修改。

—— 与君共勉~ ——

回首页,搜索你想要的文档

在首页搜索框输入关键词,快速定位资料

文库首页搜索指引 立即搜索

相关学习资料