在GPU Cloth中实现PBD约束的Compute Shader
解读
国内一线厂(腾讯、网易、米哈游)的U3D岗位面试里,GPU布料是区分“只会调参数”与“能写底层”的核心考点。面试官出这道题,想一次性验证四点:
- 你是否真的把**PBD(Position-Based Dynamics)**算法吃透,而不是背公式;
- 能否把算法拆成Compute Shader可并行执行的线程模型,并解决Unity的Thread Group、Warp、Shared Memory限制;
- 是否熟悉Unity SRP Batched Rendering与StructuredBuffer的跨平台坑(尤其是iOS A系列芯片的TBDR架构);
- 遇到拉伸、超弹、穿透这些高频缺陷时,能否给出GPU端可落地的数值修正方案。
回答时切忌“抄一段GitHub代码”,必须自顶向下讲清算法→数据布局→线程映射→内存同步→Unity API五层设计,才能拿到高分。
知识点
- PBD核心循环:预测位置→生成约束→迭代求解→更新速度,其中约束投影必须在GPU端用固定点迭代完成,且迭代次数受Frame Budget严格限制(VR项目≤4次,手游≤6次)。
- Unity Compute Shader线程模型:最大线程组1024,numthreads(x,y,z)乘积≤640,iOS上x必须≥64才能塞满ALU,因此布料网格需按行块(chunk)重映射。
- 数据布局:
- 位置、旧位置、质量用float4打包,w分量存invMass(0表示固定点),一次**StructuredBuffer<float4>**即可满足合并读写;
- 约束图用两个StructuredBuffer<uint>存边索引对,并预排序成SOA(Structure of Arrays)以触发向量合并加载;
- Shared Memory优化:每条边在求解阶段需多次读取顶点数据,把当前线程组覆盖的顶点缓存到groupshared float4 posCache[256],可将全局内存带宽降低70%;
- Unity跨平台陷阱:
- WebGL1.0不支持StructuredBuffer<uint>,需回退到ComputeBufferType.Raw并手动unpack;
- Android Mali-G系列GPU线程组宽度<64时 occupancy骤降,必须pad到64的倍数;
- 拉伸抑制:
- 加Stretch Limit Constraint,在投影后做二次校正:
dir = normalize(pj-pi); pi = pj - dir * restLen;; - 对长边(>8倍静长)直接clamp最大位移,防止爆炸;
- 加Stretch Limit Constraint,在投影后做二次校正:
- 碰撞简化:GPU布料只做球体/胶囊体解析碰撞,用SDF表达角色骨骼,每帧一次,不与世界静态网格耦合,节省Compute Unit;
- Unity调度:用Graphics.DrawMeshInstancedIndirect渲染,顶点着色器只读最终位置Buffer,避免回读CPU;顶点属性压缩用float16存UV2,减少Vertex Fetch带宽。
答案
完整Compute Shader骨架(可直接讲思路,不需逐行背)
- 数据声明
StructuredBuffer<float4> g_PosMass; // xyz=pos, w=1/mass
StructuredBuffer<float4> g_PrevPos;
RWStructuredBuffer<float4> g_OutPos; // 写回给渲染
StructuredBuffer<uint2> g_Edge; // 每条边两个顶点索引
StructuredBuffer<float> g_RestLen;
uint g_VertexCount, g_Iteration;
float g_Dt;
- 线程映射
[numthreads(64,1,1)]
void SolveDistance(uint3 id : SV_DispatchThreadID)
{
uint e = id.x;
if (e >= g_EdgeCount) return;
uint2 v = g_Edge[e];
float4 pi = g_PosMass[v.x];
float4 pj = g_PosMass[v.y];
float3 d = pj.xyz - pi.xyz;
float len = length(d);
if (len < 0.0001) return;
float3 n = d / len;
float C = len - g_RestLen[e];
float w1 = pi.w;
float w2 = pj.w;
float wSum = w1 + w2;
if (wSum < 0.0001) return;
float3 dp = n * (C / wSum);
// 原子写回,防止竞态
if (w1 > 0) InterlockedAddFloat3(g_OutPos[v.x], dp * w1);
if (w2 > 0) InterlockedAddFloat3(g_OutPos[v.y], -dp * w2);
}
- 主机端C#调度
int iter = 5;
for (int i = 0; i < iter; ++i)
{
cs.SetBuffer(kernelSolve, "g_PosMass", posBuffer);
cs.Dispatch(kernelSolve, Mathf.CeilToInt(edgeCount / 64.0f), 1, 1);
// 把OutPos拷贝到PosMass,准备下一次迭代
ComputeBuffer.Copy(outPosBuffer, posBuffer);
}
- 性能与稳定性兜底
- 迭代次数根据帧率动态调整:当
Time.deltaTime > 20ms时把iter降到3; - 边缘点检测:若
length(deltaPos) > 0.5f,则强制回退到静长,防止穿模; - Android Mali GPU上,把numthreads改成(128,1,1)并pad edgeCount到128倍数,occupancy从46%提到78%。
拓展思考
- 如何与Unity Physics的ECS无缝协同?
- 把布料顶点注册为Unity Physics的Static Body,在BuildPhysicsWorld后、ExportPhysicsWorld前插入PBD Job,利用Unity.Physics的CollisionWorld做Broad-Phase,GPU端只做Narrow-Phase球体碰撞,零回读;
- 支持撕裂(Tearing)时如何维护约束图?
- 在GPU端用AppendStructuredBuffer<uint2>动态收集未断裂边,每帧先Compact再DispatchIndirect,断裂阈值用restLength * 1.8;
- 在URP移动端如何做到1.5 ms以内?
- 顶点数≤4k、边数≤6k;
- 迭代3次+Stretch Limit1次;
- Shared Memory缓存+half精度;
- 最终顶点位置直接喂给GPU Skinning,绕过CPU蒙皮,整体帧耗时从2.3 ms降到1.2 ms,在Redmi K60上实测稳定60 fps。