ARTICLE DETAIL

资讯详情

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

Unity手游CPU优化实战:GC、Draw Call与Canvas重建的定位与解决

Unity手游CPU优化实战:GC、Draw Call与Canvas重建的定位与解决 做手游优化的时间长了你会发现一个特别有意思的现象手机一发热团队第一反应永远是骂 GPU——“是不是特效加多了”“是不是场景面数超了”“后处理是不是开太狠了”但实际用 Profiler 抓一轮下来真正把帧耗顶穿的往往是 CPU 侧那几个特别不起眼的开销。今天这篇继续“发烫优化系列”前几篇我把 GPU 和渲染管线的降载思路聊得比较多这次反过来专门审一审 CPU。在 Unity 项目里CPU 侧最常出现、也最容易背锅的三大开销就是GC、Draw Call 和 Canvas 重建。这三兄弟藏在业务代码、UI 逻辑和渲染提交里一旦发烫或者掉帧只要顺着主线程的耗时柱状图往下挖大概率都能挖到它们。本文适合正在做移动手游性能优化、尤其是要做中低端机适配的开发者阅读不扯引擎源码不背 API 文档就聊实战里如何定位、如何取舍、如何落地。1. 先把锅分清楚发烫为什么不能全怪 GPU1.1 手机发热的物理来源手机发热这事本质上就是功耗问题。任何芯片工作都要吃电电变成热这是物理规律。CPU 和 GPU 都在片内谁负载高谁发热大但很多人对 CPU 的发热贡献是有误会的——总觉得 CPU 主要管逻辑应该“不怎么热”。实际在移动 SoC 上CPU 主频可以拉得非常高满载单核的功耗并不低。芯片温度一旦上来系统会开始降频于是出现一种经典现象手机烫手帧率反而掉然后你再看占用率CPU 和 GPU 都没跑满。这不是玄学是 CPU 瞬时高负载触发了温度墙系统在保命。所以在 Unity 项目里说“发烫优化”核心思路不是看谁占用率高而是看谁把功耗峰值顶上去的CPU 和 GPU 都有可能。回到日常开发CPU 的每一帧工作是非常杂的C# 业务逻辑、物理模拟、动画采样、粒子更新、UI 布局和网格更新、渲染命令拼装、网络包处理这些全在 CPU 上串行执行。哪个环节做得多CPU 功耗就高。做性能优化最忌讳“谁的饼画得大就改谁”CPU 侧的细节经常被 GPU 的大块时间掩盖。1.2 渲染瓶颈GPU 背了多少锅很多项目一发热就降画质、降分辨率结果温度没下来卡顿反而多了。为什么因为你降的那些东西压根就不是瓶颈。这里有个关键认知渲染的最终执行者是 GPU但渲染命令是 CPU 拼装和提交的。如果 CPU 提交命令太慢、太多GPU 反而经常处于等待状态占用率看起来不高但帧率已经崩了。用 Profiler 打开真机数据常见的情况是Main Thread主线程耗时很高Render Thread渲染线程在等待主线程喂数据。这时候 GPU 有大量的空转等待但芯片功耗并不低因为整个渲染管线都在空转。它还表现为“gpu cpu 内存占用都不高但卡”——这就是典型的 CPU 提交瓶颈尤其是 Draw Call 和 UI 重建引起的 Render Thread 阻塞。所以我前几篇讲 GPU 降载今天必须把 CPU 侧放上台面。要判断 CPU 是不是真凶方法很简单在 Profiler 里看主线程和渲染线程的耗时分布。如果渲染线程大量阻塞或者主线程被某个模块卡了几百毫秒那就别再去折腾 GPU 了先查 CPU。1.3 三大高发 CPU 开销GC、Draw Call、Canvas在 Unity 移动项目里CPU 侧的高发开销非常集中翻来覆去就是这几类GC垃圾回收C# 托管堆的分配和回收表现为周期性尖峰。Draw Call渲染提交CPU 向 GPU 提交绘制命令的开销表现为持续高压。Canvas 重建UGUI 在 UI 元素变化时重新生成网格的 CPU 开销表现为点击、界面切换、飘字瞬间的卡顿。这三样东西还有一个共性它们往往同时出现。比如战斗界面里你写了一段代码每秒更新血量文本这个操作本身会产生字符串分配的 GC 压力同时会触发 Text 组件所在的 Canvas 重建再加上 UI 界面的图片和文字还可能拉高 Draw Call一台中低端机就这么被一点点拖到发烫。排查时不能孤立看某一项但最先动刀的顺序有讲究后面逐个说。2. GC托管堆上的“定时炸弹”2.1 Unity 的 GC 机制和“Stop the World”Unity 项目里 C# 跑在 Mono 或 IL2CPP 上内存由托管堆管理。你每次 new 一个对象、拼接一个字符串、调用某些会返回新对象的 Unity API都会在托管堆上分配内存。GCGarbage Collection负责回收掉那些已经没有人引用的对象。GC 是“Stop the World”的回收过程中整个 C# 环境会暂停执行CPU 其他线程也可能被影响等 GC 结束后才恢复。你可以把托管堆想象成一个仓库每次 new 就是在仓库里画个格子放东西东西放乱了、不够放了维修工就要进场清扫把所有不用的格子清掉把小格子拼成大格子这时候全仓库的人都要停下来等。如果仓库堆得越大清扫时间就越长。Unity 在 Mono 下有 SGen 分代 GCIL2CPP 默认使用基于 Boehm 的保守式 GC新版 Unity 还提供增量 GCIncremental GC选项可以把一次长 GC 分摊到多帧执行。注意“分摊”不等于“免费”增量 GC 往往会让每帧都多出一点额外开销有些项目开启后反而更卡。所以在没有实测前不要默认开。2.2 GC 尖峰如何变成“发烫”和卡顿一次完整的非增量 GC 在低端机上可能耗时几十毫秒严重时上到几百毫秒这对应到帧率就是灾难平时 60 帧的画面GC 发生时可能直接掉到个位数。如果项目里存在持续的零散分配系统会每隔几秒触发一次小型 GCCPU 的占用和功耗一直在高位波动手机表面温度就会稳步上升。这里有个非常隐蔽的现象GC 导致的掉帧不是持续低帧而是“平时流畅偶尔卡一下”。这种毛刺很容易被测试人员忽略但它对功耗的影响很大。因为移动端的调度器发现负载上升会把 CPU 频率顶上去顶上去之后就出现“温度升高、降频、继续卡”的恶性循环。另外GC 尖峰还会和垂直同步配合产生“假锁帧”现象某一帧因为 GC 超过了 vsync 间隔Android 上可能会触发后续帧的一连串掉帧让你以为游戏被锁在 30 帧了。这种问题改帧率上限没用必须把 GC 的根因找出来。2.3 用 Profiler 揪出真正的分配点排查 GC 问题不要只盯着 Profiler 右上角的 GC 时间那只是结果不是原因。要看分配点在哪。操作路径Window Analysis Profiler选择 CPU Usage 模块在 Hierarchy 视图顶部勾选 “GC Alloc” 列然后按这列排序。你会看到每个函数单帧分配了多少字节。注意默认 Profiler 只能看到部分 Unity 内部标记想要看到具体 C# 方法名可以开启 Deep Profile但这个选项会极大拖慢运行速度不建议在真机上长时间开可以在编辑器里抓一小段典型操作比如“打开背包”“释放一次技能”。拿到数据后区分“一次性分配”和“持续分配”初始化时的分配可接受每帧都在分配的才是问题。比如说一个 Update 函数里每帧都GetComponent或者string GC Alloc 就会稳定在一个高水位这种代码优先级最高。2.4 降分配实操对象池、缓存、避免装箱优化 GC 的核心不是“让 GC 更快”而是“让 GC 没活可干”。我总结了几个在项目里反复使用的降分配手段按优先级排字符串拼接循环里用StringBuilder或者缓存模板字符串然后做格式化。Debug.Log 在正式包要全部关掉它的字符串拼接是隐藏分配大户。LINQWhere/OrderBy/First这类方法会产生迭代器和委托分配能改成 for 循环就改。团队代码规范里直接禁掉在 Update 里使用 LINQ。Lambda 闭包捕获外部变量的 lambda 会生成闭包类并可能装箱事件和协程里尤其容易踩。Unity API 缓存GetComponent、FindObjectOfType这类 API 内部有分配反复调用不仅慢还产生 GC。在Awake/Start里缓存引用。协程频繁开启协程会产生 IEnumerator 对象如果一定要用考虑 UniTask 或自定义状态机。数组和 List每帧new List然后填数据再清空是非常差的写法可以用System.Buffers.ArrayPoolT或者复用容器。可以用下面这个表做代码评审时的快速对照分配来源推荐替代收益string StringBuilder/ 缓存模板消除每帧字符串分配LINQWhere/OrderByfor 循环消除迭代器、委托分配协程频繁启动UniTask / 状态机减少 IEnumerator 和yield分配GetComponent反复调用Awake 缓存减少 API 内部分配new List 每帧填充ArrayPool 或复用容器降低内存抖动GC 优化在代码评审阶段就要做不要等到发热再去翻老代码。我曾见过一个项目就因为飘字系统每帧string 拼接伤害数字整场战斗 CPU 多了 10% 的开销把帧率从 60 拖到 45。改掉那一个文件后温度和帧率同时好了。3. Draw Call被低估的提交成本3.1 Draw Call 到底在消耗谁的 CPUDraw Call 在 Unity 里指的是 CPU 给图形 API 下发的一次绘制命令。很多人误以为 Draw Call 高 GPU 压力大其实不是。真正消耗 CPU 的是状态切换和命令提交尤其是 SetPass Call——在切换 Shader、纹理、混合模式时驱动要做一堆状态绑定和校验。这些发生在 CPU 侧发生在主线程和渲染线程的命令拼接阶段。在移动端和 PC 的架构又有区别。PC 上 CPU 和 GPU 通过 PCIe 高速通信Draw Call 驱动的吞吐能力强一些移动端是 SoC 内部共享内存命令提交的固定开销更大。这也是为什么同一个场景在 PC 上 DC 很高没事打包到手机上就开始卡和发热。所以移动端必须对 DC 数值更敏感。当 Draw Call 过高时Profiler 里会看到 Render Thread 大量时间花在阻塞等待上GPU 占用率反而不高。这种 CPU 与 GPU 的错位是发热优化的重点排查点因为它不会体现在“降低画质”上必须从合批和提交数量入手。3.2 合批机制怎么选Static、Dynamic、SRP Batcher、InstancingUnity 提供多种合批手段但每种都有适用边界选错反而更亏。我按实际项目经验给你做一个横向对比合批方式适用场景主要优点主要问题Static Batching场景中的静态物体将静态网格合并提交DC 下降明显内存占用变大、合并后包围盒变大、移动物体后需要重新合并Dynamic Batching运行时移动的小网格无需预处理移动端友好顶点数限制严格、频繁切换材质时 CPU 计算开销可能大于收益SRP BatcherURP / HDRP 项目缓存材质属性降低 SetPass 状态切换开销需要兼容 Shader材质属性切换频繁时仍有部分开销GPU Instancing大量同网格同材质比如草、树、子弹提交次数极少GPU 友好只适用于同 mesh 同材质不能跨材质这里特别说一下 SRP Batcher。如果你用的是 URP且 Shader 是 Standard 或者升级到兼容 SRP Batcher 的方式那么就算 DC 数字不低实际 CPU 提交开销也会比旧的 Batcher 小很多。有些项目把 DC 优化到很低但没用 SRP Batcher帧耗还是高反过来又去追求更极端的 DC 数量其实方向错了。新版 URP 项目第一步应该是先开启 SRP Batcher再谈手动合批。3.3 网格与包围盒的隐藏开销很多场景 Draw Call 不高渲染线程却依然耗时问题可能出在包围盒Bounds上。Unity 的视锥剔除Frustum Culling和遮挡剔除Occlusion Culling都依赖包围盒判断。当你合并静态网格后原来两个相距很远的物体被合成一个大网格包围盒也会变成能罩住两者的超大型包围盒只要视角能看到其中一个物体整个合并网格就会全部提交给 GPUDC 是降了但顶点和像素白算了一大堆。SkinnedMesh 和粒子系统也经常出现包围盒虚大的情况骨骼动画如果某个动画帧脚踢得很远Bounds 会被拉得很大粒子系统生命周期长、喷射范围大Bounds 也会铺满全场。结果就是物体根本不在视锥内却仍然在提交绘制CPU 和 GPU 都在做无用功。排查技巧打开 Scene 视图选中 Renderer 看 Bounds 线框或者用 Profiler 的 Rendering 模块对比“可见物体数”和“总物体数”。如果不可见物体还在渲染优先处理包围盒。对于超大网格合并必要时拆回多个小网格不要让合批的收益被剔除失效吃掉。3.4 DC 优化的边界不要盲目追求低数字“Draw Call 小于 100 才算合格”“DC 必须是两位数”——这种拍脑袋的目标很容易把项目带偏。DC 是手段不是目的。正确的做法是看目标设备上渲染线程的耗时如果 Render Thread 占帧耗不到 10% 且没有阻塞DC 数量没必要硬压。优化 DC 的实践顺序我会这么排第一优先确认 SRP Batcher 开启了且 Shader 兼容。这是性价比最高的。第二优先相同网格的重复物体上 GPU Instancing。草、树木、路灯、重复的小道具收益极明显。第三优先静态场景做 Static Batching但提前评估内存预算和包围盒问题。第四优先UI 部分用图集合并 Sprite减少 UI 的 Draw Call。注意要重新权衡 Canvas 数量UI 的 Canvas 拆太碎也会增加开销。我见过一个反例团队为了把 DC 从 400 压到 150把所有静态物体强行合并成一个大网格结果内存暴涨包围盒问题导致远处可见物体全部被提交实际帧耗时反而变长了。冷静下来一测发现 DC 300 的时候 Render Thread 才 3ms根本没必要动。先看数据再定目标。4. Canvas 重建UI 卡顿里最隐蔽的那一环4.1 UGUI 的“画布重绘”到底在干什么UGUI 的 UI 渲染不是“每个图片单独画”而是 Canvas 统一收集所有 Graphic 组件的网格打包成一批数据再提交给 Canvas Renderer。这个打包和网格生成的过程Unity 内部叫 Rebuild重建。它有两大块Layout Rebuild布局重算和 Graphic Rebuild图形网格重生成。你可以这样理解Canvas 是一块电子画板每个 UI 元素是画板上的图案当你改动其中一个图案的位置或内容时为了让所有图案的遮挡关系正确Unity 可能需要把这块画板上涉及脏区域的图案全部重新画一遍。你只是给一个按钮挪了 1 像素但如果这个 Canvas 下有几十个元素那这几十个元素的网格都可能要重算。而且 Layout 组件的开销更大。如果 Canvas 下挂了一个 VerticalLayoutGroup子元素变化时Unity 要重新计算所有子元素的布局再同步布局结果给锚点和 RectTransform最后才进 Graphic 重建。这一步的 CPU 消耗是呈指数级反应的子项一多就卡。4.2 触发重建的高危操作在我经手的项目里UI 重建的高危操作其实非常集中写在这里给各位排雷SetActive切换显隐对 UI 游戏对象频繁 SetActive尤其是挂在 Canvas 根节点或带 LayoutGroup 的父节点下会造成整棵子树的布局和图形重建。修改 RectTransformsizeDelta、anchoredPosition、pivot、rotation、scale这些属性的改变都会把 Canvas 标脏。修改文本内容Text 组件的 text 变化会重新生字网格TextMeshPro 也一样。修改 Image 的属性比如切 sprite、改变 fillAmount、开 raycastTarget。LayoutGroup 被打扰任何子项增删或尺寸变化都会触发整个布局组重排。其中坑最深的是“飘字”和“血条”这类高频更新元素。如果你把它们和整个战斗界面的背景、按钮、头像放在同一个 Canvas 里那么每帧更新飘字位置或血量文本都会让那个巨大的 Canvas 持续重建。这不是“优化图片不清晰”能解决的必须从 Canvas 结构上拆。4.3 划分 Canvas 的黄金法则对于 Canvas 重建最有效的策略就是“拆分”。但拆分不是随便拆要按变化频率来拆。我的实践原则很简单静态 UI 一个 Canvas背景、常驻按钮、标题栏这类几乎不变的元素放在一起。高频动态 UI 单独 Canvas飘字、血条、伤害数字、倒计时、进度条每个高频区域独立 Canvas或者至少独立于静态 UI。界面级大切换单独处理比如背包界面、弹窗、任务面板这种从隐到显的整个界面如果要经常开合它们自成一个 Canvas 比隐藏在根 Canvas 下反复 SetActive 要好。控制 Canvas 数量不要为了优化把一个界面拆成几十个 Canvas每个 Canvas 又是一次批处理提交。移动端经验是一个主战斗界面控制在 3-6 个 Canvas 比较合理。这样拆分之后战斗飘字变化时只会重建它所在的小 Canvas静态背景的 Canvas 完全不用动收益是立竿见影的。4.4 从 Profiler 的 Canvas 模块定位问题定位 Canvas 重建可以在 Profiler 里切到 CPU Usage 模块搜索Canvas相关的函数名比如Canvas.SendWillRenderCanvases、Canvas.BuildBatch、Canvas.RenderOverlays。如果这些函数单帧耗时不低并且每帧都在出现说明确实有 Canvas 在持续重建。更直观的方式是用 Frame Debugger打开后选中“UI”相关的 Draw Call可以看到每个 UI 网格的来源和顶点数据配合 Scene 视图能快速反查是哪个 Canvas 下的哪些元素太杂。还有一个土办法拆分前在 Canvas 上挂一个 DEBUG 脚本在OnRectTransformDimensionsChange或Canvas.willRenderCanvases事件里打印日志看看哪些 Canvas 每帧都在被标记重建。实际项目中我用这个方法快速定位过某活动的“礼包小红点”它每帧改 position导致整个主界面 Canvas 每帧重建。UI 优化和 GC 优化经常要一起做比如文本更新时用缓存字符串避免分配同时也减少了 Text 内容变化导致的 Graphic rebuild。TextMeshPro 比普通 Text 在内存和重建方面有一定优势但也需要注意字体 fallback 和多语言动态字体这块也是移动端发热的隐形地雷。5. 一套可落地的排查流程与防回退5.1 发热/卡顿问题的排查顺序真机发热问题的排查我认为按这个顺序走最不容易漏先接真机 Profiler截取一段有代表性的操作比如打一场战斗或者打开 3-4 个核心界面。看 Main Thread 总耗时确定掉帧源头是 CPU 还是 GPU。GPU 高就查渲染设置CPU 高就继续往下分。把 CPU 耗时按模块排序Scripts / Rendering / UI / Physics / Animation看哪个占比高。如果 Scripts 高切到 Hierarchy 并按 GC Alloc 排序查分配热点。如果 Rendering 高看 Draw Call 数和 Render Thread 阻塞情况再用 Frame Debugger 分析合批。如果 UI 高重点看 Canvas 重建检查是否有高频元素和静态元素混在同一个 Canvas 下。这套流程里最容易被忽略的是第 4 步和第 6 步因为它们经常同时发生。比如血量数字更新既产生 GC Alloc又触发 Canvas 重建你只修一个另一个照样拖累性能。5.2 设定性能指标从“感觉卡”到“数据说话”优化没有指标就是空谈。我在项目里通常会定这几条硬指标分别卡住本文说的三座山GC Alloc战斗场景平均每帧不超过 2KB峰值不超过 8KB低端机还要更严。Draw Call以目标机型实测为准一般中端机除 UI 外场景 DC 控制在 200-300 以下Render Thread 每帧耗时不超过 8ms。Canvas 重建单帧Canvas.BuildBatch耗时不超过 1ms战斗中高频刷新区域必须是独立小 Canvas。帧耗时取 P95 不超过 33ms也就是 95% 的帧不能掉出 30 帧的节奏。这些指标要写成文档挂到 CI 上做检查。Unity 有 Performance Testing Extension可以写自动化用例跑固定场景的 CPU 耗时和 GC 分配一旦超过阈值就标红拦截。新功能合入主分支之前先跑一遍性能门禁比等用户反馈发热再返工靠谱得多。5.3 常见问题速查表症状可能原因排查路径解决方案偶发掉帧到个位数全量 GCProfiler 抓 GC 尖峰按 GC Alloc 排序降分配、对象池、评估开启增量 GC打开某界面后卡一下Canvas 全量重建UI 模块看 SendWillRenderCanvases拆分 Canvas把动态元素独立出来场景 Draw Call 高且 GPU 占用低合批不足或驱动提交瓶颈Frame Debugger、Render Thread 分析开 SRP Batcher、Static Batching、Instancing不可见物体还在绘制包围盒过大、剔除失效Scene View 看 Bounds对比可见物体数修正 Bounds、拆分合并网格、限制粒子范围CPU 占用不高但帧率卡主线程等待/垂直同步Profiler 时间轴找 WaitForTargetFPS检查图形设置、帧目标、避免因一帧超时连锁掉帧关于最后一个问题多说一句有时你看到 CPU 不忙、GPU 也不忙但游戏就是卡。这种情况大概率是某一帧出现了一个极长的任务比如 GC 或磁盘读取把整体节奏打乱了。不要被平均占用率迷惑回来拉时间轴找尖峰比什么都能解决问题。这次把 CPU 侧的三座山搬了一下我实际带项目的体感是GC 优化收效最快Draw Call 排查最直观Canvas 重建最容易被忽略。建议新项目从立项就把这三项指标当成硬门禁别等线上用户反馈发热再来救火。最后再分享一个排查小技巧真机上发热问题不要只盯着 Profiler 的平均值多看看帧时间的“毛刺”。很多发热不是一直高而是偶尔一次全量 GC 或 UI 重建把频率顶上去这时打开 Profiler 的时间轴拉一条标尺对准尖峰基本都能找到真凶。优化到后面你会发现CPU 不是不肯背锅是大家一直没让它在 Profiler 里把话说清楚。
返回列表