Les cartes graphiques/Le pipeline géométrique d'un GPU
Dans le chapitre précédent, nous avons vu qu'il y a une différence entre le pipeline géométrique des anciennes stations de travail et des ordinateurs personnels. Les premiers tendaient à utiliser des processeurs flottants, programmés avec un firmware/microcode non-modifiable. Les ordinateurs personnels ont eu commencé avec des circuits géométriques fixe, pour les rendre de plus en plus programmables. Dans ce chapitre, nous allons étudier les circuits géométriques d'un GPU d'ordinateur personnel, et voir comment ils ont évolués dans le temps.
Le vertex pipeline
[modifier | modifier le wikicode]Les premières cartes graphiques ne traitaient que des sommets, les primitives n'apparaissaient qu'à l'étape de rastérisation. Leur pipeline a progressivement évolué pour pouvoir exécuter des shaders sur des primitives, mais ce n'est apparu qu'avec DirectX 10. Avant, les unités géométriques ne géraient que des sommets. Nous allons voir de telles unités géométriques ici. Elles sont composées de trois circuits : l'input assembly, l'unité géométrique proprement dit, et l'assemblage des primitives.
| Cartes accélératrices PC, avant l'arrivée des shaders | |||
|---|---|---|---|
| Input assembly | Transform & Lighting | Primitive assembly | |
| Vertex shader | |||
Pour comprendre à quoi servent l'input assembler et l'assemblage de primitives, il faut parler de certaines optimisations présentes sur les cartes graphiques de l'époque.
Les représentations des maillages : les optimisations
[modifier | modifier le wikicode]Les optimisations visaient à réduire la mémoire prise pour les objets 3D. Pour rappel, les objets géométriques et la scène 3D sont mémorisés dans la mémoire vidéo, avec un assemblage de triangles collés les uns aux autres, l'ensemble formant un maillage. Pour mémoriser un maillage, il suffit d'utiliser une liste de triangles, chaque triangle étant définit par trois sommets consécutifs. Cependant, utiliser cette représentation gaspille beaucoup de mémoire !


Pour comprendre pourquoi, il faut savoir qu'un sommet est très souvent partagé par plusieurs triangles. Pour comprendre pourquoi, prenons l'exemple du cube de l'image ci-contre. Le sommet rouge du cube appartient aux 3 faces grise, jaune et bleue, et sera présent en trois exemplaires dans le tampon de sommets : un pour la face bleue, un pour la jaune, et un pour la grise. Et si vous croyez que l'exemple du cube n'est pas réaliste, voici un chiffre obtenu empiriquement, par analyse de maillages utilisés dans un JV : en moyenne, un sommet est dupliqué en 6 exemplaires.
Pour éviter ce gâchis, les concepteurs d'API et de cartes graphiques ont inventé des représentations pour les maillages, qui visent éliminer cette redondance. Nous les appellerons des représentations compressées, bien que ce terme soit un peu trompeur. Mais dans les faits, il s'agit bien d'une forme de compression de données, bien que très différente de celle utilisée pour compresser un fichier, de la vidéo, du texte ou de l'audio. La liste de triangle est en quelque sorte compressée lors de la création du maillage, puis décompressée par le matériel.
Les représentations compressées n'utilisent pas une liste de triangles, mais une liste de sommets. La liste de sommets est mémorisée en mémoire vidéo, et s'appelle le tampon de sommets. Ainsi, un sommet présent dans plusieurs triangles n'est mémorisé qu'une seule fois, ou presque. Reste à reconstituer les triangles à partir de cette liste de sommets. Et c'est le travail de l'input assembler et l'assemblage de primitive, justement. Mais avant de comprendre ce qu'ils font, nous devons voir les représentations compressées utilisées sur les cartes graphiques de l'époque.
Les premières versions d'Open GL et Direct X implémentaient deux représentations compressées : les triangle fans et celle des triangle strips. Elles ont été remplacées par la représentation indicée, apparue avec Direct X 7 et les versions équivalentes d'Open GL. Nous allons voir cette dernière en premier, car elle est plus simple.
La représentation indicée stocke les triangles et les sommets séparément, avec une liste de triangle séparée de la liste de sommets. Dit comme cela, on ne voit pas vraiment où se trouve le gain en mémoire. Mais il y a une astuce, qui tient à ce qu'on met dans la liste de triangles. Les sommets sont numérotés, le numéro indiquant leur place dans la liste de sommets. Dans la liste de triangles, un triangle est mémorisé non pas par trois sommets consécutifs, mais par trois numéros de sommets. Le numéro est aussi appelé l'indice du sommet, et la liste de triangles est appelée le tampon d'indices.
- Le terme '"indice" devrait rappeler quelques chose à ceux qui savent ce qu'est un tableau en programmation.
Le résultat est que les sommets ne sont pas dupliqués, mais on doit ajouter un tampon d'indice pour compenser. L'astuce est que l'économie en termes de sommets dépasse largement l'ajout du tampon d'indice. En effet, un indice prend moins de place qu'un sommet. Un sommet demande trois coordonnées, une couleur de sommet, des coordonnées de texture, une normale et bien d'autres attributs de sommets. En comparaison, un indice est un simple numéro, un nombre entier. En moyenne, un sommet prend 10 fois plus de place qu'un indice. Si on fait le compte, au lieu d'avoir N copies d'un sommet, on a juste une seule copie et N indices. L'économie liée à la taille des indices l'emporte.
- On pourrait remplacer les indices par des pointeurs, ce qui donnerait un cas particulier d'une structure de données connue sous le nom de vecteur de Liffe. Mais ce n'est pas très pratique et n'est pas utilisé dans le domaine du rendu 3D. Un numéro entier est plus court qu'un pointeur complet.

