L’usage de Compute Shaders sur Unity : un gain de performance violent

Phase 1 : fonctionnement de base du Compute Shader

(Ce kernel, qu’Unity propose par défaut, dessine un triangle de Sierpinski sur une texture)
  • Évacuons immédiatement la première ligne :
    #pragma kernel <function name> signifie simplement qu’on déclare, à l’avance, quelles fonctions vont être utilisées en tant que kernels, c’est-à-dire appelées par le CPU à un instant précis de notre choix.
  • La seconde ligne est une déclaration de variable. Devant le type Texture2D<float4>, on ajoute “RW” qui signifie “Read-Write”. Cette apposition précise que l’on va pouvoir modifier l’élément au cours de nos calculs : en l’occurrence, changer les pixels pour y faire des dessins.
    (Note pour toute personne découvrant le HLSL : “float4” est l’équivalent de “Vector4” sur Unity, on peut donc y faire correspondre une couleur de pixel)
  • L’attribut [numthreads(x,y,z)]. Je parlais de multithreading et de parallélisme plus haut : ici, on précise combien de threads simultanés vont être mobilisés pour exécuter la fonction. C’est un nombre en trois dimensions, ce qui n’a d’utilité que dans le but de faciliter l’organisation de nos calculs (faciliter pour nous, l’être humain derrière, et non pour la machine). Que je choisisse par exemple numthreads(16,16,2) ou numthreads(32,4,4) ne fait aucune différence au fait qu’un total de 512 threads seront mobilisés.
    (16*16*2 =32*4*4)
Je recopie simplement l’image précédente pour vous épargner le scrolling :)
  • L’argument de type uint3 et son semantic SV_DispatchThreadID. Cet argument passé automatiquement permet de repérer l’index exact du thread, unique, qui est en train d’effectuer un calcul. Cela est possible car, comme on va le voir du côté CPU, le nombre de threadgroups est également un nombre en trois dimensions. Par conséquent, pour un X, un Y et un Z donné, (ou juste un X et un Y si je laisse Z égal à 1), le nombre id.xy fera référence à un et un seul thread, donc un et un seul pixel !
Le schéma de Microsoft permet de bien comprendre l’organisation des threads. (Source)
(Si les quatre int ne sont pas déclarés plus haut, c’est juste pour garder compact cet exemple. Il y a toujours Start ou Awake pour initialiser ce genre de choses !)

Phase 2 : Récupérer nos résultats

Au Start, on crée une texture, on l’initialise, et on y autorise l’écriture. Après usage du shader, la texture peut servir n’importe où.
On utilise un type de structure personnalisé, MyStruct, déclaré juste au-dessus du buffer et du kernel.
  • Lorsque l’on le déclare, on doit spécifier les paramètres size (nombre d’éléments contenus, à la manière d’un tableau) et stride (poids, en bytes, d’un élément). Pour rappel une variable numérique pèse 4 bytes (32 bits), ainsi un float3 prendra par exemple 12 bytes. La struct de mon exemple ci-dessus prendra 4*(2+3+16) = 84 bytes. Et ainsi de suite.
  • Bonne pratique #1 : quitte à ajouter des variables supplémentaires (dummies) inutiles à notre struct, s’arranger pour que la stride tombe sur un multiple de 32 accélérera le calcul.
  • Bonne pratique #2 : on déclare deux fois la struct contenue par le buffer. Une fois dans le script en C#, une fois dans le Compute Shader. À l’identique. Pour que la correspondance soit assurée, elles doivent porter le même nom, les mêmes attributs, de même nom, dans le même ordre. Le type Vector3 correspond au type float3, le type Matrix4x4 correspond au type float4x4, et ainsi de suite.
  • On remplit le buffer avec une fonction SetData() qui y copiera le contenu d’un array adéquat (bon type, bonne taille).
  • On l’assigne dans le Compute Shader avec SetBuffer() (similairement à SetTexture()). Le Compute Shader est maintenant prêt à l’appel de Dispatch.
  • Après le Dispatch, on récupère les données du buffer (qui viennent d’être traitées) avec GetData() pour les copier vers un array adéquat, indiqué en argument.
  • Immédiatement après usage du buffer, on s’en débarrasse pour qu’il puisse être collecté par notre Garbage Collector, avec Dispose() ou Release().

Phase 3 : ce que permet et impose le multithreading

Plus d’infos à ce sujet ici
Source : docs.microsoft.com
  • Le premier argument (la valeur que l’on cherche à modifier) est un input, la valeur sera directement modifiée. Comme si, en C#, le mot-clé ref avait été utilisé.
  • On peut insérer un troisième argument, auquel cas celui-ci est un output, il stocke le résultat de notre opération à l’instant T. Pour autant, la fonction ne retourne rien. Comme si, en C#, le mot-clé out avait été utilisé.
On utilise ces deux lignes au lieu d’un “someSharedValue++”, et on utilise la valeur de myOutput.

Conclusion

--

--

--

I develop games and I write things. (Most of my writings may be in French and/or reposts from my previous blog)

Love podcasts or audiobooks? Learn on the go with our new app.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store
Simon Albou

Simon Albou

I develop games and I write things. (Most of my writings may be in French and/or reposts from my previous blog)

More from Medium

Making Enemies go BOOM part 2 — implementing explosion

Adding Cinemachine to Our Loot Chest Sequence

Unity’s Cinemachine: Getting to Know Virtual Cameras

Observer Pattern — Design Patterns for Unity #1