首页 / 博客 / 图形
发布: 2026年3月28日 约 12 分钟 图形
用HLSL compute shader把GPU粒子系统做到一百万个:状态全留在显存的优化与实测记录

用HLSL compute shader把GPU粒子系统做到一百万个:状态全留在显存的优化与实测记录

CPU端的粒子更新在四万个时就占掉主线程11.4毫秒,于是把粒子状态整体搬进StructuredBuffer,用HLSL compute shader更新,再由DrawProceduralIndirect直接绘制。文中记录线程组大小的取舍、DeadList回收机制与同步陷阱,并给出一百万粒子的实测数据。

四万个粒子时帧时间就崩了

去年参与的一个动作原型里,屏幕上可能同时出现将近三十个爆炸。当CPU端的粒子系统跑到四万个存活粒子时,性能分析器在主线程上显示11.4毫秒。我们的帧预算是16.6毫秒,而这个数字里还没有算进游戏逻辑、动画和物理。

时间花在哪里一目了然。每一帧都要遍历四万个Particle结构体,更新位置和速度,再把同一份数据拷进顶点缓冲。光是Mesh.SetVertexBufferData这一次调用就要2.1毫秒。用Burst和Job System把更新部分压到了3.8毫秒,但拷贝那一块纹丝不动。

我最先试的办法是干脆少放一些粒子:把每个特效的数量砍掉一半。帧时间降到7.9毫秒,可爆炸看起来单薄,美术组提出了合理的反对。砍数量不是解决方案,那是承认自己输给了问题。

真正的问题不是计算本身的开销,而是数据在CPU和GPU之间来回搬运。如果一个粒子的位置只有GPU会读,那它就没有任何理由待在CPU内存里。于是我用HLSL的compute shader把整套系统搬到了GPU上,下面是这次迁移的技术细节和跑出来的数据。

把状态放进StructuredBuffer

粒子状态放在一个struct里:float3 posfloat3 velfloat lifeuint seed,一共32字节,所以我从来没有为对齐加过填充。一百万个粒子就是32MB;因为要做ping-pong,两个缓冲加起来占64MB显存。seed只在出生时写一次,让一个粒子的随机性在整个生命周期里保持确定。

读用StructuredBuffer<Particle>,写用RWStructuredBuffer<Particle>。在同一次dispatch里对同一个缓冲又读又写属于未定义行为,因为没有任何机制告诉你线程组会按什么顺序执行。在ParticleSystemGPU.cs里我每帧交换两个缓冲的引用;内存不会被拷贝,只是两个变量换了位置。

把结构体压在32字节是能量化出收益的。我做过一次试验,加了一个half4 color字段变成40字节,更新核函数在RTX 3060上就从0.82毫秒涨到1.19毫秒。颜色本来就能由寿命推出来,放在缓冲里搬运没有任何意义。在GPU上,瓶颈通常是显存带宽而不是算术运算。

分配时我用GraphicsBuffer而不是ComputeBuffer。两者都能跑,但GraphicsBuffer允许我把同一块分配同时标记为compute的写入目标和间接绘制的参数。一块内存两用,比多留一份拷贝更快,也顺手消掉了一类容易犯的错误。

ParticleUpdate.compute
#pragma kernel CSUpdate struct Particle { float3 pos; float3 vel; float life; uint seed; }; StructuredBuffer<Particle> _ParticlesIn; RWStructuredBuffer<Particle> _ParticlesOut; AppendStructuredBuffer<uint> _DeadList; RWStructuredBuffer<uint> _AliveIndices; float _DeltaTime; uint _Capacity; [numthreads(128, 1, 1)] void CSUpdate(uint3 id : SV_DispatchThreadID) { // Capacity is rarely an exact multiple of the group size. if (id.x >= _Capacity) return; Particle p = _ParticlesIn[id.x]; if (p.life <= 0.0) return; p.vel += float3(0.0, -9.81, 0.0) * _DeltaTime; p.pos += p.vel * _DeltaTime; p.life -= _DeltaTime; _ParticlesOut[id.x] = p; // Dead slots return to the pool; live ones feed CopyCount. if (p.life <= 0.0) { _DeadList.Append(id.x); return; } _AliveIndices[_AliveIndices.IncrementCounter()] = id.x; }
hlslParticleUpdate.compute

为什么线程组我选64或128

核函数开头写的是[numthreads(128, 1, 1)],因为硬件本来就是按波来跑的。NVIDIA上一个warp是32个线程,AMD上一个wave是64个。如果线程组大小不是它的整数倍,最后一个波里就有一部分在空转,而这份浪费大到能被测出来。

同一个核函数,一百万粒子,我测了四种大小:32线程1.41毫秒,64线程0.91毫秒,128线程0.82毫秒,256线程1.05毫秒。32之所以差,是因为每组的固定开销——边界判断、常量缓冲读取——重复的次数太多。256之所以差,是因为我的核函数用掉40个寄存器,寄存器压力一高,一个SM上能同时驻留的组数就下降。

