声明:本文部分内容使用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
| #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
| float halfLambert = dot(v_normal, lightDir) * 0.5 + 0.5;
float spec = specBase * specBase * specBase;
|
纹理采样优化
纹理采样是GPU性能杀手。
1 2 3 4 5 6
| 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);
|
优化策略:
- 合并贴图:把AO、Roughness、Metallic合并到一张图的RGB通道
1 2 3 4 5 6
| vec4 diffuse = texture2D(u_diffuseTex, v_uv); vec4 packed = texture2D(u_packedTex, v_uv); float ao = packed.r; float roughness = packed.g; float metallic = packed.b;
|
- 降低精度:非关键贴图用half精度
1 2
| half4 color = tex2D(_MainTex, i.uv);
|
- 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
| 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;
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 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; }
void main() { vec3 viewDir = normalize(u_cameraPos - v_worldPos); vec3 lightDir = normalize(u_lightPos - v_worldPos); }
|
注意:Fragment Shader里重复计算也有代价,要权衡。
批次合并
静态合批
Unity的静态合批是神器,但限制多。
踩坑:静态合批会在运行时合并网格,内存占用增加。一个场景几百个静态物体,内存多占几十MB。
GPU Instancing
大量相同物体用GPU Instancing。
1 2 3 4 5 6
| 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
|
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
|
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
| 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
| 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); }
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。
性能分析:
优化方案:
- 降低分辨率:Bloom用半分辨率或四分之一分辨率
- 减少迭代次数:从4次降到2次
- 阈值优化:只对高亮区域做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
| malioc -c Mali-G78 shader.frag
|
Adreno GPU
Adreno是Immediate Mode,和桌面GPU类似。
1 2 3 4
| 关键:ALU利用率 - 避免复杂数学运算 - 避免动态分支 - 纹理采样相对便宜
|
总结
这几年做Shader优化,最核心的经验:
先分析再优化:别凭感觉,用工具看数据。Frame Debugger、RenderDoc、Snapdragon Profiler都用上。
指令数是关键:Shader越简单越好。能省一条指令就省一条。
纹理采样是杀手:合并贴图、降低精度、用Mipmap。
合批能救命:Static Batching、GPU Instancing、SRP Batcher,根据场景选。
平台差异大:安卓碎片化严重,必须在目标设备上测试。
踩坑最多的地方:
- 开发机上性能没问题,低端机直接崩
- Shader变体爆炸,内存占用失控
- 后处理效果全开,带宽吃满
- 忽略Mipmap,远距离采样抖动严重
最后说一句:优化是取舍,不是追求极致。在效果和性能之间找平衡,才是正经事。