
这问题我太熟了。拼图游戏碎片多每个碎片都要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里只放了两个小ImageDrawCall也一点不少。用一句话概括生活化类比合批就好比流水线上多个工人一起干同一种活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 思路BRectMask2D能不能顶上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 { QueueTransparent RenderTypeTransparent IgnoreProjectorTrue } 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个碎片各自设置不同RefShader方案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模板方案因为不用MaskSetPass就低。虽然没有用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 8UI分辨率1080x2340碎片数量32个。方案SetPass CallsDrawCallUI耗时ms内存增量全Mask方案961488.20RectMask2D预裁30562.840MB纯预裁贴图15321.560MBShader模板方案18361.72MB注意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 CPUUI合并网格、提交渲染命令的耗时合批成功率高这里就低。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其次用RectMask2DMask组件必须申请审批才能使用。每个Mask的引入都要在CodeReview里说明用途和预期数量。拼图这个项目后续又做了几款类似的休闲游戏都是沿用Shader模板方案做异形区域的裁切性能和效果都很稳。现在再看小伙伴那句“不能合批”我已经不会急着反驳了。他说得对Mask真的不能合批但优化不一定要走“去掉所有Mask”这条路更好的思路往往是换一种实现裁剪的方式绕开状态切换保住合批的同时让渲染流程更干净。最后再分享一个小技巧Shader模板方案的模板写入矩形如果只是作为“裁剪区域”使用建议把它的Graphic组件关闭或设置颜色Alpha为0避免它本身产生Overdraw。我踩过这个坑最初写模板层的Image还显示着结果多了一整层透明绘制虽然SetPass不高但Overdraw涨了。关掉显示后性能数据才算真正干净。这种细节不在Profiler里逐帧抠很容易漏掉。