项目发热排查到了第三轮,CPU侧优化做了,Draw Call也压下来了,帧率看着还算正常,可手机背面依然烫得能煎鸡蛋。这时候我基本可以断定,问题出在GPU的像素填充阶段——换句话说,Overdraw。这期是发烫优化系列的第3篇,我想把事情讲透一点:Overdraw到底是怎么把GPU热量拉起来的,怎么把那些看不见的"重复刷漆"揪出来,以及真正动手优化时应该从哪些地方下手。
1. Overdraw与发热之间的因果链:GPU每一次重复上色都是有代价的
1.1 Overdraw到底在刷什么:像素填充率才是核心
先抛开Unity里的专业术语,用装修刷墙来理解。你家里有一面墙,最终看到的颜色是面漆的颜色,但在刷面漆之前,你可能刷了底漆、腻子层、甚至旧的墙皮没铲干净又多刷了两层。站在外面看,墙面是最后那层漆的颜色,可前面每一遍漆都是花过钱、花过时间、耗过人工的,只是被盖住了,看不到。Overdraw在渲染里的意思完全一样:一个屏幕像素在一帧里被Fragment Shader处理了多次,后面绘制的内容把前面绘制的结果盖住了,但前面那几次GPU运算已经发生过了,花掉的算力收不回来。
在Unity里,Overdraw的定义就是单个像素在一帧画面中被重复绘制的次数。假设屏幕分辨率是1080p,约200万个像素,如果平均每个像素被画了3次,那这一帧GPU实际处理的像素量就是600万,多出来的400万次纯粹浪费。移动GPU的像素处理能力本来就被功耗和散热锁得死死的,这种浪费会直接转换成热量,手机自然发烫。
这里要引入一个专业概念:填充率(Fill Rate)。填充率指的是GPU在一秒内能处理多少像素,单位通常是"百万像素/秒"。分辨率越高、Overdraw越严重、帧率要求越高,每秒钟需要处理的像素总量就越大。一旦超过硬件能力上限,帧率就会往下掉,GPU为了追帧率又会强行拉高频率,频率一高功耗跟着涨,热量堆积后触发降频,最终陷入"帧率越来越低、机身越来越烫"的恶性循环。
1.2 为什么半透明是重灾区:Early-Z与混合的秘密
如果所有物体都是不透明的,Overdraw其实不会那么致命,因为GPU有隐藏面消除机制。移动GPU基本都是Tiled架构,在执行Fragment Shader之前会做一个逐像素深度测试,也就是Early-Z。对于不透明物体,如果这个像素已经被更近的物体覆盖了,那它根本不会进入后面的着色计算,直接被丢弃,所以不透明物体之间的Overdraw很多情况下是无效成本,或者被硬件优化掉了。
问题出在半透明物体。半透明物体需要和后面已经画好的颜色做混合,你必须先拿到背景颜色,才能算出"半透明效果",所以它没法在Early-Z阶段被剔除。一个半透明粒子、一片半透明玻璃、一块半透明UI面板盖在画面上,它下面那些已经画好的像素全都会被保留,一次次叠加计算。
这就是为什么很多人发现"场景里没有几个物体,Overdraw却高得离谱"——大概率是半透明物体堆出来的。尤其粒子系统,几百个半透明粒子叠在一起,屏幕中心区域轻松画上十几遍甚至几十遍。这也是我说"Overdraw和发热直接挂钩"的核心原因:半透明导致的Overdraw是无法被硬件自动优化的,每一层都要实打实跑一遍Fragment Shader。
1.3 发热不是玄学:功耗与频率的负反馈
再往深一层说,GPU不是一直满负荷运转的,它的功耗和当前频率以及活跃的硬件单元成正比。像素填充阶段会大量占用GPU里的Shader ALU阵列、纹理采样单元和混合单元,这些模块一忙起来,电流就会往上飙。移动设备没有主动散热,热量只能通过机身慢慢散发,表面温度一高,系统会强制降频保护硬件,游戏帧率随之暴跌。
我自己的经验是,用Profiler看GPU Frame Time往往只能看到结果,真正让人体感"烫"的其实是持续的高负载。哪怕平均帧率是60,只要GPU的Fragment阶段长时间占用超过70%,手机握在手里十分钟必发热。优化Overdraw的本质不是把帧率从30提到60,而是把GPU从"每帧处理500万像素"降回"每帧处理200万像素",从源头减少发热。
2. 让Overdraw显形:Scene视图、Frame Debugger和真机Profiler三件套
2.1 Scene视图的Overdraw模式:先宏观定位
Unity的Scene视图自带Overdraw可视化模式:在Scene窗口左上角的Draw Mode下拉菜单里选择Overdraw,场景就会切换成一套特殊的配色方案。大致规则是:白色区域表示基本没有重复绘制,颜色越偏暖色(红、品红)说明该区域被重复绘制的次数越多。不同Unity版本的色带映射略有差异,但判读逻辑是一样的——先找最刺眼的区域。
实际操作时有一个小技巧:先把Scene视图的摄像机调整到与Game摄像机一致的角度,再进入Play模式拖视角观察。这样你在Scene里看到的红色区域,基本就是玩家屏幕上会发烫的地方。我一般会从俯视角、主视角和侧视角各看一遍,把高Overdraw区域用截图记录下来,再去Game视图里对照是哪些物件。
这里要提醒一下:Scene视图的Overdraw模式主要反映的是场景物体的重复绘制,Screen Space类型的UI和后处理Pass并不一定完整显示在这个视图里。UI的Overdraw问题要用其他工具来看,稍后细说。所以Scene视图的作用是"宏观定位",不能当最终结论。
2.2 Frame Debugger:逐个Draw Call揪出元凶
定位到大致区域后,还需要知道到底是哪个Draw Call在重复覆盖。Unity的Frame Debugger(Window > Analysis > Frame Debugger)可以一帧一帧地回放所有渲染事件,每一笔Draw Call、每一个RenderPass都能看到。操作方法是:进入Play模式后打开Frame Debugger,点Enabled冻结当前帧,然后在左侧事件列表里逐条浏览。
看Frame Debugger的时候重点关注三类事件:一是Render.TransparentGeometry队列里的大Mesh,半透明大物体的Draw Call会直接暴露;二是ParticleSystem的批量绘制事件,粒子系统通常会一次性提交大量半透明四边形;三是后处理(Post-processing)的全屏Pass,比如Bloom的模糊和合成,这些Pass一出现就是整屏像素再算一遍,等于无条件把Overdraw翻倍。
配合Frame Debugger右上角的Info面板,可以查看当前事件的Shader、Pass、RenderQueue、顶点数和三角形数。如果一个Draw Call的三角形数量很少、但它在事件列表里排得很靠后,盖在已有画面上,你就可以断定它是Overdraw贡献者。看到可疑对象后,直接在Hierarchy里点开对应物体确认,用这种方式能快速定位90%以上高Overdraw的源头。
2.3 真机Profile:Fragment阶段是评估的最终裁判
编辑器里看到的Overdraw分布再严重,最终还是要落到真机上量化。因为移动GPU和PC GPU在像素处理策略上有差异,编辑器里的表现只能作为参考,真机上的Fragment阶段耗时才是硬指标。
如果你用Android机调试,推荐用Android GPU Inspector(AGI)抓帧。它能按渲染阶段统计耗时,重点看Fragment Stage的占比和Bandwidth(带宽)占用。Fragment阶段高、或者总体GPU Busy高,基本就能坐实是像素填充压力过大。高通平台也可以配合Snapdragon Profiler看GPU频率曲线,观察高负载时频率是否被拉满。
如果是iOS设备,用Xcode自带的Metal GPU Capture。抓帧后看Shader Profiler里的Pixel/Fragment处理时间,再结合Device的功耗记录,发热情况一目了然。真机Profiler的意义不仅在于验证,还在于提供优化前后的对比数据——没有基线数据,你就说不清楚优化到底有没有效果。所以项目从一开始就要养成抓真机GPU数据的习惯。
3. 五个最容易"反复刷漆"的高发区:UI、粒子、后处理、阴影与半透明材质
3.1 UI:全屏半透明底加多层弹窗,想不烫都难
UI是很多团队容易忽视的Overdraw重灾区。一个常见的界面:全屏半透明黑底遮罩、两层弹窗背景、按钮上的半透明高光、文本的Outline和Shadow组件……这些都叠在屏幕同一区域内,一次点击可能触发四五层半透明绘制。更要命的是Screen Space UI是每帧重绘的,哪怕画面静止不动,UI层依然在持续烧GPU。
UGUI里最容易忽略的是Image组件默认开启的Raycast Target,以及Text的Outline和Shadow效果。Shadow和Outline本质上是把同一段文字/图形复制平移了好几次画出来,叠加区域会翻倍甚至翻三倍。当下流行的大面积高斯模糊、磨砂玻璃UI,成本就更大了——一个全屏模糊效果往往需要多次采样,等于把全屏Overdraw再乘一个系数。
UI Overdraw的检测建议用Profiler里的UI模块,也可以配合Frame Debugger查看Canvas的渲染事件。真机上一张纯色半透明背景在一整帧里如果排在最前面,底下所有UI和3D场景都要被它再混合一遍,发热贡献非常大。
3.2 粒子与大雾景:粒子的叠加问题
粒子系统天生就是Overdraw制造机。每个粒子都是一个独立的半透明四边形,几百个粒子叠在一起时,屏幕中心区域的重复绘制次数可能达到几十次。尤其是烟雾、火焰、光晕、拖尾这类特效,本身贴图就带大范围的半透明过渡,叠加后几乎没有一处像素是只画一遍的。
最典型的是移动端游戏里的大范围环境雾效:美术为了氛围,放了一大团半透明烟雾粒子,把这团烟雾罩在场景前面。从相机角度看,雾片覆盖的区域内,所有场景物体都在被这团雾反复混合,Overdraw直接从1涨到4以上。这种设计在PC上无所谓,在移动端就是灾难。
平时排查粒子Overdraw时,我会直接用"每粒子屏幕面积 × 粒子数量"来估算压力:假设一个粒子占屏幕1%的面积,场景里有300个粒子,且它们大部分堆叠在中心区域——中心像素的实际被覆盖次数不是300乘以1%(那是分散情况),而是几十粒子在同一个像素上叠加,这就是Overdraw噩梦的来源。
3.3 后处理链:全屏Pass的隐形成本
后处理是另一个"全屏无差别刷漆"的环节。Bloom(泛光)通常需要先把画面采样下来,做若干次降采样模糊,再和原图混合;DOF(景深)需要计算散景再合成;SSAO、Color Grading也都是逐像素Pass。每多一个全屏Pass,屏幕上的每个像素就至少多画一遍,Overdraw增加1。
移动端后处理还要考虑RenderTexture切换的开销。内置渲染管线里随便挂一个后处理组件,可能就多出3~5个全屏Pass,GPU Fragment阶段的耗时直接翻倍。URP里用Volume后处理虽然方便,但如果同时开Bloom加DOF加Film Grain,同一帧的全屏Pass数量依然不容小觑。
有个容易忽略的地方:MSAA和HDR叠加后处理时,像素填充的压力不是简单相加。MSAA会在屏幕边缘多做几次采样,某些移动GPU上这种sample级别的开销会放大Overdraw的代价。所以在移动端,如果场景本身半透明和粒子很多,我会建议关掉MSAA,后处理分辨率也单独降半。
3.4 阴影与多Pass材质:重复上色的来源
阴影对Overdraw的贡献往往比较隐蔽。平行光的Shadow Map渲染本身会额外绘制一遍场景;如果场景里还有大量半透明物体会投射阴影,那Shadow Pass里也要再处理一遍这些半透明物体的深度。很多团队为了省事,给所有材质都开了Cast Shadows,包括大面积的半透明墙面、玻璃、甚至粒子系统——这些都会在Shadow Pass里再被画一次。
多Pass材质同理。游戏里常见的描边效果、外发光效果,用两个Pass实现:先画背面放大轮廓,再画正面正常颜色。这就是一帧内同一个物体被画了两次甚至三次。角色身上再多挂几个特效组件、披风材质是双面渲染……整个角色的GPU开销会成倍往上走。
我把这两个问题单独拎出来说,是因为它们和半透明材质还不一样:很多团队知道粒子烧性能,但完全意识不到一个普通的双层Pass Shader或一个没关阴影的半透明片,也在暗地里给Overdraw添柴火。底下用一个表格总结高发区及其特征,方便对照排查。
| 高发区域 | 典型表现 | 额外代价 |
|---|---|---|
| UI | 全屏半透明底、多层弹窗、Shadow组件 | 静止界面也在反复混合 |
| 粒子特效 | 烟雾、拖尾、光晕大面积叠加 | 同像素被反复着色 |
| 后处理 | Bloom、DOF等多个全屏Pass | 每Pass等于整屏Overdraw+1 |
| 阴影 | 半透明物体Cast Shadows | Shadow Pass多一遍绘制 |
| 多Pass材质 | 描边、双面渲染、外发光 | 同一物体被画多次 |
4. 开始动手削减:Shader、粒子系统和UI层的具体优化手法
4.1 UI层:从层级、Raycast Target到贴图改造
UI的Overdraw优化是最容易见效的。第一步,关闭所有不需要响应点击的Image和Text的Raycast Target。游戏里绝大多数UI元素不需要接收事件,留着Raycast Target只会让Canvas的射线检测变重,虽然它本身不直接增加Overdraw,但减少不必要的元素能降低UI重建和批处理成本,间接缓解GPU压力。
第二步是砍掉Shadow和Outline组件。很多UI设计稿里的阴影效果,完全可以用一张预烘焙阴影贴图替代。如果团队用的是TextMeshPro,建议用TMP自带的Shadow/Outline写法去控制开销,或者直接让美术把描边画进图集贴图里。实测同一个战斗界面,只把20多个Shadow组件删掉,UI模块的Overdraw就降低了30%以上。
第三步是处理大面积半透明底。全屏黑色的半透明遮罩,如果透明度低于80%,基本可以直接改成不透明纯色UI,视觉差别极小,GPU省一大笔混合操作。磨砂模糊效果优先用RenderTexture把背景降采样后做一次模糊,再贴成UI背景图,别用多个半透明Image叠出模糊感。这里的原则是:能不透明的绝不用半透明,能预烘焙的绝不做运行时叠加。
4.2 粒子系统:调参之外的Shader整改
粒子Overdraw的优化,第一步肯定是调参数:减少粒子发射数量、缩短生命周期、减小粒子大小、关掉不需要的Trail(拖尾)。这些都是常规手段,但很多时候调完参数粒子效果就不好看了,所以我更推荐从Shader层面想办法。
常用的一个手段是给粒子材质加上Cutout控制:粒子贴图的透明区域直接clip掉,而不是参与混合。很多人担心Alpha Clip在移动端会破坏Early-Z,确实有这个风险,但粒子本来就是半透明队列,本身就不走Early-Z优化通道,所以用Clip把完全透明的像素剔除,只对剩下的半透明像素做混合,实际能减少不少Fragment负载。实现方式是在Shader的fragment函数里加一行:
clip(texColor.a - _Cutoff);比如把粒子贴图上完全透明的区域直接discard,让GPU跳过混合计算。美术通常会留一层很薄的半透明边缘来保证圆滑过渡,把_Cutoff控制在0.05到0.1之间,视觉上基本无损,但Overdraw能低一截。
另一个思路是把整片半透明粒子合并成一张全屏贴图,或者用Camera方向的Quad去模拟大范围光晕,减少粒子数量。早期一个项目里,环境雾气效果用了200多个半透明粒子,后来换成一张逐帧播放的Flipbook贴图,同样是雾,粒子数量直接降到30,GPU Fragment时间降了一半。这个方案对美术资源要求高一些,但收益非常明显。
4.3 后处理与摄像机:分辨率、剔除和Pass数量
后处理优化的核心思路是"降分辨率"和"减Pass"。URP的Bloom通常支持Downsample参数,把它从1改成2或4,计算量会成倍下降。DOF和Bloom同时挂的时候,建议把Bloom的迭代次数减少,或者把DOF放到半分辨率去做。实在不行,就砍掉某个效果——移动端上一个后处理就够了,别什么都想要。
对于内置渲染管线,我更推荐直接把后处理的RenderTexture分辨率改为屏幕分辨率的1/2或1/4。很多玩家在手机上根本分辨不出半分辨率Bloom和全分辨率Bloom的差别,但GPU负载差好几倍。这里需要美术和程序达成共识:移动端后处理的目标不是"画面惊艳",而是"画面好看且散热压得住"。
摄像机侧有一个容易被忽视的优化:减小Far Clip Plane。远裁剪面每拉远一点点,视野里可能就多出成千上万个三角形和成片的小像素区域,这些小区域虽然单个面积不大,但数量上去后同样拉高填充压力。配合Occlusion Culling(遮挡剔除)把被挡住的室内物体裁掉,才能真正做到"没看到的东西不画"。注意Occlusion Culling需要在Lighting窗口里先烘焙,很多项目没做这个,等于白白让GPU画了一堆被墙挡住的物体。
4.4 一个综合案例:把平均Overdraw从3.8降到1.9
拿一个真实项目举例。之前接到一个MMO战斗场景的发热报告,手机上GPU Frame Time稳定在13到14毫秒,机身烫手。我用Scene Overdraw模式一看,整个屏幕几乎都是红色,尤其主城中心区域,场景里密集的装饰物加上大雾粒子和复杂UI,把Overdraw顶到了平均3.8倍。
优化动作按优先级排:先关掉所有UI多余组件的Raycast Target,删掉20多个Shadow和Outline组件;然后把背景雾粒子从250个降到60个,材质里加了Clip裁剪透明区域;接着把Bloom的后处理分辨率降到1/2,关掉MSAA;最后给每个非玩家角色关掉Cast Shadows,替换成简单假阴影。
一轮改完,Overdraw平均值从3.8降到1.9,GPU Frame Time从13.5毫秒降到8.2毫秒,机身温度明显下降。整个优化过程没有动任何大美术资源,纯粹是技术层面的"减法"——很多时候Overdraw高不是因为东西多,而是因为一堆东西在同一个像素上重复劳动。
5. 用数据说话:优化前后的GPU时间、Fill-rate与机身温度验证
5.1 记录基线:Frame Time、Fill-rate与功耗
优化前必须先记录基线数据,否则改完效果根本说不清楚。我习惯在固定场景、固定机型的条件下记录以下几项:平均帧率、Profiler里的GPU Frame Time、GPU Fragment阶段耗时、平均Overdraw估算值、以及机身温度曲线。
平均Overdraw估算值可以这么算:找一台固定分辨率的测试机,把屏幕总像素数和Per-Frame Fragment Count做对比。部分Profiler工具能看到已渲染像素总数,用它除以屏幕像素总数就是平均Overdraw。Unity自带的Profiler在GPU模块不一定直接显示这个值,但Frame Debugger里每个Draw Call的屏幕覆盖面积可以粗略估算,配合AGI的统计会更准。经验参考:平均Overdraw在1到2倍之间是健康水平,3倍以上就该警惕,5倍以上基本必然发烫。
机身温度需要注意测量方式。Android端可以用adb命令读取热区温度:
adb shell cat /sys/class/thermal/thermal_zone*/temp输出值通常是毫摄氏度,比如47000代表47摄氏度。对比不同时间段读取的值,画出发热曲线。iOS端可以用Xcode的Energy Log或Metal System Trace看温度状态。测温度时尽量让手机处于同一环境温度、同一帧率模式下跑同一段场景,至少跑10分钟再记录,避免刚开机和玩了半小时的数据混在一起。
5.2 用真机Profiler验证Fragment阶段消耗
优化完成后,重新在相同机型、相同场景、相同路径下抓一次AGI或Xcode GPU Capture,重点看Fragment阶段和Texture Bandwidth的变化。Fragment阶段从8毫秒降到4.5毫秒,带宽占用下降,这种数据最直观,也最容易跟策划对齐——你说场景卡,拿数据说话,别拿感觉说话。
如果优化后Fragment阶段没有明显下降,那说明问题可能不再Overdraw,而是Shader本身的计算量太大。比如一个复杂的BlinnPhong加上多层纹理采样,就算只画一遍,Fragment开销也很高。这种情况就要去优化Shader本身的复杂度,而不是继续砍Overdraw。这也是为什么我一直强调"Profiler数据是最终裁判",因为它可以帮你区分"画了很多遍"和"每一遍都很贵"这两种不同的问题。
5.3 可持续监控:上线后的Overdraw巡检方法
优化做完不代表一劳永逸。后续美术提了新的场景资源、加了新的特效、UI叠了新面板,Overdraw随时可能反弹。我建议在项目里建立一套定期巡检机制:每个月抽一个版本,用统一机型跑一遍核心场景,导出Overdraw热力截图和GPU时间曲线,跟基线版本做对比。
Unity的Scene视图Overdraw模式可以作为日常检查工具,每次新场景提交前,让场景美术自己切一遍Overdraw视图看一眼,红色区域如果占屏幕比例太高,打回修改后再提交。这个习惯养成之后,Overdraw问题会少很多。真机数据巡检可以做成一个简单的自动化流程:固定路径录屏回放,用AGI命令行抓帧,解析出Fragment耗时写入CI报表,超过阈值自动报警。虽然搭建起来要花点时间,但对长期维护的项目来说非常值。
我自己踩过的一个坑是:只优化了编辑器里看到的Overdraw,忽略了真机上的分辨率适配。同一款游戏,在低端机上是1080P渲染,在高端机上是1440P渲染,屏幕像素量差了近一倍,Overdraw的绝对值也跟着翻倍。所以巡检时一定要带上不同分辨率档位的机型,不能只看一款旗舰机。另一个经验是Overdraw和发热之间的关系有"滞后性"——不是帧时间一涨温度就立刻上来,而是连续几分钟高负载后温度才明显升高。测试时至少持续运行五到十分钟,别跑一个关卡就下结论。
优化Overdraw这事,跟装修刷墙一样,最怕的就是"差不多得了"。每一遍被盖住的漆,最后都会变成热量从手机背面透出来。多 Debug、多量化、多巡检,你手里的项目才能真正做到"看起来挺好,摸起来不烫"。