Aller au contenu

Les cartes graphiques/Le rendu d'une scène 3D : l'API graphique

Un livre de Wikilivres.

De nos jours, le développement de jeux vidéo, ou tout simplement de tout rendu 3D, utilise des API 3D. Les API 3D les plus connues sont DirectX, OpenGL, et Vulkan. L'enjeu des API est de ne pas avoir à recoder un moteur de jeu différent pour chaque carte graphique ou ordinateur existant. Elles fournissent des fonctions qui effectuent des calculs bien spécifiques de rendu 3D, mais pas que. L'application de rendu 3D utilise des fonctionnalités de ces API 3D, qui elles-mêmes utilisent les autres intermédiaires, les autres maillons de la chaîne. Typiquement, ces API communiquent avec le pilote de la carte graphique et le système d'exploitation.

La description des API 3D les plus communes

[modifier | modifier le wikicode]

Dans ce chapitre, nous n'allons pas faire de cours du DirextX, ulkan ou toute API précise. Toutes le API graphiques fonctionnent globalement sur les mêmes principes, que nous allons expliquer dans les grandes lignes. Les explications seront conçues pour que les personnes sans bagage de la programmation graphique puissent comprendre, seuls desbases très mineures en programmation seront nécessaires dans le pire des cas.

Les draw calls

[modifier | modifier le wikicode]

Une API 3D fournit un certain nombre de fonctions qu'un programmeur peut exécuter à loisir. La principale est la fonction qui dessine quelque chose dans le framebuffer. Elle est appelée draw() dans la terminologie DirectX, gldraw pour OpenGL, vkcmddraw pour Vulkan. Une exécution de cette fonction est appelée un draw call. Un draw callenvoie des informations à la carte graphique, afin qu'elle affiche ce qui est demandé.

Instinctivement, on pourrait croire que la fonction draw calcule tout l'image à afficher d'un seul coup, mais ce n'est pas le cas. En réalité, le moteur graphique d'un jeu effectue le rendu objet par objet, avec un draw call par objet. Plus il y a d'objets, plus le processeur exécutera de draw calls. Diverses optimisations permettent d'économiser des draw calls, mais cela ne change pas le fait que dessiner l'image finale demande plusieurs draw calls, entre une centaine et plusieurs centaines de milliers suivant la complexité de la scène à rendre.

Le fait de rendre une image objet par objet permet de nombreuses optimisations. Par exemple, il peut utiliser une première passe pour dessiner les objets opaques, puis une seconde pour les objets transparents. Tous les moteurs 3D font ainsi, car gérer la transparence est toujours compliqué, surtout avec un tampon de profondeur.

Un autre avantage est que le moteur de jeu peut faciliter le travail de l'élimination des surfaces cachées. Par exemple, le moteur de jeu peut trier les objets selon leur profondeur, afin de les rendre du plus proche au plus lointain. Pour les objets opaques, cela permet d'éliminer les surfaces cachées à la perfection : aucun triangle/pixel caché par un autre ne sera rendu. Pour la transparence, cela permet un rendu idéal. Mais trier les objets selon leur profondeur prend alors du temps CPU, qu'il faut comparer à ce qui est gagné sur le GPU.

Avant les années 2010 environ, le processeur faisait une bonne partie de l'élimination des surfaces cachées, dans le sens où il déterminait quels objets étaient cachés par d'autres. Il n'émettait pas de draw calls pour les objets complétement cachés par un autre objet opaque. Par contre, il travaillait au niveau des objets, alors que le GPU travaillait au niveau des triangles. Les objets partiellement cachés étaient gérés par le GPU, avec une élimination des surface cachées triangle par triangle.

De nos jours, l'élimination des surfaces cachées est réalisée sur le GPU, dans sa totalité. L'idée est d'utiliser un shader séparé, un compute shader, qui s'exécute avant toute autre opération de rendu. La scène 3D et tous les modèles sont dans la mémoire vidéo, et non en mémoire RAM. Le compute shader lit l'ensemble de la géométrie et élimine les surface cachées. On parle de GPU driven rendering pour désigner cette élimination des surfaces cachées réalisée sur le GPU (il faudrait aussi rajouter le choix du Level Of Detail, mais passons.

Les render target

[modifier | modifier le wikicode]

