这期发烫优化系列,终于聊到硬骨头了。前面几篇我们拆过CPU主频、内存频率、GPU负载,但真正扛起发热大旗的,其实是两个平时不太起眼又逃不掉的东西:纹理和后处理。这俩兄弟在设备内部的“搬运量”常年霸榜,一个负责把贴图从内存搬到渲染单元,一个负责把整张画面在多个中间缓冲之间来回倒腾,哪个都不省电。
如果你玩手游时觉得后盖发烫,或者做渲染管线优化时发现GPU频率拉满但Shader计算明明不多,大概率就是被这两个惯犯给坑了。这篇我会把原理、排查方法和优化思路串起来讲,主要面向Unity/引擎开发者、图形程序员,也想让对手机为什么发热感兴趣的玩家能看个明白。内容会有点长,但每一条都是实际项目里能直接用上的经验。
1. 先搞清楚发烫的根源:GPU搬东西比算东西更费电
1.1 带宽是怎么变成热量的
现代手机的SoC里,CPU和GPU共享同一块DRAM(内存),数据在DRAM、缓存、Shader核心之间搬运时,每一次信号翻转都要消耗能量。你可以把内存带宽想象成一条很窄的公路,车(数据)越多,跑得越辛苦,油耗自然越高。对应到芯片上,就是DRAM控制器和总线的功耗快速上升,最终反映为机身发热。
很多开发者容易把发热归结为“Shader算得太狠了”,但实际项目里,算力和数据搬运的开销常常是分开看的。一个像素复杂计算再多,只要数据都在寄存器或者缓存里,功耗是可控的;但一旦要从外部DRAM读一个8MB的纹理,或者把整张1080p画面反复写成中间缓冲,功耗立刻就不一样了。这也是为什么我常说:发烫优化,本质上是带宽优化。
移动GPU和桌面GPU还不一样。桌面有独立显存和更暴力的缓存,移动端则GPU和CPU共用DRAM,可用带宽本身就紧张。再加上很多移动GPU采用的是TBR/TBDR架构(比如Mali、Adreno、PowerVR),它们对“帧缓冲”做了很多片上优化,却没办法把“纹理读取”也全部塞进缓存。纹理数据一旦超出缓存大小,就会产生DRAM访问,这部分带宽消耗非常直接。
1.2 纹理:每一个像素都要从内存里把数据背过来
一个典型的PBR渲染流程里,模型表面至少要采样反照率(Albedo)、法线(Normal)、粗糙度/金属度(Roughness/Metalness)等多张纹理。Game Camera渲染一帧画面,每个可见像素都可能要经过多次纹理采样。如果纹理没有mipmap、格式又是未压缩的RGBA8888,那么即便物体离镜头非常远,GPU也会老老实实地去读原始尺寸的贴图数据。
我们随手算一笔账:一张1024x1024的RGBA8888纹理,大小是1024x1024x4 ≈ 4MB。假设一个1080p的场景,只有一个全屏物体,每个像素只采样一次这张纹理,那一帧就要读大约4MB数据,60帧就是240MB/s。听起来好像不多?可真实场景里一个角色可能要采样5到8张纹理,场景光照还要打阴影、SSAO、反射探针,采样次数轻松翻几倍。再加上各向异性过滤会增加额外采样点,纹理缓存miss率一高,最终带宽可以轻松突破2-3GB/s。
重点是这些数据不是“白读”的,GPU读取纹理时的任何带宽消耗都要付电费。纹理尺寸越大、格式越原始、采样次数越多,发热就越明显。很多项目发烫严重,打开Profile一看,Texture Read Bytes高得吓人,原因就是这么简单。
1.3 后处理:把整张画面反复倒腾,等于让GPU搬砖
后处理又是另一座大山。你可能只开了Bloom(泛光)和抗锯齿,觉得“特效不多”,但后处理做的事情是:把场景渲染到一张离屏RenderTarget(RT),然后对这张RT做一遍又一遍的全屏读取和写入。
拿最常见的Bloom来拆解:先要把场景RT降采样到小图,然后做水平模糊、垂直模糊,可能还要做几层金字塔模糊,最后再和原图合成。每一步都意味着“读一整张RT → 写一整张RT”。以1080p RGBA8 RT为例,一张RT大约8.3MB(1920x1080x4),一次“读+写”就是16.6MB带宽。如果一条Bloom链有4个后处理Pass,那一帧就是66MB;60帧就是约4GB/s。如果RT是HDR FP16格式,容量直接翻倍,数字就更夸张。
这就是为什么后处理被称为“搬运惯犯”的原因。它不像纹理那样是“按需取用”,而是强制把整幅画面从DRAM里拖出来又写回去,还经常反复多次。只要后处理链稍微长一点,GPU带宽就被拖死,温度跟着起飞。
2. 纹理侧省带宽:压缩、分级、缓存一起上
2.1 纹理压缩是成本最低的第一步
我见过太多项目为了省事,直接让美术上传PNG/JPEG到Unity,或者干脆用无压缩RGBA纹理作为贴图。这类做法放在十年前也就算了,现在的移动项目再用,就是纯纯的“带宽刺客”。解决思路很简单:所有纹理进入GPU前,都必须转换成GPU硬件支持的压缩格式。
移动端目前最推荐的是ASTC(Adaptive Scalable Texture Compression)。它支持从4x4到12x12的block size,压缩率灵活,iOS和Android的主流GPU都支持。老一些的Android设备可能只支持ETC2,所以发行前要确认目标机型下限。
压缩格式的block size选择要分情况:要求高的法线贴图和关键角色贴图,用ASTC 4x4;一般场景纹理、粗糙度、金属度这类低频信息,用ASTC 6x6或者8x8;如果纹理中大面积是纯色/渐变,甚至能用10x10。从RGBA8888切到ASTC 6x6,纹理数据量大约能降到原来的四分之一到五分之一,带来的带宽收益非常直接。要注意的是,切了压缩格式后一定要在引擎里走“压缩后预览”检查,尤其观察边缘是否出现脏色块或明显色阶,必要时针对少数高精度贴图单独用4x4。
这里还有个常见误区:UI纹理也别忘了压缩,但UI通常不生成mipmap,可以使用ASTC 4x4来保证文字和图标边缘清晰。如果UI图集一直用原始RGBA,实测每帧的UI刷新也是一笔不小的带宽开销。
2.2 mipmap:让远处的物体不再扛着整张地图跑
mipmap是纹理队列里最容易忽略但又特别重要的优化项。它的原理不难理解:预先为一张纹理生成一系列长宽减半的小尺寸副本,渲染时根据当前像素覆盖的纹理区域大小,自动选择“合适尺寸”的mip层去采样。
举例来说,一张1024x1024的纹理,如果某个物体在屏幕上只覆盖了16x16像素,那GPU实际上只需要读16x16大小的mip层,也就是约1KB的数据,而不是从4MB的原始纹理里反复采样。mipmap本身会额外增加约33%的纹理存储空间,但换来的带宽节省往往是数量级的。更重要的是,mipmap能有效缓解远距离物体纹理闪烁和摩尔纹问题,画质看起来也稳。
开启mipmap在实际操作中有两个容易踩的坑。第一个是内存大增:如果项目里所有大图都强制开mipmap,纹理内存会增多,尤其移动端内存本身就紧张。正确做法是:3D场景贴图和地形材质打开mipmap,UI、图集、动态RenderTexture不需要mipmap。第二个坑是加载方式:如果用Unity的旧版AssetBundle,要注意mipmap是否在导入设置里勾选,否则打包后可能没生成完整mip链。我个人习惯是在管线的纹理导入阶段对“3D用途”和“UI用途”分别设置默认值,避免美术手动勾错。
2.3 控制采样次数和重复贴图
除了格式和mipmap,采样次数也是决定纹理带宽的关键变量。很多项目里,同一个物体被多个灯光照亮,每个灯光都触发一次纹理采样循环;或者后处理里的SSAO、体积光等效果会对场景颜色纹理做多次采样。这些采样未必都是必要开销,需要逐项排查。
在实际优化时,我建议先统计一下每个Pass的纹理采样次数:一个物体如果被多个灯光重复绘制,可以改用单Pass多光源的渲染方式;如果阴影贴图和高精度纹理在远距离下没什么贡献,可以按距离切换Shader变体。还有一个非常有效的优化是用纹理数组或图集,把大量小纹理合并成一张大图,减少纹理切换的缓存miss,同时也方便引擎做批次合并。
这里要特别提醒:同一张纹理被多个材质引用并不等于带宽翻倍。GPU的纹理缓存会缓存最近访问的纹素,只要多个材质在同一帧内连续访问同一张纹理,命中率往往很高。真正的风险是“每帧全部换新纹理”:一旦场景里塞了几百张独有纹理,每个物体只采样自己的那张,缓存不断miss,DRAM带宽就会爆炸。所以设计场景时,优先重复使用纹理图集和材质变体,而不是每个物件都配独立贴图。
3. 后处理侧省带宽:降分辨率、合并Pass、按需开关
3.1 先拆后处理流程,看清哪些Pass在全屏搬砖
在做后处理优化前,我习惯先用Frame Debugger把后处理链路整个拉开,看看当前帧到底跑了几个全屏Pass、每张RT的尺寸和格式是什么。Unity里可以打开Window > Analysis > Rendering Debugger,或者在Frame Debugger里逐个查看Draw Call。你会发现很多默认后处理链里隐藏着大量“Blit Blit Blit”。
以Unity URP内置后处理为例,一卷完整的后处理可能包括:泛光(Bloom)的预滤波、多级降采样、逐级模糊、上采样合成、色调映射(ToneMapping)、环境光遮蔽(SSAO)、景深(DOF)、动态模糊(Motion Blur)等。其中Bloom和DOF尤其费带宽。常规做法是“看到什么特效就去调它的参数”,但我的建议是先从“这个Pass真的非跑不可吗”开始问。比如移动端游戏的抗锯齿如果用FXAA,通常一个Pass就够;如果用TAA,就需要额外的历史缓冲和运动向量重投影,这两个选择对发热的影响完全不同。
3.2 半分辨率是发热和画质之间的“黄金分割点”
大多数后处理效果其实不太需要全分辨率。人眼对屏幕边缘的细节敏感度远低于中心区域,对低速运动时的高频模糊也不敏感。Bloom、AO、DOF这类效果天然具备“低通滤波”属性,先用半分辨率甚至四分之一分辨率去计算,再在最终合成时放大,视觉差异很小,带宽却可以省下数倍。
具体操作上,Bloom一般做法是:先把全分辨率场景RT降采样到1/4分辨率,在低分辨率下提取亮部并做模糊,最后以半分辨率甚至全分辨率合成回来。这样做对比度损失几乎看不出来,纹理细节依旧由场景RT保留。DOF也可以把模糊半径放到半分辨率处理,只需要在深度边界处适当处理,避免前景物体出现明显的“糊边”或“挖空”。如果使用URP,可以通过自定义后处理Volume组件把中间RT的RTHandle scale设为0.25或0.5;如果自己写RenderFeature,就直接通过Descriptor控制RT宽高,不要每层都默认用全分辨率。
需要注意:半分辨率后处理常见的副作用是闪烁和线条断裂,尤其在亮部边缘和细小遮挡处。解决方案是:对降采样之前的输入做一次低通滤波(比如从全分辨率缩小到半分辨率时使用4点双线性而不是简单点采样);对有深度依赖的效果(如DOF),避免把整个Pass塞进低分辨率,深度权重最好在全分辨率或半分辨率下计算再下采样。
3.3 合并Pass和Framebuffer技巧:少搬几次,搬小一点
后处理带宽消耗的大头是“Pass之间数据往返”。如果能减少中间RT的总量,或者把多个Pass合并到一个Shader里,就能显著降低搬运量。自己在写后处理Shader时,可以把“采样多个输入 + 完成多个输出”写在一个Kernel里,用Compute Shader一次搞定多级模糊或混合,避免逐级Blit。
移动端还要注意Render Pass的切换开销。TBR架构下,频繁切换不同RT会让GPU在“片上Tile”和“DRAM回写”之间反复横跳。尽量把需要读写同一张RT的Pass放在同一个RenderPass里,或者使用移动端Vulkan的SubPass/FrameBuffer Feedback机制,让上一个Pass的输出能直接作为下一个Pass的输入,而不用先写回DRAM再读出来。这条优化在Unity里主要靠Renderer Feature和CommandBuffer的规划,在自定义引擎项目里则要贴近渲染硬件去做Pass调度。
另外一个实用技巧是“按区域/按物体开启后处理”。整张屏幕并不是时时刻刻都需要后处理,比如只有主角身上有特殊发光效果时,可以用Stencil标记发光区域,Bloom只处理标记区域。又或者场景中大部分时间没有烟雾和水中倒影,就根据摄像机所在区域动态开关对应RenderFeature。改完这部分之后,我可以负责任地讲:帧率未必变高,但发热体感会明显改善,因为全屏级的数据往返少了一大截。
4. 实测记录:从一个发烫Demo到降温5℃
4.1 量数据,不靠手感,靠Profiler
我经手过的一个Unity手游Demo,问题非常典型:场景美术堆了大量PBR材质,有很多4K贴图,后处理默认开着Bloom、DOF、Motion Blur和ToneMapping,在中端安卓机上跑30分钟就烫手。项目负责人第一反应是“Shader写得太重”,但打开Profile一看,Shader ALU占用只有35%,而Texture Read带宽高达约12GB/s,Framebuffer Write带宽约3.5GB/s。这个数据说明,问题根本不在计算,而在搬运。
移动端做这类排查,安卓上推荐用Android GPU Inspector(AGI)和Snapdragon Profiler;iOS上可以用Xcode的GPU Frame Capture + Metal System Trace。AGI能抓取GPU Counter中的Texture Read Bytes、Framebuffer Write Bytes、Shader Cycles等关键指标。如果没有厂商工具,Unity里的Profiler + Frame Debugger也能大致看出哪些Pass在频繁Blit整屏缓冲。重要的是先量化,再动手,否则很容易被视觉上“看起来很复杂”的效果带偏方向。
4.2 优化改动和最终对比
针对这个Demo,我们做了两件事:第一,把所有非关键贴图从RGBA8888转成ASTC 6x6,并确保3D材质全部开启mipmap;关键金属材质里的Normal贴图单独保留ASTC 4x4,避免细节损失。第二,重写Bloom链:原来4个全分辨率Pass,先改成1/4分辨率提取亮部,再用双层金字塔模糊,最后半分辨率合成;同时关掉了对玩法毫无影响的Motion Blur。
优化后再抓一次数据:Texture Read从12GB/s降到了6.1GB/s左右,Framebuffer Write从3.5GB/s降到2.2GB/s。持续跑30分钟,手机背面温度从之前的46℃降到43℃左右,整机功耗表上大概从4.6W降到3.7W。玩家体感上“烫手”变成了“温热”。画面方面,只有金属高光的轮廓在特定角度下能看出轻微区别,调节Bloom阈值后基本不可感知。
那次实践后我最大的体会是:不要盲目追求全特效最高画质,玩家优先感受到的是机器烫不烫、卡不卡、电掉得快不快。把纹理压缩和半分辨率后处理这两件事做好,等于拿回了大量功耗预算,这些预算完全可以再投入给更有价值的画面细节。
4.3 后处理与纹理优化其实不只属于游戏
说到“后处理”和“搬运量”,我偶尔会串到别的领域。比如做AI目标检测时,YOLO的NMS(非极大值抑制)和阈值过滤被称为“后处理流程”,它同样要对海量候选框做全量遍历和排序,判断不当会成为整个pipeline的性能瓶颈。再比如遥感领域的ENVI提取纹理特征,三维重建的OpenMVS生成纹理贴图,甚至数控加工里UG后处理判断4轴变化与Z轴回零规律,本质上都是在“主计算结束后,对结果做二次加工”的环节。
这些领域看起来毫无关系,但底层逻辑高度一致:先想清楚“中间结果有多大、要写多少次、被读多少次”,再决定是压缩、降采样还是用并行归纳减少遍历。所以说,纹理和后处理是“搬运量最大的惯犯”这个结论,放到更广的计算体系里也仍然成立。
5. 常见问题、避坑清单与排障实录
5.1 症状与原因对照表
| 症状 | 可能原因 | 推荐处理 |
|---|---|---|
| 手机一跑场景就烫,但Shader复杂度看不出问题 | 纹理格式未压缩、纹理尺寸过大、mipmap未开启 | 转ASTC/ETC2,开启mipmap,检查纹理导入设置 |
| 开启后处理特效后帧率骤降、发热明显 | 后处理Pass过多、RT尺寸太大、全屏多次读写 | 合并Pass、半分辨率处理、关闭非必要效果 |
| 纹理转ASTC后出现色斑和边缘模糊 | block size过大或sRGB空间处理不当 | 降低block size(改用6x6或4x4),检查sRGB/线性空间 |
| 开了mipmap后内存暴涨 | 对UI/图集也开了mipmap,或场景纹理数量太多 | 关闭UI的mipmap,限制3D贴图总尺寸,使用纹理流式加载 |
| 半分辨率后处理边缘闪烁、出现断线 | 降采样前未做低通滤波,或深度相关效果分辨率不一致 | 增加降采样前滤波,对深度效果单独用半分辨率处理并修复深度权重 |
| 温度还是压不住,带宽也看了没有爆炸 | 可能瓶颈在顶点数、DrawCall、CPU逻辑或GPU ALU | 用Profile分层定位,不要只盯纹理和后处理 |
5.2 几个新手容易忽略的细节
纹理压缩之后发灰或发紫,很多时候是颜色空间问题。线性空间的法线贴图如果按照sRGB采样,结果会完全乱掉;但某些压缩格式会默认修改颜色通道顺序,需要做对应的重映射。比如常用的法线重映射会把蓝色通道从[-1,1]映射到[0,1],在Unity里表现为“Normal Map”勾选项,千万不要漏。
另一个细节是移动端尽量不要用BC系列压缩纹理,比如BC1/BC3。BC压缩本来是给桌面显卡准备的,虽然Visual效果不错,但移动GPU对BC的支持非常差,运行时可能直接解码到内存里,带宽反而更大。检查一下Build后的纹理信息,如果看到BC开头格式,多半是导入配置没针对移动端做覆盖。
还有后处理里的“高动态范围”也会吓一跳。HDR RT通常是FP16格式,一个像素占8字节,比RGBA8翻了一倍。如果后处理链在HDR空间里做了多遍模糊,带宽数字会明显飙升。移动端没有绝对必要的话,可以用“中小范围HDR + 快速色调映射”策略:半分辨率下做HDR特效,最终合成时再处理成LDR,发热会好很多。
5.3 给团队和个人的一条可落地优化顺序
如果你现在接手一个发烫严重的项目,我可以给个保守但有效的顺序。第一步,导出当前APK/IPA,用Profiler抓一次GPU Counter,确定Texture Read和Framebuffer Write两项的数值。第二步,把所有3D纹理资源检查一遍:是否ASTC、是否mipmap、是否过大的无意义贴图,改完再抓一次数据。第三步,打开后处理特效列表,把Bloom和DOF先用半分辨率跑起来,能关的就先关,再看温度和帧率曲线变化。第四步,如果温度还是不行,才去检查Shader指令数、顶点数、光源数量和CPU主逻辑。
一次只改一项,记录前后温度和功耗变化,不要同时把十个优化一起上。相信我,每次只动一个变量,你才能真正知道是哪个环节省下了热量。这样做也能避免团队里“我昨天优化也没问题”这种说不清的争端。
说到底,纹理和后处理这两个惯犯,本质上都是“数据搬运量”问题。只要在项目里多花半天时间把搬运数据量化清楚,再按压缩、降分辨率、合并Pass的顺序去处理,发热问题通常会迎刃而解。如果你也被发烫优化折磨得头疼,不妨先把这一帧要从DRAM搬多少纹理数据、后处理Pass要倒腾多少次全屏缓冲数一遍,答案清晰之后,该动哪里自然就清楚了。