ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Unity UGUI合批优化实战:从原理到DrawCall压降的完整指南

Unity UGUI合批优化实战:从原理到DrawCall压降的完整指南

1. 从一个卡顿的背包界面说起

做过Unity项目的人大概率都经历过这种场景:项目前期跑得挺流畅,UI一多、界面一复杂,帧率就开始往下掉,尤其是打开背包、排行榜、商城这类格子特别多的界面时,手机直接烫手,帧率从60掉到30甚至更低。打开Profiler一看,Canvas.BuildBatch或者Canvas.SendWillRenderCanvases占了一大块耗时,这时候你就知道,UI的合批出问题了。

这篇内容聊的就是Unity UI优化里最核心的一块——Batch(合批)。我会把UGUI的合批机制拆开讲清楚,包括它到底怎么工作的、什么情况下会断批、有哪些实操手段能把DrawCall压下来,以及我在实际项目里踩过的坑和验证过的方案。内容适合已经写过UGUI、但被UI性能问题折磨过的开发者,也适合刚接触UI优化、想系统搞明白合批原理的新手。看完你应该能自己动手把一个复杂界面的DrawCall从几百降到几十,并且知道每一步为什么这么做。

需要先说明一点:UGUI的合批和3D渲染的静态/动态合批不是一回事,它走的是Canvas自己的重建和网格合并逻辑,理解这一点是后面所有优化的前提。

2. UGUI合批机制到底是怎么运转的

2.1 Canvas是合批的基本单位

UGUI里所有的UI元素都必须挂在一个Canvas下面,Canvas是合批的最小管理单元。一个Canvas下的所有UI元素,Unity会尝试把它们合并成尽量少的DrawCall。这里有个关键点:不同Canvas之间不会合批。也就是说,如果你场景里有三个Canvas,那至少就是三个批次起步,哪怕它们用的是同一张图集。

所以第一个原则就出来了:能合并到一个Canvas的UI,就别拆成多个。但这里有个矛盾——Canvas下的元素一旦有任何一个发生变化(位置、颜色、显隐、文本内容),整个Canvas都会触发重建(Rebuild),重建又分两种:Layout重建和Graphic重建。元素越多,重建成本越高。所以Canvas的拆分本质上是在DrawCall数量和重建成本之间做权衡。

我一般的做法是:静态不动的UI(比如背景、边框、固定按钮)放一个Canvas,动态变化的UI(比如血条、倒计时、滚动列表)单独放一个Canvas。这样动态部分重建时不会连累静态部分。

2.2 合批的判定条件

Unity判断两个UI元素能不能合批,核心看几个条件,我用一个表格列清楚:

判定条件说明是否影响合批
材质(Material)必须是同一个材质实例是,材质不同必断批
纹理(Texture)同一材质下纹理需一致,或来自同一图集是,不同图集断批
渲染层级重叠两个元素的渲染顺序区间不能交叉是,交叉会强制断批
Mask/RectMask2D使用Mask会打断合批是,Mask内外通常断批
层级顺序中间插入不同材质的元素会断批是

这里最容易踩坑的是渲染层级重叠。举个例子,你有元素A(图集1)、元素B(图集2)、元素C(图集1),按渲染顺序排下来是A、B、C。虽然A和C用同一张图集,但中间隔了个B,A和C的渲染区间被B隔开了,Unity就没法把它们合到一起,只能拆成A、B、C三个批次。这就是所谓的“交叉断批”。

解决办法很简单:把用同一图集的元素在Hierarchy里排到一起。这也是为什么很多优化文章都强调“图集要按界面模块划分”,因为划分合理了,同图集的元素天然就挨在一起。

2.3 为什么图集是合批的命根子

UGUI默认的UI材质是UI/Default,这个材质本身是共享的,所以理论上只要所有图片都来自同一张图集,它们就能合批。但现实是,一个项目里图片资源成百上千,不可能全塞一张图集(图集太大加载慢、内存高、还容易浪费空间)。

所以图集划分策略直接决定了合批效果。我见过不少项目图集是按“功能”分的,比如按钮图集、图标图集、背景图集,结果一个界面里同时用到这三种,DrawCall直接翻三倍。更合理的做法是按界面/模块划分图集,让同一个界面用到的图尽量集中在一到两张图集里。

提示:图集不是越小越好,也不是越大越好。单张图集建议控制在1024x1024到2048x2048之间,具体看目标平台的内存和加载策略。

3. 把DrawCall压下来的实操手段

3.1 图集合并与Sprite Packer的正确用法