Plus haut, j'ai dit qu'un draw call dessine une image dans le framebuffer. Et il s'agit là du cas le plus important, mais certaines techniques de rendu demandent de dessiner des images intermédiaires, qui sont utilisées pour calculer l'image finale. Les images intermédiaires doivent alors être enregistrées ailleurs, par exemple dans une texture. L'idée générale d'enregistrer des images intermédiaires dans une texture, qui sont alors lues par un pixel shader pour des calculs d'éclairage, des filtres de post-traitement, ou autre. Autoriser d'enregistrer l'image finale dans une texture s'appelle du render-to-texture.

Les techniques d'éclairage basées sur des shadowmap sont dans ce cas. Elles demandent de rendre la scène 3D deux fois : une fois du point de vue de la source de lumière, puis une seconde fois pour obtenir l'image finale. L'idée est que les pixels invisibles depuis la source de lumière, mais visibles depuis la caméra, sont dans l'ombre. La scène rendue depuis la caméra doit donc être mémorisée quelque part, de préférence dans une texture appelée une shadowmap.

Une autre utilisation est l'application de filtres de post-traitement, comme du bloom, de la profondeur de champ, etc. L'idée est de mémoriser l'image initiale, sans post-traitement, dans une texture. Puis, un shader lit cette texture, applique un filtre dessus, et mémorise le résultat dans une autre texture ou dans le framebuffer s'il calcule l'image finale.

Pour cela, les API 3D modernes permettent de préciser où enregistrer l'image finale : dans le framebuffer, dans une texture, dans une simple portion de mémoire, etc. Les endroits où l'image finale peut être rendue s'appellent des render target. Les API modernes supportent de nombreux render target, avec au minimum un framebuffer. Initialement, les API anciennes ne supportaient que le framebuffer. Puis le render-to-texture est apparu, puis d'autres formes de render target.

Les attributs de sommets, variables uniformes et unités de texture

[modifier | modifier le wikicode]

Lors d'un draw call, certains paramètres vont rester constants, alors que d'autres vont varier d'un sommet à l'autre. Les paramètres qui varient d'un sommet à l'autre sont des attributs de sommet. Par exemple, prenons un sommet : sa position, sa couleur et ses coordonnées de texture sont des attributs du sommet. Les paramètres constants sont appelées des variables uniformes, ou encore des uniforms. Elles restent les mêmes pour un objet, mais varient d'un objet à l'autre. Un exemple est les matrices utilisées par les étapes de transformation et de projection.

Il y a la même chose avec les pixels, avec des attributs de pixels et des pixel uniforms, la différence étant que les attributs de pixels sont calculés par la rastérisation.

Les deux sont stockés différemment : les variables uniformes sont simplement intégrées dans les shaders, alors que les attributs sont placés dans le tampon de sommets. Il faut noter que les processeurs de shaders avaient autrefois des registres séparés pour les deux, et c'est toujours un peu le cas à l'heure actuelle.

Les textures et unités de texture

[modifier | modifier le wikicode]

Les API OpenGL et DirectX gèrent les textures d'une manière assez différente de ce qui se fait en matériel. Les API partent du principe que le GPU a plusieurs unités de textures, qui sont numérotées de 0 à N. Les textures doivent être associées à une unité de texture avant du draw call. En clair, on doit dire que telle texture doit aller dans l'unité de texture numéro 0, telle autre texture dans l'unité de texture numéro 1, etc. Les anciennes API ne géraient qu'une seule texture, mais l'arrivée du multitexturing a imposé d'ajouter des unités de texture en plus.

Une texture est placée en mémoire vidéo et on connait son adresse, sa position en mémoire. En théorie, l'unité de texture a juste besoin de connaitre l'adresse de la texture, ainsi que sa résolution, sa taille en mémoire VRAM, et quelques autres détails. L'ensemble de ces informations est appelé le texture state. L'unité de texture mémorise le texture state dans des registres internes, et tout accès à une texture utilise le texture state pour lire le bon texel. En associant une texture a une unité de texture, on copie ces informations dans les registres de l'unité de texture. En changeant de texture, on modifie le contenu de ces registres internes.

