Système de particules GPU en HLSL : passer le million
J'ai déplacé tout l'état des particules sur le GPU avec StructuredBuffer et DrawProceduralIndirect : de 40 000 à un million, mesures et pièges à l'appui.
La frame qui s'effondrait à quarante mille
Sur un prototype d'action sur lequel j'ai travaillé l'an dernier, jusqu'à trente explosions pouvaient coexister à l'écran. Dès que le système de particules côté CPU atteignait quarante mille particules vivantes, le profiler affichait 11,4 ms sur le main thread. Notre budget de frame était de 16,6 ms, et ce chiffre n'incluait encore ni la logique de jeu, ni l'animation, ni la physique.
L'endroit où partait le temps était évident. À chaque frame, nous parcourions quarante mille structures Particle, mettions à jour position et vitesse, puis recopiions les mêmes données dans un vertex buffer. Le seul appel à Mesh.SetVertexBufferData coûtait 2,1 ms. Burst et le Job System ont ramené la partie mise à jour à 3,8 ms, mais la copie, elle, n'a pas bougé d'un millimètre.
Ma première tentative a été de réduire simplement le nombre de particules : j'ai divisé par deux le compte par effet. Le temps de frame est tombé à 7,9 ms, mais les explosions paraissaient maigres et l'équipe artistique a protesté, à juste titre. Baisser le nombre n'est pas une solution, c'est admettre que le problème a gagné.
Le vrai problème n'était pas le coût des calculs, mais le trajet des données entre CPU et GPU. Si seule la GPU lit la position d'une particule, rien ne justifie que cette position vive en mémoire CPU. J'ai déplacé tout le système côté GPU avec un compute shader HLSL ; ce qui suit, ce sont les détails techniques de ce déménagement et les chiffres qu'il a produits.
Garder l'état dans un StructuredBuffer
L'état d'une particule tient dans un seul struct : float3 pos, float3 vel, float life, uint seed. Cela fait 32 octets, je n'ai donc jamais eu à ajouter de padding pour l'alignement. Un million de particules, c'est 32 Mo ; comme je fais du ping-pong, les deux buffers représentent ensemble 64 Mo de VRAM. Le champ seed n'est écrit qu'une fois, à la naissance, et garde l'aléatoire d'une particule déterministe toute sa vie.
Je lis via StructuredBuffer<Particle> et j'écris via RWStructuredBuffer<Particle>. Lire et écrire le même buffer dans un seul dispatch relève du comportement indéfini, car rien ne vous dit dans quel ordre les thread groups vont s'exécuter. Dans ParticleSystemGPU.cs, j'échange les deux références de buffer à chaque frame ; aucune mémoire n'est copiée, seules deux variables changent de place.
Maintenir la structure à 32 octets se paie de façon mesurable. Par curiosité, j'ai ajouté un champ half4 color, ce qui m'a amené à 40 octets, et le kernel de mise à jour est passé de 0,82 ms à 1,19 ms sur une RTX 3060. La couleur pouvait déjà se déduire de la durée de vie : la transporter dans le buffer n'apportait rien. Sur GPU, le goulot d'étranglement est le plus souvent la bande passante mémoire, pas l'arithmétique.
J'alloue avec GraphicsBuffer plutôt qu'avec ComputeBuffer. Les deux fonctionnent, mais GraphicsBuffer me permet de marquer la même allocation à la fois comme cible de compute et comme argument de draw indirect. Une seule allocation pour deux usages est plus rapide que d'en garder une copie, et cela supprime toute une famille d'erreurs.
Pourquoi je choisis 64 ou 128 threads par groupe
Le kernel commence par [numthreads(128, 1, 1)], parce que le matériel travaille déjà par vagues. Un warp fait 32 threads chez NVIDIA, une wave en fait 64 chez AMD. Si votre taille de groupe n'est pas un multiple exact de cette valeur, une partie de la dernière vague tourne dans le vide, et ce gaspillage se mesure.
J'ai mesuré le même kernel avec un million de particules à quatre tailles : 1,41 ms à 32 threads, 0,91 ms à 64, 0,82 ms à 128 et 1,05 ms à 256. 32 est mauvais parce que le coût fixe par groupe — test de bornes, lectures de constant buffer — se répète beaucoup trop souvent. 256 est mauvais parce que mon kernel occupe 40 registres, et plus la pression sur les registres monte, moins il tient de groupes simultanés sur un SM.
La capacité est rarement un multiple exact de la taille de groupe : la première ligne du kernel est donc if (id.x >= _Capacity) return;. Oubliez-la et le dernier groupe écrit au-delà de la fin du buffer ; sous Windows, vous obtenez en général des résultats faux et silencieux, parfois une réinitialisation du pilote par TDR. Je calcule le nombre de dispatches avec Mathf.CeilToInt(capacity / 128f), et arrondir la capacité à un multiple de 128 rend ce test presque gratuit.
Un million de particules en groupes de 128 donne 7 813 groupes, très en dessous de la limite de 65 535 sur un seul axe : je n'ai jamais eu besoin d'étaler sur une deuxième dimension. Au-delà d'environ quatre millions, on touche ce plafond et il faut faire entrer id.y dans l'équation. Je ne l'ai pas écrit d'avance ; généraliser pour un cas que je n'avais pas rendait le kernel illisible.
Recycler les particules mortes avec append/consume
Une particule qui arrive au bout de sa vie doit céder sa place à une nouvelle-née. Je fais cela avec AppendStructuredBuffer<uint> _DeadList : la particule mourante ajoute son propre index. Le kernel de spawn tire des index de cette même liste via ConsumeStructuredBuffer<uint>. Ainsi je n'écris jamais de boucle de balayage à la recherche d'un emplacement libre.
Append et consume reposent sur un compteur atomique : chaque appel fait la queue derrière les autres. Si toutes les particules meurent dans la même frame, ce compteur devient le goulot d'étranglement : dans un test synthétique où j'ai tout tué d'un coup, le kernel est passé de 0,82 ms à 1,26 ms. Dans les scènes réelles, les morts s'étalent dans le temps et l'écart est resté sous 0,05 ms, alors je n'y ai pas touché.
Le kernel de spawn tourne dans son propre dispatch, avant le kernel de mise à jour. Dans l'ordre inverse, les particules nouvellement nées étaient mises à jour une fois dans la même frame et prenaient une frame d'avance ; invisible en mouvement, mais cela laissait un petit trou au centre de chaque explosion. Je passe le nombre de spawns depuis le CPU sous forme d'un seul int, puisque c'est de toute façon la logique de jeu qui le décide.
Il y a ici deux pièges classiques. Le premier consiste à allouer le buffer sans le flag ComputeBufferType.Append : le shader compile, s'exécute, le compteur reste à zéro, et vous n'obtenez aucun message d'erreur. Le second est de remettre le compteur à zéro au mauvais endroit : je n'appelle SetCounterValue(0) que sur le buffer d'index vivants, à chaque frame, juste avant le dispatch. Appliquez le même appel à la dead list et vous effacez les emplacements libres accumulés — plus rien ne naîtra jamais.
DrawProceduralIndirect : ne jamais revenir au CPU
La GPU sait combien de particules sont vivantes ; le CPU, non. Demander ce nombre avec GetData revient à attendre que la file de commandes se vide — mesuré, un seul readback ajoutait 4 à 6 ms à la frame. Je dessine donc avec Graphics.DrawProceduralIndirect, et le compte ne passe jamais par le CPU.
Le buffer d'arguments contient quatre valeurs uint : nombre de vertices, nombre d'instances, vertex de départ, instance de départ. À chaque frame, j'appelle GraphicsBuffer.CopyCount(_aliveIndices, _argsBuffer, 4) pour recopier le compteur de vivants dans le deuxième emplacement. Le CPU ignore complètement la valeur de ce nombre ; il se contente d'écrire une commande de copie dans la file.
Côté vertex non plus, il n'y a pas de mesh. Je déduis l'index de particule et l'index de coin de SV_VertexID et je construis le quad directement dans le vertex shader, en y liant _ParticlesOut et _AliveIndices comme StructuredBuffer. Liaison de vertex buffer, index buffers, mises à jour de mesh : tout cela disparaît.
Voici l'écart mesuré. Le système CPU consommait 11,4 ms de main thread pour quarante mille particules ; le système GPU consomme 0,82 ms de compute, 1,6 ms de dessin et 0,05 ms de main thread pour un million. Vingt-cinq fois plus de particules, environ huit fois moins de temps de frame. Plus important encore, le CPU s'est retrouvé au repos et nous avons donné ce budget à l'IA.
Pièges de synchronisation et par où commencer
GroupMemoryBarrierWithGroupSync() ne synchronise que son propre groupe. Il n'existe aucune synchronisation entre groupes à l'intérieur d'un dispatch, et c'est la chose la plus déroutante quand on passe sur GPU. Si des particules voisines doivent se lire les unes les autres — collision, comportement de nuée, grille de voisinage —, cette étape doit devenir un second dispatch.
Le deuxième piège, c'est l'ordre. L'ordre à l'intérieur de _AliveIndices change à chaque frame, car rien ne garantit quel groupe finira en premier. Dessinez des particules en alpha blending dans cet ordre et l'image scintille d'une frame à l'autre ; ce n'est pas moi qui l'ai repéré en premier, c'était flagrant sur une capture au ralenti envoyée par la QA. Deux issues : trier par profondeur sur la GPU (un tri bitonique mesuré à 0,9 ms pour un million d'éléments) ou passer en blending additif. J'ai pris la seconde, puisque la plupart des effets étaient déjà additifs.
Vos habitudes de débogage doivent changer aussi. Pas de breakpoints, pas de Debug.Log. J'ouvre un RWStructuredBuffer<float4> séparé, j'y écris les valeurs intermédiaires qui me semblent suspectes, et je ne le relis que pendant une chasse au bug. Ce readback coûte les mêmes 4 à 6 ms, il vit donc à l'intérieur d'un bloc #if DEBUG_PARTICLES.
N'essayez pas de déplacer tout le système d'un coup. Prenez d'abord un seul effet — pour moi, les traînées de balles —, mettez-le sur GPU avec un buffer à capacité fixe, mesurez le dispatch dans RenderDoc, et n'ajoutez la dead list et le dessin indirect qu'ensuite. Choisir un million comme capacité dès le départ est tout aussi inutile : mesurez le pic de particules vivantes dans une vraie scène et prenez le double, et la VRAM comme le temps de mise à jour tomberont au bon endroit.