Les premières versions d'Open GL et Direct X implémentaient deux représentations compressées : les triangle fans et celle des triangle strips. Elles sont plus complexes, mais permettent une économie de mémoire encore plus importante.
La technique des triangles fan était la moins utilisée des deux, mais elle est plus simple à expliquer, ce qui fait que je commence avec elle. Elle permet de dessiner des triangles qui partagent un sommet unique, ce qui donne une forme soit circulaire, soit en forme d'éventail. Les triangles fans sont utiles pour créer des figures comme des cercles, des halos de lumière, etc. Un triangle est définit par le sommet partagé, puis deux sommets. Le sommet partagé n'est présent qu'en un seul exemplaire, et une autre optimisation permet d'optimiser les deux autres sommets.

Avec cette représentation, le tampon de sommets contient une liste de sommets, qui est interprétée sommet par sommet. Le premier sommet est le sommet partagé par tous les triangles du triangle fan. Le premier triangle est définit par le sommet partagé et deux nouveaux sommets. Les triangles suivants sont eux définit par un seul sommet, pas deux. En effet, deux triangles consécutifs partagent une arête, définie par le sommet partagé et un des deux sommets. Sur les deux sommets, le dernier sommet est celui de l'arête partagée. En faisant ainsi, un triangle est définit par un nouveau sommet, le sommet précédent dans le tampon de sommet, et le sommet partagé.
| Tampon de sommet | Triangle 1 | Triangle 2 | Triangle 3 | Triangle 4 | Triangle 5 | Triangle 6 | Triangle 7 | ... |
|---|---|---|---|---|---|---|---|---|
| Sommet 1 | X | X | X | X | X | X | X | X |
| Sommet 2 | X | |||||||
| Sommet 3 | X | X | ||||||
| Sommet 4 | X | X | ||||||
| Sommet 5 | X | X | ||||||
| Sommet 6 | X | X | ||||||
| Sommet 7 | X | X | ||||||
| Sommet 8 | X | X |
La technique des triangles strip optimise le rendu de triangles placés en série, comme illustré dans le schéma ci-dessous. Notez que deux consécutifs ont deux sommets en commun. L'idée est alors que quand on passe au triangle suivant, on ne précise que le sommet restant, pas les deux sommets en commun.