Unity自带的Sprite Packer(在Window > 2D > Sprite Atlas里配置)是合批的基础工具。配置的时候有几个参数必须搞清楚:

  • Type:选Master还是Variant。Master是主图集,Variant是变体(用于不同分辨率)。一般项目用Master就够了。
  • Include in Build:勾上才会打进包,不勾的话运行时加载会失败。
  • Allow Rotation:允许旋转能提高图集利用率,但某些情况下旋转后的Sprite在UI里显示会有问题,建议UI图集关掉。
  • Tight Packing:紧密打包能省空间,但可能导致Sprite边缘采样问题,UI图集一般关掉,用Rectangle方式。

配置好之后,把同一个界面用到的Sprite拖进同一个Atlas。这里有个实操细节:Sprite的Packing Tag和Atlas配置要对应,否则可能打不进你想要的图集。我一般直接手动指定Atlas,不依赖Packing Tag自动分组,这样更可控。

3.2 层级顺序调整的实战技巧

前面说了交叉断批,实操中怎么排查?我的方法是打开Frame Debugger,一帧一帧看DrawCall的合并情况。Frame Debugger里会明确显示每个DrawCall合并了哪些元素,如果发现某个批次只合了一两个元素,那基本就是被断批了。

调整层级顺序有个技巧:把同一图集的元素在Hierarchy里连续排列。比如一个背包界面,格子背景、格子边框、物品图标如果来自不同图集,那就把所有的格子背景放一起、所有边框放一起、所有图标放一起,而不是按格子一个个排。这样虽然视觉上Hierarchy结构变了,但渲染顺序优化了,合批效果立竿见影。

不过要注意,改变层级顺序可能影响遮挡关系。UI是按下标顺序渲染的,后面的盖前面的。所以调整顺序前要确认元素之间没有遮挡需求,或者用Canvas Group、Sorting Order等机制单独控制。

3.3 Mask和RectMask2D的取舍

Mask组件是合批杀手,因为它会引入Stencil缓冲,导致Mask内外的元素无法合批。如果非要用遮罩,优先用RectMask2D,它只做矩形裁剪,不涉及Stencil,对合批的破坏比Mask小得多。

我实测过一个滚动列表,用Mask的时候DrawCall是48,换成RectMask2D之后降到22。当然RectMask2D只能做矩形裁剪,如果要做圆形头像遮罩这种,还是得用Mask,那就只能接受断批,或者用Shader自己实现裁剪。

注意:RectMask2D虽然对合批友好,但它本身也会触发Canvas重建,滚动列表里如果每个item都挂RectMask2D,重建成本会很高。建议只在列表容器上挂一个,而不是每个item都挂。

3.4 文本组件的合批陷阱

Text组件(尤其是旧版Text)是另一个合批黑洞。每个Text都会生成自己的网格,而且如果字体图集不同(比如用了不同的字体、不同的字号导致动态字体图集分裂),就会断批。

优化手段有几个:

  • 尽量用TextMeshPro替代旧版Text,TMP的合批效率更高,而且支持字体图集共享。
  • 同一界面统一字体和字号,避免动态字体图集分裂。
  • 对于纯数字变化的文本(比如分数、倒计时),可以考虑用图集数字(把0-9做成Sprite)来替代Text,这样能和其他UI元素合批。

我做过一个倒计时界面,原本用Text每秒刷新,DrawCall是12,改成图集数字后降到3,而且重建成本也低了很多。

4. 一个完整界面的优化实录

4.1 优化前的状态摸底

拿一个模拟的背包界面举例。这个界面有6x8共48个格子,每个格子有背景、边框、物品图标、数量文本,顶部有标题和关闭按钮,底部有整理和排序按钮。优化前用Profiler和Frame Debugger测下来:

  • DrawCall:约120
  • Canvas.BuildBatch耗时:约4.2ms
  • 帧率:中端机上约38fps

打开Frame Debugger看,发现批次非常碎,很多批次只合了一两个元素。主要问题有三个:图集按功能划分导致同界面用了4张图集;格子元素按单个格子排列导致交叉断批;数量文本用了旧版Text且字号不统一。

4.2 分步优化过程

第一步,重新划分图集。把背包界面用到的所有图片(格子背景、边框、图标、按钮)合并到一张2048x2048的图集里。图标数量多,但单个尺寸小,2048的图集完全放得下。这一步之后DrawCall从120降到约60。

第二步,调整层级顺序。把48个格子的背景全部提到前面连续排列,然后是所有边框,再是所有图标,最后是所有文本。这一步之后DrawCall降到约35。这里有个细节:调整后要检查物品图标的遮挡关系,确保图标还在背景之上。

第三步,替换文本组件。数量文本改用TextMeshPro,统一字体和字号。这一步之后DrawCall降到约28。

第四步,处理滚动和遮罩。这个界面没有滚动,但如果有,容器用RectMask2D而不是Mask。这一步在这个例子里没体现,但在滚动列表场景里很关键。

