- 示例工程
【免费下载链接】Unity3DTraining
【Unity杂货铺】unity大杂烩~
本文基于《TX工作室UI优化文档》整理成篇。该文档是腾讯系工作室在 UGUI 项目中的实战经验沉淀,聚焦于 UGUI 的 DrawCall(下称 DC)产生条件、
SendWillRenderCanvases重绘时机、UI Despawn 方案选型、以及无 Canvas / 有 Mask / 有 Canvas 三种场景下的 DC 计算规则,并给出了可直接落地的六条 UI 规范。读完本文,你将能够准确判断一次 UI 操作是否会触发 Canvas Rebuild,能在编辑器与真机上分析 DC 构成,并掌握使用CanvasRenderer.color、CanvasGroup.alpha、EmptyGraphic 等技巧在不牺牲表现的前提下压低 DC 与 CPU 消耗。
一、UGUI DrawCall 的产生条件
在动手优化之前,必须先搞清楚 UGUI 到底在什么情况下会产生 DrawCall。文档给出了三条关键规则:
- 区域交汇才计算:当 Canvas 下所有节点区域的最小 AABB 包围盒与 Canvas 的绘制区域有交汇时,才会进行 DrawCall 计算。即便发生了 DrawCall 计算,Canvas 自身也固定有2 个 DrawCall 消耗。
- 移出屏幕仍然计算:当 Canvas 进行 DrawCall 计算时,即便把子元素移出至屏幕外,移出的子元素依然参与 DrawCall 计算。
- 移出屏幕的重叠依然会增加 DC:移出屏幕外的子元素,其 DrawCall 计算规则与仍在屏幕内的元素完全一致。因此,如果移出屏幕外的元素有区域重叠,同样会使 DrawCall 增高。
这三点是后续所有优化手段的底层依据:“移出屏幕”并不能让 UI 免于 DC 计算,这也是文档在 Despawn 方案对比中反复强调"移出屏幕外同样会进行 DrawCall 计算"的原因。
二、UI 重绘时机:SendWillRenderCanvases
UGUI 的重绘由SendWillRenderCanvases触发,而触发它的前置条件是ModifyMesh。文档总结的触发规则如下:
- 尺寸/锚点/轴心变化触发:RectTransform 修改了 SizeDelta、Anchor、pivot 都会触发
ModifyMesh,进而触发SendWillRenderCanvases进行 UI 重绘;而其他的 SQT(Scale / Rotation / Translation)变化并不会触发ModifyMesh。 - 切换 Active 触发:切换 UI 的 Active 状态之后会触发
ModifyMesh,带来SendWillRenderCanvases消耗。 - alpha 不触发:切换 UI 的 CanvasGroup 或 CanvasRenderer 的 alpha不会触发
ModifyMesh。也就是说,设置 alpha 为 0 能够达到与SetActive(false)相同的“看不见”效果,并且 alpha 设置为 0 之后,也不会再进行 DrawCall 计算。 - 切换 Parent 触发:切换 UI 的 Parent 之后也会触发
ModifyMesh,引发SendWillRenderCanvases消耗。
这条规则直接催生了文档中"用 alpha 代替 SetActive"的核心优化思路:既然 alpha 为 0 既不触发重绘也不产生 DC,那么在需要隐藏 UI 时,它就比SetActive(false)更省。
三、UI Despawn 的四种方式对比
文档将 UI 隐藏(Despawn)的常见做法归纳为四种,并逐一分析了优缺点:
| 方式 | 优点 | 缺点 |
|---|---|---|
1.SetActive(false) | 最直接,无 bug | 会造成SendWillRenderCanvases |
| 2. 将 UI 节点移出屏幕外 | 不会产生SendWillRenderCanvases | 移出屏幕外同样会进行 DrawCall 计算;需要进行 UI 区域判断,不能造成 UI 重叠增加 DC;需要手动关闭 Update 函数;需要注意 OnEnable / OnDisable 中的逻辑是否能正常工作;如果 Parent 中有 Layout,需要在移出屏幕时给 UI 添加 IgnoreLayout |
| 3. 给 UI 添加 CanvasGroup,Despawn 时设置 alpha 为 0 | 不会造成SendWillRenderCanvases,不会进行 DrawCall 计算 | 如果 Parent 包含 Layout 仍需添加 IgnoreLayout;需要手动关闭 Update 函数;需要注意 OnEnable / OnDisable 逻辑;需要注意 CanvasGroup 参数造成的其他逻辑因素 |
| 4. 切换 Parent | 无 | 会造成SendWillRenderCanvases;切换 Parent 会造成其他消耗,比如重新设置 SQT 等变量 |
综合来看,方案 3(CanvasGroup + alpha=0)在"不触发重绘、不产生 DC"两个维度上表现最佳,但引入的额外成本是必须手动管理 Update 逻辑与 Layout 忽略标记。值得注意的是,方案 2 与方案 3 都强调"需要手动关闭 Update 函数"——因为隐藏的 UI 若仍在运行 Update,省下的重绘开销会被逻辑开销抵消。
在仓库中可以看到实际项目对方案 1 的使用:背包系统示例中,TooltipsUI.cs 的Show()/Hide()直接调用gameObject.SetActive(true/false),DragItemUI.cs 同样如此。这正是文档建议优化的典型场景:频繁弹窗的 Tooltips 若改用 CanvasGroup.alpha 控制显隐,可避免每次弹出造成的 Canvas Rebuild。
四、关键实测数据:Color 与 Active 的 CPU 开销
文档给出了一组基于66 个 Image的实测对比数据,是选择 API 时的重要参考:
1. 修改 Image.color 与修改 CanvasRenderer.color 的对比
- 修改 Image.color:CPU 消耗约15ms,GPU 消耗约0.7ms
- 修改 CanvasRenderer.color:CPU 消耗约1.3ms,GPU 消耗约0.7ms
结论:如果修改CanvasRenderer.color能够达到与修改Image.color一样的效果,优先修改CanvasRenderer。原因是Image.color与Text.color都会造成 Canvas Rebuild,而修改CanvasRenderer.color则不会。
2. 切换 UI 的 Active 与切换 CanvasGroup 的对比
- 设置 Active:CPU 消耗20ms
- 设置 CanvasGroup:CPU 消耗1.3ms
同样是 66 个 Image 的测试规模,SetActive的 CPU 开销是 CanvasGroup 的 15 倍以上,进一步印证了上一节的方案选型结论。
3. 重复设置的惰性行为
文档还验证了"重复设置不引起 Rebuild"的惰性行为,这意味着一味做"值是否变化"的判断并不能省下所有开销,但反过来也说明相同值的重复赋值是安全的:
- 重复设置 Image 的 color(相同 Color):不会引起 Rebuild
- 重复设置 Text 的 text(相同 text):不会引起 Rebuild
- 重复设置 Image 的 sprite(相同 Sprite):不会引起 Rebuild
- 重复设置 Image 的 fillAmount(相同数值,相差小于 0.000001f):不会引起 Rebuild
五、UGUI DrawCall 分析过程
文档强调:UGUI 的 DrawCall 分析过程应分三种情况讨论,不能一概而论。
1. 无 Canvas、无 Mask 情况(最简单)
分析过程简述如下:
- 首先判断当前 UI 所占的 Rect 区域(相对于整个 Root 节点)底下是否有重叠;如果没有重叠,当前就是第 0 层,将当前层级记录下来。
- 深度优先迭代 Transform 下的所有孩子,从最顶层的层级开始判断:查看当前 UI Rect 所占区域是否与该层级的任意 UI 节点重叠。如果重叠,判断当前 UI 节点是否能被该层级的 UI 节点 Batch;能则归入该层级,否则归入后面一个层级;如果没有重叠,则依次向前面的层级检测,最低层级为 0。
- 得到所有 UI 节点及对应层级后,将每个层级中的 UI 节点进行 Batch 合并分析,能够合并的放入同一个 Batch 结构。
- 对所有 Batch 结构排序。可确认的排序规则是:Text 的 Batch 先绘制,之后才是 Image 的绘制;有 Mask 时,Mask 的 Text 与 Mask 的 Image 会比无 Mask 的后绘制。需要注意的是,即便是 Text,由于 font 不同或 Text 材质不同,也不能进行 Batch。此时 Batch 排序如何判断,文档作者猜测是根据 Hierarchy 中的先后顺序得出的,Image 也不例外。
- 最后将所有层级从下到上取出所有 Batch 集合放进统一集合,迭代该集合,如果相邻的两个 Batch 之间能够再进行合并,就合并为一个 Batch。
- 最终得出的 Batch 集合就是 UI 的 DC 数目,也就是最后 UI 的渲染顺序。
2. 无 Canvas 但有 Mask 情况
出现 Mask 后情况复杂得多,差异点如下:
- 如果当前节点上存在 Mask 组件,该节点下的所有子孩子都会添加 Mask 标签。由于 Mask 下面还能再出现 Mask,Mask 标签应该用数量记录;相同层级的相同数量 Mask 能够合并,但直属 Mask 节点的合并规则与孩子节点的合并规则不同。
- 每一个 UI 节点上的 Mask 数量不同,肯定不能进行合并。
- 同一个层级进行 Batch 排序时,Mask 标签数量越大,排序越靠前。
- 每添加一个 Mask 组件,会多出一个 DC。用 Unity 5.x 的 FrameDebug 观察,这个 DC 并没有绘制内容,只是在做 UI 的 AlphaClip。该 DC 也可以被合并:如果出现时机一致,即 MaskA 所占的 ImageA 能与 MaskB 所占的 ImageB 合并,那么这个 Alpha 裁剪 DC 也能被合并。
- MaskA 所占 ImageA 与 MaskB 所占 ImageB 能合并的条件是:ImageA 与 ImageB 本身能够合并,并且 ImageA 与 ImageB 没有发生重叠。
- 直属 Mask 的节点不能与节点下的子节点合并,所有子节点的层级都相对 +1。
- 如果 MaskB 与 MaskA 发生了重叠,那么 MaskB 的层级比 MaskA 子节点下层级最高的多 1,因此会出现:当 MaskA 的节点全部渲染完毕、并且出现了 MaskA 的 AlphaClip DC 之后,才可能进行 MaskB 的渲染。
- 如果当前 Mask 的子节点都出现在 Mask 外面,即便看不见了仍会被计算 DrawCall,同时会先渲染出现在 Mask 外面的节点;如果 Mask 的所有子节点都出现在 Mask 外面,Mask 自身的渲染会被放在最后面——这并不会对 DC 数量有所改变。
- 如果 Mask 外的当前节点与 Mask 直属节点的 Rect 重叠,当前节点的层级为 Mask 最大子节点层级 +1;如果只是与 Mask 的子节点区域重叠,则只是 Mask 子节点的层级 +1。
- Mask 的子节点如果原本可以 Batch,但其中一个在 Mask Rect 外部、一个在 Mask Rect 内部,那么它们不能一起 Batch。
- 在 OutOfMask 情况下,如果 Mask 的子节点所处 Rect 的重叠区域只有 Mask 本身,该子节点的层级与 Mask 节点层级一致(文档标注为"诡异");如果重叠区域是 Mask 下的其他子节点,层级为重叠子节点层数 +1。
3. 有 Canvas 情况
Canvas 的存在让规则变得非常清晰:
- UGUI 的合并策略是以 Canvas 为单位的,上述所有分析过程都发生在单个 Canvas 之下。
- 如果在 Hierarchy 中出现 Canvas,会直接打断当前 Hierarchy 的合并策略。例如在一个 Canvas 的孩子节点中间出现一个子 Canvas,就可以直接按三个 Canvas 的层级进行处理。
六、DC 计算的三个例子
例 1:无 Canvas、无 Mask
- 一个 ImageA 层级为 0,一个 ImageB 层级为 1,一个 Text 层级为 0,最后 DC 为2。渲染顺序为 Text → ImageA → ImageB,因为相邻的 batch 能继续合并,所以 ImageA 与 ImageB 最终合并。
- 一个 TextA 层级为 0,一个 ImageA 层级为 0,一个 TextB 层级为 1。按分析过程 DC 应为 3(TextA → ImageA → TextB),但实测诡异地为2,渲染过程为 ImageA → TextA & TextB。作者据此猜测:Unity 的 Batch 过程可能对 batch 排序做了简单调整。
- 一个 TextA 层级为 0,一个 ImageA 层级为 0,一个 ImageB 层级为 0,一个 TextB 层级为 1。按上述实验推测 DC 应为 2(ImageA & ImageB → TextA & TextB),但实际 DC 为3,过程为 TextA → ImageA & ImageB → TextB。可见例 2 只能算意外。不过可以通过一些手段使 TextA 与 TextB 能够 Batch,最终 DC 也能降到 2,具体做法在第七节优化规范中说明。
例 2:无 Canvas 但有 Mask
- ImageA-Mask 层级为 0,ImageB 层级为 2,ImageC 层级为 2,TextA 层级为 2。如果 ImageA 不存在 Mask,DC 应为 2(ImageA 与 ImageB、ImageC 可合并);加上 Mask 后 DC 变为4,过程为 ImageA-Mask → TextA → ImageB & ImageC → AlphaClip。
- 两个互不重叠的 Mask 场景:ImageA1-Mask 层级为 0,ImageA2 层级为 2,ImageA3 层级为 2,TextA 层级为 2;ImageB1-Mask 层级也为 0(两个 Mask 未重叠),ImageB2 层级为 2,ImageB3 层级为 2,TextB 层级为 2。DC 仍为4,过程为 ImageA1-Mask & ImageB1-Mask → TextA & TextB → ImageA2 & ImageA3 & ImageB2 & ImageB3 → AlphaClip。
- 如果 MaskB 与 MaskA 发生了重叠,则 ImageB1-Mask 层级变为 3(因为 MaskA 子节点最高层级为 2),ImageB2 层级为 4,ImageB3 层级为 4,TextB 层级为 4,最终 DC 为8,过程为 ImageA1-Mask → ImageA2 & ImageA3 → TextA → AlphaClip → ImageB1-Mask → ImageB2 & ImageB3 → TextB → AlphaClip。
例 3:有 Canvas
- Hierarchy 中排序为 ImageA、ImageB、ImageC,原本 DC 应为 1(3 个 Image 都可合并)。给 ImageB 添加 Canvas 后,DC 变为3:ImageB 自成一个 DC 的同时,把 ImageA 与 ImageC 的相邻关系也打断了。
七、六条可落地的 UI 优化规范
基于以上原理,文档给出了六条明确的项目规范:
1. 事件触发区域统一使用 EmptyGraphic,其他 Graphic 的 Raycast Target 都设为 false
- EmptyGraphic 是只有逻辑区域、没有显示区域的 Graphic,不产生 DrawCall。
- Image、Text 等默认 Graphic 都会勾选 Raycast Target,表示当前 RectTransform 区域点击有效。但过多的 Raycast Target 会让 EventSystem 产生更多消耗。
- 所有事件触发区域统一使用 EmptyGraphic,还利于 UI Despawn 与 Spawn 的统一操作。
2. 将 Image.color、Text.color 都改为 CanvasRenderer.color
- 修改 Image.color 与 Text.color 都会造成 Canvas Rebuild,但修改 CanvasRenderer.color 不会。
- 大部分颜色需求都能通过调节 CanvasRenderer 的 Color 实现效果。
- 文档作者坦诚:修改 CanvasRenderer.color 之后 UI 还能继续 Batch 的原理当时尚未完全弄清,但实测效果是喜人的(见第四节 15ms vs 1.3ms 的数据)。
3. 限制 UI GameObject 的 SetActive / SetDeactive 操作
- 每次 UI 更新 Active 标记,都会造成 Canvas Rebuild 消耗。
- 使用
CanvasRenderer.setAlpha(0)或CanvasGroup.alpha = 0的方式,同样能达到 UI SetDeactive 的效果,且不产生 DC 与 Canvas Rebuild;但此时点击区域与 Update 更新需要手动关闭。 - 将 UI 移除出屏幕外也是一种做法,但缺陷较大:移除出屏幕外的 UI 同样计算 DC,如果它们重叠在一起,会造成 DC 不可预期增长,同样需要手动关闭 Update。
- 避免频繁设置 UI 的 Parent,因为每次设置都会造成 Canvas Rebuild。
- 建议通过中间代理(UIObjectRoot)进行 UI GameObject 的 Deactive 操作。
4. 尽量避免使用 Mask
- 使用 Mask 会造成多余 DC,几乎每多一个 Mask 就会产生一个 DrawCall(尽管 Mask 也能 Batch)。
- Mask 会打破统一的 DC Batch 机制,使 DC 数量更难以控制。
- 优先使用RectMask2D:它与 Mask 的原理不一样,不会产生多余 DC,也不会影响 DC 的计算规则;当然也有特定限制——只能用于 Rect 区域。
5. 不使用 Text 的 Best Fit
6. 尽量避免 Unity 提供的 Outline 与 Shadow
- Outline 与 Shadow 会产生多4 倍的顶点数,难以接受。
- 使用自己提供的 SingleOutline,只多一倍的顶点数,并且更符合美术预期。
八、其他实战要点与合批补充
文档还记录了若干零散但实用的结论:
- 设置位置需判重:设置 UGUI 元素位置时,就算设置的位置和原位置一样,也会导致 Canvas 重建,所以设置位置时最好先做相等判断。
- 顶点数据缓存的共性:UGUI 和 NGUI 都有一个类来存储顶点、uv、颜色等信息,每一次重新绘制或更改都会重新填充其中的数据。
- 静态化高消耗元素:Outline 和 Tiled Image、长文本最好不要做成动态的,绘制消耗太大;Outline 会复制 5 个原网格,顶点数和边数增加 5 倍(相比标准 Outline 官方组件多 4 倍的顶点,这个"5 个原网格"的表述来自工程实践中的另一个观察口径,两条数据都指向同一个结论:Outline 对网格的膨胀是致命的)。
- UGUI 合批的本质(补充知识点):对于每一个 UI 元素,对应其材质和 shader 找到一个 batch;没有 batch,或者有 batch 但被其他 batch 的 UI 挡住了,就要新生成一个 batch,否则就合到已存在的 batch。
关于第三点中"通过抬高 Text 层级合并 Text"的思路,文档在"作用"一节专门说明:工具可以在编辑状态分析当前 UI 的 DC 消耗,以指导优化 DC;并提供 Text 组件 DC 优化功能——通过抬高 Text 组件的层级,达到整体合并 Text 组件的作用。以此类推,其他的 batch 也能通过类似的抬高层级操作进行 UI 之间的 Batch 优化。这正是第八节例 1 中"使 TextA 与 TextB 能够 Batch,最终 DC 降到 2"的具体做法。
九、在仓库中继续深入
这份优化文档是 PerformanceOptimization 专题目录下的一份实战沉淀,与该目录中的 AnimOptimization.md、UGUI的优化.docx、ProfilerExample 等互为补充。仓库的 UGUITraining 目录则提供了大量可运行验证的 UGUI 项目:
- KnapsackSystem:背包系统,其中 TooltipsUI.cs 展示了文档提到的典型"频繁 SetActive"写法,可作为对照优化场景;
- UGUIDemo01 与 UGUIDemo02:菜单、技能、背包、任务列表等完整 UI 界面;
- Nightmares_Demo:完整的 UGUI 系统应用案例;
- RectTransform.md:RectTransform 相关知识点,可与本文"修改 SizeDelta/Anchor/pivot 触发 ModifyMesh"的规则互相印证。
阅读本文后,建议在 Unity 编辑器中打开 FrameDebugger 逐帧观察第六节中的三个例子,再结合第五节的分析规则,即可逐步建立"看到 Hierarchy 结构就能估算 DC 数量"的能力。
- 示例工程
【免费下载链接】Unity3DTraining
【Unity杂货铺】unity大杂烩~
相关推荐
GhostTrack:在终端里查询 IP 归属地和手机号的入门指南
GhostTrack:在终端里查询 IP 归属地和手机号的入门指南 假设你收到一条陌生来电,想知道号码大概属于哪个地区;或者在运维日志里看到一堆陌生 IP,想快
网络安全CLIKotlin/Native性能优化:内存管理与GC机制深度剖析
Kotlin/Native性能优化:内存管理与GC机制深度剖析 内存管理架构概览 Kotlin/Native采用 内存安全模型 与 显式内存管理 相结合的架构,
编译器语言运行时编程语言Phoenix 实验恢复机制深度剖析:原子认领、孤儿扫描与工作重建
Phoenix 实验恢复机制深度剖析:原子认领、孤儿扫描与工作重建 导读 本篇文章深入解析 Phoenix(AI Observability & Evaluat
可观测性AI 评测LLMOpsAI 应用人工智能
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考