ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Unity CPU性能优化:GC、Draw Call与Canvas重建的定位与治理

Unity CPU性能优化:GC、Draw Call与Canvas重建的定位与治理 说实话每次看到项目群里发烫、掉帧的问题大家第一反应都是 GPU 不行、Shader 太费、后处理太多……但我做过不少 Unity 项目的性能优化后越来越确定一件事CPU 往往才是那个“闷声干大事”的发热源。尤其当你把 GPU 侧压得差不多之后Profiler 里剩下的三个大块就是 GC、Draw Call 和 Canvas 重建。这一篇就围绕这三件事讲讲它们在 CPU 上到底干了什么、怎么定位、怎么治。先说结论显卡发热通常来自像素填充率和显存带宽压力而 CPU 发热则来自线程空转、内存分配、渲染状态切换和 UI 网格重建。如果你在真机上摸过后盖会发现很多时候 CPU 的功耗占比并不比 GPU 低多少。这个系列前面几篇主要聊了 GPU 侧的渲染开销今天把火力集中到 CPU把这三个“背锅侠”的老底掀开看看。1. CPU 在“发烫”这件事上扮演的角色1.1 为什么 CPU 常常被冤枉很多团队的优化流程是手机发烫 → 打开 Profiler → 看到 Rendering 耗时偏高 → 开始砍 Shader、砍后处理、砍分辨率。这一套做完帧率有时候确实上来一点但后盖照样烫。再仔细看 Profiler发现主线程里真正的大头是脚本里的内存分配、渲染线程的 Draw Call 提交、还有 UI 的网格重建。CPU 发热的本质不是“计算量大”这么简单而是功耗。手机 SoC 上 CPU 和 GPU 通常是同一个封装里的不同模块CPU 满频工作一样会推高整机功耗和温度。而且 CPU 一旦长时间高负载系统调度器会主动降频降频后帧率掉到 30 以下体感就是“越来越卡”。所以优化 CPU 侧的这三个点不只是为了帧率数字好看更是为了让机器在 30 分钟、60 分钟之后还能保持稳定性能。拿一个形象点的类比GPU 是流水线上的加工设备CPU 是调度员、打包工和原料工。你让加工设备优化到飞起但调度员每秒钟要喊几百次“下一批”、打包工不停把货物拆了重新装、原料工反复把布料剪了重新织最后产能一样上不去而且这三个人的体力消耗功耗一点不少。1.2 三个问题之间的优先级GC、Draw Call、Canvas 重建这三件事看似互不相干实际上经常一起出现。举个例子一个 MMO 大厅主城场景里大量角色、大量 UI 飘字、排行榜列表频繁刷新这时候 GC 分配爆炸、Draw Call 分分钟破百、Canvas 每帧重建三种问题同时压在主线程上帧间隔直接拉成心电图。我的经验是优先级按影响面排GC 最先处理因为它影响的是所有逻辑的稳定性哪怕你 Draw Call 很低只要每帧都在分配对象GC 一触发就是几十毫秒的停顿Draw Call 次之它决定渲染线程和主线程之间的提交压力影响整体帧率上界Canvas 重建最后处理因为它的爆发通常集中在 UI 交互的瞬间属于“点状卡顿”需要单独压。在动手之前先定位。不要凭感觉猜Unity Profiler 的 CPU Usage 模块能直接告诉你主线程时间都花在哪。后面第 3 章会详细讲怎么用 Profiler 给这三件事做体检。2. 核心细节解析与实操要点2.1 GC别再让它随意抄底GC 在 Unity 里是 Mono 或 IL2CPP 托管堆的垃圾回收机制。老版本 Unity以及不少 IL2CPP 项目的默认配置使用的 Boehm GC 是非分代、非压缩的回收器它的特点是只要触发回收就会把整个托管堆扫一遍扫描期间所有托管线程全部暂停。这个“暂停”在移动端上非常致命常见表现就是帧率突然掉一下然后恢复正常过几秒又掉一下。为什么移动端对 GC 这么敏感因为移动端 CPU 主频低、核数少一帧预算只有 16.6ms60fps或 33.3ms30fpsGC 一次扫堆只要超过 5ms这帧就废了。而且更麻烦的是GC 的触发时机不可控它往往在你刚分配一批对象、堆内存达到阈值的时候来一下正好卡在战斗最激烈的时刻。最常见的隐形分配源我列个清单你们对着查字符串拼接Debug.Log(HP: hp / maxHp)这种代码每执行一次就产生至少两个临时字符串。LINQlist.Where(...).Select(...)看起来简洁但 Where 会创建迭代器结构体Select 还会再包一层遇到 lambda 更是每次都分配。闭包和匿名委托循环里给按钮onClick.AddListener(() DoSomething(i))这个i会被闭包捕获编译器会创建一个包装类对象。装箱值类型转 object比如把 int 传给一个接收 object 参数的函数或者调用非泛型接口方法都会装箱。协程里的yield return new WaitForSeconds(...)每次执行都 new 一个对象循环等待更是频繁分配。GetComponent 本身不分配但如果你在 Update 里每帧调用它虽然不产生 GC也会增加 CPU 指令开销应该缓存引用。用 Profiler 的 GC Alloc 列抓分配点时我的阈值是每帧 2KB 以上就要警惕了超过 10KB 基本意味着你在热路径上做了不该做的事。比如某个循环里每帧创建一个临时 List那 GC 曲线肯定是一根直线往上窜。实操修正的方向很明确字符串拼接改成 StringBuilder 或字符串插值注意插值也会分配但比多个好LINQ 改成普通 for 循环闭包改成显式传参装箱用泛型接口或直接 ToString协程的等待对象手动缓存GameObject 和临时列表一律走对象池。2.2 Draw Call合批不是万能的Draw Call 是 CPU 向 GPU 提交渲染命令的开销但很多开发者把“Draw Call 数量”和“性能”直接画等号这是误解。严格来说CPU 更怕的是 SetPass Call也就是渲染状态切换。每次切换材质、Shader Pass、贴图CPU 都要重新打包一批渲染状态给 GPU这个过程的成本远高于单纯增加一个 Draw Call。Unity 提供了静态合批、动态合批、GPU Instancing、SRP Batcher 四种合批手段各有各的适用场景静态合批把场景里标记为 Static 且共享相同材质的网格合并成一个大网格。代价是内存增长因为合批会复制顶点数据打包时间和包体也会变大。适合地形、建筑、摆放件这类完全静止的物体。动态合批对小网格自动合并限制很死顶点数超过 900某些平台 300就不生效而且很多材质属性差异会打断合批。移动端项目基本可以放弃依赖它。GPU Instancing适合大量相同网格、相同材质、只有 transform 或颜色不同的物体比如几百棵一样的树、成千上万个小兵。它把每个实例的变换数据打包成缓冲区一次提交绘制全部实例。注意它和动态合批互斥一种物体只能走其中一条路径。SRP BatcherURP 和 HDRP 下的主力方案专门解决材质属性绑定和状态切换的开销。前提是 Shader 必须兼容 SRP Batcher尽量用 URP 内置的 Lit/Unlit 或者遵循 SRP Batcher 规范的自定义 Shader。你以为开启合批就万事大吉实际问题往往是合批没有按预期生效。最常见的原因包括物体使用了材质实例而不是共享材质两个物体虽然看起来一样但各自有独立材质实例就会打断合批UI 元素没有打图集每个小图都是独立纹理阴影投射和额外 Pass 会把批次拆开。用 Frame Debugger 可以逐帧查看每个 Draw Call 为什么合批/为什么没合批。它会把合批失败的物体标出来点击后能看到这个 Draw Call 的材质、网格、光照信息对照原因慢慢排查。2.3 Canvas 重建UI 卡顿的隐形元凶UGUI 的 Canvas 是一个网格缓存和批处理单元它把子 UI 元素的顶点数据合并成网格提交给 GPU。问题是只要 Canvas 范围内的任何一个 UI 元素发生变化整个 Canvas 的所有元素都要重新生成网格、重新合批、重新提交。这就是“Canvas 重建”在 Profiler 里通常表现为Canvas.SendWillRenderCanvases。触发重建的操作比你想的更常见修改 RectTransform 的尺寸或位置、修改文本内容、更改图片 Sprite、启用或禁用子物体、修改材质、改变颜色和 alpha部分版本。哪怕是 ScrollRect 滚动一下整个 content 下的所有元素都会重新计算如果你把所有 UI 都放在同一个 Canvas 下那么每次弹窗、每次飘字、每次进度条变化所有 UI 全部重建一遍。这个成本有多高一个包含上百个元素的 Canvas重建一次消耗 2~3ms 是很正常的事如果还有大量 Text 在里面Text 的字体图集查找和顶点生成会更慢。几个弹窗动画同时在跑每帧重建几十上百个元素帧率直接被按在地上摩擦。优化 Canvas 的核心原则是“分割”。把静态 UI背景、边框、常驻图标放在一个 Canvas把动态 UI血量数字、冷却时间、飘字放在另一个 Canvas两张 Canvas 互不干扰动态部分重建时静态部分不用跟着倒霉。值得注意Canvas 不能无脑切碎每个 Canvas 都有自己的合批链路和网格切得太碎会导致 Draw Call 上涨和内存浪费一般控制在 3~5 个以内。另外几个高频坑Text 的 Best Fit 会在字体变化时重新采样非常贵尽量固定字号Shadow 和 Outline 组件会让文本顶点翻倍能不用就不用Image 的 Raycast Target 默认开启每帧 UI 射线检测会遍历所有带这个选项的组件能关就关。3. 实操过程与核心环节实现3.1 用 Profiler 给 CPU 三个痛点做“体检”连接真机打开 Window Analysis Profiler切到 CPU Usage 模块。这里我强烈建议用 Development Build Autoconnect Profiler 方式跑真机Editor 里的 Profiler 数据参考价值极低因为编辑器自身开销、 vsync 模拟和资源加载路径都跟真机差很多。录制一段至少 5 分钟的真实玩流程包括战斗、UI 操作、切场景。然后看两个东西一个是主线程 Timeline 的耗时分布一个是 GC Alloc 曲线。Timeline 里主要关注这几项项代表什么合理范围Scripts脚本逻辑总耗时目标 4ms 60fpsRendering渲染提交耗时依场景复杂度稳定不抖动Canvas.SendWillRenderCanvasesUI 网格重建目标 1msVSync垂直同步等待如果经常是 0说明帧率被其他东西拖住GC Alloc 曲线可以用 Hierachy 模式查看。关闭 Deep ProfileDeep Profile 会注入大量插桩代码性能和分配数据都会失真。默认 Profiler 已经能抓 GC Alloc 的调用栈只是粒度可能不如 Deep Profile 细但对定位热分配点够用了。真机测试时还有个小技巧在 Profiler 窗口的 Frame 面板里点击某一帧右侧 Hierarchy 会按耗时排序重点看 Scripts 和 GC Alloc 两列交叉最高的函数那基本就是元凶。3.2 GC 定位与清零从 Profiler 抓分配到代码整改先说一个我自己的标准热路径Update、协程、UI 回调、战斗逻辑上每帧 GC Alloc 清零非热路径的单次分配控制在 KB 级以下。这不是强迫症是真的能做到关键在于把“每帧分配”的习惯改成“预分配 复用”。用一个最常见的滑动条、数值飘字场景举例。优化前代码可能是这样的void Update() { // 很常见的写法每帧拼接字符串 hpText.text HP: currentHp / maxHp; // 协程等待每帧都 new 一个 WaitForSeconds StartCoroutine(WaitAndDoSomething(1f)); }这段代码每帧做了什么字符串拼接创建了至少 3 个临时对象StartCoroutine本身也可能分配WaitForSeconds又是一个新对象。如果这个脚本同时挂在 20 个角色身上一帧就是 20 份分配GC 曲线直接起飞。优化后的做法分两步。第一步把文本拼接改成手动缓存数值或使用 StringBuilderStringBuilder sb new StringBuilder(32); void Update() { sb.Clear(); sb.Append(HP: ); sb.Append(currentHp); sb.Append(/); sb.Append(maxHp); hpText.text sb.ToString(); }注意 StringBuilder 的ToString()仍然会分配字符串这是绕不过去的因为 Text 组件必须接收字符串。但相比之前 3 个临时对象至少少了一半。如果数字变化不频繁更好的做法是只在数值真正变化时才更新文本加一个脏标记判断。第二步协程等待对象手动缓存static readonly WaitForSeconds waitOneSecond new WaitForSeconds(1f); IEnumerator WaitAndDoSomething() { yield return waitOneSecond; // do something }WaitForSeconds只要参数不变完全可以做成静态只读对象整个游戏生命周期只分配一次。类似的还有WaitForEndOfFrame、WaitForFixedUpdate。对象池方面我的建议是不要只盯着 GameObject临时 List、数组、结构体容器同样值得池化。比如一个频繁生成的飘字系统与其每次new一个飘字对象和它的位置 List不如提前开一个固定容量的池子用完归还。配合List.Clear()复用内部数组GC 分配能压到接近零。3.3 Draw Call 落地合批方案选型和验证接到一个优化任务不要上来就到处勾 Static先分析场景构成。我的流程是首先把场景物体按“是否经常移动、是否共享材质、是否是同网格”分成三类不动且共享材质的进静态合批大量重复且可能移动的进 GPU Instancing其余交给 SRP Batcher。如果是 URP 工程先把 SRP Batcher 打开在 Project Settings Graphics 里确保勾选然后检查自定义 Shader 是否兼容。兼容的标准是 Shader 里所有属性都走 CBUFFER 声明而不是用_Color、_MainTex_ST这类不在 CBUFFER 里的变量。Unity 官方 Lit/Unlit 没问题第三方 Shader 要逐个验证。静态合批的操作看起来简单勾上 Static 就行但注意几个坑静态合批只对完全静止的物体生效运行时任何 transform 变化都会打断合批并产生警告合批后的网格顶点数不能超过单个 Mesh 的索引上限64K 索引超出会分成多个批次内存会增加所以静态合批不是越多越好而是“该合的合不该合的不合”。验证合批效果用 Frame Debugger。打开 Window Analysis Frame Debugger逐帧看 Draw Call 列表如果两个物体理论上能合批但实际分开了点击对应的 Draw Call右侧会显示它的 mesh、material、pass以及为什么没有合批。常见提示包括“Material differs”“Mesh differs”“Shadow caster pass 导致额外 draw”。你可以针对性地改材质实例、合并贴图图集、调整光照设置。实际项目中我遇到最多的问题是美术给同一个模型的不同部位贴了独立材质导致静态合批直接失效。解决办法是把多张贴图合到一张图集里用 UV 偏移区分部位一套材质搞定。另一个问题是粒子系统每个粒子发射器都是一个独立 Draw Call优化方向是粒子贴图图集化和限制发射器数量而不是去合批它们因为粒子本身是动态顶点流静态合批对它无效。3.4 Canvas 重建优化实操拆分与降频前面说了 Canvas 重建的原理实战里的第一步永远是定位。打开 Profiler 的 UI 模块可以看到每个 Canvas 的重建耗时和重建原因。如果你的 Unity 版本没有 UI 模块就主线程里抓Canvas.SendWillRenderCanvases的调用栈它能告诉你哪个 Canvas 触发了重建。定位到问题 Canvas 后按静态/动态拆分成两个或更多 Canvas。静态 Canvas 放背景、边框、常驻按钮、图标动态 Canvas 放血量数字、冷却遮罩、飘字、滚动列表。两个 Canvas 的层级关系不影响视觉表现因为 UI 渲染顺序由 Canvas 的 sortingOrder 决定和父子关系不是强绑定。拆分之后我在动态 Canvas 内部还会做更细的降频处理。比如每帧都在变化的数字文本改成数值变化时才更新文字的脏标记模式int _displayedHp; public int hp { set { if (_displayedHp value) return; _displayedHp value; hpText.text value.ToString(); } }这段代码的意义是避免每帧调用ToString()和 Text 的 setter因为后者内部会标记脏并触发重建。只有真正数值变化才更新重建次数从每帧一次降到实际变化次数。ScrollRect 滚动列表是 Canvas 重建的重灾区。推荐的做法是对象池 只渲染可见项不要整个 content 全量生成。就算不列表化至少要把列表所在的 content 独立成一个 Canvas避免列表滚动拖累全屏 UI 重建。另外少用 Shadow、Outline 这类会产生额外顶点和重建的组件。一个带 Outline 的 Text其顶点数会翻倍重建成本随之上升。如果在列表项里大量使用滑动时卡顿非常明显。可以改用预先做好的九宫格背景图或者直接去掉阴影效果。4. 常见问题与排查技巧实录4.1 Profiler 数据波动大如何判断是不是 GC很多人打开 Profiler 看到帧时间忽高忽低却说不清问题在哪。我的判断顺序是先看 GC Alloc 曲线是否持续上涨或周期性出现尖峰再看主线程 Timeline 是否有那种“突然一条长条”的阻塞最后用 Memory Profiler 抓堆快照对比两个时间点的 Object 数量和类型。如果你看到一个函数的 GC Alloc 是 0说明它没有额外分配但这不代表它实时性就好。有些函数本身不分配对象但做了大量计算导致 CPU 占用高这类问题用 GC 视角是抓不到的得看 Self 耗时。GC 定位专门看分配CPU 耗时定位专门看 Self两套指标要分开用。还有一个很容易忽略的点IL2CPP 的 GC 行为和 Mono 不一致同一个项目两种后端跑出来的 GC 曲线可能完全不同。优化的时候我一般兼顾两个平台但以真机目标平台为准比如目标是 Android 就用 IL2CPP ARM64 跑 Profiler。4.2 我已经开启合批但 Draw Call 还是高合批没有生效的原因很多我把平时排查的清单整理成表方便大家按图索骥现象可能原因排查步骤静态合批后 Draw Call 没降物体没正确标记 Static检查 GameObject 的 Static 复选框确认不是运行时才设置的两个物体同网格同材质却分两个批各自持有材质实例检查 Material 引用是否是同一个不要用 Renderer.material会实例化UI Draw Call 很高图片没用图集或用了独立材质查看 AtlasProvider 是否生效确认所有 UI 元素引用图集里的 SpriteURP 下 SRP Batcher 没生效Shader 不兼容 SRP Batcher在 Frame Debugger 里看 Draw Call 是否为 Batch 类型控查 Shader 是否走 CBUFFER阴影导致批次翻倍多光源投影产生额外 Pass阴影质量调低或改用烘焙光影调试技巧在 Frame Debugger 里搜关键词 “Why did the batch break”Unity 新版会给出相对明确的拆分原因照着提示改比瞎猜效率高得多。4.3 Canvas 重建优化的经典误区和注意点先说一个最常见的误区不少人以为 UI 元素少就没事。实际上 Canvas 重建的开销不仅取决于元素数量还取决于网格生成的复杂度。一个只有 5 个 Text 的 Canvas如果这 5 个 Text 都开了 Best Fit 并且频繁改内容重建成本可能超过 50 个静态 Image 的 Canvas。另一个误区是“拆分越细越好”。每个 Canvas 都有独立的批次和网格拆出十几个 CanvasDraw Call 会涨内存也会涨。一般控制在 3~5 个以内并且每个 Canvas 的作用域要清晰。我见过把每个按钮单独包一个 Canvas 的项目Draw Call 直接翻倍得不偿失。注意点方面我特别强调真机测试时不要只看 Editor。Editor 的 UI 重建路径和真机有差异尤其是字体渲染和网格上传部分Editor 里看不出来的问题到了真机全暴露。做 UI 优化必须用 Development Build 在真机上跑用 Profiler 的 UI 模块和真机帧率双确认。列表优化还有一个容易被忽略的小点当 ScrollRect 停下来之后如果 content 的尺寸和位置还在变化比如惯性回弹动画它依然会持续触发 Canvas 重建。可以在回弹动画结束后把 content 的anchoredPosition冻结或者干脆在不需要动画时禁用 ScrollRect 的 inertia。结尾调 UI 和 GC 这种事表面上是在抠代码细节实际上是在跟项目里的“每帧惯性”作斗争。我自己的体会是性能优化不靠神来之笔靠的是把每帧干了什么、为什么这么干、有没有更省的方式这三个问题问到底。如果你能把 GC Alloc、SetPass Call、Canvas 重建这三个指标都量化进项目的性能预算里并且每次提交代码都看一眼变化发热问题基本不会积累到上线前才爆发。最后分享一个我常用的“性能预算表”模板适合贴到项目 Wiki 或者团队文档里主线程脚本耗时不超过 4ms、GC Alloc 每帧不超过 2KB、SetPass Call 移动端不超过 50 个、Canvas 重建不超过 1ms。这四个数守住CPU 侧的发烫黑锅基本就可以摘掉了。
返回列表