ホーム / ブログ / グラフィックス
公開日: 2026年3月28日 約 12 分 グラフィックス
HLSLのcompute shaderでGPUパーティクルを100万個描画する最適化と計測の実践記録

HLSLのcompute shaderでGPUパーティクルを100万個描画する最適化と計測の実践記録

CPU側のパーティクル更新が4万個で11.4msに達したため、状態をStructuredBufferに載せてHLSLのcompute shaderへ完全に移し、DrawProceduralIndirectで描画するまでの手順をまとめました。DeadListによる再利用と同期の落とし穴まで実測値付きで解説します。

4万個でフレームが破綻した

昨年関わっていたアクション系のプロトタイプでは、画面内に爆発が同時に30個近く出ることがあった。CPU側のパーティクルシステムが生存4万個に達すると、プロファイラはメインスレッドで11.4msを表示していた。フレーム予算は16.6msで、しかもこの数字にはゲームロジックもアニメーションも物理もまだ含まれていない。

時間の行き先ははっきりしていた。毎フレーム4万個のParticle構造体を走査して位置と速度を更新し、同じデータを頂点バッファへコピーしていた。Mesh.SetVertexBufferDataの呼び出しだけで2.1msかかる。更新部分はBurstとJob Systemで3.8msまで落とせたが、コピーはまったく動かなかった。

最初に試したのは単純に数を減らすことだった。エフェクトあたりの個数を半分にすると、フレーム時間は7.9msまで下がった。ただし爆発は貧相になり、アートチームからもっともな異議が出た。数を削るのは解決ではなく、問題に負けたと認めることでしかない。

本当の問題は計算コストではなく、データがCPUとGPUの間を往復していることだった。パーティクルの位置をGPUしか読まないなら、その位置がCPUメモリに置かれている理由はない。そこでHLSLのcompute shaderでシステム全体をGPU側へ移した。以下はその移行の技術的な中身と、実際に出た数字である。

StructuredBufferに状態を置く

パーティクルの状態は一つのstructに収めている。float3 posfloat3 velfloat lifeuint seedで合計32バイト、アライメントのためのパディングを足す必要は一度もなかった。100万個で32MB、ping-pongしているので二つのバッファを合わせて64MBのVRAMになる。seedは生成時に一度だけ書き込み、そのパーティクルの乱数を生涯にわたって決定的に保つ。

読み出しはStructuredBuffer<Particle>、書き込みはRWStructuredBuffer<Particle>で行う。同じバッファを一つのdispatch内で読み書きするのは未定義動作だ。スレッドグループがどの順で走るかは誰も保証してくれない。ParticleSystemGPU.csでは毎フレーム二つのバッファ参照を入れ替えている。メモリはコピーされず、変数が二つ入れ替わるだけだ。

構造体を32バイトに保つ効果は数字に出る。試しにhalf4 colorを足して40バイトにしたところ、更新カーネルはRTX 3060で0.82msから1.19msへ跳ね上がった。色は寿命から導出できていたので、バッファで運ぶ意味はまったくなかった。GPUのボトルネックは多くの場合、演算ではなくメモリ帯域である。

確保にはComputeBufferではなくGraphicsBufferを使っている。どちらでも動くが、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スレッド。グループサイズがその倍数でないと最後の波の一部が空回りし、その無駄は計測できるだけの大きさになる。

同じカーネルを100万個で四通り測った。32スレッドで1.41ms、64で0.91ms、128で0.82ms、256で1.05ms。32が悪いのは、境界チェックや定数バッファの読み出しといったグループごとの固定コストが何度も繰り返されるからだ。256が悪いのは、このカーネルがレジスタを40本使っており、レジスタ圧が上がるほどSMに同時に載るグループ数が減るからである。

容量がグループサイズの倍数になっているとは限らないので、カーネルの一行目はif (id.x >= _Capacity) return;だ。これを忘れると最後のグループがバッファの外へ書き込む。Windowsではたいてい黙って結果が壊れ、ときにはTDRでドライバがリセットされる。dispatch数はMathf.CeilToInt(capacity / 128f)で計算し、容量を128の倍数へ切り上げておくとこのチェックはほぼ無料になる。

100万個を128ずつに分けると7,813グループになり、単一軸の65,535という上限にはまるで届かないので、二次元へ展開する必要はなかった。おおよそ400万を超えるとその天井に当たり、id.yを持ち出すことになる。それを先回りして書くことはしなかった。手元にない状況のために一般化すると、カーネルが読めなくなる。

append/consumeで死んだパーティクルを再利用する

