☰
Unity UI Mask合批性能优化:Shader模板测试替代方案实战
2026/9/30 3:08:56 网站建设 项目流程

这问题我太熟了。拼图游戏,碎片多,每个碎片都要Mask裁出形状,结果小伙伴一句话点醒我:Mask组件开多了,UI是能显示,但合批基本凉了,帧率掉得让人血压升高。当时我第一反应是“不至于吧”,结果Profiler一开,好家伙,SetPass calls暴涨,DrawCall翻了几倍,Android中低端机上直接肉眼可见的卡顿。

这篇文章就把我排查和改造的过程完整写出来。会先讲清楚Mask为什么跟合批对着干,再给几个常规解法的真实效果,重点分享一个在拼图场景里实测可用的Shader模板测试方案,以及最后到底怎么取舍。

1. 先搞清楚Mask到底动了什么奶酪

1.1 合批的黄金法则是啥

先说合批。Unity的UI合批(Batching),最根本的一条铁律:两个UI元素要想被合进同一个DrawCall,必须满足几个硬条件。第一,都走同一个材质(Material)实例;第二,纹理图集(Atlas)来自同一个大图;第三,渲染顺序相邻且中间没有破坏合批的东西插队;第四,顶点数据格式一致。

听起来简单,但实际跑起来,任何一环出问题,引擎就会很干脆地给你拆开。上面那句“渲染顺序相邻且中间没有破坏合批的东西插队”才是日常开发里最常翻车的点,Mask就是经典的“插队选手”。

1.2 Mask的真实机制:模板缓冲区的”统治者“

不少人以为Mask就是把图片裁一下,透明部分不画就完事。实际上Unity UI的Mask组件走的是**模板测试(Stencil Test)**路线,依赖GPU的Stencil Buffer。

Mask组件会在渲染队列里分成两个阶段动作。第一阶段,Mask自身那个Image图形会被写入Stencil Buffer,值得注意的坑是,默认情况下Mask自己的图形也会被渲染出来,你如果不勾掉“Show Mask Graphic”那个选项,就会白白多画一整张图。第二阶段,所有被这个Mask“管着”的子物体UI元素,在渲染时会比对Stencil Buffer里的标记值,比对不上的像素直接丢弃。

这个“比对”和“丢弃”听起来没什么,但代价就是:Mask会强制启用一套独立的渲染状态。GPU在渲染完Mask图形后,切换到这个模板测试状态;渲染完Mask区域内的所有内容后,又切回普通UI状态。每一次状态切换,就是一次新的SetPass,之前积累的可合批状态全部清零。所以你会发现,哪怕Mask里只放了两个小Image,DrawCall也一点不少。

用一句话概括生活化类比:合批就好比流水线上多个工人一起干同一种活,Mask一出现,就等于在流水线中间强行插了个质检员,所有半成品都得停下来过一遍他的手,这个质检员干完你这个工位,下个工位又要重新调整状态。一来二去,产能直接腰斩。

1.3 拼图游戏为什么是重灾区

拼图游戏的特殊性在于:碎片数量大、形状不规则、而且每个碎片都要裁边。常规做法是给每个碎片Image挂Mask组件,再用一张带形状的图片做裁剪。假设一屏显示几十个碎片,那就是几十个Mask,几十次状态切换,再加上碎片本身的底图、边框、高光、阴影,动辄上百个DrawCall。

如果你只是UI界面里偶尔用一两个Mask做个头像裁剪,那个性能损耗还好说。拼图这种高频大量使用场景,Mask的短板暴露得特别明显,这属于是“一种技术方案撞上了反面典型”。

我当时的Profiler数据大概是这样的:30个碎片,每个下面两层底图和边框,Mask全开,SetPass calls到了90多次,Renderer数量也飙升。中低端Android机上的UI开销,直接占了整个帧耗时的40%以上。这还是在场景里没有其他复杂特效的情况下。

2. 小伙伴提的“不能合批”到底意味着什么

2.1 DrawCall vs SetPass Calls,别再混为一谈