L'implémentation est assez simple : dans le tampon de sommets, trois sommets consécutifs forment un triangle. Et pour passer d'un triangle au suivant, on ne saute pas de trois sommets, on passe d'un sommet au suivant.
| Tampon de sommet | Triangle 1 | Triangle 2 | Triangle 3 | Triangle 4 | Triangle 5 | Triangle 6 | ... |
|---|---|---|---|---|---|---|---|
| Sommet 1 | X | ||||||
| Sommet 2 | X | X | |||||
| Sommet 3 | X | X | X | ||||
| Sommet 4 | X | X | X | ||||
| Sommet 5 | X | X | X | ||||
| Sommet 6 | X | X | X | ||||
| Sommet 7 | X | X | |||||
| Sommet 8 | X |
Les triangle fan et triangle strip permettent une économie de mémoire conséquente, comparé à la représentation non-compressée. Au lieu de trois sommets pour chaque triangle, on se retrouve avec un sommet pour chaque triangle, plus les deux premiers sommets. La comparaison avec l'usage d'un tampon d'indice dépend de la taille des indices, mais triangle fan et triangle strip sont plus économes niveau mémoire vidéo.
Un problème est que les triangle strip ne permettent pas de représenter tous les modèles 3D, certains ne sont simplement pas compatibles avec cette représentation. Et pour les triangle fan, c'est encore pire ! Cependant, il est souvent possible de ruser, ce qui permet de faire rentrer des modèles non-coopératifs dans un triangle strip, mais quelques sommets sont alors redondants.
L'input assembler et le tampon d'indice
[modifier | modifier le wikicode]Les représentations précédentes ont une influence importante sur le pipeline géométrique. Pour les gérer, il a fallu non seulement modifier l'assemblage de primitives, mais aussi rajouter un circuit juste avant l'unité géométrique : l'input assembler. Il charge les sommets depuis la mémoire vidéo, pour les injecter dans le reste du pipeline.

Pour faire son travail, il a besoin de l'adresse des données géométriques en mémoire, leur taille et éventuellement du type des données qu'on lui envoie (sommets codées sur 32 bits, 64, 128, etc). En clair, il doit connaitre l'adresse du tampon de sommet et éventuellement celle du tampon d'indice. Et en général, c'est une unité d'accès mémoire un peu particulière, qui contient des circuits assez classiques pour ce genre de circuits : des circuits de calcul d'adresse, des circuits pour commander la mémoire VRAM, un contrôleur mémoire, diverses mémoires tampons, etc.
Il procède différemment suivant la représentation utilisée. Il peut lire trois sommets consécutifs avec une représentation non-compressée, il peut lire un tampon d'indice et l'utiliser pour charger les sommets adéquats, il peut lire un sommet à la fois avec les triangle fan/strip, etc. Tout dépend de comment l'unité est configurée.
Dans ce qui suit, nous allons étudier un input assembler qui gère la représentation indicée. Il peut être adapté pour gérer les autres représentations assez simplement. L'idée est que l'input assembler est composé de trois circuits principaux : un qui lit le tampon d'indice, un autre qui lit le tampon de sommets, un dernier qui package les sommets. Le premier lit les indices depuis la mémoire vidéo. Le second récupère l'indice chargé par le premier, et lit le sommet associé dans le tampon de sommets. Ils sont respectivement appelés avec les noms : index fetch et vertex fetch. Le dernier circuit se contente de formater les sommets pour qu'ils soient compréhensibles par les unités géométriques.