Les anciennes cartes graphiques fonctionnaient sur ce principe, elles avaient bien des unités de texture avec des registres pour mémoriser un texture state. Le problème est que les GPU ne fonctionnent pas exactement sur ce principe. L'unité de texture des GPU modernes est intégrée dans les processeurs de shaders, ce qui fait qu'attribuer une texture à une unité de texture n'est pas l'idéal. De plus, les unités de textures actuelles sont bindless : elles n'utilisent pas de registres internes, elles ne mémorisent pas de texture state. A la place, le texture state est mémorisé dans les registres du processeur de shader. A chaque accès, le texture state est envoyé à l'unité de texture. Il faut dire que les unités mémoire des processeurs fonctionnent sur ce principe : on leur envoie une adresse, un indice et d'autres informations ; et elles effectuent l'accès mémoire.

Les API actuelles tendent de plus en plus vers les textures bindless, qui ne doivent pas être associées à une unité de texture. A la place, leur adresse est simplement fournie au pixel shader via des uniforms, ou un autre mécanisme. L'adresse se retrouve simplement dans les registres du pixel shader, ce qui fait qu'on n'est plus limité par le nombre d'unités de texture.

Les render states et les Pipeline State Object

[modifier | modifier le wikicode]

Pour rendre un objet avec un draw call, il faut préciser toutes informations nécessaires pour son rendu : la géométrie de l'objet représentée par une liste de triangles, les textures de l'objet, les shaders à exécuter (vertex ou pixel shaders), etc. Pour simplifier, nous allons regrouper ces informations en deux : un mesh qui représente la géométrie de l'objet, et le reste. La géométrie de l'objet est juste une liste de triangles. Le reste est regroupé dans un render state, qui liste les textures, les shaders, quel render starget utiliser, et surtout : diverses options de configuration.

Il n'y a qu'un seul render state actif, qui est mémorisé dans une portion de la RAM qui est toujours fixe. Pour les programmeurs, le render state est dans une variable globale, qui est lue directement par la fonction draw. Si on veut rendre un objet, on doit mettre à jour le render state avant de lancer un draw call. Un moteur graphique fait donc le travail suivant :

  • Pour chaque image :
    • Mettre à jour la position de la caméra et autres
    • Pour chaque objet, scène 3D inclue :
      • 1 - Mettre à jour le render state
      • 2 - Exécuter le draw call

L'API 3D fournit des fonctions pour modifier le render state, en plus de la fonction draw. A ce niveau, les anciennes API fonctionne différemment des API plus récentes comme DirectX 12, Vulkan et consort. Les anciennes API fournissaient plusieurs fonctions très spécialisées : certaines pour modifier les textures, d'autres pour changer les shaders, et un paquet d’autres pour modifier telle ou telle option de configuration. Par exemple, il y a probablement une fonction pour changer l'antialiasing.

Les API modernes, comme DirectX 12 et Vulkan, permettent de mettre à jour le render state assez simplement. L'idée est de pré-calculer un render state, qui est alors appelé un Pipeline State Object (PSO). Mettre à jour le render state demande alors juste de copier un PSO dans le render state, au lieu d'exécuter une dizaine ou centaine de fonctions pour obtenir le render state voulu.

Les commandes graphiques

[modifier | modifier le wikicode]

L'API 3D traduit chaque draw call en une ou plusieurs commandes graphiques, qui sont envoyées au driver du GPU. Les commandes en question sont assez diverses, mais elles sont spécifiques à chaque API graphique. Intuitivement, un draw call correspond à une commande graphique. Mais il peut y avoir d'autres types de commandes. Par exemple, copier une texture dans la mémoire vidéo demande d'exécuter une commande decopie, idem pour ce qui est de copier un objet/mesh.

Pour comprendre en quoi un draw call peut se traduire en plusieurs commandes, prenons l'exemple suivant. On souhaite rendre un objet avec une texture bien précise, mais celle-ci n'a pas encore été chargée en mémoire vidéo. Dans ce cas, le draw call utilisera une commande pour copier la texture en mémoire vidéo, puis une seconde commande pour rendre l'objet dans le framebuffer. Par contre, si la texture est déjà en mémoire vidéo, le draw call se traduira en une unique commande de rendu 3D. Il en est de même si le mesh n'est pas encore en mémoire vidéo : il faut exécuter une commande pour copier le mesh dans la mémoire vidéo.

