GPU-Partikelsystem mit HLSL Compute Shader: eine Million
Ich habe den Partikelzustand komplett auf die GPU verlagert – mit StructuredBuffer und DrawProceduralIndirect von 40.000 auf eine Million, samt Messwerten.
Der Frame, der bei vierzigtausend zusammenbrach
In einem Action-Prototyp, an dem ich letztes Jahr gearbeitet habe, konnten bis zu dreißig Explosionen gleichzeitig auf dem Bildschirm sein. Sobald das CPU-seitige Partikelsystem vierzigtausend lebende Partikel erreichte, zeigte der Profiler 11,4 ms auf dem Main Thread. Unser Frame-Budget lag bei 16,6 ms – und darin steckten Gameplay-Logik, Animation und Physik noch gar nicht.
Wohin die Zeit floss, war offensichtlich. In jedem Frame liefen wir vierzigtausend Particle-Structs durch, aktualisierten Position und Geschwindigkeit und kopierten dieselben Daten anschließend in einen Vertex Buffer. Allein der Aufruf Mesh.SetVertexBufferData kostete 2,1 ms. Mit Burst und dem Job System bekam ich den Update-Teil auf 3,8 ms herunter, aber das Kopieren blieb exakt dort, wo es war.
Mein erster Versuch war schlicht, weniger Partikel zu verwenden: Ich halbierte die Anzahl pro Effekt. Die Frame-Zeit fiel auf 7,9 ms, doch die Explosionen wirkten dünn, und das Art-Team protestierte zu Recht. Die Anzahl zu kürzen ist keine Lösung, sondern das Eingeständnis, dass das Problem gewonnen hat.
Das eigentliche Problem war nicht die Rechenlast, sondern der Weg der Daten zwischen CPU und GPU. Wenn nur die GPU die Position eines Partikels liest, gibt es keinen Grund, warum diese Position im CPU-Speicher liegen sollte. Ich habe das gesamte System per HLSL Compute Shader auf die GPU verlagert; was folgt, sind die technischen Details dieses Umzugs und die Zahlen, die dabei herauskamen.
Den Zustand im StructuredBuffer halten
Der Partikelzustand steckt in einem einzigen struct: float3 pos, float3 vel, float life, uint seed. Das sind 32 Byte, ich musste also nie Padding für das Alignment einfügen. Eine Million Partikel sind 32 MB; weil ich Ping-Pong-Buffer nutze, bedeuten beide zusammen 64 MB VRAM. Das Feld seed wird genau einmal bei der Geburt geschrieben und hält die Zufälligkeit eines Partikels über sein ganzes Leben deterministisch.
Gelesen wird über StructuredBuffer<Particle>, geschrieben über RWStructuredBuffer<Particle>. Denselben Buffer in einem Dispatch zu lesen und zu beschreiben ist undefiniertes Verhalten, denn nichts sagt Ihnen, in welcher Reihenfolge die Thread Groups laufen. In ParticleSystemGPU.cs tausche ich die beiden Buffer-Referenzen in jedem Frame; dabei wird kein Speicher kopiert, es wechseln nur zwei Variablen den Platz.
Das Struct bei 32 Byte zu halten zahlt sich messbar aus. Als Experiment habe ich ein Feld half4 color ergänzt, war damit bei 40 Byte, und der Update-Kernel sprang auf einer RTX 3060 von 0,82 ms auf 1,19 ms. Die Farbe ließ sich ohnehin aus der Lebensdauer ableiten, sie im Buffer mitzuschleppen brachte also nichts. Auf der GPU ist der Flaschenhals meist die Speicherbandbreite, nicht die Arithmetik.
Allokiert wird mit GraphicsBuffer statt mit ComputeBuffer. Beides funktioniert, aber GraphicsBuffer erlaubt mir, dieselbe Allokation gleichzeitig als Compute-Ziel und als Argument für einen Indirect Draw zu markieren. Eine Allokation für zwei Zwecke ist schneller, als eine zweite Kopie zu halten – und sie schließt eine ganze Fehlerklasse aus.
Warum ich 64 oder 128 Threads pro Gruppe wähle
Der Kernel beginnt mit [numthreads(128, 1, 1)], weil die Hardware ohnehin in Wellen arbeitet. Ein Warp umfasst auf NVIDIA 32 Threads, eine Wave auf AMD 64. Ist Ihre Gruppengröße kein exaktes Vielfaches davon, dreht ein Teil der letzten Welle leer – und dieser Verschnitt ist messbar.
Ich habe denselben Kernel mit einer Million Partikeln in vier Größen gemessen: 1,41 ms bei 32 Threads, 0,91 ms bei 64, 0,82 ms bei 128 und 1,05 ms bei 256. 32 ist schlecht, weil sich die festen Kosten pro Gruppe – Bounds Check, Lesen der Constant Buffer – viel zu oft wiederholen. 256 ist schlecht, weil mein Kernel 40 Register belegt und höherer Register-Druck senkt, wie viele Gruppen gleichzeitig auf einen SM passen.
Die Kapazität ist selten ein exaktes Vielfaches der Gruppengröße, deshalb steht in der ersten Zeile des Kernels if (id.x >= _Capacity) return;. Vergisst man das, schreibt die letzte Gruppe über das Ende des Buffers hinaus; unter Windows bekommt man meist still falsche Ergebnisse, manchmal einen TDR-Reset des Treibers. Die Zahl der Dispatches berechne ich mit Mathf.CeilToInt(capacity / 128f), und die Kapazität auf ein Vielfaches von 128 aufzurunden macht diesen Check praktisch kostenlos.
Eine Million Partikel ergeben in 128er-Gruppen 7.813 Gruppen und liegen damit weit unter dem Limit von 65.535 pro Achse; ich musste also nie auf eine zweite Dimension ausweichen. Ab etwa vier Millionen stößt man an diese Decke und muss id.y hinzunehmen. Ich habe das nicht vorab geschrieben; für einen Fall zu verallgemeinern, den ich nicht hatte, machte den Kernel unlesbar.
Tote Partikel mit Append/Consume recyceln
Ein Partikel, dessen Lebenszeit abläuft, muss seinen Platz an ein neu geborenes weitergeben. Das mache ich mit AppendStructuredBuffer<uint> _DeadList: Das sterbende Partikel hängt seinen eigenen Index an. Der Spawn-Kernel zieht über ConsumeStructuredBuffer<uint> Indizes aus derselben Liste. So schreibe ich nie eine Schleife, die den Buffer nach einem freien Platz absucht.
Unter Append und Consume liegt ein atomarer Zähler, jeder Aufruf reiht sich also hinter den anderen ein. Sterben alle Partikel im selben Frame, wird dieser Zähler zum Flaschenhals: In einem synthetischen Test, in dem ich alles auf einmal getötet habe, stieg der Kernel von 0,82 ms auf 1,26 ms. In echten Szenen verteilen sich die Tode über die Zeit, der Unterschied blieb unter 0,05 ms – also habe ich es so gelassen.
Der Spawn-Kernel läuft als eigener Dispatch, vor dem Update-Kernel. In umgekehrter Reihenfolge wurden neu geborene Partikel im selben Frame einmal aktualisiert und liefen einen Frame voraus; in Bewegung unsichtbar, aber es blieb ein kleines Loch in der Mitte jeder Explosion. Die Spawn-Anzahl übergebe ich von der CPU als einzelnen int, weil die Gameplay-Logik diese Zahl ohnehin bestimmt.
Hier lauern zwei klassische Fallen. Die erste ist, den Buffer ohne das Flag ComputeBufferType.Append zu allokieren; der Shader kompiliert, läuft, der Zähler bleibt bei null, und Sie sehen keinerlei Fehlermeldung. Die zweite ist, den Zähler an der falschen Stelle zurückzusetzen: SetCounterValue(0) rufe ich nur auf dem Live-Index-Buffer auf, in jedem Frame, direkt vor dem Dispatch. Wenden Sie denselben Aufruf auf die Dead List an, löschen Sie die angesammelten freien Plätze – und es entsteht nie wieder ein neues Partikel.
DrawProceduralIndirect: nie zurück zur CPU
Die GPU weiß, wie viele Partikel leben; die CPU nicht. Diese Zahl mit GetData abzufragen heißt, auf das Leerlaufen der Command Queue zu warten – gemessen kostete ein einziger Readback 4–6 ms pro Frame. Deshalb zeichne ich mit Graphics.DrawProceduralIndirect, und die Anzahl kommt nie bei der CPU vorbei.
Der Argument-Buffer besteht aus vier uint-Werten: Vertex-Anzahl, Instanz-Anzahl, Start-Vertex, Start-Instanz. In jedem Frame rufe ich GraphicsBuffer.CopyCount(_aliveIndices, _argsBuffer, 4) auf, um den Live-Zähler in den zweiten Slot zu kopieren. Die CPU hat keine Ahnung, wie hoch diese Zahl ist; sie schreibt lediglich einen Kopierbefehl in die Queue.
Auf der Vertex-Seite gibt es ebenfalls kein Mesh. Aus SV_VertexID leite ich Partikel-Index und Eckindex ab und baue das Quad direkt im Vertex Shader, wobei ich _ParticlesOut und _AliveIndices dort als StructuredBuffer binde. Vertex-Buffer-Bindings, Index Buffer, Mesh-Updates – all das fällt weg.
Der gemessene Unterschied sieht so aus. Das CPU-System verbrauchte für vierzigtausend Partikel 11,4 ms Main-Thread-Zeit; das GPU-System braucht für eine Million 0,82 ms im Compute, 1,6 ms beim Zeichnen und 0,05 ms auf dem Main Thread. Fünfundzwanzigmal so viele Partikel bei etwa achtmal weniger Frame-Zeit. Wichtiger noch: Die CPU lag brach, und wir konnten dieses Budget der KI geben.
Synchronisationsfallen und wo man anfängt
GroupMemoryBarrierWithGroupSync() synchronisiert nur die eigene Gruppe. Innerhalb eines Dispatch gibt es keine Synchronisation über Gruppen hinweg, und genau das verwirrt beim Umzug auf die GPU am meisten. Müssen benachbarte Partikel einander lesen – Kollision, Schwarmverhalten, ein Nachbarschaftsgitter –, muss dieser Schritt zu einem zweiten Dispatch werden.
Die zweite Falle ist die Reihenfolge. Die Ordnung in _AliveIndices ändert sich in jedem Frame, denn nichts garantiert, welche Gruppe zuerst fertig wird. Zeichnet man alphagemischte Partikel in dieser Reihenfolge, flackert das Bild von Frame zu Frame; aufgefallen ist es nicht zuerst mir, sondern es war in einer Zeitlupenaufnahme deutlich zu sehen, die QA geschickt hat. Es gibt zwei Auswege: auf der GPU nach Tiefe sortieren (ein Bitonic Sort lag gemessen bei 0,9 ms für eine Million Elemente) oder auf additives Blending wechseln. Ich habe den zweiten genommen, weil die meisten Effekte ohnehin additiv waren.
Auch die Debugging-Gewohnheiten müssen sich ändern. Es gibt keine Breakpoints und kein Debug.Log. Ich öffne einen separaten RWStructuredBuffer<float4>, schreibe die Zwischenwerte hinein, die mir verdächtig vorkommen, und lese ihn nur beim Fehlersuchen zurück. Dieser Readback kostet dieselben 4–6 ms, deshalb steckt er in einem #if DEBUG_PARTICLES-Block.
Versuchen Sie nicht, das ganze System auf einmal umzuziehen. Nehmen Sie zuerst einen einzelnen Effekt – bei mir waren es die Geschossspuren –, bringen Sie ihn mit einem Buffer fester Kapazität auf die GPU, messen Sie den Dispatch in RenderDoc und ergänzen Sie erst danach Dead List und Indirect Drawing. Von vornherein eine Million als Kapazität zu wählen ist ebenfalls sinnlos: Messen Sie die Spitzenzahl lebender Partikel in einer echten Szene und nehmen Sie das Doppelte – dann landen VRAM und Update-Zeit gemeinsam an der richtigen Stelle.