Pour les représentations autres qu'indicée, seul le vertex fetch est utilisé. Il se contente alors de balayer le tampon de sommets dans l'ordre, du premier sommet au dernier. Un vulgaire compteur d'adresse suffit pour cela.
Avec la représentation indicée, le circuit d'index fetch est utilisé. Il balaye un tableau d'indices du début à la fin, ce qui fait que le calcul d'adresse est réalisé par un simple compteur d'adresse. Le circuit de vertex fetch fait des calculs d'adresse un chouilla moins simples, mais qui se contentent de combiner l'adresse du tampon de sommets avec l'indice.
Les unités de index fetch et de vertex fetch font donc des calculs d'adresse et des accès mémoire. Par contre, les deux circuits peuvent implémenter des mémoires caches, pour améliorer les performances.
Vous remarquerez que l’input assembler fait surtout des calculs d'adresse, des lectures en mémoire, et des conversions de format de données. Un processeur de vertex shader peut faire la même chose, ce qui fait qu'il est possible d'émuler l'input assembler avec un vertex shader. La seule condition, absolument nécessaire, est que le vertex shader puisse lire des données en mémoire vidéo. Et pas seulement lire des textures, comme le permettent les techniques de vertex texturing, mais de vraies lectures arbitraires, pour lire les tampons de sommet/indice.
Cette possibilité est arrivée avec Direct X 10, ce qui fait que l’input assembler peut être émulé par les vertex shaders à partir de cette version de Direct X. De nos jours, tous les GPUs font à leur sauce. Certains émulent l’input assembler avec des shaders, d'autres non. Ceux qui le font le font en modifiant les vertex shaders. Le driver du GPU injecte du code dans les vertex shaders, code qui émule l'input assembler.
Les caches de sommets : une optimisation du tampon d'indice
[modifier | modifier le wikicode]Idéalement, le vertex shader doit être exécuté une seule fois par sommet (idem pour son équivalent avec une unité de T&L). Mais quand des sommets sont dupliqués, ce n'est pas le cas. Le problème se comprend bien si on prend une représentation non-compressée, où les sommets sont dupliqués si nécessaires. Le résultat est que les copies d'un même sommet sont toutes lues depuis la mémoire, transformées, éclairées, puis envoyées à l'unité d'assemblage de primitives. En clair : un sommet est lu en VRAM plusieurs fois, et subit des calculs géométriques redondants. Ce qui est un problème.
Les représentations compressées permettent de grandement réduire cette redondance. Les triangle strip et triangle fan sont de loin les plus efficaces, de ce point de vue : un sommet n'est chargé qu'une seule fois, et n'est traité qu'une seule fois. Du moins, si tout se passe bien. En effet, pour convertir un modèle 3D en triangle strip/fan, il faut parfois ruser, ce qui fait que des sommets sont redondants.
Avec la représentation indicée, l'input assembler doit détecter quand un sommet dupliqué a déjà été rencontré. Si un tel sommet dupliqué est détecté, on récupère le sommet déjà calculé, plutôt que de refaire les calculs. Mais cela demande d'ajouter une mémoire cache pour mémoriser les sommets transformés/éclairés. Elle est appelée le Post Transform Cache et il est crucial pour éviter les calculs redondants.
L'idée est la suivante : en sortie de l’index fetch, un circuit regarde les indices chargés et vérifie s'ils ont déjà été rencontrés. Si l'indice est inconnu, alors on suppose que le sommet associé n'a jamais été rencontré. L'indice est envoyé à l'unité de vertex fetch, le sommet est chargé depuis le tampon de sommet et envoyé à l'unité géométrique. Par contre, si l'indice est reconnu, c'est que le sommet associé a déjà été transformé/éclairé : on lit alors le sommet transformé depuis le Post Transform Cache.
Pour détecter un sommet déjà rencontré, rien de plus simple : il suffit de consulter le Post Transform Cache. Une fois un indice chargé, le Post Transform Cache est consulté pour vérifier s'il a une copie du sommet associé. Le cache répond alors soit en disant qu'il n'a pas le sommet associé, soit il renvoie le sommet transformé. Le Post Transform Cache est consulté en lui envoyant l'indice du sommet, et potentiellement de quoi identifier le tampon d'indice utilisé. C'est pour ne pas confondre deux sommets appartenant à deux modèles différents mais qui ont le même indice par hasard. Deux solutions pour cela : soit on utilise un identifiant pour le tampon d'indice utilisé (pas une adresse), soit on vide le cache entre deux draw call.
Il est vraisemblable que tout soit plus compliqué. En, effet, il faut tenir compte du cas où un sommet est en cours de calcul. Pour gérer ce cas, il est probable que l’input assembler réserve de la place dans ce cache à l'avance. Quand un sommet est envoyé aux unités géométriques, l’input assembler doit réserver de la place dans le cache, en mettant l'indice dans le tag du cache, et en laissant la ligne de cache vide.
Le Post Transform Cache mémorise les N derniers sommets rencontrés. Elle est souvent qualifiée de mémoire FIFO, mais c'est un intermédiaire entre une mémoire cache du point de vue des lectures, et une mémoire FIFO du point de vue des écritures. Il mémorise entre 16 et 64 sommets, pas plus. Aller au-delà ne sert pas à grand chose, vu que des sommets dupliqués sont très souvent proches en mémoire RAM et sont traités dans une fenêtre temporelle assez petite.