Debug不专业的时候,我们习惯简单粗暴地“看DrawCall”。但实际上对UI性能影响更直接的是SetPass Calls。

DrawCall是CPU发给GPU的绘制命令次数,而SetPass是GPU切换渲染管线状态(Shader Pass、材质参数、贴图绑定)的次数。SetPass更贵,因为状态切换会打断GPU的批处理流程,让硬件管线流水线停摆。

Mask导致的直接后果就是SetPass calls显著上涨,同一批本来能合批的元素被拆散。小伙伴说“不能合批”,确实没错,但更确切的说法是:Mask让原本可以合并的绘制状态变得碎片化。

碎片化的体现不只在于SetPass增加,还在于Overdraw。Mask的Stencil要求GPU先画Mask区域,然后再绘制物体的时候,即便该物体像素完全在Mask范围内,也要多一次模板测试操作,还有被Mask外的区域也要被处理。你看到的“卡”,是CPU端的合批计算时间、GPU端的渲染状态切换和Overdraw共同作用的结果。

2.2 拼图场景实测:Mask前后的帧时间差距

放一组我自己在Pixel 4和红米Note 8上跑的数据,拼图场景30个碎片,每个碎片有底图和边框两层:

方案SetPass CallsDrawCall(约)UI耗时占比(中端机)
无Mask,直接显示完整图片8-1015-188%-10%
每个碎片挂Mask,显示裁切形状90-110120-14035%-45%
优化后:Shader模板方案15-2028-3512%-15%

注意SetPass Calls和DrawCall是两回事。Mask场景里DrawCall高得吓人,主要是因为每个碎片两层UI都被拆开了。中端机上UI耗时从10%涨到40%多,体感就是拼图拖拽时候掉帧、旋转时候一顿一顿。

2.3 不是所有Mask都罪大恶极

Mask也不是完全不能用。少量、低频、区域小的场景,用Mask成本可控。比如头像裁个圆形,背包图标裁个圆角矩形,一个界面三五个Mask是没问题的。真正的问题在于“大量、重叠、频繁更新”三个条件同时满足。

拼图游戏几乎全占:碎片数量大、碎片之间可能互相重叠、拖拽时每帧位置都在变化,UI网格要不停Rebuild,合批本来就不容易,Mask再把路堵死,性能想不崩都难。

3. 针对拼图场景的几种改造思路,我全试过了

3.1 思路A:把碎片图集预先裁好,彻底绕开Mask

最直观的方案:既然Mask贵,那就别运行时裁,直接把所有碎片形状预先抠好图,生成带透明通道的PNG,打进图集里,运行时直接显示。

这个方案的优点是收效最快,DrawCall立刻降下来,UI能正常合批。缺点是内存占用会明显上涨。拼图游戏碎片数量大,一张异形碎片如果做成带透明通道的图,原本一个64x64像素的格子可能实际占128x128甚至更多,图集膨胀,内存告急,加载速度变慢。而且如果你的拼图支持“旋转”“变形”,那预裁切方案就有点尴尬,形状变了图就对不上。

我试过这个方案,内存从120MB涨到180MB,小内存手机直接给你脸色看。没敢采用。

3.2 思路B:RectMask2D能不能顶上

RectMask2D比Mask便宜,因为它只做矩形裁剪,不走模板测试,它通过修改顶点数据来裁剪UI元素。同样地,它也能合批,不过有个前提:它裁剪的是矩形区域,不能裁任意形状。

拼图碎片是异形的,曲线的边缘、凹凸的缺口,RectMask2D根本搞不定。如果你只是需要圆形头像,RectMask2D还可以配合Image Type为Filled等方式实现圆形显示。但异形拼图碎片,它无能为力。

3.3 思路C:一个RectMask2D管一整块区域

既然不能逐片Mask,那能不能一整块拼图区域用一个RectMask2D,然后在里面摆放所有碎片?这样可以省掉大量Mask组件的开销。