容量很少正好是线程组大小的整数倍,所以核函数第一行就是if (id.x >= _Capacity) return;。忘了它,最后一组就会越界写;在Windows上通常是悄无声息地算错,偶尔会触发TDR把驱动重置。dispatch的数量我用Mathf.CeilToInt(capacity / 128f)算,再把容量向上取整到128的倍数,这个判断几乎不花钱。

一百万个粒子按128一组是7813组,离单个轴65535的上限还差得远,所以我一直没有必要摊到第二个维度上去。大概超过四百万才会顶到那个天花板,那时就得把id.y用起来。我没有提前写这段:为一个还不存在的情况做泛化,只会让核函数变得没法读。

用append/consume回收死掉的粒子

寿命耗尽的粒子得把自己的槽位交给新生的那个。我用AppendStructuredBuffer<uint> _DeadList来做这件事:将死的粒子把自己的索引追加进去。spawn核函数则通过ConsumeStructuredBuffer<uint>从同一个列表里取索引。这样我一行找空槽的扫描循环都不用写。

append和consume底下是一个原子计数器,所以每一次调用都要排队。如果所有粒子在同一帧死掉,这个计数器就会成为瓶颈:在一次把全部粒子一次性杀掉的合成测试里,核函数从0.82毫秒变成了1.26毫秒。真实场景中死亡是分散在时间上的,差距一直在0.05毫秒以内,所以我没有去动它。

spawn核函数作为独立的一次dispatch,跑在更新核函数之前。顺序反过来的话,新生粒子会在同一帧被更新一次,整体提前一帧;运动中看不出来,但每次爆炸的中心会留一个小空洞。生成数量我从CPU用一个int传进去,反正决定这个数字的本来就是游戏逻辑。

这里有两个经典的坑。第一个是分配缓冲时漏掉ComputeBufferType.Append标志;着色器照样编译、照样运行,计数器一直是零,而且你连一条报错都看不到。第二个是把计数器重置放错了地方:SetCounterValue(0)我只对存活索引的缓冲调用,每帧一次,就在dispatch之前。要是把同样的调用用在DeadList上,攒下来的空槽会被抹掉,之后再也不会有粒子生成。

DrawProceduralIndirect:不再回到CPU

知道有多少粒子还活着的是GPU,不是CPU。用GetData去问这个数字,意味着要等命令队列排空——我实测过,一次回读就给一帧加上4到6毫秒。所以绘制我交给Graphics.DrawProceduralIndirect,这个数字压根不经过CPU。

参数缓冲是四个uint:顶点数、实例数、起始顶点、起始实例。每帧调用GraphicsBuffer.CopyCount(_aliveIndices, _argsBuffer, 4),把存活计数拷到第二个位置。CPU根本不知道那个数是多少,它只往队列里写了一条拷贝命令。

顶点这边也没有mesh。我从SV_VertexID推出粒子索引和角点索引,直接在vertex shader里把quad拼出来,_ParticlesOut_AliveIndices就以StructuredBuffer的形式绑到那里。顶点缓冲绑定、索引缓冲、mesh更新,全都不见了。

实测的差距是这样的。CPU版本在四万个粒子上吃掉主线程11.4毫秒;GPU版本在一百万个粒子上是compute 0.82毫秒、绘制1.6毫秒、主线程0.05毫秒。粒子多了二十五倍,帧时间反而降到大约八分之一。更重要的是CPU彻底闲了下来,那份预算被我们交给了AI。

同步的陷阱,以及从哪里开始

GroupMemoryBarrierWithGroupSync()同步的只是它自己那一组。一次dispatch内部不存在跨组同步,这是转到GPU上最容易把人绕晕的一点。如果相邻粒子需要互相读取——碰撞、群体行为、邻域网格——那一步就必须拆成第二次dispatch。

第二个陷阱是顺序。_AliveIndices里的排列每帧都在变,因为没有任何东西保证哪个组先结束。按这个顺序去画alpha混合的粒子,画面会一帧一帧地闪;最先发现的人不是我,QA发来的慢动作录像里一眼就能看出来。出路有两条:在GPU上按深度排序(bitonic sort在一百万个元素上我测到0.9毫秒),或者改用加法混合。我选了后者,因为大部分特效本来就是加法混合的。

调试习惯也得跟着改。没有断点,也没有Debug.Log。我会单独开一个RWStructuredBuffer<float4>,把怀疑的中间值写进去,只有在排查问题的时候才读回来。这次回读同样要花4到6毫秒,所以它一直待在#if DEBUG_PARTICLES块里。

不要想着一口气把整套系统搬过去。先挑一个特效——我这边是弹道拖尾——用固定容量的缓冲放到GPU上,用RenderDoc量一下dispatch的耗时,之后再加DeadList和间接绘制。一上来就把容量定成一百万也没必要:在真实场景里测出存活数量的峰值,取它的两倍,显存和更新耗时就都落在了合适的位置。

← 全部文章