Il faut préciser que c'est la même chose si le draw call exécute un shader pour la première fois . Le driver doit compiler le shader pour la première fois, puis utiliser une commande pour mettre le résultat en mémoire vidéo, puis enfin effectuer le rendu. Cela explique le shader stuttering présent dans certains jeux récents, à savoir le petit ralentissement très énervant qui survient quand un shader est compilé en plein milieu d'une partie de jeu. Il est possible de limiter ce problème en compilant des shaders à l'avance, histoire de préparer le terrain pour les futurs draw calls, dans une certaine mesure, mais cela demande du travail, qui n'est possible que le nombre de shaders à compiler reste faible.

Les commandes graphiques sont envoyées au driver de la carte graphique. Il transforme alors ces commandes graphiques en commandes matérielles, compréhensibles par le matériel, en quelque chose que le GPU peut exécuter. Le format des commandes matérielles est spécifique à chaque marquer de GPU, les GPU NVIDIA, Intel et AMD n'utilisent pas le même format de commande. Il est même possible que chaque GPU ait son propre format pour les commandes matérielles. Aussi, nous allons nous arrêter là pour le moment et laissons cela au chapitre sur le processeur de commande.

Les optimisations liées aux draw calls

[modifier | modifier le wikicode]

Il faut noter qu'un draw call demande d'utiliser un peu de puissance CPU : il faut traduire le draw call en commandes, les envoyer au driver, qui fait du travail dessus, avant de les envoyer au GPU. Dans les premières versions d'OpenGL et DirectX, chaque draw call effectuait une commutation de contexte pour passer en espace noyau, afin de communiquer avec le driver. Mais cette contrainte a depuis été relâchée, bien qu'elle marche dans les grandes lignes. Faire plein de draw calls aura donc un cout en CPU conséquent.

Les optimisations du render state

[modifier | modifier le wikicode]

Les changements de render state sont souvent assez gourmands. Pas forcément pour le GPU, mais il y a toujours un certain cout soit au niveau de l'API, soit pour le pilote du GPU. Le cout est donc partagé entre CPU et GPU, avec un cout certain pour le CPU, très variable pour le GPU.

Les changements d'état en question ne sont pas égaux non plus, certains sont plus couteux que d'autres. Pour donner quelques chiffres, je vais me baser les données obtenues par Cass Everitt et John McDonald en 2014, pour un driver OpenGL. Ils ont été présentés dans la conférence Beyond Porting: How Modern OpenGL Can Radically Reduce Driver Overhead, aux Steam Dev Days 2014, trouvable sur Youtube. Les données sont anciennes, les choses ont certainement évoluées. Les couts vont, du plus grand au plus petit :

  • changer de render target ;
  • changer de shader ;
  • reconfigurer les ROPs ;
  • changer de texture ;
  • changer la géométrie ou les variables uniforms.

Une première optimisation vise à réduire les changements de render state. Elle rend les objets avec le même render state ensemble, les uns à la suite des autres. Sans cette optimisation, le moteur graphique met à jour le render state à chaque fois qu'il rend un objet. Avec cette optimisation, il met à jour le render state plus rarement. Par contre, le moteur graphique dépense du temps et de la puissance de calcul pour faire le tri. Il y a donc un compromis pas évident, qui ne vaudrait pas souvent le coup. Cependant, d'autres optimisations permettent d'en profiter au mieux.

Une optimisation de ce type est l'usage d'atlas de textures. Un atlas de texture regroupe plusieurs textures dans une super-texture. La technique évite des changements d'état dans l'API 3D. Pas besoin de changer de texture à chaque objet, on peut partager une super-texture capable de texturer plusieurs objets différents. La création d'un atlas de texture n'est pas de tout repos, il ne suffit pas de regrouper plusieurs textures. Le filtrage de texture ne doit pas mélanger des texels provenant de deux textures différents, même si elles sont dans la même super-texture. Et il y a d'autres problèmes liés aux techniques dites de mip-mapping, ainsi qu'aux modes d'adressage de texture.

Pour éviter ces problèmes, les API modernes ont ajouté une fonctionnalité : les texture arrays. Le terme array est assez clair : il s'agit de tableaux, qui regroupent plusieurs textures. Précisons ce qu'on veut dire pas regrouper. Les textures ne sont pas placées dans une super-texture, elles ne sont pas consécutives en mémoire. Par contre, le texture array mémorise l'adresse de chaque texture. Pour accéder à une texture, il suffit de fournir son indice, sa place dans le tableau : on récupère l'adresse de la texture, qui est ensuite envoyée à l'unité de texture.

