项目封测前最后一次真机验证,我一加8T跑了张刚做好的开放世界地图,5分钟机身温度直接顶到47°C,帧率从60线一路跌破30。群里炸锅的时候,我第一反应是查DrawCall和顶点数——毕竟发烫问题里这俩是常客。可profiler拉出来一看,渲染状态居然干净得很,既没有爆批也没有炸多边形。真正把带宽吃到吐的,是俩我一开始没在意的家伙:纹理采样,和一长串全屏后处理pass。
这篇是发烫优化系列的第4篇,专门把这两个"搬运量"最大的惯犯拎出来开刀。不管你是Unity还是UE、做手游还是做VR,只要落地在移动端,纹理和后处理就是你发热排查清单里必须排在前两名的怀疑对象。我尽量把原理讲明白、给出可复现的优化步骤,也会把我在项目里踩过的坑一起倒出来。
1. 发热的根源:移动GPU最怕的不是算力,而是带宽搬运
1.1 TBDR架构下,一次纹理采样到底搬了多少数据
先放下工具,把最底层的那笔账算清楚。移动端GPU几乎清一色是Tile-Based Deferred Rendering(TBDR)架构,也就是把屏幕切成一个个小tile,在芯片内部完成绝大多数计算后再写回显存。这个设计的本意是省电:只有tile内部的数据交换发生在片上,带宽压力小。但TBDR有个死穴——纹理采样和全屏读写绕不开DRAM。
什么叫绕不开DRAM?GPU内部有tile memory,相当于一个顺手的工作台;但纹理数据放在显存里,显存在移动端通常是跟系统共享的DRAM,相当于几米开外的仓库。每次shader要采样一张贴图,GPU就得去仓库把对应的texel搬到片上缓存。搬多少、搬多频繁,直接决定功耗。
我一般这么跟队友类比:CPU像厨师,cache是操作台的调料架,DRAM是后厨仓库。炒菜的时候每次都要跑到仓库扛一袋米回来,跑一趟不觉得累,但一帧里跑几百趟,整个后厨的人都在忙着搬货,电费自然哗哗涨。
具体数字更直观。假设一张1024×1024的RGBA8888纹理,单次全屏采样一遍,数据量是多少?1024×1024×4字节 = 4MB。如果场景里同时有几十张纹理,每张都被各种pass采样多次,60帧跑下来,光纹理读取的带宽就是每秒几百MB甚至几GB。这些流量全部走DRAM,而DRAM的每bit访问功耗比片上SRAM高两个数量级——发热就是这么来的。
1.2 把带宽换算成温度:一张无压缩的4K贴图有多费电
我见过太多项目,美术同学出于"清晰度"的执念,把主场景贴图拉到4096×4096,格式还是RGBA8888。算一下:一张4K RGBA8888纹理是4096×4096×4 = 64MB。假设这个贴图在一个比较常见的视角下被完整采样一遍,每帧搬运64MB,60帧就是3.84GB/s的带宽。手机DDR4/DDR5的总带宽大概在20~40GB/s,听起来没满?问题是你不可能只有这一张贴图。地形、建筑、角色、特效,加起来几十张,再加上后处理,很容易就把带宽吃到接近上限。
而且GPU有个特性:带宽一旦吃紧,频率会主动往上抬去追赶渲染进度,频率越高、电压越高、发热越猛,降频来得就越快,帧率像过山车一样往下掉。这就是为什么你会看到"温度47°C、帧率25"这种惨状。
再对比一下压缩后的情况。同一张4K贴图用ASTC 8×8压缩,数据量约为4MB,是原来的1/16。被采样一遍的带宽从64MB降到4MB,60帧就是240MB/s。**同样画质观感下,发热成本差了一个数量级。**所以纹理问题从来不是"清晰度"问题,而是"搬运量"问题。这也是为什么我每次做发热优化,第一件事永远是先把所有纹理的压缩格式、mipmap和最大尺寸过一遍。
2. 纹理瘦身方案:从贴图导入设置开始的战场
2.1 压缩格式怎么选:ASTC、ETC2和平台上那些暗坑
现在移动端主流的纹理压缩格式就两个方向:ASTC和ETC2。先说结论,新项目无脑ALL IN ASTC,除非你还要兼容2016年之前的老古董安卓机。
ASTC的优势在于块大小灵活,从4×4到12×12都能选。4×4约8bpp,画质接近RGBA8888;8×8约2bpp,属于比较高压缩比的选择。控制在项目里一般是这么分的:
| 用途 | 推荐格式 | 理由 |
|---|---|---|
| UI大图、法线贴图、关键角色 | ASTC 4×4或6×6 | 细节不能丢,观感优先 |
| 漫反射、地表、建筑 | ASTC 6×6 | 平衡画质和带宽 |
| 远景、噪声、遮罩图 | ASTC 8×8 | 搬运量小,质量损失人眼不易察觉 |
| 老安卓兜底 | ETC2 RGB/RGBA | 兼容性兜底,分辨率别拉太高 |
ETC2在iOS和Android上都是硬性支持的,但它的压缩率有限,RGB约4bpp、RGBA约8bpp,搬运量比ASTC 8×8高了4倍。所以ETC2只适合当低端兼容兜底,不适合全项目统一用。
这里有个容易踩的坑:法线贴图不要直接压成ASTC 8×8,法线方向上的精度丢失会导致光照出现肉眼可见的断层和闪光。做法是用法线贴图专用的压缩管线,或者用法线重映射的方式先把法线范围重新映射到0~1再压缩。很多引擎提供了Normal Map的导入选项,勾上就行,但别为了省带宽把全部法线也压到8×8,回来画质检查会让你改得想哭。
2.2 mipmap、最大尺寸和通道打包,一个都不能省
压缩格式之外,mipmap是另一个被严重低估的带宽杀手。很多人觉得mipmap只是防闪烁,其实它真正救的是带宽和cache命中率。没有mipmap,远处一个占屏幕不到几个像素的房子,GPU依然从最高清晰度层级采样,一个像素要反复跑仓库取数据,效率极低。开了mipmap之后,远处物体自动切到小尺寸层级,读取量大幅下降。
我在项目里立过一个硬性规范:**所有3D纹理默认开启mipmap,UI贴图不强制,但超过512×512的UI图也建议开。**同时把各向异性过滤(Anisotropic Filtering)限制在4x或8x,手机屏幕像素密度本来就高,再往上基本看不出差别,带宽却实打实多烧。
最大尺寸限制也必须有。我见过一张角色贴图采样尺寸拉到8192,纯属浪费。移动端惯例:角色主贴图1024~2048,大型地表拼接用512或1024的重复贴图,远景物件256~512。**4K贴图在移动端不是资产,是热源。**Unity导入设置里Max Size一栏,按平台分别压下来,UE里对应Texture Size Limit和Texture Streaming,别让编辑器默认值替你决定。
再说一个容易被忽略又特别有效的优化:通道打包。把Metallic、Roughness、AO、Height这类标量属性塞进同一张RGBA纹理的四个通道,让采样器只加载一张贴图就拿到全部PBR数据。这样等于把四张贴图的搬运量合并成一张,带宽收益是75%。美术一开始会觉得麻烦,等你把发热数据和包体数据摆出来,基本都会配合。
2.3 落地时可以抄的纹理规范清单
项目里我会把下面这些直接写进规范文档,新来的同学照着做就行:
- 所有3D贴图开启mipmap,各向异性过滤不超过8x。
- 漫反射/颜色贴图统一ASTC 6×6,远景和遮罩ASTC 8×8,UI和角色关键贴图ASTC 4×4。
- 贴图最大尺寸按平台分级:中端机1K封顶,旗舰机2K封顶,特殊情况单独审批。
- PBR标量属性统一走通道打包,禁止一张图只存一个float。
- 法线贴图单独压缩管线,压缩质量调到High。
- 定期用Asset Studio或TextureAnalyzer扫一遍工程,凡是尺寸超过上限或格式还是RGBA8888的,直接标红处理。
这套规范看起来简单,但落地之后我见过的实际效果是:整个场景纹理带宽下降了大约70%,镜头转动时卡顿感减轻,机身温度肉眼可见地回落。纹理这块管住了,后面才能安心处理后处理。
3. 后处理才是真正的"全屏级"搬运黑洞
3.1 一个Bloom为什么需要五六个pass,每个pass烧多少带宽
如果说纹理是"单张资源"的搬运大户,后处理就是**"全屏幕"的搬运黑洞**。纹理还有压缩和尺寸可以压,后处理根本不给你讨价还价的机会:屏幕就那么大,每个pass都在全屏读写。
还是用数字说话。1080p的一帧RGBA8数据大约是8MB。一个全屏后处理pass,读一次8MB、写一次8MB,合计16MB带宽。单独看一个pass没什么,但Post-processing从来不是一个pass——以最基础的Bloom为例,常规流程需要:
- 亮度阈值提取(全屏读取 + 全屏写入,16MB)
- 降采样到1/4分辨率(读取全屏8MB,写入半分辨率2MB,约10MB)
- 水平模糊(小分辨率下读写,约4MB)
- 垂直模糊(约4MB)
- 再降一次采样做更柔和的扩散(约2~3MB)
- 把Bloom结果合并回主画面(读主画面8MB + 读Bloom 2MB + 写8MB,约18MB)
整个Bloom跑完,每帧约55MB带宽。60帧就是3.3GB/s,这个数字比前面那张4K纹理压缩前的单次采样还夸张。而这还只是一个特效。
如果后处理链上同时挂着Bloom、景深(DOF)、SSAO、色调映射,每个都是全屏官方或半分辨率pass,叠加起来每帧上百MB带宽太正常了。你想想,这还只是单纯的搬运,没算shader计算本身。移动端一旦后处理开满,芯片想低功耗都难。
3.2 高频后处理效果的开销排序:哪些能留,哪些必须砍
在移动端做过性能优化的人,心里得有张效果开销表。我按自己踩过的坑和实测数据排了个序:
| 效果 | 实测开销 | 移动端建议 |
|---|---|---|
| SSAO/HBAO | 极高,6~10个pass,随机采样 | 默认关闭,旗舰机或高端可开低档 |
| 景深DOF | 高,4~6个pass,半分辨率还要多次混合 | 中低端关闭,重交互场景关掉 |
| 体积光/泛光 | 高,依赖Ray Marching,pass数不可控 | 除特定演出外不开 |
| Bloom | 中偏高,5~6个pass | 保留但必须降采样到1/4甚至1/8 |
| TAA | 中,2~3pass,还要存历史缓冲 | 视机型保留,注意历史缓冲带宽 |
| 运动模糊 | 中,1~2pass,需要速度缓冲 | 低端关闭 |
| 色调映射/颜色分级 | 低,单pass全屏 | 默认保留,这是质感底线 |
我自己总结的经验是:**移动端默认后处理链条里只放色调映射和FXAA/TAA,Bloom按档位开,DOF和SSAO是重灾区,能不碰就不碰。**很多开放世界手游其实后期氛围全靠美术调色和灯光,跟SSAO较劲的性价比极低。你把它关了,玩家未必说得出差别,但手机温度一定会给你好脸色。
4. 给后处理做"降载手术":分辨率、pass合并与动态开关
4.1 降采样不是无脑砍半:不同效果的分辨率策略
后处理优化的第一原则,也是性价比最高的一招:能降分辨率就降分辨率。
这里的降分辨率不是把整个屏幕渲染分辨率砍半,而是把特定后处理pass放到更低的离屏分辨率上做。做法上分三种:
一是后处理输入降采样。以Bloom为例,阈值提取完成后,直接把亮度图降到1/4分辨率再做模糊,最后合并时再upsample回来。因为Bloom本质是一种柔和的光晕,人眼对它的分辨率敏感度很低,1/4甚至1/8都够用。你可以算:1/4分辨率的模糊pass,带宽直接是原来的1/16。
二是半分辨率后处理链。DOF、SSR这类效果,把整条链子都放在半分辨率RT上做,最后跟全屏画面对齐。半分辨率下的读写量只有全分辨率的1/4,效果损失肉眼几乎不可见,这才是移动端DOF的正确打开方式。
三是关键pass保留全分辨率。像色调映射、颜色分级这种一帧就一个pass的,没必要降,成本本身可控;降了反而容易出现画面文字、UI边缘的色阶断层。这个度要拿捏,别为了优化把所有pass都扔半分辨率,后期画面糊了还得返工。
我在实际项目中给Bloom定的标准流程是:亮度提取在1/2分辨率做,模糊在1/4分辨率做两轮,合并时用带一点偏移的upsample。实测观感和全分辨率Bloom差别很小,带宽成本却低了大概60%。
4.2 后处理队列重组与移动端友好选型
降采样之外,第二步是把后处理链子的结构重新排一遍。很多团队的后处理一多,pass就一个接一个串行地blit,这是典型的搬运浪费。可以合并的pass尽量合并,比如亮度阈值提取和第一级降采样可以在同一个pass里完成——一边读原图,一边按阈值筛选,同时把结果写到低分辨率RT。一眼看过去Bloom链就从6个pass缩到了4个。
框架选型上,移动端优先考虑那些为TBDR友好的方案。UE里的Post Process Volume虽然好用,但泛用性越强、pass越完整,搬到手机上越吃力。Unity则建议直接用URP的Render Feature管理后处理,它能让你精确控制每个pass的RT格式和分辨率。不要图省事把PC全套后处理卷轴直接搬到手机,最后一定是用发热来还债。
另外,MSAA和后处理不能盲目叠加。后处理链每个pass都离不开采样,MSAA一旦开高,每个全屏pass读写的是多重采样缓冲,带宽再乘以采样数。移动端能用TAA+FXR就别硬上MSAA 4x,除非你的RT管理和台积电真的同意。
4.3 用温度感知做动态画质:发热不是二值问题
优化做到最后,光靠静态配置还不够。手机型号、散热条件、环境温度差异巨大,同一套设置不可能通吃所有设备。这时候可以做动态画质降级:系统检测到设备温度偏高或帧率持续过低,自动一步步把高开销的后处理关掉。
我做过的一个简化逻辑长这样:
float currentTemp = GetBatteryTemperature(); if (currentTemp > 42.0f) { postProcessProfile.SetActive("Bloom", false); SetRenderScale(0.85f); QualitySettings.masterTextureLimit = 1; } else if (currentTemp > 39.5f) { SetRenderScale(0.9f); } else if (fpsAverage < 40.0f) { SetRenderScale(0.95f); }温度阈值、降级步长这些参数要根据真机实测标定。注意降级最好是逐级渐进的,一下子把所有特效全关,玩家能明显感觉到"画面变了";分几步走,感知会小很多。这个机制看似是"牺牲画质",其实是把连续可玩性放在第一位。我见过太多项目因为发热降帧被用户骂回炉,装上动态降级之后差评明显少了。
5. 一个真实案例复盘:把中端机烫手的开放世界救回来
5.1 三个profiler锁定真凶的过程
理论讲完,说个我手上的真实案例。前面开头提到那款开放世界,锁定方向之后,我用三条链路同时排查:
第一步,Unity Profiler / Unreal Insights看CPU与GPU帧时间分布。发现CPU侧DrawCall和脚本耗时都不高,GPU帧时间却占了近18ms,明显瓶颈在GPU。
第二步,Xcode GPU Frame Capture(iOS)或Snapdragon Profiler(安卓)看带宽和shader占的百分比。安卓机器上打开Snapdragon Profiler,能直接看到GPU总线的实时带宽使用率和Frag Shader的占用曲线。当时实测总线带宽在13GB/s上下,高得离谱,而Fragment Shader ALU占用其实只有30%左右——这基本实锤了是搬运瓶颈而不是计算瓶颈。
第三步,截帧分析资源列表。用RenderDoc截一帧,看所有绑定过的纹理资源。场面很壮观:一张4K广场地砖、一堆RGBA8888未压缩贴图、一堆没有开mipmap的远景植被贴图,后处理链上挂着SSAO、DOF和两重Bloom。所有问题全凑齐了。
5.2 双管齐下的整改清单与前后数据对比
按前面说的思路,我带着图形组做了一轮整改,核心动作如下:
| 优化项 | 优化前 | 优化后 |
|---|---|---|
| 纹理压缩格式 | 大量RGBA8888 | 漫反射ASTC 6×6,远景ASTC 8×8 |
| mipmap | 一半贴图没开 | 全开,各向异性过滤8x |
| 贴图最大尺寸 | 4096为主 | 角色2K,场景1K,远景512 |
| PBR标量属性 | 独立贴图 | 四通道打包 |
| 后处理链 | SSAO + DOF + 两重Bloom + 色调映射 | 色调映射 + 单Bloom(1/4分辨率)+ TAA |
| 动态降级 | 无 | 39.5°C/42°C两级降级 |
跑完真机数据:
- GPU帧时间:从约18ms降到约9ms;
- 平均帧率:从28fps恢复到55fps(极限调度场景)和稳定60fps(常规场景);
- 机身温度:满载10分钟从47°C降到39~41°C;
- 包体大小:纹理资源总体积减小了约55%。
这轮改动里没有任何让人眼前一亮的黑科技,就是把纹理搬运量砍到合理范围、把后处理pass数压到移动端该有的程度。**数据说明,带宽才是移动端渲染发热的根。**你对着shader调半天指令数,可能不如把一张贴图压成ASTC、把SSAO关掉来得快。
这套思路后来成了我们每个新项目的启动自查项:先看纹理,再看后处理,最后才聊算法。我自己的习惯是,每次发热抱怨进来,先在profiler里把带宽曲线调出来,只要它高企,纹理和后处理里一定跑不掉一个。等你把这两位伺候好了,手机凉下来,剩下的优化都是加分项。后面如果时间允许,我再写纹理串流和自适应画质的具体落地,那块水比今天这两座雷池还深。