游戏着色器性能优化踩坑记录

声明:本文部分内容使用AI辅助生成,经人工编辑、审核和补充个人经验。

更新说明:本文最后更新于 2024-05-23。

游戏着色器性能优化踩坑记录

做手游这几年,被着色器性能坑过无数次。从Cocos Creator到Unity,从低端安卓机到旗舰iPhone,GPU性能问题一直是头疼的事。记录一下踩过的坑和摸索出来的优化经验。

第一次性能危机

去年做一个二次元风格的项目,美术效果要求很高:描边、Bloom、多Pass渲染。开发机上跑得飞起,结果测试组拿了一台红米Note 9,帧率直接掉到15帧。

当时整个人都懵了。开发机是iPhone 14 Pro,压根没考虑过低端机的感受。

GPU性能分析工具

Unity Frame Debugger

Unity自带的Frame Debugger是排查问题的第一步。

1
Window -> Analysis -> Frame Debugger

关键看几个指标:

指标 含义 健康值
SetPass Calls Shader切换次数 < 100
Draw Calls 绘制调用次数 < 200(移动端)
Batches 批次数量 越少越好
Triangles 三角面数 < 100K
Vertices 顶点数 < 150K

踩坑:Frame Debugger只能看一帧,不能看连续帧。有些性能问题是动态产生的,比如粒子爆发时。

RenderDoc

RenderDoc是神器,但配置麻烦。

1
2
3
# 安卓设备连接
adb devices
# 启动RenderDoc,选择设备,Capture帧

能看每个Draw Call的耗时、纹理采样次数、Shader指令数。

血泪教训:RenderDoc抓帧会严重影响性能,抓到的数据比实际高20-30%。只能做相对比较,不能当绝对值。

Snapdragon Profiler

高通芯片用这个最准。

1
2
# 需要高通设备,开启开发者选项
# 连接后能看到GPU时钟频率、ALU利用率、纹理带宽

关键指标:

  • ALU Utilization:计算单元利用率,超过80%说明Shader计算太重
  • Texture Busy:纹理采样繁忙度,超过60%说明采样太多
  • Vertex Busy:顶点处理繁忙度

Shader复杂度优化

指令数控制

Shader编译后的指令数直接影响性能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 反面教材:太多分支和计算
void main() {
vec4 color = texture2D(u_texture, v_uv);
if (u_hasLight) {
// 复杂光照计算
vec3 lightDir = normalize(u_lightPos - v_worldPos);
float diff = max(dot(v_normal, lightDir), 0.0);
color.rgb *= diff;

if (u_hasSpecular) {
vec3 viewDir = normalize(u_cameraPos - v_worldPos);
vec3 reflectDir = reflect(-lightDir, v_normal);
float spec = pow(max(dot(viewDir, reflectDir), 0.0), 32.0);
color.rgb += spec;
}
}
if (u_hasFog) {
float fogFactor = (u_fogEnd - v_depth) / (u_fogEnd - u_fogStart);
color.rgb = mix(u_fogColor, color.rgb, fogFactor);
}
gl_FragColor = color;
}

这段Shader在低端机上直接爆炸。分支太多,计算太重。

优化方案1:变体(Shader Variant)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 用multi_compile生成不同变体
#pragma multi_compile _ LIGHT_ON
#pragma multi_compile _ SPECULAR_ON
#pragma multi_compile _ FOG_ON

void main() {
vec4 color = texture2D(u_texture, v_uv);

#if LIGHT_ON
vec3 lightDir = normalize(u_lightPos - v_worldPos);
float diff = max(dot(v_normal, lightDir), 0.0);
color.rgb *= diff;

#if SPECULAR_ON
vec3 viewDir = normalize(u_cameraPos - v_worldPos);
vec3 reflectDir = reflect(-lightDir, v_normal);
float spec = pow(max(dot(viewDir, reflectDir), 0.0), 32.0);
color.rgb += spec;
#endif
#endif

#if FOG_ON
float fogFactor = (u_fogEnd - v_depth) / (u_fogEnd - u_fogStart);
color.rgb = mix(u_fogColor, color.rgb, fogFactor);
#endif

gl_FragColor = color;
}

变体在编译期确定,运行时没有分支。代价是Shader变体数量爆炸,内存占用高。

优化方案2:简化计算

1
2
3
4
// 简化版:用半Lambert代替标准Lambert
float halfLambert = dot(v_normal, lightDir) * 0.5 + 0.5;
// 避免pow,用乘法近似
float spec = specBase * specBase * specBase; // 近似pow(x, 3)

纹理采样优化

纹理采样是GPU性能杀手。

