1. 先把“搬运量”这件事说清楚
做移动端图形优化做了六七年,我最常说的一句话是:发热的本质不是算得多,而是搬得多。
画面卡了,大家第一反应是“顶点多了”“像素算不过来了”,但在移动平台上一测功耗,往往发现真正的开销大头是内存带宽,也就是 GPU 从显存里把数据来回搬运的体量。GPU 的 ALU 算一次数,能耗是几皮焦级别,但从显存里搬一个 32 位的纹素进缓存,能耗要高出几个数量级。一次全屏后处理,要把整块颜色缓冲读一遍再写一遍,数据搬运量动辄几十 MB;一张 2048×2048 的 RGBA 纹理,一次采样就是 16MB 的潜在读取。别小看这些数字,手机 SoC 的带宽是共享预算,CPU、GPU、NPU、显示控制器全都挤在这条高速公路上,GPU 多搬一点,整体功耗就往上蹿一点,机身温度跟着就上去了。
1.1 发热不是“算”出来的,是“搬”出来的
用搬砖来类比就很直白。**Shader 计算是在工地上砌墙,纹理读取和后处理读写就是卡车在拉砖。**砌墙再快,砖拉不过来都是白搭;反过来,砖拉得太多,路堵死,卡车油耗上去,整条工地(整个 SoC)的温度就爆了。
移动端 GPU 和桌面端架构不一样,桌面 GPU 不差带宽,显存位宽动不动 256-bit、384-bit,GDDR6 的带宽以几百 GB/s 计;手机这边是 LPDDR5 或 LPDDR5X 共享内存,带宽通常在 30~70 GB/s 这个量级,还要和 CPU 抢。也就是说,同样的绘制内容,放在 PC 上可能完全没瓶颈,一到手机就发热降频,根源就在“搬运量”超标。
所以做发烫优化,先不要急着砍特效、降帧率,先把每个 pass 的数据搬运量算清楚,往往会发现几个“搬运惯犯”——纹理和后处理,是每次都会上榜的两位。
1.2 纹理和后处理,凭什么被叫“惯犯”
先说纹理。一张 2048×2048 的 RGBA32 纹理,不压缩时占用 16MB 显存,在支持 ASTC 压缩后可以压到 1~2MB(ASTC 8x8),差距是 8 到 16 倍。**如果项目里的纹理没有正确压缩,等于每个物件都拉着一大卡车砖在渲染,发热不找你找谁。**更隐蔽的问题是,纹理压缩格式选错了,或者用了不支持硬件直接采样的格式(比如把 PNG 直接塞进纹理内存),GPU 要先解码再采样,搬运量成倍往上翻。
后处理就更典型了。一个 Bloom 效果,本来粒子、模型、场景光照都跑完了,突然为了“氛围感”加了三四个全屏 pass,每个 pass 都要把整块屏幕读进来、写出去。一次全屏 pass 的搬运量 = 分辨率宽度 × 高度 × 每像素字节数 × 2(读+写)。1080p 的 RGBA16F 缓冲,一次 pass 就是 1920×1080×8×2 ≈ 33MB。一顿后处理链下来,搬运几百 MB 是家常便饭。这不是“算得很复杂”,而恰恰是“搬得很离谱”。
所以这个系列走到第 4 篇,我决定把这两个惯犯单独拎出来审一遍,讲清楚它们为什么耗电、怎么优化、踩过的坑有哪些。
2. 纹理优化:从压缩、层级到采样设置,把货藏在源头
纹理优化不是一个开关就能搞定的,它是一套从资产制作到运行时的组合拳。我见过太多项目,美术出图的时候用 PNG 直出,程序加载的时候也不做转换,结果光纹理解码就把 CPU 干到降频。下面按优化优先级把纹理相关的关键点拆开讲。
2.1 纹理压缩不是“压画质”,而是“改货箱”
很多新手一听纹理压缩,第一反应是“画质会不会变差”。这里要澄清一个概念:移动端的纹理压缩,更多是选择一种 GPU 硬件可以直接读取的存储格式,而不是普通的图片压缩。JPEG、PNG 这类格式,GPU 没法直接采样,必须先解码成 RGBA 原始数据放进显存,这个过程既慢又占空间。而 ETC2、ASTC 这类格式,手机 GPU 的纹理单元能直接识别和采样,读取的时候无需要解压,压在显存里的体积还小,带宽消耗也小。
ASTC 是目前移动端最推荐的格式,它是 ARM 主导的可扩展纹理压缩标准,Android 和 iOS 的设备基本都支持。ASTC 的核心优势是可以灵活选择 block 尺寸:4x4 块质量最高但压缩率最低,8x8 块质量和体积比较均衡,12x12 块体积最小但可能出现明显色块。我在项目里通常会定一套规则:
| 使用场景 | 推荐格式 | 说明 |
|---|---|---|
| UI 图标/小图 | ETC2/RGBA 或 ASTC 6x6 | UI 讲究清晰度,不建议压太狠 |
| 3D 角色贴图 | ASTC 8x8 | 兼顾体积和皮肤/布料渐变质量 |
| 场景大纹理 | ASTC 10x10 或 12x12 | 远景和地面的细节损失肉眼可接受 |
| 法线贴图 | ASTC 6x6 或 8x8 | 法线信息比较敏感,压缩过狠会有光照瑕疵 |
这里要特别提醒一个坑:如果目标设备不支持某种格式,运行时就必须回退,回退过程往往就是先把纹理通过 CPU 解码成 RGBA,再重新上传显存,这个操作会让首发卡顿和发热双爆炸。所以上线前一定要用真机列表的 GPU 信息核对一下设备支持的纹理压缩格式,不要想当然。
2.2 mipmap:给 GPU 准备一套“阶梯货架”
mipmap 的原理很简单,就是从原始纹理开始,每级缩小一半,生成一串”等比缩小“的纹理序列,从 2048 一直到 1x1。渲染时,GPU 根据当前 fragment 到摄像机的距离,自动选择合适的层级采样。别小看这个机制,它对带宽的影响是数量级的。
如果一个 2048 的纹理用在只有 64 像素大小的远处物体上,你不开 mipmap,GPU 照样要加载那张 2048 的贴图纹素,然后做大量降采样,采样缓存命中率极低,基本等于“杀鸡用牛刀,还把刀磨得特别大”。开了 mipmap 之后,GPU 会直接挑 64 或 32 像素那个层级去读,读取量小了十几倍,带宽压力自然就下来了。
操作层面,3D 场景的纹理建议无条件开启 mipmap。代价是显存增加约 33%,但这和带宽节省相比完全值得。另一个容易被忽略的设置是LOD 偏移(Texture LOD Bias),它控制 GPU 偏向选哪个层级,调正一点可以让 GPU 倾向用更小的 mipmap,能进一步省带宽,代价是远处纹理变模糊。我一般在低端机配置里会把 LOD Bias 拉高 0.5~1.0,肉眼几乎看不出差别,但发热体感改善明显。
2.3 各向异性过滤与采样器设置的平衡
各向异性过滤(Anisotropic Filtering,AF)是为了解决地面、墙面这类几乎平行于视线方向的表面采样模糊的问题。AF 级别越高,采样质量越好,但采样次数也越多,带宽消耗越高。在移动平台,我建议中高端机用 4x,低端机用 2x 或者干脆关掉。这个阈值不是拍脑袋定的,实测中 4x 到 8x 的画质提升很小,但带宽开销几乎翻倍,性价比很低。
另外还有一个常见误区:采样状态(Sampler State)不要每个对象都新建一个,尽量做成全局复用。有些引擎里如果纹理的过滤方式、寻址方式不一致,会导致纹理描述符和采样状态频繁切换,本来就紧张的带宽花在了无谓的状态切换上。实际的工程里,我会把采样器按“Trilinear+Clamp”“Bilinear+Repeat”“Point+Clamp”等几个固定配置提前建好,材质里只引用配置 id,不动态创建。
说到 Unity 项目,还有个隐藏的坑:**Streaming Mipmap 的触发距离如果设得太近,角色跑两步就要加载更高精度的 mipmap,加载任务瞬间打满 IO 和 CPU,发热反而更高。**建议把触发距离放宽,让系统提前异步加载,避免瞬时尖峰。
3. 后处理优化:低分辨率、合并 pass、少来回
后处理一直是我在项目中最警惕的部分,因为它太“好用”了。美术说加点辉光,就上一个 Bloom;说色彩要电影感,就加 Color Grading;说景深要高级,再加 DoF。每加一个效果,就是往 GPU 的“搬运清单”里添一笔全屏读写。
3.1 一次全屏 pass 的真实成本
先说一个简单的计算公式:一次全屏后处理 pass 的带宽开销 ≈ 屏幕分辨率 × 每像素字节数 × 2(一次读 + 一次写)。
以 1080p 为例,RGBA8 缓冲(4 字节/像素):1920×1080×4×2 ≈ 16.6MB;如果是 HDR 场景,缓冲通常是 RGBA16F(8 字节/像素),一次 pass 就是 33MB。**一个典型的 Bloom 效果:先降采样到 1/2,再降采样到 1/4,模糊两三遍,再和原图合成,前前后后差不多 7~9 个 pass。**就算每个 pass 只操作半分辨率,累加起来的搬运量也在 200MB 以上。一天玩一小时,就是几十 GB 的数据搬运,发热不找你找谁。
所以后处理优化的第一原则是:能不用的 pass 就不用,能合并的 pass 必须合并。比如 Vignette(暗角)、Bloom 的最终合成、Color Grading,如果引擎支持,尽量写在同一个 shader 里,用一张全屏 RT 采样一次完成,而不是叠三四个 OnRenderImage。
3.2 Bloom 降分辨率:性价比最高的后处理优化
Bloom(辉光)是发热大户中的大户,很多项目的问题就出在它的实现方式上。产品级做法是把 Bloom 做在降分辨率缓冲上,而不是全分辨率。通常流程是:先把场景 RT 降采样到 1/2 分辨率,甚至 1/4,然后在这个低分辨率缓冲上做高斯模糊和叠加。由于模糊本身就是低频操作,低分辨率下做几乎不影响观感,但搬运量直接降到 1/4 或 1/16。
实际参数可以参考:1080p 下,Bloom 的源分辨率用 540p,模糊迭代 4 次,半径 2~4 像素,效果和 1080p 全分辨率做 6 次迭代的差距很小,但带宽开销能差 5 倍以上。我在项目里测试过,单纯把 Bloom 降一半分辨率,整机功耗能掉 0.3W~0.5W,机身温度下降 2~3 度,画面观感几乎没变化,这是后处理优化里投入产出比最高的一刀。
色差的另一个细节:不要在 HDR 的 16F 缓冲上重复做模糊,最好先降采样到 8-bit LDR 缓冲,再做效果叠加。很多效果在 LDR 下完全够用,硬生生在 HDR 缓冲上来回读写,带宽翻倍还感觉不到差异。这个取舍在移动端尤其重要。
3.3 移动端 Tile GPU 下,少“碰”缓冲比狂压特效更稳
移动 GPU 大多是 Tile-based Deferred Rendering(TBDR)架构,渲染完一个 tile 之后,数据是存在片上高速缓存(On-Chip Memory)里的。如果当前渲染目标的内容不需要拿到外部显存去读写,就能省下大量内存带宽。这也是为什么移动端极力推荐使用 FrameBuffer Fetch(帧缓冲读取)的原因——上一阶段算完的颜色,不用出去绕一圈再回来采,直接在 tile 里传给下一阶段。
对于后处理链,要注意引擎实现方式。很多后处理栈为了通用性,会跑若干个全屏 blit pass,每一下都从显存读写。这在桌面端没什么,但在移动端就是灾难。有条件的话,尽量选用支持“合并 pass”的后处理实现:比如 GitHub 上一些开源的移动端后处理栈会把多个效果写进一个 shader,通过关键字切换,一次全屏绘制就把 Bloom、Color Grading、Vignette 全做了。本质上是把原来七八次全屏读写压缩到两三次。
还有个很实际的经验:不要对 UI 层做后处理。有些项目图省事,把 UI 也放进屏幕空间特效里,结果 UI 每一帧都被重新采样和读写。UI 一般应该独立一层,在最后直接合成,不参与任何后处理链。
4. 实操:一套可以照着抄的优化流程
这一节我拿一个典型的中型 3D 手游项目举例,走一遍纹理和后处理的优化流程。这项目当时的症状是:中端机跑 10 分钟就发烫降频,帧率从 45 掉到 25,手感全无。用 PerfDog 一测功耗,平均 5.8W,其中 GPU 占了大头。
4.1 纹理资产检查与压缩方案落地
第一步,先把项目的纹理资产清单拉出来,检查压缩格式和尺寸。我们当时的检查脚本统计出项目里有 1200+ 张纹理,其中约 40% 还是 RGBA32 未压缩格式,主要原因是美术用的图片导入设置不对。
处理步骤是:
- 按使用场景把纹理分成 UI、3D 模型、场景、特效四类,每类指定不同的压缩格式和最大尺寸限制。
- 对所有 3D 纹理批量开启 mipmap,并设置 LOD Bias 全局偏移 +0.5。
- 对未压缩纹理,统一走 ASTC 8x8(Android)和 ASTC 8x8(iOS)的转换,格式不支持的旧机走 ETC2 回退。
- UI 纹理单独保留 ETC2 或 RGBA 线性格式,避免 UI 文字和图标出现压缩脏色。
这里最花时间的是处理“尝试压但压坏”的纹理:法线贴图如果用 ASTC 10x10 压,某些地方会出现明显的“麻点”,高光也怪。我们的做法是单独筛出法线贴图列表,统一用 ASTC 6x6,压完在真机上拉几个场景跑了验证,肉眼基本看不到差异后定稿。
改完纹理这步,中端机上的 GPU 负载立刻降了一截。PerfDog 显示 GPU 利用率从 82% 降到 65% 左右,整机功耗降到 4.7W。
4.2 后处理链重构:降分辨率 + 合并 pass
当时项目用了一个第三方的后处理栈,默认跑 Bloom + DoF + Color Grading + Tonemapping + Vignette,一共 12 个全屏 pass,1080p 下每帧搬运量高达 300MB+,非常夸张。我们做了两件事:
第一,Bloom 和 DoF 全部降到 1/4 分辨率执行。具体做法是先把场景 RT 用双线性降采样到 480p 的中间缓冲,所有模糊类效果都在这个缓冲上跑,最后再 upscale 合回主缓冲。模糊类效果本身是低频信息,低分辨率执行基本不失真。
第二,把 Color Grading、Tonemapping、Vignette 合并到最终合成 pass。这三个效果的输入输出完全一致,完全不必要分开执行。我们把它们浓缩成一个 shader,一次全屏绘制全部完成,RT 读写的次数从 3 次减到 1 次。
优化后的后处理链从 12 个 pass 缩减到 4 个 pass(降采样 pass、Bloom 模糊 pass×2、最终合成 pass),总搬运量从 300MB+/帧 降到 70MB/帧左右。这 230MB 的节省,直接反映在整机功耗上:又降了 0.6W。
如果项目对画质要求更高,还有一个折中方案:让玩家可以选“性能模式”,后处理渲染分辨率固定为屏幕分辨率的 75%,高端机默认 100%。低端机的体验会平滑很多,不至于一开特效就发烫掉帧。这本质上是用分辨率换带宽,也是手游厂商最常用的“自适应画质”策略之一。
4.3 用工具量化验证:不要凭感觉优化
优化的效果必须用数据验证,不能凭“好像凉快了”这种玄学感觉。我在项目里固定用几套工具组合:
| 工具 | 用途 | 关键指标 |
|---|---|---|
| PerfDog | 整机功耗、帧率、CPU/GPU 占用 | 整机平均功耗、帧率波动 |
| Unity Frame Debugger | 查每帧画了多少 drawcall、纹理绑定情况 | drawcall 数、RT 切换次数 |
| RenderDoc | 定位后处理 pass 的输入输出、资源状态 | RT 尺寸、格式、pass 顺序 |
| Xcode Metal System Trace / ARM Streamline | 查看 GPU 带宽和缓存命中率 | 带宽利用率、L2 命中率 |
优化过程中,我习惯在改一个措施前后各测一次:**同场景、同路线、同机型,跑 15 分钟,记录平均功耗和最高温度。**没有前后对照的优化都是耍流氓,因为你根本分不清是哪个改动起作用了,还是只是散热环境变了。
RenderDoc 对查后处理特别有用。它会显示每个 pass 的渲染目标尺寸和格式,你一眼就能发现哪些 pass 在 1080p 的 RGBA16F 上折腾。另一个常用技巧是材质 Debug View,直接输出纹理的 mipmap 层级覆盖图,检查场景纹理是否真的按照距离正确选择 mipmap 层级。
5. 常见问题与排查技巧实录
做优化这些年,踩过的坑不少,有些问题甚至反直觉。我把最常见的问题整理成一张速查表,再挑几个典型展开讲。
| 现象 | 可能原因 | 排查思路与对策 |
|---|---|---|
| 纹理压缩后出现明显色块/麻点 | 压缩格式选得过狠,或法线贴图不该压这么狠 | 法线贴图改用更小的 block(ASTC 6x6);UI 纹理避免过度压缩 |
| 帧率高但机身烫 | GPU 带宽超限,碎片化读写太多 | 用 profiler 查看带宽指标,重点排查后处理 pass 和未压缩纹理 |
| 远处贴图闪烁/摩尔纹 | mipmap 没开,或 LOD Bias 过小 | 开启 mipmap,调大 LOD Bias |
| Bloom 一开帧率就腰斩 | Bloom 在全分辨率 + HDR 缓冲上多次模糊 | 降到 1/4 分辨率,利用低分辨率缓冲完成模糊,最后再合成 |
| 低端机比高端机烫得多 | 分辨率/后处理参数未按档位适配 | 实现画质分档,低档位用 75% 分辨率渲染后处理,关掉 DoF |
| 后处理链一堆 RT,内存带宽爆炸 | 过多全屏 pass 独立读写 | 合并 pass,减少 RT 切换,尽量一次全屏绘制完成多个效果 |
| 某些 Android 机型纹理花屏 | 纹理压缩格式不支持,回退逻辑出问题 | 核对 GPU 支持的格式列表,做好 ASTC/ETC2 的运行时判断 |
5.1 为什么“画质全高”和“发热”是老冤家
很多玩家抱怨“手机一开高画质就烫”,这是正常的,但也可以优化得更平衡。高画质意味着更大分辨率、更高精度的纹理、更复杂的后处理,每一样都在往带宽上加码。我通常会给玩家提供“极致/高清/均衡/省电”四档,每档背后其实就是一套参数组合:
- 极致:原生分辨率渲染,后处理 100% 分辨率,纹理全高,AF 8x
- 高清:渲染分辨率 90%,后处理 75%,纹理中高,AF 4x
- 均衡:渲染分辨率 80%,后处理 50% ,纹理中,AF 2x
- 省电:渲染分辨率 70%,后处理 25%,纹理低,AF 关
这四档对发热的影响差异很大,但对玩家来说,均衡档的画面观感依然过得去。真正应该避免的是“一刀切”,让所有机型都跑同一套效果,低端机体验糟糕,又不甘心降画质。
5.2 关于纹理坐标、UV 接缝和压缩格式的一个冷门坑
再分享一个比较冷门的案例。有一个场景,贴图压成 ASTC 8x8 后,某些模型的 UV 接缝处出现了一条明显的“亮边”。排查了很久,最后发现是 UV 跨了多个纹理 block 边界,ASTC 在 block 边界压缩时产生了不连续。解决方法是把模型的 UV 做 4 像素的 padding,或者改用 ASTC 6x6,问题立刻消失。如果新版压缩格式引入不可见的边界瑕疵,不要硬扛,考虑在资源生产端做调整。
5.3 优化后的复查和回归
最后提醒一句:**纹理和后处理优化,做完之后一定要回归真机测试,特别是帧率稳定性(Frame Time 的 P1/P99)和发热曲线。**有时候优化后平均帧率上去了,但 1% Low(最卡的一帧)反而更难看了,说明某些改动引入了偶发性的加载尖峰。这时候配合 Streaming Mipmap 的触发距离、纹理异步加载的队列长度,把这些尖峰压平,体验才算真正稳住。
我个人在实际项目里最常用的收尾手段是:在游戏设置页放一个“自动检测画质”按钮,根据真机跑分自动给玩家推荐一组合适的参数。**与其让玩家在发热和画质之间反复纠结,不如让设备自己说出它扛得住的档位。**这个思路比任何单一的优化技巧都更能提升口碑。
做优化这么多年,回头看,纹理和后处理这两个“惯犯”之所以总在发热榜单里排名靠前,核心原因就是它们和带宽的关系太密切了。带宽这东西,平时看不见摸不着,但每一份发热账单里都写着它的名字。当你把纹理压缩做到位、mipmap 全部打开、后处理链压到最低必要 pass 数的时候,你会发现整机功耗降得比想象中更明显,而画面观感却几乎没有牺牲。这大概就是图形优化最“值”的那部分工作了吧。