Shader 优化实战清单:从指令、带宽到 Shader Cache(170ms → 1.04ms 案例)
这篇文章把 Shader 优化拆成两类问题:
- 算得太多(ALU):指令数高、昂贵函数太多、重复计算、分支导致浪费。
- 读得太多(带宽/采样):纹理采样次数多、采样依赖(dependent read)、格式不亲和、缓存命中差。
最后再补上经常被忽略但收益巨大的工程项:Shader Cache / 预编译(你给的例子里,编译耗时从 170ms 降到 1.04ms)。
目标不是“把指令数降到极限”,而是在视觉可接受的前提下,把瓶颈从 GPU/编译/加载链路里移走,并让优化可验证、可回归。
0. 先说清楚:你要优化的是哪一种“慢”
Shader 相关的“慢”常见有三类,解决手段完全不同:
- 运行时 GPU 慢:帧时间里 Pixel/Vertex/Compute 占比高(看 GPU Profiler / RenderDoc / Xcode GPU Frame Debugger)。
- 运行时 CPU 慢(驱动/状态切换):材质变体过多、关键字爆炸、drawcall 状态切换开销高。
- 加载/首帧/切场景卡顿:Shader 编译/变体编译在运行时发生(典型:无 cache 或 warmup 不足)。
后文的清单会标注它主要影响哪一类。
1. 精度浪费:能用 half 就别用 float(主要影响:运行时 GPU)
1.1 为什么有效
在很多移动 GPU 上,half/mediump 的吞吐、寄存器压力、带宽都会更友好(不同 GPU/驱动差异很大,但“过度用 float”的坏处几乎是确定的)。
1.2 你可以优先降精度的地方
- 颜色与常规材质参数:albedo、AO、roughness/metallic、mask、插值后的 varyings。
- 法线相关:切线空间法线、
NdotL之类(注意精度会影响 banding/闪烁)。 - UV 与小范围偏移:尤其是屏幕空间小扰动(但要小心大范围 world UV)。
1.3 需要谨慎的地方
- 世界坐标/大场景:float 精度不足容易抖动,更别用 half。
- 高动态范围/极亮值:半精度溢出或量化会带来色带/爆点。
- 累加链路很长:例如多次叠加的雾、SSR、体积等。
占位图:精度浪费示意(把你的截图放到仓库后替换路径)