Pour ce qui est des shaders, on peut regrouper plusieurs shaders en un seul. Par exemple, au lieu d'avoir un shader pour les sources de lumière ponctuelles et un autre pour les sources directionnelles, on peut utiliser un shader qui traite les deux. Pour cela, il suffit d'avoir une partie du shader spécialisée pour les sources ponctuelles, une autre pour les sources directionnelles. Le shader a juste à utiliser un branchement pour exécuter le bon morceau de code. Le branchement a juste à tester une variable uniforme pour faire son choix. Poussée à soin paroxysme, le résultat est un ou plusieurs über shaders, qui supporte tous les types de sources de lumières et tous les materials.

Un problème est que les über shaders ont une grande taille, ce qui fait qu'ils peuvent déborder du cache d'instruction. Un autre problème des über shaders est qu'ils tendent à être moins bien optimisés, car il utilise plus de registres. Pour donner un exemple, prenons un shader qui gère à la fois les sources de lumières ponctuelles et directionnelles, avec un morceau de code pour chaque. L'un des deux morceau de code utilisera plus de registres que les autres, et le shader réservera assez de shader pour ce dernier. Ainsi, au lieu d'avoir un shader pour les sources ponctuelles qui utilise 20 registres, et un autre shader pour les sources directionnelles qui en utilise 50, on a un seul shader qui en utilise 50. Le problème est que vu que le shader utilise plus de registres, cela limite le nombre d'instances du shader lancées en simultané, et donc le masquage de la latence.

Les optimisations des draw calls : batching et instancing

[modifier | modifier le wikicode]

Plus haut, j'ai dit que le rendu se fait objet par objet, mesh par mesh. Mais il s'agit là d'une simplification. En réalité, tout moteur graphique digne de ce nom incorpore des optimisations qui cassent cette règle. L'idée est d'éviter de faire plein de petits draw call : le GPU sera alors peu utilisé alors que le CPU fera beaucoup de travail. A l'inverse, faire peu de gros draw call entrainera une forte occupation du GPU au prix d'un cout CPU mineur.

La première optimisation, appelée le batching, regroupe plusieurs objets/meshs en un seul draw call. Par contre, cette optimisation ne marche que pour des objets ayant le même render state, à l'exception de la géométrie. Les deux objets rendus ensemble doivent utiliser les mêmes shaders, les mêmes textures, etc. De plus, la fusion de deux objets doit se faire en mémoire RAM et est le fait du CPU, le GPU et la mémoire vidéo ne sont pas concernés. L'optimisation marche bien pour des objets statiques, ce qui permet de faire la fusion une fois pour toute, là où les objets dynamiques demandent de faire la fusion à chaque image.

Une seconde optimisation,appelée l'instancing, marche dans le cas où un objet dynamique est présent en plusieurs exemplaires à l'écran. L'idée est qu'au lieu d'utiliser un draw call par exemplaire, on utilise un seul draw call pour tous les exemplaires. L'avantage est que la carte n'a besoin de mémoriser qu'un seul exemplaire en mémoire vidéo, au lieu de mémoriser plusieurs copies du même mesh.

Il faut préciser que les différents exemplaires peuvent être placés à des endroits éloignés, être tournés différemment par rapport à la caméra, être dans des états d'animation différents, etc. Pour cela, le draw call précise, pour chaque exemplaire, comment l'orienter, le tourner et l'animer. Le render state contient pour cela une liste d'instances pour mémoriser ces informations pur chaque exemplaire. Le GPU peut consulter cette liste et la copier en mémoire vidéo. Une seule commande permet ainsi de rendre plusieurs exemplaires : le GPU lit la liste d'instance, le mesh et dessine automatiquement chaque exemplaire voulu de l'objet.

Réduire le nombre de draw calls peut aussi se faire en évitant les objets peu détaillés, qui utilisent peu de polygones. Pour des objets trop peu détaillés, le GPU exécutera le draw call très vite et devra attendre que le CPU envoie le suivant. Le cout du draw call dominera le temps de calcul sur le GPU. Du temps de DirectX 9, l'idéal était d'avoir des objets d'au moins une centaine de triangles. De nos jours, les GPU les CPU sont plus puissant,ce qui fait que ce chiffre est à revoir, mais je n'en connais pas la valeur, même approximative.