Le Post-transform cache se trouve donc en sortie de l'unité d’index fetch. Mais serait-il possible d'ajouter un second cache, cette fois-ci pour l'unité de vertex fetch ? Un tel cache existe lui aussi, et s’appelle le pre-transform cache. Il mémorise les sommets chargés, mais pas encore transformés/éclairés. Il se situe entre l'unité de vertex fetch et l'unité géométrique.
Intuitivement, on se dit qu'il évite de charger un sommet plusieurs fois. Mais ce n'est en réalité qu'un intérêt secondaire, bon à prendre, mais pas primordial. En réalité, il permet de profiter du fait que le vertex fetch charge les sommets par paquets de 32 à 64 sommets, qui sont copiés dans le cache de sommets. Ainsi, quand on charge un sommet, les 32/64 suivants sont chargés avec et sont disponibles pour l'unité de vertex shader si celle-ci en a besoin dans le futur, ce qui a de très fortes chances d'être le cas. De plus, il est possible de précharger des lignes de cache : quand le vertex fetch lit un paquet de sommets, le paquet de sommet est copié dans le cache, mais les paquets suivants peuvent aussi être chargés en avance. Une telle technique de 'préchargement permet d'améliorer les performances.

Pour résumer, l’input assembler contient deux caches, qui sont collectivement appelés des caches de sommets. Le Post Transform Cache a disparu dans certains GPU modernes. Je recommande la lecture de l'article "Revisiting The Vertex Cache : Understanding and Optimizing Vertex Processing on the modern GPU" à ce sujet. Quant au Pre Transform Cache, il a été remplacé par des mémoires caches généralistes, qui ne sont pas spécialisées dans les sommets.
L'assemblage de primitives
[modifier | modifier le wikicode]En sortie des unités géométriques, on a des sommets éclairés et colorisés, pas des triangles. Pour recréer des triangles, on doit lire les sommets dans l'ordre adéquat, par paquets de trois pour obtenir des triangles. C'est le rôle de l'étape d'assemblage de primitives (primitive assembly), qui regroupe les sommets appartenant au même triangle, à la même primitive. L'assemblage des primitives est réalisée par un circuit fixe, non-programmable, qui utilise le tampon d'indice pour regrouper les sommets en primitives.
Un problème pour l'assemblage de primitives est que les sommets n’arrivent pas dans l'ordre. Il arrive que des sommets soit traités plus vite que les autres, et passent devant. Le pipeline ne peut pas se baser sur l'ordre d'arrivée des sommets, pour regrouper les sommets en triangles. Pour gérer ces temps de calcul variable, le pipeline mémorise les triangles en sortie des unités géométriques et attend que tous les sommets d'un triangles soient disponibles. La méthode pour cela dépend de la représentation utilisée. L'assemblage des primitives ne se passe pas pareil avec les triangle strip, triangle fan, représentation indicée et représentation non-compressées.
Avec la représentation non-compressée, l'assemblage de primitives regroupe les triangles par paquets de trois, rien de plus. Mais attention, des triangles consécutifs en mémoire ne sortent pas des unités géométriques l'un à la suite de l'autre. Pour gérer ça, l'input assembler associe, un numéro à chaque triangle, qui indique sa place dans le tampon de sommets, qui est un indice. L'assemblage de primitive regarde ces numéros pour regrouper les triangles. Il attend que trois numéros consécutifs soient disponibles pour assembler le prochain triangle.
Pour l'adressage indicé, il procède comme la représentation non-compréssée, sauf qu'il regarde le tampon d'indice. Il lit le tampon d'indice en partant du début, et fait des groupes de trois indices consécutifs. Les sommets sont associés avec leur indice, qui les accompagne lors de leur trajet dans le pipeline géométrique. Une fois qu'ils sortent des unités géométriques, ils sont accumulés dans une mémoire juste avant l'unité de primitive, et l'assemblage de primitive attend que les trois sommets avec les trois indices adéquats soient disponibles.
Avec les triangle strip, il mémorise les deux derniers sommets chargés, pour les combiner avec le prochain sommet à charger. L'implémentation matérielle est assez simple : un registre pour mémoriser le premier sommet, une mémoire FIFO pour mémoriser les deux sommets les plus récents. Pour générer un triangle, l'étape d'assemblage de primitive lit le registre et la mémoire FIFO, pour récupérer les trois sommets. Avec les triangle fan, il doit mémoriser le sommet partagé, et le dernier sommet chargé, ce qui demande deux registres.
Les geometry shaders
[modifier | modifier le wikicode]Les GPU d'avant DirectX 10, qui n'avaient que les vertex shaders et ne pouvaient manipuler que des sommets. Depuis DirectX 10, le pipeline graphique a intégré des techniques pour gérer nativement des triangles dans les shaders. Dans ce chapitre, nous allons étudier le pipeline graphique de DirectX 10, DirectX 11 et DirectX 12. L'intérêt est que cela permet de faciliter l'implémentation de techniques de tesselation, sans compter que certaines optimisations deviennent plus simples à effectuer. Dans ce chapitre, nous allons étudier le pipeline graphique de DirectX 10, DirectX 11 et DirectX 12.
DirectX 10 et OpenGl 3.2 ont introduit les geometry shaders, juste avant l'étape d'assemblage des primitives. Les geometry shaders peuvent ajouter, supprimer ou altérer des primitives dans une scène 3D. Un geometry shader prend en entrée un point, une ligne ou un triangle, donc les trois primitives de base supportées sur les GPU modernes. Il émet en sortie : soit un triangle strip, soit une line strip (c'est à une ligne ce qu'un d'un triangle strip est à un triangle) ou un point.
Ils n'ont pas été très utilisés, leurs utilisations étant assez limitées. Ils peuvent en théorie être utilisés pour la gestion des cubemaps, le shadow volume extrusion, la génération de particules, et quelques autres effets graphiques. Ils pourraient aussi être utilisés pour faire de la tesselation, mais leurs limitations font que ce n'est pas pratique. Rappelons que les geometry shaders sont optionnels et que beaucoup de jeux vidéos ou de moteurs de rendu 3D n'en utilisent pas.
La conservation de l'ordre des sommets entrants et sortants
[modifier | modifier le wikicode]Les geometry shaders sont exécutés après l'assemblage de primitive, car ils manipulent les primitives fournies par l'étape d'assemblage des primitives. Les geometry shaders n'ont jamais eu de processeur de shader dédié, car ils ont été introduits avec DirectX 10 et OpenGl 3.2, en même temps que les processeurs de shaders ont étés unifiés (rendu capable d’exécuter n'importe quel shader). Leur place dans le pipeline graphique est quelque peu étrange.
Un point important est que DirectX 10 impose de conserver l'ordre d'envoi des sommets. Si les sommets arrivent dans un certain ordre, il ressortent du geometry shader dans ce même ordre. Faire ainsi simplifie grandement les choses pour le programmeur. Mais cela impose des contraintes pour le GPU. Les sommets ont beau être envoyés dans l'ordre aux processeurs, certains peuvent être traités plus vite que les autres. Et quand on distribue des sommets sur pleins de processeurs de shader, cela fait que l'ordre de sortie change. Pour corriger cela, les sommets sortants du geometry shader doivent être remis en ordre.
Une première solution est de les mettre en attente dans un second tampon de primitives, pour les remettre en ordre avant la rastérisation. Les primitives sortent des geometry shaders dans le désordre, sont ajoutées dans le tampon de primitive dans le désordre, mais la rastérisation les consomme dans l'ordre.

Au passage, j'ai menti plus haut en disant que les geometry shaders fournissent en entrée de 0 à plusieurs primitives : la sortie d'un geometry shader est un ensemble de sommets, souvent complété par un mini-tampon d'indice indiquant comment assembler ces sommets en primitives. Le résultat est que l'assembleur de primitive doit refaire son travail après le passage d'un geometry shader. Heureusement, la sortie d'un geometry shader est soit un point, soit une ligne, soit un triangle strip, ce qui simplifie la seconde phase d'assemblage des primitives.
Avec les geometry shaders, il y a donc deux phases d'assemblage des primitives : une phase avant, décrite dans la section précédente, et une seconde phase simplifiée après les geometry shaders. Il n'y a pas que la phase d'assemblage de primitives qui est dupliquée : le tampon de primitives l'est aussi. On trouve donc un tampon de primitives à l'entrée des geometry shaders et un autre à la sortie.
Les complications liées à la sortie des geometry shaders
[modifier | modifier le wikicode]J'ai dit plus haut que le GPu incorpore un second tampon de primitives. Mais sur quelques GPU, les résultats d'un geometry shader ne passent pas directement par un second tampon de primitives. À la place, ils sont mémorisés en mémoire vidéo, avant d'être lu par l'assemblage de primitives. C'était très lent, mais c'est nécessaire pour une raison qu'on va expliquer immédiatement.
Un geometry shader fournit un résultat très variable en fonction de ses entrées. Pour une même entrée, la sortie peut aller d'une simple primitive à plusieurs dizaines. Le geometry shader précise cependant un nombre limite de sommets qu'il ne peut pas dépasser en sortie. Il peut ainsi préciser qu'il ne sortira pas plus de 16 sommets, par exemple. Et ce nombre maximal est celui qui est utilisé pour savoir comment organiser le tampon de primitive.
Par exemple, si jamais on a un tampon de primitive capable de mémoriser 1024 sommets, celui-ci peut être partitionné en 512 blocs de deux sommets, ou 256 blocs de 4 sommets, 128 blocs de 4 sommets, etc. Pour savoir comment subdiviser le tampon de primitives en parts égales, il n'y a qu'une seule solution : diviser le tampon de primitive par des blocs de taille maximale. Ainsi, si le shader dit qu'il aura en sortie entre 0 et 16 sommets maximum, on doit diviser le tampon en parts de 16 sommets, ce qui fait maximum 1024/16 = 128 instances de shaders maximum. En conséquence, le second tampon de primitives sera sous-utilisé en pratique.
Et le principe reste le même si on change les chiffres exacts : chaque instance de shader reçoit une certaine portion du tampon de primitive, égale à la taille du tampon de primitives divisée par ce nombre limite. Vous noterez que la répartition n'est pas dynamique, mais statique. C'est la méthode la plus simple niveau matériel et celle qui coute le moins en circuits, malgré sa mauvaise utilisation, du tampon de primitives. Le problème est que le nombre d'instances exécutables en parallèle est rapidement limité.
Une solution à cela est la suivante. Quand un geometry shader a terminé son travail, il regarde s'il y a de la place dans le second tampon de primitives. Si celui-ci est plein, il attend que de la place se libère. On a donc un processeur de shader qui ne fait rien. les primitives calculées sont juste mémorisées dans les registres en attendant d'être transférées au tampon de primitives. Au pire, on peut espérer qu'une autre instance s'exécute dans un autre thread, grâce aux propriétés de multithreading matériel. Le nombre de geometry shader pouvant attendre est alors limité par le nombre de registres du processeur, et la taille des shaders. Avoir beaucoup de registres est alors un avantage (Why Geometry Shaders Are Slow (Unless you’re Intel)).
Une solution alternative est de mémoriser le résultat des geometry shaders en mémoire RAM, pour ensuite relire le résultat pour l'envoyer à la rastérisation. Pas besoin de second tampon de primitives, les limitations de nombre de shaders exécutés en parallèle disparaissent. Les processeurs de shaders sont utilisés au maximum, mais le cout en bande passante mémoire est assez élevé. les performances ne sont donc pas franchement meilleures.
- Il n'y a pas le même problème avec les vertex shaders car ils ne font que modifier des sommets : pour N sommets en entrées, ils fourniront N sommets en sortie. Ainsi, si on X processeurs de shaders pouvant traiter Y sommets en même temps avec leurs instructions SIMD, on peut prévoir le nombre de sommets en sortie. Le tampon de primitive est conçu pour encaisser ce nombre de sommets sortants, voire beaucoup plus. Il est rarement un point bloquant en termes de performances.
Les mesh shaders
[modifier | modifier le wikicode]Avec l'introduction des geometry shaders et de la tesselation, le pipeline graphique est devenu très complexe. Mais Direct X 12 a simplifié le tout, sous l'impulsion de technologies introduites par AMD et de NVIDIA. AMD a introduit les primitive shaders, NVIDIA a introduit les mesh shaders'' ont été introduit par NVIDIA. Les derniers ont été gardés pour DirectX 12, simplifiant grandement le pipeline.
Les primitive/mesh shaders
[modifier | modifier le wikicode]Les deux solutions de AMD et NVIDIA partent du même principe : elles fusionnent certaines étapes du pipeline. Les primitive/mesh shaders font disparaitre les étapes d'input assembly et d'assemblage de primitives, qui sont maintenant gérées par les primitive/mesh shaders. Les primitive/mesh shaders lisent directement le tampon d'indice et lisent les sommets depuis la VRAM, sans passer par une étape non-programmable. Ils assemblent les primitives eux-mêmes et les envoient directement au rastériseur. Le tout permet des optimisations très intéressantes, comme un culling précoce.
Les mesh shaders sont des shaders généralistes, semblables aux compute shaders. Pour rappel, un compute shader peut lire des données en RAM, exécuter des traitements dessus, et enregistrer les résultats en RAM. Il peut lire ou écrire à des adresses arbitraires, sans limitations. Il n'est pas limité à lire des données consécutives, peut sauter d'une donnée à une autre donnée distante en RAM. Les mesh shaders sont des variantes des compute shaders, qui n'écrivent pas leur résultat en RAM, mais envoient celui-ci au rastériseur. Plus précisément, ils écrivent leur résultat dans le tampon de primitives.
Les mesh shaders peuvent contourner l'étape d'input assembly et la remplacer par leur propre code. Pour rappel, l'étape d'input assembly était non-programmable et gérait des tampons de vertices et d'indices très normés. Les sommets étaient lus soit un par un, soit par paquets de N sommets consécutifs, ce qui était assez rigide. Il n'y avait pas d'accès arbitraire en mémoire RAM comme peuvent le faire les compute shaders. Par contre, un mesh shader peut accéder aux sommets de la manière qu'il souhaite, ce qui permet d'émuler un input assembler normal et plus encore.
Une autre différence avec les vertex shaders est qu'ils ne traitent pas forcément des sommets, mais peuvent aussi envoyer des primitives au rastériseur directement. En clair, ils n'ont pas besoin d'une étape de primitive assembly, qu'ils peuvent émuler directement dans le shader lui-même. Le culling est lui aussi réalisé par le primitive shader, pas par une unité fixe. Et cela permet de contourner un problème fondamental des vertex shaders : il fallait que les primitives soient assemblées pour qu'on puisse déterminer si elles sont ou non invisibles. À l'opposé, les primitive/mesh shaders assemblent les primitives de manière précoce dans le primitive/mesh shader, ce qui permet d'éliminer les primitives invisibles le plus tôt possible. Pour cela, les opérations permettant de déterminer si une primitive est visible sont exécutés en priorité, les autres opérations sont retardées et effectuées le plus tard possible. Ainsi, les calculs pour colorier ou orienter un sommet ne sont pas exécutés si le sommet est invisible.
Il y a des différences entre primitive et mesh shaders. Les primitive shaders permettent de lire un sommet à la fois, alors que les mesh shaders permettent de lire des batchs de plusieurs primitives d'un coup. Ces batchs de plusieurs primitives sont appelés des meshlets. La différence n'est pas fondamentale : le hardware des cartes AMD, qui gère des primitive shaders, peut regrouper dynamiquement plusieurs instances de primitive shaders en un seul mesh shader, via les technique de SIMT (une instance de primitive shader effectue des opérations scalaires, qui peuvent être regroupées en une seule instance SIMD en traitant plusieurs sommets en parallèle). La seule différence est que les mesh shaders exposent ce comportement au niveau du jeu d'instruction des shaders, les programmeurs en ont conscience.
Le pipeline géométrique avec les primitive/mesh shaders
[modifier | modifier le wikicode]Avec les primitive shaders, l'implémentation exacte dépend de si la tesselation est activée ou non. Si la tesselation n'est pas activée, le vertex shader et le geométry shader sont fusionnés en un seul primitive shader.
| DirectX 11 | Input assembly | Vertex shader | Geometry shader | Primitive assembly |
|---|---|---|---|---|
| DirectX 12 | Primitive shader (AMD) | |||
Avec la tesselation activée, les geometry shaders et les domain shaders en un seul shader. De même, les vertex shaders et les hull shaders sont fusionnés en un seul shader, nommé l'amplification shader. Ainsi, le pipeline graphique est grandement simplifié, avec seulement deux shaders et un étage fixe, au lieu de quatre shaders différents.
| DirectX 11 | Input assembly | Vertex shader | Hull shader | Tesselation | Domain shader | Geometry shader | Primitive assembly |
|---|---|---|---|---|---|---|---|
| DirectX 12 |
|
Tesselation |
| ||||