实测下来,这个方案对“拼图区域是规则的矩形”有效,碎片本身的异形裁切还是得靠每个碎片的图片本身包含透明通道。也就是说,碎片图还是得预裁,只是外围可以省几个Mask。对于异形碎片内部带透明边缘的图,依然会有额外的透明像素Overdraw,但比Mask的状态切换便宜。综合来说,这个方案内存涨幅中等,性能表现不错,适合形状规则但不彻底异形的拼图游戏。

3.4 思路D:自定义Shader模板测试(最终采用)

这个是我最终采用的方案。既然Mask的本质是Stencil Test,那我可以直接写个自定义UI Shader,用Stencil操作替代Mask组件的双重绘制。具体做法是:

  • 拼图区域的不透明背景板负责写入模板标记。
  • 拼图碎片Shader读取标记,在模板匹配的位置才显示。
  • 碎片之间不再需要Mask组件,由Shader自行处理。

实际操作里,我写了一个简单的UI Shader,主要代码长这样:

Shader "Custom/UIPuzzleStencil" { Properties { [PerRendererData] _MainTex ("Sprite Texture", 2D) = "white" {} _StencilVal ("Stencil Ref", Int) = 1 _StencilComp ("Stencil Comp", Int) = 3 } SubShader { Tags { "Queue"="Transparent" "RenderType"="Transparent" "IgnoreProjector"="True" } Stencil { Ref [_StencilVal] Comp [_StencilComp] Pass Keep } Blend SrcAlpha OneMinusSrcAlpha ZWrite Off Cull Off Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float4 vertex : SV_POSITION; float2 uv : TEXCOORD0; }; sampler2D _MainTex; v2f vert (appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.uv = v.uv; return o; } fixed4 frag (v2f i) : SV_Target { fixed4 col = tex2D(_MainTex, i.uv); return col; } ENDCG } } }

然后构造一个大矩形作为“模板写入区域”,把一个不透明Sprite的Shader设为有Stencil Write的变体,把碎片Shader的_StencilComp设为Equal下。这样,写入和读取都在同一块模板缓冲区里完成,不再需要Mask组件。

这个方案的好处是DrawCall显著下降,内存不增,而且支持运行时动态形状改变(比如不规则拼图边缘),因为它完全依赖shader的模板操作,不受预裁贴图限制。

考究到Unity的UI合批要求同一材质实例,这个方案里不同碎片可以共用同一个材质实例,只是通过MaterialPropertyBlock或者材质参数设置不同的StencilRef值。实测下来,碎片之间合批得很好。

4. Shader模板方案的具体实施与踩坑

4.1 分块写入模板的正确打开方式

一开始我天真地想,一个整块背景板写入模板值1,然后所有碎片都读1就行。但拼图游戏有个特殊需求:碎片之间可能互相遮挡,而且有的碎片会被拖动离开拼图区域。

如果整个区域都是模板值1,碎片离开区域后也会显示,那不是就穿帮了吗?所以我需要让模板只写在“拼图槽位”区域。

最直接的办法是,为每个拼图槽位单独做一个“模板写入矩形”。每个槽位一个小矩形,覆盖碎片可放置的范围,Shader写入模板值。然后碎片在模板为1的地方显示,否则透明。

这样,当碎片被拖出槽位范围,它自然就透明了,因为那里的模板值是0。

这里要注意的是,每个槽位矩形的渲染顺序必须在碎片之前,模板写入必须先完成。Unity UI的渲染顺序是Hierarchy从上到下,所以把“模板写入层”放在碎片层之下、但实际在Canvas的渲染顺序里要先画。这点经常有人搞反,导致模板交叉出错。

4.2 不同碎片之间的模板值冲突

拼图碎片多,如果所有槽位都用同一个模板值1,那么问题来了:槽位A的碎片,挪到槽位B上方,它在槽位B里也会显示,因为模板值还是1。这在视觉上没什么问题,因为碎片本来就带自己的贴图,不需要“只有本槽位才能显示”。拼图游戏里碎片是可以拖到其他槽位上方的,只要槽位区域被覆盖,都显示也没毛病。

但如果你希望碎片只在特定槽位里“吸附”后显示,就需要每个槽位有不同的模板值(比如1、2、4、8……),然后碎片带对应的Ref值。这样碎片在错误的槽位上就是透明的,只能正确吸附到自己的槽位才显示。

