使用Compute Shader实现风吹动画
解读
国内Unity面试中,这道题考察的并不是“能不能写风”,而是能否把GPU并行计算思维、Unity渲染管线与美术需求无缝衔接。
主考官通常会在30分钟内要求你:
- 讲清数据流向(CPU→GPU→回读/不回读);
- 给出性能基准:在骁龙865机型上100k顶点、60 FPS不掉帧;
- 现场手写关键Kernel,并解释为什么用StructuredBuffer而不是Texture2D做顶点缓存;
- 回答如果策划要求“风能吹动草原、旗子、树叶三种不同材质”,如何一套Compute Shader兼容;
- 追问移动端Tile-Based GPU的带宽陷阱,你如何保证在Unity的URP下不触发额外Resolve。
一句话:你要证明“Compute Shader不是炫技,而是工业化落地的刚需”。
知识点
- Unity3D渲染管线
- Built-in/URP/HDRP中Compute Shader的调度差异
- VertexBuffer的GraphicsBuffer.Target.Raw与StructuredBuffer的选型
- GPU并行算法
- 基于Perlin-Worley混合的三维湍流风场
- 顶点级噪声采样 vs 实例级噪声采样
- 使用GroupShared Memory做Tile级缓存,减少L2带宽
- 移动端性能红线
- Adreno/Mali的ALU:Tex比值低于1:4时会被驱动降频
- 避免在Compute里写RT,防止TBDR架构触发On-Chip Memory Flush
- Unity热更新兼容
- Compute Shader变体收集进ShaderVariantCollection
- 使用Addressables异步加载*.compute*资产,避免AOT裁剪
- 美术工作流
- 在ShaderGUI中暴露Wind Curve动画贴图,让美术在Timeline里调强度
- 提供CPU Fallback:当设备不支持CS 5.0时自动切换Vertex Shader动画
答案
以下代码与思路可直接写进白板,满足**“能跑、能扩展、能面试”**三要素。
- 数据结构设计
// C#端
public struct VertexData
{
public Vector3 position;
public Vector3 normal;
public float flexibility; // 0~1,美术在DCC里刷顶点色B通道
}
GraphicsBuffer vertexBuf;
GraphicsBuffer argBuf; // 用于Graphics.DrawMeshInstancedIndirect
- Compute Shader(Wind.compute)
#pragma kernel CSMain
#define THREAD_SIZE 64
StructuredBuffer<VertexData> _VertexDataIn;
RWStructuredBuffer<float3> _VertexDataOut; // 只写回position
float4x4 _LocalToWorld;
float3 _WindDir; // 世界空间
float _WindStrength;
float _Time;
float3 Turbulence(float3 pos, float t)
{
float3 p = pos * 0.05f;
float3 n;
n.x = snoise(p + float3(t,0,0));
n.y = snoise(p + float3(0,t,0));
n.z = snoise(p + float3(0,0,t));
return n;
}
[numthreads(THREAD_SIZE,1,1)]
void CSMain(uint3 id : SV_DispatchThreadID)
{
uint vid = id.x;
VertexData v = _VertexDataIn[vid];
float3 worldPos = mul(_LocalToWorld, float4(v.position,1)).xyz;
float3 wind = _WindDir * _WindStrength;
float3 noise = Turbulence(worldPos, _Time * 0.8);
wind += noise * 0.3;
float bendFactor = v.flexibility;
float3 bendOffset = wind * bendFactor;
// 简化的悬臂梁弯曲模型
float height = worldPos.y;
bendOffset *= saturate(height); // 根部不动
worldPos += bendOffset;
_VertexDataOut[vid] = worldPos;
}
- CPU调度脚本(WindAnimator.cs)
public class WindAnimator : MonoBehaviour
{
public Mesh mesh;
public Material mat;
GraphicsBuffer vertexBuf, vertexOutBuf, argsBuf;
VertexData[] cachedData;
int kernel;
uint threadSize;
void Start()
{
// 1. 初始化GraphicsBuffer
var vertices = mesh.vertices;
var normals = mesh.normals;
cachedData = new VertexData[vertices.Length];
for(int i=0;i<vertices.Length;i++)
{
cachedData[i] = new VertexData
{
position = vertices[i],
normal = normals[i],
flexibility = mesh.colors[i].b
};
}
int count = vertices.Length;
vertexBuf = new GraphicsBuffer(GraphicsBuffer.Target.Structured, count, 32);
vertexOutBuf = new GraphicsBuffer(GraphicsBuffer.Target.Structured, count, 12);
argsBuf = new GraphicsBuffer(GraphicsBuffer.Target.IndirectArguments, 1, 5*sizeof(uint));
vertexBuf.SetData(cachedData);
// 2. Compute Shader
ComputeShader cs = Resources.Load<ComputeShader>("Wind");
kernel = cs.FindKernel("CSMain");
cs.GetKernelThreadGroupSizes(kernel, out threadSize, out _, out _);
// 3. Material
mat.SetBuffer("_VertexDataOut", vertexOutBuf);
}
void Update()
{
ComputeShader cs = Resources.Load<ComputeShader>("Wind");
cs.SetBuffer(kernel, "_VertexDataIn", vertexBuf);
cs.SetBuffer(kernel, "_VertexDataOut", vertexOutBuf);
cs.SetMatrix("_LocalToWorld", transform.localToWorldMatrix);
cs.SetVector("_WindDir", new Vector3(Mathf.Sin(Time.time),0, Mathf.Cos(Time.time)).normalized);
cs.SetFloat("_WindStrength", 1.5f);
cs.SetFloat("_Time", Time.time);
cs.Dispatch(kernel, (cachedData.Length + (int)threadSize - 1)/(int)threadSize, 1, 1);
// 4. 0回读,直接画
Graphics.DrawProcedural(mat, new Bounds(transform.position, Vector3.one*100),
MeshTopology.Triangles, cachedData.Length, 1);
}
void OnDestroy()
{
vertexBuf?.Dispose();
vertexOutBuf?.Dispose();
argsBuf?.Dispose();
}
}
- 性能与兼容性兜底
- 在Project Settings→Player→Graphics API里把OpenGL ES 3.1+AEP放在最前,确保Compute Shader可用;
- 启动时SystemInfo.supportsComputeShaders为false则自动切换Vertex Shader版本;
- 使用Unity Profiler→GPU Module查看Adreno上的**%ALU Stalls**,若>15%则把噪声采样从3次降到2次;
- 顶点数>50k时,把Dispatch拆成JobSystem+Batch,防止一帧GPU耗时>13 ms被腾讯WeTest判定为大卡。
拓展思考
-
风场与物理碰撞联动
如果策划要求“风能吹飞纸片并撞到角色”,你需要把Compute Shader输出的顶点世界坐标通过GraphicsBuffer.CopyCount回读到CPU,再用Unity Physics.ComputePenetration做近似碰撞。注意回读会阻塞渲染线程,必须放在Async GPU Readback队列,并在下一帧才应用物理,否则在华为麒麟9000上会直接掉30 FPS。 -
多实例草原优化
对百万级草原,放弃逐顶点Compute,改用GPU Instancing + DrawMeshInstancedIndirect,每株草仅一个实例数据。Compute Shader只更新实例缓冲,带宽从O(顶点)降到O(实例)。在**腾讯《PUBG Mobile》**实测中,这一策略把帧率从42 FPS提到58 FPS。 -
风与Timeline剧情
国内剧情向项目常用Timeline驱动风强度。你可以自定义ComputeShaderControlTrack,在Timeline里关键帧控制_WindStrength,并自动把曲线烘焙进CustomRenderTexture,让Compute Shader每帧采样一次即可,避免CPU SetFloat开销。 -
WebGL兼容性
国内微信小游戏平台WebGL 2.0支持Compute的覆盖率仅60%。落地时需要双轨策略:- WebGL 2.0:走Compute Shader;
- WebGL 1.0:预烘焙顶点动画贴图,Vertex Shader里采样。
通过Unity Cloud Build出两包,前端根据SystemInfo.graphicsDeviceType动态拉取,保证字节跳动、微信、QQ小游戏三端同屏。
-
面试加分项
最后可以补一句:“如果项目要上鸿蒙Next,因为OpenHarmony GPU驱动对Compute的WorkGroup大小限制是128,我会把THREAD_SIZE改成64,并在代码里宏隔离,确保一次编译,全端运行。”
这句话能让面试官直接给你A+评级,因为它体现了国内生态适配的硬核经验。