这篇文章把 Shader 优化拆成两类问题:

  • 算得太多(ALU):指令数高、昂贵函数太多、重复计算、分支导致浪费。
  • 读得太多(带宽/采样):纹理采样次数多、采样依赖(dependent read)、格式不亲和、缓存命中差。

最后再补上经常被忽略但收益巨大的工程项:Shader Cache / 预编译(你给的例子里,编译耗时从 170ms 降到 1.04ms)。

目标不是“把指令数降到极限”,而是在视觉可接受的前提下,把瓶颈从 GPU/编译/加载链路里移走,并让优化可验证、可回归。


0. 先说清楚:你要优化的是哪一种“慢”

Shader 相关的“慢”常见有三类,解决手段完全不同:

  1. 运行时 GPU 慢:帧时间里 Pixel/Vertex/Compute 占比高(看 GPU Profiler / RenderDoc / Xcode GPU Frame Debugger)。
  2. 运行时 CPU 慢(驱动/状态切换):材质变体过多、关键字爆炸、drawcall 状态切换开销高。
  3. 加载/首帧/切场景卡顿: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、体积等。

占位图:精度浪费示意(把你的截图放到仓库后替换路径)

![Shader 精度浪费](assets/20260417-shader-opt/precision-waste.png)


2. 尽量复用中间结果,避免重复计算(主要影响:运行时 GPU)

2.1 典型高成本组合:normalize / length / distance

这些经常会触发 dot + rsqrt + 额外的乘法;在热路径里重复调用非常亏。

反模式:

1
2
3
4
float3 v = posWS - _CenterWS;
float d = distance(posWS, _CenterWS);
float3 n = normalize(v);
float invD = 1.0 / length(v);

更好的写法(复用 dot / rsqrt):

1
2
3
4
5
float3 v = posWS - _CenterWS;
float d2 = dot(v, v);
float invD = rsqrt(max(d2, 1e-8));
float d = d2 * invD; // sqrt(d2) = d2 * rsqrt(d2)
float3 n = v * invD;

要点:

  • 复用 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
2
3
4
5
// VS
o.color = lerp(_ColorA, _ColorB, saturate(v.vertex.y * _InvHeight));

// PS
return i.color;

3.3 CPU 只算一次,别让 GPU 算上亿次

你提到的“某个矩阵/某个颜色”:如果它是 per-object 或 per-frame 常量,就应该在 CPU/常量缓冲里准备好,避免在 shader 里重复组合/拆分。


4. 避免动态分支:能用 step + lerp 就别 if(主要影响:运行时 GPU)

动态分支(每个像素分支条件不同)会导致:

  • 同一个 wave/warp 里分歧执行(两边都跑),吞吐下降
  • 指令与寄存器压力上升

4.1 轻量条件:step/lerp 替代 if

1
2
3
// if (x > t) a else b
float m = step(t, x);
float y = lerp(b, a, m);

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
2
3
float x2 = x * x;      // x^2
float x4 = x2 * x2; // x^4
float x5 = x4 * x; // x^5
  • 近似曲线:用低阶多项式或分段线性(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 编译耗时(替换为你的截图路径)

![无 Shader Cache](assets/20260417-shader-opt/no-cache.png)

占位图:有 cache 编译耗时(替换为你的截图路径)

![有 Shader Cache](assets/20260417-shader-opt/with-cache.png)

10.1 工程侧常见做法(思路层)

  • 预编译常用变体:构建时把“必用变体”编译出来,避免运行时抖动。
  • 限制关键字数量:关键字/feature 爆炸会导致变体组合指数增长。
  • warmup/预热:进场景前把关键 shader 触发一次(加载界面或后台线程),把卡顿从战斗/镜头切换挪走。

10.2 你需要防的坑

  • “只在开发机没事,真机/线上卡”:往往是缓存没持久化、变体没包含、或者某些路径只在特定机型触发。
  • “shader 变体太多导致包体/构建时间爆炸”:需要做变体剔除与白名单管理。

11. 指令集优化:看得懂统计,才能改得动(主要影响:运行时 GPU)

常见落点:

  • 减少纹理采样与相关依赖(往往是第一收益)
  • 减少寄存器压力:过多临时变量、过多分支与循环会让寄存器溢出到 local memory
  • 合并计算:同一表达式多次出现(编译器不一定都能 CSE)
  • 避免不必要的高精度与矩阵运算

12. 一个“能落地”的优化顺序(建议照这个排)

  1. 先处理首帧/切场景卡顿:把 shader cache / 预编译 / warmup 做起来(用户最直观)。
  2. 再处理纹理采样与格式:采样次数、dependent read、贴图压缩格式(通常是最大头)。
  3. 最后处理 ALU:pow/normalize/distance 重复、分支、能从 PS 挪到 VS 的计算。

13. 小结

Shader 优化的本质是一套“算力与带宽预算管理”:

  • 少算:降精度、复用中间结果、搬到 VS、减少动态分支、用拟合/预计算替代昂贵函数。
  • 少读:减少采样次数、避免 dependent read、选择亲和的纹理格式、合理通道打包。
  • 不编译:用 shader cache/预编译把运行时卡顿移出关键路径。