1
2
3
4
5
6
// 反面教材:一个Shader采样5张贴图
vec4 diffuse = texture2D(u_diffuseTex, v_uv);
vec4 normal = texture2D(u_normalTex, v_uv);
vec4 specular = texture2D(u_specularTex, v_uv);
vec4 emissive = texture2D(u_emissiveTex, v_uv);
vec4 ao = texture2D(u_aoTex, v_uv);

优化策略

  1. 合并贴图:把AO、Roughness、Metallic合并到一张图的RGB通道
1
2
3
4
5
6
// 合并后只需采样2张
vec4 diffuse = texture2D(u_diffuseTex, v_uv);
vec4 packed = texture2D(u_packedTex, v_uv); // R=AO, G=Roughness, B=Metallic
float ao = packed.r;
float roughness = packed.g;
float metallic = packed.b;
  1. 降低精度:非关键贴图用half精度
1
2
// Unity中声明half精度
half4 color = tex2D(_MainTex, i.uv);
  1. Mipmap:确保所有纹理都有Mipmap,远距离采样用低分辨率
1
2
// Unity中设置
TextureImporter -> Generate Mip Maps -> 勾选

顶点Shader优化

顶点计算也很耗。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 反面教材:在顶点Shader里做复杂计算
attribute vec4 a_position;
attribute vec3 a_normal;
attribute vec2 a_uv;

varying vec3 v_worldPos;
varying vec3 v_normal;
varying vec2 v_uv;
varying vec3 v_viewDir;
varying vec3 v_lightDir;
varying float v_depth;

void main() {
vec4 worldPos = u_modelMatrix * a_position;
gl_Position = u_viewProjMatrix * worldPos;

// 太多varying,带宽爆炸
v_worldPos = worldPos.xyz;
v_normal = mat3(u_modelMatrix) * a_normal;
v_uv = a_uv;
v_viewDir = normalize(u_cameraPos - worldPos.xyz);
v_lightDir = normalize(u_lightPos - worldPos.xyz);
v_depth = gl_Position.w;
}

优化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 精简varying,把计算移到Fragment Shader
varying vec3 v_normal;
varying vec2 v_uv;
varying vec3 v_worldPos;

void main() {
vec4 worldPos = u_modelMatrix * a_position;
gl_Position = u_viewProjMatrix * worldPos;
v_worldPos = worldPos.xyz;
v_normal = mat3(u_modelMatrix) * a_normal;
v_uv = a_uv;
}

// Fragment Shader里再算
void main() {
vec3 viewDir = normalize(u_cameraPos - v_worldPos);
vec3 lightDir = normalize(u_lightPos - v_worldPos);
// ...
}

注意:Fragment Shader里重复计算也有代价,要权衡。

批次合并

静态合批

Unity的静态合批是神器,但限制多。

1
2
3
4
5
6
7
// 标记为Static
// Inspector -> Static -> 勾选Batching Static

// 限制:
// 1. 顶点数 < 900(OpenGL ES 2.0)
// 2. 相同材质
// 3. 缩放不能不同(不能一个(1,1,1)一个(2,1,1))

踩坑:静态合批会在运行时合并网格,内存占用增加。一个场景几百个静态物体,内存多占几十MB。

GPU Instancing

大量相同物体用GPU Instancing。

1
2
3
4
5
6
// Unity中开启
Material mat = new Material(shader);
mat.enableInstancing = true; // 关键!

// 绘制
Graphics.DrawMeshInstanced(mesh, 0, mat, matrices);

Cocos Creator 3.x也支持

1
2
3
4
5
6
7
// Creator中,相同材质和mesh自动合批
// 需要开启GPU Instancing
const material = new Material();
material.initialize({
effectName: 'builtin-unlit',
defines: { USE_INSTANCING: true }
});

限制

  • 相同Mesh
  • 相同Material
  • 每个Instance数据通过Uniform或Buffer传递

SRP Batcher

Unity的SRP Batcher是新一代合批方案。

1
2
3
4
5
6
7
8
9
// 使用URP/HDRP自动开启
// 关键:Shader要兼容SRP Batcher

// Shader中声明
CBUFFER_START(UnityPerMaterial)
float4 _BaseMap_ST;
float4 _BaseColor;
float _Cutoff;
CBUFFER_END

效果

  • 不同材质也能合批,只要Shader相同
  • CPU开销大幅降低
  • 但GPU端没有减少Draw Call
合批方式 原理 优点 缺点
Static Batching 运行时合并网格 减少Draw Call 内存增加,限制多
Dynamic Batching CPU合并顶点 自动 CPU开销大,顶点限制
GPU Instancing GPU复制绘制 大量相同物体 需要相同Mesh和Material
SRP Batcher 材质数据缓存 兼容性好 只减CPU不减GPU

实际优化案例

案例1:二次元描边

项目需求:角色要有动漫风格的描边。

