纹理和后处理的确是最典型的“搬运大户”。在移动端,GPU和显存(或者说是共用内存)之间的带宽,往往比GPU本身的算力更早成为瓶颈。很多时候你觉得自己瓶颈在像素着色器太复杂,把Shader翻来覆去优化半天,帧率纹丝不动,结果把纹理格式从RGBA32换成ASTC,帧率突然就上去了,手机也不烫了。这篇文章我就结合最近的优化日志,把这两个“惯犯”的作案手法、取证方式和整改方案一次说清楚。
1. 发烫的物理课:为什么“搬运”比“计算”更费电
先从最底层的物理逻辑说起。手机SoC里,GPU核心和内存(DDR)是两颗不同的芯片,数据在它们之间传输的通道就是带宽。GPU要渲染一帧画面,必须从内存里把纹理数据、顶点数据、帧缓冲数据全部“搬”到GPU内部,处理完再“搬”回去。这个搬运过程,每一比特数据的移动,都要给总线充电、给内存刷新,单位能耗比GPU内部做一次浮点运算高出一个数量级。
所以“发烫优化”的核心,很多时候不是减少GPU运算量,而是减少数据搬运量。
功率公式可以简化理解:( P \propto C \times V^2 \times f ),其中C是负载电容——对于内存总线来说,负载电容非常大,因为它要驱动整条物理走线。带宽占用越高,单位时间内翻转的比特数越多,功耗就呈线性甚至超线性上涨。这在手游上的体感就是:玩《原神》这种开放世界,模型面数并不比某些PC大作高多少,但手机烫得快,核心原因之一就是移动GPU要持续搬运超高分辨率的纹理和整屏特效缓存。
用生活化类比:GPU内部计算好比在厨房里切菜,内存搬运好比从超市往家里搬菜。切菜再快,如果买菜车一次只能装两棵白菜,整个做饭流程还是快不起来,而且来回跑路消耗的体力(功耗)远大于切菜本身。纹理和后处理之所以是“惯犯”,就是因为它们每个帧都要不间断地搬运巨量数据,而且搬运次数往往还不止一次。
2. 纹理的搬运账单:从RGBA32到ASTC的带宽算术
纹理是所有渲染资源里体积最大的一类。一张2048×2048的RGBA32纹理,未压缩时占16MB显存。你可能觉得16MB不算什么,但关键不在容量,而在采样时的带宽消耗。
2.1 纹理带宽的精确计算公式
每帧纹理带宽 = 采样次数 × 每次采样的数据量。
一个典型移动端游戏帧,假设场景里有200个物体,每个物体平均被5张贴图影响(Albedo、Normal、ORM、Mask、Lightmap),每个物体每帧在像素着色器里平均采样30次,屏幕分辨率是1125×2436(iPhone X级别),每个像素都跑一次像素着色器。粗略算一下:
- 屏幕像素数:约274万
- 总采样次数:274万 × 30 ≈ 8220万次
- 如果所有纹理都是RGBA32(每像素32bit即4字节),采一个bilinear的mipmap层级纹理,实际读取的数据量要考虑缓存命中率,移动端GPU纹理缓存命中率通常较高,但即便是命中率70%,每帧搬运的纹理数据量轻松超过200MB
200MB每帧,60帧就是12GB/s的带宽占用。手机内存带宽(LPDDR4x双通道)大约在34GB/s左右,GPU还要同时处理顶点数据、帧缓冲读写、后处理pass,很快就撞上带宽墙。撞墙后GPU被迫stall,占用率上不去,帧率暴跌,手机发烫——因为总线一直在满负荷做无效功。
2.2 ASTC压缩的真实收益
换用ASTC 6×6块压缩(每像素约0.89字节),同样的采样量,带宽直接从12GB/s降到约3GB/s,直接省掉75%。这就是为什么现在的移动平台强制要求纹理用ASTC或者ETC2。
具体的对照表:
| 纹理格式 | 每像素占用 | 2048×2048体积 | 相同采样量下的带宽权重 |
|---|---|---|---|
| RGBA32 | 4字节 | 16MB | 1.0(基准) |
| RGBA16 | 2字节 | 8MB | 0.5 |
| ETC2 RGBA | 0.67字节 | 2.7MB | 0.17 |
| ASTC 6×6 | 0.89字节 | 3.6MB | 0.22 |
| ASTC 4×4 | 0.4字节 | 8MB | 0.5 |
| ASTC 8×8 | 0.5字节 | 2MB | 0.125 |
注意ASTC 4×4每像素0.4字节?不对,通常ASTC 4×4是1字节/像素,6×6约0.89字节/像素,8×8约0.5字节/像素。
ASTC块越大,压缩率越高,但画质损失也越大。实际优化时的经验:
- 不需要Alpha通道的贴图,优先ASTC 8×8,画质损失在移动屏幕上肉眼几乎不可见
- 需要Alpha的UI贴图,用ASTC 6×6,兼顾边缘质量
- 法线贴图要求精度高,建议ASTC 5×5或者4×4
- 如果项目的最低端机型还在用不支持ASTC的老芯片(比如某些低端ARM Mali早期型号),保守方案是ETC2
2.3 纹理尺寸与mipmap的搬运数学
用ASTC 8×8压缩一张4096×4096的贴图,体积约8MB,一张就顶16张1024×1024的贴图。这里必须强调:体积和带宽有关,但真正要命的其实是mipmap缺失。
没有mipmap的纹理,在透视缩小的情况下,GPU会采样原始高清mip层级的纹理并做bilinear过滤。此时缓存命中率跌到惨不忍睹,因为远处物体占屏幕面积小,采样点在原始纹理上的分布却跨越巨大物理跨度,缓存一行载入的数据只有少量被实际使用,有效带宽利用率可能跌到30%以下。而正确生成mipmap后,远处物体自动采小mip层,每帧搬运的数据量可以降低90%。
所以很多项目一上来就抱怨“贴图太多,烧带宽”,排查后发现根本不是贴图数量问题,而是trilinear filter的mip bias设置错误或者干脆没生成mipmap。你只需要在导入设置里勾上Generate Mip Maps,然后将LOD Bias尽量接近0,就白赚一大截带宽。
2.4 别忽略UI和粒子纹理
UI是纹理搬运的重灾区。一个复杂UI界面,大量九宫格拉伸的图、带阴影的按钮、动态数字图片,每帧都要全屏刷新。粒子系统每帧生成几千个粒子,每个粒子采样一张128×128的光点贴图——单个不大,但粒子数量叠加上去后,采样次数非常夸张。
优化方式除了压缩格式,还有一个隐蔽技巧:UI缓存为单张RT。如果UI层没有严重动态变化(比如战斗HUD中只有血量数字在变),可以把整个UI面板烘焙到一张RT上,每帧只搬运这张RT,而不是几十上百张UI贴图。血量数字变化时,只重绘该区域,或者用局部Update。
3. 后处理链路:一帧被搬了N遍的“半成品”
后处理是另一个搬运量漏洞。它的本质是“多Pass串行处理”——每个Pass都要读写整张屏幕RT。假设你的后处理链有5个Pass:Bloom提亮 → 高斯模糊横向 → 高斯模糊纵向 → ToneMapping → 色差/暗角。那这一帧除了主场景渲染,还有5次全屏RT读取+5次全屏RT写入。
我们算笔账:主渲染分辨率为1125×2436,每像素RGBA16(后处理RT一般用半浮点),每张全屏RT的读写带宽是 1125 × 2436 × 2字节 × 2(读写) ≈ 11MB。5个Pass就是55MB,注意这是每帧的增量。60帧时额外占3.3GB/s带宽。很多后处理链还远不止5个Pass,美术一加再加大佬疯狂,最后光后处理吃掉20GB/s都不稀奇。
3.1 最容易忽略的RT类型开销
很多团队后处理RT无脑用RGBA16或RGBA32F。其实大部分后处理效果用R11G11B10_FLOAT(每像素4字节,但精度足够)就能满足,亮度范围可以覆盖到大概±64000,对HDR的亮度范围足够。RGBA16是每像素8字节,R11G11B10只有4字节,直接省一半。不需要Alpha通道的后处理链,用R11G11B10一点问题没有。
3.2 半分辨率后处理不是“降画质”,是“省预算”
Bloom、景深、运动模糊这类模糊类后处理,数学上本来就是低通滤波。你可以在半分辨率(甚至四分之一分辨率)下做完整的模糊链,再上混合回主RT。人对高频纹理的细节很敏感,但对“模糊的模糊”没有任何感知。这个降分辨率操作能把模糊类后处理链的带宽消耗压缩到原来的1/4甚至1/16。
以Bloom为例,错误的做法是:
全分辨率RT → 全分辨率提亮 → 全分辨率模糊5次 → 全分辨率合成正确的做法:
全分辨率RT → 降采样到1/4分辨率 → 提亮 → 模糊2-3次 → 上采样到全分辨率 → 合成(合成时加回原场景)提亮和模糊都在低分辨率下做,上采样混合后再做ToneMapping。视觉差异极小,但带宽消耗差了近一个数量级。
3.3 Pass合并的艺术:来回切换最致命
后处理链里最耗性能的不是计算,而是RT切换。GPU在渲染Pass之间切换RenderTarget时,需要flush管线、更新tile内存、同步数据,带来严重的流水线气泡。移动端基于Tile-Based架构的GPU(Mali、Adreno)尤其厌恶这种切换,会丢失On-Chip高速缓存中的数据,被迫写回DDR。
实际优化手段:
- 合并相邻Pass:如果两个Pass只是做颜色空间的简单线性变换(比如LUT调色、伽马校正),可以合并成一个Shader,在一个Pass里完成,避免两次RT读写
- 利用FrameBuffer Fetch:在移动端(尤其是Mali GPU)可以使用framebuffer_fetch扩展直接读取当前像素的值,无需额外采样RT,让若干Pass可以在同一个RT上原地计算
- 避免频繁切换半/全分辨率RT:尽量把同分辨率的Pass排在一起,先做完所有全分辨率Pass,再统一做半分辨率Pass,减少分辨率切换引发的内存重绑定
3.4 关于In-Game后处理裁剪
还有一条战术优先级问题:不是所有后处理都必须每帧全屏跑。
- 景深:只有镜头对焦变化时才需要,平时可以缓存结果甚至直接关闭
- 运动模糊:只在高速移动时开启,静止视角直接跳过
- 色差/暗角:属于静态全屏效果,根本不需要实时计算,直接预烘焙到一张覆盖层纹理上
- 泛光(Glow):适配不同亮度区域时可以用阈值控制,只处理超过阈值的像素,亮度不足的区域直接跳过
这些“视情况开关”的优化后处理,是对带宽最大的尊重。
4. 一场真实的带宽排查:从Profiler数据到纹理替换
光讲道理没用,把排查链路完整走一遍,才能找到最终能落地的优化方向。
4.1 用Profiler找出搬运瓶颈
我习惯用Unity Profiler + Xcode Instruments(Metal System Trace)/ Snapdragon Profiler双管齐下。Unity Profiler能看到DrawCall、三角面和SetPassCall,但看不到真实的带宽占用,只能看到GPU时间。
- 如果GPU时间高,但DrawCall不高,基本可以怀疑是带宽或像素填充率问题
- 用Xcode的Metal System Trace可以看到每个RenderPass的带宽消耗和GPU利用率;Adreno的Snapdragon Profiler会显示总线负载,Mali可以用Streamline
- 如果总线负载长期超过50%,带宽基本就是瓶颈了
4.2 纹理替换的实测数据
最近处理的一个项目,场景里有大量建筑外立面贴图,都是2048×2048的RGBA32,明显是为了法线贴图和Metalness贴图分开存而保留了Alpha通道。整个场景显存瞬间飙到2GB,总线负载常年在70%以上,手机游戏运行5分钟就发烫降频。
我的排查步骤:
- 把打开场景前后显存占用和总线负载数据记录下来(显存从1.2GB涨到2.1GB)
- 用Unity的Memory Profiler按“Texture”排序,找出体积最大的top 20贴图
- 发现大量2048×2048 RGBA32的建筑贴图,用Texture Analyzer分析后发现Alpha通道全是1(没有实际用途)
- 将这些贴图改为ASTC 8×8,且把Shader里不需要Alpha的采样改成三通道采样
结果:
- 显存占用降到约700MB
- 总线负载从70%降到35%
- 帧率从42FPS稳定到60FPS
- 手机温度从46℃降到39℃
你以为这就完了?没有,还有更隐蔽的坑……
4.3 最隐蔽的坑:体积纹理和探针
除了常规2D纹理,CubeMap和3D纹理(体素化光照、体积雾的3D纹理)消耗超级大,因为它们天生就是“多面”或“多层”。一张1024×1024的Cube用ASTC 12×12压缩后仍然有320KB左右,关键是采样的时候,GPU需要跨多个面做三线性插值,缓存命中率极低。尤其反射探针,一个场景挂8个反射探针,每个都生成CubeMap,带宽直接爆表。
这部分我个人的做法是:
- 反射探针数量压到2个以下,且CubeMap分辨率用128或256
- 远处的探针用SH(球谐光照)替代
- 体积雾的3D纹理控制低分辨率(比如64×64×32),且用ASTC压缩(部分GPU支持3D纹理ASTC,不支持的低端机型直接砍掉体积雾效果)
4.4 纹理流送(Texture Streaming)的实用逻辑
纹理流送相当于给纹理搬运做一个“饥荒管理”。核心思路:只加载当前视野内需要的纹理mip层,视角拉远后卸载高mip层,回到近景时再动态加载。Unity的Texture Streaming API(Texture2D.SetStreamingTexture)可以配合LOD系统实现mip分级控制。经验值:把最远mip层限制在256或512分辨率,近景最高4K,这套组合能显著降低内存中的纹理滞留,间接降低带宽压力。
但要注意:流送有延迟,如果玩家瞬移镜头,会出现贴图突然模糊然后清晰的过程。解决方式是在瞬移时禁用流送,强制加载完整mip。不要在战斗激烈时搞流送,不然会出现敌人消失的奇观。
5. 后处理管线重构:一个4Pass改2Pass的完整案例
说完纹理,把后处理管线优化也走一遍,这是我踩过坑之后总结出的经验。
5.1 原有管线的问题
一个赛车项目的后处理链原本是这样:
RT_A = 主场景渲染(RGBA16) RT_B = Bloom提亮(全分辨率RGBA16) RT_C = 横向模糊(全分辨率RGBA16) RT_D = 纵向模糊(全分辨率RGBA16) RT_E = Bloom合成回RT_A(全分辨率RGBA16) RT_F = ToneMapping + 色差(全分辨率RGBA16) RT_G = 暗角合成 + UI(全分辨率RGBA16)这有7个RT,每帧写7次、读6次,光后处理每帧的带宽约 1125×2436×8字节×13 ≈ 285MB。60帧就是17GB/s——直接吃满整条总线!
5.2 重构方案
我做的调整:
- 分辨率分级:主场景RT用R11G11B10_FLOAT,Bloom的提亮和模糊全部降到1/4分辨率(281×609——不是,是282×610左右)
- Pass合并:把ToneMapping和色差合并成一个Shader的两个Pass(利用同一个RT、一个Pass输出到RT_F,另一个Pass直接用FrameBuffer原地处理)
- 合并暗角:暗角直接预烘焙成一张全屏UV网格纹理,在UI合成Pass时叠加,不开额外Pass
- 减少写次数:Bloom提亮和第一次模糊合并在同一个Pass里(提亮结果直接写入半分辨率RT,省去一次全屏读写)
- 利用Framebuffer Fetch:ToneMapping + 色差在碎片着色器里直接读取上一Pass的颜色值,无需单独的采样纹理
重构后的管线:
RT_A = 主场景渲染(R11G11B10) RT_B = 降采样 + 提亮 + 第一次模糊(1/4分辨率R11G11B10) RT_C = 横向模糊(1/4分辨率R11G11B10) RT_D = 纵向模糊(1/4分辨率R11G11B10) RT_A = 上采样合成回主RT(R11G11B10) RT_F = ToneMapping+色差+暗角(同一Pass,R11G11B10)实际等效Pass数由5个减到2个,模糊部分等效分辨率在1/4下运行,带宽计算:
- 全分辨率两个Pass(主场景 + ToneMapping):1125×2436×4字节×4 ≈ 44MB
- 半分辨率/低分辨率三个Pass:282×610×4字节×6 ≈ 4MB
总共约48MB,比原来的285MB降低83%。实际帧率提升约20~25%,温度直观下降3-5℃。
5.3 关于HDR和Color Grading的取舍
很多团队看到网上教程就照搬“ACES ToneMapping + 完整Color Grading + LUT”。但移动端后处理链每多一个Pass,都是一笔带宽。ACES是算法开销,不额外吃带宽(本质是一个复杂的像素着色器),但对低端机来说像素着色器会拉高GPU ALU占用,间接提高功耗。
权衡后我的建议是:
- 中低端机:用Uncharted2或Reinhard近似曲线做ToneMapping,省ALU但保留无私
- 高端机:ACES + LUT(LUT贴图256×16,体积小,带宽可忽略)
- Color Grading不要太嗨,移动屏幕的色彩管理本身就很复杂,调色过度反而失真
6. 一个容易忽略的补充:后处理与Tile-Based GPU的相处之道
移动GPU(Adreno、Mali、PowerVR)都是Tile-Based架构,它们有On-Chip的高速缓存(Tile Memory)。如果一个Pass写的RT,在下一个Pass立即被读取,并且分辨率完全一致,那这块Tile数据可以留在On-Chip,不需要写回DDR,实现零额外带宽。
这个特性的应用方式:
- 后处理链中相邻Pass分辨率一致时,GPU可以自动复用On-Chip Tile Memory,不需要出DDR
- 前提是使用framebuffer_fetch或者subpass机制(Vulkan的Subpass),否则你仍然在读写DDR
- 一旦中途换了RT(比如从全分辨率降到半分辨率),Tile Memory就失效了
所以当你在移动端做后处理时,尽量保持分辨率阶梯的一致性:全分辨率阶段一口气做完所有全分辨率效果,再降到半分辨率做模糊类效果,一次性做完再升回全分辨率。不要“升-降-升-降”来回折腾,那是给总线放血。
Unity里用CommandBuffer.Blit或RenderGraph的Subpass即可实现。Metal里可用的framebuffer_fetch通过 [[color(0)]] 输入修饰符实现,Vulkan支持输入Attachment,OpenGL ES 3.1支持GL_EXT_shader_framebuffer_fetch。
7. 最终自查清单:纹理和后处理的搬运量整改表
优化完所有环节后,把这张清单当成最终质检表,逐项打勾,缺哪项补哪项。
| 检查项 | 具体动作 | 收益等级 |
|---|---|---|
| 纹理格式 | 所有无Alpha纹理切ASTC 8×8,有Alpha切6×6 | 高 |
| 纹理Mipmap | 有透视缩小的纹理全部开启Mipmap并设合理LOD Bias | 极高 |
| 纹理尺寸上限 | 限制单张纹理上限(场景贴图2K,UI贴图1K) | 中 |
| 纹理图集 | 大量小贴图合图集,减少切换纹理的缓存失效率 | 中 |
| 反射探针 | CubeMap分辨率限制128或256,数量压到2个内 | 高 |
| UI缓存 | 静态UI烘焙RT,动态区域局部更新 | 高 |
| 后处理RT格式 | 用R11G11B10_FLOAT/ R8G8B8A8替代RGBA16 | 高 |
| 半分辨率后处理 | Bloom/景深/运动模糊在1/4分辨率下运算 | 极高 |
| Pass合并 | 尽量一个Shader内做多步运算,减少RT切换 | 高 |
| Framebuffer Fetch | 相邻Pass使用On-Chip数据,避免DDR读写 | 极高 |
| 后处理裁剪 | 非必要效果(色差/暗角/景深)低频或预烘焙 | 中等 |
最后分享一点个人体会。纹理和后处理的优化,核心思路永远是“减少搬运次数,减少单次搬运体积,提升搬运效率”。这三个方向分别对应:合并Pass/降分辨率、换压缩格式/调RT类型、保持分辨率阶梯一致/利用On-Chip缓存。每次做性能优化时,先在脑子里过一遍这三个维度,很多方案的取舍就自动浮现了,不必每次都从零开始翻文档。