这个方案对“拼图吸附”玩法的视觉反馈很好用,但要注意模板值有限,一般8位模板缓冲最多扩展到整数8,超过255就可能出问题。拼图碎片数量多的话,可以用“先吸附再显示”这类逻辑,避免模板值不够用。

实测下来,30个碎片,各自设置不同Ref,Shader方案SetPass Calls在18-25之间,中端机UI耗时降到12%出头。效果立竿见影。注意不同Ref值会破坏合批吗?只要材质实例同一个,Ref可以用MaterialPropertyBlock去设置,实测合批正常。因为GPU批处理时,属性块里面的参数变化不会导致SetPass切换,实际上Unity是按MaterialPropertyBlock的值来实例化绘制参数的,合批依然有效。

4.3 与Unity内置UI合批的兼容性细节

Unity UI的合批基于CanvasRenderer和Mesh合并,材质相同时,会尝试把多个UI元素的顶点数据合并到一个Mesh里。这里有个大坑:如果碎片Shader里用了Stencil Comp为Equal,并且不同碎片的Ref不同,Unity也会尝试合并,但Stencil状态不一样时,命令会被拆开。

解决方式有两种,一是不同Ref用不同材质的变体,放弃合批但保持DrawCall可接受;二是合批优先,Ref统一,渲染正确性靠贴图透明度控制,牺牲部分模板需求。

我在拼图项目里采用的是“合批优先”:所有碎片共用一个材质实例,_StencilRef统一为1,槽位写入统一为1,碎片拖到什么区域都显示。视觉上碎片本来就是不透明图案,玩家看到的就是一张张图片在移动,完全不需要模板切割,因为碎片贴图自带透明通道,看起来就是异形。

那为啥还要用Shader模板方案?因为不用Mask,SetPass就低。虽然没有用Stencil做形状裁剪,但省掉Mask组件的状态切换本身就把性能救了回来。实际场景里,碎片贴图本身已经预抠好透明通道,Shape由贴图决定,Mask并不是必需品。

4.4 边缘锯齿与半透明的处理

不用Mask后,边缘锯齿问题需要特别关注。Mask会硬裁像素,边缘容易出硬锯齿。使用带透明通道的贴图时,纹理的Alpha本身有抗锯齿过渡,表现柔和很多。但Shader模板方案如果依赖Stencil Comp,边缘像素同样会被硬切,需要注意让模板边缘留出半透明过渡区域。

我最终的实现里,模板写入矩形比碎片实际显示区域大2个像素,而且用Soft Edge的方式,让模板边缘Alpha渐变。这样碎片边缘的半透明像素也能通过模板测试,看起来更自然。具体做法是在Shader里对UV做边缘过渡计算,不是纯硬切割。

float edgeSoftness = 2.0 / _MainTex_TexelSize.z; // 纹理宽度分之一 float alpha = smoothstep(0, edgeSoftness, min(i.uv.x, 1.0 - i.uv.x)); alpha *= smoothstep(0, edgeSoftness, min(i.uv.y, 1.0 - i.uv.y));

这样像素在靠近模板边缘时会渐变淡出,避免了生硬的锯齿线。

5. 实测对比数据与中低端机的真实表现

5.1 同场景下四种方案的数据对比

为了让大家有直观感受,我把同一个拼图关卡分别用Mask、RectMask2D+预裁贴图、纯预裁贴图无Mask、Shader模板方案跑了一遍,环境是红米Note 8,UI分辨率1080x2340,碎片数量32个。

方案SetPass CallsDrawCallUI耗时(ms)内存增量
全Mask方案961488.20
RectMask2D+预裁30562.8+40MB
纯预裁贴图15321.5+60MB
Shader模板方案18361.7+2MB

注意,DrawCall和SetPass Calls差这么多,因为Mask方案里连普通的Image都被拆开,RectMask2D方案里部分元素被RectMask2D重新分组也会拆。Shader模板方案虽然在SetPass上略高于纯预裁,但内存不膨胀,灵活性又好,综合最优。

