Aller au contenu

Les cartes graphiques/Le pipeline géométrique après DirectX 10

Un livre de Wikilivres.

Dans le chapitre précédent, nous avons étudié la manière dont les anciennes cartes graphiques traitaient la géométrie. Elles traitaient uniquement des sommets, via des vertex shaders. Mais depuis DirectX 10, le pipeline graphique a intégré des techniques pour gérer nativement des triangles et faire des traitements dessus. 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.

Contrairement à l'ancien pipeline graphique, le nouveau pipeline graphique gère nativement des primitives. Pour rappel, les primitives sont tout simplement les triangles ou les polygones utilisés pour décrire les modèles 3D, les surfaces de la scène 3D. Les moteurs de rendu acceptent aussi des primitives simples, comme des points (utiles pour les particules), ou les lignes (utiles pour le rendu 2D). Les primitives sont toutes définies par un ou plusieurs points : trois sommets pour un triangle.

Dans l'ancien pipeline graphique, les primitives sont assemblées dans la dernière étape géométrique, avant le rastériseur. Aucun traitement n'est effectué sur les primitives, qui sont juste envoyées au rastériseur. Elles sont éventuellement éliminée via culling, mais c'est le rastériseur qui s'en charge. Tout traitement géométrique est réalisé en manipulant des sommets, via un vertex shader. mais cette organisation est rapidement devenue impraticable. Elle empêchait certaines optimisations, notamment l'élimination précoce des primitives invisibles : il fallait attendre la rastérisation pour les éliminer, elles étaient transformées et éclairées même si elles étaient invisibles. De plus, quelques fonctionnalités graphiques étaient impossibles. Voyons l'une d'entre elle : la tesselation.

Un exemple d'utilisation des primitives : la tessellation

[modifier | modifier le wikicode]

La tessellation est une technique qui permet d'ajouter des primitives à une surface à la volée. Les techniques de tesselation décomposent chaque triangle en sous-triangles plus petits, et modifient les coordonnées des sommets créés lors de ce processus. L'algorithme de découpage des triangles et la modification des coordonnées varie beaucoup selon la carte graphique ou le logiciel utilisé. Typiquement, les cartes graphiques actuelles ont un algorithme matériel pour le découpage des triangles qui est juste configurable, mais la modification des coordonnées des nouveaux sommets est programmable depuis DirectX 11.

Tessellation.

Elle permet d'obtenir un bon niveau de détail géométrique, sans pour autant remplir la mémoire vidéo de sommets pré-calculés. Lire des sommets depuis la mémoire vidéo est une opération couteuse, même si les caches de sommets limitent la casse. La tesselation permet de lire un nombre limité de sommets depuis la mémoire vidéo, mais ajoute des sommets supplémentaires dans les unités de gestion de la géométrie. Les détails géométriques ajoutés par la tesselation demandent donc de la puissance de calcul, mais réduisent les accès mémoire.

L'historique de la tesselation sur les cartes 3D

[modifier | modifier le wikicode]

Les premières tentatives utilisaient des algorithmes matériels de tesselation, et non des shaders. Par exemple, la première carte graphique commerciale avec de la tesselation matérielle était la Radeon 8500, de l'entreprise ATI (aujourd'hui rachetée par AMD), avec la technologie de tesselation TrueForm. Elle utilisait un circuit non-programmable, qui tessellait certaines surfaces et interpolait la forme de la surface entre les sommets.

ATI améliora ensuite le TrueForm pour que des informations de tesselation soient lues depuis une texture, ce qui permet une implémentation de la technologie dite du displacement mapping. En même temps, Matrox ajouta un algorithme de tesselation basé sur la technique de N-patch dans ses cartes graphiques. Mais ces techniques se basaient sur des algorithmes matériels non-programmables, ce qui rendait ces technologies insuffisamment flexibles et impraticables. De plus, c'était des technologies propriétaires, que les autres fabricants de cartes graphiques n'ont pas adopté. Elles sont donc tombées en désuétude.

La tesselation a eu un regain d'intérêt à l'arrivée des geometry shaders dans DirectX 10 et OpenGL 3.2. Et il y avait de quoi, de tels shaders pouvant en théorie implémenter une forme limitée de tesselation. Mais le cout en performance est trop important, sans compter que les limitations de ces shaders n'a pas permis leur usage pour de la tesselation généraliste.

Dans les tableaux qui vont suivre, les circuits non-programmables sont indiqués en rouge.
DirectX 9
Input assembly Vertex shader Primitive assembly
DirectX 10
Input assembly Vertex shader Geometry shader Primitive assembly

Une nouvelle étape a été franchie avec l'AMD Radeon HD2000 et le GPU de la Xbox 360, qui permettaient une tesselation partiellement programmable. La tesselation se faisait en deux étapes : une étape de découpage des triangles et une étape de modification des sommets créés. La première étape était un algorithme matériel configurable mais non-programmable, alors que la seconde était programmable. Mais le manque de support logiciel, le fait qu'on ne pouvait pas utiliser la tesselation en même temps que les geometry shader, ainsi que la non-utilisation de cette technique par NVIDIA, a fait que cette technique n'a pas été reprise dans les GPU suivants.

Il fallut attendre l'arrivée des tesselation shaders dans OpenGL 4.0 et DirectX 11 pour que des shaders adéquats arrivent sur le marché commercial. La tesselation sur ces cartes graphiques se fait en trois étapes : deux shaders et un algorithme matériel fixe entre les deux. Dans le détail, un hull shader est suivi par un étage fixe de tesselation, lui-même suivi par un domain shader. L'étage fixe est là où se situe le découpage des triangles par l'unité matérielle configurable. La tesselation est suivie par la modification de la place des vertices créées, mais il y a aussi un shader avant la génération des nouvelles primitives.

