1. 发烫优化系列第5篇:先把烫手山芋从GPU头上挪开
做Unity性能优化的朋友,应该都有过这种经历:测试机上游戏跑起来,手机背面热得能煎鸡蛋,帧率忽高忽低,玩家在评论区疯狂反馈“一玩就烫”。大多数人的第一反应是“GPU又爆了”——马上降画质、砍分辨率、关阴影,结果发现温度没降多少,该掉的帧还是照掉。
这一篇系列文章,我想把矛头从GPU那边转回来,认真聊聊CPU在发热问题里扮演的角色。标题起得很直接——“CPU不是无辜的”,这不是标题党。在大量中低端安卓机(尤其是发热敏感机型)上,GC(Garbage Collection,垃圾回收)、Draw Call、Canvas重建这三个CAD方向的CPU开销,才是发烫和卡顿的真正来源。GPU在多数时候反而挺闲的,是CPU在背后疯狂加班,导致整机功耗居高不下,发热自然跟着飙升。
这篇内容适合正在做Unity手游性能优化、被发热和卡顿问题困扰、尤其是项目里UGUI用得比较重的开发者。我会把三个“CPU杀手”的原理拆开揉碎,讲清楚它们为什么会让手机发烫,再给出可落地的定位方法和修法。读完不敢说你立刻就能把温度压下来,但至少能让你下次用Profiler定位问题时,不再只是在GPU那一栏干瞪眼。
2. GC不是背锅侠,它是真的在烧CPU
2.1 托管堆分配和发热之间的隐秘联系
先说一个反常识的事实:GC之所以会导致发热,根源不在垃圾回收本身,而在垃圾回收之前的内存分配。Unity的Mono和IL2CPP都跑在托管环境里,C#脚本中每new一个对象、每拼接一次字符串、每用一次LINQ,都会在托管堆上分配内存。托管堆被填到一定程度,就会触发GC,GC需要暂停游戏线程去扫描对象引用、标记存活对象、压缩堆内存——这个过程是纯CPU操作,而且极其耗时。
发热的逻辑链是这样形成的:频繁分配内存 → 堆很快变满 → GC频繁被触发 → GC期间CPU占用率瞬间飙高 → CPU高负载产生更多热量 → 手机降频 → 游戏变卡。很多团队只看到“游戏卡了”,去查GPU,发现渲染压力并不大,转头又去优化Shader,温度依然压不住。其实问题的根子,是代码里隔几帧就new一个List、一个字节数组,或者Update里做字符串拼接。
我之前接手过一个卡顿项目,UI上的金币数字每秒刷新,代码写的是coinText.text = "金币:" + coinCount.ToString()。这里面每次赋值都会产生两个临时字符串对象,每秒刷新一次就产生两个垃圾对象。单看不多,但配合其他地方的分配,几分钟就能把托管堆填满,然后GC就会卡掉好几帧。这类问题用Profiler抓出来以后,改成string.Format或者预分配StringBuilder,温度立刻就能看到改善。
2.2 怎么判断GC是不是烫源:Profiler的正确打开方式
很多人用Unity Profiler只会看CPU Usage那一栏的横向条,看到“垃圾回收”四个字就完事了。但真正要定位GC问题,我的习惯是分三步走。
第一步,看GC Allocation曲线。在Profiler窗口里打开Memory分类下的“GC Allocation”图表,如果这条曲线在持续上升且没有明显回落,说明托管堆在持续分配,这就是GC会被频繁触发的直接证据。
第二步,点开CPU Usage里的某一个高帧,找到耗时的调用栈。如果看到GC.Collect或者GarbageCollect相关的条目,就顺着调用关系往上查,看哪段业务代码触发了GC。注意,触发GC的那个帧不一定就是分配最多的帧,因为GC是延迟触发的,要看分配峰值帧的调用栈,找的是“谁在疯狂new对象”。
第三步,用Memory Profiler(Unity官方包,Package Manager里可以装)抓一次内存快照,看托管堆里到底什么类型的对象占了大头。这一步能看到byte[]、string、class实例各自占了多少,比猜靠谱得多。
注意:经验上,如果一帧内GC Allocation超过2MB,且每秒发生一次以上,基本就是灾难级别。中低端机上的合理目标是一帧分配控制在几百KB以内,能压到100KB以下更理想。
2.3 几个立竿见影的GC削减手段
削减GC分配的思路,说白了就一句话:让托管堆上尽量少地诞生新对象,或者干脆提前租好对象反复用。
最基础也最容易被忽略的几招先列出来:
- 字符串拼接禁用
+号,改用StringBuilder或$""插值(注意插值在部分版本里也会产生装箱,讲究的话直接拼StringBuilder)。 - Update、协程、UI回调这种高频路径里,禁止出现
new关键字,包括匿名委托、Lambda捕获局部变量(会生成闭包类实例)。 - 避开LINQ的Where/Select/OrderBy,这些语法糖背后是迭代器和委托对象分配,GC压力很大。换成for循环,性能一样,零分配。
- 装箱要命:把int塞进object、把struct当interface调用,都会产生装箱。用泛型或者直接重载来规避。
- 协程的
WaitForSeconds每次都会new一个对象,应该缓存成静态字段复用。同一道理,yield return null本身没分配,但如果你在循环里new WaitForSeconds(0.5f),那就是持续在造垃圾。
举一个实际案例,我优化过一个战斗飘字系统,每次飘字都创建新的GameObject和Text组件,数字变化时还会重新new一个字符串数组来格式化。改成对象池以后,Text组件复用了,字符串格式化也统一走StringBuilder,战斗场景里的GC Allocation从每帧1.5MB直接掉到每帧50KB。画质没动、分辨率没动,手机温度肉眼可见地降了。
2.4 分代GC、增量GC和IL2CPP下的差异
Unity的GC在不同后端下表现有细微差异,但原理一致。Mono运行时用的是分代GC,把托管堆分成0代、1代、2代,新对象分配在0代,GC优先回收0代,回收成本低。IL2CPP后端在Android和iOS上使用的是Boehm GC的变体,它不分代(至少历史上不分代),每次GC都是全量扫描——这就意味着同样频率的分配,IL2CPP下的GC停顿可能更久。
Unity 2020以后引入了Incremental GC(增量式垃圾回收),在Player Settings里可以勾选“Use Incremental GC”。它的做法是把一次完整的GC工作拆成多帧来做,每帧只做一小部分,从而把单次GC的卡顿摊薄。代价是每帧都会多消耗一点CPU时间用于GC的增量处理,如果项目本身CPU已经吃紧,这个选项不见得是好事。但如果是“偶尔卡一大下”的问题,增量GC确实能把卡顿感抹平不少。
实操建议是:中低端机目标机型上开增量GC,同时继续削减分配频率,两手抓。增量GC只是把卡顿从“一次大卡”变成“每帧小卡”,如果分配量还是那么大,CPU照样持续发热,治标不治本。
3. Draw Call不是渲染问题,是CPU的物理课
3.1 SetPass Call和Draw Call别搞混,但都是CPU的活
关于Draw Call,行业内有个很常见的误区:觉得Draw Call是GPU的负担。实际上,Draw Call的瓶颈在CPU,不在GPU。每次Draw Call,CPU都要做这样几件事:检查渲染状态变更、绑定Shader和贴图、更新常量缓冲区、向GPU提交命令。每件事都有指令开销。GPU反而无所谓——它就是等命令来了,照着画。
Unity的Stats面板里有两列数据:一个是Draw Call,一个是SetPass Call。很多人只看Draw Call去了,其实SetPass Call才是更值得盯的指标。Draw Call只是“画一次”的命令数,SetPass Call是“切换一次渲染状态(Shader Pass)”的次数。切换Pass这个动作在CPU端极其昂贵,因为要重新验证状态、重新绑定一堆资源。一个4万个顶点的大模型只要1个Draw Call,但十几个小模型如果各用各的Shader,SetPass Call就上去了,CPU照样崩。
从发热层面看,Draw Call高意味着CPU每帧要执行大量渲染状态相关指令,指令流水线被塞满,CPU功耗居高不下,手机自然烫。所以“降低Draw Call”本质上不是图形优化,是CPU优化。
3.2 静态批处理和动态批处理的边界
Unity提供了两种内置批处理方式,理解它们的边界条件,比背API重要得多。
静态批处理(Static Batching):只要标记了Batching Static的物体,且Shader、贴图、材质相同(或兼容),Unity在构建时会把它们的网格合并成一个大网格,运行时只需要一次Draw Call。代价是内存暴涨——合并不等于压缩,每个物体原始网格数据还在,合并后的网格是额外内存。我见过一个项目为了把所有场景物件都标成Static,内存直接从1.2GB飙到1.8GB,中低端机直接闪退。静态批处理适合场景里的静态装饰物,不适合需要动态变化的物体,更不是“越多越好”。
动态批处理(Dynamic Batching):Unity会在运行时自动把满足条件的小物体合并成一波Draw Call。条件很苛刻:顶点数要少(Unity文档说是小于900个顶点,实际不同管线有差异)、材质要相同、不能有镜像变换(scale为负数会失效)、不同光照法线信息也会阻断合批。动态批处理CPU也是有开销的,它会每帧合并顶点数据,物体越多开销越大,顶点总量大到一定程度,反而比直接分批Draw Call更耗CPU。
实操里我的经验是:能合批的尽量通过图集和共用材质实现,不要把希望完全寄托在动态批处理上。与其让Unity每帧帮你做合批运算,不如直接把材质贴图统一、减少材质种类,这是最稳妥的方向。
3.3 SRP Batcher:现代Unity的CPU减负神器
如果你的项目用的是URP或HDRP,SRP Batcher是绕不开的重点。这套机制的理念和传统批处理完全不同——它不合并网格,而是复用渲染状态。具体来说,它把每个材质的属性数据预先打包成GPU可读的缓冲区(CBUFFER),当切换物体时,如果Shader变体不变、材质属性缓存的布局不变,CPU就不需要重新绑定一堆渲染状态,只需要更新缓冲区的数据偏移量就行。
关键点在于:SRP Batcher只对SRP Batcher兼容的Shader生效。如果你还在用内置管线的旧Shader(比如自己写的Surface Shader没做改造),SRP Batcher是不起作用的,会静默退回传统Draw Call路径。用URP项目的时候,打开Frame Debugger看一眼Draw Call后面有没有显示“SRP Batch”标记,没有就说明Shader不兼容,赶紧改。
我的实测经验:同样是30个物体各带独立材质颜色,传统方式要30个Draw Call、30次SetPass Call;改用SRP Batcher后,SetPass Call可以压到1,Draw Call也会因为材质属性不必重新上传而大幅减少CPU时间。中低端机上,这个改动省下来的CPU时间能明显缓解发热。
3.4 工具链:Frame Debugger才是Draw Call问题的显微镜
定位Draw Call问题,不要只盯着Stats面板的数字发呆。Frame Debugger是Unity自带的逐Draw Call回放工具,打开后每一帧的每一个Draw Call都能单独点开看,能直观看到合批为什么失败。
我排查Draw Call问题时,通常这样用Frame Debugger:
- 逐帧翻阅,找到未被合批的Draw Call条目,看它的“原因”列,Unity会直接写明为什么不合批(比如“材质不同”“网格顶点超过动态批处理限制”“没有标记静态批处理”等)。
- 同一材质下的多个物体,如果每个都单独出现,优先怀疑是不是复制了材质而非共用同一个材质实例。项目里很多物体喜欢用
GetComponent<Renderer>().material去改颜色,这会导致每次调用都实例化一个新材质,瞬间把合批拆散。 - 注意Canvas的Draw Call。UGUI的Canvas渲染其实也是通过网格提交Draw Call的,如果Canvas下面挂了多个材质不同的Graphic元素,每个元素都会破坏合批,导致Draw Call爆炸。
顺带一提,RenderDoc这种外部工具也能抓帧分析,但对Unity项目来说,Frame Debugger给的信息已经足够解决90%的合批问题。别一上来就整重型工具,先用自带的把问题看清。
4. Canvas重建:UGUI发热里最容易被忽视的那个坑
4.1 什么是Canvas重建,它为什么那么贵
如果说GC和Draw Call还算“名声在外”,那Canvas重建(Canvas Rebuild)绝对属于“隐形的CPU杀手”。它发生在UGUI的层级树更新时,主要包含两个阶段:Layout Rebuild(布局重建)和Graphic Rebuild(图形重建)。每次你修改UI元素的位置、尺寸、文本内容、颜色或者层级结构,UGUI都会把对应的Canvas标记为脏(dirty),然后在当前帧的更新阶段重新计算网格。
这个重建过程完全在CPU上进行。文本要重新生成Mesh,Image要重新根据Sprite计算四个顶点和UV,复杂的UI界面一套重建下来,CPU开销轻松超过一整个场景的渲染开销。更要命的是,Unity有一个“脏”标记的传播机制——子Canvas元素变了,如果你没有正确隔离层级,可能整个Canvas下所有元素都跟着重建一遍。
发热逻辑正值:UI动态变化频繁(比如飘字、伤害数字、聊天滚动、金币刷新)→ Canvas持续被标记脏 → 每帧都执行网格重建 → CPU狂转 → 热量飙升。
4.2 嵌套Canvas:隔离重建范围的利器
这里最有价值的优化技巧,不是告诉你怎么精简UI元素,而是要用嵌套Canvas把重建隔离在最小范围。
原理很简单:UGUI的重建是以Canvas为单位的。如果一个Canvas下有100个UI元素,你只改了其中一个小图标的颜色,理论上是只重建那个图标,但如果其他元素之间存在脏标记传播,或者Text有auto-size之类的属性,重启布局会导致大量节点一起重建。一个有效的做法是把动态元素成组放进独立的子Canvas里,这样动态元素的任何变化,只会触发该子Canvas自己的重建,不会污染整个界面。
举个实际案例:一个头像面板,包含头像框、等级数字、血条、聊天气泡、按钮。血条每帧都在变长度,聊天气泡时不时弹出来。如果这四样全挂在同一个根Canvas下,血条一变化,整个UI可能都要参与布局重建。把血条单独放进一个子Canvas,聊天气泡放进另一个子Canvas,头像和等级数字放进静态的根Canvas——动态变化被物理隔离,CPU的每帧重建量从“全部UI”缩减到“一个子Canvas”,效果极其明显。
4.3 Text和Font的隐藏陷阱
Canvas重建里有个特别容易被低估的东西——Text组件的字体纹理上传。当你修改Text的字符串内容时,UGUI不仅要重新生成文本Mesh,如果新文本里包含了字体纹理图集(Font Texture Atlas)里没有的字符,还会触发字体纹理的动态更新,把新字符的位图上传到GPU。这个操作在CPU和GPU之间产生同步等待,造成的卡顿比单纯重建Mesh更严重。
回避方案很直接:优先使用动态字体时,要控制字符集范围。Unity的Font有个“Dynamic”选项,勾选后就是动态字库,字库纹理是运行时生成的。对于中文字体,几千个常用汉字会导致图集非常大,建议做字库裁剪——把不常用的生僻字从字体资产里移除,图集小了,上传压力小了,重建成本也低了。
另一个常见问题是Text的Best Fit(自动适应尺寸)选项。这个选项听着方便,但它会让Text在每次内容变化后都重新测量尺寸,测量过程是逐字符进行的,非常吃CPU。UI上能不开Best Fit就别开,手动定好字号和RectTransform大小,换行就用Horizontal Overflow和Vertical Overflow控制,或者直接用ContentSizeFitter代替(注意ContentSizeFitter本身也会触发Layout重建,别滥用)。
4.4 实战:定位Canvas重建热点
定位Canvas重建问题,Unity Profiler的CPU Usage窗口里,搜索关键字“Canvas”或“Rebuild”,能看到Canvas.SendWillRenderCanvases、CanvasUpdateRegistry.PerformUpdate、CanvasRenderer等条目。点开看调用栈,如果看到Text.OnPopulateMesh、Image.OnPopulateMesh、LayoutGroup之类的函数,基本就知道是哪个节点在拖后腿。
还有一种更细的做法:用Profiler的“Hierarchy”视图,按Self CPU排序,那一列会显示每个函数的自身耗时,能直接看到是哪个UI组件把CPU时间吃掉了。对于频繁重建的Text,直接看它的OnPopulateMesh耗时,通常就是重灾区。
经验上,一个中等复杂度UI界面(50个左右元素)的Canvas重建耗时如果超过1ms,在中低端机上是需要警惕的。压到0.2ms以下是理想状态。怎么压?回到4.2的思路,隔离动态区、裁剪字库、减少Text的刷新频率(比如不要每帧刷新血条数字,改成每隔几帧或数值变化时才刷新),都是有效手段。
5. CPU发烫的调优排查顺序:从现象到根因
5.1 先定框架:不要一上来就闷头改代码
很多开发者做性能优化,都习惯“想到什么改什么”——今天觉得是GC,明天觉得是Draw Call,后天又觉得是阴影。没有一套系统的排查顺序,改了一堆,发热还是照旧。
我个人常用的方法是:先定场景、再定帧、最后定函数。
先定场景是在说,发热问题只在战斗场景出现还是主界面也烫?战斗场景和主界面的渲染压力差别很大,如果主界面也烫,可以先怀疑UI和后台逻辑(比如网络轮询、AI更新),别急着怀疑特效和粒子。
定帧是第二步:用Profiler选一个发热期间的平均帧(不要选最低帧,也不要选最高帧,选CPU占用率最高的典型帧),看这个帧的CPU时间都花在哪块。Unity的Profiler分好几个模块:Rendering、Scripts、Physics、Animation、UI、GC等,每个模块占的毫秒数一目了然。哪块大,先动哪块。
最后才落到具体函数上。用Deep Profile(发布包不要开Deep Profile,开销太大,用Profiler Context Menu手动标记性能关键区)或者用ProfileMarker把业务代码划分成块,定位具体函数。
5.2 发热问题专属的排查清单
我自己的经验是,发热问题往往不是单一原因,而是一个“综合症”。以下清单是我在优化发热问题时必查的几项,按优先级排序:
- GC Allocation是否持续增长:这是头号怀疑对象,因为它导致的发热最隐蔽、最难在普通性能测试里暴露。
- UI是否有持续重建:打开Profiler观察Canvas重建频率,动态UI越是高频刷新,越要处理。
- 是否在Update里做了太多循环和查询:比如每帧
FindObjectOfType、GetComponent、transform.position的频繁访问——这些API看着无害,内部却有引擎级开销和缓存穿透。 - 是否有每帧执行的协程:协程每帧MoveNext也有开销,数量多了一样吃CPU。
- 物理和动画:Physics每帧步进、Animator的蒙皮更新(Skin Mesh)也在CPU上跑,刚体和骨骼多的场景,CPU占用会相当可观。
- 粒子系统:虽然粒子渲染在GPU,但粒子系统的更新(模拟、碰撞检测)在CPU。大量并行粒子会明显抬高CPU占用。
- 阴影和实时灯光:这点容易被误解成GPU压力,其实阴影的阴影映射(Shadow Map)渲染时,需要CPU提交额外的渲染Pass指令,Draw Call翻倍,SetPass Call更是成倍增长,CPU端开销巨大。
5.3 “先Castle,后工人”:分级优化策略
优化发热问题,不要追求一步到位。我的建议是分两轮进行:第一轮做“止血”,第二轮做“治理”。
止血轮的目标是快速消除最严重的CPU尖峰。用Profiler定位到当前最耗CPU的前三个热点(比如GC分配、Canvas重建、粒子更新),针对这三个点做最小改动,比如把金币刷新从每帧改为每0.1秒,关闭超出屏幕的粒子发射器,把动态UI隔离到子Canvas。这些改动不需要重写架构,一两天就能完成,但温度下降会很明显。
治理轮的目标是架构级优化:对象池全面落地、UI框架从即时刷新改成事件驱动刷新、把频繁的字符串操作改成缓存复用、合批与SRP Batcher全面启用、制定代码规范禁止高频路径上的GC分配。这个阶段可能需要一两周,要铺开做,但收益是稳定和可持续的。
注意:优化发热问题最怕“眉毛胡子一把抓”。一轮改动里同时改了十个地方,最后温度降了,但你根本不知道是哪个改动起了作用,下次遇到类似问题依然要靠猜。建议每次改动只动一个变量,然后用Profile对比改动前后的CPU占比和温度数据,形成可复盘的记录。
6. 实测案例:一个“低画质但发烫”的MMO主城修复记录
6.1 问题描述和初步定位
有一次我接手一个MMO项目的主城场景发热优化。场景本身并不复杂,美术风格偏卡通,面数也不高,理论上画质压力不大。但测试机上运行5分钟后,手机背部温度稳定超过42度,帧率从60掉到30以下。
第一轮用Profiler定位,先说结论:GPU的耗时只在6ms左右,不是热点。CPU那个条状图里,占比最高的分别是“UI.Rebuild”(3.8ms)、“GC.Alloc”(2.1ms)和“Animator”(1.7ms)。我当时就明白这是个“CPU发热”的典型样本——渲染压力不大,但CPU在疯狂加班。
顺着调用栈往下挖,UI.Rebuild集中在主城界面左上角的一个活动入口图标上。这个图标每隔几秒就有一个闪烁动画,且它的Text子物体每帧都在用协程更新颜色数值——最致命的是,这段话写在了整个主UI Canvas下,导致这个小小的闪烁动画,触发整个主界面的Canvas重建。
6.2 修复动作和结果
针对上面三个热点,我做了三个层面的改动:
第一,把活动入口图标从主Canvas下提出来,放进独立的子Canvas,并关闭它的“Raycast Target”属性(UI射线检测也会参与重建判定)。这一个小改动,主界面的Canvas重建耗时从3.8ms降到了0.4ms。
第二,把协程刷颜色的写法改成Tweener(比如DOTween)的DOColor来做,并且把更新频率从每帧改为每隔0.1秒更新一次。这避免了持续产生的新对象和字符串操作,GC Allocation从2.1ms降到了0.3ms。
第三,排查Animator时发现有几个NPC身上的Animator开启了“Update Mode = Always”,即使玩家不靠近也每帧更新骨骼动画。改成“Culling Mode = Based On Renderers”后,远离镜头的NPC直接跳过动画更新,Animator耗时降到0.2ms。
最终主城场景的CPU耗时从11.7ms降到了5.8ms,帧率稳定在60。测试机连续运行30分钟后,背部温度从42度降到37.5度。整个优化过程没有调一档画质、没砍一个特效,完全是在CPU端做文章——这正好印证了标题的观点:CPU不是无辜的。
7. 关于CPU占用的最后几点心得
做Unity优化做多了,我越来越觉得,性能优化不是“调参数”的手艺活,而是“建立数据直觉”的能力。每一次改动前记录CPU各模块占比,改动后再记录一次,时间久了,看一眼Profiler就能大概判断出问题出在GC、渲染、UI还是动画上。
分享一个我常用的简单模型:中低端机主场景CPU帧耗时预算可以这样分——渲染相关3ms以内,UI重建1ms以内,脚本逻辑2ms以内,GC分配0.5ms以内,物理动画1ms以内,余量留2ms。超过预算的部分,就是发热的可疑源头。按这个预算表去卡每一项,发热问题通常能定位到具体模块。
最后,如果你真的想让手机不烫,本质上是降低CPU在单位时间内的有效工作量——不是让CPU“变快”,而是让它“少干没用的事”。代码里减少一次new、UI里减少一次Canvas重建、渲染上少设置一个Pass,都是在给CPU减负,也是在给手机降温。
“发烫优化系列”到现在正好五篇,这个主题其实还没聊完。脚本侧的优化思路和资源侧的内存分配策略,后面有空了我会继续写。如果你在项目里遇到CPU占用高但GPU看起来不忙的情况,不妨用这篇里的方法先查一遍GC和Canvas,大概率会有发现。