方案1:后处理描边

1
2
3
4
5
6
7
8
9
10
11
// 后处理Pass,检测边缘
vec4 color = texture2D(u_mainTex, v_uv);
vec4 up = texture2D(u_mainTex, v_uv + vec2(0, u_pixelSize.y));
vec4 down = texture2D(u_mainTex, v_uv - vec2(0, u_pixelSize.y));
vec4 left = texture2D(u_mainTex, v_uv - vec2(u_pixelSize.x, 0));
vec4 right = texture2D(u_mainTex, v_uv + vec2(u_pixelSize.x, 0));

float edge = length(up - down) + length(left - right);
if (edge > u_threshold) {
color = u_outlineColor;
}

问题:全屏后处理,每像素采样5次,带宽爆炸。

方案2:顶点外扩

1
2
3
4
5
6
7
8
9
10
11
12
13
// 顶点Shader:沿法线方向外扩
attribute vec4 a_position;
attribute vec3 a_normal;

void main() {
vec3 pos = a_position.xyz + a_normal * u_outlineWidth;
gl_Position = u_viewProjMatrix * u_modelMatrix * vec4(pos, 1.0);
}

// Fragment Shader:纯色
void main() {
gl_FragColor = u_outlineColor;
}

优化后:描边变成普通几何绘制,没有全屏后处理开销。

:外扩后描边宽度在透视下不均匀,远处太粗近处太细。

解决:在观察空间做外扩

1
2
3
4
vec4 viewPos = u_viewMatrix * u_modelMatrix * a_position;
vec3 viewNormal = mat3(u_viewMatrix) * mat3(u_modelMatrix) * a_normal;
viewPos.xy += normalize(viewNormal.xy) * u_outlineWidth;
gl_Position = u_projMatrix * viewPos;

案例2:Bloom效果

初始方案:全屏Bloom,4个Downsample + 4个Upsample Pass。

性能分析:

  • 每个Pass全屏绘制
  • 低端机直接掉10帧

优化方案

  1. 降低分辨率:Bloom用半分辨率或四分之一分辨率
1
2
// Unity URP中设置
// Pipeline Asset -> Bloom -> Downscale -> Quarter
  1. 减少迭代次数:从4次降到2次
1
2
3
4
// 简化版Bloom
// 1次Downsample到1/4
// 1次高斯模糊
// 1次Upsample合并
  1. 阈值优化:只对高亮区域做Bloom
1
2
3
vec4 color = texture2D(u_mainTex, v_uv);
float brightness = dot(color.rgb, vec3(0.299, 0.587, 0.114));
vec4 bloom = brightness > u_threshold ? color : vec4(0.0);

优化后:Bloom开销从3ms降到0.5ms。

平台差异

OpenGL ES 2.0 vs 3.0

ES 2.0限制多,优化空间大。

特性 ES 2.0 ES 3.0
纹理采样器 8个 16个
Varying变量 8个 16个
Uniform向量 128个 256个
整数运算 不支持 支持
MRT 不支持 支持

踩坑:ES 2.0下超过8个纹理采样器,Shader编译失败。有些手机驱动不报错,直接黑屏。

Mali GPU

Mali的Tile-Based架构特殊。

1
2
3
4
关键:减少带宽,因为Mali的带宽是瓶颈
- 避免大量纹理采样
- 避免Framebuffer读回
- 避免随机内存访问

Mali Offline Compiler

1
2
3
4
5
6
7
8
# 编译Shader看指令数
malioc -c Mali-G78 shader.frag
# 输出:
# Work registers: 12
# Uniform registers: 8
# Stack spilling: false
# 4x texture fetches
# 16x arithmetic operations

Adreno GPU

Adreno是Immediate Mode,和桌面GPU类似。

1
2
3
4
关键:ALU利用率
- 避免复杂数学运算
- 避免动态分支
- 纹理采样相对便宜

总结

这几年做Shader优化,最核心的经验:

  1. 先分析再优化:别凭感觉,用工具看数据。Frame Debugger、RenderDoc、Snapdragon Profiler都用上。

  2. 指令数是关键:Shader越简单越好。能省一条指令就省一条。

  3. 纹理采样是杀手:合并贴图、降低精度、用Mipmap。

  4. 合批能救命:Static Batching、GPU Instancing、SRP Batcher,根据场景选。

  5. 平台差异大:安卓碎片化严重,必须在目标设备上测试。

踩坑最多的地方:

  • 开发机上性能没问题,低端机直接崩
  • Shader变体爆炸,内存占用失控
  • 后处理效果全开,带宽吃满
  • 忽略Mipmap,远距离采样抖动严重

最后说一句:优化是取舍,不是追求极致。在效果和性能之间找平衡,才是正经事。