如果你也是做移动端渲染优化的,一定对这种画面不陌生:手机跑了一会儿,后盖先热起来,帧率像坐过山车,电池百分比肉眼可见地往下掉。打开 Profiler 一看,GPU 占用率高高挂起,再往下翻,十次里有七八次都是两个老熟人在带头搞事——纹理采样和后处理。这期发烫优化系列已经写到第 4 篇,前面聊了不少从系统调度、帧率策略、功耗监控入手的思路,今天我把镜头对准这两个“搬运量”最大的惯犯:它们看起来不像复杂的逻辑代码,也不像密集的 CPU 计算,但每帧搬的数据量,足以把移动端 GPU 的带宽烧穿。
这篇不是教你把画质无脑调低,恰恰相反,我要说的是:在视觉效果基本不变的前提下,怎么从纹理格式、采样方式、后处理 Pass 的组织方式上,把 GPU 每帧需要搬的数据量砍下来,发热和功耗自然跟着下来。不管你是做 Unity、UE,还是直接写 Metal 或 Vulkan 底层,这套思路都通用。适合哪些人?就是正在被项目发热、掉帧、功耗问题反复折磨的客户端渲染工程师、游戏性能优化工程师,尤其是移动端项目刚起步就踩了温控坑的团队。看完这篇,你至少知道该从哪里下手,能省多少东西,以及哪些优化优先级最高。
1. 先给“搬运量”算笔账:为什么纹理和后处理最容易烧电
CPU 快不快看主频和核心数,但 GPU 发热不发热,其实很大程度看它一秒钟内部搬了多少数据。移动端 GPU 做得再强,功耗墙就摆在那里,内存带宽也有限。这里说的“搬运”,主要是三种数据:纹理数据从内存/显存里搬到纹理单元,渲染目标在帧缓冲里读进写出,以及顶点数据一遍遍过管线。纹理和后处理,恰好是最容易被指数级放大的两个环节。
1.1 GPU 的搬运量到底是什么
你可以把 GPU 想象成一个快递中转站,一堆纹理贴图就是仓库里的货物,后处理 Pass 就是一条条需要反复上料、卸料的流水线。CPU 算得再快,如果仓库里取货、搬货的动作太多,整个站的吞吐能力还是会被卡住。而且这个“搬运”本身是实实在在耗电的:数据总线翻转、缓存填充、内存读写,每一步都在消耗能量。搬同样的货,搬的次数越多、单次越重,功耗就越高。
画面上更直白一点:一个 1080p 的全屏特效,假设渲染目标是 RGBA16F,每像素 8 字节。读一次再写一次,就是 1920×1080×8×2,约等于 33MB。如果一秒 60 帧,就是每秒 2GB 左右的搬运量。这还只是一个全屏 Pass。后处理链一多,动辄就是五六个 Pass,每帧几个 GB 的数据在 GPU 内部来回倒腾。带宽一旦跑满,GPU 核心只能干等数据过来,功耗降不下来,发热就成了必然结果。
1.2 纹理采样和后处理分别在“烧”什么
纹理侧的消耗,主要来自三块:纹理采样和过滤、纹理缓存命中率、各向异性过滤的额外采样。很多场景角色身上的漫反射贴图动不动就是 2048,特效贴图一堆 1024,每个像素采样四五层,叠加起来带宽直接爆。更隐蔽的是缓存命中率——如果同一块纹理在帧内被反复以随机位置采样,缓存基本形同虚设,每帧都从主存里重新拉数据,发热就很明显。
后处理侧的消耗,核心就一句话:渲染目标的读加写。一个 Bloom 效果,可能要先做高光提取,再做 3 到 5 次降采样模糊,最后再升采样合并,加起来就是 10 来个全屏 Pass。每个 Pass 都相当于把一整幅画搬进仓库、搬出仓库、再搬进去。PC 端因为带宽大、散热强,经常能掩盖这些问题;移动端没这个资本,所以中招的永远是手机。
1.3 换个视角:其他领域也在处理“纹理”和“后处理”
有意思的是,很多不搞实时渲染的领域,也在天天和这两个词打交道,而且遇到的问题本质是相通的。遥感图像处理里有个概念叫“提取纹理特征”,像 ENVI 这类软件会通过均值、方差、对比度、GLCM 等统计量把地物类别分出来。纹理在那里代表的是信息量,纹理越碎越密,信息量越大,需要的处理资源也越多。这跟渲染里“高频纹理会带来更大采样开销”是同一个逻辑。
再比如三维重建领域,OpenMVS 这类开源算法在做纹理贴图时,会专门做纹理块打包和重叠优化,目的就是让生成出来的贴图尽量少、尽量紧凑,否则模型一出炉就是几十张 2K 贴图,加载慢、渲染卡。而工业 CAM 领域里的“后处理”,比如 Hypermill 和 UG 的后处理制作,是把刀路转成机床能认的 NC 程序,涉及轴变换、循环、条件判断,像“4 轴变化时 Z 轴回零”这种安全逻辑,本质上就是一种带条件的指令生成。AI 推理里的 YOLO 后处理流程也是,置信度过滤、NMS 合并、坐标解码,全是在整理和筛选数据。
这些跨界例子放到一起就能看出一个规律:凡是叫“后处理”的环节,都容易成为性能瓶颈,因为它们在承担整理、筛选、转换的任务时,往往要把大量数据从头到尾过一遍。图形渲染里的后处理链,也不过是这一规律的又一个现场罢了。
2. 纹理优化实战:从源头把搬运量打下来
直接进入操作。纹理这块优化属于典型的“改一行、省全部”,收益立竿见影,只要路径对了,基本不需要犹豫。
2.1 纹理压缩:最划算的一刀,选对格式就成功一半
首先要明确一点:GPU 纹理压缩和我们平时用 ZIP 压文件完全是两回事。纹理压缩格式的设计目的,是让 GPU 在采样时不用先解压整张图,而是能直接按块读取。正因为如此,格式选对选错,对渲染效率的影响极大。
移动端现在的主流选择是 ASTC,Android 和 iOS 都支持,可以按 block 大小控制压缩率,从 4x4 到 12x12,越高压缩率画质损失越大。我给个比较稳的参数参考:普通颜色纹理(sRGB 图)用 ASTC 6x6 或 8x8 通常肉眼不太看得出区别;法线纹理这种对精度敏感的,最低建议到 ASTC 5x5,压得再狠就容易出现光照断层;Mask、数据类纹理尽量少压,用 4x4 或者单独定制会更稳。老安卓机型如果担心兼容性,ETC2 是 OpenGL ES 3.0 的标配,可以作为回退方案。PC 和主机平台则是 BC 系列的天下,BC3、BC5、BC7 各管一摊,具体看平台和纹理用途。
为什么要这么在意压缩格式?看一组数字就懂了。一张 1024×1024 的 RGBA8888 纹理,不压缩是 4MB,转成 ASTC 6x6 后大约是 0.44MB,差了快 9 倍。项目里一百张贴图,光内存就能省出一大块。内存少了,缓存命中率上去了,纹理带宽下降,发热自然跟着降。这还不算显存带宽的压力,后者才是最直接的发热来源。
注意:压缩格式一定要确认目标平台真正加载的是压缩格式,而不是回退成 RGBA。有些引擎在平台不支持指定格式时宁可自动转 RGBA,也不报错,表现就是内存爆涨、GPU 烫得离谱,但你还以为是代码问题。
还有一个容易被忽略但很实用的思路,就是控制贴图的场景内占比。一张 2048 的特效贴图,如果最终在屏幕上只占到 1/4 区域,那 1024 完全够用。把“贴图尺寸跟着屏幕占比走”当成一个基本纪律,比事后发现烫了再降更高效。
2.2 mipmap 和各向异性过滤:不只是画质选项,更是省电开关
mipmap 这个功能,很多项目在导入设置里开了就算完,没细想过它到底在干嘛。mipmap 会预生成一系列逐级减半的纹理,比如 1024 生成 512、256、128……一直到 1×1。这样做有两个核心好处:一是避免远处物体采样时出现摩尔纹和闪烁,二是大幅节省纹理带宽。当物体离相机远时,GPU 会自动选择更小的 mip 级别,单次采样覆盖更多屏幕像素,相当于“一次搬小货”,搬运量自然小得多。
代价也很明确,内存约增加 1/3,因为 1 + 1/4 + 1/16 + … 约等于 1.33。但绝大多数场景里,这点内存换来的带宽收益远远超过成本。真正不适合开 mipmap 的,是 UI 贴图和图集。尤其是图集,一张图集里塞了几十个不同大小的小图,它们的 mip 链会互相污染,远处缩小采样时很容易出现奇怪的边缘渗透问题,宁可不开或者单独处理。
各向异性过滤则是另一个双刃剑。它能提升斜向观察纹理时的清晰度,但代价是额外的采样次数。16x AF 在某些移动端 GPU 上开销并不小。我的建议是移动端 4x 足够,PC 端可以开到 8x 或 16x,但如果项目本身发热敏感,优先降 AF。很多工程师一上来就喜欢把所有纹理选项拉到最高,实际上在 5 到 6 英寸的屏幕上,4x 和 16x 的视觉差异远没有想象中明显。
2.3 怎样识别纹理瓶颈:给贴图做一次“特征画像”
先把思路从 ENVI 提取纹理特征上借过来。在遥感影像分类里,纹理特征是一种统计量,它帮你判断一片区域是高频复杂纹理还是低频平坦区域。渲染优化也一样,我们要找的是场景里那些“高频高复杂度”的贴图:纹素密度高、细节多、在屏幕上被频繁采样,而且往往还加了 AF 的贴图。它们才是真正的带宽杀手。
具体怎么找?Unity 里可以用 Profiler 的 Rendering 模块看 texture streaming 和 GPU upload 的变化,用 Frame Debugger 看当前 drawcall 用了哪张纹理、采样了几层;Android 平台用 Snapdragon Profiler 或 Android GPU Inspector(AGI)看带宽模块里 texture fetch 的字节数;iOS 上用 Xcode 的 GPU Frame Capture 查纹理资源和 bandwidth usage。关键不是看尺寸,而是看这张贴图在屏幕上“最终画出来占多大”。去 RenderDoc 或 Xcode 里看每个 draw 的 target size,如果一张 2048 贴图最后只画在屏幕 1/8 大小的区域,那它要么是细节冗余,要么就是因为没开 mipmap 导致的过度采样。
这里分享一个我自己常用的方法:把场景里所有纹理按“占用 GPU 带宽估算”排序,公式简化为:纹理尺寸 × 采样次数 × 在屏幕上的影响范围。先砍排名前五的,基本就够忙活一阵了。越是项目后期,越要养成这种给贴图做画像的习惯,否则等到发热了再全场检索,效率极低。
3. 后处理优化实战:少搬一趟就是赚到
后处理是全场景渲染里最后一道工序,也是最能藏画面质感的地方。Bloom 一开,整体氛围立刻不一样;DOF 一上,镜头语言马上高级。但这份漂亮是有代价的,而且代价往往被严重低估。
3.1 每一次全屏 Pass,都是一趟全量搬运
先算笔账。一次全屏 Pass 的成本,大约是渲染目标宽 × 高 × 每像素字节数 × 2,读一次、写一次。1080p 用 RGBA16F,每像素 8 字节,跑一次全屏 Pass 的搬运量就是 1920×1080×8×2≈33MB。如果一个后处理链里有 Bloom 的 4 个 Pass、Tone Mapping 1 个 Pass、DOF 1 个 Pass,再加上 Color Grading,光 RT 搬运就上百 MB。60 帧每秒就是 6GB 以上的搬运量——这还没算 Bloom 本身空间滤波的采样开销。
把后处理链想象成快递中转站里反复搬运包裹的传送带,每多一个全屏 Pass,就把一整堆包裹从 A 仓搬到 B 仓再搬回来。传送带的数量越多,中转次数越密,功耗自然指数上升。这也是为什么单纯数 Pass 数量没有意义,真正要看的,是每一趟搬运的“货物重量”和“趟数”。
3.2 轻量化三板斧:降采样、合并 Pass、换目标格式
降采样是后处理优化里性价比最高的动作。Bloom 的经典做法是先把亮度阈值提取到半分辨率,后面所有模糊都在 1/4 分辨率甚至更低做,最后再合成回全分辨率。DOF 和 Motion Blur 也可以在半分辨率下做,再配合深度引导或双边滤波把边缘补回来。移动端项目里,把所有后处理 RT 从全分辨率降到 1/2 分辨率,视觉差异往往很小,但带宽直接砍到原来的四分之一。
合并 Pass 也是老生常谈但永远有效的优化。Tone Mapping 和 Color Grading 完全可以写在同一个 shader 里;Bloom 的最终合成步骤,也可以和 Tone Mapping 合并在一起,省掉一次全屏 RT 读写。能合就合,渲染目标切换全屏 Pass 是 GPU 上最贵的操作之一。每个 Pass 之间的 RT ping-pong,看起来只是切换一下,实际上一轮读写下来,几百 MB 就没了。
换目标格式则是最容易被新人忽略的一点。不是所有后处理都需要 RGBA16F。移动端如果不追求 HDR 效果,主 RT 用 LDR 或者 R11G11B10F 都可以;法线、深度这类单值纹理,按数据范围选 R8、R16 或 R32,不要一律 RGBA。每像素字节数小一截,带宽就等比省一截,而视觉差异在大多数情况下根本看不出来。
3.3 给后处理加“判定逻辑”:不该干活的地方别干活
工业 CAM 后处理里有个很经典的操作叫“判断 4 轴变化、Z 轴回零”——意思是后处理程序会预先检查刀路里是否有旋转轴变化,如果条件满足才插入特定指令,避免每一次输出都做重复操作。这个“条件触发”的思维放到图形后处理里,思路完全一致:让每个后处理 Pass 在适合的时候跑,在不该跑的时候立刻退出或降档。
实现方式就是写一个后处理管理器,动态决定每个 Pass 跑不跑、跑多少分辨率。举个例子:镜头快速旋转时动态模糊有存在感,但玩家站在原地聊天、镜头几乎不动时,动态模糊对画面的贡献几乎为零,这时直接关掉是最省电的决策。Bloom 也是,UI 界面全屏弹出或者处于战斗结算画面时,根本不需要全分辨率高光提取,降到最低档甚至关闭也无妨。DOF 可以加一个距离判断,主体物体在景深内时不需要额外算散景。再加上设备温度和帧率作为兜底信号,发热到阈值就直接切换到只有一个 Tone Mapping 的极简模式。
用一段很短的伪代码就能说清楚这个思路:
int GetPostFxLevel(float frameMs, float batteryTempC, bool isUIShowing) { if (isUIShowing) return 0; // UI 界面:极简模式 if (frameMs > 20f || batteryTempC > 42f) return 0; // 高温/重负载:极简模式 if (frameMs > 16f || batteryTempC > 38f) return 1; // 中低档:半分辨率 Bloom,无 DOF return 2; // 高档:完整特效 }这段逻辑本身没有任何玄学,但能帮你省掉大量无效的 GPU 工作。关键是,用户几乎感知不到变化,但温度和功耗是实打实的改变。
3.4 借 YOLO 后处理的思路:过滤、合并、提前退出
再往深一层说,后处理优化和 YOLO 这类目标检测模型的推理后处理,其实遵循的是同一套原则。YOLO 后处理流程里,会先对模型输出的大量候选框做置信度过滤,再做类别筛选,最后通过 NMS 合并重叠框。核心思想就是:把计算量大的运算放到数据量小的时候再做,把无效数据尽早剔除。
图形后处理里完全可以照搬这套过滤机制。Bloom 之前先估算整帧平均亮度,低于阈值就跳过整个高光提取流程,因为提了也没东西可提;SSAO 或 DOF 类深度相关效果,先用深度纹理生成一张重要性图,在平坦区域直接不做采样;UI 界面出现时整个后处理链降为最低档。这种“过滤 + 合并 + 提前退出”的模式,比单纯优化单个 Pass 的 shader 更釜底抽薪。你省的不是一个 Pass 的 0.1ms,而是整条流水线一秒钟几 GB 的搬运量。
4. 一套能直接抄的优化流程与复盘案例
讲了这么多原理,下面给一套我自己项目里验证过的排查和优化流程。直接对着做就行。
4.1 抓现场:用 Profiler 黄金三件套定位热源
第一步永远是先抓现场,别猜。猜纹理问题还是后处理问题,最后大概率会白忙一场。我通常先用引擎内置 Profiler 看整体 GPU 时间占比和帧率分布,确认是不是 GPU bound。如果 GPU 耗时占总帧时间很大,再开平台级工具做二次定位。
Android 上我常用 Snapdragon Profiler 或 Android GPU Inspector(AGI),重点看带宽模块里的 texture fetch 字节数和 render target 读写字节数。iOS 上用 Xcode 的 Metal System Trace 和 GPU Frame Capture,同样能看到带宽和每个 Pass 的开销。Unity 工程还可以叠加 Frame Debugger,直接看每一帧的 drawcall 和 RT 状态。
怎么判断主犯?“纹理”问题通常表现为 texture fetch 相关数值明显高,或者单个 drawcall 绑定了巨大的贴图;“后处理”问题则表现为大量的全屏 Pass、RT 读写带宽高,以及 Pass 间隔里有频繁的 RT 切换。两者经常会同时出现,但总有一个是主要贡献者。先抓主犯,再处理从犯,优化效率会高很多。
4.2 纹理侧优化 SOP
按这个顺序走,基本不会漏:
- 先建立贴图清单:把所有纹理导出成一张表,标出尺寸、格式、mipmap 状态、被哪些材质引用。
- 按“世界空间占用率”排序:在场景里实际显示尺寸远小于贴图尺寸的,统一降尺寸,别怕降,视觉几乎无变化。
- 统一压缩格式并确认生效:移动端优先 ASTC,兼容性要求高时回退 ETC2,确认目标平台加载的确实是压缩格式,不是 RGBA。
- 检查 mipmap 状态:除 UI 和图集外全部开启;图集要么拆开处理,要么单独定义 mip 链。
- 控制各向异性过滤等级:移动端 4x 足够,PC 端视发热情况取 8x,不要无条件 16x。
- 复查一次实际内存占用和带宽占用,做前后对比,确认每一项改动都产生了预期收益。
4.3 后处理侧优化 SOP
后处理侧按这个顺序执行:
- 打开 Frame Debugger 或 RenderDoc,列出整条后处理链的所有全屏 Pass。
- 给每个 Pass 分类:必要型(Tone Mapping、抗锯齿)和氛围型(Bloom、DOF、Motion Blur、Vignette)。
- 优先降分辨率,再考虑砍数量:把氛围型 Pass 的 RT 分辨率统一降到 1/2,Patch 光照相关效果可以用 1/4。
- 合并能合并的 Pass,目标是把后处理链控制在 3 个全屏 Pass 以内。
- 写动态档位管理器,按帧耗时、设备温度、场景状态进行切档。
- 记得锁帧率。如果功耗预算充足,60 帧就是 60 帧,别让 GPU 偷偷跑满 120 帧还觉得自己“性能很好”,对发热毫无好处。
4.4 复盘一个典型项目:从 82% 降到 48%
前阵子一个 3D 卡牌对战项目,战斗场景 GPU 占用率 82%,手机烫得根本不敢碰,帧率一路从 60 掉到 30 上下。我们按上面的流程抓现场,Snapdragon Profiler 显示 texture fetch 带宽很高,RT 读写量也不低;Frame Debugger 里一拉,后处理链整整有 6 个全屏 Pass。
纹理侧做了三件事:角色立绘从 2048 降到 1024,压缩成 ASTC 6x6;场景装饰贴图从 1024/2048 统一降到 512/1024,并且全部开启 mipmap;法线纹理从 RGBA8888 转成 ASTC 5x5,精度损失在可接受范围内。后处理侧也做了三件事:Bloom 的高光提取降到半分辨率,模糊在 1/4 分辨率下做 3 次;Tone Mapping 和 Bloom 合成合并成一个 shader;待机状态下只保留 Tone Mapping 和一个轻量版 Bloom。
优化后对比如下:
| 项目 | 优化前 | 优化后 |
|---|---|---|
| GPU 占用率 | 82% | 48% |
| 后处理全屏 Pass 数 | 6 | 3 |
| 主场景纹理内存占用 | 约 420MB | 约 180MB |
| 整机功耗下降 | - | 约 1.2W |
| 机身温度感受 | 烫手 | 温热 |
画质上,普通玩家完全感觉不到区别,只有盯着对比截图抠细节的测试同事,才能勉强发现远景某些高光稍微弱了一点点。这个案例给我最大的触动就是:所谓发热优化,很多时候不是做多高深的算法优化,而是把不该搬的数据量省下来。
5. 常见排查问题与避坑实录
最后整理一些我在实际项目里经常遇到的问题,都是一些很容易踩进去的坑。
5.1 高频问题速查表
| 现象 | 可能原因 | 排查与解决 |
|---|---|---|
| 压缩纹理后 GPU 还是烫 | 压缩格式没生效,回退成 RGBA | 用材质查看工具或抓帧工具确认实际加载的格式 |
| ASTC 压完画面发糊 | 压缩率选得过高 | 颜色图降到 6x6 或 8x8,法线图至少 5x5 |
| Bloom 降采样后依然慢 | 高光提取还是全分辨率,模糊迭代次数过多 | 高光提取也在半分辨率做,迭代次数随 RT 分辨率调整 |
| mipmap 开了之后贴图边缘发黑 | 图集开了 mipmap | 图集单独处理或拆开,UI 贴图不要开 mipmap |
| 后处理 Pass 减少了还是烫 | RT 格式太大或反复切换 | 检查 RT 格式,合并 Pass,减少 ping-pong 切换 |
| 动态档位切换时画面闪烁 | 切换时机不对 | 在切场景或 UI 弹出的瞬间切换,不要在半屏渲染中途切换 |
| 远处物体依然在闪 | mipmap 没开或 LOD 偏移过大 | 开启 mipmap,调整各向异性过滤等级 |
5.2 几条用真金白银换来的经验
第一,不要迷信“全屏 Pass 数量少就快”这句话。真正吃带宽的是 RT 的格式和切换频率,不是 Pass 数量本身。把两个 Pass 合并成一个,如果不小心把目标从 R11G11B10F 换成了 RGBA16F,那等于白忙一场。
第二,法线贴图压缩要留足精度。ASTC 压到 6x6 以后,在动态光照下很容易出现明显的断层感,尤其是角色皮肤和金属材质。这块省出来的内存不大,但视觉风险很高,建议法线至少 5x5,或者干脆用 BC5 在 PC 端保底。
第三,Bloom 的迭代次数要跟着 RT 分辨率走。RT 分辨率越小,每一级模糊的有效半径就会显得越小,这时候反而可以适当增加迭代次数来弥补视觉上的精度损失。反过来,全分辨率下疯狂迭代高斯模糊,除了把 GPU 烧穿,不会有任何人注意到差别。
第四,动态档位切换时,选一个画面不太敏感的“安全时刻”再切。比如切场景的瞬间、UI 全屏弹出的瞬间,那些时候画面本身就在变化,观众很难注意到后处理降了一档。千万别在镜头缓慢推进时突然关掉 DOF,整屏都会看起来明显“抖”一下。
这些年我踩过最深的坑,就是一开始总觉得纹理多几 MB、后处理多一两个 Pass 不是什么大事,结果真机上发热了才回头优化,项目节奏被拖得很被动。如果你正好在做一个新项目,我的建议是项目启动时就给纹理预算和后处理 Pass 数量设一个上限,把“搬运量”当成一种资源去管理,而不是等设备烫了再到处抓惯犯。真到发烫再回头,总归是亡羊补牢。