Le pipeline graphique

[modifier | modifier le wikicode]

En plus de fournir des fonctions que les programmeurs peuvent utiliser, les API graphiques décrivent comment s'effectue le rendu d'une image. Elles spécifient comment doit être traité la géométrie, comment doit se faire la rastérisation, le filtrage de texture et bien d'autres choses. Pour le dire autrement, elles décrivent le pipeline graphique à utiliser. Pour rappel, le pipeline graphique comprend plusieurs étapes : plusieurs étapes de traitement de la géométrie, une phase de rastérisation, puis plusieurs étapes de traitement des pixels. Une API 3D comme DirectX ou OpenGl décrète quelles sont les étapes à faire, ce qu'elles font, et l'ordre dans lesquelles il faut les exécuter.

Il n'existe pas un pipeline graphique unique et chaque API 3D fait à sa sauce, mais la plupart des API modernes ont des pipelines graphiques très similaires. Les seules différences majeures concernent la présence d'étapes facultatives, comme l'étape de tesselation, qui sont absentes des API anciennes. Pour donner un exemple, je vais prendre l'exemple d'OpenGL 1.0, une des premières version d'OpenGL, aujourd'hui totalement obsolète.

Le pipeline d'OpenGL 1.0 est illustré ci-dessous. Il implémente le pipeline graphique de base, avec une phase de traitement de la géométrie (per vertex operations et primitive assembly), la rastérisation, et les traitements sur les pixels (per fragment operations). On y voit la présence du framebuffer et de la mémoire dédiée aux textures, les deux étant soit séparées, soit placée dans la même mémoire vidéo.

La display list est une liste de commandes, de draw calls, que la carte graphique doit traiter d'un seul bloc, chaque display list correspond au rendu d'une image, pour simplifier. Les étapes evaluator et pixel operations sont des étapes facultatives, qui ne sont pas dans le pipeline graphique de base, mais qui sont utiles pour implémenter certains effets graphiques.

Pipeline d'OpenGL 1.0

Le pipeline d'OpenGL 1.0 vu plus haut est très simple, comparé aux pipelines des API modernes. Pour comparaison, voici des schémas qui décrivent le pipeline de DirextX 10 et 11. Vous voyez que le nombre d'étapes n'est pas le même, que les étapes elles-mêmes sont légèrement différentes, etc. Toutes les API 3D modernes sont organisées plus ou moins de la même manière, ce qui fait que le pipeline des schémas ci-dessous colle assez bien avec les logiciels 3D anciens et modernes, ainsi qu'avec l'organisation des cartes graphiques (anciennes ou modernes).

D3D Pipeline
Pipeline de D3D 11

L'implémentation peut être logicielle ou matérielle

[modifier | modifier le wikicode]

Une API graphique est avant tout quelque chose qui aide le programmeur. Il est d'ailleurs possible de les utiliser sans GPU, avec une simple carte d'affichage. Le rendu 3D se fait alors sur le processeur, et la carte d'affichage ne fait que recevoir l'image calculée et l'afficher. Et c'était le cas dans les années 90, avant l'invention des premières cartes accélératrices 3D. Le rôle des API 3D était de fournir des morceaux de code et un pipeline graphique, afin de simplifier le travail des développeurs, pas de déporter des calculs sur une carte accélératrice 3D.

D'ailleurs, OpenGl et Direct X sont apparues avant que les premières cartes graphiques grand public soient inventées. Les premiers accélérateurs 3D sont arrivés sur le marché quelques mois après la toute première version de Direct X et Microsoft n'avait pas prévu le coup. OpenGL était lui encore plus ancien et ne servait pas initialement pour les jeux vidéos, mais pour la production d'images de synthèses et dans des applications industrielles (conception assistée par ordinateur, imagerie médicale, autres). OpenGL était l'API plébiscitée à l'époque, car elle était déjà bien implantée dans le domaine industriel, la compatibilité avec les différents OS de l'époque était très bonne, mais aussi car elle était assez simple à programmer.

De nos jours, la grosse majorité du rendu 3D se fait sur le GPU. Les draw calls sont intégralement traités par le GPU, à quelques détails près. Mais les premières cartes accélératrices 3D ne le gérait que partiellement. Concrétement, les premières cartes de 3Dfx déléguaient le traitement de la géométrie au processeur, et ne s'occupaient que des étapes de rastérisation, de placage de texture et les étapes suivantes. Autant prévenir maintenant, nous verrons de nombreuses cartes graphiques de de genre dans le chapitre sur l'historique de l'accélération 3D.

