
1. Mask 的成本到底去哪了先把原理讲清楚1.1 一个 Mask 在 GPU 上到底干了三件事UGUI 的 Mask 组件本质上是基于 Stencil Buffer 的裁剪方案。Stencil Buffer 可以理解成一块“镂空模板”——先在一张模板上挖出形状之后所有画上去的内容只有在模板被挖空的位置才会真正落到屏幕上其余位置直接被丢弃。这个机制非常适合做圆形头像、滚动列表边缘遮罩、技能冷却倒计时这些需求所以 Mask 组件在 UI 里特别常见。但很多开发者没意识到的是一个 Mask 在运行期并不是“附带了一个裁剪属性”这么简单。它有至少三个独立的 GPU 阶段推入模板push把 Mask 自身 Graphic 的网格渲染一遍这一步会把模板缓冲区里对应区域的数值写成一个特定值。可以理解成“把镂空模板放到画布上”。渲染子物体stencil test子物体在渲染时都要做模板测试只有模板值恰好等于当前 Mask 设定的值时像素才会被保留。这是“用模板挡住不该出现的内容”。弹出模板pop恢复进入该 Mask 之前模板缓冲区的状态。相当于“把模板从画布上拿下来恢复原样”。这三步里第 1 步和第 3 步都要把 Mask 自身的 Graphic 再画一遍。注意这个行为和你勾不勾“Show Mask Graphic”没有关系——勾了只是多画一次让你看见 Mask 图形不勾的话push/pop 那两下仍然存在。我第一次意识到这个问题的场景是这样的一个活动界面里放了大概二十个圆形头像每个头像为了切圆都套了一个 Mask。当时在编辑器里跑得挺流畅但一放到低端安卓机上打开头像列表就掉帧。刚开始我以为是头像加载或动画的问题查了半天没结果。后来打开 Profiler 看了一眼 UI 模块才发现 Batches 数量已经涨到四五十SetPass Calls 更是吓人。把 Mask 全部换成 RectMask2D 之后帧率立刻回来了两倍。从那时候起我对 Mask 就多了一个心眼它是个能用但代价很高的组件必须搞清楚它到底贵在哪。1.2 合批最怕的就是渲染状态中途变化要理解这个陷阱得先理解 UGUI 的合批逻辑。合批Batching的本质是把多个 UI 元素的绘制合并到一个 DrawCall 里前提是它们具备完全相同的渲染状态同一个材质实例、同一张纹理、相同的 Shader 状态以及——重要——相同的模板Stencil设置。这些元素在渲染顺序上还得是连续排列的中间不能夹着别的状态的元素。你可以把合批想象成一台自动打印机的连续作业。只要纸张的类型不变机器可以一直吐纸。可一旦中途要插入一张特殊纸或者要把某种特殊纸抽走机器就必须停下来换纸。每一次换纸都是一次新的作业这就是 SetPass Call 的来源。Mask 的问题恰恰在这进入 Mask 前UI 元素处于“无模板”的渲染状态进入 Mask 后子物体处于“带模板测试”的渲染状态离开 Mask 后又回到“无模板”状态。每一次状态切换都会强制当前批次结束再开启新批次。所以哪怕一个 Mask 下面只挂了一张小图整个 Canvas 的合批链也会因为这个 Mask 被切断成三段甚至更多。更致命的是合批是链式的。你界面上如果分布了 5 个 Mask它们会把一个本来可以整体合批的 UI 切碎成十几段。在 Profiler 里看到的不是你多了 5 个 DrawCall而是整个 UI 的 Batches 数量成倍上涨SetPass Calls 可能从个位数变成几十。这里还要说一个容易被忽略的细节Mask 对合批的破坏不只发生在“遮罩内部”还会波及遮罩前后的普通元素。假设你的层级顺序是普通图片 A → 普通图片 B → 带 Mask 的容器 C → 容器里的子图 D → 普通图片 E。渲染顺序是 A、B、Cpush、D、Cpop、E。A 和 B 之间的状态一致可以合批B 和 C 的关系就不行了因为 C 的 push 引入了模板状态D 虽然只和 C 绑定但它和 C 的 push、pop 是三个不同的状态E 又要重新回到无模板状态。最终这一个简单的 UI 被拆成了五六个批次。1.3 最容易忽略的真相Mask 裁的是网格不是图片 alpha这里有个我踩过且身边不少同事也踩过的坑Mask 的裁剪形状取决于挂在 Mask 上的那个 Graphic 的网格形状而不是图片的透明区域。默认情况下Image 组件的 Type 是 Simple它生成的网格是一个矩形 Quad四角即使图片是透明的网格依然覆盖整个矩形范围。也就是说你拿一张圆形 Sprite 放到 Mask 上指望它裁出圆形通常是不行的——它裁的还是那个矩形的范围四角该露的东西还是会露出来。要让 Mask 严格按 Sprite 的形状裁剪要么在 Image 上勾选 Use Sprite Mesh这会使用 Sprite 自身的多边形网格但网格只是对形状的近似放大后仍然可能不精确要么就得用带 alpha clip 的 Shader 来丢弃透明像素。这个坑最容易出现在做“圆形头像列表”的时候美术给了一张圆形头像贴图程序为了“保险起见”套了一个 Mask结果发现相邻头像的矩形边缘还是露出来甚至遮住了列表外的元素。根源就是 Mask 拿矩形网格写了模板于是所有子物体都被限制在一个矩形里而不是圆形里。记住一句话Stencil 是跟着几何网格走的不是跟着贴图像素走的。2. 亲手复现一次用 Frame Debugger 看到合批被拆散2.1 搭一个最小测试场景光讲原理你可能没有体感建议你花五分钟搭一个最小场景复现一下。新建一个 Canvas在下面按顺序放六个 Image元素 1普通 Image使用默认 Sprite元素 2普通 Image使用同一张 Sprite元素 3带 Mask 组件的 Image内部放一个子 Image元素 4普通 Image使用同一张 Sprite元素 5普通 Image使用同一张 Sprite场景搭好后先打开 Game 视图右上角的 Stats 面板记一下 Batches 和 SetPass Calls。这时候你应该看到 Batches 非常少因为五个元素如果没有 Mask完全可以合批成一个批次。然后给元素 3 挂上 Mask 组件不需要勾 Show Mask Graphic再观察 Stats 面板。你大概率会看到 Batches 从 1 变成 4 甚至 5SetPass Calls 从原来的 1 变成 3 到 4。这个简单的变化已经说明问题了仅仅一个 Mask就把整个合批链从 1 拆成了多段。我建议你做一个对照实验把元素 3 的 Mask 换成 RectMask2D然后再次观察 Stats。你会发现 Batches 又回到了接近 1 的水平。这个对照是理解整套机制最快的方式。2.2 Frame Debugger 里看到的真相Stats 面板只能告诉你“变多了”但看不到具体为什么。这时候打开 Window Analysis Frame DebuggerEnable 之后逐帧查看。你会在 Canvas.Render 事件下看到当前帧的每一个批次点开某个带 Stencil 的批次右侧面板会显示当前的渲染状态其中 Stencil 相关的参数是关键。正常的 UI 元素Stencil 通常是 Disabled 或者 Ref 为 0。进入 Mask 后你会看到类似 Stencil Ref 4、Comp Equal、Pass Keep 这样的状态。换句话说GPU 在这个批次里不仅要在意贴图颜色还要先做一次模板比较只有模板值等于 4 的像素才被绘制出来。而没有 Mask 的元素根本不会出现这组状态。Frame Debugger 里还能看到同一个 Mask Graphic 被绘制了多次。如果你点开好几个批次会发现它们引用的是同一个物体。这就是前面说的 push、pop 机制——它把面具自己的网格画了一遍又一遍只是每次画时的 Stencil 指令不同。这个现象在平时很难注意到因为结果显示在屏幕上完全一样只有逐帧调试才能看出来。实操提示Frame Debugger 需要暂停在具体帧上才方便观察如果 UI 有动画可以先把 Time Scale 调到 0找一个稳定状态再看。否则你看到的批次编号每一帧都在跳很难对比。2.3 用 Profiler 量化损失Stats 面板适合快速看真正要量化损失还是得靠 Profiler。打开 Profiler 的 UI 模块里面有两个关键指标Batches 和 SetPass Calls。Batches 是实际提交的批次数量SetPass Calls 是渲染状态切换的次数。移动平台上SetPass 的成本通常远高于桌面设备因为每一次状态切换都可能触发驱动层重新配置渲染管线。我做过一个粗略测试一个只有 10 个 Image 的界面全部用同一张 Sprite正常情况下 1 个 Batch、1 个 SetPass。每插入一个 MaskBatches 大约增加 2 到 3SetPass 增加 1 到 2。如果界面里有 10 个 MaskBatches 就会从 1 涨到 30 左右SetPass 也会突破 20。在低端安卓机上一个 SetPass 大约要消耗 0.2 到 1 毫秒20 个 SetPass 就意味着光渲染状态切换就吃掉 4 到 20 毫秒帧预算直接爆掉。这里想强调一下很多人在编辑器里看不出问题是因为编辑器下的图形 API 和驱动对状态切换的优化远比移动端激进。同样的 UI在 PC 上可能 60 帧稳稳的到手机上就卡成狗。所以做 UI 合批优化时请在目标平台上做真机 Profile不要只盯着编辑器看。除了 UI 模块还可以看 CPU 侧的 Canvas.SendWillRenderCanvases 耗时。Mask 组件的启停、子物体的增减都会触发 Canvas 重新生成网格和批次信息。如果你的代码里频繁 SetActive 带 Mask 的物体这个指标的耗时就会明显上涨。3. 优化方案五个能直接落地的替代思路3.1 矩形裁剪一律用 RectMask2D别碰 Mask这是最直接有效的一条你的需求只要是矩形裁剪比如滚动列表在视口内滑动、聊天框内容超出边界被隐藏、弹窗内容被裁切统一用 RectMask2D。RectMask2D 不碰 Stencil 缓冲区它走的是另一条路在 CPU 侧根据裁剪矩形重新计算子物体的网格把超出矩形部分的顶点裁掉或者压到矩形边界上。材质不变、纹理不变、Stencil 不变所以它对合批的伤害要小得多。RectMask2D 的使用方式几乎和 Mask 一样放在一个带 RectTransform 的节点上子物体自然会被裁剪。它不需要一个 Graphic 来提供形状因为它本来就是纯矩形所以也不会有 push/pop 那两下额外绘制。两者的对比可以看这张表对比项MaskRectMask2D裁剪原理Stencil Buffer 模板测试CPU 侧重算网格矩形裁剪额外 GPU 绘制至少 push/pop 两次无对合批的影响强切断前后批次弱材质不变基本不破坏批次支持的裁剪形状理论上任意 Graphic 网格形状仅矩形旋转子物体支持支持矩形裁剪旋转内容按包围盒裁适用场景圆形头像、不规则遮罩滚动列表、滑动区域的矩形裁剪看到没绝大多数“遮罩”需求其实都是矩形裁剪用 RectMask2D 就对了。你在代码里只要看到 Mask 组件第一反应应该是问自己这里真的需要任意形状吗如果只是矩形立刻换掉。3.2 让美术把形状做进贴图从根上消灭遮罩还有一个更彻底的办法不需要运行时遮罩直接把形状做在美术资源里。圆角按钮就交给美术出圆角贴图圆形头像直接用圆形 Sprite 做可视内容进度条底图把左右端做成圆角再配合九宫格拉伸。大多数你以为需要 Mask 的地方其实只是美术资源没到位而已。这种方案对合批的影响是零因为图片的 alpha 通道天然就是“裁剪”——GPU 一样会画整个 Quad但透明区域混合后什么都看不见。直观的结果和 Mask 几乎没有差别但 DrawCall 完全没有增加。唯一要接受的是透明区域的像素仍然会被光栅化会消耗一点填充率。如果特别在意填充率可以给 UI 用一个带 clip 的 Shader让 alpha 小于阈值的像素直接 discard而不是继续参与混合。但你要注意换 Shader 本身会改变材质多个不同 Shader 的元素之间又没法合批了所以这个取舍要结合实际情况。这里有个细节值得说如果只是做“圆形头像”更常规的做法是头像本身就用圆形贴图外面叠一个圆环边框贴图用两个 Image 拼出来。这个方法在无数项目里验证过性能上没有任何额外开销。如果你还关心“点击区域不能点在头像四角上”那就给 Image 设置 alphaHitTestMinimumThreshold让透明区域的点击穿透这个属性只影响射线检测不影响渲染批次。3.3 用 shader 级平滑遮罩替代 Stencil还有一种常见需求是“软遮罩”也就是遮罩边缘有渐入渐出的过渡效果。这种情况用 Mask 做不出来因为 Stencil 是二值的要么挡住要么放行没有中间值。社区里流行的 SmoothMask 方案其实是渲染管线层面的替代把一张遮罩图采样进 Shader用 alpha 值来做混合或者 clip遮罩的边缘可以做模糊过渡视觉效果好得多。这类方案的好处在于不依赖 Stencil所以理论上不会像 Mask 那样切断合批。但实际使用时有一个要命的前提所有使用同一个遮罩 Shader、同一张遮罩贴图的元素才能合批。如果你给十个不同的 Mask 分别用了十张不同的遮罩贴图合批照样保不住。所以用这种方案时要把多个遮罩图案合并到同一张贴图的 R、G、B、A 四个通道或者把它们全部打进一张 Mask 图集里让所有需要用遮罩的素材都引用同一张纹理。从项目实践角度看这种方案很适合做头像的圆形裁切加边缘羽化一个 Shader一张包含所有遮罩图案的贴图所有头像共用既没有 Stencil 的批次破坏又支持软边。代价是需要维护一张自定义贴图并且要确保打包时不留空隙。对于大型项目这值得做成一个公共 UI 遮罩工具而不是让每个界面都去拖 Mask 组件。3.4 如果必须用 Mask就把它的破坏范围隔离有些场景确实只有 Mask 能做比如一个不规则形状的界面遮罩或者纯商业需求里美术要求的那种异形裁剪。这时候可以采取“隔离”思路不要让 Mask 穿插在大量普通 UI 元素中间而是把它放在 Canvas 渲染顺序的最前面或最后面让被它切断的合批链尽量短。进一步的做法是单独拉一个 Canvas把带 Mask 的内容隔离到独立 Canvas 里通过 Sorting Order 控制它显示在上层还是下层。这样主 Canvas 的合批链不会被 Mask 干扰遮罩的破坏被限制在它所在的子 Canvas 内。代价是一个独立的 Canvas 有自己的渲染提交移动端上 Canvas 数量不建议超过五六个否则也是一笔额外开销。但比起十个 Mask 把主界面切成几十段的灾难隔离出来通常划算得多。还有一个小技巧同一个 Mask 内部的子元素尽量保证它们共用同一张图集、同一个材质并且渲染顺序连续。因为 Mask 内部的子物体都带着相同的 Stencil 状态它们之间理论上还是可以合批的。如果你把不同纹理、不同材质的元素乱插在里面或者中间隔着空对象合批链还是会断。换句话说你没法消除 Mask 带来的边界成本但至少可以让“里面那一段”尽可能长。3.5 顺带提醒和 Mask 一样破坏合批的“近亲”搞清楚了 Mask 的原理后你会发现 UGUI 里有几个组件和它很像都是通过改状态或改网格来打断合批的。比如 UI 的 Outline 和 Shadow 效果组件它们会给原图形额外生成若干个偏移副本相当于一个 Image 变成了四个小 Image四张副本的 SiblingIndex 也是紧挨着的但这种额外网格会让批次数量上升。还有自定义材质、不同 Z 轴深度、Canvas 的 Sorting Order 变化都会影响合批。实际项目中UI 性能优化最忌讳的就是“几个问题叠加”既有 Mask、又有 Outline、又有小图集碎片每个看起来都不严重叠加起来帧率就崩了。所以排查的时候不要把目光只盯在 Mask 上要把所有会改变渲染状态的组件一起列出来检查。4. 避坑清单与排查速查表4.1 为什么 Mask 下的文字和图片就是不合批这是一个高频疑问同一个界面里Image 和 Text 放在同一个 Mask 下面明明渲染顺序连续为什么还是拆成两批原因很简单文字用的是 Text 或者 TextMeshPro 的材质和 Image 的默认 UI 材质不同材质一不同合批条件就不满足了。这不是 Mask 的锅但 Mask 会放大这个问题——因为 Mask 让子物体的材质经过了一套 Stencil 再包装不同字体材质之间的差异会更明显地体现为批次增加。排查方式在 Frame Debugger 里看每个批次的材质实例。如果两个相邻元素合不上批先看材质实例 ID 是否一致、贴图是否一致、Shader 是否一致。不要一上来就怀疑 Mask。4.2 圆形 Mask 裁成方形或者边框溢出前面说过Mask 的裁剪范围跟着 Graphic 的网格走。如果你在 Mask 上放了一张圆形 Sprite 但没勾 Use Sprite Mesh那你得到的就是一个矩形裁剪区域。具体表现是圆形头像边缘外还有一个矩形区域在裁剪四角的东西可能被错误保留或错误遮挡。解决方案有三个方向一是让 Mask 上的 Image 勾选 Use Sprite Mesh前提是你导入的 Sprite 有足够的几何精度二是把遮罩形状改为 Shader 实现三是干脆放弃运行时遮罩让美术直接出圆形贴图。我的经验是第三个方向最省心因为绕开了引擎的裁剪边界问题。4.3 粒子系统和自定义 Shader 放在 Mask 下面不生效这个坑非常隐蔽而且破坏力极强。很多人把粒子特效放进 Mask 下面期望特效只在指定区域内显示结果发现特效直接无视 Mask 完整渲染出来或者整个区域都看不到粒子。原因是 Mask 的裁剪依赖模板测试而 UI 粒子系统的默认 Shader 根本没有声明任何 Stencil 参数。没有模板测试粒子自然不会被遮住。解决途径是找到粒子用的 Shader手动给它加上 UGUI 风格的 Stencil 块——设置 Comp Equal、Pass Keep、Ref 与 Mask 的 Stencil 值一致。但这里操作起来很容易踩坑因为不同 UGUI 版本对 Stencil 的默认 Ref 计算方式有差异。更稳妥的做法是使用支持 Stencil 的 UI 粒子专用 Shader社区里有很多现成方案注意测试不同 UI 层级与多个 Mask 嵌套的情况。4.4 频繁开关带 Mask 的界面导致 CPU 尖刺还有一个性能问题是 CPU 层面的。带 Mask 的物体 SetActive 时Canvas 需要重新计算该区域的批次和模板状态。如果你在一个滚动列表里频繁复用带 Mask 的 Item每次复用都触发重建CPU 尖刺会非常明显。表现就是 Profiler 里 Canvas.SendWillRenderCanvases 或者 Canvas.BuildBatch 占用突然拉高。遇到这种情况优先检查你的对象池回收逻辑。如果只是因为显示隐藏试试用 CanvasGroup 的 alpha 控制透明度而不是 SetActive或者把物体移出可视区域而不是销毁。当然前面也说了如果能用 RectMask2D 就不用 MaskRectMask2D 同样也存在网格重建的开销但因为不涉及模板状态的复杂变化通常比 Mask 轻很多。4.5 排查遮罩性能问题的操作流程这里整理一个我实际使用的排查顺序供你参考打开 Profiler 的 UI 模块记录 Batches 和 SetPass Calls 的基线值。在 Game 视图里逐个禁用可能有问题的 Mask每禁用一个就观察指标变化。如果某个 Mask 禁用后指标大幅下降它就是元凶。用 Frame Debugger 定位到具体批次检查带 Stencil 的绘制事件确认状态切换的位置。判断该 Mask 的裁剪需求类型矩形用 RectMask2D圆形用贴图或 Shader不规则形状再考虑保留 Mask。真机 Profiling 对比优化前后的帧耗时特别关注低端安卓的真机数据。这个流程基本能覆盖 90% 的 UGUI 遮罩性能问题。如果你发现优化完 Mask 之后 Batches 还是很高那就要回到合批的基本条件去查图集是否散乱、材质是否统一、渲染顺序是否连续、是否存在多余 Canvas。这些是另一个话题但排查思路完全一样。做 UI 优化这几年我最大的体会是性能问题往往不是某个单一技术点造成的而是开发时对“默认组件”的滥用。Mask 就是个典型的例子——它太好用了以至于很多人根本没意识到它内部有多重。我现在写 UGUI 相关的代码默认只用 RectMask2D遇到圆形头像先问美术贴图只有真到了万不得已才上 Mask并且一定会把它的影响范围通过 Canvas 隔离起来。希望这篇文章能帮你省下几个通宵排查性能问题的夜晚。