寿命が尽きたパーティクルは自分の枠を新しく生まれるものへ渡す必要がある。これはAppendStructuredBuffer<uint> _DeadListで行い、死んだパーティクルが自分のインデックスを追加する。spawnカーネルはConsumeStructuredBuffer<uint>で同じリストからインデックスを取り出す。おかげで空き枠を探す走査ループを一行も書かずに済む。

appendとconsumeの下にはアトミックカウンタがあり、呼び出しはすべて列に並ぶ。全パーティクルが同じフレームで死ぬとこのカウンタが詰まる。合成テストで一度に全滅させたところ、カーネルは0.82msから1.26msになった。実際のシーンでは死が時間的に散らばるため差は0.05ms未満にとどまり、ここには手を入れていない。

spawnカーネルは独立したdispatchとして、更新カーネルより先に走らせている。順序を逆にすると生まれたてのパーティクルが同じフレームで一度更新され、一フレーム分先へ進んでしまう。動いている間は分からないが、爆発の中心に小さな空洞が残った。生成数はCPUからint一つで渡している。どのみちその数を決めているのはゲームロジックだからだ。

ここには古典的な罠が二つある。一つ目はComputeBufferType.Appendフラグなしでバッファを確保すること。シェーダはコンパイルも実行もされ、カウンタはゼロのまま、エラーメッセージは一つも出ない。二つ目はカウンタをリセットする場所を間違えること。SetCounterValue(0)は生存インデックスのバッファにだけ、毎フレーム、dispatchの直前で呼んでいる。同じ呼び出しをDeadListに当てると貯めた空き枠が消え、二度と何も生まれなくなる。

DrawProceduralIndirectでCPUに戻らない

生存数を知っているのはGPUで、CPUではない。その数をGetDataで尋ねるのはコマンドキューが空になるまで待つことを意味し、実測では一度の読み戻しがフレームに4〜6msを足した。だから描画はGraphics.DrawProceduralIndirectで行い、この数はCPUに立ち寄らない。

引数バッファはuint四つ、頂点数、インスタンス数、開始頂点、開始インスタンスだ。毎フレームGraphicsBuffer.CopyCount(_aliveIndices, _argsBuffer, 4)を呼び、生存カウンタを二番目の枠へコピーする。CPUはその数がいくつなのか知らない。キューにコピー命令を一つ書き込むだけである。

頂点側にもメッシュはない。SV_VertexIDからパーティクルのインデックスと角のインデックスを導き、quadは頂点シェーダの中で組み立てる。_ParticlesOut_AliveIndicesはそこへStructuredBufferとしてバインドしている。頂点バッファのバインドもインデックスバッファもメッシュ更新も、まとめて消える。

実測の差はこうなった。CPU版は4万個でメインスレッドを11.4ms使っていた。GPU版は100万個でcomputeに0.82ms、描画に1.6ms、メインスレッドには0.05msしか使わない。パーティクルは25倍、フレーム時間はおよそ8分の1。それ以上に大きいのはCPUが空いたことで、その予算をAIへ回すことができた。

同期の落とし穴と、どこから始めるか

GroupMemoryBarrierWithGroupSync()が同期するのは自分のグループだけだ。一つのdispatchの中にグループ間の同期は存在しない。GPUへ移るときにいちばん混乱するのがここだと思う。隣り合うパーティクルが互いを読む必要があるなら——衝突、群れの挙動、近傍グリッド——その工程は二つ目のdispatchに分けるしかない。

二つ目の罠は順序である。_AliveIndicesの中身の並びは毎フレーム変わる。どのグループが先に終わるかは何も保証されていないからだ。アルファブレンドのパーティクルをその順で描くと絵がフレームごとにちらつく。最初に気づいたのは私ではなく、QAが送ってきたスローモーションの録画では一目瞭然だった。逃げ道は二つ、GPU上で深度ソートするか(bitonic sortは100万要素で0.9msだった)、加算ブレンドへ切り替えるか。私は後者を選んだ。エフェクトの大半はもともと加算だったからだ。

デバッグの習慣も変える必要がある。ブレークポイントはなく、Debug.Logもない。私は別にRWStructuredBuffer<float4>を用意し、疑わしい中間値をそこへ書き出して、不具合を追っているときだけ読み戻す。この読み戻しにも同じ4〜6msがかかるので、#if DEBUG_PARTICLESブロックの中に置いてある。

一度にシステム全体を移そうとしないほうがいい。まずエフェクトを一つ——私の場合は銃弾の軌跡だった——固定容量のバッファでGPUに載せ、dispatchの時間をRenderDocで測り、DeadListと間接描画はそのあとで足す。容量を最初から100万に決めるのも無意味だ。実際のシーンで生存数のピークを測ってその二倍を取れば、VRAMと更新時間の両方が妥当なところへ落ち着く。

← すべての記事