Les API imposent des contraintes sur le matériel

[modifier | modifier le wikicode]

Les API graphiques décrivent un pipeline, mais fournissent aussi d'autres contraintes. Par exemple, elles fournissent des régles sur la manière dont doit être faite la rastérisation. Elle disent plus ou moins quel doit être le résultat attendu par le programmeur. Et les GPU doivent respecter ces règles, ils doivent effectuer le rendu de manière à avoir un résultat identique à celui spécifié par l'API.

Notez ma formulation quelque peu alambiquée, qui cache un point important : les GPU font comme si ! Je dis faire comme si, car il se peut que le matériel fasse autrement, mais pour un résultat identique. Tant que l'image finale est celle attendue par l'API 3D, le GPU a le droit de prendre des raccourcis, d'éliminer des calculs inutiles, d'utiliser un algorithme de rastérisation différent, etc.

Par exemple, il arrive que la carte graphique fasse certaines opérations en avance, comparé au pipeline imposé par l'API, pour des raisons de performance. Typiquement, effectuer du culling ou les tests de profondeur plus tôt permet d'annuler de nombreux pixels invisibles à l'écran, et donc d'éliminer beaucoup de calculs inutiles. Mais la carte graphique doit cependant corriger le tout de manière à ce que pour le programmeur, tout se passe comme l'API 3D l'ordonne.

De manière générale, sans même se limiter à l'ordonnancement des étapes du pipeline graphique, les règles imposées par les API 3D sont des contraintes fortes, qui contraignent les cartes graphiques dans ce qu'elles peuvent faire. De nombreuses optimisations sont rendues impossibles à cause des contraintes des API 3D.

Le pilote de carte graphique

[modifier | modifier le wikicode]

Le pilote de la carte graphique est un logiciel qui s'occupe de faire l'interface entre les API 3D et la carte graphique. En théorie, le système d'exploitation est censé jouer ce rôle, mais il n'est pas programmé pour être compatible avec tous les périphériques vendus sur le marché. Le pilote d'un périphérique sert justement à ajouter ce qui manque : ils ajoutent au système d'exploitation de quoi reconnaître le périphérique, de quoi l'utiliser au mieux.

Avant toute chose, précisons que les systèmes d'exploitation usuels (Windows, Linux, MacOsX et autres) sont fournis avec un pilote de carte graphique générique, compatible avec la plupart des cartes graphiques existantes. Rien de magique derrière cela : toutes les cartes graphiques vendues depuis plusieurs décennies respectent des standards, comme le VGA, le VESA, et d'autres. Et le pilote de base fournit avec le système d'exploitation est justement compatible avec ces standards minimaux.

Mais le pilote ne peut pas profiter des fonctionnalités qui sont au-delà de ce standard. L'accélération 3D est presque inexistante avec le pilote de base, qui ne sert qu'à faire du rendu 2D très basique, juste assez pour afficher l’interface de base du système d'exploitation. Par exemple, certaines résolutions ne sont pas disponibles et les performances sont loin d'être excellentes. Si vous avez déjà utilisé un PC sans pilote de carte graphique installé, vous avez certainement remarqué qu'il était particulièrement lent.

Le pilote de la carte graphique gère beaucoup de choses. Comme tout pilote de périphérique, il gère la communication entre procersseur et GPU, via des techniques communes comme les interruptions, le pooling ou le DMA. Plus évident, il s'occupe de la gestion de la mémoire vidéo, à savoir que c'est lui qui place les textures ou les modèles 3D dedans, il place le framebuffer, les render target et tout ce qui réside en mémoire vidéo. Il s'occupe aussi des fonctionnalités liées à l'affichage : initialiser la carte graphique, fixer la résolution, le taux de rafraichissement, gérer le curseur de souris matériel, etc. Mais surtout, le pilote de périphérique s'occupe de l'exécution des draw call et des changements de render state. Dans ce qui suit, nous allons nous intéresser aux fonctionnalités spécifiques au rendu 3D.

Les commandes matérielles, compréhensibles par le GPU

[modifier | modifier le wikicode]