Avant DirectX 11
Input assembly Vertex shader Geometry shader Primitive assembly
DirectX 11
Input assembly Vertex shader Hull shader Tesselation Domain shader Geometry shader Primitive assembly

Les geometry shaders et les tesselation shaders étaient très limités, ce qui fait qu'ils ont été peu utilisés. Les programmeurs avaient beaucoup de mal à les utiliser de manière performante, sans compter que ces shaders s'intégraient très mal au pipeline graphique existant. Les cartes graphiques avaient du mal à les intégrer au hardware, sauf à recourir à des méthodes quelque peu tordues, comme on le verra dans ce qui suit.

DirectX 10 : les geometry shaders

[modifier | modifier le wikicode]

DirectX 10 et OpenGl 3.2 ont introduit les geometry shaders, juste avant l'étape d'assemblage des primitives, afin de gérer une forme limitée de tesselation. Les geometry shaders peuvent ajouter, supprimer ou altérer des primitives dans une scène 3D. Ils prennent entrée une primitive et fournissent en sortie zéro, une ou plusieurs primitives. Ils sont surtout utilisés pour la gestion des cubemaps, le shadow volume extrusion, la génération de particules, et quelques autres effets graphiques. Ils pourraient en théorie ê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.

Un point important est que les geometry shaders sont exécutés par les processeurs de shaders, qui s'occupent de tous les shaders, qu'il s'agisse des pixels shaders, des vertex shaders ou des geometry shaders. Les geometry shaders ont été introduits avec DirectX 10 et OpenGl 3.2, et c'est avec DirectX 10 que les processeurs de shaders ont étés unifiés (rendu capable d’exécuter n'importe quel shader).

L'étape d’assemblage de primitives est dupliquée

[modifier | modifier le wikicode]

Les geometry shaders sont exécutés par les processeurs de shaders normaux. Leur place dans le pipeline graphique est quelque peu étrange. En théorie, ils sont placés après l'assembleur de primitive, car ils manipulent les primitives fournies par l'étape d'assemblage des primitives. Mais le résultat fournit par les geometry shaders doit être retraité par l'assembleur de primitive.

En effet, 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, non-regroupés en primitives. Le résultat est que l'assembleur de primitive doit refaire son travail après le passage d'un geometry shader, pour déterminer les primitives finales. Et il faut aussi refaire le culling, au cas où les primitives générées ne soient pas visibles depuis la caméra. 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.

Implémentation matérielle des geometry shaders

L'implémentation des tampons de primitive est assez compliquée par la spécification des geometry shaders. 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. Mais ce nombre est généralement très élevé, bien plus que la moyenne réelle du résultat du geometry shader.

Or, le tampon de primitives de sortie a une taille finie qui doit être partagée entre plusieurs instances du geometry shader. Et cette répartition n'est pas dynamique, mais statique : chaque instance reçoit une certaine portion du tampon de primitive, égale à la taille du tampon de primitives divisée par ce nombre limite. Aussi, le nombre d'instance exécutables en parallèle est rapidement limitée par le nombre de sommets maximal que peut sortir le geometry shader, nombre qui est rarement atteint en pratique.

La fonctionnalité de stream output

[modifier | modifier le wikicode]

Une fonctionnalité des geometry shaders est la possibilité d'enregistrer leurs résultats en mémoire. Il s'agit de la fonctionnalité du stream output. On peut ainsi remplir une texture ou le vertex buffer dans la mémoire vidéo, avec le résultat d'un geometry shader. Notons que celle-ci mémorise un ensemble de primitives, pas autre chose. Cette fonctionnalité est utilisée pour certains effets ou rendu bien précis, mais il faut avouer qu'elle n'est pas très souvent utilisée. Aussi, les concepteurs de cartes graphiques n'ont pas optimisé cette fonctionnalité au maximum. Le stream output n'a généralement pas accès prioritaire à la mémoire, comparé aux ROP, et n'a souvent accès qu'à une partie limitée de la bande passante mémoire.

Notons qu'il existe deux formes de stream output : une qui permet aux vertex shader d'écrire dans une texture, l'autre qui permet aux geometry shaders de le faire. Notons que le stream output fournit un flux de primitives, pas de sommets, même pour le flux sortant d'un vertex shader. En clair, beaucoup de sommets sont dupliqués et ont n'a pas d'index buffer. Les résultats du stream output sont donc assez lourds et prennent beaucoup de mémoire.

Stream output

DirectX 12 : les mesh shaders

[modifier | modifier le wikicode]
Pipeline graphique de Direct x 11.

Avec l'introduction des geometry shaders et de la tesselation, le pipeline graphique est devenu très complexe. Plusieurs étages en plus sont ajoutés à sa portion géométrique : un pour les geometry shaders, trois pour la tesselation, et ce en plus des vertex shaders existants et des étages non-programmables. Le pipeline en question est celui d'Open GL 4 et de DirectX 11.

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 dinput assembly et la remplacer par leur propre code. Pour rappel, l'étape dinput 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. A 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.

Comparaison entre les pipelines géométriques de DirectX 11 et 12, sans tesselation
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.

Comparaison entre les pipelines géométriques de DirectX 11 et 12, avec tesselation
DirectX 11 Input assembly Vertex shader Hull shader Tesselation Domain shader Geometry shader Primitive assembly
DirectX 12
  • Amplification shader (AMD)
Tesselation
  • Primitive shader (AMD)