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、C(push)、D、C(pop)、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 甚至 5,SetPass Calls 从原来的 1 变成 3 到 4。这个简单的变化已经说明问题了:仅仅一个 Mask,就把整个合批链从 1 拆成了多段。
我建议你做一个对照实验:把元素 3 的 Mask 换成 RectMask2D,然后再次观察 Stats。你会发现 Batches 又回到了接近 1 的水平。这个对照是理解整套机制最快的方式。
2.2 Frame Debugger 里看到的真相
Stats 面板只能告诉你“变多了”,但看不到具体为什么。这时候打开 Window > Analysis > Frame Debugger,Enable 之后逐帧查看。你会在 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。每插入一个 Mask,Batches 大约增加 2 到 3,SetPass 增加 1 到 2。如果界面里有 10 个 Mask,Batches 就会从 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 那两下额外绘制。
两者的对比可以看这张表:
| 对比项 | Mask | RectMask2D |
|---|---|---|
| 裁剪原理 | 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 就不用 Mask,RectMask2D 同样也存在网格重建的开销,但因为不涉及模板状态的复杂变化,通常比 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 隔离起来。希望这篇文章能帮你省下几个通宵排查性能问题的夜晚。