做发烫优化做了这么多期,纹理和后处理这两个“搬运量”大户,几乎每次出现发热问题都能看到它们的身影。这一篇我不绕弯子,直接抓这两个惯犯。
先说清楚一件事:设备为什么会发烫?本质上就是单位时间内数据搬运量太大,GPU 的计算单元和内存控制器长期处于高负载,功耗上去了,热量自然散不掉。而纹理采样和后处理全屏 Pass,恰恰是移动端 GPU 里搬运量最大的两个环节。我见过太多项目,模型面数砍了一轮又一轮,粒子数量减了一半,结果发热问题还在,最后用 Profiler 一查,罪魁祸首就是一张超大纹理和一条充满冗余 Pass 的后处理链。
这篇就把它们的底细彻底扒干净。做手机游戏、AR/VR、实时渲染工具的同学可以直接照着文里的思路排查,刚入行想搞懂“性能优化到底在优化什么”的新手,也能从中建立一套完整的带宽意识。
1. 追凶第一步:搞懂纹理和后处理凭什么“搬运量”最大
1.1 纹理:带宽层面的隐形黑洞
纹理占用的不光是显存容量,真正要命的是采样时消耗的带宽。片元着色器每处理一个像素,就要从显存里取一次纹理数据。做一个粗糙的估算:1080p 分辨率下,每个像素只做一次纹理采样,一帧就要读取 1920 × 1080 × 4 字节,大约 8MB 数据。跑 60 帧,每秒就是 480MB。注意,这只是一个纹理、一次采样的理想情况。
真实项目里几乎没有这么温柔的场景。一个片元里采样四五张纹理是常态,再加上 mipmap 切换、各向异性过滤的额外采样,搬运量瞬间翻好几倍。比如各向异性过滤开到 16x,某些角度下每个像素的采样次数可能翻几倍,带宽消耗直接爆炸。更隐蔽的是,很多美术资源喜欢用 4096 甚至 8192 分辨率的贴图,这些贴图如果在屏幕上只占几百个像素,绝大部分采样都在浪费带宽,而且浪费得毫无意义。
我自己在真机上抓数据时发现,很多发热严重的场景,纹理相关的带宽占比能到 40% 以上。这个时候你去找 CPU 的 GC 问题,去优化逻辑代码,一点用都没有,因为瓶颈在内存控制器那边,整个系统都在忙着搬运像素数据。
1.2 后处理:把整帧画面一遍遍搬来搬去
后处理的问题更直观。每做一个后处理效果,就是一次全屏 Pass:读整张颜色缓冲,做一轮计算,再写回新缓冲。一个 Bloom 效果,可能需要降采样到 1/4 分辨率、做两次模糊、再上采样合并,这就四五个全屏 Pass 了。如果项目里再叠上景深、运动模糊、色彩校正、暗角、噪点,整条后处理链跑下来十个 Pass 很常见。
这意味着什么?1080p 的 RGBA16F 浮点缓冲,一个 Pass 读写就要搬约 16MB 数据(8 字节每像素 × 2 次)。十个 Pass 就是 160MB 每帧,60 帧每秒接近 10GB 的搬运量。这个数字放在移动端,发热几乎不可避免。
特别是有些项目习惯创建一堆 Render Target,来回切换。Render Target 切换在移动端 GPU 上的开销比 PC 端大得多,每一次切换可能都要 flush 管线,代价极高。所以我常说,后处理链优化的核心就一句话:减少全屏读写的次数,控制缓冲的数据格式。
1.3 “搬运量”的量化理解:带宽如何转化为发热
为了更好理解,可以把 GPU 想象成一个工厂,显存是仓库,计算单元是工人。纹理采样和后处理全屏读写,相当于工人反复从仓库搬货。仓库的传送带(内存总线)宽度有限,搬运量一大,传送带就堵住了,工厂只能加电加速,功耗随之飙升。
有个简单的公式可以估算发热趋势:功耗 ≈ 动态电容 × 电压的平方 × 主频。总线堵了之后,GPU 会把频率拉高试图缓解瓶颈,电压也跟着上去,功耗呈平方级增长,温度自然压不住。这就是为什么解决发热问题,往往不是降频,而是减少搬运量——把不必要的纹理采样、多余的 Pass 砍掉之后,GPU 不需要那么高的频率也能跑满帧率,体温自然下降。
我实际测试过的印象很深:某个场景把带宽从 12GB/s 降到 7GB/s 之后,功耗降了将近 1.2W,电池温度下降了大概 3 度。所以优化发热问题,盯着带宽这个核心指标,比盲目改代码、砍画质要有效得多。
2. 纹理减负实操:格式、尺寸、mipmap 三板斧
2.1 纹理压缩格式选型:选对格式等于直接减负一半
我在项目里见过很多“搬运量”失控的案例,根源就是纹理格式没有任何优化。美术直接丢一张未压缩的 RGBA8888 纹理进来,或者项目为了省事把所有纹理都转成同一种格式,完全不考虑平台差异。这种做法在发热优化里属于技术债,早晚要还。
纹理压缩这块,移动端最值得掌握的是 ASTC 和 ETC2,PC 端则用 BC7。做个简单对比:
| 格式 | 每像素位数 | 特点 | 适用平台 |
|---|---|---|---|
| RGBA8888 | 32 bpp | 无压缩,画质最好,带宽灾难 | 不推荐用于运行期资源 |
| ETC2 RGB | 4 bpp | 压缩比高,Android 全兼容 | Android 基础兜底 |
| ETC2 RGBA | 8 bpp | 带 alpha,压缩比适中 | Android alpha 图 |
| ASTC 4x4 | 8 bpp | 画质接近 RGBA,体积 1/4 | iOS / 中高端 Android |
| ASTC 8x8 | 2 bpp | 体积 1/16,远看画质可接受 | 大尺寸背景、低频纹理 |
| BC7 | 8 bpp | 画质好,但移动端兼容性差 | PC / 主机 |
ASTC 最大的优势是块尺寸可变,4x4 到 12x12 都可以选。块越大,压缩率越高,画质损失也越大。实际项目里我习惯这样分配:角色贴图、UI 大图用 ASTC 4x4 或 6x6,场景大纹理、远景贴图用 ASTC 8x8,能肉眼看出区别的地方再局部换成高规格。如果目标平台包含低端 Android,建议保留一套 ETC2 作为 fallback,在加载时按机型选择加载哪套纹理。
做纹理压缩格式切换时,必须在目标手机上肉眼检查一遍高光边缘、天空渐变、UI 文字这三类最容易出问题的区域。ASTC 在高频细节多的地方会有块状噪声,金属高光边缘容易发糊,如果美术验收时没发现,上线后就会被玩家截图吐槽。
2.2 纹理尺寸与 mipmap:别让“高清”变成“发热元凶”
格式之外,纹理解析度和采样策略是更大的“搬运量”决定因素。这一步建议用“屏幕占比预算”的思路来管。
一张 UI 背景图,屏幕宽最多占 1080 像素,美术却给了 4096 的原始图,这就是浪费。3D 模型贴图同理,先看模型在镜头里最大可能占多大,再反推纹理尺寸。场景中最常见的 2048 贴图,实际渲染可能只用了屏幕的三分之一,完全可以降到 1024 甚至 512。
mipmap 这一点很多人容易忽略。没有 mipmap 的纹理,在物体远离相机时,采样点会稀疏地落在大纹理上,显存缓存命中率极低,带宽消耗成倍增长。打个比方,你要在一张世界地图上找某个城市的名字,可你偏偏要凑近了看,每次只能看到一小块,找起来又慢又累。有了 mipmap,相当于提前准备好不同缩放级别的地图,看远处时直接用缩略版,效率高得多。
各向异性过滤也要控制。手机上开 4x 或 8x 就足够了,16x 在斜视角场景下带宽消耗明显增加,视觉差异却微乎其微。我用 RenderDoc 抓过帧对比,同样的场景,各向异性从 16x 降到 4x,纹理带宽能省下近 20%。
这里多说一句。做离线重建或者扫描资源的团队应该遇到过 openmvs 这类工具生成的纹理贴图,动辄 8K 甚至更高。直接把这种贴图扔进实时引擎,根本跑不动。这类资源导入引擎之前,一定要先做好尺寸规划、格式压缩和 mipmap 生成,否则它就是移动端的发热炸弹。
2.3 纹理图集与纹理流送:从低频细节里再“榨”一笔
还有一种不易察觉的浪费,来自大量重复的小纹理。我见过一个项目的 UI 界面有两百多张 256×256 的小图标,每张都单独采样、单独切换,绘制开销和带宽开销翻倍。解决办法是合并成图集,不仅减少绘制批次,还能缓解纹理切换的开销。
不过图集有个副作用:mipmap 会导致相邻贴图边缘渗色。解决方法是每张纹理在原始图集里预留 padding,或者叫“内边距填充”,需要美术在出图时预留。我在项目里固定用 4px 的 padding,然后开启纹理的 mipmap 和无缝采样,基本能杜绝边缘混色问题。
对于那些地图巨大、场景广阔的开放世界项目,纹理流送几乎是必备手段。只加载相机附近需要的纹理块,远处的直接使用低精度版本。这个方案做起来相对复杂,但收益非常大。如果你的项目目前不需要这么重型的方案,也可以用更简单的方式替代:把场景里的纹理分成“贴脸可见”“中距离可见”“远距离可见”三个级别,运行时按相机距离切换对应的 mipmap 级别,效果也非常明显。
另外,做纹理资源规划时,可以借鉴图像分析领域提取纹理特征的做法,用灰度共生矩阵算出对比度、能量、熵这些指标,评估哪些纹理的“视觉复杂度”高,哪些纹理的细节本来就少。在实时渲染里,我们可以用同样的思路做资源分级:高频细节丰富的纹理保留高分辨率、高标准压缩格式;大面积的墙面、地面、天空这类低频纹理,优先降级为低分辨率、高压缩格式。这比美术凭感觉拍脑袋定尺寸靠谱得多。
3. 后处理减负实操:用最少次数搬运换最大画质收益
3.1 先算一笔账:你的后处理链到底搬了多少数据
做机加工的朋友听到“后处理”,想到的是 Hypermill、UG 这些 CAM 软件里把刀路数据转成机床代码的环节,核心是数据再编排、再输出。图形渲染里的后处理,本质上也是同一套逻辑:把已经渲染好的像素数据,再搬出来做一轮加工,然后写回。
所以在优化之前,先给自己算笔账。假设你的项目是 1080p 分辨率,后处理缓冲用的是 RGBA32F,每像素 16 字节。一帧画面只读一次,就是 8.3MB,读写各一次就是 16.6MB。如果后处理链有 6 个 Pass,一帧就要搬约 100MB,60 帧每秒就是 6GB/s。这个数字已经很可怕了。
这里有一个经验法则:后处理缓冲的格式,能不用浮点就不用浮点,能用半浮点就不用全浮点。RGBA16F(8 字节每像素)比 RGBA32F 直接省一半带宽;如果某个 Pass 用不到 alpha 通道,R11G11B10 是更好的选择;纯色彩操作甚至可以直接用 R10G10B10 或 RGBA8。把几个 Pass 的缓冲格式降下来,带宽降幅立竿见影,画面几乎看不出区别。
3.2 分辨率分级:低频率特效放心用低分辨率
后处理效果的“感知频率”不同,对分辨率的需求差异极大。Bloom、景深、运动模糊、屏幕空间环境光遮蔽(SSAO)这些效果,本质上是低频信息,降到 1/4 甚至 1/8 分辨率做,视觉上没人看得出来,但带宽能省一大截。
我在项目里常用的策略是这样:
| 特效类型 | 推荐分辨率 | 原因 |
|---|---|---|
| Bloom 光晕 | 1/4 或 1/8 | 光晕是低频扩散,低分辨率影响微弱 |
| 景深模糊 | 1/4 | 模糊本身就是降频操作 |
| 运动模糊 | 1/4 | 高频细节本来就糊掉了 |
| SSAO / 环境光遮蔽 | 1/2 或 1/4 | 遮蔽是低频信息,分辨率要求低 |
| 色彩校正、色调映射 | 全分辨率或 1/2 | 涉及边缘锐利变化,降太多会出现色阶断层 |
| 抗锯齿(TAA / FXAA) | 全分辨率 | 抗锯齿就是为了处理亚像素边缘,必须全分辨率 |
拿 Bloom 来说,很多引擎默认从一开始就在全分辨率做降采样,其实完全没必要。Bloom 的正确做法应该是先把高亮区域降采样到 1/4 或 1/8,在低分辨率下做几次模糊,再逐级上采样合并回去。这样整条链路的移动量只是全分辨率方案的 1/16 左右,视觉差异微乎其微。
3.3 合并 Pass 与减少 Render Target 切换:把“搬运次数”降下来
后处理优化里最直接的“减搬”手段,就是把多个 Pass 合并成一个。我有一个很深的体会:每次创建新的 Render Target 并切换过去,移动端的代价比你想象的大得多,它可能强制 GPU 等待上一次渲染全部结束,造成流水线气泡。
常见的合并手法:
- 暗角、噪点、色彩分级这类简单效果,全部写进色调映射的 Shader,一个 Pass 完成;
- 景深的散景模糊和 Bloom 的模糊可以共用中间缓冲,只需要在算法上做点小调和;
- 如果项目里同时有体积光和泛光,可以先在低分辨率下算出光照贡献,再一次性合成。
我见过最夸张的方案调整,是一条原本 12 个 Pass 的后处理链,最后优化成 5 个 Pass:Bloom 半分辨率处理,暗角/噪点/色彩校正全并入最后的 Tonemap Pass,景深只保留近景半分辨率模糊,另外两个特效直接砍掉。画质整体几乎没变化,但带宽少了将近一半。
说到合并思路,其实和 AI 推理里的后处理流程是相通的。做过 YOLO 这类目标检测项目的人都知道,模型输出的一堆候选框如果在 GPU 上频繁做解码、NMS,中间 buffer 来回创建销毁,性能也会非常难看。优化思路同样是合并 kernel、减少中间数据往返,把多次小搬运合并成一次大搬运,CPU 与 GPU 之间的同步次数降下来,性能自然就上去了。
3.4 后处理发热排行:哪些特效是“惯犯”中的“惯犯”
根据我在多个项目的统计,后处理特效的“发热贡献”大致可以排个序:
| 特效 | 发热贡献 | 备注 |
|---|---|---|
| 多层 Bloom / 高质量光晕 | 极高 | 多重降采样+上采样,搬运量爆炸 |
| 屏幕空间反射(SSR) | 极高 | 每个像素多次 ray march,计算量巨大 |
| 体积光 / 体积雾 | 高 | 通常要多次 ray march 采样 3D 纹理 |
| 高质量景深(散景) | 高 | 大半径模糊采样次数多 |
| 时域抗锯齿(TAA) | 中高 | 历史缓冲读写,叠加抖动和重投影计算 |
| SSAO / HBAO | 中 | 低分辨率下可接受,全分辨率则偏大 |
| 色调映射 / 色彩校正 | 低 | 单 Pass 简单计算,不占带宽 |
| 暗角 / 噪点 | 低 | 可以并入其他 Pass,成本极低 |
我在移动端项目里优先砍掉的高发热特效,排第一的是 SSR。手机屏幕上小,反射细节肉眼很难分辨,但发热贡献却排在最前面,性价比极低。其次就是多层 Bloom,把层数从 4 层降到 2 层,发热能明显下降。体积光和景深,优先降低分辨率和采样次数,不要轻易删,因为它们对画面氛围帮助很大。
4. 实战复盘:一次“电量哗哗掉”的优化全程
4.1 现象与 Profile 数据
去年我接手过一个中重度 3D 项目,帧率稳在 60,但在中端 Android 手机上玩 15 分钟,电量掉了接近 20%,电池温度摸上去明显发烫,已经到 43°C 左右。用 Snapdragon Profiler 抓数据,结果非常典型:
| 指标 | 优化前 |
|---|---|
| GPU 总线带宽 | 11.8 GB/s |
| 纹理采样占比 | 41% |
| 后处理 Pass 数 | 11 |
| 帧渲染时间(GPU) | 12.5ms |
| 电池温度(15分钟) | 43.1°C |
这个场景并没有特别复杂的物理、粒子特效,问题几乎全部集中在纹理和后处理上。所以定位方向很明确,按纹理、后处理两块逐个排查。
4.2 纹理维度:从一张“8K”纹理开始查
先查纹理。我把场景里的纹理资源列了一张清单,按分辨率和格式排序,结果一眼就看到问题:某几个核心场景物体用的是 4096×4096 的 RGBA8888 未压缩纹理,还有大量 2048 纹理,即使屏幕占比并不高。另外,部分地面贴图没勾选 mipmap,采样缓存命中率低得离谱。
处理方式如下:
- 将所有 4096 纹理降到 2048,并在导入管线里统一转成 ASTC 6x6(detail 纹理保留 ASTC 4x4);
- 地面、墙面这类大面积低频细节贴图,直接降到 1024,用 ASTC 8x8;
- 所有纹理开启 mipmap,各向异性过滤统一改为 4x;
- 在场景里单独检查 UI 背景,确认没有隐藏的大尺寸纹理“漏网”。
改完之后带宽数据立刻下降。纹理采样在总线带宽中的占比从 41% 降到 22%,这一项就省下了约 25% 的总带宽。
4.3 后处理维度:把一条“十一道工序”流水线砍到五道
接着处理后处理链。原工程的后处理链有 11 个 Pass,从上到下依次是:
Tonemap → Bloom(含 4 次降采样、4 次模糊、2 次上采样)→ DOF → 色差 → 暗角 → 噪点 → 颜色分级
这条链子里,色差(Chromatic Aberration)在手机屏幕上几乎不可感知,砍掉没有任何损失。暗角和噪点直接并进最后的 Tonemap Shader,完全不额外占 Pass。DOF 从全分辨率降为 1/4 分辨率实现。Bloom 的光晕模糊在 1/8 分辨率下做,然后逐级上采样合并。
优化后的后处理链长这样:
Tonemap + 暗角 + 噪点 + 颜色分级(1 Pass)→ Bloom(1/8 分辨率模糊 + 上采样合并,共 3 Pass)→ DOF(1/4 分辨率,2 Pass)
总 Pass 数从 11 降到 6,缓冲格式从 RGBA16F 为主降到部分 Pass 使用 R11G11B10。
4.4 优化前后数据对比
最终在真机上跑了同场景、同机型、同样 15 分钟测试,数据对比如下:
| 指标 | 优化前 | 优化后 | 变化 |
|---|---|---|---|
| GPU 总线带宽 | 11.8 GB/s | 7.2 GB/s | -39% |
| 纹理采样占比 | 41% | 22% | -19pp |
| 后处理 Pass 数 | 11 | 6 | -5 |
| 帧渲染时间(GPU) | 12.5ms | 9.3ms | -25.6% |
| 电池温度(15分钟) | 43.1°C | 39.4°C | -3.7°C |
| 平均功耗 | 4.6W | 3.7W | -0.9W |
温度从 43°C 附近降到了 39°C 左右,功耗降了接近 1W。帧渲染时间也缩短了,说明 GPU 不再是满负荷运转。这里要强调一下,整个过程中我没有降低任何画质档位,只是把“搬运量”压了下来,这就回到了我反复说的那句话:发热问题的本质是搬运量问题,不是你画质好不好的问题。
5. 问题速查与定位技巧
5.1 常见问题速查表
| 问题现象 | 可能的纹理原因 | 可能的后处理原因 |
|---|---|---|
| 温度高但 GPU 占用不高 | 纹理格式未压缩/带宽型瓶颈 | RT 切换频繁,GPU 等待 |
| 帧率波动明显 | mipmap 缺失,缓存命中率低 | Pass 过多,每帧搬运量不稳 |
| 滑动场景突然发烫 | 大面积纹理动态加载 | 视口变化触发全屏重算 |
| 发热集中在复杂画面 | 高频细节纹理过多 | 全分辨率 Bloom/DOF/SSR |
| 长时间运行后变卡 | 纹理缓存未释放 | 后处理缓冲未滚动复用 |
| 低端机特别容易热 | ASTC 不支持回退到了 ETC2 高规格 | Pass 未按性能档位动态调整 |
5.2 高效定位“搬运量”消耗点的方法
真机调试工具优先用 Snapdragon Profiler(高通平台)和 Xcode GPU Frame Capture(苹果平台)。打开 GPU 计数器里的 Busy / Stall 指标和带宽读数,能直接看到哪一大类资源消耗最多。
如果你手头没有这么细的工具,还有个非常高效的二分法:把所有纹理临时替换成 1×1 白图,跑一轮测试看温度和功耗;再把后处理链全部关掉,跑一轮看数据和耗电。两次结果一对比,立刻能判断是纹理还是后处理的“搬运量”更大。我经常用这个方法在新项目里快速定位发热来源,十分钟就能出结论。
另外,用 Unity 或 UE 的同学,可以在 Frame Debugger 里按 “RenderTarget” 列表看每个 Pass 的缓冲格式和尺寸。如果一个项目的 RT 列表里有大量 RGBA16F/32F 而且尺寸是屏幕分辨率,这就是后处理搬运量过高的直接证据。
5.3 踩了几次坑之后我总结的经验
后处理 Shader 里最容易藏雷的写法,是在循环内部做“随机访问”式采样。比如为了做模糊效果,写了多层 for 循环,每层采样附近 20 个像素。这种写法在 PC 上没感觉,在移动端上带宽瞬间爆炸。正确的做法是先把采样次数压到合理范围,或者用两个 Pass 分别做水平方向和垂直方向的高斯模糊,用 9-tap 甚至 5-tap 就能达到肉眼理想的效果。
关于减 Pass,要注意半分辨率处理边缘闪烁的问题。半分辨率下做景深时,前景和背景的交界处容易闪烁或出现拖影,尤其是有 alpha 混合的物体交界。解决办法是对深度和物体 ID 做边缘检测,交界区域回退到全分辨率处理,但只处理局部,不增加全屏 Pass。
低规格机型适配也是一个重点。同样是 Android 手机,高端机可以全特效,低端机必须要动态关掉一些高发热效果。我建议内置三档画质:低端机直接砍掉 Bloom 里最重的那层光晕、SSR 直接关闭、DOF 降半分辨率;中端机保持后处理默认配置但锁 30 帧或使用自适应分辨率;高端机再上全部特效。这套“分级方案”比一个画质选项打天下要合理得多。
材质这块还有一个常见失误:用金属高光比较强的材质时,纹理压缩格式的块状噪声会被放大得非常明显,看起来像表面有一层脏东西。遇到这种情况,优先检查是不是 ASTC 压缩比太高,把次要通道(比如粗糙度、金属度)拆到单独的 4 bpp 纹理里,主反照率保持 6x6 或 4x4,通常能解决问题,代价是增加一次纹理采样,但对总体带宽来说反而是节省的。
最后分享一个小技巧。在项目初期就建立“搬运量预算”的概念,像管理内存一样管理纹理和后处理 Pass。给后处理设置单帧全屏读写次数的上限,超出即报警;给纹理设置全场景总采样带宽预算,超了就自动降级。这样做两三个版本之后,团队里的美术和 TA 都养成了自发检查资源体积、合并 Pass 的习惯,发热问题会越来越少,而不是每次上线前靠优化师去“救火”。