Pour rappel, les API 3Denvoient des commandes graphiques au pilote de périphérique. Les commandes graphiques sont standardisées, spécifiques à chaque API, et surtout : indépendantes du matériel. Le matériel ne comprend pas ces commandes graphiques ! A la place, le GPU comprend des commandes matérielles, spécifiques à chaque marque de GPU, si ce n'est à chaque GPU. Lors du passage à une nouvelle génération de GPU, des commandes matérielles peuvent apparaître, d'autres disparaître, d'autre voient leur fonctionnement légèrement altéré, etc. Le pilote de la carte graphique doit convertir les commandes graphiques de l'API 3D, en commandes matérielles que le GPU peut comprendre.

La traduction des commandes se fait dans le pilote en espace utilisateur, alors que leur envoi au GPU est le fait du pilote en espace noyau.

L'envoi des commandes à la carte graphique ne se fait pas directement. La carte graphique n'est pas toujours libre pour accepter une nouvelle commande, soit parce qu'elle est occupée par une commande précédente, soit parce qu'elle fait autre chose. Il faut alors faire patienter les données dans une file de commandes, où les commandes matérielles attendent leur tour, dans l'ordre d'arrivée. Elle est placée soit dans une portion de la mémoire vidéo, soit est dans la mémoire RAM.

Si la file de commandes est plein, le driver n'accepte plus de demandes en provenance des applications. Une file de commandes pleine est généralement mauvais signe : cela signifie que la carte graphique est trop lente pour traiter les demandes qui lui sont faites. Par contre, il arrive que la file de commandes soit vide : dans ce cas, c'est simplement que la carte graphique est trop rapide comparé au processeur, qui n'arrive alors pas à donner assez de commandes à la carte graphique pour l'occuper suffisamment.

La compilation des shaders

[modifier | modifier le wikicode]

Le pilote de carte graphique traduit les shaders en code machine que le GPU peut exécuter. En soi, cette étape est assez complexe, et ressemble beaucoup à la compilation d'un programme informatique normal. Les shaders sont écrits dans un langage de programmation de haut niveau, comme le HLSL ou le GLSL, avant d'être pré-compilés vers un langage dit intermédiaire. Le langage intermédiaire, comme son nom l'indique, sert d'intermédiaire entre le code source écrit en HLSL/GLSL et le code machine exécuté par la carte graphique. Il ressemble à un langage assembleur, mais reste malgré tout assez générique pour ne pas être un véritable code machine. Par exemple, il y a peu de limitations quant au nombre de processeurs ou de registres.

En clair, il y a deux passes de compilation : une passe de traduction du code source en langage intermédiaire, puis une passe de compilation du code intermédiaire vers le code machine. Notons que la première passe est réalisée par le programmeur des shaders, alors que la seconde est le fait du pilote du GPU. L'avantage est que la compilation prend moins de temps, comparé à compiler directement du code HLSL/GLSL. Le gros du travail à été fait lors de la première passe de compilation et le pilote graphique ne fait que finir le travail. Autant dire que cela économise plus le processeur que si on devait compiler complètement les shaders à chaque exécution.

Fait amusant, il faut savoir que le pilote peut parfois remplacer les shaders d'un jeu vidéo à la volée. Les pilotes récents embarquent en effet des shaders alternatifs pour les jeux les plus vendus et/ou les plus populaires. Lorsque vous lancez un de ces jeux vidéo et que le shader originel s'exécute, le pilote le détecte automatiquement et le remplace par la version améliorée, fournie par le pilote. Évidemment, le shader alternatif du pilote est optimisé pour le matériel adéquat. Cela permet de gagner en performance, voire en qualité d'image, sans pour autant que les concepteurs du jeu n'aient quoique ce soit à faire.

Enfin, certains shaders sont fournis par le pilote pour d'autres raisons. Les anciennes cartes graphiques avaient des circuits de T&L pour traiter la géométrie, mais elles ont disparues sur les machines récentes. Par souci de compatibilité, les circuits de T&L doivent être émulés sur les GPU récents. Sans cette émulation, les vieux jeux vidéo conçus pour exploiter le T&L et d'autres technologies du genre ne fonctionneraient plus du tout. Émuler les circuits fixes disparus sur les cartes récentes est justement le fait de shaders fournit par le pilote de carte graphique.