手机玩到某个关头,背面开始发烫,帧率从 60 掉到 45,卡顿说来就来。如果你已经排查过 Draw Call,也优化过 CPU 主循环,帧耗时却还是压不下去,那大概率是 GPU 侧出了问题。而 GPU 侧最容易被人忽略、又经常“默默烧钱”的一个指标,就是 Unity Overdraw。拿刷墙来比喻,同一面墙你来回刷了 N 遍漆,每一遍都真实消耗涂料和人工,但最后墙并没有因此变得更硬、更白,纯粹是浪费。Overdraw 就是同一块屏幕上同一个像素被反复绘制了 N 遍:GPU 每画一遍都要跑一遍光栅化、像素着色器,再把结果写回帧缓冲,重复得越多,功耗越高,手机自然越来越烫。这篇文章会从 Overdraw 的发热原理讲到检测手段,再把 UI、粒子、半透明、阴影、后处理这几大重灾区逐个拆开,最后给出我实际项目里用过的优化动作和验证方法。适合同样在做发烫优化、而且已经处理完 CPU 和 Draw Call 的 Unity 开发者。
1. Overdraw 的发热原理:GPU 像素管线的重复劳动
1.1 一个像素被画了 N 遍,GPU 就多烧 N 遍的电
要讲清楚 Overdraw,得先回顾 GPU 绘制一块画面的基本过程。顶点数据从 CPU 送进来,GPU 先做顶点变换和裁剪,然后把三角形光栅化,生成一个个像素位置。接下来的像素着色器会对这些像素逐个执行材质计算,最后把颜色写进帧缓冲。同一个像素位置,如果在同一帧里被多个物体覆盖,就会被反复执行每一套完整流程。
举个例子:一个 UI 弹窗叠了五层半透明图片,最底层是一张全屏遮罩,上面有背景框、按钮底图、按钮文字、再叠一层光效。用户看到的是最上面的效果,但 GPU 实际把遮罩、背景框、按钮底图都完完整整画了一遍。像素着色器的所有指令,包括纹理采样、颜色计算、混合操作,每一层都要跑一次。
在移动端 GPU 里,这个消耗会被放大。因为移动 GPU 通常把屏幕分成很多小块(Tile),每个 Tile 在芯片内部完成渲染后再写回显存。Overdraw 越高,意味着 Tile 内要处理的像素着色次数越多,芯片的计算单元忙得更久,功耗自然更高。你摸到手机背面发烫,很多情况下就是在为这些看不见的重复工作买单。
1.2 从 FillRate 到带宽:移动端为什么扛不住
Overdraw 影响的并不仅仅是 GPU 计算单元,更致命的是显存带宽。现代移动 SoC 里,内存控制器的带宽是固定的,而读写帧缓冲、读写贴图都要占用这部分带宽。整个系统里 CPU 要读写内存、GPU 要采样纹理、还要写帧缓冲,大家挤在同一条高速公路上。
算一笔简单的账:假设 1280×720 的分辨率,RGBA8 每像素 4 字节,平均 Overdraw 是 8,跑 60 帧,那么一秒内写帧缓冲的数据量大约是1280 × 720 × 8 × 4 × 60,算下来接近 1.77 GB/s。如果把分辨率提到 2560×1440,这个数字直接变成 7.1 GB/s 左右。再看移动端内存带宽,LPDDR4X 一般在 17~34 GB/s,LPDDR5 在 50 GB/s 左右,听起来数字不小,但 CPU、贴图采样、顶点数据、GPU 其他读写操作都要分走带宽。Overdraw 一旦失控,带宽占用就会成倍上涨,芯片功耗跟着拉升,热量散发出来,手机就到降频边缘了。
所以移动端项目里,分辨率和 Overdraw 是必须一起控制的。很多人只盯着分辨率调 Render Scale,却忽略了一个画面上几十层重叠的粒子、UI、半透明网格,就算分辨率压得再低,Overdraw 照样能把带宽耗尽。
1.3 不同场景的 Overdraw 参考值
我整理了一份自己在项目里常用的经验参考值,方便你判断当前场景的严重程度。
| 场景 | 平均 Overdraw | 说明 |
|---|---|---|
| 纯天空盒 + 简单地面 | 1~2 | 基本没有重叠,正常水平 |
| 常规打斗场景 | 2~4 | 角色、技能、地面装饰产生一定重叠 |
| 复杂 UI 弹窗 | 5~8 | 多层图片叠加、半透明遮罩,值得警惕 |
| 粒子特效密集场景 | 8~12+ | 满屏半透明粒子叠加,发热重灾区 |
| 堆叠多个全屏后处理 | 等效再翻 3~5 倍 | Bloom 的多次降采样升采样会全屏读写 |
当然,这个数值不是绝对的。同样 Overdraw 是 5,一个只采样一次颜色的半透明粒子,和一个要算阴影、反射、折射的复杂半透明 Shader,发热代价完全不同。但作为最初筛查,参考值能帮你快速锁定重点场景。
2. 先把 Overdraw 看透:Scene、Frame Debugger 和真机 Profiler 三件套
2.1 Scene 视图 Overdraw 模式:一秒钟找出重灾区
很多项目优化 Overdraw 靠的是肉眼猜,觉得“这里好像有点亮”“那里感觉叠了很多层”。实际上 Unity 早就提供了 Overdraw 可视化模式。在旧版内置渲染管线里,Scene 窗口左上角把绘制模式切到 Overdraw,就能看到彩色覆盖层。URP 里现在更推荐用 Rendering Debugger,路径是 Window > Analysis > Rendering Debugger,在 Rendering 选项卡里的 Debug Mode 中选择 Overdraw。
这个模式会把绘制次数映射成颜色,越亮、越偏红的区域,说明被绘制次数越多。你不需要逐帧分析,一眼扫过去就能看到哪些屏幕区域是“刷了好几遍漆”的地方。打开之后我发现,很多项目的弹窗界面、战斗技能特效中心、角色脚下阴影叠加处,全是红得发亮的区域,这些地方就是发热的元凶。
不过要泼一盆冷水:编辑器里的 Overdraw 视图反映的是绘制次数,不直接等于真实渲染耗时。有些区域虽然偏红,但 Shader 很便宜,优化收益有限;有些区域颜色不是特别亮,但 Shader 里做了五六次纹理采样,反而是真正的性能瓶颈。所以 Overdraw 视图用来定位重灾区,具体优化优先级还要结合 Shader 成本和真机能耗来判断。
2.2 Frame Debugger 逐 Draw Call 分析:看到底是谁在重复画
Overdraw 视图告诉你哪里有问题,Frame Debugger 告诉你是什么东西在重复绘制。打开 Window > Analysis > Frame Debugger,在播放状态下点击 Enable,左侧会出现当前帧的所有 Draw Call 列表。逐条点开,右侧能查看这个 Draw Call 使用的 Mesh、材质、Pass,Scene 视图里也会高亮显示这次绘制的内容。
实际排查时,我会从可疑区域反推:比如 UI 弹窗区域 Overdraw 很高,就在 Frame Debugger 里往下翻,先把所有 Draw Mesh 里引用的 UI 元素 Mesh 找出来,看看同一个 Image 是不是被画了好几次。一个常见情况:同一张角色立绘,为了让边缘发光,美术叠了三个半透明材质层,每层都是一个独立的 Draw Call 和完整的像素着色流程。在 Frame Debugger 里你能清楚看到同一个网格出现三次,这就是典型可优化的 Overdraw 来源。
另一个容易被忽略的是基础通道和附加光源通道。前向渲染里,一个物体如果被多盏实时灯照射,每多一盏逐像素灯,就多一次完整的着色 Pass。Frame Debugger 里你能看到同一个模型在同一帧里被反复绘制,这种情况下单纯压缩 Overdraw 没用,还需要从光照方案上动手。
2.3 真机 Profiler:不要只看 Frame Time
编辑器里的 Overdraw 视图和 Frame Debugger 只能说明绘制次数,真机的实际影响必须用 Profiler 和硬件工具确认。Unity Profiler 连接真机后,切换到 GPU Usage 或 Rendering 模块,能看到渲染循环的耗时分布。但只看 Frame Time 还不够,因为 Overdraw 导致的带宽压力有时并不直接体现在 GPU 时长上,而是体现在整机功耗和降频上。
有条件的话,Android 平台可以用高通或 ARM 的 GPU 性能分析工具看总线利用率和 GPU Active Cycles,iOS 平台可以用 Xcode 的 Metal System Trace 或 Instruments 里的 GPU 项目。这些工具对大多数人来说有点重,但至少要在同一台真机上做“控制变量”测试:固定同一个镜头、同一段场景、同一个角色动作,跑三分钟记录帧率曲线和机身温度,再对比优化前后的数据。只有这样得出的优化结论才靠谱,光在编辑器里看数据是没有说服力的。
3. 五大 Overdraw 重灾区:UI、粒子、半透明、阴影与后处理
3.1 UGUI 的重复绘制:图片叠图片,阴影叠阴影
UI 是 Overdraw 最容易被忽视的重灾区。UGUI 的渲染机制是把同一 Canvas 下的所有 UI 元素按层级顺序绘制,元素相互遮挡时,遮挡区域就会被重复填充。一个常见的弹窗界面,往往由全屏半透明遮罩、底部背景框、渐变底图、内容面板、多个按钮、文字、图标叠加而成。你以为用户看到的是一个精致界面,实际上 GPU 把整个弹窗区域反复画了五六遍。
更糟糕的是很多项目的图片资源没有打图集,一个按钮底图、一个图标、一个背景各自占用独立的纹理材质,合批断裂,Draw Call 上升的同时,半透明层叠也没减少。Shadow 组件和 Outline 组件也会让同一元素多绘制一遍。我见过一些项目,一个按钮为了做立体感,挂了一个 Shadow 和一个 Outline,整个按钮区域的实际绘制次数瞬间多了两遍。而且 UI 在全屏状态下参与 Overdraw 时,通常是一个全屏覆盖层,就算是“很便宜”的纯色 Image,也会额外触发全屏像素写入,发热影响不小。
3.2 Particle System 的透明混合:满屏都是半透明
粒子系统是移动端 Overdraw 的另一座大山。ParticleSystem 默认的渲染方式就是生成大量面向摄像机的四边形,每个四边形都使用半透明混合(Alpha Blend 或 Additive)。半透明混合意味着 GPU 必须把粒子颜色和背景颜色做一次混合运算,既不能像不透明物体那样直接覆盖,也不能用简单的 Z-Buffer 剔除。粒子层数一多,整个屏幕几乎每个像素都被反复读写。
项目里常见的情况是:一个大招特效由三四个粒子系统叠加而成,有拖尾、有爆点、有散射、有光晕,每个系统再发射几百个粒子,粒子尺寸还拉得很大。粒子之间互相重叠,粒子和角色、粒子和场景也重叠,最终这些区域的实际 Overdraw 能轻松超过 10。更麻烦的是粒子通常会动,几乎每一帧的覆盖区域都在变化,缓存优化很难生效,GPU 只能老老实实把每一层都算一遍。
3.3 半透明 Mesh:玻璃、水面、空气墙
场景里的半透明网格是第三种常见来源。玻璃墙、水面、全息投影、光柱、隐形护盾,这些物体为了视觉效果必须走半透明队列。半透明物体之间,以及半透明物体和不透明物体之间,遮挡部分都会产生额外的绘制。
大面积半透明是移动端的“重罪”。一块覆盖屏幕 1/3 的水面,如果 Shader 里还叠加了反射、折射、菲涅尔计算,每个像素的着色成本都非常高;再加上水面上漂浮的粒子特效、岸边物体的半透明倒影,整个画面区域等于被反复刷了好几层漆。还有一些项目喜欢用半透明网格做“空气墙”或者场景氛围光,这些看似不起眼的透明片,叠得多了,整片屏幕都会被拖慢。
3.4 实时光照与阴影:动态物体的隐形成本
严格意义上,实时光照和阴影并不完全等于 Overdraw,但它们在发热上有着同样的“每像素重复计算”特征。一个在实时阴影范围内的动态物体,像素着色器需要计算阴影衰减,地面、其他物体上的投射区域也会因此多一次纹理采样和乘法运算。如果场景里有多盏实时灯光,每增加一盏逐像素光源,在这个光源照射范围内的物体就要多执行一遍完整的着色流程,相当于给画面多涂了一层“漆”。
Unity 的前向渲染尤其明显。URP 里虽然对主光源有优化,但多光源叠加时,每个物体可能因为不同光源组合产生额外的 Pass。这种情况和 Overdraw 本质上是同一类问题:同一像素在同一帧里的着色次数被重复放大。处理光源数量,比一味减少物体 Mesh 面数更紧急。
3.5 后处理全屏 Pass:每一个特效都在叠层
后处理极其隐蔽,因为它不会出现在你常规检查的 3D 场景里,但它的每一次全屏 Pass 都在对整个屏幕做读写。以 Bloom 为例:先对全屏做一遍亮度阈值提取,然后连续几次降采样、模糊、升采样,每一个步骤都是一次全屏或半全屏的纹理读取和写回;最后还要把 Bloom 结果和原场景做一次合成。整个过程下来,等效 Overdraw 翻了 3 到 5 倍,哪怕原场景 Overdraw 只有 2,加完 Bloom 之后整个屏幕区域的实际读写次数也非常可怕。
还有景深、抗锯齿、色彩分级、暗角。这些特效单独看都不重,但叠在一起就是一场全屏带宽灾难。移动端项目里常见的问题是为了“画质好”把后处理全开,结果一打起来就疯狂发烫掉帧。发热优化这块,后处理往往是最值钱的一刀。
4. 对症下药:每一类 Overdraw 的优化动作清单
4.1 UI:用图集预烘焙阴影,而不是用组件实时生成
处理 UI Overdraw,核心思路是“少叠层、能合就合”。第一步是排查弹窗、主界面、战斗结算界面这些高频界面,把每个元素的 Image 层数记录下来,砍掉视觉上可有可无的层级。全屏半透明遮罩能改用局部遮罩就别用全屏;按钮底图、背景底图可以优先考虑合并成一张完整图集。第二步是禁用 Shadow 和 Outline 组件,让美术直接在贴图里画好阴影和描边效果。表面上看,这只是把阴影改成了贴图素材,实际上它同时减少了半透明层叠、额外 Draw Call 和纹理采样,一举三得。
第三步是拆分 Canvas。把动态元素和静态元素拆开,动态列表的滚动区域单独放一个 Canvas,背景、标题、按钮等不常变动的元素放另一个 Canvas,能有效减少 UI 重建导致的额外开销。第四步是检查 RaycastTarget。不需要接收点击的 Image 和 Text 把 RaycastTarget 关掉,这样既减少 UI 事件检测的计算,也间接降低一些不必要的界面逻辑压力。最终目标是把复杂界面的平均 Overdraw 压到 3 以下。
4.2 粒子:画得小一点,活少一点
粒子优化的第一原则是控制粒子在屏幕上的覆盖面积。粒子面积越大,半透明混合的成本越高。把粒子的尺寸上限收紧,从一个几十米的巨大光柱缩小到 3~5 米的范围内,视觉冲击可能只差一点,但 Overdraw 能下降一个量级。
第二原则是控制同时存在的粒子系统数量。我见过一些战斗场景,同一时刻有六个粒子系统同时运行,它们互相叠加,屏幕中央全是接近白色的一片。这种时候你应该做优先级取舍,或者让特效团队用更少的系统达到同样的视觉表现。三个粒子系统能表达的效果,不要用六个。另一个常见优化点是关闭粒子系统的 Shadow Casting 和 Receive Shadow,粒子的阴影计算对画面贡献很低,却能让每个粒子像素的着色成本翻倍。
第三原则是资源复用。粒子贴图尽量打进图集,避免不同粒子系统使用不同材质。相同材质的粒子系统数量减少后,合批更容易,Draw Call 和 Overdraw 压力都会缓解。额外说一句,Additive 混合并不比 Alpha Blend 便宜多少,别再迷信“改成 Additive 就不烧了”,它一样要读写帧缓冲。
4.3 半透明:从 Transparent 队列挪到 Opaque 队列
半透明对象最容易想到的优化是换渲染队列。很多所谓“半透明”效果,其实只是想在表面留一些高光和边缘透光,并不需要真正的 Alpha 混合。把这些对象从 Transparent 队列改成 Opaque 队列,配合 Alpha Clip,可以让它们参与不透明物体的深度测试,被遮挡的像素可以在早期阶段被丢弃,大幅减少重复着色。
对于必须保持半透明的物体,比如水面、玻璃,优化重点放在降低 Shader 复杂度上。水面 Shader 里如果同时有反射采样、折射采样、法线多次扰动、菲涅尔计算,那每个像素的着色成本都相当高。移动端精简版水面一般只需要采样一次颜色、一次法线,配合简单高光就足够。半透明特效之间的排序也要处理好,让同一区域内的半透明层数尽量少,不要让两块大面积半透明完全重叠。场景布局上,至少别让半透明水面上再叠满屏粒子,不然 Overdraw 一定爆表。
4.4 阴影距离、烘焙光照、深度预写
光照和阴影的优化,优先做的事情是减少实时阴影影响范围。URP 里的 Shadow Distance 参数,能控制阴影贴图实际生效的距离。距离设得太大,远处没必要的物体也会参与阴影计算,还会增加地面上的阴影采样开销。移动端项目一般把 Shadow Distance 控制在 20~50 米,角色周围有影子就行,远山远树就不要强行算阴影了。
静态场景的光照尽量全烘焙。Lightmap 加光照探针的组合,在移动端的效果和性能平衡很好。动态物体需要阴影时,让它们接收主光的阴影即可,关闭次要光源的实时阴影。顺便提一个很多团队容易忽略的点:URP 里可以开启 Depth Priming Mode,让物体先统一画一遍深度,再进入正式着色阶段。这个方案对不透明物体的 Overdraw 压制很有效,但会额外增加一次深度全屏 Pass,具体收益要看场景复杂度,值得在项目里实际测一轮再决定用不用。
4.5 后处理参数瘦身:移动端不过度堆叠
后处理的优化优先级最高,因为直接砍效果见效最快。Bloom 在移动端是发热大户,迭代次数从 5 次降到 2~3 次,模糊范围适当收窄,画质损失在大屏手机上通常可以接受,但功耗能明显下降。Render Scale 从 1.0 降到 0.9 或 0.85,像素总量变成原来的 81% 或 72%,全屏 Pass 的读写开销也跟着下降,这个参数在后处理越多时收益越明显。
更激进的做法是用 Dynamic Resolution。URP 支持动态分辨率,当 GPU 负载超过阈值时自动降低渲染分辨率,帧率稳定后再恢复。这套机制适合战斗场景的特效峰值保护。最后提醒一句:所有后处理效果加在一起,要统计一下总 Pass 数。一个 Bloom 加一个景深加一个色调映射,就是好几轮全屏读写。如果项目对发热敏感,宁愿美术去调整 Bloom 强度,也不要让多个全屏特效无脑叠加。
5. 移动端 GPU 架构差异:优化优先级也要分机型
5.1 PowerVR 的 HSR:能自动隐藏表面消除,但半透明依然挡不住
在 iOS 设备上,GPU 通常采用类似 PowerVR 的 Tile-Based Deferred Rendering 架构,内置了 Hidden Surface Removal(HSR)。这个机制可以在渲染早期阶段识别出被遮挡的像素,直接在 Tile 内存里把它丢掉,后面的像素着色器根本不会执行。所以在纯不透明场景里,哪怕 Overdraw 达到 3 或 4,PowerVR 也可能通过 HSR 自动消掉大部分无效像素,实际发热没有理论上那么夸张。
但 HSR 对半透明物体基本没什么用。半透明必须保持绘制顺序,前景、背景、粒子、UI 一层一层混下来,HSR 不能因为后面的半透明层遮挡了前面的像素就去掉它,它们天然就是互相依赖的。所以你会看到一种现象:同一个 Overdraw 严重的粒子特效,在 iPhone 上可能温温热,在中高端 Android 上却已经烫得降频了。做优化时千万不要因为测试机是 iPhone 就放松对 UI 和粒子的 Overdraw 管控。
5.2 Adreno 和 Mali 的 Early-Z 与绘制顺序敏感性
现在的 Adreno 和 Mali 也都是基于 Tile 的架构,也有 Early-Z、Forward Pixel Kill 一类优化。关键是这些优化非常依赖绘制顺序和深度状态。如果物体不是大致按由近到远排列的,后面的物体会在深度测试前就进入像素着色阶段,白白浪费一堆计算。Unity 的 Opaque 队列在排序策略上并不保证严格从近到远,很多时候是按材质、Shader、队列顺序排的,所以实际场景中不透明物体的 Overdraw 依然是真实存在的开销。
Mali 的 Forward Pixel Kill 也要求深度信息已经准备好。深度数据不可用、物体用了复杂 Alpha 测试,这些早期剔除机制都会失效。这就是为什么有些场景明明看起来不复杂,在中端 Android 上运行却特别烫。如果项目要覆盖中低端 Android 设备,不能在优化时只依赖 GPU 的自动隐藏。人工控制绘制顺序、考虑开启 Depth Prepass,或者减少同屏物体数量,才是更可靠的方案。
5.3 实测数据:同一个 Overdraw 场景在不同机型上的表现
我自己在项目里测过一个战斗场景:不透明部分 Overdraw 平均 3,透明粒子部分平均 8,画面上还叠加了 Bloom 后处理。新 iPhone 上帧率稳定 60,机身只是温温的;同场景放到一台中端 Android(骁龙 7 系列级别),帧率只能稳定在 40 左右,背面明显发烫。后来我们把粒子的覆盖面积缩小,Bloom 迭代次数从 5 砍到 2,透明 Overdraw 从 8 降到 4 左右,这台 Android 设备终于能稳 55 帧以上,iPhone 这边也变得更省电。
这个案例想说明的是:优化不能只看“能不能跑 60 帧”。同一份 Overdraw 开销,在旗舰机型上可能感知不强,在中低端机型上就是体验灾难。只要你的发行目标里包含 Android,就必须按最差设备做基准来优化,别被高端机的体感骗过去。
6. 优化完怎么验证:数据对比与回归防线
6.1 记录基线数据:优化前先量化,不要凭感觉
Overdraw 优化很容易陷入“改了一堆东西,但不知道哪个有效”的困境。所以第一步是建立基线:优化前,在同一台真机上,用同一个游戏包、同一段场景路径、同一个镜头角度,跑一遍并记录数据。需要记录的主要是 FPS、Frame Time、GPU Usage、机身温度。还要在编辑器里用 Rendering Debugger 的 Overdraw 模式截一张图,把红色区域分布作为视觉基线。
优化后再跑同一条路径,记录相同数据。对比 GPU 耗时和温度曲线,通常能明显看到改善。机身温度测试时要注意环境温度一致,最好放在相同材质的桌面上跑同样时长,避免空调风向、手机壳隔热这些变量影响结论。我自己习惯的做法是连续跑 10 分钟,取后 5 分钟的平均帧率和峰值温度,这样比只跑 1 分钟稳定得多。
6.2 把 Overdraw 检查加进日常流程,防止回归
最后提一个团队协作层面的建议:把 Overdraw 视图截图当成版本评审的固定检查项。尤其是特效、UI、新场景提交时,看 Overdraw 截图里有没有新出现的红色高亮区域。粒子系统提交时限制同时叠加的层数,UI 设计稿评审时检查半透明层级,Shader 统一走 URP 标准流程,避免有人为了“效果”塞进自定义双 Pass 半透明 Shader。
版本迭代过程中,Overdraw 的问题很容易在不知不觉中回来。今天加一个光效,明天加一个全屏特效,后天调整后处理参数,每步看起来都不严重,积累到一定程度又回到发烫的老路。有了截图对比和性能基线的流程,至少能在合入前发现趋势,而不是等玩家把差评写出来。
从我个人的实际体会来看,Overdraw 这类问题最反直觉的地方在于:它不像 Draw Call 那样一眼能数出来,也不像 CPU 慢那样能靠堆代码解决,它藏在一帧画面的“重叠”里。只有习惯了用 Overdraw 视图扫场景、用 Profiler 量化收益、把优化动作落到日常流程里,发烫优化才算是真正闭环。最后分享一个我自己常用的土办法:把优化前和优化后的 Overdraw 截图各存一份,每次版本回归时翻出来对比,哪个版本开始屏幕上的红色区域悄悄变大了,顺着那次改动往下查,基本都能找到是哪一帧、哪一个特效、哪一层 UI 把“漆”又刷厚了。