上周优化一个植物园场景的Demo,真机跑了两分钟机身就开始烫手。截帧一看,顶点数不算夸张,DrawCall也压得住,GPU频率却稳稳顶在最高档。真正把我的带宽预算掏空的,是纹理采样和后处理这两个环节。我习惯把这两个家伙称为“搬运量最大的两个惯犯”——它们的发热贡献常常被开发者低估,因为从表面上,它们既不像DrawCall那么扎眼,也不像顶点数那样直观可见,但每一帧都在内存里疯狂搬运数据。
这篇是发烫优化系列的第4篇。前面聊过网格、阴影和过早优化这些方向,这篇专门盯着纹理和后处理这两座带宽大山。文章会把“搬运量”这件事拆开讲清楚:它到底搬了什么、怎么计算、怎么降下来,以及我在实际项目里踩过的坑和实测数据。无论你是做Unity还是UE,做手游还是AR应用,纹理压缩、Mipmap、半分辨率后处理、合并Pass这些手段,都值得对着自己的项目重新排查一遍。
1. 发烫的账本:GPU功耗里的“搬运费”
1.1 计算和带宽,谁是发热主力
移动GPU的功耗来源大致可以分成两类:一类是计算,也就是Shader里跑了多少条指令,ALU在干活;另一类是带宽,也就是GPU从显存里读了多少数据、往显存里写了多少数据。很多开发者会把发热归因于“特效太复杂”“Shader太贵”,但实测下来,在移动端带宽往往是比计算更早撞上的瓶颈。
为什么带宽对发热这么敏感?因为数据的“搬运”要经过内存控制器、总线、缓存这些物理链路,每搬一次数据,这些电路都在工作,都在耗电。更关键的是,移动端的LPDDR内存带宽远没有桌面端那么富裕。一块高通骁龙平台的LPDDR4X,理论带宽通常也就二三十GB每秒,实际可用还要再打折扣。而GPU核心在同样时间内能计算的FLOPs却在不断膨胀,结果就是“算得越来越快,但喂数据的路就那么宽”,谁在带宽上贪得多,谁就把手机变成暖手宝。
1.2 纹理和后处理为什么是搬运最多的环节
把一帧画面的带宽开销列出来,纹理采样几乎必然排第一。一个复杂的PBR材质,一张基础色贴图、一张法线贴图,加上粗糙度、金属度、AO,可能还有细节贴图,每个像素要采样好几张,而且在延迟渲染或TAA等后期效果里,还可能做重复采样,带宽就像流水一样哗哗往外流。
后处理则是另一类搬运大户:它自己不产生原始画面,却把整张屏幕图像在多个RenderTarget之间来回倒腾。每一个全屏Pass,都要先读一遍整张RT,写一遍另一张RT。一个超标的后处理链,等于让GPU把屏幕数据当货物一样反复装卸。这两个“惯犯”叠加起来,有时候能占掉整帧带宽的50%以上。这也是我写这篇文章的原因——想降发热,先盯住这两个地方,往往性价比最高。
2. 纹理:在缓存和显存之间反复横跳
2.1 采样一次纹理,GPU到底在搬多少数据
先看一个基础公式:每一次纹理采样,GPU都要把纹理所占的显存数据读进缓存。这个数据量取决于纹理格式和大小。比如一张2048×2048的RGBA32贴图,未压缩时占2048×2048×4字节,约等于16MB。如果在屏幕上铺满这张贴图,每帧至少要完整读一遍,也就是16MB的搬运量。按60帧算,每秒就是接近1GB的纯纹理读取带宽。
实际开销还要加码。三线性过滤采样会一次读多层Mipmap,各向异性过滤更夸张,采样次数成倍上升;同一张纹理被多个物体重复引用时,缓存命中率高还好,如果图集安排不当、纹理过大导致缓存频繁失效,GPU就要反复去显存里搬同一块数据。用生活类比,就是一道菜本来能在厨房灶台上直接取用食材,你非要把冰箱搬到客厅去,来回跑三趟。
所以纹理优化的核心,不是“贴图画质好不好看”,而是“每一次采样到底搬了多少不该搬的数据”。这是理解后面所有操作的大前提。
2.2 纹理压缩:性价比最高的降压药
纹理压缩是移动端发热优化里投入产出比极高的一步。先把一个概念说清楚:这里说的压缩,不是打Zip、PNG那种文件层面的压缩,而是GPU硬件可以直接解码的压缩格式。它的价值在于,纹理在显存里本身就变小了,那每次采样读进缓存的数据自然也变小,带宽、缓存命中率、发热都一起受益。
移动端主流格式就两类:ETC2和ASTC。ETC2是Android从4.3开始官方支持的标准格式,RGBA版本的位率是8bpp(每像素8位);ASTC是更新的标准,普及度已经是现代移动设备的主流,从4×4到12×12分了很多档位。用表格对比一下:
| 纹理格式 | 位率 | 1张2048×2048贴图大小 | 相对RGBA8888开销 |
|---|---|---|---|
| RGBA8888 | 32 bpp | 16 MB | 100% |
| ETC2 RGBA8 | 8 bpp | 4 MB | 25% |
| ASTC 4×4 | 8 bpp | 4 MB | 25% |
| ASTC 6×6 | 3.56 bpp | 约1.8 MB | 约11% |
| ASTC 8×8 | 2 bpp | 1 MB | 6.25% |
从这张表能直观看出,一张RGBA8888的贴图压成ASTC 8×8,显存占用直接降为原来的1/16。我们项目里做过一次全量重压缩,主要场景贴图从RGBA32切到ASTC 6×6和8×8,GPU整体带宽降了将近40%。注意,ASTC对透明纹理的处理比ETC2好很多,不用像ETC2那样担心Alpha通道的额外开销。如果纹理本身是UI或带透明边缘的粒子,优先考虑ASTC 4×4或6×6,质量足够,效果稳定。
另外要提醒一句:纹理压缩格式也要看目标设备范围。虽然ASTC现在是主流,但一些老设备或者模拟器只支持ETC2,甚至不完全支持ASTC。项目里要做纹理格式的平台映射:iOS全系基本可以无脑ASTC,Android按OpenGL ES版本分级,保留一个ETC2回退档。我用过Unity的Texture Import Settings里的平台Override,也用过Addressables的格式分组,都能做到自动替换,关键是测试时别只在真机旗舰机上验证,多找几台中低端机刷一刷。
2.3 Mipmap、图集与尺寸控制的隐藏收益
纹理压缩解决的是“单位面积数据量”问题,Mipmap解决的则是“采样密度匹配”问题。当地面离摄像机很远时,屏幕上可能只有几个像素,但GPU却会去读整张4096贴图,读进来的数据绝大部分用不上,这种浪费就是典型的无效搬运。生成Mipmap之后,GPU会自动选择与屏幕像素尺寸接近的那一层Mip,读入的数据量大幅下降。
虽然Mipmap会让纹理总显存增加大约33%,但换来的是带宽的大幅下降和采样质量的提升(还能减少远处闪烁),对移动端来说极端划算。我见过不少项目为了省显存不生成Mipmap,结果就是在Profiler里看到带宽爆表,发热和掉帧一起找上门,怎么看都不划算。
图集(Texture Atlas)和纹理尺寸控制同样影响搬运量。过大的图集,比如超过2048甚至4096的单张纹理,很容易突破GPU缓存行的有效命中范围。移动GPU的纹理缓存是按块管理的,纹理越大,同样一块屏幕区域需要读入的数据越分散。我做UI时会尽量把图集控制在2048以内,场景贴图也按“近景可用大图、远景用中图”的规则做分类,减少大纹理在全屏场景里的滥用。
顺带提一句,像OpenMVS这类三维重建算法生成的纹理贴图,动辄单张4096甚至8192,导入项目做展示或者放AR里落地时,不做重压缩和重排,发热是肉眼可见的猛。这类贴图要先做匀色、重分UV、再压到ASTC 8×8,才能在移动端真正用起来。
3. 后处理:重复搬运全屏数据的惯犯
3.1 一张全屏Pass的搬运清单
后处理和纹理的发热机制不同。纹理是“每个像素采多次”导致搬运量大,后处理则是“整张屏幕图像被反复读写”导致搬运量爆炸。
先算一笔账:以1080p的屏幕为例,一帧全屏图像约207万个像素;如果RenderTarget用RGBA16F,每像素8字节,那读一次全屏RT就是16MB左右的带宽。任何一个全屏后处理Pass,都要“读一次RT + 写一次RT”,也就是搬运32MB左右的数据。按60帧算,一个Pass就要吃掉接近2GB/s的带宽。
问题是后处理往往不是一个Pass。Bloom要降采样、模糊、升采样,算下来四五个Pass起步;再加景深、泛光、色调映射、色差、噪点,一条后处理链跑下来,全屏图像被搬了十几次。哪怕每个Pass单独看都不算贵,合起来就是带宽大户。我把这类问题称为“后处理洗手效应”:洗个手只要几秒,但一天洗十几次,水费就开始让人肉疼了。
顺便吐槽一句,网上搜“后处理”会出来一堆数控加工领域的内容,比如五轴后处理、UG后处理判断四轴变化时Z轴回零,那是完全不同的应用领域。这篇文章聊的是渲染管线里的后处理,别搞混。
3.2 用半分辨率砍掉大半搬运量
后处理优化的第一个思路,是降低参与后处理的像素总量。人眼对高频细节和低频颜色变化的分辨能力是不同的,像泛光、景深、体积雾这类低频效果,完全可以在半分辨率、甚至四分之一分辨率下计算,再升采样回原分辨率。这有个专门的说法叫“频谱分离”:把画面拆成高频和低频,低频部分用低分辨率处理,高频部分保留原分辨率。
具体到Bloom,我通常的做法是:先把原分辨率RT按二分之一降采样一次,得到半分辨率图层,后续的模糊和迭代全在半分辨率甚至四分之一分辨率上进行,最后做一次升采样叠加回原图。这样整套Bloom的像素处理总量可能只有全分辨率的25%到30%,带宽压力和发热立刻降下来。景深、泛光、光晕也是同理。
需要小心的是,半分辨率处理太狠会有画质问题,比如高频边缘出现闪烁、模糊结果显得脏。我的经验是:模糊半径大了之后,视觉上反而能掩盖分辨率不足的问题;关键参数是全分辨率到半分辨率的降采样滤镜要选好,别用普通点采样,用带有小幅高斯权重或者双线性过滤的降采样,能缓解很多闪烁。移动端项目里,我一般把Bloom的模糊层控制在半分辨率,只有特别简单的卡通风格才敢用四分之一。
3.3 合并Pass,让GPU少跑几趟内存
后处理的另一个优化方向,是减少全屏Pass的数量,让GPU尽量不来回读写字面量。这里有两个层级的手段:
第一个层级是“MergePass”。很多后处理效果之间是可以合并的,比如色调映射、饱和度调整、噪点、暗角、色差,这些逐像素处理完全可以写进同一个Shader里,在最后一个Pass中一次性完成。做合并之前,理清依赖关系很重要:哪些效果是输入性依赖(需要整张图信息),哪些是逐像素独立操作,独立操作尽量往后合并。我见过有些项目把Bloom、色调映射、噪点分成了三个单独的Pass,一个合并就能把后处理链从9个Pass压到6个。
第二个层级是利用移动GPU的TBDR架构特性。移动GPU大多数是基于瓦片渲染的,片上有一块高速缓存(Tile Memory),RT数据可以被留在片上,避免真正写回显存再读出来。在GLES里可以用GL_EXT_shader_framebuffer_fetch扩展实现片上帧缓冲读取,在Vulkan里用Subpass,在Metal里也有对应的LoadStoreAction配置。这样多个后处理Pass之间,数据就只在这个快速缓存里流动,内存带宽大幅下降。类似YOLO这类AI检测里的后处理流程,如果你把NMS、阈值过滤每个小步骤都拆成单独内核跑,显存和计算开销会非常难看;图形后处理也是同一套逻辑,能在片上完成的工作,别折腾到全局内存里。
Unity里用Shader可以实现framebuffer fetch,但要注意设备兼容性,最好做特性的运行时检测;直接使用CommandBuffer做Pass串联时,也可以利用LoadAction/StoreAction的配置,让不必要存储的RT留在瓦片上。这部分的收益非常可观,我优化过一个项目的景深,把六个Pass改成三个Subpass之后,帧时间直接降了将近两毫秒。
3.4 机型分级与动态开关策略
后处理不是“全有或全无”,而是“按设备能力给不同档位”。中低端机连全屏Pass跑起来都吃力,更别说叠好几个后处理效果了。我建议给后处理管线配置三档方案:
- 高配档:开启Bloom、景深、体积雾、完整的色调映射,RT格式可以用RGBA16F。
- 中配档:只保留Bloom(半分辨率)和基础色调映射,关掉景深和体积雾,RT降到RGBA10或RGBA8。
- 低配档:顶多做一个极轻量的泛光和LUT调色,其他全部关闭。
这套配置怎么落地?用Unity的话可以将后处理组件挂在同一个Volume上,按Quality Level设置不同的Override;用UE的话可以用Scalability Level配置。关键是运行时不要做太复杂的实时判断,成本要压到最低。还有一点:后处理开关要平滑过渡,不要让人一眼看出画质掉了。通常我把Bloom的强度调低而不是直接关闭,人眼反而不会太敏感。
在旗舰机上,还可以用动态分辨率系统做进一步控制:当帧时间超过警戒值,先用后处理半分辨率兜底,或者把屏幕分辨率从100%动态降到90%、80%,把带宽压力释放出来。这个方向很多厂商都在做,Unity的Dynamic Resolution和TAA Upsample方案就是干这个的,配合后处理的分档,发热控制会从容很多。
4. 实操流程:一次完整的纹理和后处理降载
4.1 摸底:找出项目里的带宽大户
动手优化之前,先要做一次摸底,搞清楚到底是谁在烧带宽。我常用的工具链是:RenderDoc抓帧看纹理清单,Snapdragon Profiler看Adreno的带宽计数器,Arm Mobile Studio看Mali GPU的周期和总线占用,iOS上则是Xcode的Metal System Trace。没有这些硬核工具时,Unity Profiler的GPU模块和Frame Debugger也能提供重要线索。
实操第一步,是把一帧画面里所有纹理按显存大小排序。方法很简单:RenderDoc里导出纹理列表,按Size排序,一眼就能看到一张4096的RGBA32背景贴图、一套8张2048的法律线贴图、一堆没有压缩的UI图集。这些未经压缩的“巨无霸”通常就是第一波要处理的资源。
第二步,看后处理链的Pass数量和RT格式。Frame Debugger里能看到每个CommandBuffer干了什么,RT有没有反复Load、Store。如果发现一个Bloom链里有五六个全屏全分辨率Pass,基本可以确定它是发热的另一大源头。把这两组数据记录下来,作为优化前的基线。
4.2 纹理降载步骤与参数参考
先做能快速见效的“低垂果实”:
- 把所有RGBA32/RGBA16纹理切成平台对应的压缩格式。移动平台从ASTC 6×6开始试,质量不够再换4×4;UI和海报类重点内容用ASTC 4×4,场景环境贴图用ASTC 8×8。
- 给所有带距离衰减的场景纹理开启Mipmap。如果担心内存增加,把原始Max Size降到合理的上限,比如场景大图不超过2048,角色贴图不超过1024。
- 检查图集大小,超过2048的图集拆小或重新排版。
- 重建类算法产出的纹理,如果单张超过4096,必须先重投影再压缩。
参数上,我一般以“视觉上再过5%就接受不了”为界。金属、皮革这类高细节材质用ASTC 6×6;天空、地面、墙壁这类大色块材质用ASTC 8×8甚至10×10;带渐变的半透明粒子用ASTC 4×4。压缩后要在多台真机上对比截图,重点看边缘渗色和黑暗区域的条带感。这个过程中,Unity的Compress Texture选项和自带预览窗口就能帮上忙,不一定需要额外工具。
4.3 后处理管线裁剪方案
后处理管线优化的动作我们分三步走:
第一步,砍冗余。把实际上看不出区别的效果删掉,比如过强的径向模糊、不必要的色差、重复的泛光叠加。少一个Pass,就少一整轮全屏搬运。
第二步,降分辨率。只保留低频效果走半分辨率路径,画面中的锐利边缘和高频细节不要进后处理链。具体做法就是先把RT降采样到半尺寸,处理完再升采样混合。
第三步,合并Pass。把所有逐像素独立的后处理效果合并到最后一个Pass,减少中间RT的切换。实际做的时候,用Unity的Post Processing V2/V3或者URP的Volume系统,可以把效果排序、分层配置,然后用一个MergePass统一处理色调映射、饱和度、暗角、噪点。
针对不同的后处理链,我推荐的掉头顺序是:先关体积雾(贵又吃内存)、再关景深(容易诱发眩晕且带宽高)、再关Bloom的级数(从五级降到三级)、再降RT格式(RGBA16F改RGBA10)。
4.4 优化效果实测对比
这是我上个月在某个AR展示类项目上得到的实测数据(Unity、Android真机,目标设备为中端骁龙芯片):
| 指标 | 优化前 | 优化后 | 变化 |
|---|---|---|---|
| 纹理显存占用 | 486 MB | 182 MB | 下降62% |
| 平均GPU带宽 | 11.2 GB/s | 5.8 GB/s | 下降48% |
| 后处理Pass总数 | 11 | 6 | 减少5个 |
| GPU帧时间 | 17.5 ms | 11.2 ms | 下降36% |
| 连续运行15分钟机身温度 | 44.6℃ | 39.2℃ | 下降5.4℃ |
优化动作就是“全量纹理压缩 + 加Mipmap + 拆图集 + Bloom降半分辨率 + 合并后处理Pass”。没有动模型面数,也没有砍主场景的特效,发热体感从烫手变成温热,这个结果对项目来说已经足够有说服力。当然,不同GPU的计数器口径不同,数值不能跨平台直接比,但方向是一致的:带宽下降了,温度就跟着降。
温度测试的姿势也很重要:固定亮度、固定场景、跑相同的一段路径,录满15分钟看曲线,不能拿手摸一下就下结论。数据说话,别靠体感。
5. 常见问题与排查技巧实录
5.1 纹理优化翻车现场
问题一:ASTC之后,边缘出现一圈黑边或亮边。这个常见于带透明通道的贴图,原因是RGB边界的颜色外溢进了Alpha边缘区域。解决方式有两种:一是把透明材质的纹理在导入前做“预乘Alpha”处理,把边缘颜色信息“塞”进透明区域;二是在DCC工具里对贴图边缘做几像素的RGB扩张。做UI贴图时,我还会在纹理周围留一点透明安全的余量。
问题二:压到ASTC 8×8后,天空出现明显的色带。这是量化位率不足以表现平滑渐变导致的。解决思路不是全部提回6×6,而是给天空这类关键渐变纹理单独设高质量档,其余继续用8×8。也可以在做渐变时加少量高频抖动噪点,让色带在人眼感知上被打散。
问题三:老设备显示花屏或者紫屏。这是目标设备不支持ASTC导致的兼容性问题。务必要做运行时格式能力检测,Android平台按OpenGL ES版本和扩展列表判断,不支持ASTC的设备回退到ETC2、甚至RGBA32,然后用资源分平台的方式打包,别指望大胆一把梭。
5.2 后处理优化后的画质陷阱
问题一:半分辨率Bloom一移动就闪。这是因为降采样时高频光照信息被破坏,导致模糊层跟着闪烁。解决要点是降采样先用带滤波的Pass,别直接点采样;升采样前可以再加一次融和,或者采用“两段式高斯”的做法,先横向后纵向,闪烁会小很多。
问题二:合并Pass之后颜色不对。这通常是因为合并时把依赖顺序搞错了。色调映射必须在线性空间里做,调节的是LDR之后的颜色;而Bloom结果要在色调映射之前混入HDR。合并在一个Pass里不是随便写几行代码就行,必须把操作顺序标清楚,不然会偏色、过曝。我习惯在注释里写清“输入线性HDR → Bloom相加 → 色调映射 → 饱和度 → 暗角 → 噪点”的顺序,传阅给同事时也不容易出错。
问题三:RT格式降低后暗部出现断层。RGBA16F降到RGBA10,这个问题的确容易出现。缓解方式:尽量避免在RT里做太多次迭代计算,后处理链变短之后,即使位宽降下来,累计误差也会小很多。
5.3 排查工具与思路备忘
没有专业Profiler时怎么排查?一个土办法是“二分法排除”:在后处理链里逐级开/关效果,看帧时间曲线和发热的差异,哪个影响最大就先优化哪个;纹理方面也是一样,把场景分成几大块,一块块替换成压缩纹理,对比帧时间。这个方法费点时间,但不需要依赖高级工具,对调试老项目也够用。
工具方面,我几乎不用单一的Profiler,而是把多个数据交叉着看:RenderDoc保证画面细节和纹理正确性;Snapdragon/Streamline看带宽和核心占用;系统温控的CPU/GPU降频日志看持续负载能力。综合起来,才能定位“到底是纹理吃带宽,还是后处理吃带宽”这种问题,而不是单纯靠猜。
排查过程中还有个小技巧:把游戏的各性能指标曲线录下来后,对齐到同一时间坐标,观察是否和发热降频曲线吻合。很多时候你会发现,功耗并不是一路高,而是跑到几分钟后因为芯片降频突然降低,这时候别再加大优化力度,先解决“最高功耗点”才对。
在动手调纹理和后处理之前,先别急着大一统地压默认参数。每个项目都有每个项目的特点,地图类场景纹理带宽大,FPS竞技场景后处理更容易超标,AR应用则两个都跑不掉。最优解往往是在这几个维度之间做平衡,而不是单一指标拉满。
最后分享一个实用的工作习惯:每次做一轮优化,都专门记录“优化前后对比截图+帧时间+带宽+温度”,存成一份带时间戳的文档。过一个月项目内容更新后,再翻出这份基线数据对照,能立刻判断是新增功能又引入了发热回退,还是环境变化导致的波动。这样每次排查发热问题,都不至于从零开始,永远有一套持续的量化依据可以依赖。纹理和后处理这两座大山的优化,本质就是把“搬运量”这个抽象概念一点点量化、收敛、再验证的过程,数据和耐心比任何特效开关都管用。