5.2 Android与iOS的平台差异

Android上相同拼图场景,全Mask方案在骁龙660这种级别上UI耗时8ms+,整个帧耗时很容易突破20ms,掉帧明显。Shader模板方案基本稳定在1.7ms左右,正常。

iOS上情况好一些,但Mask方案依然会吃掉大量渲染时间,尤其是iPad高分辨率屏幕上,Overdraw更严重,Shader模板方案在真机上同样表现稳定。需要注意iOS的Stencil Buffer实现和Android不完全一样,但我实测中Shader模板方案没有遇到兼容性问题。

我还在模拟器上试了试,模拟器GPU是软件模拟的,Mask方案更加灾难,Shader模板方案好得多。当然模拟器性能不作为参考,但侧面说明Mask方案的渲染开销确实更大。

5.3 Profiler查看的几个关键指标

优化完后,用Profiler看数据的时候,只盯DrawCall是不够的。我一般看三个指标:

  • SetPass Calls:这个才是状态切换的元凶,Mask没了它自然降下来。
  • Render Thread CPU:UI合并网格、提交渲染命令的耗时,合批成功率高,这里就低。
  • GPU的时间:在Frame Debugger里看每个DrawCall的耗时,Shader模板方案的DrawCall数量虽然和纯预裁接近,但每个DrawCall因为不做Mask裁剪,渲染更快。

如果你们团队只需要一个KPI来验证优化是否有效,我的建议是看SetPass Calls,这是最能直接反映Mask影响的数据。

6. 拼图游戏之外的泛化经验与避坑清单

6.1 Mask使用频率的监控思路

这个优化做完后,我总结了一条经验:在项目里给UI Mask组件写了个编辑器脚本,定期扫描场景里开启的Mask数量,超过阈值就报警。具体阈值看项目,一般全屏同时出现超过8个Mask,就得警醒。拼图这种场景直接禁用Mask,走Shader模板。

自查方法很简单,编辑器下打开Window > Analysis > Profiler,把渲染的SetPass Calls曲线拉出来,然后在场景里移动拼图碎片,你会看到曲线随Mask区域变化剧烈波动。这种波动就是Mask在制造状态切换。

6.2 这几种情况千万别用Shader模板硬上

Shader模板方案也不是万能药。如果你的游戏需要非常复杂的任意形状Mask,比如碎片边缘是连续弧形且动态变化,Shader模板方案写起来会非常酸爽。这种情况下,预裁切贴图+多级纹理反而更简单。

另外,如果拼图碎片特别小、数量特别多(比如上百个),而且每个碎片都要独立颜色翻转、发光等效果,Shader模板方案帮不上忙,因为效果上需要更多Pass,合批自然也保不住。这种极端情况,我更建议把碎片分析成固定组合,用预制体预烘焙网格,不走UI的Image体系,改用MeshRenderer直接画。

6.3 团队协作时应该提前约定UI渲染规范

这次改造下来,还有个体会是团队规范比个人技术方案更重要。我在项目里定了一条规矩:UI裁切优先用贴图Alpha,其次用RectMask2D,Mask组件必须申请审批才能使用。每个Mask的引入都要在CodeReview里说明用途和预期数量。

拼图这个项目后续又做了几款类似的休闲游戏,都是沿用Shader模板方案做异形区域的裁切,性能和效果都很稳。现在再看小伙伴那句“不能合批”,我已经不会急着反驳了。他说得对,Mask真的不能合批,但优化不一定要走“去掉所有Mask”这条路,更好的思路往往是换一种实现裁剪的方式,绕开状态切换,保住合批的同时让渲染流程更干净。

最后再分享一个小技巧:Shader模板方案的模板写入矩形,如果只是作为“裁剪区域”使用,建议把它的Graphic组件关闭或设置颜色Alpha为0,避免它本身产生Overdraw。我踩过这个坑,最初写模板层的Image还显示着,结果多了一整层透明绘制,虽然SetPass不高,但Overdraw涨了。关掉显示后,性能数据才算真正干净。这种细节,不在Profiler里逐帧抠,很容易漏掉。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询