1. 先说结论:烫的不是手机,是你的游戏逻辑
做 Unity 开发的朋友应该都有过这种体验:开发机上跑得好好的,一装到真机上,玩个三五分钟,手机背面就开始发烫。你要是开的还是高画质 + 高帧率,那妥妥可以当暖手宝。遇到这种情况,很多人第一反应是“手机不行”,但说实话,大部分时候锅不在手机,而在咱们自己写的项目上。
我先给一个大部分项目都适用的判断基准:正常 3D 手游在跑的时候,机身温度应该保持温热但你拿得住,而不是烫得想立刻放下。手持设备表面温度超过 45℃ 就已经开始影响体感,超过 50℃ 就算严重发热。如果你的游戏“越玩越烫”,尤其是持续玩 10 分钟以上温度还在往上走,那基本可以断定:项目里有什么东西在持续消耗资源,它不该存在,或者它不该这么干。
这篇是系列第 1 篇,我先不急着堆优化技巧,而是先把一条主线讲清楚——发热到底是什么引起的,以及你怎么从项目里去定位那些把手机变成暖手宝的元凶。很多人一上来就搜“Unity 游戏优化”的帖子,跟着改几个参数,结果发现没多大用,原因就是不知道到底哪里在烧电。
我们先把问题拆开:发热的本质是功耗,功耗的本质是在人眼看不到的时间片里,CPU、GPU、内存带宽这三样东西有活儿在干。活儿多,功耗自然高,温度自然往上爬。所以思路就一句话:你想让手机凉下来,就得让 CPU 和 GPU 干更少的活,或者让它们干同样多的活但干得更高效。
我见过太多项目第一步就跑偏了——跑去调画质、降分辨率,结果问题根本不在 GPU,而在 CPU 上的某个死循环逻辑。所以咱们这篇的重点不是教你怎么改某项参数,而是先学会怎么判断问题出在哪一层。
在开始动任何一个优化手段之前,你要先做一件事:把 Project Settings 里的Development Build勾上,然后用 Profiler 连真机跑一遍你的游戏。我后面会专门讲怎么去读 Profiler 的数据,但你现在至少要建立一个概念:没有性能数据支撑的优化,都是盲人摸象。
2. 搞懂发热的底层逻辑:CPU、GPU 与功耗的关系
2.1 手机为什么热:从能量消耗讲起
很多人一说到手机发热,第一反应就是“CPU 占用率太高”。这个理解大方向是对的,但不完整。手机发热的本质是整机功耗高,而功耗由几个大件共同决定:SoC 里的 CPU 核心、GPU、内存控制器、Modem(射频模块)、屏幕背光,以及各类传感器。对游戏来说,前三样是绝对的大头。
拿一颗常见的手机 SoC 举个例子:它的 CPU 大核(比如 A710 或 X2 架构)在满频运行时,单核功耗能飙到 4~5W;GPU 在满负载下更是能跑到 6~8W。要知道,一台手机正常亮屏待机的整机功耗也就 1~2W。你算算,如果游戏让 CPU 和 GPU 全速跑,整机功耗直接翻好几倍,热量怎么可能不堆积起来。
这里有个比较反常理的点:主板上的发热大户往往不是 CPU,而是 GPU。原因很简单,移动端游戏画面的像素填充、着色计算、纹理采样几乎全压在 GPU 上。而 GPU 一旦长时间处于高负载,发热是非常可观的。所以你在 Profiler 里看到 CPU 占用率只有 30%,觉得“不高啊”,但手机依然烫,那大概率是 GPU 的 load 已经爆了。
2.2 Unity 的帧循环:一切问题的集中营
Unity 引擎的核心是一个死循环,顺序大概是:处理输入 → 调用 Update → 物理模拟 → 渲染 → 提交命令到最后 GPU 执行。这个循环会在每一帧里不断重复。你要明白一件事:Unity 的 CPU 逻辑和 GPU 渲染是并行流水线的关系,但它们之间需要同步点,也就是“等待显卡完成当前帧的命令”。
这个等待,很容易导致一种假象:CPU 看着占用率不高,但 fps 就是上不去。因为线程在等 GPU,卡在了同步点上。反过来,如果 CPU 侧的脚本逻辑跑得慢,GPU 早就把活干完了,就会出现“GPU 空转”,这时候功耗虽然没那么高,但画面照样卡。
移动端发热的典型路径常见有两种:
- CPU 侧,一些脚本逻辑在 Update 里反复跑高开销操作,比如 GetComponent、字符串拼接、频繁实例化对象,每秒执行几十次甚至几百次,CPU 被拖到高负载。
- GPU 侧,画面里塞了大量半透明物体、动态阴影、没有合批的小物件,导致每帧的渲染指令数爆炸,GPU 满载发热。
真正麻烦的是第三种:CPU 和 GPU 同时都在高负载状态下。这时候手机基本上就是一个小烤炉,掉电速度肉眼可见。
2.3 耗电 = 发热,别再把它们分开看
有个概念需要先建立:电量消耗和发热是同一个物理过程的两个面。功耗高了,电池输出电流大,内部电阻发热,再加上 SoC 本身的发热,整机温度就起来了。所以优化游戏发热,本质上是优化游戏功耗。
功耗从哪里来?一半来自 CPU 周期,一半来自 GPU 周期。CPU 周期贵的操作包括:代码里频繁的类型转换、反射调用、List 扩容、GC 分配和回收。GPU 周期贵的操作包括:过多的 DrawCall、过大的 overdraw(一个像素被画了多次)、无压缩的纹理导致的带宽浪费、全屏后处理叠加。
这些术语听起来多,但落到实际项目里,就对应几个非常具体的场景,我后面会逐个拆。
3. 热源第一梯队:DrawCall、Overdraw 与渲染管线的代价
3.1 DrawCall 数量为什么会成为发热大户
先给个定性结论:如果你在真机上看到 DrawCall 数量老是维持在 500 以上,手机的 CPU 侧已经开始吃紧,GPU 也会因为频繁命令切换发热。这个数值在 PC 上根本不值一提,但在移动端,每一次 DrawCall 都伴随着 CPU 侧调用图形 API 的开销,以及 GPU 侧切换渲染状态的成本。
讲个真实例子。我之前接手过一个 DEMO,场景里放了几百个模型,单个模型的面数都不高,但材质球完全不共用。这个项目在编辑器里看流畅得很,一上骁龙中端芯片的真机,掉帧加发热,电池像漏了一样。用 Profiler 一抓,好家伙,每帧 1500 多个 DrawCall。
为什么会导致发热?因为每一次 DrawCall 都不是白白执行的。从 CPU 的角度看,它要把顶点数据、纹理、材质参数打包成 GPU 能识别的命令,然后塞进命令缓冲区;从 GPU 的角度看,它每切换一次渲染状态(比如切纹理、切着色器),内部管线就要 flush 一次缓存。当设备上有大量状态切换时,GPU 的利用率反而下降,但是功耗上升了——因为它必须在更短的时间内处理更多的状态切换指令。
优化方向也很清晰:把 DrawCall 的数量压下去。比如让多个物体共用同一份材质、同一张纹理图集,这样 Unity 就能把它们合批成一次 DrawCall。再比如启用静态合批或 GPU Instancing,前者适合不会动的场景物体,后者适合大量同名同材质的动态物体。
值得专门强调的是:很多人压 DrawCall 会直接跑到网上抄一个“把全部物体合批”的配置,结果发现物体是合并了,但出现问题——物体位置对不上、灯光效果错乱、内存占用暴涨。DrawCall 不是越低越好,而是要在场景复杂度、内存占用和渲染状态切换三者之间取一个平衡。
3.2 Overdraw:每个像素被重复绘制了几次?
我在移动平台调试时经常打开一个选项,叫Overdraw 视图。它会用颜色标识屏幕上每个区域被绘制的次数:绿色表示只画了 1~2 次,红色表示被重复绘制了 5 次以上。
正常情况下,一张 1080p 的屏幕有大约 200 万个像素。如果某个区域被画了 5 遍,就相当于真实渲染了 1000 万个像素的着色工作量。GPU 的 Fillrate(填充率)就那么多,多画的部分全是白耗电。
最容易造成 Overdraw 的几种情况我都踩过:
- 半透明粒子系统叠了太多层,比如技能特效里三层雾、五层光晕叠在一起。
- UI 界面上大量使用了带透明度的图片,一层叠一层,尤其是全屏的暗色遮罩后面还叠了一堆图。
- 场景里摆了很多大的半透明面片,比如假烟雾、体积光模拟贴片,从相机看过去全是重叠的。
要查 Overdraw 很简单:Scene 视图的右上角切到Overdraw模式,你会立刻看到一片一片的红色区域。优先处理那些红色最密集的屏幕区域,尤其是 UI 界面和使用半透明特效的技能演出。把不必要的层删掉,把透明改成不透明(注意图片格式),Overdraw 就能降一截。
3.3 动态合批与静态合批的正确用法
合批这个词,网上一搜一大把,但真正能讲清楚什么时候用哪种的人不多。
静态合批的本质是在构建时把多个静态物体的网格合并成一个大网格,这样运行时只需要一次 DrawCall。它对场景中不动的墙体、地面、装饰物非常管用。代价是内存占用上升,因为合并后的网格是单独存一份的,原来独立的小网格也在,内存直接翻倍。所以静态合批的开和关,要按场景内存预算来权衡。
动态合批则是引擎在每一帧运行时,把符合条件的多个动态物体临时拼成一个批次,前提是它们使用同一个材质、网格顶点数不能超过上限(Unity 早期版本是 900 顶点,后续有调整)。如果不符合条件,合批失败,引擎反而会因为合批判定逻辑产生额外 CPU 开销,得不偿失。
我的建议:不要把合批当成“开了就省”的选项,而是先在 Profiler 的Rendering标签页里看下当前帧的 Batches 数量,再对照场景里的物件构成去判断值不值得开。还有一点,同一材质球才能合批,这是王道。想让大量的物体合批,就要合理复用材质,而不是每个物体都 new 一个材质的实例。
4. 热源第二梯队:UI 与脚本的隐藏开销
4.1 UI 重建与动静分离:UIPanel 的隐形负担
Unity 的老牌 UI 系统(UGUI)用起来很顺手,但它有一个非常经典的性能陷阱:只要某个 UI 元素的任何属性发生变化,系统就可能触发一次或多次网格重建。网格重建的逻辑很简单——它要把可见的 UI 元素重新生成顶点数据,然后重新提交给 GPU。
这里有一个非常关键但新手不怎么知道的概念:Canvas 之间的动静分离。每个 Canvas 下的所有 UI 元素共享一个渲染批次;只要 Canvas 下面任意一个元素把它惹毛了(比如动态改变了位置、尺寸、文字内容),这个 Canvas 下的所有元素都会参与重建。所以你要做的第一件事,是去检查 UI 层级的布局,把会频繁变化的元素(比如飘字、冷却图标、滚动列表)单独放到一个 Canvas 里,让那块区域的重建不至于带动整个 UI。
具体做法我再说细一点:想象你有一个 HUD 界面,背景是固定的血条边框,中间是实时变化的血量数字,右下角是一个正在转圈的技能 CD 图标。如果这三样东西都在同一个 Canvas 下,那么每一帧血量数字在变,整个 HUD 都要重建。一旦把它拆成三个 Canvas,数字变了只重建数字所在的那块,背景和 CD 图标完全不受影响。这一步操作简单,但收益立竿见影。
另外,我还想提一个大家在用 UGUI 时经常忽略的问题:文字导致了大部分 UI 性能问题。TextMeshPro 的字符数据结构和网格信息比老版字体系统复杂得多,再加上字体动态生成图集、阴影、描边,每个文字都是一个微型渲染单元。如果你的 HUD 里有大段实时刷新的日志文本,比如每秒更新 10 次的那个调试输出框,相信我,把它取消勾选或者移除,温度立刻下去。
4.2 Update 里的“定时炸弹”与主线程阻塞
很多项目里的脚本,写的时候图省事,把所有能跑的全都放进了 Update()。Update 每帧都会执行,60fps 下就是每秒执行 60 次,这本来没什么问题,但如果你在里面写了高开销操作,就成了定时炸弹。
来几个我真实见到的反面写法:在 Update 里调 FindObjectOfType,这玩意儿的实现原理是全局遍历场景里所有符合条件的对象,Find 一次可能要遍历几百个 GameObejct;在 Update 里叠加字符串(比如把玩家名字 + 血量 + 伤害值拼成一个日志字符串),会产生大量 GC 垃圾,导致频繁触发垃圾回收,CPU 卡顿;在 Update 里 new 一个 List 或者 Dictionary,每一帧都在堆内存上分配空间,GC 压力巨大。
如果这些代码里的任何一段出现在你的项目里,那么它每帧都在做无用功。这种无用功的代价不是瞬间的掉帧,而是持续的 CPU 占用,CPU 占用高,功耗就高,温度就上去了。
正确的做法是把这些每帧重复的调用抽出来。不是在 Update 里找物体,而是在 Start 或 Awake 里缓存引用;不是用字符串拼接来做状态显示,而是用 StringBuilder 或者直接拼 Int 转字符串的缓存;不是每帧 new 容器,而是提前定义好容器并在每帧之前 Clear 后复用。
另外一个经常被忽略的点:协程(Coroutine)并不等于异步线程。协程依然运行在主线程里,它只是在主线程的时间片上被切成了多个片段执行。如果你在协程里写了死循环或者长时间的密集计算,主线程一样被卡死。某些项目里为了做延迟,一个协程里套着 while(true) 然后里面跑复杂计算,这在中低端机上就是灾难。
5. 实操:如何用 Profiler 定位发热元凶
5.1 Profiler 连接真机的基本操作
废话不多说,直接上操作。用 Profiler 挂真机,是定位性能瓶颈的必备技能。步骤非常简单,但我见过很多人在第一步就栽了——连不上设备。
打开 Edit → Project Settings → Player,在 Other Settings 里把Development Build和Autoconnect Profiler勾上,然后 Build 一次工程装到手机上。接着用数据线连着电脑,在 Unity 里打开 Window → Analysis → Profiler,点右上角的Record旁边的下拉,选择你的手机设备,开始录制。
如果设备列表里找不到手机,检查一下有没有开启手机的 USB 调试(Android),或者是不是用了不支持的数据线。注意:自动连接的 Profiler 会拖慢一些帧率,性能数据会和最终发布版有差异,但定位方向没问题。
5.2 三张关键图表:CPU Usage、Rendering、Memory
连接成功后,你会看到 Profiler 里一列一列的数据。我一般只盯着三个标签页看。
第一个是CPU Usage。在这里你能看到每帧各个系统模块消耗了多长时间。我最先看的两项是Scripts(玩家脚本)和Rendering(渲染)。如果Scripts的耗时占比非常高(超过 40%),那优先怀疑代码逻辑的问题。如果Rendering特别高,就去 GPU 侧找原因。
第二个是Rendering标签。这里会明确列出每一帧的 Batches、Triangles、SetPassCalls 这些数据。Batches 是合批后的 DrawCall 数量,Triangles 是三角形数量。假如 Batches 数量四五百以上,Triangles 有几十万甚至上百万,那大概率是渲染负载过高,发热的锅在 GPU。
第三个是Memory,关注两个数字:GfxDriver 和 ManagedHeap。GfxDriver 是显存相关的分配量,ManagedHeap 是托管堆,也就是脚本新分配的对象所在的空间。如果 ManagedHeap 在不断上涨并且触发了 GC(界面会显示 GC Alloc 的尖峰),说明脚本在持续分配垃圾对象,频繁 GC 是 CPU 发热的常见元凶。
我个人抓性能曲线的习惯是这样的:先跑 3 分钟的游戏流程,然后暂停回放,找到最高的一帧,逐个模块看谁最突出。如果那一帧刚好对应你操作最频繁的时刻(比如放技能、切界面、开箱子),说明这个操作本身有性能漏洞。
5.3 用 Frame Debugger 审查每一帧
Profiler 看到了宏观数据,但你要知道具体是哪一步渲染最费钱,就需要打开Window → Analysis → Frame Debugger。
Frame Debugger 的原理很简单:你可以逐条翻阅当前帧里 GPU 执行的每一步渲染命令,看它的批次、使用的材质、渲染了哪些顶点。它就像一个逐帧的渲染命令审查器。
这个工具最适合回答一个问题:“为什么这一帧这么贵?”你可以从上往下翻看渲染命令列表,找到那些顶点数量特别大、或者被反复使用的半透明物体渲染项。看到某个怪物模型的渲染命令占了几万个顶点,你自然就知道该去压它的面数还是禁用它的投影。
我一般把 Profiler 和 Frame Debugger 搭配着用:先用 Profiler 定位问题出现在 CPU 还是 GPU,再用 Frame Debugger 具体到是哪一个物体或哪一步操作拖累了帧时间,这样修复起来就有的放矢,而不是瞎猜。
6. 常规优化手段:先别急着上高级技巧
6.1 画质分级:不是所有手机都能全特效
“为什么开发机上不卡,真机上卡”,这个问题很多人问过。其实原因很简单:开发机是一个高性能的台式机或顶配笔记本,而你的真机是骁龙中端处理器 + 有限的散热结构。拿 PC 的水平去要求手机,手机当然会烫。
正确的做法是:在你的项目里做画质分级。不是做不做的问题,而是怎么做才不费劲的问题。
最简单的分级方式是根据设备的平台和处理器能力,在启动时选择一组预设的 QualitySettings 配置。Unity 的 Project Settings → Quality 里已经默认配了几档:Low、Medium、High、Ultra。你可以按设备性能去匹配。比如中端机给 Medium 档,关闭动态阴影、降低实时反射、调低 LOD 距离。
关键点在于:降到某一档之后,画面观感不要有明显劣化。很多项目一刀切把阴影关了,结果画面一片死白,玩家反而更不满意。我的做法是分级时优先动硬件消耗大但人眼不敏感的选项:抗锯齿降一档、实时阴影换成烘焙阴影贴图、后处理特效里砍掉景深或色差。这些对画面品质的影响相对小,但省掉的硬件消耗非常大。
6.2 贴图与内存:压缩是王道,但别无脑压
移动端的贴图(纹理)格式和内存占用,是一直被低估的发热源。一张 2048×2048 的 RGBA32 贴图在内存里占 16MB,如果是多张贴图叠加,内存占用立刻爆炸。内存大了,内存控制器的负载就高,功耗上升,发热也随之而来。而且贴图带宽——GPU 每一次采样贴图都要走内存带宽,带宽是移动端芯片的稀缺资源,消耗越高,GPU 功耗越大。
移动端能选的纹理压缩格式很多,ASTC、ETC2、ETC1 等等。我的建议是:Android 优先用 ASTC(如果 GPU 支持),iOS 用 ASTC 也完全没问题。ASTC 在相同质量下的压缩比和画质表现都比较理想。
有个常见的坑要提醒一下:很多项目的 UI 图集没做压缩,因为 UI 需要较高的清晰度,但真机上 UI 图集占用的内存也不小。UI 图集通常可以压缩到显存友好的格式,同时把“生成 Mipmap”关掉——UI 在屏幕上的缩放变化不大,不需要 Mipmap 来抗闪烁,保留 Mipmap 反而白白多占 33% 的内存。
6.3 物理与碰撞:别让引擎白跑
Unity 的物理引擎在移动端是个隐形消耗大户,尤其是大量碰撞体活跃在场景里的时候,每一帧都要做碰撞检测和物理模拟,CPU 占用蹭蹭上涨。
我见过一个项目,场景里散落了几百个空的碰撞体(Collider),它们根本不参与任何交互,但物理引擎依然要每帧检测它们是否与其他物体碰撞。解决方案是给它们加一个角色标识,或者干脆取消碰撞体的 IsTrigger 属性,直接把不参与的碰撞体禁用(SetActive false)。
物理优化的核心原则是:把物理计算量降到必要的范围。不需要物理互动的物体,不要挂 Rigidbody;不需要精确碰撞的物体,用一个大致匹配的 Box Collider 代替精贵的 Mesh Collider;多层级的场景里,分层碰撞检测(Layer Collision Matrix)能大幅减少无效检测次数。
7. 实测排查流程:按这个顺序走,基本不会漏
给大家一套我经常用的排查顺序,跟着走能避开 90% 的坑。
第一步:建立基线。装一个 Release 包(不带 Development Build 的版本),把手机调成飞行模式,连着电源,同一场景同一操作流程,记录帧率和手机温度。这一步的目的是获得一个可对比的基准,没有这个基准,后面任何优化都说不清是有效还是无效。
第二步:跑 Profiler。用 Development Build 连接 Profiler,运行相同的流程,分别记录 CPU Usage、Rendering、Memory 三个模块的数据。目标是找出哪个模块占用率最高。
第三步:分类施策。如果 CPU 的 Scripts 占比最高,去优化代码逻辑(缓存、合批、GC);如果 Rendering 的 Batches 和 Triangless 很高,去做场景和材质的合并、压缩、降级;如果 Memory 的 GC Alloc 持续上涨,去排查脚本里高频率的临时对象分配。
第四步:验证优化效果。改完以后,不要只看编辑器里不卡,一定要重新打 Release 包装到真机上,再跑一遍相同的流程。对比帧率和温度的基线,如果温度降低了 3~5℃ 或者帧率稳定性明显上升,说明方向是对的。
这四步每一步都很重要。我见过太多开发者在没有基线、没有数据的情况下随手改了几个参数,折腾一天也不知道有没有效果。有了这套流程,你的优化效率会高很多。
8. 系列预告和一点个人的真心话
这是“为什么你开发的 Unity 游戏越玩越烫?”系列的第 1 篇,先把发热的原因和整体排查思路讲清楚了。我在实际项目里看到过太多的“瞎优化”——不知道问题在哪就忙着上各种插件、改各种参数,结果往往事倍功半。
这一篇的内容对想入行或刚入行 Unity 开发的初学者来说,最重要的不是记住那些具体参数,而是学会一套看问题的方法:发热 → 功耗 → CPU/GPU 负载 → 具体开销来源 → 针对性优化。这个思路能贯穿你整个 Unity 开发生涯。
后续的系列文章里,我打算针对几个高频热点专门展开:Assects 资源加载到底该怎么管理、UI 系统的终极优化手法、粒子特效和 Shader 的移动端最佳实践、新版本 Render Pipeline 选型取舍、以及 Addressables 加载和资源释放的深度拆解。
我个人在里面最偏好的优化手段,永远是把大的问题拆成小的、可量化的单元去逐一解决。等你把渲染负载、脚本开销、内存占用和 UI 重建这几座大山都平掉了,你会发现手机温度自然而然就下来了,而且不用付出画质上的妥协。这就是用心和技巧之间的差别。