ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Unity CPU发热优化:GC、Draw Call与Canvas重建的实战解析

Unity CPU发热优化:GC、Draw Call与Canvas重建的实战解析 手机发烫、掉帧、续航崩这三件事几乎是同时出现的。很多人习惯性把锅甩给 GPU觉得画面复杂才发热。但我在实际项目中反复验证过尤其在 Unity 场景里CPU 才常常是被低估的头号瓶颈。GC 的卡顿、Draw Call 的提交开销、Canvas 重建的隐藏消耗这三者叠加起来能把一颗本来性能不错的移动端 CPU 吃干抹净。这篇是发烫优化系列的第 5 篇专门把这三个 CPU 侧的吃电大户拆开看。这篇内容适合正在做 Unity 项目优化、已经解决了部分渲染压力但发现帧率依然不稳的开发者。同时也适合那些用 Profiler 看到 CPU 时间高但不知道从哪下手的朋友。我会把原理、定位思路、实操方案和踩坑记录一起讲清楚。1. CPU 是如何成为发热元凶的1.1 帧时间里的 CPU 部分比你想的更重很多人对发热的理解停留在GPU 渲染得多所以热这在 3D 手游里确实成立但不够全面。一个 Unity 帧的完整生命周期从输入处理、物理模拟、动画更新、脚本逻辑、渲染提交到 GPU 执行每一段都在消耗 CPU。移动端通常采用垂直同步锁帧比如 60 fps一帧的预算只有 16.7 ms。这个时间里 CPU 侧要完成所有逻辑和渲染命令的组装再把命令交给 GPU。实测下来不少项目的 CPU 帧时间能占到整体帧时间的 40% 到 60%尤其在有大量 UI、复杂逻辑和频繁资源加载的场景里CPU 时间甚至反超 GPU。这就意味着即使 GPU 的渲染压力不大只要 CPU 侧的命令组装、逻辑执行效率低下照样会把处理器频率拉高带来功耗上升和发热。更要命的是CPU 忙不过来时不管 GPU 多空闲游戏帧率照样被限制住表现为CPU 满载但 GPU 利用率不高的畸形状态。判断这个状态的工具就是 Unity ProfilerWindow Analysis Profiler查看 CPU Usage 模块按照 Total 排序能看到每一项消耗的时间占比。1.2 主线程、渲染线程与脚本开销Unity 的 CPU 侧工作由两条主要线程承载主线程Main Thread负责游戏逻辑、物理、动画、UI 布局和部分渲染提交渲染线程Render Thread负责将主线程准备好的渲染指令转化为 GPU 可执行的指令。移动端常见的优化目标是让主线程帧时间维持在 10 ms 以下给渲染线程和 GPU 留出余量。有种情况很经典项目里的怪物 AI 用了大量 Update 里做的路径计算主线程时间被拉高到 14 ms这时候哪怕场景里的模型面数很低手机上照样烫手。原因很简单高主线程占用会让 CPU 长时间处于高频率运行状态发热随之而来。解决思路就是两类一是减少主线程工作量比如把 AI 计算分散到多个帧分帧、使用 Job System 多线程化二是降低每帧执行成本比如缓存组件引用、避免重复查找、用对象池减少实例化开销。这些听起来基础但真正执行到位需要一套完整的帧时间预算意识给每个系统逻辑、物理、UI、渲染划定每帧的 CPU 时间上限超出就触发优化动作。2. GC被低估的帧率杀手2.1 GC 的本质与 Unity 的特殊性GCGarbage Collection垃圾回收是运行时自动管理内存的一种机制。Unity 的 Mono/IL2CPP 运行时会在堆内存中分配对象当堆内存不足以满足新分配时触发回收把不再被引用的对象清理掉腾出空间。听起来很智能但问题在于什么时候回收和回收多久是运行时决定的不归你控制。Unity 使用的是一个非分代的垃圾回收器主要是 Boehm GC 的变体它在回收时通常会暂停整个程序的执行这就是你会在 Profiler 里看到 GC 时间突然飙到几十毫秒甚至几百毫秒的原因。如果一帧里脚本分配了大量临时对象字符串、数组、Lambda 表达式、LINQ 查询结果等GC 就会在某个临界点被触发然后所有线程都停在那里等它打扫卫生。表现在游戏里就是一顿一顿的卡顿发热反而是次要问题了。2.2 编译目标与 GC 的差异这里必须分清 IL2CPP 和 Mono 的差异。IL2CPP 模式下代码会被转换为 C 再编译GC 行为与 Mono 模式略有不同尤其是内存布局和分配路径有变化但 GC 本身的回收时暂停特性没有本质改变。不要以为切了 IL2CPP 就万事大吉GC 分配的问题仍然存在。手游项目通常强制使用 IL2CPP很多团队在开发期用 Mono上线前切 IL2CPP结果发现逻辑性能有变化GC 也会因为编译器优化不同而表现不同。我的建议是性能测试一定要在 IL2CPP 实际目标机型的配置下进行不能只看编辑器里的 Profiler 数据编辑器与真机在 GC 行为上差异很大。2.3 堆分配的主要来源GC 分配的核心就是在游戏运行过程中不断创建又弃用的临时对象。最常见的分配点包括字符串拼接使用连接字符串每次都会生成新的 string 对象装箱操作把值类型int、float、struct转换为 object 类型比如把 int 塞进 ArrayList 或字典查找的某些重载LINQ 查询与委托LINQ 的 Where、Select 会产生委托对象和迭代器闭包捕获Lambda 表达式里捕获了外部变量时会生成闭包类实例数组与 List 扩容动态数组在容量不足时分配新的底层数组GameObject.Instantiate 和 Destroy反复创建销毁对象尤其是带组件的对象2.4 实操用 Profiler 定位 GC 分配打开 Profiler 的 CPU Usage 模块把时间轴切到 Player 模式勾选 Hierarchy 视图下的 GC Alloc 列。你可以看到每个函数的 GC 分配量。常见做法是运行一小段时间然后按总分配量排序找出 Top 几的分配函数逐个击破。我遇到过一个真实案例一个技能系统每帧调用一个GetDamageText()方法里面用字符串拼接生成伤害数字显示。一帧只分配不到 100 字节看起来不多但技能多、调用频繁长时间运行下堆内存不断增长每隔几秒触发一次 GC帧率瞬间掉到 10 帧以下。优化方案是把字符串拼接改成 StringBuilder 复用或者用对象池管理伤害文本组件。改动不大但 GC 频次直接降为零。另一个常见分配坑是 foreach。在 Unity 旧版本中对ListT使用 foreach 不会产生 GC 分配但对一些自定义迭代器或DictionaryTKey, TValue用 foreach 会产生垃圾。升级到较新的 Unity2020 以上后大部分集合类型在 IL2CPP 下的 foreach 已经消除了明显分配但保险起见热路径里的遍历仍可用 for 循环。2.5 GC 进一步优化策略除了挡住分配源头还有几个 GC 优化手段值得记录对象池化对于频繁创建销毁的对象子弹、敌人、飘字用一个池子存起来用的时候取用完放回缓存组件引用GetComponentT()的调用在 Profiler 里显示为引擎内部分配反复调用会累积内存压力减少闭包和匿名委托事件订阅尽量用实例方法而不是 Lambda特别是在 Update 里频繁调用的情况下手动控制堆内存上限在移动端通过System.GC的某些底层设置或者依赖 IL2CPP 的堆管理策略给堆设置一个合理上限避免堆无限增大导致触发更大的 GC 停顿使用NativeArrayT或UnsafeUtility管理高频临时数据这适合熟悉 ECS/Job System 的团队普通项目不推荐注意不要一上来就追求零 GC 分配。合理的优化目标是消除高频调用路径上的分配而不是所有代码。为此付出的代码可读性代价也要考虑进去。3. Draw Call 与合批的硬核真相3.1 Draw Call 无关 GPU它首先是个 CPU 问题Draw Call 指的是 CPU 向 GPU 发送的一个渲染命令告诉 GPU用这个材质、这个网格、这个变换去画一坨东西。每次提交都有状态检查和参数校验的开销虽然有优化的驱动层和 API 抽象但这种提交动作本身是有成本的。当场景里 Draw Call 数量达到几百上千CPU 的提交时间就会明显拉高。这里要点破一个误区Draw Call 高不一定代表 GPU 压力大。很多项目用静态场景、低模物体GPU 秒秒钟就画完了但 CPU 在每一帧要发送 1500 个 Draw Call光状态切换和命令提交就吃掉 8 ms 以上的多的 CPU 时间。手机在这种状态下必然发热因为 CPU 满负荷运转同时高频的 CPU-GPU 通信也增加了功耗。3.2 合批机制的横向对比Unity 的合批机制恰好是为了减少 Draw Call但各自的适用场景、限制条件和可感知的收益差异很大我整理成了下面的对照表合批方式运行时机适用对象优势典型限制动态合批运行时 CPU 每帧计算小网格、相同材质的物体对美术资源要求低物体可移动顶点数限制严格合批本身有 CPU 消耗静态合批进入场景时预处理标记为 Static 的物体对大场景静态物体效果好减少运行时计算内存开销大物体不可移动、不变化GPU Instancing运行时提交大量相同网格的物体开销低、性能极佳适合植被、人群要求网格和材质一致需要 Shader 支持 instancing 宏SRP Batcher运行时提交SRP 管线下使用兼容 Shader 的 Renderer合并效果好减少状态切换需要 Shader 兼容 SRP BatcherURP/HDRP 下配置动态合批是最容易踩坑的。它的原理是把多个符合条件的小网格在 CPU 侧合并成一个大的顶点缓冲然后一次提交。但如果网格顶点数超过限制通常为 300 顶点左右动态合批就会失效。更要命的是动态合批本身也在消耗 CPU频繁移动的物体需要每帧重新合并可能得不偿失。我的经验是移动端项目优先使用 GPU Instancing SRP Batcher 的组合静态场景使用静态合批。这三者配合好了一个普通场景的 Draw Call 从 1200 降到 200 以内问题不大。如果还是传统内置渲染管线静态合批 动态合批也能缓解但天花板明显。3.3 材质与合批失败的常见原因合批失败是很多团队最头疼的问题因为 Frame Debugger 看到的结果往往让人困惑。总结下来最常见的失败原因有这些材质实例不同即使两个物体用的同一个 Shader、同一张贴图只要材质球实例不同就不会被合批。标准做法是共享材质或者把动态参数用 MaterialPropertyBlock 控制而不是生成新实例贴图不同不同纹理意味着不同的材质状态合批直接失效。解决方案是图集化Texture Atlas把多张小图合成一张大图Shader 变体不一致两个物体用同一个 Shader但启用的关键字不同也不会合批网格参数差异动态合批要求使用相同的材质同时顶点属性布局尽可能一致缩放负值Scale 为负数某些合批路径会拒绝负缩放的非对称变换排查工具是 Window Analysis Frame Debugger。打开后一帧一帧地看 Draw Call 的合批情况在事件列表里选中某个 Draw Call右侧会显示合并的物体数量和合批失败原因。遇到显示Some objects have different textures之类的提示基本就能锁定是贴图参数问题。3.4 SRP Batcher 的配置与收益SRP Batcher 是在 Scriptable Render PipelineURP/HDRP下的合批加速技术它通过持久化的材质属性缓冲区减少 CPU 侧的状态反复设置。实现原理很简单把材质数据缓存在 GPU 侧渲染时不再逐个渲染器绑定材质。开启方式不复杂URP Asset 里找到 Renderer SRP Batcher勾选 Enable。但要让它真正生效Shader 需要兼容 SRP Batcher。所有用 Shader Graph 创建的 Shader 和 URP 自带的 Lit/Unlit Shader 都兼容。自定义 Shader 需要改后缀在 Tags 里声明SRPBatcher True。判断是否生效的方式仍然是用 Frame Debugger 看合批数量。我测试过一个中大型场景在 URP 管线环境下未开启 SRP Batcher 时 Draw Call 850开启后降到了 390CPU 帧时间下降约 3 ms。这在实际调优中是相当可观的收益。注意SRP Batcher 只对使用了兼容 Shader 的渲染器生效如果场景里还有大量内置管线的 Shader收益会打折扣。4. Canvas 重建UI 性能的隐形消耗点4.1 什么是 Canvas 重建UGUI 的渲染和传统 3D 物体不同它会把 Canvas 下的所有 UI 元素转换成网格再把网格提交给 GPU。这个转换过程叫网格构建而当 UI 元素发生变化位置、尺寸、颜色、文本内容时Canvas 会标记为脏Dirty在下一帧触发重建。重建的代价是 CPU 需要重新计算所有 UI 元素的顶点数据、图元数据和材质提交数据。这就是 Canvas 重建。关键点在于一个 Canvas 下任何一个 UI 元素发生变化都会导致整个 Canvas 网格的重建某些情况下可以被部分优化。想象一下一个页面里有几百个 UI 元素其中只有一个血条在每帧更新数值整个 Canvas 每帧都要重建一次这消耗的 CPU 时间不可小觑。4.2 动静分离Canvas 优化的第一法则优化 Canvas 重建最有效的方法就是动静分离。把静态 UI不常变化的部分和动态 UI每帧或频繁变化的部分分别放在不同的 Canvas 下。静态部分因为永远不标记脏就不会重建只会在初始生成时构建一次。动态部分虽然每帧重建但元素少、顶点少CPU 开销可控。具体操作上一个典型的 HUD 页面可以拆成三个 Canvas静态 Canvas背景、边框、按钮上的固定文字、静止的图标动态 Canvas血条、计时器、飘字、需要频繁变化的数值文本特效 Canvas独立管理必要时单独控制显隐这种做法看似简单但在大型 UI 界面里效果极其明显。我在一个 RPG 项目里做过类似重构主城 HUD 的 UI 重建时间从 6.8 ms 降到了 1.2 ms帧率稳定性显著提升。注意Canvas 的拆分不是越多越好。每个 Canvas 都是一个独立的渲染提交单位过多 Canvas 会引入额外的 Draw Call 和渲染状态切换。一个界面建议控制在 2 到 3 个 Canvas 以内动态元素如果很少甚至可以进一步用 RectMask2D 或自定义网格更新来优化。4.3 布局组件和文本的隐藏成本UI 布局组件LayoutGroup、ContentSizeFitter、AspectRatioFitter 等在更新元素时会触发布局重建和网格重建开销比普通位置变化大得多。尤其在列表、背包、聊天框这类高频增删元素的界面里布局组件的开销经常成为 UI 性能的元凶。一个处理办法是分帧布局不要在同一个帧里一次性添加几十个子元素并强制立即布局而是分几帧逐步添加或者用占用率更低的自定义布局逻辑。另一个办法是缓存尺寸如果列表项尺寸固定就不要用 ContentSizeFitter 每帧计算改用 ScrollRect 配合固定尺寸的 Item。文本组件Text/TextMeshPro的重建成本更高。Text 每次修改文字内容都会触发文本网格的重建TMP 因为有复杂的字形处理虽然外观和性能都好于旧版 Text但重建成本同样存在。高频更新的文本比如飘字建议用对象池缓存 TMP 组件并且避免在 Update 里每帧修改文本。实在要每帧改可以降低更新频率比如每 0.1 秒更新一次肉眼几乎无感知。4.4 图集与 Canvas 的配合UI 粒子的图集处理不仅影响 GPU 的纹理切换也影响 Canvas 重建的效率。同一张图集里的 Sprite 在重建时需要提交的图元数据更紧凑Draw Call 合并的机会也更多。不同图集之间的 Sprite 相互交叉排列时渲染排序可能会强制打断合批。图集划分的原则是按 UI 模块登录界面、主城界面、战斗界面拆分避免做一张巨大的万用图集超大图集直接烧显存加载时间也很痛苦也不要做一堆碎片化的几个像素的小图集合批效率差。一个界面内使用的 Sprite 尽量落在 1 到 2 张图集里这是经验之谈。5. 定位瓶颈与优化实战策略5.1 先测量再优化避免轮流撞墙很多团队在优化 CPU 发热时常见的方式是猜猜猜看某个文章说 GC 高于是花两周重构全部代码又看到另一个说法说 Draw Call 高于是花两周重做图集。最后发现发热问题依旧因为瓶颈不在这里或者优化没有落在热点路径上。正确的顺序是先跑 Performance Profiler分析出一帧的时间构成。用真机跑不要用编辑器数据。重点关注三块CPU Usage 模块中的 Player Loop 各系统耗时排序Rendering 模块的 Draw Call 数量和网格数据量Memory 模块的堆内存趋势和 GC 触发频率一个高效的排查动作是在 Profiler 里开启 Deep Profile或使用 Profile 标记打点录制一段典型的战斗或跑图场景然后按耗时排序。通常你能一眼认出排行第一的系统再决定针对 GC 还是 DC 还是 UI 重建下手。切忌同时改所有东西否则你根本不知道哪个改动真正起到了效果。5.2 优化优先级与方案取舍根据经验我一般按这个优先级推进 CPU 发热优化先用 Profiler 识别 CPU 耗时的 Top 系统如果 GC 分配过高未分代 GC 触发频繁优先修 GC因为 GC 停顿的观感问题最为突出且修改成本通常低于 DC/UI 重构如果 GC 正常再看 Canvas 重建。UI 的重建问题在 HUD 密集的游戏中非常普遍动静分离半天能见效最后处理 Draw Call。注意 Draw Call 是提交成本它和场景复杂度强相关优化方案往往要配合美术资源调整各系统优化完之后再做一轮真机验证用 Frame Time 均值、P95、P99 来评估而不只是看平均帧率5.3 工欲善其事Profiler 之外的工具除了 Unity 原生的 Profiler 和 Frame Debugger还有几个扩展工具Memory ProfilerUnity 官方的内存分析工具可以看到托管堆和非托管堆的详细占用和引用关系找内存泄漏、堆增长十分高效UWA针对 Unity 的第三方性能分析平台适合团队化项目能对 Profiler 数据做自动分析给出综合建议RenderDoc 或 Xcode Instruments / Android Studio Profiler用于查看 GPU 侧的渲染细节和底层系统级的热点工具的组合使用一般分阶段使用 Profiler 定位 CPU 热点使用 Frame Debugger 判定合批和重建情况使用 Memory Profiler 检查内存与 GC 的关联最后的真机系统 Profiler 用于确认功耗和频率是否真的下降。5.4 一个综合案例的优化过程记录拿一个 ARPG 项目举个例子。场景是三人在线打 Boss真机测试发热严重掉帧明显Profiler 显示 CPU Frame Time 有 25 ms其中 PlayerLoop 占 18 msGPU 只占 7 ms。CPU 侧各模块的排序如下UI 模块6.5 ms主要来自一个战斗结算面板的 Canvas 在持续重建面板里有个实时更新的计时器脚本逻辑4.2 ms其中大量字符串拼接和 GetComponent 调用渲染提交5.5 msDraw Call 高达 1100对应的优化动作把战斗结算面板拆成静态背景 Canvas 和动态数值 Canvas重建时间降到 1.8 ms对飘血文字做对象池缓存 TMP 对象字符串拼接全部改为 StringBuilder 复用场景中的小石头和植被改为 GPU Instancing角色特效启用 SRP Batcher 兼容 ShaderDraw Call 从 1100 降到 420优化后同样的战斗场景 CPU Frame Time 从 25 ms 降到 12 ms发热明显缓解帧率稳定在 55 到 60 帧。这个案例最有价值的部分不是单个技巧多新鲜而是先测量再动手的顺序保证了每个改动都服务于实测暴露出的瓶颈。5.5 这份排查清单建议保存收藏症状特征优先排查方向快速验证方式典型解决方案周期性卡顿、掉帧有规律GC 分配过高Profiler 查看 GC Alloc 与 GC 调用频率减少字符串、LINQ、装箱对象池化场景物体多转视角时发烫Draw Call 过高Frame Debugger 查看 Draw Call 数量静态合批、GPU Instancing、SRP BatcherUI 高频更新界面操作卡顿Canvas 重建Profiler 查看 Canvas.SendWillRenderCanvases 耗时动静分离、减少 LayoutGroup、文本对象池整体发热、所有场景都热CPU 主线程逻辑过重查看 PlayerLoop 系统耗时排序分帧、Job System、缓存组件引用这份表不是标准答案但按这个顺序排查大概率能覆盖 80% 以上的 Unity CPU 发热问题。剩下的 20% 往往与资源加载、Shader 复杂度、物理碰撞数量和音频系统有关需要结合项目的具体情况分析。我在实际项目里反复体会到一件事性能优化最难的从来不是某个具体技巧而是建立用数据说话的流程。GC、Draw Call、Canvas 重建这三个家伙每一个都擅长伪装成其他问题如果你不拿 Profiler 拆开看很容易在错误的方向上浪费几周时间。先把测量做扎实再动手修优化一次就是实打实的一次收益。发烫问题也是一样找到 CPU 真正的时间和功耗去向它就没那么可怕了。
返回列表