2. 尽量复用中间结果,避免重复计算(主要影响:运行时 GPU)
2.1 典型高成本组合:normalize / length / distance
这些经常会触发 dot + rsqrt + 额外的乘法;在热路径里重复调用非常亏。
反模式:
1 | float3 v = posWS - _CenterWS; |
更好的写法(复用 dot / rsqrt):
1 | float3 v = posWS - _CenterWS; |
要点:
- 复用
d2(平方距离)在很多地方也够用(例如衰减用d2)。 - 对 0 做保护,避免
rsqrt(0)产生 NaN。
3. 能从 PS 挪到 VS 就挪:把“上亿次”变成“几万次”(主要影响:运行时 GPU)
3.1 为什么有效
像素着色(PS/fragment)执行次数通常远高于顶点着色(VS/vertex)。同一段计算在 PS 跑一次可能等于在 VS 跑几十到几百次(取决于屏幕占比与过度绘制)。
3.2 适合搬家的计算
- 线性插值友好的量:颜色、简单的渐变参数、UV 偏移(不需要 per-pixel 的精度时)。
- 不依赖屏幕导数的量:避免影响 mip 选择(后面会讲 dependent read 的坑)。
例:顶点色渐变(VS 计算,PS 直接用):
1 | // VS |
3.3 CPU 只算一次,别让 GPU 算上亿次
你提到的“某个矩阵/某个颜色”:如果它是 per-object 或 per-frame 常量,就应该在 CPU/常量缓冲里准备好,避免在 shader 里重复组合/拆分。
4. 避免动态分支:能用 step + lerp 就别 if(主要影响:运行时 GPU)
动态分支(每个像素分支条件不同)会导致:
- 同一个 wave/warp 里分歧执行(两边都跑),吞吐下降
- 指令与寄存器压力上升
4.1 轻量条件:step/lerp 替代 if
1 | // if (x > t) a else b |
4.2 什么时候 if 反而更好
- 分支两边差距巨大,而且分支条件在大块区域一致(例如整块物体/整片屏幕一致)。
- 你能把分支提到更高层:用 shader variant / keyword / pass 把运行时分支变成编译期分支。
5. 避免在 PS 中做“动态纹理采样”(Dependent Texture Read)(主要影响:运行时 GPU)
在 VS 里先算出 UV,再用这个 UV 去采样,这类采样更难利用纹理缓存与预取,也更容易导致 mip 选择与导数计算变得复杂。
5.1 优先策略
- 把 UV 的大部分计算挪到 VS,PS 只做少量修正。
- 或者让 UV 计算更“规则”:减少高频非线性扰动(噪声、pow、复杂旋转等)。
5.2 需要注意的副作用
把 UV 计算从 PS 挪到 VS 会改变插值方式,可能导致:
- 纹理细节扭曲/漂移
- mip 选择变化(尤其屏幕空间效果)
做法是:先搬 “低频、线性部分”,保留 PS 做 “高频或屏幕空间” 的小修正。
6. 合理分配纹理通道,减少采样次数(主要影响:运行时 GPU / 带宽)
纹理采样通常比简单 ALU 更贵,尤其在移动端与高分辨率下。
6.1 常见 packing 思路
- 一张 mask 贴图打包多通道:
- R: AO
- G: Roughness(或 Smoothness)
- B: Metallic
- A: Emission/DetailMask
- 法线单独一张(通常压缩格式不同)。
6.2 采样次数治理的一个实用方法
为常用材质建立“预算”:
- 不透明基础材质:BaseColor 1 + Normal 1 + Mask 1 = 3 次采样(只是示例)
- 需要额外效果时明确写“+1 / +2” 并衡量收益
这样团队会更容易在评审时阻止“随手加一张贴图”的膨胀。
7. 贴图格式:非常重要(主要影响:运行时 GPU / 带宽 / 内存)
选择 GPU 亲和的压缩格式,会显著减少带宽与缓存压力。
7.1 通用原则(不绑定具体引擎)
- 优先使用平台原生支持的压缩格式(硬件解码更省)。
- 法线/Mask/颜色贴图分开选格式:它们对误差的敏感程度不同。
- 避免“明明可以压缩却用 RGBA32/Float”(除非确实需要精度/HDR)。
平台格式具体到“安卓一定是 ETC1 / iPhone 一定是 PVRTC”并不总准确(设备与图形 API 代际差异很大)。工程上更稳的做法是:为目标平台配置一套纹理格式策略,并用真机 profile 验证带宽与质量。
8. 用曲线拟合替代昂贵函数:特别是 pow(主要影响:运行时 GPU)
pow 在很多 GPU 上是“又常见又容易变贵”的操作(可能涉及对数/指数近似或特殊指令路径)。
8.1 常见替代方案
- 固定指数的幂:用乘法展开(例如 (x^2, x^4, x^5))。
1 | float x2 = x * x; // x^2 |
- 近似曲线:用低阶多项式或分段线性(step/lerp)拟合(尤其 UI/特效的“感觉曲线”)。
- 用纹理/LUT:把非线性响应做成 1D LUT(注意采样开销与缓存)。
8.2 什么时候别替代
PBR 里某些 pow(例如高光指数/分布函数相关)替代不当会明显破坏能量与观感;这种建议优先用引擎已有实现或经过验证的近似。
9. 用预处理/预计算优化:把“每像素”变成“离线或每帧一次”(主要影响:运行时 GPU / CPU)
你提到 IBL 环境光计算就是经典例子:
- 预计算辐照度(Diffuse IBL)
- 预滤波环境贴图(Specular IBL)
- BRDF 积分 LUT
这些本质上都是把复杂积分从实时搬到离线/预处理,实时只做少量采样与查表。
10. Shader Cache:从“运行时编译卡顿”到“几乎不感知”(主要影响:加载/首帧/切场景)
你给的对比非常典型:
- 无 shader cache:运行时编译,单次可能 170ms 级别(直接卡帧/卡加载)
- 有 shader cache:命中缓存或提前编译,可能降到 1ms 级别
占位图:无 cache 编译耗时(替换为你的截图路径)
占位图:有 cache 编译耗时(替换为你的截图路径)

10.1 工程侧常见做法(思路层)
- 预编译常用变体:构建时把“必用变体”编译出来,避免运行时抖动。
- 限制关键字数量:关键字/feature 爆炸会导致变体组合指数增长。
- warmup/预热:进场景前把关键 shader 触发一次(加载界面或后台线程),把卡顿从战斗/镜头切换挪走。
10.2 你需要防的坑
- “只在开发机没事,真机/线上卡”:往往是缓存没持久化、变体没包含、或者某些路径只在特定机型触发。
- “shader 变体太多导致包体/构建时间爆炸”:需要做变体剔除与白名单管理。
11. 指令集优化:看得懂统计,才能改得动(主要影响:运行时 GPU)
常见落点:
- 减少纹理采样与相关依赖(往往是第一收益)
- 减少寄存器压力:过多临时变量、过多分支与循环会让寄存器溢出到 local memory
- 合并计算:同一表达式多次出现(编译器不一定都能 CSE)
- 避免不必要的高精度与矩阵运算
12. 一个“能落地”的优化顺序(建议照这个排)
- 先处理首帧/切场景卡顿:把 shader cache / 预编译 / warmup 做起来(用户最直观)。
- 再处理纹理采样与格式:采样次数、dependent read、贴图压缩格式(通常是最大头)。
- 最后处理 ALU:pow/normalize/distance 重复、分支、能从 PS 挪到 VS 的计算。
13. 小结
Shader 优化的本质是一套“算力与带宽预算管理”:
- 少算:降精度、复用中间结果、搬到 VS、减少动态分支、用拟合/预计算替代昂贵函数。
- 少读:减少采样次数、避免 dependent read、选择亲和的纹理格式、合理通道打包。
- 不编译:用 shader cache/预编译把运行时卡顿移出关键路径。