第五步,动静分离。把顶部标题、底部按钮这些静态元素放一个Canvas,格子区域放另一个Canvas。这样格子刷新时不会触发静态部分重建。这一步之后BuildBatch耗时从4.2ms降到约1.8ms。

4.3 优化后的数据对比

指标优化前优化后
DrawCall12028
BuildBatch耗时4.2ms1.8ms
帧率(中端机)38fps58fps
图集数量41

这个数据是我在一个模拟项目里实测的,不同项目会有差异,但优化方向和幅度基本一致。核心就是三件事:图集合并、层级排序、动静分离。

5. 那些年我踩过的合批坑

5.1 图集合并后反而更卡了

有一次我把一个界面的图集从4张合并成1张2048的,结果DrawCall是降了,但加载时间变长了,而且内存占用上去了。原因是这张图集里有很多没用到的空白区域,而且加载时整张图集都要读进内存。

后来我调整了策略:图集尺寸按实际内容定,不要盲目追求大图集。如果界面用到的图不多,1024甚至512就够了。另外可以用Sprite Atlas的Variant功能,针对不同分辨率设备加载不同精度的图集。

5.2 动态字体导致的隐形断批

旧版Text用动态字体时,如果界面上出现了字体图集里没有的字符,Unity会实时生成新的字形并扩充字体图集,这个过程会导致字体图集重新打包,进而触发所有用到该字体的Text重建。表现就是:平时好好的,一旦显示某个生僻字或者特殊符号,整个界面卡一下。

解决办法是预生成常用字符,在字体设置里把项目会用到的字符集提前烘焙进去。或者直接用TextMeshPro,TMP的字体图集管理更成熟,支持动态和静态混合。

5.3 粒子特效和UI混排的断批

UI界面里经常要加粒子特效,比如按钮点击的闪光、物品获得的光效。粒子用的是Particle材质,和UI材质不同,必然断批。而且粒子如果渲染层级夹在UI元素中间,还会把UI的合批也打断。

我的处理方式是:把粒子特效单独放一个Canvas,并设置合适的Sorting Order,让它整体渲染在UI之上或之下,而不是夹在中间。这样UI的合批不受影响,粒子自己一个批次,代价可控。

5.4 常见问题速查表

问题现象可能原因排查手段解决方向
DrawCall居高不下图集分散、交叉断批Frame Debugger看批次合并情况合并图集、调整层级
界面打开瞬间卡顿Canvas重建成本高Profiler看BuildBatch耗时动静分离、减少单Canvas元素
滚动列表卡每帧重建、Mask断批Profiler看Rebuild频率用RectMask2D、对象池复用
文本变化导致卡顿动态字体图集重建Profiler看Font重建预烘焙字符、用TMP
特效出现时掉帧粒子断批、层级交叉Frame Debugger看批次特效单独Canvas

6. 进阶:什么时候该考虑换方案

UGUI的合批优化是有天花板的。当界面元素数量到几千个、DrawCall优化到极限还是压不下来的时候,就该考虑换思路了。几个方向:

一是用UI Toolkit(原UIElements)。它的渲染机制和UGUI不同,基于Retained Mode,对大量静态元素的处理更高效。但UI Toolkit对复杂交互和动画的支持还不如UGUI成熟,适合做工具类界面或者相对静态的界面。

二是自己写Mesh合并。对于特别复杂的列表,可以把多个UI元素的网格在代码里手动合并成一个Mesh,然后用一个DrawCall渲染。这种做法灵活但工作量大,适合对性能要求极高的场景。

三是用Shader做UI。比如把整个背包界面用一张Shader渲染,格子和图标通过UV偏移来采样。这种做法性能极好,但失去了UGUI的交互便利性,适合展示型界面。

不过对绝大多数项目来说,把前面讲的图集、层级、动静分离这三件事做到位,UI性能就已经够用了。换方案是最后的手段,不要一上来就想着造轮子。

7. 我在实际项目里的几点体会

合批优化这件事,工具和原理都不复杂,难的是在项目早期就建立规范。我见过太多项目是等到卡顿了才来优化,这时候图集已经乱成一团,层级结构也改不动了,优化成本极高。所以我的建议是:项目一开始就定好图集划分规则、Canvas拆分规则、文本组件规范,后面能省掉大量返工。

另外,优化要有数据支撑。不要凭感觉说“这个界面卡”,要用Profiler和Frame Debugger测出具体数字,优化完再测一遍对比。我习惯给每个主要界面建一个性能基线,DrawCall、BuildBatch耗时、内存占用都记录下来,版本迭代时如果数字涨了,立刻排查。

最后分享一个小技巧:Unity的Frame Debugger可以配合Profiler.SetAreaEnabled在代码里打标记,把UI重建的耗时单独圈出来,这样在Profiler里一眼就能看到是哪个界面、哪个操作触发了重建。这个手段在排查“打开某个界面就卡一下”这类问题时特别好用。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进