Les cartes graphiques/Version imprimable

Ceci est la version imprimable de Les cartes graphiques.
  • Si vous imprimez cette page, choisissez « Aperçu avant impression » dans votre navigateur, ou cliquez sur le lien Version imprimable dans la boîte à outils, vous verrez cette page sans ce message, ni éléments de navigation sur la gauche ou en haut.
  • Cliquez sur Rafraîchir cette page pour obtenir la dernière version du wikilivre.
  • Pour plus d'informations sur les version imprimables, y compris la manière d'obtenir une version PDF, vous pouvez lire l'article Versions imprimables.


Les cartes graphiques

Une version à jour et éditable de ce livre est disponible sur Wikilivres,
une bibliothèque de livres pédagogiques, à l'URL :
https://fr.wikibooks.org/wiki/Les_cartes_graphiques

Vous avez la permission de copier, distribuer et/ou modifier ce document selon les termes de la Licence de documentation libre GNU, version 1.2 ou plus récente publiée par la Free Software Foundation ; sans sections inaltérables, sans texte de première page de couverture et sans Texte de dernière page de couverture. Une copie de cette licence est incluse dans l'annexe nommée « Licence de documentation libre GNU ».

Les cartes d'affichage

Les cartes graphiques sont des cartes qui communiquent avec l'écran, pour y afficher des images. Au tout début de l'informatique, ces opérations étaient prises en charge par le processeur : celui-ci calculait l'image à afficher à l'écran, et l'envoyait pixel par pixel à l'écran, ceux-ci étant affichés immédiatement après. Pour simplifier la vie des programmeurs, les fabricants de matériel ont inventé des cartes d'affichage, ou cartes vidéo. Avec celles-ci, le processeur calcule l'image à envoyer à l'écran, la transmet à la carte d'affichage, qui la transmet à l'écran. L'avantage d'une carte d'affichage et qu'elle décharge le processeur de l'envoi de l'image à l'écran. Le processeur n'a pas à se synchroniser avec l'écran, juste à envoyer l'image à une carte d'affichage.

Les cartes graphiques actuelles sont des cartes d'affichage améliorées auxquelles on a ajouté des circuits annexes, afin de leur donner des capacités de calcul pour le rendu 2D et/ou 3D, mais elles n'en restent pas moins des cartes d'affichages. La seule différence est que le processeur n’envoie pas une image à la mémoire vidéo, mais que l'image à afficher est calculée par la carte graphique 2D/3D. Si vous analysez une carte graphique récente, vous verrez que les circuits des premières cartes d'affichage sont toujours là, bien que noyés dans des circuits de calcul ou de rendu 2D/3D.

L'architecture globale d'une carte d'affichage modifier

Une carte d'affichage contient au minium trois circuits : une mémoire vidéo, un circuit de contrôle, un circuit d'interfaçage avec l'écran et un circuit d'interfaçage avec le bus. La mémoire vidéo mémorise l'image à afficher, les deux circuits d'interfaçage permettent à la carte d'affichage de communiquer respectivement avec l'écran et le reste de l'ordinateur, le circuit de contrôle commande les autres circuits et sert de chef d'orchestre pour un orchestre dont les autres circuits seraient les musiciens.

 
Carte d'affichage - architecture.

La carte graphique communique via un bus, un vulgaire tas de fils qui connectent la carte graphique à la carte mère. Les premières cartes graphiques utilisaient un bus nommé ISA, qui fût rapidement remplacé par le bus PCI, plus rapide. Viennent ensuite le bus AGP, puis le bus PCI-Express. Ce bus est géré par un contrôleur de bus, un circuit qui se charge d'envoyer ou de réceptionner les données sur le bus. Les circuits de communication avec le bus permettent à l'ordinateur de communiquer avec la carte graphique, via le bus PCI-Express, AGP, PCI ou autre. Il contient quelques registres dans lesquels le processeur pourra écrire ou lire, afin de lui envoyer des ordres du style : j'envoie une donnée, transmission terminée, je ne suis pas prêt à recevoir les données que tu veux m'envoyer, etc. Il y a peu à dire sur ce circuit, aussi nous allons nous concentrer sur les autres circuits.

Le circuit d'interfaçage électrique se contente de convertir les signaux de la carte graphique en signaux que l'on peut envoyer à l'écran. Il s'occupe notamment de convertir les tensions et courants : si l'écran demande des signaux de 5 Volts mais que la carte graphique fonctionne avec du 3,3 Volt, il y a une conversion à faire. De même, le circuit d'interfaçage électrique peut s'occuper de la conversion des signaux numériques vers de l'analogique. L'écran peut avoir une entrée analogique, surtout s'il est assez ancien. Les anciens écrans CRT ne comprenaient que des données analogiques et pas le binaire, alors que c'est l'inverse pour la carte graphique, ce qui fait que le circuit d'interfaçage devait faire la conversion. La conversion était réalisée par un circuit qui traduit des données numériques (ici, du binaire) en données analogiques : le convertisseur numérique-analogique ou DAC (Digital-to-Analogue Converter). Au tout début, le circuit d’interfaçage était un DAC combiné avec des circuits annexes, ce qu'on appelle un RAMDAC (Random Access Memory Digital-to-Analog Converter). De nos jours, les écrans comprennent le binaire sous réserve qu'il soit codé suivant le standard adapté et les cartes graphiques n'ont plus besoin de RAMDAC.

Il y a peu à dire sur les circuits d'interfaçage. Leur conception et leur fonctionnement dépendent beaucoup du standard utilisé. Sans compter qu'expliquer leur fonctionnement demande de faire de l'électronique pure et dure, ce qui est rarement agréable pour le commun des mortels. Par contre, étudier le circuit de contrôle et la mémoire vidéo est beaucoup plus intéressant. On peut donner quelques généralités particulièrement utiles sur ces deux circuits, qui forment le cœur de la carte d'affichage. Aussi, les deux sections qui suivent seront consacrées à la mémoire vidéo et au circuit de contrôle.

Le circuit de contrôle modifier

Le circuit de contrôle, aussi appelé séquenceur, s'occupe de générer les signaux à destination de l'écran. Pour cela, il doit faire deux choses : lire les pixels à envoyer à l'écran depuis la mémoire vidéo, et générer des signaux de contrôle annexes. Les signaux de contrôle sont variés, sans compter qu'ils sont émis avec des timings bien précis.

 
Architecture globale d'une carte d'affichage, avec CRTC.

L'envoi des pixels à l'écran modifier

Le câble qui relie la carte graphique à l'écran transmet au mieux un seul pixel à la fois, voire un seul bit à la fois. On ne peut pas envoyer l'image d'un seul coup à l'écran, et on doit l'envoyer pixel par pixel. L'écran traite alors ce flux de pixels de deux manières différentes. Dans le cas le plus intuitif, l'écran accumule les pixels reçus dans une mémoire tampon et affiche l'image une fois qu'elle est totalement reçue. Sinon, l'écran affiche les pixels reçus immédiatement dès leur réception sur l'entrée. Mais il faut envoyer les pixels dans un certain ordre bien précis.

 
Coordonnées d'un pixel à l'écran.

Rappelons qu'un écran est considéré par la carte graphique comme un tableau de pixels, organisé en lignes et en colonnes. Les écrans LCD sont bel et bien conçus comme cela, c'est plus compliqué sur les écrans CRT, mais cela ne change rien du point de vue de la carte graphique. Chaque pixel est localisé sur l'écran par deux coordonnées : sa position en largeur et en hauteur. Par convention, on suppose que le pixel de coordonnées (0,0) est celui situé tout haut et tout à gauche de l'écran. Le pixel de coordonnées (X,Y) est situé sur la X-ème colonne et la Y-ème ligne. Le tout est illustré ci-contre.

Le balayage progressif de l'écran modifier

La carte graphique doit envoyer les pixels dans un certain ordre, qui est généralement ligne par ligne, colonne par colonne : de haut en bas et de gauche à droite. Cet ordre d'envoi est appelé le balayage progressif. Le balayage progressif est utilisé sur tous les écrans LCD moderne, mais il était plus adapté aux écrans CRT. Sur les écrans plats, l'image transmise à l'écran est affichée une fois qu'elle est intégralement reçue, d'un seul coup. Mais sur les anciens écrans de télévision, les choses étaient différentes. Les vieux écrans CRT fonctionnaient sur ce principe : un canon à électrons balayait l'écran en commençant en haut à gauche, et balayait l'écran ligne par ligne. Ce scan progressif de l'image faisait apparaître l'image progressivement et profitait de la persistance rétinienne pour former une image fixe. L'image était donc affichée en même temps qu'elle était envoyée et le scan progressif correspondait à l'ordre d'allumage des pixels à l'écran.

 
Intérieur d'un écran CRT. En 1, on voit le canon à électron. En 2, on voit le faisceau d'électron associé à chaque couleur. En 3, les faisceaux d'électrons sont déviés par des électroaimants, pour atterrir sur le pixel à éclairer. En 4, le faisceau d'électrons frappe la surface de l'écran, composée de phosphore, qui s'illumine alors. En 5, on voit que les trois faisceaux ne frappent exactement au même endroit : l'un frappe sur une zone colorée en bleu, l'autre sur du vert, l'autre sur du rouge. Les trois zones combinées affichent une couleur par mélange du rouge, du vert et du bleu. Ne vous trompez pas : le faisceau d'électron n'a pas de couleur, comme indiqué sur le schéma, la couleur a été ajoutée pour faire comprendre qu'un faisceau est dirigé sur les pixels rouges, un autre sur les pixel bleus, et l'autre sur les pixels verts.

Avec le balayage progressif, la carte graphique envoie le pixel (0,0) en premier, puis celui situé à gauche et ainsi de suite. Quand il a fini d'envoyer la ligne de pixel, il descend et reprend à la ligne suivante, tout à gauche. L'ordre de transfert est donc assez simple : ligne par ligne, de gauche à droite. Pour gérer cet ordre de transmission, la carte graphique contient deux compteurs (des circuits qui comptent de 0 à N). Le premier compteur compte pour la coordonnée X et l'autre la coordonnée Y, ce qui leur vaut les noms de compteur de colonne et de compteur de ligne. Les deux compteurs sont initialisés à 0 avant la transmission. De plus, ils sont configurés de manière à prendre en compte la résolution de l'écran. Par exemple, pour une résolution de 640 par 480, le compteur de colonne est configuré pour compter de 0 à 639, alors que l'autre compte de 0 à 479. En clair, leur valeur maximale, celle à laquelle ils s’arrêtent de compter, est égale à la résolution horizontale/verticale. Quand un pixel est envoyé, le compteur de colonne X est incrémenté, afin de pointer sur le pixel suivant. Quand ce compteur dépasse sa valeur maximale, cela signifie qu'il faut changer de ligne. Le compteur de ligne Y est alors incrémenté, afin de passer à la ligne suivante. Quant au compteur de colonne, il est réinitialisé, remis à zéro, afin de balayer la prochaine ligne à partir de la bonne colonne. Ainsi, pour une résolution de 640 par 480, le compteur de colonne est remis à 0 quand on l'incrémente au-delà de 639 : il ne passe alors pas à 640, mais repasse à 0.

La carte graphique doit lire l'image dans la mémoire vidéo pixel par pixel dans l'ordre adéquat, pour obtenir ce balayage ligne par ligne, de gauche à droite. Cela est réalisé par des compteurs, des registres à décalage et quelques circuits. Le contenu des compteurs de ligne et de colonne est combiné avec d'autres informations, de manière à pointer sur le pixel en mémoire vidéo. En clair, l'adresse mémoire du pixel à afficher est calculée à partir de la valeur des deux compteurs, et de l'adresse du premier pixel. Mais le calcul à réaliser dépend de la manière dont l'image est codée en mémoire vidéo. En général, ce codage est des plus simple : l'image est stockée dans ce que les programmeurs appellent un tableau bi-dimensionnel. On peut récupérer un pixel en spécifiant les deux coordonnées X et Y, ce qui est l'idéal. Pour détailler un peu ce tableau bi-dimensionnel de pixels, c'est juste que les pixels consécutifs sur une ligne sont consécutifs en mémoire et les lignes consécutives sur l'écran le sont aussi dans la mémoire vidéo. En clair, il suffit de balayer la mémoire pixel par pixel en partant de l'adresse mémoire du premier pixel, jusqu’à atteindre la fin de l'image.

 
CRTC et calcul d'adresse.

L'entrelacement modifier

 
Illustration de l'entrelacement.

La technique du balayage progressif n'avait pas de défauts particuliers, ce qui fait que tous les écrans d’ordinateurs CRT l'utilisait. Mais les télévisions de l'époque utilisaient une méthode différente, appelée l'entrelacement. Avec elle, l'écran faisait un scan pour les lignes paires, suivi par un scan pour les lignes impaires. Le tout est illustré dans l'animation ci-contre.

 
Illustration de l'entrelacement et de ses effets sur la perception.

L'entrelacement donne l'illusion de doubler la fréquence d'affichage, ce qui est très utile sur les écrans à faible fréquence de rafraîchissement. Pour comprendre pourquoi, il faut comparer ce qui se passe entre un écran à scan progressif non-entrelacé et un écran entrelacé. Avec l'écran non-entrelacé, l'image met un certain temps à s'afficher, qui correspond au temps que met le canon à électron à balayer la totalité de l'écran, ligne par ligne. Avec l'entrelacement, le temps mis pour balayer l'écran est le même, car le nombre de lignes à balayer reste le même, seul l'ordre change. Par contre, sur l'écran entrelacé, l'image s'affiche à moitié une première fois (sur les lignes paires) avant que l'image complète s'affiche. La moitié d'image affichée par l'écran entrelacé a une résolution suffisante pour que le cerveau humain soit trompé et perçoive une image presque complète. En clair, le cerveau verra deux images par balayage complet : une image partielle lors du balayage des lignes paires et une image complète lors du balayage des lignes impaires. Sans entrelacement, le cerveau ne verra qu'une seule image lors de chaque balayage complet.

L'effet est d'autant plus important que la résolution verticale (le nombre de lignes) est important. De plus, l'effet est encore plus important si l'ordinateur calcule un grand nombre d'images par secondes. Par exemple, pour un écran avec une fréquence de rafraîchissement de 60 Hz et un jeu vidéo qui tourne deux fois plus vite (à 120 images par secondes, donc), l'image sur les lignes impaires sera plus récente que celle sur les lignes paires. Le cerveau humain sera sensible à cela et verra une image plus fluide (bien qu'imparfaitement fluide).

L'entrelacement est géré par le circuit de communication avec l'écran, qui s'occupe aussi de la gestion de la lecture de l'image en mémoire vidéo. L'entrelacement demande de lire l'image à afficher une ligne sur deux, donc d’accéder à la mémoire vidéo d'une certaine manière. Gérer l'entrelacement est donc un sujet qui implique l'écran mais aussi la carte d'affichage. Notamment, la lecture des pixels dans la mémoire vidéo se fait différemment. Le compteur de ligne est modifié de manière à avoir une séquence de comptage différente. Déjà, il compte deux par deux, pour sauter une ligne sur deux. De plus, quand il est réinitialisé, il est réinitialisé à une valeur qui est soit paire, soit impaire, en alternant.

Le nombre de lignes est toujours impair (normes analogiques : 625 en Europe, 525 en Amérique), ce qui fait un nombre non entier de lignes pour chacune des 2 trames (impaires et paires). Par exemple, pour 625 lignes cela fait 312,5 lignes par trame. Le balayage vertical étant progressif durant le balayage horizontal, les lignes sont imperceptiblement penchées. À la fin du balayage d'une trame, le rayon se retrouve au milieu de la ligne horizontale, soit un décalage vertical d'une demie-ligne (voir image ci-dessous).

 
Entrelacement sur tube cathodique.

La gestion des timings pour la communication avec l'écran modifier

Un autre point important est que la carte graphique ne peut pas envoyer un flux de pixels n'importe quand et doit respecter des timings bien précis. Le flux de pixel envoyé à l'écran est souvent structuré d'une certaine manière, avec des temps de pause, un temps de maintien minimum pour chaque pixel, etc.

Premièrement, l'écran doit être synchronisé avec la carte graphique. Même si cela commence à changer de nos jours, l'écran affiche un certain nombre d'images par secondes, le nombre en question étant désigné sous le terme de fréquence de rafraîchissement. Pour un écran avec une fréquence de rafraîchissement de 60 Hz (60 images par secondes), la carte graphique doit envoyer une nouvelle image tous les (1 seconde / 60) = 16,666... millisecondes. Sur les écrans LCD, la fréquence de rafraîchissement ne dépend pas de la résolution utilisée, en raison de différences de technologie. Sur les anciens écrans CRT, la fréquence de rafraîchissement dépendait de la résolution utilisée, et la carte d'affichage devait alors gérer le couple résolution-fréquence elle-même et la gestion de la fréquence de rafraîchissement était donc plus compliquée.

De plus, il faut tenir compte des timings liés à la transmission de l'image elle-même. La carte graphique doit envoyer les pixels avec des timings tout aussi stricts, qui dépendent du standard vidéo utilisé. Chaque pixel doit être maintenu durant un certain temps bien précis, il y a un certain temps entre la transmission de deux pixels, etc. Et le circuit d’interfaçage doit gérer tout cela.

Enfin, il faut aussi tenir compte d'autres timings, notamment sur les écrans CRT. Sur les écrans CRT, les pixels sont envoyés ligne par ligne et une ligne de pixel n'a pas la même taille suivant la résolution : 640 pixels pour du 640*480, 1280 pour du 1280*1024, etc. La carte graphique doit donc indiquer quand commencent et se terminent chaque ligne dans le flux de pixels. Sans cela, on ne pourrait pas gérer des résolutions différentes.

L'exemple du standard VGA modifier

Pour comprendre quels sont ces timings, prenons l'exemple de l'antique standard VGA. Avec ce standard, il existait un fil H-SYNC pour indiquer qu'on transmettait une nouvelle ligne et un fil V-SYNC pour indiquer qu'on envoie une nouvelle image. Une nouvelle ligne ou image est indiquée en mettant un 0 sur le fil adéquat. De plus, on devait attendre un certain temps entre la transmission de deux lignes, ce qui introduisait des vides dans le flux de pixels. Même chose entre deux images, sauf que le temps d'attente était plus long que le temps d'attente entre deux lignes. Le tout est détaillé dans le schéma ci-dessous, qui détaille le cas pour une résolution de 640 par 480.

 
Standard VGA : spécification des temps d'attentes entre deux lignes et deux images.

Le circuit de gestion des timings est souvent fusionné avec le circuit qui lit la mémoire vidéo, pour des raisons de simplicité de conception. Et c'est le cas avec le standard VGA. Les deux signaux H-sync et V-sync sont fournit à partir du contenu des deux compteurs de ligne et de colonne vus plus haut. Ils sont synchronisés à une fréquence bien précise, qui détermine le temps mis pour passer d'un pixel à l'autre et d'une ligne à l'autre. Le temps de transmission d'un pixel est de 25,6 µs / 640 = 0,04 µs, ce qui correspond à une fréquence de 25 MégaHertz. Le compteur de colonne est donc cadencé à 25 MHz. Les temps d'attente de 1,54 et 0,64 µs correspondent respectivement à 38 et 16 cycles du compteur. Quant à la durée de 3,8 µs du signal H-sync, elle correspond à 95 cycles. En tout, cela fait 640 + 95 + 16 + 38 = 789. Il faut donc un compteur qui compte de 0 à 788. La transmission des pixels commence quand le compteur commence à compter. Puis, le compteur continue de compter pendant 0,64 µs alors qu'aucun pixel n'est envoyé, afin de gérer le temps d'attente avant le signal H-sync. Puis, au 640 + 16e cycle, le signal H-sync est généré pendant 95 cycles. Enfin, le compteur continue de compter pendant 38 cycles pour le second temps d'attente. Le signal H-sync est donc généré quand le compteur a une valeur comprise entre 656 et 751 : il suffit d'ajouter un comparateur qui vérifie si le compteur est dans cet intervalle, et donc la sortie est à zéro si c'est le cas. La même logique s'applique avec le signal V-sync, mais avec des timings différents, illustrés plus haut.

 
Circuit de gestion des timings H-sync et V-sync d'un écran VGA.

Le framebuffer modifier

La mémoire vidéo est nécessaire pour stocker l'image à afficher à l'écran, mais aussi pour mémoriser temporairement des informations importantes. Sur les toutes premières cartes graphiques, elle servait uniquement à stocker l'image à afficher à l'écran. Le terme pour ce genre de mémoire vidéo est : Framebuffer. Au fil du temps, elle s'est vu ajouter d'autres fonctions, comme stocker les textures et les vertices de l'image à calculer, ainsi que divers résultats temporaires.

Elle est très proche des mémoires RAM qu'on trouve sous forme de barrettes dans nos PC, à quelques différences près. En premier lieu, la mémoire vidéo peut supporter un grand nombre d'accès mémoire simultanés. Ensuite, elle est optimisée pour accéder à des données proches en mémoire. Dans les grandes lignes, elle est optimisée pour avoir un débit de donnée très élevé, au détriment du temps d'accès, qui est assez moyen.

Le codage des pixels modifier

Chaque pixel est codé sur un certain nombre de bits, qui dépend du standard d'affichage utilisé. À l'époque des toutes premières cartes graphiques, les écrans étaient monochromes et ne pouvait afficher que deux couleurs : blanc ou noir. De fait, il suffisait d'un seul bit pour coder la couleur d'un pixel : 0 codait blanc, 1 codait noir (ou l'inverse, peu importe). Par la suite, les niveaux de gris furent ajoutés, ce qui demanda d'ajouter des bits en plus.

1 bit 2 bit 4 bit 8 bit
       

La technique de la palette indicée modifier

 
Palette indicée. En haut, on a le framebuffer, qui contient les couleurs codées par des nombres. La table de correspondance est donnée au milieu, et l'image finale en bas.

Avec l'apparition de la couleur, il fallut ruser pour coder les couleurs. Cela demandait d'utiliser plus de 1 bit par pixel : 2 bits permettaient de coder 4 couleurs, 3 bits codaient 8 couleurs, 4 bits codaient 16 couleurs, 8 bits codaient 256 couleurs, etc. Chaque combinaison de bit correspondait à une couleur et la carte d'affichage contenait une table de correspondance qui fait la correspondance entre un nombre et la couleur associée. Cette technique s'appelle la palette indicée, la table de correspondance s'appelant la palette.

 
Palette de l'IBM16.

Au tout début, la table de correspondance était généralement fixée une bonne fois pour toute dans la carte d'affichage, dans un circuit dédié. Mais par la suite, les cartes d'affichage permirent de modifier la table de correspondance dynamiquement. Les programmeurs pouvaient modifier son contenu, et ainsi changer la correspondance nombre-couleur à la volée. Des applications différentes pouvaient ainsi utiliser des couleurs différentes, on pouvait adapter la palette en fonction de l'image à afficher, c'était aussi utilisé pour faire des animations sans avoir à modifier la mémoire vidéo. Les applications étaient multiples. En changeant le contenu de la palette, on pouvait réaliser des gradients mobiles, ou des animations assez simples : c'est la technique du color cycling.

Exemples d'animations obtenues avec du color Cycling
       

Le standard RGB et ses dérivés modifier

 
Image codée en RGB : l'image est un mélange de trois images : une ne contenant que des nuances de rouge, une des nuances de vert, et la dernière uniquement des nuances de bleu.

La table de correspondance grandit exponentiellement avec le nombre de bits, ce qui fait qu'elle devient rapidement très grande. Au-delà de 8/12 bits, la technique de la palette n'est pas très intéressante. Ce qui fait que le codage des couleurs a dû prendre une autre direction quand la limite des 8 bits fût dépassée. L'idée pour contourner le problème est d'utiliser la synthèse additive des couleurs, que vous avez certainement vu au collège. Pour rappel, cela revient à synthétiser une couleur en mélangeant deux à trois couleurs primaires. La manière la plus simple de faire cela est de mélanger du Rouge, du Bleu, et du Vert. En appliquant cette méthode au codage des couleurs, on obtient le standard RGB (Red, Green, Blue). L'intensité du vert est codée par un nombre, idem pour le rouge et le bleu.

Autrefois, il était courant de coder un pixel sur 8 bits, soit un octet : 2 bits étaient utilisés pour coder le bleu, 3 pour le rouge et 3 pour le vert. Le fait qu'on ait choisi seulement 2 bits pour le bleu s'explique par le fait que l’œil humain est peu sensible au bleu, mais est très sensible au rouge et au vert. Nous avons du mal à voir les nuances fines de bleu, contrairement aux nuances de vert et de rouge. Donc, sacrifier un bit pour le bleu n'est pas un problème. De nos jours, l'intensité d'une couleur primaire est codée sur 8 bits, soit un octet. Il suffit donc de 3 octets, soit 24 bits, pour coder une couleur.

Une autre astuce pour économiser des bits est de se passer d'une des trois couleurs primaires, typiquement le bleu. En faisant cela, on code toutes les couleurs par un mélange de deux couleurs, le plus souvent du rouge et du vert. Vu que l’œil humain a du mal avec le bleu, c'est souvent la couleur bleu qui disparait, ce qui donne le standard RG. En faisant cela, on économise les bits qui codent le bleu : si chaque couleur primaire est codée sur un octet, deux octets suffisent au lieu de trois avec le RGB usuel.

RGB 16 bits RG 16 bits
   

L'organisation du framebuffer modifier

La taille du framebuffer limite la résolution maximale atteignable. Autant ce n'est pas du tout un problème sur les cartes graphiques actuelles, autant c'était un facteur limitant pour les toutes premières cartes d'affichage. En effet, prenons une image dont la résolution est de 640 par 480 : l'image est composée de 480 lignes, chacune contenant 640 pixels. En tout, cela fait 640 * 480 = 307200 pixels. Si chaque pixel est codé sur 32 bits, l'image prend donc 307200 * 32 = 9830400 bits, soit 1228800 octets, ce qui fait 1200 kilo-octets, plus d'un méga-octet. Si la carte d'affichage a moins d'un méga-octet de mémoire vidéo, elle ne pourra pas afficher cette résolution, sauf en trichant avec les techniques d'entrelacement. De manière générale, la mémoire prise par une image se calcule comme : nombre de pixels * taille d'un pixel, où le nombre de pixels de l’image se calcule à partir de la résolution (on multiplie la hauteur par la largeur de l'écran, les deux exprimées en pixels).

Le framebuffer peut être organisé plusieurs manières différentes, mais deux grandes méthodes se dégagent. La toute première est celui du packed framebuffer, ou encore framebuffer compact. Elle est très intuitive : les pixels sont placés les uns à côté des autres en mémoire. L'image est découpée en plusieurs lignes de pixels, deux pixels consécutifs sur une ligne sont placés à des adresses consécutives, deux lignes consécutives se suivent dans la mémoire. L'autre organisation est le planar framebuffer, aussi appelé la méthode des bitplanes. Pour la comprendre, prenons le cas où chaque pixel est codé par deux bits : l'organisation planaire va découper l'image en deux : une image qui contient seulement le premier bit pour chaque pixel, et une autre image qui contient seulement le second bit. Chaque image est codée avec un framebuffer compact. Le principe se généralise pour des pixels codés sur N bits, sauf qu'il faudra alors N images.

Disons-le clairement, la première méthode est la plus simple et la plus intuitive, alors que la seconde n'a pas d'intérêt évident. Son avantage principal est qu'elle gaspille moins de mémoire quand les pixels sont codés sur 3, 5, 6, 7, 9, 11 bits ou tout autre nombre de bits qui n'est pas une puissance de deux. Un autre avantage est que l'on peut modifier un bitplanes indépendamment des autres, ce qui permet de faire certains effets graphiques simplement. C'est leur avantage principal, mais ils ont l’inconvénient que lire un pixel est plus lent. Aussi, ils sont rarement utilisés dans les cartes d'affichage, sauf pour les très anciens modèles qui codaient leurs couleurs sur 3, 5 ou 7 bits.

Pour donner un exemple d'utilisation de planar framebuffer est l'ancien ordinateur/console de jeu Amiga Commodore. Une autre utilisation est celle faite dans le standard VGA. Dans sa résolution native de 640 par 480 en 16 couleurs, le framebuffer est de type planaire. Il y a quatre plans de 1 bit chacun, ce qui colle bien avec le fait que chaque couleur est codée sur 4 bits dans cette résolution. De plus, le framebuffer est une mémoire de 256 kibioctets, divisé en 4 banques de 64 kibioctets chacun. Les quatre banques sont accessibles en parallèles, ce qui permet de lire 4 bits en même temps. La raison derrière ce système est avant tout la compatibilité avec le standard d'avant le VGA, l'EGA, qui avait une mémoire limitée à 64 kibioctets.

Le multibuffering et la synchronisation verticale modifier

Sur les toutes premières cartes graphiques, le framebuffer ne pouvait contenir qu'une seule image. L'ordinateur écrivait donc une image dans le framebuffer et celle-ci était envoyée à l'écran dès que possible. Cependant, écran et ordinateur n'étaient pas forcément synchronisés. Rien n’empêchait à l’ordinateur d'écrire dans le framebuffer pendant que l'image était envoyée à l'écran. Et cela peut causer des artefacts qui se voient à l'écran.

Un exemple typique est celui des traitements de texte. Lorsque le texte affiché est modifié, le traitement de texte efface l'image dans le framebuffer et recalcule la nouvelle image à afficher. Ce faisant, une image blanche peut apparaitre durant quelques millisecondes à l'écran, entre le moment où l'image précédente est effacée et le moment où la nouvelle image est disponible. Ce phénomène de flickering; d'artefacts liés à une modification de l'image pendant qu'elle est affichée, est des plus désagréables.

Le double buffering modifier

Pour éviter cela, on peut utiliser la technique du double buffering. L'idée derrière cette technique est de calculer une image en avance et de les mémoriser celle-ci dans le framebuffer. Mais cela demande que le framebuffer ait une taille suffisante, qu'il puisse mémoriser plusieurs images sans problèmes. Le framebuffer est alors divisé en deux portions, une par image, auxquelles nous donnerons le nom de tampons d'affichage. L'idée est de mémoriser l'image qui s'affiche à l'écran dans le premier tampon d'affichage et une image en cours de calcul dans le second. Le tampon pour l'image affichée s'appelle le tampon avant, ou encore le front buffer, alors que celui avec l'image en cours de calcul s'appelle le back buffer.

 
Double buffering

Quand l'image dans le back-buffer est complète, elle est copiée dans le front buffer pour être affichée. L'ancienne image dans le front buffer est donc éliminée au profit de la nouvelle image. Le remplacement peut se faire par une copie réelle, l'image étant copiée le premier tampon vers le second, ce qui est une opération très lente. C'est ce qui est fait quand le remplacement est réalisé par le logiciel, et non par la carte graphique elle-même. Par exemple, c'est ce qui se passait sur les très anciennes versions de Windows, pour afficher le bureau et l'interface graphique du système d'exploitation. Mais une solution plus simple consiste à intervertir les deux tampons, le back buffer devenant le front buffer et réciproquement. Une telle interversion fait qu'on a pas besoin de copier les données de l'image. L'interversion des deux tampons peut se faire au niveau matériel. Rappelez-vous que plus haut, nous avions vu qu'il y a un circuit qui détermine l'adresse du pixel à lire à partir de deux compteurs X et Y, ainsi que de l'adresse du premier pixel. L'adresse du premier pixel n'est autre que l'adresse à laquelle commence le front buffer. En changeant cette adresse pour la faire pointer vers l'ancien back buffer, l’interversion se fait automatiquement.

 
Circuit de contrôle et double buffering

La synchronisation verticale modifier

Lors de l'interversion des deux tampons, le remplacement de la première image par la seconde est très rapide. Et il peut avoir lieu pendant que l'écran affiche la première image. L'image affichée à l'écran est alors composée d'un morceau de la première image en haut, et de la seconde image en dessous. Cela produit un défaut d'affichage appelé le tearing. Plus votre ordinateur calcule d'images par secondes, plus le phénomène est exacerbé.

 
Tearing (simulé)

Pour éviter ça, on peut utiliser la synchronisation verticale, aussi appelée vsync, dont vous en avez peut-être déjà entendu parler. C'est une option présente dans les options de nombreux jeux vidéo, ainsi que dans les réglages du pilote de la carte graphique. Elle consiste à attendre que l'image dans le front buffer soit entièrement affichée avant de faire le remplacement. La synchronisation verticale fait disparaitre le tearing, mais elle a de nombreux défauts, qui s'expliquent pour deux raisons que nous allons aboder.

Rappelons que l'écran affiche une nouvelle image à intervalles réguliers. L'écran affiche un certain nombre d'images par secondes, le nombre en question étant désigné sous le terme de "fréquence de rafraîchissement". La fréquence de rafraichissement est fixe, elle est gérée par un signal périodique dans l'écran. Par contre, sans Vsync, le nombre de FPS n'est pas limité, sauf si on a activé un limiteur de FPS dans les options d'un jeu vidéo ou dans les options du driver. Avec Vsync, le nombre de FPS est limité par la fréquence de l'écran. Par exemple, si vous avez un écran 60 Hz (sa fréquence de rafraichissement est de 60 Hertz), vous ne pourrez pas dépasser les 60 FPS. Vous pourrez avoir moins, cependant, si l'ordinateur ne peut pas sortir 60 images par secondes sans problème. Un autre défaut de la Vsync est donc qu'il faut un PC assez puissant pour calculer assez de FPS.

Par contre, même avec la vsync activée, l'écran n'est pas parfaitement synchronisé avec la carte graphique. Pour comprendre pourquoi, nous allons faire une analogie avec une situation de la vie courante. Imaginez deux horloges, qui sonnent toutes les deux à midi. Les deux ont la même fréquence, à savoir qu'elles sonnent une fois toutes les 24 heures. Maintenant, cela ne signifie pas qu'elles sont synchronisées. Imaginez qu'une horloge indique 1 heure du matin pendant que l'autre indique minuit : les deux horloges sont désynchronisées, alors qu'elles ont la même fréquence. Il y a un décalage entre les deux horloges, un déphasage.

Eh bien la même chose a lieu, avec la vsync. La vsync égalise deux fréquences : la fréquence de l'écran et les FPS (la fréquence de génération d'image par la carte graphique). Par contre, les deux fréquences sont généralement déphasées, il y a un délai entre le moment où la carte graphique a rendu une image, et le moment où l'écran affiche une image. Cela n'a l'air de rien, mais cela peut se ressentir. D'où l'impression qu'ont certains joueurs de jeux vidéo que leur souris est plus lente quand ils jouent avec la synchronisation verticale activée. Le temps d'attente lié à la synchronisation verticale dépend du nombre d'images par secondes. Pour un écran qui affiche maximum 60 images par seconde, le délai ajouté par la synchronisation verticale est au maximum de 1 seconde/60 = 16.666... millisecondes.

Un autre défaut est que la synchronisation verticale entraîne des différences de timings perceptibles. Le phénomène se manifeste avec les vidéos/films encodés à 24 images par secondes qui s'affichent sur un écran à 60 Hz : l'écran affiche une image tous les 16.6666... millisecondes, alors que la vidéo veut afficher une image toutes les 41,666... millisecondes. Or, 16.666... et 41.666... n'ont pas de diviseur entier commun : une image de film d'affiche tous les 2,5 images d'écran. Concrètement, écran et film sont désynchronisés. Si cela ne pose pas trop de problèmes sans la synchronisation verticale, cela en pose avec. Une image sur deux est décalée en termes de timings avec la synchronisation verticale, ce qui donne un effet bizarre, bien que léger, lors du visionnage sur un écran d'ordinateur. Le même problème survient dans les jeux vidéos, qui ont un nombre d'images par seconde très variable. Ces différences de timings entraînent des sauts d'images quand un jeu vidéo calcule moins d'images par seconde que ce que peut accepter l'écran, ce qui donne une impression désagréable appelée le stuttering.

Pour résumer, les problèmes de la vsync sont liés à deux choses : le nombre de FPS n'est pas nécessairement synchronisé avec le rafraichissement de l'écran, et le déphasage entre ordinateur et écran se ressent.

Le triple buffering et ses dérivés modifier

Diverses solutions existent pour éliminer ces problèmes, et elles sont assez nombreuses. La première solution ajoute un troisième tampon d'affichage, ce qui donne la technique du triple buffering. L'utilité est de réduire le délai ajouté par la synchronisation verticale : utiliser le triple buffering sans synchronisation verticale n'a aucun sens. L'idée est que l'ordinateur peut calculer une seconde image d'avance. Ainsi, si l'écran affiche l'image n°1, une image n°2 est terminée mais en attente, et une image n°3 est en cours de calcul.

 
Triple buffering

Le délai lié à la synchronisation verticale est réduit dans le cas où les FPS sont vraiment bas comparé à la fréquence d'affichage de l'écran, par exemple si on tourne à 40 images par secondes sur un écran à 60 Hz, du fait de l'image calculée en avance. Dans le cas où les FPS sont (temporairement) plus élevés que la fréquence d'affichage de l'écran, la troisième image finit son calcul avant que la seconde soit affichée. Dans ce cas, la seconde image est affichée avant la troisième. Il n'y a pas d'image supprimée ou abandonnée, peu importe la situation.

Les améliorations de la synchronisation verticale modifier

La technologie Fast Sync sur les cartes graphiques NVIDIA est une amélioration du triple buffering, qui se préoccupe du cas où les FPS sont (temporairement) plus élevés que la fréquence d'affichage de l'écran. Dans ce cas, avec le triple buffering simple, aucune image n'est abandonnée : on a deux images en attente, dont l'une est plus récente que l'autre. La technologie fast sync élimine la première image en attente et de la remplacer par la seconde, plus récente. L'avantage est que le délai d'affichage d'une image est réduit, le temps d'attente lié à la synchronisation verticale étant réduit au strict minimum.

Une autre solution est la synchronisation verticale adaptative, qui consiste à désactiver la synchronisation verticale quand le nombre d'images par seconde descend sous la fréquence de rafraîchissement de l'écran. Le principe est simple, mais il s'agit pourtant d'une technologie assez récente, introduite en 2016 sur les cartes NVIDIA. Notons qu'on peut combiner cette technologie avec la technologie fast sync : cette dernière fonctionne quand les FPS dépassent la fréquence de rafraîchissement de l'écran, alors que la vsync adaptative fonctionne quand les FPS sont trop bas. C'est utile si les FPS sont très variables.

Une dernière possibilité est d'utiliser des technologies qui permettent à l'écran et la carte graphique d'utiliser une fréquence de rafraîchissement variable. La fréquence de rafraîchissement de l'écran s'adapte en temps réel à celle de la carte graphique. En clair, l'écran démarre l'affichage d'une nouvelle image quand la carte graphique le lui demande, pas à intervalle régulier. Évidemment, l'écran a une limite physique et ne peut pas toujours suivre la carte graphique. Dans ce cas, la carte graphique limite les FPS au maximum de ce que peut l'écran. Les premières technologies de ce type étaient le Gsync de NVIDIA et le Free Sync d'AMD, qui ont été suivies par les standards AdaptiveSync et MediaSync.

L'historique des cartes d'affichage : mode texte et mode graphique modifier

Fort des explications précédentes, il est temps de faire un rapide aperçu de l'histoire des cartes d'affichage. Les toutes premières cartes d'affichage portaient le nom de cartes d'affichage en mode texte. Elles ont été suivies par les cartes d'affichage en mode graphique. Paradoxalement, les cartes d'affichage en mode texte sont de loin les moins intuitives et elles sont plus complexes que les cartes d'affichage en mode graphique. Les limitations de la technologie de l'époque rendaient plus adaptées les cartes en mode texte, notamment les limitations en terme de mémoire vidéo. La faible taille de la mémoire rendait impossible l'usage d'un framebuffer proprement dit, ce qui fait que les ingénieurs ont utilisé un moyen de contournement, qui a donné naissance aux cartes graphiques en mode texte. Par la suite, avec l'amélioration de la technologie des mémoires, les cartes d'affichage avec un framebuffer sont apparues et ont remplacé les complexes cartes d'affichage en mode texte.

Les cartes d'affichage en mode texte modifier

 
Illustration du mode texte.

Les premières cartes graphiques fonctionnaient en mode texte, c'est à dire qu'elles traitaient des caractères et non des pixels. L'écran était découpé en carré ou en rectangles de taille fixe, contenant chacun un caractère. Les caractères affichables sont des lettres, des chiffres, ou des symboles courants, même si des caractères spéciaux sont disponibles. La carte d'affichage traitait les caractère comme un tout et il était impossible de modifier des pixels individuellement. Ceux-ci sont encodés dans un jeu de caractère spécifique (ASCII, ISO-8859, etc.), qui est généralement l'ASCII.

 
Ce mode texte est toujours présent dans nos cartes graphiques actuelles et est encore utilisé par le BIOS, ce qui lui donne cet aspect désuet et moche des plus inimitable.

Tous les caractères sont des images de taille fixe, que ce soit en largeur ou en hauteur. Par exemple, un caractère peut faire 8 pixels de haut et 8 pixels de large, sur les écrans cathodiques. Sur les écrans LCD, les pixels sont carrés et les caractères font 16 pixels de haut par 8 de large pour avoir un aspect rectangulaire. Les images de chaque caractère sont mémorisées dans une mémoire : la table des caractères. Certaines cartes graphiques permettent à l'utilisateur de créer ses propres caractères en modifiant cette table. La mémoire de caractères est une mémoire ROM/EEPROM. Dans cette mémoire, chaque caractère est représenté par une matrice de pixels, avec un bit par pixel. On pourrait croire que la table des caractère telle que si l'on envoie le code ASCII sur l'entrée d'adresse, on récupère en sortie l'image du caractère associé. Mais cette solution simple est irréaliste : un simple caractère monochrome de 8 pixels de large et de 8 pixels de haut demanderait près de 64 bits en sortie, ce qui est impraticable, surtout pour les mémoires de l'époque. En réalité, la mémoire de caractère a une sortie de 1 bit, le bit en question étant pris dans l'image du caractère sélectionné. L'entrée d'adresse s'obtient alors en concaténant trois informations : le code ASCII pour sélectionner le caractère, le numéro de la ligne et le numéro de la colonne pour sélectionner le bit dans l'image du caractère.

 
Représentation d'un caractère à l'écran et dans la table de caractère.

Le tampon de texte (text buffer) est une mémoire dans laquelle les caractères à afficher sont placés les uns à la suite des autres. Le processeur envoie les caractères à afficher un par un, ceux-ci étant accumulés dans le tampon de texte au fur et à mesure de leur arrivée. Chaque caractère est stocké en mémoire avec deux octets : un octet pour le code ASCII, suivi d'un octet pour les attributs. Les attributs des caractères sont des informations qui indiquent si le caractère clignote, sa couleur, sa luminosité, si le caractère doit être souligné, etc. Une gestion minimale de la couleur est parfois présente. Le tout est mémorisé dans un octet, à la suite de leur code ASCII. L'avantage du mode texte est qu'il utilise très peu de mémoire vidéo : on ne code que les caractères et non des pixels indépendants, et un caractère correspond à beaucoup de pixels. La carte graphique contient un circuit chargé de gérer ces attributs des caractères : l'ATC (Attribute Controller), aussi appelé le contrôleur d'attributs. Il est situé juste en aval du tampon de texte

Vient ensuite le CRTC (Cathod Ray Tube Controller) ou contrôleur de tube cathodique, qui gère l'affichage sur l'écran proprement dit. Sur les vieux écrans CRT, les pixels sont affichés les uns après les autres, ligne par ligne, en commençant par le pixel en haut à gauche. Pour cela, il se souvient du prochain pixel à afficher grâce à deux registres : un registre Y pour la ligne, et un registre X pour la colonne. Évidemment, les deux registres sont incrémentés régulièrement afin de passer au pixel suivant et repassent à zéro quand on les incrémente au-delà de leur valeur maximale.

Mais sur une carte en mode texte, on n'a pas accès directement aux pixels en mémoire vidéo. À la place, on doit sélectionner le caractère à afficher, puis sélectionner le pixel dans celui-ci, ce qui demande la collaboration de la mémoire de caractères et le tampon de texte. Le CRTC va déduire à quel caractère correspond le pixel choisit, et le récupérer dans le tampon de texte. Là, le code du caractère est envoyé à la mémoire de caractère, avec quelques informations pour sélectionner le numéro de ligne et le numéro de colonne. le pixel récupéré dans la mémoire de caractère est alors envoyé à l'écran. Concrètement, les calcul à faire pour déterminer le caractère et pour trouver les numéros de ligne/colonne sont très simples, sans compter que les deux sont liés. Prenons par exemple un écran dont les caractère font tous 12 pixels de large et 8 pixels de haut. Le pixel de coordonnées X (largeur) et Y (hauteur) correspond au caractère de position X/12 et Y/8. Le reste de la première division donne la position de la colonne pour la mémoire de caractère, alors que le reste de la seconde division donne le numéro de ligne.

Si les caractères ont une largeur et une hauteur qui sont des puissances de deux, les divisions se simplifient : la position du caractère dans la mémoire se calcule alors à partir des bits de poids forts des compteurs X et Y, alors que les bits de poids faible permettent de donner le numéro de ligne et de colonne pour la mémoire de caractère. Dans le diagramme ci-dessus, les 3 bits de poids faible des registres X et Y de balayage des pixels servent à adresser le pixel parmi les 8x8 du bloc correspondant au caractère à afficher. L'index de ce caractère est lu dans le tampon de texte, adressé par les bits restant des registres X et Y.

 
Balayage des pixels par le CRTC pour un mode texte avec des caractères de 8x8 pixels.

Enfin, le pixel à afficher est envoyé à l'écran. Les écrans assez anciens fonctionnent en analogiques et non en numérique, ce qui fait que les cartes d'affichage de l'époque contenaient un DAC (Digital-Analogic Converter) pour convertir les données binaires en données analogiques (courant appliqué à chaque canon à électron pour le bleu, le vert et le rouge).

 
Architecture interne d'une carte d'affichage en mode texte

Les cartes d'affichage en mode graphique modifier

Les cartes en mode texte ont rapidement été remplacées par des cartes graphiques capables de colorier chaque pixel de l'écran individuellement. Il s'agit d'une avancée énorme, qui permet beaucoup plus de flexibilité dans l'affichage. Ces cartes graphiques étaient conçues avec des composants similaires aux composants des cartes graphiques à mode texte, mais avec quelques simplifications. Premièrement, la mémoire de caractères disparait (ou n'est pas utilisée en mode graphique). Ensuite, la mémoire vidéo est remplacée par un framebuffer, qui mémorise des pixels et non des caractères. Ensuite, le CRTC est modifié pour balayer le framebuffer ligne par ligne, pixel par pixel. Il peut gérer différentes résolutions, quelques registres permettant de configurer la résolution voulue.

La couleur fait son apparition, grâce à la technique de la palette indicée. Les couleurs étaient codées sur 4 bits, 8 bits, à la rigueur 16, alors que les couleurs acceptées par l'écran étaient au format RGB. La table de correspondance utilisée pour traduire les couleurs au format RGB s'appelait la Color Look-up Table. Dans les cas plus simples, il s'agissait d'une ROM qui mémorisait la couleur RGB pour chaque numéro envoyé en adresse. Dans les cas les plus courants, cette mémoire était une RAM, ce qui permettait de changer la palette d'une application à l'autre sans aucun problème, comme on l'a vu plus haut. La Color Look-up Table était parfois fusionnée avec le DAC, et formait ce qu'on appelait le RAMDAC.

Le cas le plus simple est celui des premiers modes CGA où 4 bits étaient utilisés pour indiquer la couleur : 1 bit pour chaque composante rouge, verte et bleue et 1 bit pour indiquer l'intensité (sombre / clair).

 
Architecture interne d'une carte d'affichage en mode graphique


Les cartes accélératrices 2D

Avec l'arrivée des interfaces graphiques (celles des systèmes d'exploitation, notamment) et des jeux vidéo 2D, les cartes graphiques ont pu s'adapter. Les cartes graphiques 2D ont d'abord commencé par accélérer le tracé et coloriage de figures géométriques simples, tels les lignes, segments, cercles et ellipses, afin d’accélérer les premières interfaces graphiques. Par la suite, diverses techniques d'accélération de rendu 2D se sont fait jour. Ces techniques ne sont pas utiles que pour les jeux vidéo, mais peuvent aussi servir à accélérer le rendu d'une interface graphique. Après tout, les lettres, les fenêtres d'une application ou le curseur de souris sont techniquement du rendu 2D. C'est ainsi que les cartes graphiques actuelles supportent des techniques d’accélération du rendu des polices d'écriture, une accélération du scrolling ou encore un support matériel du curseur de la souris, toutes dérivées des techniques d'accélération de rendu 2D.

Les cartes graphiques 2D peuvent se classer en deux grands types : celles où l'image est calculée puis mémorisée dans un framebuffer et celles qui font sans. Les premières sont relativement simples à comprendre, alors que les autres sont beaucoup plus complexes. Ces dernières fabriquent l'image en même temps qu'elles l'affichent à l'écran. Il n'y a pas d'image calculée proprement dit, mais la carte graphique calcule un pixel à la fois en même temps qu'elle l'affiche.

Les cartes 2D avec framebuffer modifier

Les cartes 2D avec un framebuffer calculent l'image intégralement dans le framebuffer. La base d'un rendu en 2D avec ces cartes d'affichage est de superposer des images 2D pré-calculées les unes au-dessus des autres. Par exemple, on peut avoir une image pour l’arrière-plan (le décor), une image pour le monstre qui vous fonce dessus, une image pour le dessin de votre personnage, etc. On distingue généralement l'image pour l'arrièreplan, qui prend tout l'écran, des images plus petites qu'on superpose dessus et qui sont appelées des sprites. Le rendu des sprites doit s'effectuer de l'image la plus profonde (l'arrière-plan), vers les plus proches (les sprites qui se superposent sur les autres) : on parle d'algorithme du peintre.

 
Exemple de rendu 2D utilisant l'algorithme du peintre.

Les copies en mémoire et le circuit de Blitter modifier

Les cartes 2D avec framebuffer partent des sprites et de l'image d'arrière-plan et les combinent pour former l'image finale dans le framebuffer. Superposer les sprite se traduit par une copie des pixels de l'image aux bons endroits dans la mémoire. Le framebuffer est d'abord remplit par l'image d'arrière-plan, puis chaque sprite est copié dans la portion de mémoire adéquate, qui correspond à sa place en mémoire. Ce genre de copie est nécessaire pour superposer les sprites, mais aussi lorsqu'on doit scroller, ou qu'un objet 2D se déplace sur l'écran. Déplacer une fenêtre sur votre bureau est un bon exemple : le contenu de ce qui était présent sur l'écran doit être déplacé vers le haut ou vers le bas. Dans la mémoire vidéo, cela correspond à une copie des pixels correspondant de leur ancienne position vers la nouvelle.

Une fois totalement calculée, l'image est envoyée à l'écran avec les timings adéquats. De telles cartes graphiques doivent être assez performantes. Elles doivent pouvoir rendre une image complète tous les soixantième ou cinquantième de secondes, les écrans CRT de l'époque ayant une fréquence de rafraichissement de 50 ou 60 Hertz. Vu que le rendu 2D demande de faire beaucoup de copie pour superposer les sprites, les cartes 2D ont introduit un composant pour accélérer les copies d'images en mémoire. Ce circuit chargé des copies s'appelle le blitter. Sans blitter, les copies étaient donc à la charge du processeur, qui devait déplacer lui-même les données en mémoire. Le blitter est conçu pour ce genre de tâches, sauf qu'il n'utilise pas le processeur.

La gestion des masques modifier

Ceci dit, un blitter possède d'autres fonctionnalités. Il peut effectuer une opération bit à bit entre les données à copier et une donnée fournie par le programmeur. Pour voir à quoi cela peut servir, reprenons notre exemple du jeu 2D, basé sur une superposition d'images. Les images des différents personnages sont souvent des images rectangulaires. Par exemple, l'image correspondant à notre bon vieux pacman ressemblerait à celle-ci. Évidemment, cette image s'interface mal avec l’arrière-plan. Avec un arrière-plan blanc, les parties noires de l'image du pacman se verraient à l'écran.

 
Image de Pacman.

L'idéal serait de ne pas toucher à l’arrière-plan sur les pixels noirs de pacman, et de ne modifier l’arrière-plan que pour les pixels jaunes. Ceci est possible en fournissant un masque, une image qui indique quels pixels modifier lors d'un transfert, et quels sont ceux qui ne doivent pas changer. Grâce à ce masque, le blitter sait quels pixels modifier. Le blitter prend l'image du pacman, le morceau de l’arrière-plan auquel on superpose pacman, et le masque. Pour chaque pixel, il effectue l'opération suivante : ((arrière-plan) AND (masque)) OR (image de pacman).

 
Masque de Pacman.

Au final, l'image finale est bel et bien celle qu'on attend.

 
Sprite rendering by binary image mask

Les cartes 2D sans framebuffer modifier

Avec les cartes 2D sans framebuffer, les sprites ne sont pas copiés sur un arrière-plan préexistant. À la place, l'image est rendue pixel par pixel, à la volée. La carte graphique décide, à chaque envoi de pixel, s'il faut afficher les pixels de l’arrière-plan ou du sprite pendant l'envoi des pixels à l'écran, lors du balayage effectué par le CRTC.

L'architecture matérielle d'une carte 2D sans framebuffer modifier

Sur ces cartes 2D, les sprites et l'image d'arrière-plan sont stockés dans des registres ou des RAM. La RAM pour l'arrière-plan est généralement assez grosse, car l'arrière-plan a la même résolution que l'écran. Il arrive que l'arrière-plan ait une résolution supérieure à celle de l'écran, ce qui est utile pour le scrolling. Par exemple, si je prends la console de jeux NES, elle a une résolution de base de 256 par 240, alors que l'arrière-plan a uje résolution de 512 par 480. Pour les sprites, la mémoire est généralement très petite, ce qui fait que les sprites ont une taille limitée.

Pour chaque sprite, on trouve deux registres permettant de mémoriser la position du sprite à l'écran : un pour sa coordonnée X, un autre pour sa coordonnée Y. Lorsque le CRTC demande à afficher le pixel à la position (X , Y), chaque triplet de registres de position est comparé à la position X,Y fournie par le CRTC. Si aucun sprite ne correspond, les mémoires des sprites sont déconnectées du bus et le pixel affiché est celui de l'arrière-plan. Dans le cas contraire, la RAM du sprite est connectée sur le bus et son contenu est envoyé au RAMDAC.

 
Sprites matériels.

Les cartes 2D les plus simples ne géraient que deux niveaux : soit l'arrière-plan, soit un sprite devant l'arrière-plan. Il n'est donc pas possible que deux sprites se superposent, partiellement ou totalement. Dans ce cas, l'image n'a que deux niveaux de profondeur. C'était le cas sur les consoles de seconde et troisième génération, comme la NES ou la Sega Saturn. Par la suite, une gestion des sprite superposés est apparue. Pour cela, il fallait stocker la profondeur de chaque sprite, pour savoir celui qui est superposé au-dessus de tous les autres. Cela demandait d'ajouter un registre pour chaque sprite, qui mémorisait la profondeur. le circuit devait donc être modifié de manière à gérer la profondeur, en gérant la priorité des sprites.

D'autres consoles ont ajouté une gestion de la transparence. Dans le cas le plus simple, la transparence permet de ne pas afficher certaines portions d'un sprite. Certains pixels d'un sprite sont marqués comme transparents, et à ces endroits, c'est l'arrière-plan qui doit s'afficher. Cela permet d'afficher des personnages ou objets complexes alors que l'image du sprite est rectangulaire. Le choix du pixel à afficher dépend alors non seulement de la profondeur, mais aussi de la transparence des pixels des sprite. Le pixel d'un sprite a la priorité sur le reste s'il est opaque et que sa profondeur est la plus faible comparé aux autres. Cette gestion basique de la transparence ne permet pas de gérer des effets trop complexe, où on mélange la couleur du sprite avec celle de l'arrière-plan.

Cette technique a autrefois été utilisée sur les anciennes bornes d'arcade, ainsi que sur certaines consoles de jeu bon assez anciennes. Mais de nos jours, elle est aussi présente dans les cartes graphiques actuelles dans un cadre particulièrement spécialisé : la prise en charge du curseur de la souris, ou le rendu de certaines polices d'écritures ! Les cartes graphiques contiennent un ou plusieurs sprites, qui représentent chacun un curseur de souris, et deux registres, qui stockent les coordonnées x et y du curseur. Ainsi, pas besoin de redessiner l'image à envoyer à l'écran à chaque fois que l'on bouge la souris : il suffit de modifier le contenu des deux registres, et la carte graphique place le curseur sur l'écran automatiquement. Pour en avoir la preuve, testez une nouvelle machine sur laquelle les drivers ne sont pas installés, et bougez le curseur : lag garantit !

Le stockage des sprites et de l’arrière-plan : les tiles modifier

Les sprites et l'arrière-plan sont donc stockés chacun dans une mémoire dédiée. En théorie, les sprites et l'arrière plan sont des images tout ce qu'il y a de plus normales, et elles devraient être codées telles quelles. Mais cela demanderait d'utiliser des mémoires assez grosses. Notamment, l'arrière-plan a la même taille que l'écran : un écran d'une résolution de 640 pixels par 480 demandera d'utiliser un arrière-plan de 640 pixels par 480. La mémoire pour stocker l'arrière-plan devrait donc être assez grosse pour stocker toute l'image affichée sur l'écran et pourrait servir de framebuffer. Le problème est moins critique pour les sprite, mais il se pose pour les plus gros sprite, qui demandent des mémoires assez importantes pour être stockés.

Pour résoudre ces problèmes, diverses techniques sont utilisées pour réduire la taille des sprites et de l'arrière-plan.

  • Premièrement, les cartes 2D utilisent la technique de la palette indicée, afin de réduire le nombre de bits nécessaires pour coder un pixel.
  • Deuxièmement, on force une certaine redondance à l'intérieur de l'arrière-plan et des sprites.
 
Tile set de Ultima VI.

La seconde idée est que les sprites et l'arrière-plan sont fabriqués à partir de tiles, des carrés de 8, 16, 32 pixels de côtés, qui sont assemblés pour fabriquer un sprite. L'ensemble des tiles est mémorisée dans un fichier unique, appelé le tile set, aussi appelé le tilemap. Ci-contre, vous voyez le tile set du jeu Ultima VI. Le tile set est généralement placé dans la mémoire ROM de la cartouche de jeu. Les tiles sont numérotées et ce numéro indique sa place dans la mémoire de la cartouche. L'idée est l'image ne mémorise pas des pixels, mais des numéros de tiles. Le gain est assez appréciable : pour une tile de 8 pixels de côté, au lieu de stocker X * Y pixels, on stocke X/8 * Y/8 tiles. Ajoutons à cela que les numéros de tiles prennent moins de place que la couleur d'un pixel, et les gains sont encore meilleurs. La consommation mémoire est déportée de l'image vers la mémoire qui stocke les tiles, une mémoire ROM intégrée dans la cartouche de jeu.

Un autre avantage est que les tiles peuvent être utilisés en plusieurs endroits, ce qui garantit une certaine redondance. Par exemple, un sprite ou l'arrière-plan peuvent utiliser une même tile à plusieurs endroits, ce qui impose une certaine redondance. C'était très utile pour l'arrière-plan, qui est généralement l'image la plus redondante. On y trouve beaucoup de tiles bleues identiques pour le ciel, par exemple. Pareil si une tile est utilisée dans plusieurs sprites, elle n'est stockée qu'une seule fois. Une autre possibilité était de lire certaines tiles dans les deux sens à l'horizontale, de faire une sorte d'opération miroir. Ce faisant, on pouvait créer un objet symétrique en mémorisant seulement la moitié gauche de celui-ci, la moitié droite étant générée en faisant une opération miroir sur la partie gauche. Mais cette optimisation était assez rare, car elle demandait d'ajouter des circuits dans un environnement où le moindre transistor était cher. De plus, les objets symétriques sont généralement assez rares.

Le matériel des anciennes cartes 2D était optimisé pour gérer les tiles. Son rôle était de reconstituer l'image finale en plusieurs étapes. Le tile set était généralement mémorisé dans une mémoire à part, il était parfois dans la mémoire ROM de la cartouche, mais il était parfois recopié dans une mémoire RAM intégrée dans la carte graphique pour plus de rapidité. Les registres pour les sprites et la RAM de l'arrière-plan étaient remplis de numéros de tiles et non directement de pixels. Le matériel rendait les sprites en lisant la tile adéquate, automatiquement. D'abord les circuits d'adressage lisaient les mémoires pour l'arrière-plan et les sprites, et récupéraient un numéro de tile. La tile correspondant était lue depuis la tilemap, dans une autre mémoire. Enfin, divers circuits choisissaient le pixel adéquat dans la tile à chaque cycle d'horloge.

 
Carte 2D avec un rendu en tile

Si je devais faire une comparaison, les cartes 2D avec un rendu en tile sont aux cartes 2D ce que les cartes en mode texte sont au cartes d'affichage. Les caractères des cartes d'affichage en mode texte sont l'équivalent des tiles des cartes 2D en tiles. Là encore, le but de ces architectures est d'économiser de la mémoire, très limitée et très chère pour l'époque. Pour les consoles de 2ème, 3ème et 4ème génération, l'usage de tiles était obligatoire et tous les jeux vidéo utilisaient cette technique. Les cartes graphiques des consoles de jeux de cette époque étaient conçues pour gérer les tiles.


Les cartes accélératrices 3D

Le premier jeu à utiliser de la "vraie" 3D fût le jeu Quake, premier du nom. Et depuis sa sortie, la grande majorité des jeux vidéo utilisent de la 3D, même s'il existe encore quelques jeux en 2D. Face à la prolifération des jeux vidéo en 3D, les fabricants de cartes graphiques ont inventé les cartes accélératrices 3D, des cartes vidéo capables d'accélérer le rendu en 3D. Dans ce chapitre, nous allons voir comment elles fonctionnent et comment elles ont évolué dans le temps. Pour comprendre comment celles-ci fonctionnent, il faut faire quelques rapides rappels sur les bases du rendu 3D.

Les bases du rendu 3D modifier

Une scène 3D est composée d'un espace en trois dimensions, dans laquelle le moteur d’un jeu vidéo place des objets et les fait bouger. Cette scène est, en première approche, un simple parallélogramme. Un des coins de ce parallélogramme sert d’origine à un système de coordonnées : il est à la position (0, 0, 0), et les axes partent de ce point en suivant les arêtes. Les objets seront placés à des coordonnées bien précises dans ce parallélogramme.

Les objets 3D et leur géométrie modifier

Les objets placés dans la scène 3D sont composés de formes de base, combinées les unes aux autres pour former des objets complexes. En théorie, les formes géométriques en question peuvent être n'importe quoi : des triangles, des carrés, des courbes de Béziers, etc. En général, on utilise des polygones par simplicité, les autres solutions étant peu pratiques et plus complexes. Dans la quasi-totalité des jeux vidéo actuels, les objets sont modélisés par un assemblage de triangles collés les uns aux autres, ce qui porte le nom de maillage, (mesh en anglais). Il a été tenté dans le passé d'utiliser des carrés/rectangles (rendu dit en quad) ou d'autres polygones, mais les contraintes techniques ont fait que ces solutions n'ont pas été retenues.

 
Illustration d'un dauphin, représenté avec des triangles.

Les modèles 3D sont définis par leurs sommets, aussi appelés vertices dans le domaine du rendu 3D. Chaque sommet possède trois coordonnées, qui indiquent sa position dans la scène 3D : abscisse, ordonnée, profondeur. Un triangle est donc composé de 9 coordonnées, 3 par sommet. Ensuite, les sommets sont reliés entre eux. Un segment qui connecte une paire de sommets s'appelle une arête, comme en géométrie élémentaire. Si plusieurs arêtes délimitent une surface fermée, celle-ci est appelée une face, ou encore une primitive. Je le répète, mais les seules faces actuellement utilisées en rendu 3D sont les triangles. Quand plusieurs faces sont sur un même plan, elles forment un "polygone", bien que le terme ne soit utilisé comme en mathématique. Un assemblage de plusieurs "polygones" donne une surface. Dans la suite du cours, nous parlerons surtout des sommets et des primitives.

 
Surface représentée par ses sommets, arêtes, triangles et polygones.

La représentation exacte d'un objet est donc une liste plus ou moins structurée de sommets, pouvant contenir une certaine redondance ou des informations en plus. La liste doit préciser les coordonnées de chaque sommet, ainsi que comment les relier pour former des triangles. Pour cela, l'objet est représenté par une structure qui contient la liste des sommets, mais aussi de quoi savoir quels sont les sommets reliés entre eux par un segment. Nous en dirons plus dans le chapitre sur le rendu de la géométrie.

Les textures modifier

Tout objet à rendre en 3D est donc composé d'un assemblage de triangles, et ceux-ci sont éclairés et coloriés par divers algorithmes. Pour rajouter de la couleur, les objets sont recouverts par des textures, des images qui servent de papier peint à un objet. Un objet géométrique est donc recouvert par une ou plusieurs textures qui permettent de le colorier ou de lui appliquer du relief.

 
Texture Mapping

Notons que les textures sont des images comme les autres, codées pixel par pixel. Pour faire la différence entre les pixels de l'écran et les pixels d'une texture, on appelle ces derniers des texels. Ce terme est assez important, aussi profitez-en pour le mémoriser, nous le réutiliserons dans quelques chapitres.

Un autre point lié au fait que les textures sont des images est leur compression, leur format. N'allez pas croire que les textures sont stockées dans un fichier .jpg, .png ou tout autre format de ce genre. Les textures utilisent des formats spécialisés, comme le DXTC1, le S3TC ou d'autres, plus adaptés à leur rôle de texture. Mais qu'il s'agisse d'images normales (.jpg, .png ou autres) ou de textures, toutes sont compressées. Les textures sont compressées pour prendre moins de mémoire. Songez que la compression de texture est terriblement efficace, souvent capable de diviser par 6 la mémoire occupée par une texture. S'en est au point où les textures restent compressées sur le disque dur, mais aussi dans la mémoire vidéo ! Nous en reparlerons dans le chapitre sur la mémoire d'une carte graphique.

Le placage de textures modifier

Plaquer une texture sur un objet peut se faire de deux manières. La première part d'une texture et associe un sommet à chaque texel. Mais ce n'est pas la technique utilisée actuellement. De nos jours, on fait l'inverse : on attribue un texel à chaque sommet. Le nom donné à cette technique de description des coordonnées de texture s'appelle l'UV Mapping, ou encore de placage de texture inverse.

Avec l'UV Mapping, chaque sommet est associé à des coordonnées de texture, qui précisent quelle texture appliquer, mais aussi où se situe le texel à appliquer dans la texture. Par exemple, la coordonnée de texture peut dire : je veux le pixel qui est à ligne 5, colonne 27 dans cette texture. La correspondance entre texture et géométrie est réalisée lorsque les créateurs de jeu vidéo conçoivent le modèle de l'objet.

Dans les faits, on n'utilise pas de coordonnées entières de ce type, mais deux nombres flottants compris entre 0 et 1. La coordonnée 0,0 correspond au texel en bas à gauche, celui de coordonnée 1,1 est tout en haut à droite. L'avantage est que ces coordonnées sont indépendantes de la résolution de la texture, ce qui aura des avantages pour certaines techniques de rendu, comme le mip-mapping. Les deux coordonnées de texture sont notées u,v avec DirectX, ou encore s,t dans le cas général : u est la coordonnée horizontale, v la verticale.

 
UV Mapping

Le placage de texture inverse est opposé à une technique qui part d'une texture et associe un sommet à chaque texel. Elle s'appelle le forward texture mapping, ce qui peut se traduire par placage de texture direct. Nous n'en parlerons pas beaucoup, car la majorité des cartes graphiques utilise le placage de texture inverse, mais quelques rares cartes graphiques ont utilisé du placage de texture direct. C'était le cas sur la console 3DO, la Sega Saturn, maiss aussi sur la toute première carte graphique de NVIDIA (la NV1).

La caméra : le point de vue depuis l'écran modifier

Outre les objets proprement dit, on trouve une caméra, qui représente les yeux du joueur. Cette caméra est définie par :

  • une position ;
  • par la direction du regard (un vecteur) ;
  • le champ de vision (un angle) ;
  • un plan qui représente l'écran du joueur ;
  • et un plan limite au-delà duquel on ne voit plus les objets.
 
Caméra.
 
Volume délimité par la caméra (view frustum).

Ce qui est potentiellement visible du point de vue de la caméra est localisé dans un volume, situé entre le plan de l'écran et le plan limite, appelé le view frustum. Suivant la perspective utilisée, ce volume n'a pas la même forme. Avec la perspective usuelle, le view frustum ressemble à un trapèze en trois dimensions, délimité par plusieurs faces attachées au bords de l'écran et au plan limite. Le tout est parfois appelée, bien que par abus de langage, la pyramide de vision. Avec d'autres perspectives moins utilisées, le view frustum est un pavé, mais nous n'en parlerons pas plus dans le cadre de ce cours car elles ne sont presque pas utilisés dans les jeux vidéos actuels.

Le culling et le clipping modifier

Un point important du rendu 3D est que ce qui n'est pas visible depuis la caméra ne doit idéalement pas être calculé. A quoi bon calculer des choses qui ne seront pas affichées, après tout ? Ce serait gâcher de la puissance de calcul. Et pour cela, les cartes graphiques et les moteurs graphiques incorporent de nombreuses optimisations pour éliminer les calculs inutiles. Diverses techniques de clipping ou de culling existent pour cela. La différence entre culling et clipping n'est pas fixée et la terminologie n'est pas claire. Dans ce qui va suivre, nous n'utiliserons que le terme culling.

 
View frustum culling : les parties potentiellement visibles sont en vert, celles invisibles en rouge et celles partiellement visibles en bleu.

La première forme de culling est le view frustum culling, dont le nom indique qu'il s'agit de l'élimination de tout ce qui est situé en-dehors du view frustum. Elle fait que ce qui est en-dehors du champ de vision de la caméra n'est pas affiché à l'écran n'est pas calculé ou rendu, dans une certaine mesure. Les autres formes de culling visent à éliminer ce qui est dans le view frustum, mais qui n'est pas visible depuis la caméra. Par exemple, il se peut que certains objets situés dans le view frustum ne soient pas visibles ou alors seulement partiellement. Pensez à des objets cachés par un autre objet plus proche, par exemple. Ou encore, pensez aux faces à l'arrière d'un objet opaque qui sont cachées par l'avant. ces deux cas correspondent à deux types de culling. L'élimination des objets masqués par d'autres est appelé l'occlusion culling. L'élimination des parties arrières d'un objet est appelé le back-face culling. Enfin, certains objets qui sont trop loin ne sont tout simplement pas calculés et remplacé par du brouillard ou un autre artefact graphique, voire pas remplacé du tout.

 
Occlusion culling : les objets en bleu sont visibles, ceux en rouge sont masqués par les objets en bleu.

Pour résumer, les portions de la scène 3D qui ne sont pas visibles à l'écran ne doivent pas être calculées. Les cartes graphiques embarquent divers méthodes de culling pour abandonner les calculs quand elles s’aperçoivent que ceux-ci portent sur une partie non-affichée de l'image. Cela fait des économies de puissance de calcul assez appréciables et un gain en performance assez important. Précisons que le culling peut être plus ou moins précoce suivant le type de rendu 3D utilisé, mais nous verrons cela dans la suite du chapitre.

Les effets de brouillard modifier

Les effets de brouillard sont des effets graphiques assez intéressants. Ils sont nécessaires dans certains jeux vidéo pour l'ambiance (pensez à des jeux d'horreur comme Siletn Hill), mais ils ont surtout été utilisés pour économiser des calculs. Sur les vieux jeux vidéo, notamment sur d'anciennes consoles de jeux, les effets de brouillards étaient utilisés pour ne pas calculer les graphismes au-delà d'une certaine distance.

L'idée est de réduire la taille du view frustum, en faisant en sorte que le plan limite au-delà duquel on ne voit pas les objets soit assez proche de la caméra. Mais si le plan limite est trop proche, cela donnera une cassure dans le rendu qui se verra et sera non seulement inesthétique, mais pourrait aussi passer pour un bug. Pour masquer cette cassure, les programmeurs ajoutaient un effet de brouillard. Les objets au-delà du plan limite étaient totalement dans le brouillard, puis ce brouillard se réduisait progressivement en se rapprochant de la caméra, avant de s'annuler à partir d'une certaine distance.

La rastérisation et le pipeline graphique modifier

Les techniques de rendu 3D sont nombreuses, mais on peut les classer en deux grands types : le lancer de rayons et la rasterization. Sans décrire les deux techniques, sachez cependant que le lancer de rayon n'est pas beaucoup utilisé pour les jeux vidéo. Il est surtout utilisé dans la production de films d'animation, d'effets spéciaux, ou d'autres rendu spéciaux. Dans les jeux vidéos, il est surtout utilisé pour quelques effets graphiques, la rasterization restant le mode de rendu principal.

La raison est que le lancer de rayons demande beaucoup de puissance de calcul, sans compter que créer des cartes accélératrices pour le lancer de rayons n'est pas simple. Il a existé des cartes accélératrices permettant d'accélérer le rendu en lancer de rayons, mais elles sont restées confidentielles. Les cartes graphiques modernes incorporent quelques circuits pour accélérer le lancer de rayons, mais ils restent marginaux des compléments au rendu par rastérization. Tout cela explique que les jeux vidéo utilisent la rasterization.

La rasterization calcule une scène 3D intégralement, avant de faire des transformations pour n'afficher que ce qu'il faut à l'écran. Le calcul d'une image rendue en 3D passe par une suite d'étapes consécutives qui doivent s'enchainer dans un ordre bien précis. L'ensemble de ces étapes est appelé le pipeline graphique. Précisons cependant que la succession d'étape que nous allons voir n'est pas aussi stricte que vous pouvez le penser. En fait, il n'existe pas un pipeline graphique unique : chaque API 3D fait à sa sauce, le matériel ne respecte pas forcément cette succession d'étapes, et j'en passe. Cependant, toutes les API 3D modernes sont organisées plus ou moins de la même manière, ce qui fait que le pipeline que nous allons décrire colle assez bien avec les logiciels 3D anciens et modernes, ainsi qu'avec l'organisation des cartes graphiques (anciennes ou modernes).

Le cas le plus simple ne demande que trois étapes :

  • une étape purement logicielle, effectuée par le processeur, où le moteur physique calcule la géométrie de la scène 3D ;
  • une étape de traitement de la géométrie, qui gère tout ce qui a trait aux sommets et triangles ;
  • une étape de rastérisation qui effectue beaucoup de traitements différents, mais dont le principal est d'attribuer chaque triangle à un pixel donné de l'écran.
 
Pipeline graphique basique.

Voyons cela plus en détail. Précisons avant toute chose que nous allons omettre certaines étapes facultatives, afin de simplifier l’exposé. Par exemple, nous n'allons pas parler de l'étape de tesselation, qui permet de rajouter de la géométrie, ce qui sert à déformer les objets ou à augmenter leur réalisme.

Le traitement de la géométrie modifier

Le traitement de la géométrie se fait en plusieurs étapes.

La première étape est appelée étape de transformation effectue plusieurs changements de coordonnées pour chaque sommet.

  • Premièrement, elle place les objets au bon endroit dans la scène 3D, ce qui demande de mettre à jour les coordonnées de chaque sommet de chaque modèle. C'est la première étape de calcul : l'étape de transformation des modèles 3D.
  • Ensuite, elle effectue un changement de coordonnées pour centrer l'univers sur la caméra, dans la direction du regard. C'est l'étape de transformation de la caméra.
  • Ensuite, une correction de la perspective est effectuée, qui corrige la forme du view frustum : c'est l'étape de projection.

Lors de la seconde étape de traitement de la géométrie, les sommets sont éclairés dans une phase d'éclairage. Lors de cette phase, tout sommet se voit attribuer une couleur, qui définit son niveau de luminosité. Celui-ci indique si le sommet est éclairé ou est dans l'ombre.

Éventuellement, diverses opérations de culling sont ensuite effectuées à la fin de cette étape, notamment le clipping et le view frustum-culling.

 
Geometry pipeline.

Après toutes ces étapes, une fois que les sommets ont tous étés traités, ils sont assemblés en primitives, c'est à dire en triangles ou en carrés/rectangles. L'assemblage est absolument nécessaire pour la prochaine étape du pipeline graphique, qui manipule des primitives. Il s'agit de l'étape d'assemblage de primitives (primitive assembly).

L'étape de rastérisation modifier

 
Illustration du principe de la rasterization. La surface correspondant à l'écran est subdivisée en pixels carrés, de coordonnées x et y. La caméra est placée au point e. Pour chaque pixel, on trace une droite qui part de la caméra et qui passe par le pixel considéré. L'intersection entre une surface et cette droite se fait en un point, appartenant à un triangle.

L'étape de rastérisation effectue beaucoup de choses différentes, même pour faire un rendu basique. Elle fait le lien entre triangle et pixels, diverses opérations d'interpolation, plaque les textures, effectues divers tests de visibilité, mélange les couleurs, écrit dans le framebuffer.

Mais l'idée structurante est que rastérisation et placage de textures sont deux opérations très liées entre elles, et nous devons les voir en même temps. Nous avons dit plus haut qu'il existait deux manières de lier les textures à la géométrie : la méthode directe et la méthode inverse (UV Mapping). Et les deux font que la rastérisation se fait de manière très différente.

L'étape de rastérisation avec placage de texture inverse modifier

Avec le placage de texture inverse, la rastérisation se fait grosso-modo en trois étapes : la rastérisation proprement dite, le placage de textures, et les opérations finales. Au niveau du matériel, ainsi que dans la plupart des API 3D, les trois étapes sont réalisées par des circuits séparés. Cette séparation en trois étapes est vraiment importante. Elle est commune à tous les API 3D modernes, en plus d'avoir une certaine logique. Après, les trois étapes en question sont elles-mêmes subdivisées en plusieurs étapes distinctes, qui s'enchainent dans un ordre bien précis.

Pour simplifier, la première étape s'occupe la traduction des formes (triangles) rendues dans une scène 3D en un affichage pixelisé à l'écran. Elle détermine à quoi ressemble la scène visible sur l'écran. C'est aussi lors de cette étape que sont appliquées certaines techniques de culling, qui éliminent les portions non-visibles de l'image, ainsi qu'une correction de la perspective et diverses opérations d'interpolation dont nous parlerons dans plusieurs chapitres. Mais le principal n'est pas là.

Lors de cette étape, chaque pixel de l'écran se voit attribuer un ou plusieurs triangle(s). Pour mieux comprendre quels triangles sont associés à tel pixel, imaginez une ligne droite qui part de caméra et qui passe par un pixel sur le plan de l'écran. Cette ligne intersecte 0, 1 ou plusieurs objets dans la scène 3D. Les triangles situés ces intersections entre cette ligne et les objets rencontrés seront associés au pixel correspondant.

La rastérisation attribue un sommet à un sommet, on peut en déduire directement quelle texture appliquer sur ce pixel, quel texel. La rastérisation associe un triangle à chaque pixel, les coordonnées de texture des sommets de ce triangle sont aussi transférées au pixel. Une fois que la carte graphique sait quelles sont les coordonnées de texture associée au pixel, elle lit la texture avec ces coordonnées pour colorier le pixel. On associe donc un texel à chaque pixel, on travaille pixel par pixel.

Après l'étape de placage de textures, la carte graphique enregistre le résultat en mémoire. Lors de cette étape, divers traitements de post-processing sont effectués et divers effets peuvent être ajoutés à l'image. Uun effet de brouillard peut être ajouté, des tests de profondeur sont effectués pour éliminer certains pixels cachés, l'antialiasing est ajouté, on gère les effets de transparence, etc. Un chapitre entier sera dédié à ces opérations.

La rastérisation avec placage de texture direct modifier

La rastérisation avec placage de texture direct se fait d'une manière totalement différente. Avec elle, on n'associe pas une texture à un pixel, mais on fait l'inverse. Pour cela, la carte graphique parcourt les textures texel par texel. Pour chaque texel, elle récupère les coordonnées du sommet associé, puis détermine quel pixel est associé. Le placage de texture se fait donc en même temps que la rastérisation, ou avant, suivant comme on l'interprète.

L'avantage de cette technique est qu'on parcourt les textures dans un ordre bien précis. Par exemple, on peut parcourir la texture ligne par ligne, l'exploiter par blocs de 4*4 pixels, etc. Et accéder à une texture de manière prédictible se marie bien avec l'usage de mémoires caches, ce qui est un avantage en matière de performances. De plus, on est certain que chaque texture est accédée une seule fois lors du rendu, alors que la rastérisation accède à une même texture plusieurs fois.

Mais le désavantage est que certains texels qui n'auraient pas été rendus avec la rastérisation sont parcours et testés. De plus, déterminer quel pixel est associé à quel sommet est beaucoup plus complexe. Le clipping et le culling sont aussi plus complexes à implémenter.

Les API 3D modifier

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 du pipeline graphique modifier

L'API ne fait pas que fournir des morceaux de code que les programmeurs peuvent utiliser. Elles fournissent des contraintes et des règles de programmation assez importantes. Notamment, elles décrivent le pipeline graphique à utiliser. Pour résumer, 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. Cela signifie que le pipeline graphique varie suivant l'API. Par exemple les API récentes supportent certaines étapes 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écrit par penGL 1.0 est illustré ci-dessous. On voit qu'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 opertaions). 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 commande de rendu 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 un schéma qui décrit le pipeline de DirextX 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.

 
Pipeline de D3D 11

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

La mise en œuvre d'une API 3D peut se faire en logiciel, en matériel ou les deux. En théorie, elles peuvent être implémentées en logiciel, le processeur peut faire tout le travail et envoyer le résultat à une carte d'affichage. Et c'était le cas 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. Par exemple, les 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.

Un tournant dans l'évolution d'OpenGL fut la sortie du jeu Quake, d'IdSoftware. Celui-ci pouvait fonctionner en rendu logiciel, mais le programmeur responsable du moteur 3D (le fameux John Carmack) ajouta une version OpenGL du jeu. Le fait que le jeu était programmé sur une station de travail compatible avec OpenGL faisait que ce choix n'était si stupide, même si aucune carte accélératrice de l'époque ne supportait OpenGL. C'était là un choix qui se révéla visionnaire. En théorie, le rendu par OpenGL aurait dû se faire intégralement en logiciel, sauf sur quelques rares stations de travail adaptées. Mais 3dfx ajouta un support minimal d'OpenGL, contenant uniquement les fonctionnalités nécessaires pour Quake. John Carmack le dit lui-même, dans le readme.txt de GLQuake :

«
Theoretically, glquake will run on any compliant OpenGL that supports the texture objects extensions, but unless it is very powerfull hardware that accelerates everything needed, the game play will not be acceptable. If it has to go through any software emulation paths, the performance will likely by well under one frame per second.

At this time (march ’97), the only standard opengl hardware that can play glquake reasonably is an intergraph realizm, which is a VERY expensive card. 3dlabs has been improving their performance significantly, but with the available drivers it still isn’t good enough to play. Some of the current 3dlabs drivers for glint and permedia boards can also crash NT when exiting from a full screen run, so I don’t recommend running glquake on 3dlabs hardware.

3dfx has provided an opengl32.dll that implements everything glquake needs, but it is not a full opengl implementation. Other opengl applications are very unlikely to work with it, so consider it basically a “glquake driver”.

»


Par la suite, de nombreux autres fabricants de cartes 3D firent la même chose que 3dfx. OpenGL et Direct 3D étaient partiellement implémentées en logiciel, car les cartes graphiques ne supportaient pas toutes les fonctionnalités de l'API. C'était l'époque du miniGL, des implémentations partielles d'OpenGL, fournies par les fabricants de cartes 3D, implémentées dans les pilotes de périphériques de ces dernières. Les fonctionnalités d'OpenGL implémentées dans ces pilotes étaient presque toutes exécutées en matériel, par la carte graphique. Un cap fût franchi avec la sortie de la fameuse Geforce 256, qui incorpora la phase de T&L (Transform & Lighting) d'OpenGL directement en hardware. La phase de traitement géométrique d'OpenGl était réalisée directement par la carte graphique, au lieu de l'être en logiciel.

Avec l'évolution du matériel, les pilotes de périphériques devinrent de plus en plus complets, au point de devenir des implémentations totales d'OpenGL. De nos jours, les API sont surtout implémentées en matériel, la quasi-totalité des fonctionnalités des API étant implémentées soit avec des shaders, soit avec des circuits non-programmables.

Mais au-delà d'OpenGL, chaque fabricant de carte graphique avait sa propre API propriétaire, qui était gérée par leurs pilotes de périphériques (drivers). Par exemple, les premières cartes graphiques de 3dfx interactive, les fameuses voodoo, disposaient de leur propre API graphique, l'API Glide. Elle facilitait la gestion de la géométrie et des textures, ce qui collait bien avec l'architecture de ces cartes 3D. Mais ces API propriétaires tombèrent rapidement en désuétude avec l'évolution de DirectX et d'OpenGL.

L'API impose des contraintes sur le matériel modifier

Plus haut, j'ai dit que les API imposent un ordonnancement précis, décrit par les étapes du pipeline. Le matériel doit respecter cet ordonnancement et faire comme si tout le pipeline graphique décrit par l'API était respecté. Je dis faire comme si, car il se peut que le matériel ne respecte pas cet ordre à la lettre. Il le fera dans les grandes lignes, mais il arrive que la carte graphique fasse certaines opérations en avance, comparé à l'ordre imposé par l'API, pour des raisons de performance. Typiquement, effectuer du culling ou les tests de profondeur plus tôt permet d'éviter de rejeter 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. Les écarts à l'ordonnancement de l'API sont corrigés d'une manière ou une autre par le hardware, par des circuits spécialisés de remise en ordre.

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.

L'architecture d'une carte 3D modifier

Toute carte graphique contient obligatoirement des circuits essentiels qui ne sont pas liés au rendu 3D proprement dit, mais sont nécessaires pour qu'elle fonctionne : de la mémoire vidéo, des circuits de communication avec le bus, des circuits d’interfaçage avec l'écran, et d'autres circuits. À cela, il faut ajouter les circuits pour le rendu 3D proprement dit, qui comprennent un ensemble hétérogène de circuits aux fonctions fort différentes. Les circuits non-liés au rendu 3D sont globalement les mêmes que pour une carte d'affichage 2D, aussi nous n'en reparlerons pas ici et nous allons nous concentrer sur les circuits du pipeline graphique.

Les architectures en mode immédiat et en tiles modifier

Il existe deux types de cartes graphiques : celles en mode immédiat, et celles avec un rendu en tiles. Les deux ont sensiblement les mêmes circuits, à quelques différences près, mais elles les utilisent d'une manière fort différente. Dans la suite de ce cours, nous parlerons surtout des cartes graphiques en mode immédiat, les architectures en tile étant reléguées à la fin du cours. Nous faisons ainsi car les cartes graphiques des ordinateurs de bureau ou portables sont toutes en mode immédiat, alors que les cartes graphiques des appareils mobiles, smartphones et autres équipements embarqués ont un rendu en tiles. Les raisons à cela sont multiples. La première est que les architectures en tiles sont considérées comme moins performantes que celles en mode immédiat, et elles sont d'autant moins performantes que la géométrie de la scène 3D est complexe. Les architectures en tiles sont donc utilisées pour les équipements où la performance n'est pas une priorité, comme les appareils mobiles, alors que le rendu en mode immédiat est utilisé pour les ordinateurs performants (de bureau ou portable). Par contre, le rendu en tiles est plus simple, plus facile à implémenter en matériel. Une autre caractéristique est un avantage du rendu en tile pour le rendu en 2D, comparé aux architectures en mode immédiat, qui se marie bien aux besoins des smartphones et autres objets connectés.

Mais expliquons donc quelle est la différence entre ces deux types de cartes graphiques. Avec le rendu en tiles, la géométrie est intégralement rendue avant de faire la rastérisation. De plus, la rastérisation découpe l'écran en l'écran en carrés/rectangles, appelés des tiles, qui sont rendus séparément, les unes après les autres. Une carte graphique en tile calcule toute la géométrie, mémorise le résultat du rendu géométrique en mémoire vidéo, découpe l'écran en tiles qui sont rastérisées et texturées une par une. Cette forme de rendu est compatible avec la séparation du pipeline en deux étapes : une étape de traitement géométrique, et une étape de rastérisation proprement dite. Mais les cartes graphiques en mode immédiat ne font pas cela : la rastérisation se fait pixel par pixel, pas par tiles.

Pour comprendre quelles sont les implications en termes de performance, nous allons séparer la carte graphique en deux circuits : un qui gère la géométrie, et un qui gère la rastérisation au sens large. Nous allons les appeler l'unité géométrique et le rastériseur, respectivement. Les deux lisent ou écrivent des données dans la mémoire vidéo. L'unité géométrique lit en mémoire vidéo la scène 3D, qui est mémorisée dans un tampon de sommets. De même, les textures sont lues dans la mémoire vidéo. Enfin, une fois qu'on a finit de calculer un pixel, il doit être enregistré dans le framebuffer. Cela est vrai pour les cartes graphiques en rendu immédiat et en rendu à tiles. Les cartes graphiques ajoutent souvent des mémoires caches pour la géométrie et les textures, afin de rendre leur accès plus rapide. Le tout est illustré ci-dessous. Nous n'avons pas parlé de la manière dont communiquent le rastériseur et l'unité géométrique, volontairement, car c'est là que se situe la différence entre les deux types de cartes 3D.

 
Carte graphique, généralités

Avec les cartes graphiques en mode immédiat, l'unité géométrique envoie ses résultats à l'unité de rastérisation, sans passer par la mémoire. Et là, un premier problème survient. Un triangle dans une scène 3D correspond généralement à plusieurs pixels. Ce qui fait qu'il y a un déséquilibre entre géométrie et rastérisation : la rastérisation prend plus de temps de calcul que la géométrie. Une conséquence est qu'il arrive fréquemment que le circuit de rastérisation soit occupé, alors que l'unité de géométrie veut lui envoyer des données. Dans ce cas, on doit mettre en attente les résultats géométriques dans une mémoire tampon, appelé le tampon de primitives. De plus, la gestion de la rastérisation demande d'utiliser des informations assez lourdes, qui sont mémorisées en mémoire vidéo, comme le z-buffer dont nous parlerons à la fin du cours.

 
Carte graphique en rendu immédiat

Avec les cartes graphiques en rendu à tiles, les choses sont totalement différentes. Premièrement, l'unité géométrique n'envoie pas ses résultats au rastériseur, mais mémorise le tout en mémoire vidéo. Plus précisément, les résultats sont enregistrés tile par tile. Le rastériseur lit alors la géométrie d'une tile depuis la mémoire vidéo, et la rastérise. L'avantage est que la tile est tellement petite qu'elle est intégralement rastérisée sans avoir à passer par la mémoire vidéo. En effet, le rastériseur incorpore une petite mémoire SRAM qui mémorise la tile et toutes les informations nécessaires pour effectuer le rendu. Pas besoin d’accéder à un gigantesque z-buffer pour toute l'image, juste besoin d'un z-buffer minuscule pour la tile en cours de traitement, qui tient totalement dans la SRAM pour la tile.

 
Carte graphique en rendu par tiles

Maintenant, faisons le bilan au niveau des accès mémoire. La performance d'une carte graphique est limitée par la quantité d'accès mémoire qu'on peut effectuer par seconde. Autant dire que les économiser est primordial. Avec une architecture en mode immédiat, le tampon de primitives évite d'avoir à passer par la mémoire vidéo. Par contre, la rastérisation utilise énormément d'accès mémoire pour rendre les pixels, notamment à cause de cette histoire de z-buffer. Peu d'accès mémoire liés à la géométrie, mais la rastérisation est gourmande en accès mémoire. Avec les architectures à tile, c'est l'inverse. Toute la géométrie est enregistré en mémoire vidéo, mais la rastérisation ne fait que très peu d'accès mémoire grâce à la SRAM pour les tiles. Au final, les deux architectures sont optimisées pour deux types de rendus différents. Les cartes à rendu en tuile brillent quand la géométrie n'est pas trop compliquée, mais que la rastérisation et les traitements des pixels sont lourds. Les cartes en mode immédiat sont elles douées pour les scènes géométriquement lourdes, mais avec peu d'accès aux pixels. Le tout est limité par divers caches qui tentent de rendre les accès mémoires moins fréquents, sur les deux types de cartes, mais sans que ce soit une solution miracle.

Un avantage des GPU en rendu à tile est que l’antialiasing est plus rapide. Pour ceux qui ne le savent pas, l'antialiasing est une technique qui améliore la qualité d’image, en simulant une résolution supérieure. Une image rendue avec antialiasing aura la même résolution que l'écran, mais n'aura pas certains artefacts liés à une résolution insuffisante. Et l'antialiasing a lieu intégralement dans les circuits situés dans l'étape de rastérisation ou après. Tout se passe donc dans le rastériseur. Les GPU en mode immédiat disposent de techniques d'optimisations pour l’antialiasing, mais il n’empêche que le GPU doit mémoriser des informations en mémoire vidéo pour faire un antialiasing correct. Alors qu'avec le rendu en tiles, tout l’antialiasing se passe dans le circuit de rastérisation, sans avoir à passer par la mémoire vidéo. La SRAM qui mémorise la tile en cours suffit pour l'antialiasing. L'antialiasing est donc plus rapide.

Les carte graphique modernes sont partiellement programmables modifier

Les cartes graphiques modernes sont capables d’exécuter des programmes informatiques, comme le ferait un processeur d'ordinateur normal. Les programmes informatiques exécutés par la carte graphique sont appelés des shaders. Les shaders sont classifiés suivant les données qu'ils manipulent. On parle de pixel shader pour ceux qui manipulent des pixels, de vertex shaders pour ceux qui manipulent des sommets, de geometry shader pour ceux qui manipulent des triangles, de hull shaders et de domain shaders pour la tesselation, de compute shader pour des opérations sans lien avec le rendu 3D, etc. Ils sont écrits dans un langage de haut-niveau, le HLSL pour les shaders Direct X et le GLSL pour les shaders Open Gl, puis sont ensuite traduit (compilés) en instructions machines compréhensibles par la carte graphique. Au début, ces langages et la carte graphique supportaient uniquement des opérations simples. Mais au fil du temps, les spécifications de ces langages sont devenues de plus en plus riches à chaque version de Direct X ou d'Open Gl, et le matériel en a fait autant. Les premiers shaders apparus étaient les vertex shaders, avec la Geforce 3 qui a rendu la gestion de la géométrie programmable. Par la suite, l'étape de traitement des pixels est elle aussi devenue programmable et les pixels shaders ont fait leur apparition. Puis d'autres formes de shaders sont apparues, pour effectuer des calculs géométriques complexes ou des calculs non-graphiques.

Les shaders sont exécutés par un ou plusieurs processeurs intégrés à la carte graphique, qui portent le nom de processeurs de shaders. Les cartes graphiques récentes contiennent un grand nombre de processeurs de shaders, plusieurs centaines. Mais une carte graphique moderne ne contient pas que des processeurs de shaders, mais aussi des circuits spécialisés dans le rendu 3D qui prennent en charge une étape du pipeline graphique, appelés des unités de traitement graphiques. On trouve ainsi une unité pour le placage de textures, une unité pour l'étape de transformation, une pour la gestion de la géométrie, une unité de rasterization, une unité d'enregistrement des pixels en mémoire appelée ROP, etc. Les unités sont divisés en deux catégories : d'un côté les circuits non-programmables (dits fixes) et de l'autre les circuits programmables (les processeurs de shaders). Globalement, la gestion de la géométrie et des pixels est programmable, mais la rasterization, le placage de texture, le culling et l'enregistrement du framebuffer ne l'est pas.

 
Pipeline 3D : ce qui est programmable et ce qui ne l'est pas dans une carte graphique moderne.

Outre les unités du paragraphe précédent, une carte graphique contient d'autres circuits qui s'occupent de la logistique, du transfert de données ou de la répartition du travail entre les unités de traitement proprement dites. Le tampon de primitive vu plus haut en est un exemple et d'autres mémoires tampon de ce type existent. L'un de ces circuits de logistique est le processeur de commandes, dont nous parlerons dans quelques chapitres. Pour simplifier, il commande les différentes étapes du pipeline graphique et s'assure que les étapes s’exécutent dans le bon ordre.

Ce qu'il faut comprendre, c'est qu'une carte graphique moderne est un mélange de circuits non-programmables et de circuits programmables. L'assemblage de circuits fixes et de processeurs de shaders peut sembler contradictoire. Pourquoi ne pas tout rendre programmable ? Ou au contraire, pourquoi ne pas utiliser seulement des circuits fixes ? Le choix entre les deux parait simple. Les circuits programmables sont légèrement plus lents, mais plus flexibles, plus simples à concevoir et facilitent la vie aux programmeurs. Par contre, les circuits fixes sont plus rapides, plus économes en énergie, utilisent moins de transistors, mais sont plus compliqués à concevoir et moins flexibles. Mélanger les deux semble étrange.

La réponse rapide est qu'il s'agit d'un compromis entre flexibilité et performance qui permet d'avoir le meilleur des deux mondes. Rendre la gestion de la géométrie ou des pixels programmable est très intéressant pour le programmeur, ce qui explique qu'on a des processeurs de shader pour les vertex shaders et pixel shaders. par contre, le programmeur ne gagne pas grand chose à avoir un rastériseur programmable, mais les performances de rastérisation sont cruciales. Aussi, ce circuits est laissé non-programmable et conçu avec un circuit fixe plus rapide. Dans la fin de ce chapitre, et dans le chapitre suivant, nous allons étudier comment cette recherche du compromis a donné naissance aux cartes graphiques modernes. Nous allons voir l'évolution dans le temps des cartes graphiques à la fin de ce chapitre. Dans le chapitre suivant, nous allons voir pourquoi les vertex shaders et les pixels shaders sont apparus, en prenant l'exemple des algorithmes d'éclairage.

Un historique simplifié du hardware des cartes graphiques modifier

Le hardware des cartes graphiques a fortement évolué dans le temps, ce qui n'est pas une surprise. Les évolutions de la technologie, avec la miniaturisation des transistors et l'augmentation de leurs performances a permit aux cartes graphiques d'incorporer de plus en plus de circuits avec les années. Avant l'invention des cartes graphiques, toutes les étapes du pipeline graphique étaient réalisées par le processeur : il calculait l'image à afficher, et l’envoyait à une carte d'affichage 2D. Au fil du temps, de nombreux circuits furent ajoutés, afin de déporter un maximum de calculs vers la carte vidéo. Les GPU actuels s'occupent de tous les traitements liés au moteur graphique.

Dès le début de la 3D, le choix entre circuits fixe et circuits programmables s'est fait ressentir. Les toutes premières cartes graphiques se classaient en deux camps : d'un côté les cartes graphiques complètement non-programmables, de l'autre des GPU totalement programmables. La toute première carte 3D était du second camp : c’était la Rendition Vérité V1000. Elle contenait un processeur MIPS cadencé à 25 MHz, 4 mébioctets de RAM, une ROM pour le BIOS, et un RAMDAC, rien de plus. C'était un vrai ordinateur complètement programmable de bout en bout. Les programmeurs ne pouvaient cependant pas utiliser cette programmabilité avec des shaders, mais elle permettait à Rnedition d'implémenter n'importe quelle API 3D, que ce soit son API propriétaire ou OpenGL, voire DirectX. Mais les performances s'en ressentaient. Réaliser la rastérisation et le placage de texture en logiciel n'est pas efficace, pareil pour les opérations de fin de pipeline comme l'antialiasing. Au final, sa programmabilité était un plus pour la compatibilité logicielle, mais qui avait un cout en termes de performances. Le manque d'unités fixes très rapides pour la rastérisation, le placage de texture ou les opérations de fin de pipeline était clairement un gros défaut.

Le second camp, celui des cartes graphiques totalement non-programmables et ne contenant que des circuits fixe, regroupe les Voodoo de 3dfx, les Riva TNT de NVIDIA, les Rage/3D d'ATI, la Virge/3D de S3, et la Matrox Mystique. Elles contenaient des circuits pour gérer les textures, quelques effets graphiques (brouillard) et l'étape d'enregistrement des pixels en mémoire. Par la suite, ces cartes s’améliorèrent en ajoutant plusieurs circuits de gestion des textures, pour colorier plusieurs pixels à la fois. Cela permettait aussi d'utiliser plusieurs textures pour colorier un seul pixel : c'est ce qu'on appelle du multitexturing. Les performances avec ces cartes étaient nettement meilleures que pour la Rendition V1000. Le fait d'avoir des circuits câblés rapides pour certaines tâches bien choisie donnait de bonnes performances, et personne n'avait la moindre utilité pour des circuits programmables à l'époque.

 
Carte 3D sans rasterization matérielle.

Les cartes suivantes ajoutèrent une gestion des étapes de rasterization directement en matériel. Les cartes ATI rage 2, les Invention de chez Rendition, et d'autres cartes graphiques supportaient ces étapes en hardware. De nos jours, ce genre d'architecture est commun chez certaines cartes graphiques intégrées dans les processeurs ou les cartes mères.

 
Carte 3D avec gestion de la géométrie.

La première carte graphique capable de gérer la géométrie fût la Geforce 256, la toute première Geforce. Son unité de gestion de la géométrie n'est autre que la bien connue T&L (Transform And Lighting). Elle implémentait des algorithmes d'éclairage de la scène 3D assez simples, comme un éclairage de Phong, qui étaient directement câblés dans ses circuits. Mais le défaut de cette approche était que ces cartes graphiques incorporaient des algorithmes d'éclairage très spécifiques, alors qu'il existe de très nombreux algorithmes d'éclairage, au point où on ne peut pas tous les mettre dans une carte graphique. Les programmeurs avaient donc le choix entre programmer les algorithmes d’éclairage qu'ils voulaient ou utiliser ceux de la carte graphique.

 
Carte 3D avec gestion de la géométrie.

À partir de la Geforce 3 de Nvidia, les unités de traitement de la géométrie sont devenues programmables. L'avantage était que les programmeurs n'étaient pas limités aux algorithmes implémentés dans la carte 3D pour obtenir de bonnes performances. Ils pouvaient programmer l'algorithme d'éclairage qu'ils voulaient et pouvaient le faire exécuter directement sur la carte graphique. Les unités de traitement de la géométrie deviennent donc des processeurs indépendants, capables d’exécuter des programmes appelés Vertex Shaders. Ces shaders sont écrits dans un langage de haut-niveau, le HLSL ou le GLSL, et sont traduits (compilés) par les pilotes de la carte graphique avant leur exécution. Au fil du temps, les spécifications de ces langages sont devenues de plus en plus riches et le matériel en a fait autant. D'autres types de shaders ont été inventés : pixels shaders pour le traitement de pixels, shaders de géométrie, shaders génériques, etc. Les premiers à faire leur apparition furent les pixel shaders, suivis par les geometry shaders et les shaders pour la tesselation.

Pour résumer, l'évolution des cartes 3D s'est faite d'une manière assez complexe. Le compromis entre circuits fixes et programmables a été difficile à trouver. Les concepteurs ont procédé par essais et erreurs, jusqu'à trouver le compromis actuel. Les premières cartes graphiques sont parties sur deux pistes totalement opposées : d'un côté une carte totalement programmable, de l'autre des cartes avec des circuits fixes non-programmables. L'échec de la Rendition Vérité 1000, totalement programmable mais avec de trop faibles performances, a orienté le marché dans la seconde direction. Les cartes 3D ont alors incorporé des circuits de rastérisation et de placage/filtrage de texture, qui sont toujours là de nos jours. Elles ont ensuite intégré des circuits de calculs géométrique, avant de revenir en arrière pour les remplacer par des circuits programmables. Leur programmabilité s'est ensuite accrue, en incorporant des processeurs capables d’exécuter n'importe quel algorithme, mais sans revenir sur les circuits fixes établis. Expliquer pourquoi demande cependant d'y passer un chapitre entier, ce qui est le sujet du chapitre suivant.


L'éclairage d'une scène 3D : shaders et T&L

Dans le chapitre précédent, nous avons vu que les cartes graphiques ont progressivement évoluées dans le temps. Elles ont d'abord intégré des circuits pour les textures et la rastérisation, puis des circuits géométriques non-programmables. Mais les circuits géométriques fixes ont ensuite été remplacés par des processeurs programmables. Les cartes graphiques se sont mis à exécuter des shaders, des programmes informatiques spécialisés dans le rendu 3D. Les shaders ont remplacé les circuits géométriques, avant de gagner en fonctionnalité et de devenir indispensable pour tout moteur 3D. Pour comprendre pourquoi ce revirement, nous devons étudier le cas particulier du rendu de l'éclairage d'une scène 3D. L'éclairage d'une scène 3D calcule les ombres, mais aussi la luminosité de chaque pixel, ainsi que bien d'autres effets graphiques.

Dans le chapitre précédent, on a vu que le pipeline graphique contient une étape d'éclairage durant la phase de traitement de la géométrie. Elle attribue une illumination/couleur à chaque triangle ou à chaque sommet de la scène 3D, le plus souvent à chaque sommet de la scène 3D. Mais l'étape d'éclairage géométrique du pipeline graphique ne suffit pas à elle seule à éclairer la scène comme voulu. En effet, il faut distinguer deux types différents d'éclairage dans les jeux vidéos : le vertex lighting, où l'éclairage est calculé pour chaque sommet/triangle d’une scène 3D, et l'éclairage par pixel (per-pixel lighting), où l'éclairage est calculé pixel par pixel. L'étape d'éclairage géométrique du pipeline graphique est suffisante pour calculer du vertex lighting, mais ne peut pas faire du per-pixel lighting à elle seule. Avec le per-pixel lighting, l’éclairage est finalisé soit dans l'étape de rastérisation, soit par des pixels shaders. Pour le dire autrement, l'éclairage ne se fait pas qu'au niveau de la géométrie, comme il a pu être dit au chapitre précédent. Même des algorithmes simples demandent une intervention des pixels shaders pour calculer l'éclairage pixel par pixel. Autant dire que cette distinction a été importante dans l'évolution du matériel graphique et l'introduction des shaders. Mais pour comprendre cela, il faut voir comment fonctionnent les algorithmes d'éclairage et voir comment le hardware peut aider à les implémenter.

Les sources de lumière et les couleurs associées modifier

L'éclairage d'une scène 3D provient de sources de lumières, comme des lampes, des torches, etc. Celles-ci sont souvent modélisées comme de simples points, qui ont une couleur bien précise (la couleur de la lumière émise) et émettent une intensité lumineuse codée par un entier. La lumière provenant de ces sources de lumière est appelée la lumière directionnelle. Mais en plus de ces sources de lumière, il faut ajouter une lumière ambiante, qui sert à simuler l’éclairage ambiant (d’où le terme lumière ambiante). Par exemple, elle correspond à la lumière du cycle jour/nuit pour les environnements extérieurs. On peut simuler un cycle jour-nuit simplement en modifiant la lumière ambiante : nulle en pleine nuit noire, élevée en plein jour. En rendu 3D, la lumière ambiante est définie comme une lumière égale en tout point de la scène 3D, et sert notamment à simuler l’éclairage ambiant (d’où le terme lumière ambiante).

 
Lumière ambiante.
 
Lumière directionnelle.

Dans ce qui suit, on part du principe que les modèles 3D ont une surface, sur laquelle on peut placer des points. Vous vous dites que ces points de surface sont tout bêtement les sommets et c'est vrai que c'est une possibilité. Mais sachez que ce n'est pas systématique. On peut aussi faire en sorte que les points de surface soient les triangles de du modèle 3D. Pour simplifier, vous pouvez considérer que le terme "point de la surface" correspond à un sommet, ce sera l'idéal et suffira largement pour les explications.

L'éclairage attribue à chaque point de la surface une illumination, à savoir sa luminosité, l'intensité de la lumière réfléchie par la surface. L'illumination d'un point de surface est définie par un niveau de gris. Plus un point de surface a une illumination importante, plus il a une couleur proche du blanc. Et inversement, plus son illumination est faible, plus il est proche du noir. L'attribution d'une illumination à chaque point de surface fait que la scène 3D est éclairée. Mais on peut aussi aller plus loin et colorier la géométrie. Pour cela, il suffit que l'éclairage ne fasse pas que rendre la scène 3D en niveaux de gris, mais que les niveaux de luminosité sont calculés indépendamment pour chaque couleur RGB.

 
Illustration de la dispersion de la lumière diffuse par une surface, suivant sa rugosité.

Le calcul exact de l'illumination de chaque point de surface demande de calculer trois illuminations indépendantes, qui ne proviennent pas des mêmes types de sources lumineuses.

  • L'illumination ambiante correspond à la lumière ambiante réfléchie par la surface.
  • Les autres couleurs proviennent de la réflexion de a lumière directionnelle. Elles doivent être calculées par la carte graphique, généralement avec des algorithmes compliqués qui demandent de faire des calculs entre vecteurs.
    • L'illumination spéculaire est la couleur de la lumière réfléchie via la réflexion de Snell-Descartes.
    • L'illumination diffuse vient du fait que la surface d'un objet diffuse une partie de la lumière qui lui arrive dessus dans toutes les directions. Cette lumière « rebondit » sur la surface de l'objet et une partie s'éparpille dans un peu toutes les directions. La couleur diffuse ne dépend pas vraiment de l'orientation de la caméra par rapport à la surface. Elle dépend uniquement de l'angle entre le rayon de lumière et la verticale de la surface (sa normale).

Elles sont additionnées ensemble pour donner l'illumination finale du point de surface. Chaque composante rouge, bleu, ou verte de la couleur est traitée indépendamment des autres, ce qui donne une scène 3D coloriée.

 
Couleurs utilisées dans l'algorithme de Phong.

Le calcul des illuminations ambiantes, spéculaire et diffuse est le fait d'algorithmes plus ou moins compliqués. Et ces algorithmes sont très nombreux, au point où on ne pourrait pas en faire la liste. Les algorithmes varient beaucoup d'un moteur de jeu à l'autre, d'une carte graphique à l'autre. Les plus simples se décrivent en quelques équations, les algorithmes les plus complexes prennent un chapitre à eux seuls dans un livre spécialisé.

Les données nécessaires pour les algorithmes d'illumination modifier

L'algorithme d’illumination a besoin de plusieurs informations, certaines étant des nombres, d'autres des vecteurs, d'autres des angles. Tous les algorithmes d'éclairage directionnel impliquent de faire des calculs trigonométriques dans l'espace, de déterminer des angles, des distances, et bien d'autres choses.

Le premier de ces paramètres est l'intensité de la source de lumière, à quel point elle émet de la lumière. Là encore, cette information est encodée par un simple nombre, un coefficient, spécifié par l'artiste lors de la création du niveau/scène 3D.

Le second est un nombre attribué à chaque point de surface : le coefficient de réflexion. Il indique si la surface réfléchit beaucoup la lumière ou pas, et dans quelles proportions. Généralement, chaque point d'une surface a plusieurs coefficients de réflexion, pour chaque couleur : un pour la couleur ambiante, un pour la couleur diffuse, et un pour la couleur spéculaire.

Les calculs de réflexion de la lumière demandent aussi de connaitre l'orientation de la surface. Pour gérer cette orientation, tout point de surface est fourni avec une information qui indique comment est orientée la surface : la normale. Cette normale est un simple vecteur, perpendiculaire à la surface de l'objet, dont l'origine est le point de surface (sommet ou triangle).

 
Normale de la surface.

Ensuite, il faut aussi préciser l'orientation de la lumière, dans quel sens est orientée. La majorité des sources de lumière émettent de la lumière dans une direction bien précise. Il existe bien quelques sources de lumière qui émettent de manière égale dans toutes les directions, mais nous passons cette situation sous silence. Les sources de lumières habituelles, comme les lampes, émettent dans une direction bien précise, appelée la direction privilégiée. La direction privilégiée est un vecteur, notée v dans le schéma du dessous.

Un autre vecteur important est celui de la ligne entre le point de surface considéré et la caméra (noté w dans le schéma ci-dessous).

Il faut aussi tenir comme du vecteur qui trace la ligne entre la source de lumière et le point de surface (noté L dans le schéma ci-dessous).

Enfin, il faut ajouter le vecteur qui correspond à la lumière réfléchie par la surface au niveau du point de surface. Ce vecteur, noté R et non-indiqué dans le schéma ci-dessous, se calcule à partir du vecteur L et de la normale.

 
Vecteurs utilisés dans le calcul de l'illumination, hors normale.

La plupart de ces informations n'a pas à être calculée. C'est le cas de la normale de la surface ou du vecteur L qui sont connus une fois l'étape de transformation réalisée. Même chose pour le coefficient de réflexion ou de l'intensité de la lumière, qui sont connus dès la création du niveau ou de la scène 3D. Par contre, le reste doit être calculé à la volée par la carte graphique à partir de calculs vectoriels.

Le calcul des couleurs par un algorithme d'illumination de Phong modifier

À partir de ces informations, divers algorithmes peuvent éclairer une scène. Dans ce qui va suivre, nous allons voir l'algorithme d'illumination de Phong, la méthode la plus utilisée dans le rendu 3D.S'il n'est peut-être pas l'algorithme le plus utilisé, vous pouvez être certain que c'est au moins une version améliorée ou un autre algorithme proche mais plus poussé qui l'est à sa place.

L'illumination ambiante d'un point de surface s'obtient simplement à partir de la lumière ambiante. On pourrait croire qu'elle est simplement égale à la lumière ambiante, mais c'est légèrement plus compliqué que ça. Deux objets de la même couleur, illuminés par la même lumière ambiante, ne donneront pas la même couleur. Un objet totalement rouge illuminé par une lumière ambiante donnera une couleur ambiante rouge, un objet vert donnera une lumière verte. Et sans même parler des couleurs, certains objets sont plus sombres à certains endroits et plus clairs à d'autres, ce qui fait que leurs différentes parties ne vont pas réagir de la même manière à la lumière ambiante. La couleur de chaque point de surface de l'objet lui-même est mémorisée dans le modèle 3D, ce qui fait que chaque point de surface se voit attribuer, en plus de ces trois coordonnées, une couleur ambiante qui indique comment celui-ci réagit à la lumière ambiante. La couleur ambiante finale d'un point de surface se calcule en multipliant la couleur ambiante de base par l'intensité de la lumière ambiante. Les deux sont des constantes pré-calculées par les concepteurs du jeu vidéo ou du rendu 3D.

  avec   la couleur ambiante du point de surface et   l'intensité de la lumière ambiante.
 
Vecteurs utilisés dans l'algorithme de Phong (et dans le calcul de l'éclairage, de manière générale).

L'illumination spéculaire et diffuse sont calculées autrement, à partir des vecteurs indiqués dans le schéma ci-contre. l'illumination diffuse est calculée à partir du coefficient de réflexion diffuse   qui indique à quel point la surface diffuse de la lumière, l'intensité de la source lumineuse, et l'angle entre N et L. L'angle entre N et L donne la manière dont la surface est penchée par rapport à la surface lumineuse : plus elle est penchée, plus la lumière est diffusée et moins l'illumination diffuse est intense. L'angle en question est noté   dans l'équation suivante :

 

Pour calculer la lumière spéculaire, il faut prendre en compte l'angle que fait la caméra et la lumière réfléchie, c'est à dire l'angle entre v et R, que nous noterons  . Là encore, on doit utiliser le coefficient de réflexion spéculaire   de la surface et l'intensité de la lumière, ce qui donne :

 

En additionnant ces trois sources d'illumination, on trouve :

 

Les algorithmes d'éclairage modifier

Maintenant que l'on sait comment est calculé l'illumination d'un point de surface, passons maintenant aux algorithmes d'éclairage proprement dit. C’est une bonne chose de savoir comment les points de surface (sommets ou triangles) sont éclairés, mais rappelons que ce sont des pixels qui s'affichent à l'écran. L'étape d'éclairage réalisée par la géométrie peut calculer la luminosité/couleur d'un sommet/triangle, mais pas celle d'un pixel. Pour cela, il faut déterminer la luminosité d'un pixel à partir de la luminosité/couleur des sommets du triangle associé. Pour cela, il existe plusieurs algorithmes qui font ce calcul. les trois plus connus sont appelés l'éclairage plat, l'éclairage de Gouraud, et l'éclairage de Phong. Notons que ce dernier n'est pas identique à l'éclairage de Phong vu précédemment.

Les trois algorithmes principaux d'éclairage modifier

 
Dans ce dessin, le triangle a un sommet de couleur bleu foncé, un autre de couleur rouge et un autre de couleur bleu clair. L’interpolation plate et de Gouraud donnent des résultats bien différents.

L'éclairage plat calcule l'éclairage triangle par triangle. Typiquement, l'algorithme d'éclairage part de la normale du triangle, puis effectue les calculs d'éclairage à partir de cette normale. La normale est fournie pour chaque triangle, directement dans le modèle 3D, de même que chaque triangle a un coefficient de réflexion ambiante/spéculaire/diffuse. L'algorithme applique ensuite l’algorithme d'illumination triangle par triangle, ce qui fait que chaque triangle se voit attribuer une couleur, puis l'unité de rastérisation applique cette couleur sur tous les pixels associés à ce triangle.

L'éclairage de Gouraud calcule l'éclairage sommet par sommet. Tous les sommets se voient attribuer une illumination, puis l'algorithme calcule la couleur de chaque pixel à partir des couleurs du sommet du triangle associé. Le calcul en question est une sorte de moyenne, où la couleur de chaque sommet est pondéré par un coefficient qui dépend de la distance avec le pixel. Plus le pixel est loin d'un sommet, plus ce coefficient est petit. Typiquement, le coefficient varie entre 0 et 1 : de 1 si le pixel est sur le sommet, à 0 si le pixel est sur un des sommets adjacents. La moyenne effectuée est généralement une interpolation bilinéaire, mais n'importe quel algorithme d'interpolation peut marcher, qu'il soit simplement linéaire, bilinéaire, cubique, hyperbolique. L'étape d'interpolation est prise en charge soit par l'étape de rastérisation, soit par les pixel shaders.

L'éclairage de Phong est différent des précédents dans le sens où il calcule l'éclairage pixel par pixel. Avec cet algorithme, la géométrie n'est pas éclairée : les couleurs des sommets ne sont pas calculées, seuls les vecteurs adéquats le sont. C'est notamment le cas de la normale, qui est calculée pour chaque sommet. La normale est alors calculée pour chaque pixel, par interpolation des normales des sommets du triangle. Puis, l'algorithme d'illumination de Phong est effectué pour chaque normale interpolée.

 
Interpolation des normales dans l'éclairage de Phong.

L'éclairage de Gouraud et l'éclairage plat sont des cas particulier de vertex lighting, où l'éclairage est calculé pour chaque sommet d’une scène 3D. De l'autre côté, l'éclairage de Phong est un cas particulier d'éclairage par pixel (per-pixel lighting), où l'éclairage est calculé pour chaque pixel à partir des informations géométriques. Il existe d'autres techniques de per-pixel lighting, mais celles-ci sont surtout des techniques logicielles, comme le rendu différé.

Les résultats, avantages et inconvénients modifier

 
D3D Shading Modes
 
Flat Gouraud Shading

Pour simplifier fortement, l'éclairage plat calcule la couleur triangle par triangle, l'éclairage de Gouraud calcule la couleur sommet par sommet, et l'éclairage de Phong calcule la couleur pixel par pixel. La différence entre les trois algorithmes se voit assez facilement. L'éclairage plat donne un éclairage assez carré, avec des frontières assez nettes. l'éclairage de Gouraud donne des ombres plus lisses, dans une certaine mesure, mais pèche à rendre correctement les reflets spéculaires. L'éclairage de Phong est de meilleure qualité, surtout pour les reflets spéculaires.

 
Flat shading
 
Gouraud Shading
 
Phing Shading

En termes de calculs à effectuer, l'algorithme le plus rapide est l'éclairage plat, suivi par l'éclairage de Gouraud, puis par l'éclairage Phong. Cela tient à la quantité de calculs d'éclairage à effectuer. L’éclairage plat calcule les vecteurs une fois par triangle, alors que l'algorithme de Gouraud fait les mêmes calculs pour chaque sommet. Pour l'algorithme de Phong, on doit faire plus de calculs que pour les deux autres. Certes, il demande de faire moins de calculs géométriques : on a juste à calculer les normales des surfaces et les positions des sommets, pas de calculer l'éclairage au niveau géométrique. Par contre, on doit faire plus de calculs au niveau des pixels : on doit interpoler les normales et calculer l'éclairage sur toutes les normales. Vu qu'il y a plus de normales au final, donc plus de points de surface, on doit faire plus de calculs d'éclairage au total. Bien sûr, tout cela, ne tient pas compte des possibilités d'accélération par la carte graphique, juste du nombre total de calculs à effectuer.

En général, le per-pixel lighting a une qualité d'éclairage supérieure aux techniques de vertex lighting, mais il est aussi plus gourmand. Le per-pixel lighting est utilisé dans presque tous les jeux vidéo depuis DOOM 3 (l'un des premiers jeux à utiliser ce genre d'éclairage), en raison de sa meilleure qualité, les ordinateurs actuels étant assez performants.

L'implémentation en hardware de ces trois algorithmes modifier

Pour ce qui est de l'implémentation en hardware, les trois algorithmes ne sont pas équivalents. Le plus simple à implémenter est l'éclairage plat, qui demande juste d'avoir de quoi effectuer l'algorithme d'illumination de Phong. Les cartes graphiques anciennes comme la Geforce 256 savaient le faire en matériel. L'éclairage de Gouraud s'implémente de la même manière, sauf que l'algorithme d'illumination de Phong doit se faire sur les triangles, ce qui ne change pas chose et est à la portée de toutes les cartes graphiques depuis la Geforce 256. Il faut juste rajouter un circuit d'interpolation, mais celui-ci fait normalement partie de l'unité de rastérisation, comme on le verra plus tard dans le chapitre sur celle-ci. Pour donner un exemple, la console de jeu Playstation 1 gérait l'éclairage plat et de Gouraud directement en matériel, dans son GPU.

Avec l’éclairage de Phong, l'éclairage se fait en deux étapes, séparées par l'étape de rastérisation. Et les unités de calculs géométriques non-programmables n'étaient pas prévues pour ce genre de calculs. Si le programmeur a besoin des normales pour chaque pixel mais que l'unité géométrique ne te les fournit pas, l'implémentation de l’algorithme de Phong ne peut se faire qu'en logiciel, (ou en trichant un peu avec le pipeline graphique, mais les performances sont alors rarement au rendez-vous). Il aurait été facile de corriger cela, mais l'unité géométrique aurait dû avoir deux modes de fonctionnements : un où elle fait du vertex lighting, et un autre où elle renvoie les normales des sommets. Mais la vraie difficulté aurait été la seconde partie de l'algorithme, après l'étape de rastérisation. La séparation de l'algorithme en deux circuits séparés par le circuit de rastérisation aurait été compliquée et c'est pour cette raison que cet algorithme n'a pas été implémenté en hardware sur les anciennes cartes graphiques. La seule manière de l'implémenter proprement est d'utiliser les shaders.

Pour résumer, les unités de calculs géométrique non-programmables des Geforce se limitaient au vertex lighting, rendu avec un algorithme d'illumination de Phong, appliqué triangle par triangle ou sommet par sommet, guère plus. Elles permettaient d'implémenter certains algorithmes d'éclairage assez facilement, mais n'étaient pas assez flexibles pour en implémenter d'autres, comme l'éclairage de Phong. Les algorithmes d'éclairages complexes demandent une coordination entre les calculs géométriques et les calculs après rastérisation, ce qui n'est possible qu'avec les vertex et pixels shaders. C'est pour cela que durant longtemps, les moteurs de jeux vidéo ont préféré utiliser des techniques comme le lightmapping au lieu des unités géométriques non-programmables. La meilleure qualité du per-pixel lighting a eu raison des unités de vertex lighting matérielle.

Conclusion et remarques quant à l'évolution des cartes graphiques modifier

Pour résumer, le manque de flexibilité des unités de T&L matérielle faisait qu'elles ne servaient pas à grand chose et ne pouvaient accélérer que des algorithmes d'éclairages géométriques pas très agréables visuellement. Implémenter un grand nombre d'algorithmes directement en hardware aurait eu un cout trop important en termes de transistors et de complexité. Cela a poussé les concepteurs de cartes graphiques à passer aux shaders pour les calculs géométriques et quelques autres opérations.

Tout cela est à l'exact opposé de ce qui s'est passé pour d'autres circuits, comme les circuits pour l'étape de transformation, de rastérisation ou de placage de texture, d'antialiasing, etc. Il n'y a pas 36 façons de rastériser une scène 3D et la flexibilité n'est pas un besoin important pour cette opération, alors que les performances sont cruciales. Même chose pour le placage/filtrage de textures, ou l'antialiasing et les autres opérations du genre. En conséquences, les unités de transformation, de rastérisation et de placage de texture sont toutes implémentées en matériel. Faire ainsi permet de gagner en performance sans que cela ait le moindre impact pour le programmeur.

Le parallèle est assez intéressant à étudier entre les unités géométriques abandonnées et le reste des circuits non-programmables d'une carte graphique. Au final, une carte graphique est un mélange de circuits programmables, et de circuits qui n'ont pas besoin de l'être mais qui doivent être ultra-optimisés pour une opération bien précise et quasi immuable. L'architecture d'une carte graphique est un compromis entre performance et flexibilité, qui est conçu pour répondre au mieux aux exigences du rendu 3D et des logiciels. Est mis en hardware ce qui a besoin d'être performant, est rendu programmable ce qui a besoin de l'être pour le programmeur.


Les processeurs de shaders

Les shaders sont des programmes informatiques exécutés par la carte graphique, et plus précisément par des processeurs de shaders. Dans ce chapitre, nous allons voir comment fonctionnent les processeurs de shaders. Nous allons voir que ceux-ci ne sont pas très différents des processeurs que l'on trouve dans les ordinateurs normaux, du moins dans les grandes lignes. Ce sont des processeurs séquentiels, qui exécutent des instructions les unes après les autres, ils ont des instructions machines, des modes d'adressage, un assembleur, des registres et tout ce qui fait qu'un processeur est un processeur. Néanmoins, il y a une différence de taille : ce sont des processeurs adaptés pour effectuer un grand nombre de calculs en parallèle.

Le jeu d'instruction des processeurs de shaders modifier

Les processeurs de shaders peuvent effectuer le même calcul sur plusieurs vertices ou plusieurs pixels à la fois. On dit que ce sont des processeurs parallèles, à savoir qu'ils peuvent faire plusieurs calculs en parallèle dans des unités de calcul séparées. Suivant la carte graphique, on peut les classer en deux types, suivant la manière dont ils exécutent des instructions en parallèle : les processeurs SIMD et les processeurs VLIW.

Avant d'expliquer à quoi correspondent ces deux termes, sachez juste que l'usage de processeurs VLIW dans les cartes graphiques est anecdotique. Il a existé des cartes graphiques AMD assez anciennes qui utilisaient des processeurs de type VLIW, mais ce n'est plus en odeur de sainteté de nos jours. Pour simplifier, cette technique permettait d’exécuter deux instructions arithmétiques en même temps, en parallèle : une qui sera appliquée aux couleurs R, G, et B, et une autre qui sera appliquée à la couleur de transparence. Si cela semble intéressant sur le papier, cela complexifie fortement le processeur de shader, ainsi que la traduction à la volée des shaders en instructions machine. Raison pour laquelle cette technique a été abandonnée. Si on omet cette exception, les processeurs de shaders sont tous des processeurs SIMD ou des dérivés (la technique dites du SIMT est une sorte de SIMD amélioré).

Les instructions SIMD modifier

Les instructions des processeurs SIMD manipulent plusieurs nombres en même temps. Elles manipulent plus précisément des vecteurs, des ensembles de plusieurs nombres entiers ou nombres flottants placés les uns à côté des autres, le tout ayant une taille fixe. Une instruction SIMD traite chaque données du vecteur indépendamment des autres. Par exemple, une instruction d'addition vectorielle va additionner ensemble les données qui sont à la même place dans deux vecteurs, et placer le résultat dans un autre vecteur, à la même place. Quand on exécute une instruction sur un vecteur, les données présentes dans ce vecteur sont traitées simultanément.

 
Instructions SIMD

Les cartes graphiques récentes peuvent effectuer des branchements, mais elles disposent aussi de techniques permettant de s'en passer facilement grâce à un registre appelé le Vector Mask Register. Celui-ci permet de stocker des informations qui permettront de sélectionner certaines données et pas d'autres pour faire notre calcul. Il est mis à jour par des instructions de comparaison. le Vector Mask Register stocke un bit pour chaque flottant présent dans le vecteur à traiter, bit qui indique s'il faut appliquer l'instruction sur ce flottant. Si ce bit est à 1, notre instruction doit s’exécuter sur la donnée associée à ce bit. Sinon, notre instruction ne doit pas la modifier. On peut ainsi traiter seulement une partie des registres stockant des vecteurs SIMD.

 
Vector mask register

Les registres des processeurs de shaders modifier

Un processeur de shaders contient beaucoup de registres, sans quoi il ne pourrait pas faire son travail efficacement. Les anciennes cartes graphiques avaient beaucoup de registres spécialisés, c'est à dire que chaque registre avait une fonction bien définie et ne pouvait stocker qu'un type de donnée bien précise. Mais avec l'évolution de Direct X et d'Open GL, les registres sont devenues de moins en moins spécialisés et sont devenues des registres généraux, interchangeables, capables de stocker des données arbitraires. De nos jours, les registres spécialisés sont devenus anecdotique, car leur a perdu de son intérêt avec l'unification des shaders : la plupart des registres spécialisés n'avaient de sens que pour les vertices et rien d'autre.

Les anciennes cartes graphiques : beaucoup de registres spécialisés modifier

Sur les processeurs de vertices des anciennes cartes 3D, il y avait bien quelques registres généraux, sans fonction préétablie, mais ils étaient secondés par un grand nombre de registres spécialisés assez nombreux. Certains étaient spécialisés dans le stockage des vertices, d'autres dans le stockage des résultats de calculs, d'autres dans le stockage de constantes, etc. Les registres spécialisés des processeurs de vertices peuvent se classer en plusieurs types :

  • des registres généraux, qui peuvent mémoriser tout type de données ;
  • des registres d'entrée, qui réceptionnent les vertices ou pixels ;
  • des registres de sortie, dans lesquelles le processeur stocke ses résultats finaux ;
  • des registres de constantes qui, comme leur nom l'indique, servent à stocker des constantes.

Un processeur de vertices contenait, en plus des registres généraux, des registres de constantes pour stocker les matrices servant aux différentes étapes de transformation ou d'éclairage. Ces constantes sont placées dans ces registres lors du chargement du vertex shader dans la mémoire vidéo : les constantes sont chargées un peu après. Toutefois, le vertex shader peut écrire dans ces registres, au prix d'une perte de performance particulièrement violente. Le choix du registre de constante à utiliser s'effectue en utilisant un registre d'adresse de constante. Celui-ci va permettre de préciser quel est le registre de constante à sélectionner dans une instruction. Une instruction peut ainsi lire une constante depuis les registres constants, et l'utiliser dans ses calculs.

 
Architecture d'un processeur de shaders avec accès aux textures.

Les cartes graphiques modernes : une hiérarchie de registres généralistes modifier

De nos jours, les processeurs de shaders utilisent une hiérarchie de registres. On trouve d'abord quelques bancs de registres locaux (Local Register File), directement connectés aux unités de calcul. Ces derniers sont reliés à un banc de registres plus gros, le banc de registre global, qui sert d'intermédiaire entre la mémoire RAM et les bancs de registres locaux. La différence entre les bancs de registres locaux/globaux et un cache vient du fait que les caches sont souvent gérés par le matériel, tandis que ces bancs de registres sont gérés via des instructions machines. Le processeur dispose d'instructions pour transférer des données entre les bancs de registres ou entre ceux-ci et la mémoire. Leur gestion peut donc être déléguée au logiciel, qui saura les utiliser au mieux.

Outre son rôle d'intermédiaire, le banc de registre global sert à transférer des données entre les bancs de registres locaux, où à stocker des données globales utilisées par des Clusters d'ALU différents. Les transferts de données entre la mémoire et le Global Register File ressemblent fortement à ceux qu'on trouve sur les processeurs vectoriels. Un processeur de flux possède quelques instructions capables de transférer des données entre ce Global Register File et la mémoire RAM. Et on trouve des instructions capables de travailler sur un grand nombre de données simultanées, des accès mémoires en Stride, en Scatter-Gather, etc.

 
Registres d'un Stream processor.

On peut se demander pourquoi utiliser plusieurs couches de registres ? Le fait est que les processeurs de flux disposent d'une grande quantité d'unités de calcul. Et cela peut facilement aller à plus d'une centaine ou d'un millier d'ALU ! Si on devait relier toutes cas unités de calcul à un gros banc de registres, celui-ci serait énorme, lent, et qui chaufferait beaucoup trop. Pour garder un banc de registres rapide et pratique, on est obligé de limiter le nombre d'unités de calcul connectées dessus, ainsi que le nombre de registres contenus dans le banc de registres. La solution est donc de casser notre gros banc de registres en plusieurs plus petits, reliés à un banc de registres plus gros, capable de communiquer avec la mémoire. Ainsi, nos unités de calcul vont aller lire ou écrire dans un banc de registres local très rapide.

La microarchitecture des processeurs de shaders modifier

Outre le jeu d’instruction, la conception interne (aussi appelée microarchitecture) des processeurs de shaders possède quelques particularités idiosyncratiques. Les cartes graphiques modernes, programmables, sont des architectures massivement parallèles, à savoir qu'elles sont capables d'effectuer un grand nombre de calculs indépendants en même temps, quitte à réduire la puissance pour des calculs séquentiels (ceux qu'effectue un processeur). Il faut dire que chaque triangle ou pixel d'une scène 3D peut être traité indépendamment des autres, ce qui rend le traitement 3D fortement parallèle. De plus, le parallélisme utilisé est du parallélisme de données, à savoir qu'on exécute le même programme sur des données différentes, chaque donnée étant traitée en parallèle des autres. La raison à cela est très importante et il faut la retenir :

L’exécution d'un shader génère un grand nombre d'instances de ce shader, chacune traitant un paquet de pixels/sommets différent.

Les cartes graphiques récentes incorporent toutes les techniques de parallélisme de donnée au niveau matériel : elles ont plusieurs processeurs, ceux-ci ont plusieurs cœurs, chaque coeur est capable d’exécuter des instructions SIMD (ils ne font que cela, à vrai dire), et j'en passe. L'architecture d'une carte graphique récente est illustrée ci-dessous. On y voit la présence d'un grand nombre de processeurs/cœurs et une hiérarchie mémoire assez étagée avec des mémoires locales en complément de la mémoire vidéo principale. Rien de bien déroutant pou qui a déjà étudié les architectures à parallélisme de données, mais quelques rappels ou explications ne peuvent pas faire de mal.

 
Ce schéma illustre l'architecture d'un GPU en utilisant la terminologie NVIDIA. Comme on le voit, la carte graphique contient plusieurs cœurs de processeur distincts. Chacun d'entre eux contient plusieurs unités de calcul généralistes, appelées processeurs de threads, qui s'occupent de calculs simples (en bleu). D'autres calculs plus complexes sont pris en charge par une unité de calcul spécialisée (en rouge). Ces cœurs sont alimentés en instructions par le processeur de commandes, ici appelé Thread Execution Control Unit, qui répartit les différents shaders sur chaque cœur. Enfin, on voit que chaque cœur a accès à une mémoire locale dédiée, en plus d'une mémoire vidéo partagée entre tous les cœurs.

Un grand nombre de processeurs et d'unités de calculs modifier

 
Comparaison du nombre de processeurs et de cœurs entre CPU et GPU.

Pour profiter au mieux des opportunités de parallélisme, une carte graphique contient de nombreux processeurs, qui eux-mêmes contiennent plusieurs unités de calcul. Savoir combien de cœurs contient une carte graphique est cependant très compliqué, car la terminologie utilisée par les fabricants de carte graphique est particulièrement confuse. Il n'est pas rare que ceux-ci appellent cœurs ou processeurs, ce qui correspond en réalité à une unité de calcul d'un processeur normal, sans doute histoire de gonfler les chiffres. Et on peut généraliser à la majorité de la terminologie utilisée par les fabricants, que ce soit pour les termes warps processor, ou autre, qui ne sont pas aisés à interpréter.

La microarchitecture des processeurs de shaders est de plus particulièrement simple. On n'y retrouve pas les fioritures des CPU modernes, tant utiles pour du calcul séquentiel : pas d’exécution dans le désordre, de renommage de registres, et autres techniques avancées. En conséquence, les unités de décodage et/ou de contrôle sont relativement simples, peu complexes. La majeure partie du processeur est dédié aux unités de calcul.

 
Comparaison entre l'architecture d'un processeur généraliste et d'un processeur de shaders.

Des unités de calcul variées et spécialisées modifier

Même les premiers processeurs de shaders disposaient de plusieurs unités de calculs séparées, capables de faire des calculs en parallèle. Mais ces unités étaient conçues pour le rendu 3D, les opérations qu'elles faisaient étés prévues pour cet objectif. Les processeurs principaux (CPU) d'un ordinateur sont capables de faire beaucoup de calculs arithmétiques simples, comme des additions, des multiplications, des opérations bit-à-bit, éventuellement des divisions, etc. Mais il est rare qu'ils soient capables de faire des opérations plus complexes, comme des calculs trigonométriques, des exponentielles, des logarithmes, des racines carrées ou racines carrées inverse, etc. De tels calculs sont rares dans les programmes exécutables hors-rendu 3D ou traitement d'image et créer des instructions spécifiques ne vaudrait pas le coup, alors que les calculs arithmétiques simples y sont légion.

Mais pour les unités de calculs des processeurs de shaders, ce n'est pas le cas. Les instructions de calcul arithmétiques sont nécessaires, mais le gros du travail demande d'effectuer des opérations complexes. Il faut dire que le rendu 3D demande pas mal de calculs trigonométriques, de produits scalaires ou d'autres opérations. Aussi, les processeurs de shaders disposent souvent d'une unité de calcul spécialisée dans les calculs complexes (exponentielle/logarithme, racine carrée, racine carrée inverse, autres), parfois de plusieurs. De plus, on trouve souvent une unité de calcul spécialisée dans l’opération Multiply-And-Add (une multiplication suivie d'une addition, opération très courante en 3D, notamment dans le calcul de produits scalaires). Un bon exemple est le processeur de vertex shader de la Geforce 6800, illustré ci-dessous. On voit que le processeur contient une unité d'accès mémoire pour lire les textures, avec deux unités de calcul : une unité pour les opérations mathématiques complexes et une unité pour le calcul Multiply-And-Add. On voit que les unités de calculs sont assez spécialisées, avec une ou plusieurs unités généralistes, secondée par des unités capables de faire des calculs spécialisés.

 
Processeur de shader (vertex shader) d'une GeForce 6800. On voit clairement que celui-ci contient, outre les traditionnelles unités de calcul et registres temporaires, un "cache" d'instructions, des registres d'entrée et de sortie, ainsi que des registres de constante.
 
Schéma de la micro-architecture d'un processeur sur la micro-architecture Fermi de NVIDIA.

De nos jours, les unités de calculs dédiées à des opérations bien précises existent encore, même dans les architectures récentes. Pour vous en donner un exemple, je vais prendre l'architecture Fermi de NVIDIA, utilisée dans les cartes graphiques de la marque sorties en 2010. Les cartes graphiques de cette marque sont composées de plusieurs processeurs de shaders. L'intérieur d'un de ces processeur est illustrée ci-contre, avec les termes de nomenclature utilisée par NVIDIA.

Les différentes mémoires inclues dans le processeur sont en bleu. Elles regroupent un cache d'instruction, un banc de registres (register file) qui contient les registres du processeur, et divers caches (en bas) reliés au reste du processeur par un réseau d’interconnexion (interconnect network). En orange se trouve les circuits de contrôle, qui ont pour but de décoder et de répartir les instructions sur les unités de calculs plus bas. Ils regroupent l'unité de décodage des instructions (non-représentée), des warp scheduler et les unités de dispatch qui s'occupent de la répartition des calculs sur les unités de calcul. Enfin, en vert, on a diverses unités de calcul. Les unités core ne sont pas des cœurs de processeurs, comme la terminologie utilisée par NVIDIA peut le faire croire, mais des unités de calcul arithmétiques simples, capables de faire additions, multiplications, et quelques autres calculs du genre. Les unités LD/ST sont les unités pour l'accès à la mémoire et elles sont capables d'accèder à des textures et de les filtrer. Enfin, les unités SFU sont des unités de calculs spécialisées dans les calculs complexes, trigonométriques ou autres.

La mitigation de la latence mémoire modifier

Un accès à une texture, c'est long : si celle-ci est lue depuis la mémoire vidéo, le temps d'attente est d'une bonne centaine de cycles d'horloges. Pour limiter la casse, les unités de texture incorporent un cache de texture, mais cela ne suffit pas toujours à alimenter les processeurs de shaders en données. S'ils n'utilisaient pas d'autres optimisations, les processeurs de shader devraient attendre plusieurs centaines ou dizaines de cycles d'horloge à ne rien faire, en attendant les données de la texture. Fort heureusement, les processeurs de shaders disposent d'optimisations pour remplir ces temps d'attente avec des calculs indépendants de la texture lue.

Une forme limitée d’exécution dans le désordre modifier

L'unité de texture peut fonctionner en parallèle des unités de calcul, comme toute unité d'accès mémoire. Cela permet de continuer l’exécution du shader en cours, celui qui a lancé la lecture de texture, en parallèle de l'accès mémoire.

Évidemment, cela ne fonctionne que si la lecture est suivie de calculs qui n'ont pas besoin de la donnée lue. Les shaders doivent être conçus pour que cette technique marche, en faisant en sorte que les instructions qui suivent la lecture de texture sont bien indépendante de celle-ci. Idéalement, si un accès mémoire dure 200 cycles d'horloge, il faut que le shader exécute des instructions indépendantes de la lecture durant 200 cycles d'horloge. Dans la réalité, les résultats seront moins bons. On réussira peut-être à exécuter une dizaine d'instruction pendant la lecture, mais ce gain est bon à prendre. Dans tous les cas, le processeur de shader peut masquer totalement ou partiellement la latence de l’accès mémoire.

Les accès mémoire non-bloquants modifier

Outre le manque d'instructions indépendantes de la lecture, un autre problème réduit les performances de cette technique. Un shader effectue souvent plusieurs accès mémoire assez rapprochés. Et un problème survient quand une lecture est en cours et qu'un autre accès mémoire a lieu.

Sur les anciennes cartes graphiques, l'unité de texture ne peut gérer qu'un seul accès mémoire à la fois. En conséquence, quand une instruction d'accès mémoire a lieu, elle doit attendre que la lecture précédente se termine. Ce faisant, elle bloque toutes les instructions qui la suivent, le shader est mis en pause. C'était un problème sur les cartes graphiques anciennes, mais ce n'est plus tellement le cas de nos jours. Les unités de texture actuelles peuvent effectuer plusieurs accès mémoire en parallèle. Mais elles ont un nombre limité d'accès mémoire simultanés et il arrive rarement qu'un shader dépasse ce nombre, ce qui entraine alors une situation de blocage.

Vous vous demandez comment font les unités de texture modernes pour effectuer plusieurs accès mémoire à la fois ? La réponse est malheureusement entre les mains des concepteurs de cartes graphiques et peu de détails ont fuité. Mais une hypothèse raisonnable est qu'elles effectuent un accès mémoire à la fois, les autres accès mémoire étant mis en attente. Les accès mémoire s’exécutent vraisemblablement dans l'ordre du shader, il n'y a pas de réorganisation des accès mémoire ou d’exécution dans le désordre des accès mémoire. La manière la plus simple d'implémenter des accès mémoire multiples est de les mettre en attente dans une petite mémoire FIFO.

 
Accès mémoire simultanés.

Le multithreading matériel modifier

Trouver suffisamment d’instructions indépendantes d'une lecture dans un shader n'est donc pas une chose facile. Les améliorations au niveau du compilateur de shaders des drivers peuvent aider, mais la marge est vraiment limitée. Pour trouver des instructions indépendantes d'une lecture en mémoire, le mieux est encore d'aller chercher dans d'autres shaders… Sans la technique qui va suivre, chaque shader correspond à un programme qui s’exécute sur toute une image.

Avec les techniques de multi-threading matériel, chaque shader est dupliqué en plusieurs copies indépendantes, des threads, qui traitent chacun un morceau de l'image. Un processeur de shader peut traiter plusieurs threads, et répartir les instructions de ces threads sur l'unité de calcul suivant les besoins : si un thread attend la mémoire, il laisse l'unité de calcul libre pour un autre.


La répartition du travail sur les unités de shaders

La répartition du travail sur plusieurs processeurs de shaders est un vrai défi sur les cartes graphiques actuelles. De plus, elle dépend de l'occupation des circuits fixes, comme l’input assembler ou l'unité de rastérisation. Une carte graphique ne traite pas les sommets ou pixels un par un, mais essaye d'en traiter un grand nombre en même temps, en parallèle. Les cartes graphiques sont conçues pour cela, pour exécuter les mêmes calculs en parallèle sur un grande nombre de données indépendantes. Si on prend l'exemple d'un vertex shader, une carte graphique exécute le même shader sur un grande nombre de sommets en même temps. Pour cela, elle contient plusieurs processeurs de shaders, chacun traitant au moins un sommet à la fois. Il en est de même pour les traitements sur les pixels. Dans ce chapitre, nous allons voir comment le travail est répartit sur les processeurs de shaders.

Les cartes graphiques implémentent un parallélisme de type Fork/join modifier

Si une carte graphique exécute de nombreux calculs en parallèle, tout ne peut pas être parallélisé et ce pour deux raisons. La première est tout simplement le pipeline graphique, qui impose que le rendu se fasse en étapes bien précises. La seconde raison est que certaines étapes sont relativement bloquantes et imposent une réduction du parallélisme. Tel est le cas de l'étape de rastérisation ou de l'enregistrement des pixels en mémoire, qui se parallélisent assez mal. Elles servent de point de convergence, de point de blocage. Les deux points interagissent et font que le parallélisme sur les cartes graphiques est un peu particulier.

Concrètement, prenons l'exemple d'une carte graphique simple, illustrée ci-dessous. Le pipeline commence avec une unité dinput assembly qui lit les sommets en mémoire vidéo et les distribue sur plusieurs circuits géométriques (des circuits fixes sur les anciennes cartes 3D, des processeurs de shaders sur les modernes). En sortie des unités géométriques, les sommets sont tous envoyé au rastériseur. En sortie du rastériseur, les pixels sont répartis sur plusieurs circuits de traitement des pixels, généralement des processeurs de shaders. Là encore, comme pour les unité géométriques, on trouve un grand nombre de circuits qui effectuent leur travail en parallèle. puis vient le moment d'enregistrer les pixels finaux en mémoire. Il sont alors envoyés à un circuit spécialisé, nommé le ROP.

 
Parallélisme dans une carte 3D

On voit que le rastériseur et les ROP finaux sont des points de convergence où convergent plusieurs sommets/pixels. Pour gérer cette convergence, les cartes graphiques disposent de mémoires tampon, dans lesquelles on accumule les sommets ou pixels. Ce mécanisme de mise en attente garantit que les sommets sont mémorisés en attendant que le rastériseur soit libre, ou que les ROP soient libres. La présence des mémoires tampon désynchronise les différentes étapes du pipeline, tout en gardant une exécution des étapes dans l'ordre. Une étape écrit ses résultats dans la mémoire tampon, l'autre étape lit ce tampon quand elle démarre de nouveaux calculs. Comme cela, on n'a pas à synchroniser les deux étapes : la première étape n'a pas à attendre que la seconde soit disponible pour lui envoyer des données. Évidemment, tout cela marche bine tant que les mémoires tampon peuvent accumuler de nouvelles données. Mais si ce n'est pas le cas, si la mémoire tampon est pleine, elle ne peut plus accepter de nouveaux sommet/pixels. Donc, l'étape précédente est bloquée et ne peut plus démarrer de nouveaux calculs en attendant.

La répartition entre pixel shaders et vertex shaders modifier

Un triangle est impliqué dans plusieurs pixels lors de l'étape de rastérisation. Un triangle peut donner quelques pixels lors de l'étape de rastérisation, alors qu'un autre va couvrir 10 fois de pixels, un autre seulement trois fois plus, un autre seulement un pixel, etc. Et vu que chaque triangle est impliqué dans plusieurs pixels, cela fait qu'il y a plus de travail à faire sur les pixels que sur les sommets. C'est un phénoméne d'amplification, qui fait que les processeurs de shader sont plus occupés à exécuter des pixel shaders que des vertex shader.

Les architectures avec unités de vertex et pixels séparées modifier

Les premières cartes graphiques avaient des processeurs séparés pour les vertex shaders et les pixel shaders. Cette séparation entre unités de texture et de sommets était motivée par le fait qu'à l'époque, le jeu d'instruction pour les vertex shaders était différent de celui pour les pixel shaders. Notamment, les unités de traitement de pixels doivent accéder aux textures, mais pas les unités de traitement de la géométrie. D'où l'existence d'unités dédiées pour les vertex shaders et les pixel shaders, chacune avec ses propres capacités et fonctionnalités. Ces architectures ont beaucoup plus de processeurs de pixel shaders que de processeurs de vertex shaders, vu que les pixels shaders dominent le temps de calcul par rapport aux vertex shaders.

 
Carte 3D avec pixels et vertex shaders non-unifiés.

Pour donner un exemple, c'était le cas de la Geforce 6800. Elle comprenait 16 processeurs pour les pixel shaders, alors qu'elle n'avait que 6 processeurs pour les vertex shaders. D'autres cartes graphiques plus récentes utilisent encore cette stratégie, comme l'ARM Mali 400.

 
Architecture de la GeForce 6800.

Malheureusement, il arrivait que certains processeurs de shaders soient inutilisés. Malheureusement, tous les jeux vidéos n'ont pas les mêmes besoins : certains sont plus lourds au niveau géométrie que d'autres, certains ont des pixels shaders très gourmands avec des vertex shaders très light, d'autres font l'inverse, etc. Le fait d'avoir un nombre fixe de processeurs de vertex shaders fait qu'il va en manquer pour certains jeux vidéo, alors qu'il y en aura de trop pour d'autres. Pareil pour les processeurs de pixel shaders. Au final, le problème est que la répartition entre puissance de calcul pour les sommets et puissance de calcul pour les pixels est fixe, alors qu'elle n'est variable d'un jeu vidéo à l'autre, voire d'un niveau de JV à l'autre.

Les architectures avec unités de shaders unifiées modifier

Depuis DirectX 10, le jeu d'instruction des vertex shaders et des pixels shaders a été unifié : plus de différences entre les deux. En conséquence, il n'y a plus de distinction entre processeurs de vertex shaders et de pixels shaders, chaque processeur pouvant traiter indifféremment l'un ou l'autre.

 
Carte 3D avec pixels et vertex shaders unfifiés.
Précisons qu'il existe des architectures DirectX 10 avec des unités séparées pour les vertex shaders et pixels shaders. L'unification logicielle des shaders n’implique pas unification matérielle des processeurs de shaders, même si elle l'aide fortement.

Cela a un avantage considérable, car tous les jeux vidéo n'ont pas les mêmes besoins. Certains ont une géométrie très développée mais peu de besoin en termes de textures, ce qui fait qu'ils gagnent à avoir beaucoup d'unités de gestion de la géométrie et peu d'unités de traitement des pixels. À l'inverse, d'autres jeux vidéo ont besoin de beaucoup de puissance de calcul pour les pixels, mais arrivent à économiser énormément sur la géométrie. L'usage de shaders unifiés permet d'adapter la répartition entre vertex shaders et pixels shaders suivant les besoins de l'application, là où la séparation entre unités de vertex et de pixel ne le permettait pas.

La distribution du travail sur des processeurs de shaders modifier

L’input assembler lit un flux de sommets depuis la mémoire et l'envoie aux processeurs de vertex shader. La rastérisation, quant à elle, produit un flux de pixels qui sont envoyés aux pixels shaders et/ou aux unités de textures. Le travail sortant du rastériseur ou de l’input assembler doit donc être envoyé à un processeur de shader libre, qui n'a rien à faire. Et on ne peut pas prédire lesquels sont libres, pas plus qu'il n'est garanti qu'il y en ait un. Une des raisons à cela est que tous les shaders ne mettent pas le même temps à s'exécuter, certains prenant quelques dizaines de cycles d'horloge, d'autres une centaine, d'autres plusieurs milliers, etc. Il se peut qu'un processeur de shader soit occupé avec un paquet de sommets/pixels depuis plusieurs centaines de cycles, alors que d'autres sont libres depuis un moment. En clair, on peut pas prédire quels processeurs de shaders seront libres prochainement. Planifier à l'avance la répartition du travail sur les processeurs de shaders n'est donc pas vraiment possible. Cela perturbe la répartition du travail sur les processeurs de shader. Pour résoudre ce problème, il existe plusieurs méthodes, classées en deux types : statiques et dynamiques.

La distribution statique modifier

Avec la distribution statique, le choix n'est pas basé sur le fait que telle unité de shader est libre ou non, mais chaque sommet/pixel est envoyé vers une unité de shader définie à l'avance. L'ARM Mali 400 utilise une méthode de distribution statique très commune. Elle utilise des processeurs de vertex et de pixels séparés, avec un processeur de vertex et 4 processeurs de pixel. L'écran est découpé en quatre tiles, quatre quadrants, et chaque quadrant est attribué à un processeur de pixel shader. Quand un triangle est rastérisé, les pixels obtenus lors de la rastérisation sont alors envoyés aux processeurs de pixel en fonction de leur posituion à l'écran. Cette méthode a des avantages. Elle est très simple et n'a pas besoin de circuits complexes pour faire la distribution, l L'ordre de l'API est facile à conserver, la gestion des ressources est simple, la localité des accès mémoire est bonne, etc. Mais la distribution étant statique, il est possible que des unités de shader soient inutilisées. Il n'est pas rare que l'on a des périodes assez courtes où tout le travail sortant du rastériseur finisse sur un seul processeur.

La distribution dynamique modifier

La distribution dynamique prend en compte le fait que certaines unités de shader sont inutilisées et tente d'utiliser celles-ci en priorité. Le travail sortant du rastériseur ou de l'input assembler est envoyé aux unités de shader inutilisées, ou du moins à celles qui sont les moins utilisées. Cela garantit d'utiliser au mieux les processeurs de shaders. On n'a plus de situations où un processeur est inutilisé, alors que du travail est disponible. Tous les processeurs sont utilisés tant qu'il y a assez de travail à faire. Par contre, cela demande de rajouter une unité de distribution, qui se charge de répartir les pixels/fragments sur les différents processeurs de shaders. Elle connait la disponibilité de chaque processeur de shader et d'autres informations nécessaires pour faire une distribution efficace.

 
Dispatch des shaders sur plusieurs processeurs de shaders

Par contre, cela implique que les calculs se finissent dans le désordre. Par exemple, si deux pixels sortent du rastériseur l'un après l'autre, ils peuvent sortir de l'unité de pixel shader dans l'ordre inverse. Il suffit que le premier pixel a pris plus de temps à être calculé que l'autre pour ça. Or, l'API impose que les triangles/pixels soient rendus dans un ordre bien précis, de manière séquentielle. Si les calculs sur les sommets/pixels se font dans le désordre, cet ordre n'est pas respecté. Pour résoudre ce problème, et rester compatible avec les API, les processeurs de shader sont suivis par un circuit de remise en ordre, qui remet les pixels sortant des processeurs de pixel dans l'ordre de sortie du rastériseur. Le circuit en question est une mémoire qui ressemble un petit peu à une mémoire FIFO, appelée tampon pseudo-FIFO. Les pixels sont ajoutés au bon endroit dans la mémoire FIFO, mais ils quittent celle-ci dans l'ordre de rendu de l'API.

 
Exécution des shaders dans le désordre et circuits de remise en ordre

Les architectures actuelles font les deux modifier

La distribution dynamique marche très bien quand il y a un seul rastériseur. Mais les cartes graphiques modernes disposent de plusieurs rastériseurs, pour ces questions de performance. Et cela demande de faire deux formes de répartition : répartir les triangles entre rastériseurs et les laisser faire leur travail en parallèle, puis répartir les fragment/pixels sortant des rastériseurs entre processeurs de shader. La répartition des fragment/pixels se base sur une distribution dynamique, alors que la répartition des triangles entre rastériseurs se base sur la distribution statique.La distribution statique se base sur l'algorithme avec des quadrants/tiles est utilisé pour la gestion des multiples rastériseurs. L'écran est découpé en tiles et chacune est attribuée à un rastériseur attitré. Il y a un rastériseur par tile dans la carte graphique. le découpage en tiles est aussi utilisée pour l'élimination des pixels non-visibles et pour quelques optimisations du rendu.


La mémoire unifiée et la mémoire vidéo dédiée

Il existe deux grandes manières d'organiser la mémoire à laquelle la carte graphique a accès.

  • La première est celle de la mémoire vidéo dédiée, à savoir que la carte graphique dispose de sa propre mémoire rien qu'à elle, séparée de la mémoire RAM de l'ordinateur. On fait alors la distinction entre RAM système et RAM vidéo. Si les premières cartes graphiques n'avaient que quelques mégaoctets de RAM dédiée, elles disposent actuellement de plusieurs gigas-octets de RAM.
  • A l'opposé, on trouve la mémoire unifiée, avec une seule mémoire RAM est partagée entre le processeur et la carte graphique. Le terme "unifiée" sous-entend que l'on a unifié la mémoire vidéo et la mémoire système (la RAM).
 
Répartition de la mémoire entre RAM système et carte graphique

On pourrait croire que la mémoire unifiée est un désavantage. Entre avoir 16 gigas de RAM pour le processeur et 4 gigas pour la carte graphique, et seulement 16 gigas pour les deux, le choix est vite fait. Ceci dit, cette comparaison se fait à quantité de RAM système égale, ce qui n'a pas le même prix. En clair, ce désavantage peut se compenser en ajoutant de la RAM à l'ordinateur, ce qui n'est pas forcément plus cher. La RAM vidéo a un prix, après tout. Et surtout, ces comparaisons sont quelque peu biaisées par la manière dont l'IGP utilise la mémoire vidéo.

Par contre, les performances sont totalement différentes. La raison principale est que le débit de la RAM est à partager entre la carte graphique et le processeur. Rappelons que le débit est la quantité de données qu'on peut lire ou écrire par seconde. Plus il est élevé, plus la RAM est rapide. Avec une mémoire unifiée, ce débit est partagé entre processeur et GPU. Par exemple, pour une mémoire RAM capable de lire ou écrire 87 gigaoctets par secondes, on peut allouer 20 gigas par secondes au GPU et le reste au GPU. Alors qu'avec une mémoire dédiée, tout le débit aurait été dédié au GPU, le CPU ayant quant à lui accès à tout le débit de la RAM système.

De plus, le partage du débit n'est pas chose facile. Les deux se marchent sur les pieds et entrent en conflit pour l'accès à la RAM. La carte graphique doit parfois attendre que le processeur lui laisser l'accès à la RAM et inversement. Divers circuits d'arbitrage s'occupent de répartir équitablement les accès à RAM entre les deux, mais cela ne permet que d'avoir un compromis imparfait qui peut réduire les performances.

Le seul moyen pour réduire la casse est d'ajouter des mémoires caches entre le GPU et la RAM, le processeur ayant déjà beaucoup de caches lui-même. Mais l'efficacité des caches est relativement limitée pour le rendu 3D et ne donne pas des résultats vraiment spectaculaires.

Les cartes graphiques dédiées et intégrées modifier

Avant de poursuivre, nous devons parler d'une chose importante. Il existe deux types de cartes graphiques : les cartes dédiées et les cartes intégrées. Les cartes graphiques dédiées sont des cartes graphiques branchées sur des connecteurs/ports de la carte mère. A l'opposé, sur beaucoup d'ordinateurs modernes, il y a une carte graphique intégrée au processeur, appelée carte graphique intégrée, ou encore IGP (Integrated Graphic Processor). En somme, les cartes dédiées sont opposées à celles intégrées dans les processeurs modernes. Dans la grosse majorité des cas, les cartes vidéos dédiées ont une mémoire dédiée, alors que les cartes graphiques intégrées ont une mémoire unifiée. Il s'agit là du cas le plus commun, les deux autres étant plus rares.

Les cartes graphiques intégrées utilisent systématiquement la mémoire unifiée, car on n'a pas vraiment le choix. En théorie, il est possible d'avoir une carte graphique intégrée avec une mémoire vidéo dédiée, mais les contraintes techniques sont trop fortes pour cela. Cela demanderait d'intégrer la mémoire vidéo directement dans le processeur, et on ne pourrait y mettre que quelques dizaines ou centaines mégaoctets au grand maximum. Cela ne serait pas suffisant pour les applications actuelles, ce qui fait que cette solution n'est pas utilisée et que la mémoire unifiée règne en maitre sur les cartes graphiques intégrées.

L'usage d'une carte vidéo dédiée se marie très bien avec une mémoire vidéo dédiée : quitte à avoir une carte graphique qui dispose de sa propre carte, autant mettre de la mémoire dessus. Mais il existe de nombreux cas où une carte vidéo dédiée est associée à de la mémoire unifiée. La mémoire unifiée était notamment utilisée sur certaines consoles de jeux vidéo assez anciennes, qui avaient pourtant une carte graphique dédiée, soudée sur la carte mère, qu'on peut facilement repérer à l’œil nu. Mais les deux composants se partagent une mémoire RAM unique, même s'ils sont séparés. C'est notamment le cas sur la Nintendo 64, pour ne citer qu'elle.

Comme autre exemple, la toute première carte graphique AGP, l'Intel 740, ne possédait pas de mémoire vidéo proprement dite, juste un simple framebuffer. Tout le reste, texture comme géométrie, était placé en mémoire système et la carte graphique allait lire/écrire les données directement en mémoire RAM système ! Les performances étaient ridicules, car la carte graphique devait aller lire/écrire des données via le bus AGP qui était plus lent qu'une mémoire vidéo dédiée. Mais l'absence de RAM vidéo faisait que la carte graphique coutait peu cher, ce qui lui a valu un petit succès sur les ordinateurs d'entrée de gamme.

Le partage de la mémoire unifiée modifier

Avec la mémoire unifiée, la quantité de mémoire système disponible pour la carte graphique est généralement réglable avec un réglage dans le BIOS. On peut ainsi choisir d'allouer 64, 128 ou 256 mégaoctets de mémoire système pour la carte vidéo, sur un ordinateur avec 4 gigaoctets de RAM. L'interprétation de ce réglage varie grandement selon les cartes mères ou l'IGP.

Pour les GPU les plus anciens, ce réglage implique que la RAM sélectionnée est réservée uniquement à la carte graphique, même si elle n'en utilise qu'une partie. La répartition entre mémoire vidéo et système est alors statique, fixée une fois pour toutes. Dans ce cas, la RAM allouée à la carte graphique est généralement petite par défaut. Les concepteurs de carte mère ne veulent pas qu'une trop quantité de RAM soit perdu et inutilisable pour les applications. Ils brident donc la carte vidéo et ne lui allouent que peu de RAM.

Heureusement, les GPU modernes sont plus souples. Ils fournissent deux réglages : une quantité de RAM minimale, totalement dédiée au GPU, et une quantité de RAM maximale que le GPU ne peut pas dépasser. Par exemple, il est possible de régler le GPU de manière à ce qu'il ait 64 mégaoctets rien que pour lui, mais qu'il puisse avoir accès à maximum 1 gigaoctet s'il en a besoin. Cela fait au total 960 mégaoctets (1024-64) qui peut être alloués au choix à la carte graphique ou au reste des programmes en cours d’exécution, selon les besoins. Il est possible d'allouer de grandes quantités de RAM au GPU, parfois la totalité de la mémoire système.

 
Répartition de la mémoire entre RAM système et carte graphique

La mémoire virtuelle sur les GPUs dédiés modifier

 
Mémoire virtuelle

La mémoire virtuelle est à l'origine une technologie qui permet à un processeur d'utiliser plus de mémoire qu'il n'y en a d'installé dans l'ordinateur. Par exemple, elle permet au CPU de gérer 4 gigas de RAM sur un ordinateur qui n'en contient que trois, le gigaoctet de trop étant en réalité simulé par un fichier sur le disque dur. La technique est utilisée par tous les processeurs modernes.

Il se trouve que les cartes graphiques dédiées peuvent faire la même chose, bien que la technique soit un petit peu différente. La mémoire virtuelle des GPUs dédiés permet d'utiliser plus de RAM qu'il n'y en a d'installé sur la carte graphique. Par exemple, si on prend une carte graphique avec 6 gigas de RAM dédiée, elle pourra gérer jusqu'à 8 gigas de RAM : les 6 en mémoire vidéo, plus 2 gigas fictifs en rab. Et c'est là qu'intervient la première différence avec la mémoire virtuelle des CPU : les 2 gigas fictifs ne sont pas stockés sur le disque dur dans un fichier pagefile, mais sont à la place pris sur la mémoire RAM système. Pour le dire autrement, ces cartes dédiées peuvent utiliser la mémoire système si jamais la mémoire vidéo est pleine.

 
Mémoire virtuelle des cartes graphiques dédiées

Pour que la carte graphique ait accès à la mémoire système, elle intègre un circuit appelé la Graphics address remapping table, abrévié en GART. Cela vaut aussi bien pour les cartes graphiques utilisant le bus AGP que pour celles en PCI-Express. La GART est techniquement une Memory Management Unit, à savoir un circuit spécialisé qui prend en charge la mémoire virtuelle.

Les cartes graphiques au format PCI ou antérieures n'avaient pas de mémoire virtuelle. La technologie est apparue avec le bus AGP, dont certaines fonctionnalités (l'AGP texturing) permettaient de lire ou écrire directement dans la mémoire RAM, sans passer par le processeur. D'ailleurs, la première carte graphique AGP poussait cette logique à son paroxysme, en se passant totalement de mémoire vidéo, comme dit plus haut. L'arrivée du bus PCI-Express ne changea pas la donne, si ce n'est que le bus était plus rapide, ce qui améliorait les performances.

Au début, seules les cartes graphiques d'entrée de gamme pouvaient accéder à certaines portions de la mémoire RAM grâce à des technologies adaptées, comme le TurboCache de NVIDIA ou l'HyperMemory d'AMD. Mais la technologie s'est aujourd'hui étendue. De nos jours, toutes les cartes vidéos modernes utilisent la RAM système en plus de la mémoire vidéo, mais seulement en dernier recours, soit quand la mémoire vidéo est quasiment pleine, soit pour faciliter les échanges de données avec le processeur. C'est typiquement le pilote de la carte graphique qui décide ce qui va dans la mémoire vidéo et la mémoire système, et il fait au mieux de manière à avoir les performances optimales.

Les échanges entre processeur et mémoire vidéo modifier

Quand on charge un niveau de jeux vidéo, on doit notamment charger la scène, les textures, et d'autres choses dans la mémoire RAM, puis les envoyer à la carte graphique. Ce processus se fait d'une manière fortement différente selon que l'on a une mémoire unifiée ou une mémoire vidéo dédiée.

Avec la mémoire unifié, les échanges de données entre processeur et carte graphique sont fortement simplifiés et aucune copie n'est nécessaire. La carte vidéo peut y accéder directement, en lisant leur position initiale en RAM. Une partie de la RAM est visible seulement pour le CPU, une autre seulement pour le GPU, le reste est partagé. Les échanges de données entre CPU et GPU se font en écrivant/lisant des données dans la RAM partagée entre CPU et GPU. Pas besoin de faire de copie d'une mémoire à une autre : la donnée a juste besoin d'être placée au bon endroit. Le chargement des textures, du tampon de commandes ou d'autres données du genre, est donc très rapide, presque instantané.

 
Échanges de données entre CPU et GPU avec une mémoire unifiée

Avec une mémoire vidéo dédiée, on doit copier ces données dans la mémoire vidéo pour le rendu, ce qui implique des transferts de données passant par le bus PCI-Express. Le processeur voit une partie de la mémoire vidéo, dans laquelle il peut lire ou écrire comme bon lui semble. Le reste de la mémoire vidéo est invisible du point de vue du processeur, mais manipulable par le GPU à sa guise. Il est possible pour le CPU de copier des données dans la portion invisible de la mémoire vidéo, mais cela se fait de manière indirecte en passant par le GPU d'abord. Il faut typiquement envoyer une commande spéciale au GPU, pour lui dire de charger une texture en mémoire vidéo, par exemple. Le GPU effectue alors une copie de la mémoire système vers la mémoire vidéo, en utilisant un contrôleur DMA intégré au GPU.

 
Échanges de données entre CPU et GPU avec une mémoire vidéo dédiée

La gestion de la mémoire vidéo est prise en charge par le pilote de la carte graphique, sur le processeur. Elle a tendance à allouer les textures et d'autres données de grande taille dans la mémoire vidéo invisible, le reste étant placé ailleurs. Les copies DMA vers la mémoire vidéo invisible sont adaptées à des copies de grosses données comme les textures, mais elles marchent mal pour des données assez petites. Or, les jeux vidéos ont tendance à générer à la volée de nombreuses données de petite taille, qu'il faut copier en mémoire vidéo. Et c'est sans compter sur des ressources du pilote de périphériques, qui doivent être copiées en mémoire vidéo, comme le tampon de commande ou d'autres ressources. Et celles-ci ne peuvent pas forcément être copiées dans la mémoire vidéo invisible. Si la mémoire vidéo visible par le CPU est trop petite, les données précédentes sont copiées dans la mémoire visible par le CPU, en mémoire système, mais leur accès par le GPU est alors très lent. Aussi, plus la portion visible de la mémoire vidéo est grande, plus simple est la gestion de la mémoire vidéo par le pilote graphique. Et de ce point de vue, les choses ont évolué récemment.

Pour accéder à un périphérique PCI-Express, il faut configurer des registres spécialisés, appelés les Base Address Registers (BARs). La configuration des registres précise quelle portion de mémoire vidéo est adressable par le processeur, quelle est sa taille, sa position en mémoire vidéo, etc. Avant 2008, les BAR permettaient d’accéder à seulement 256 mégaoctets, pas plus. La gestion de la mémoire vidéo était alors difficile. Les échanges entre portion visible et invisible de la mémoire vidéo étaient complexes, demandaient d’exécuter des commandes spécifiques au GPU et autres. Après 2008, la spécification du PCI-Express ajouta un support de la technologie resizable bar, qui permet au processeur d’accéder directement à plus de 256 mégaoctets de mémoire vidéo, voire à la totalité de la mémoire vidéo. De nombreux fabricants de cartes graphiques commencent à incorporer cette technologie, qui demande quelques changements au niveau du système d'exploitation, des pilotes de périphériques et du matériel.


La hiérarchie mémoire d'un GPU

Dans ce chapitre, nous allons voir comment est organisée la mémoire d'un GPU, ou plutôt devrait-on dire les mémoires d'un GPU. Eh oui : un GPU contient beaucoup de mémoires différentes. La hiérarchie mémoire des GPUs est assez particulière, que ce soit au niveau des caches ou de la mémoire, parfois des registres. Un GPU contient évidemment une mémoire vidéo, de grande taille, capable de stocker textures, vertices, images et bien d'autres choses nécessaires pour un rendu 3D. On y trouve souvent des mémoires caches dédiées aux textures ou aux vertices, et les GPUs récents contiennent aussi des caches tout court. Mais sur les cartes graphiques récentes, les caches sont complétés par des Local Store, des mémoires RAM qui servent de cache, mais fonctionnent comme des mémoires RAM normales.

La mémoire vidéo modifier

La mémoire vidéo d'une carte graphique dédiée est nécessaire pour stocker l'image à afficher à l'écran, mais aussi pour mémoriser temporairement des informations importantes. Dans le cas le plus simple, elle sert simplement de Framebuffer : elle stocke l'image à afficher à l'écran. Au fil du temps, elle s'est vu ajouter d'autres fonctions, comme stocker les textures et les sommets de l'image à calculer, ainsi que divers résultats temporaires.

Les spécificités des RAM des cartes vidéo dédiées modifier

Sur les cartes graphiques dédiées, la mémoire vidéo est très proche des mémoires RAM qu'on trouve sous forme de barrettes dans nos PC, à quelques différences près. Le point le plus important est que la mémoire vidéo d'une carte dédiée n'est pas présente sous la forme de barrettes de mémoire. À la place, les puces de mémoire sont soudées sur la puce. La conséquence est que l'on ne peut pas upgrader la RAM d'une carte vidéo. Ce serait sympathique, mais ne serait pas d'une grande utilité, car les jeux vidéos gourmands en mémoire vidéo sont aussi gourmands en puissance de calcul. Upgrader la RAM d'une carte graphique ne sert à rien si celle-ci n'a pas assez de puissance pour jouer à des jeux récents avec un framerate convenable.

Le fait que la mémoire est soudée simplifie la conception de la carte graphique, mais cela a des avantages au niveau électrique, qui permettent d'améliorer les performances. Niveau performances, la mémoire vidéo a des performances radicalement différentes de la RAM des PC. Elle a un temps d'accès très long, de plusieurs centaines de cycles d'horloge. Cela a des conséquences sur l'architecture de la carte graphique, notamment au niveau des processeurs de shaders, qui sont conçus pour gérer ces temps d'accès long, comme on l'a vu dans le précédent chapitre. Par contre, elle a un très grand débit, autrement dit une bande passante élevée, proche de la centaine de gigaoctets par secondes sur les cartes graphiques modernes. Pour rappel, la bande passante d'une mémoire dépend de deux paramètres : sa fréquence et la largueur de son bus mémoire. Détaillons le dernier, qui explique en grande partie pourquoi la mémoire vidéo a un débit supérieur à la mémoire système.

Le bus mémoire et sa largeur modifier

Le bus mémoire est ce qui connecte la mémoire au reste de la carte graphique. La largueur de ce bus n'est autre que la quantité de données que celui-ci peut transmettre à chaque cycle d'horloge, le nombre de bits que l'on peut lire/écrire en un cycle d'horloge. Sur la RAM système, le bus est de 64 bits sur les mémoires DDR modernes, mais peut monter à 128 bits en utilisant des techniques comme le dual channel, voire en 192/256 bits avec des techniques de triple/quad channel qui sont rarement utilisées. Globalement, la configuration classique sur un PC moderne est 128 bits, avec quelques machines bas de gamme en 64 bits. Sur les cartes graphiques modernes, les bus de 128 bits ou moins sont utilisés sur les cartes graphiques de faible performance, le reste ayant un bus mémoire de 192, 256, 384, voire 512 bits. En clair, elles permettent de lire/écrire plus de données par cycle d'horloge qu'une RAM système, de 2 à 8 fois plus.

Le fait que le bus est plus large est lié au fait que les puces mémoires sont soudées. La mémoire vidéo des cartes dédiées est composée de pleins de puces mémoires accessibles en parallèle, ce qui permet de charger des blocs de plusieurs centaines d'octets en une seule fois. Les barrettes de mémoire ont des limites au nombres de broches que leur connecteur peut accepter, qui est proche de 300 pour les DDR actuelles (beaucoup de ces broches ne transfèrent pas des données, ce qui fait qu'on a bien 64 broches dédiées aux données seulement). Sans connecteurs, on est limité à ce que la puce du GPU peut accepter, et on est alors entre 4000 à 6000 broches sur les sockets de CPU ou de GPU actuels.

 
Puces mémoires d'un GPU et d'une barrette de mémoire.

Pour résumer, sur les cartes graphiques dédiées, la RAM vidéo a un débit proche de la centaine de gigaoctets par secondes. Avec une mémoire RAM unifiée, vous pouvez facilement diviser cette estimation par 10.

Les caches d'un GPU modifier

Les cartes graphiques sont censées avoir peu de caches. Les caches en question sont des caches spécialisés pour les textures ou pour les sommets, ce qui leur vaut les noms de caches de texture et de cache de sommets. Il n'existe pas beaucoup d'autres caches sur les anciennes cartes graphiques, l'usage de caches plus complexes n'étant pas vraiment utile sur les cartes graphiques. Ce n'est que par la suite, quand les GPU commencèrent à être utilisés pour du calcul généraliste (scientifique, notamment), que la situation changea. Les GPU utilisèrent alors de plus en plus de caches généralistes.

Le cache de textures modifier

Le cache de textures, comme son nom l'indique, est un cache spécialisé dans les textures. Pour améliorer les performances, les cartes graphiques actuelles disposent généralement de plusieurs caches de textures. Déjà, toutes les cartes graphiques modernes disposent de plusieurs unités de texture, mais elles ne se partagent pas un seul cache. Faire ainsi compliquerait beaucoup trop les GPU et n'aurait aucun intérêt. A la place, chaque unité de texture dispose de son ou ses propres caches de textures.

De plus, les cartes graphiques modernes ont plusieurs caches de texture par unité de texture. Généralement, elles ont deux caches de textures : un petit cache rapide, et un gros cache lent. Les deux caches sont fortement différents. L'un est un gros cache, qui fait dans les 4 kibioctets, et l'autre est un petit cache, faisant souvent moins d'1 kibioctet. Mais le premier est plus lent que le second. Sur d'autres cartes graphiques récentes, on trouve plus de 2 caches de textures, organisés en une hiérarchie de caches de textures similaire à la hiérarchie de cache L1, L2, L3 des processeurs modernes.

Notons que ce cache interagit avec les techniques de compression de texture. Les textures sont en effet des images, qui sont donc compressées. Et elles restent compressées en mémoire vidéo, car les textures décompressées prennent beaucoup plus de place, entre 5 à 8 fois plus. Les textures sont décompressées lors des lectures : le processeur de shaders charge quelques octets, les décompresse, et utilise les données décompressées ensuite. Le cache s'introduit quelque part avant ou après la décompression. On peut décompresser les textures avant de les placer dans le cache, ou laisser les textures compressées dans le cache. Tout est une question de compromis. Décompresser les textures dans le cache fait que la lecture dans le cache est plus rapide, car elle n'implique pas de décompression, mais le cache contient moins de données. A l'inverse, compresser les textures permet de charger plus de données dans le cache, mais rend les lectures légèrement plus lentes. C'est souvent la seconde solution qui est utilisée et ce pour deux raisons. Premièrement, la compression de texture est terriblement efficace, souvent capable de diviser par 6 la taille d'une texture, ce qui augmente drastiquement la taille effective du cache. Deuxièmement, les circuits de décompression sont généralement très rapides, très simples, et n'ajoutent que 1 à 3 cycles d'horloge lors d'une lecture.

Les anciens jeux vidéo ne faisaient que lire les textures, sans les modifier. Aussi, le cache de texture des cartes graphiques anciennes est seulement accessible en lecture, pas en écriture. Cela simplifiait fortement les circuits du cache, réduisant le nombre de transistors utilisés par le cache, réduisant sa consommation énergétique, augmentait sa rapidité, etc. Mais les jeux vidéos 3D récents utilisent des techniques dites de render-to-texture, qui permettent de calculer certaines données et à les écrire en mémoire vidéo pour une utilisation ultérieure. Les textures peuvent donc être modifiées et cela se marie mal avec un cache en lecture seule. Rendre le cache de texture accessible en écriture est une solution, mais qui demande d'ajouter beaucoup de circuits pour une utilisation somme toute peu fréquente. Une autre solution, plus adaptée, réinitialise le cache de textures quand on modifie une texture, que ce soit totalement ou partiellement. Une fois le cache vidé, les accès mémoire ultérieurs n'ont pas d'autre choix que d'aller lire la texture en mémoire et de remplir le cache avec les données chargées depuis la RAM. Les données de texture en RAM étant les bonnes, cela garantit l’absence d'erreur.

Ces deux techniques peuvent être adaptées dans le cas où plusieurs caches de textures séparées existent sur une même carte graphique. Les écritures doivent invalider toutes les copies dans tous les caches de texture. Cela nécessite d'ajouter des circuits qui propagent l'invalidation dans tous les autres caches.

Les caches généralistes modifier

La hiérarchie mémoire des GPU modernes ressemble de plus en plus à celle des CPU, avec toute une hiérarchie de caches, avec des caches L1, L2, L3, etc. Pour rappel, les processeurs multicœurs modernes ont pleins de mémoires cache, avec au minimum deux niveaux de cache, le plus souvent trois. Les trois niveaux de cache sont appelés les caches L1, L2 et L3. Pour le premier niveau, on trouve deux caches spécialisés par cœur/processeur : un cache pour les instructions et un cache pour les données. Pour le second niveau, on a un cache L2 par cœur/processeur, qui peut stocker indifféremment données et instructions. Le cache L3 est un cache partagé entre tous les cœurs/processeurs. Les GPU ont une organisation similaire, sauf que le nombre de cœurs est beaucoup plus grand que sur un processeur moderne.

Les caches d'instruction des GPU sont adaptés aux contraintes du rendu 3D. Le principe du rendu 3D est d'appliquer un shader assez simple sur un grand nombre de données, alors que les programmes généralistes effectuent un grand nombre d'instructions sur une quantité limitée de données. La première caractéristique est que les shaders sont des programmes assez légers, qui ont peu d'instructions. Les caches d'instructions L1 sont généralement assez petits, plus que pour les CPU, généralement quelques dizaines ou centaines de kiloctets. Et même malgré cela, il n'est pas rare qu'un shader tienne tout entier dans le cache d'instruction, alors qu'une telle situation serait impensable sur un processeur généraliste. La seconde caractéristique est qu'un même programme s’exécute sur beaucoup de données. Il n'est pas rare que plusieurs processeurs de shaders exécutent le même shader. Aussi, certains GPU partagent un même cache d’instruction entre plusieurs processeurs de shader, comme c'est le cas sur les GPU AMD d'architecture GCN où un cache d'instruction de 32 kB est partagé entre 4 cœurs.

Pour les caches de données, il faut savoir qu'un shader a peu de chances de réutiliser une donnée qu'il a chargé précédemment. Il faut dire que les processeurs de shaders ont beaucoup de registres, et que ceux-ci peuvent contenir un grand nombre de données. Aussi, si accès ultérieur à une donnée il doit y avoir, elle passe généralement par les registres. Cette faible réutilisation fait que les caches de données ne sont pas censé être très utiles. Mais le fait est qu'un shader s’exécute en un grand nombre d'instances, chacune traitant un paquet de données différent. Il est très fréquent que différentes instances s’exécutent chacune sur un même cœur et ces différentes instances tendent à accéder à des données très proches, voire à des données identiques. Si un shader charge une donnée dans le cache, la donnée et ses voisines sont alors disponibles pour les autres instances. Le cas le plus classique est celui de l'accès aux textures : lors du placage de textures, des pixels contiguës accèderont à des texels contiguës. Et outre les textures, les pixels shaders ont tendance à traiter des pixels proches, donc à avoir besoin de données proches en mémoire. Ce qui fait que les caches de données sont quand même utiles.

Dans le même registre, un shader a besoin de certaines informations spécifiques, généralement constantes, pour faire son travail. Toutes les instances du shader manipulent ces données, elles ont besoin de les lire, pour les utiliser lors de l’exécution, les copier dans des registres, etc. Les GPU incorporent des caches de constantes pour accélérer l'accès à ces données. Ainsi, quand un shader lit une donnée, elle est chargée dans le cache de constante, ce qui fait que les futures instances auront accès à celle-ci dans le cache. Ces caches de constante sont séparés des autres caches de données pour une raison bien précise : les constantes en question sont d'accès peu fréquent. Généralement, on a un accès au début de chaque instance de shader, guère plus. Vu que ce sont des données peu fréquemment utilisées, elles sont censée être évincées en priorité du cache de données, qui privilégie les données fréquemment lues/écrites. Avec un cache séparé, on n'a pas ce problème. Au passage, ce cache de constante a des chances d'être partagé entre plusieurs cœurs, des cœurs différents ayant de fortes chances d’exécuter des instances différentes d'un même shader.

Il faut noter que sur la plupart des cartes graphiques modernes, les caches de données et le cache de texture sont un seul et même cache. Même chose pour le cache de sommets, utilisé par les unités géométrique, qui est fusionné avec les caches de données. La raison est que une économie de circuits qui ne coute pas grand chose en termes de performance. Rappelons que les processeurs de shaders sont unifiés à l'heure actuelle, c'est à dire qu'elles peuvent exécuter pixel shader et vertex shader. En théorie, chaque unité de shader devrait incorporer un cache de sommets et un cache de textures. Mais le processeur de shader exécute soit un pixel shader, soit un vertex shader, mais pas les deux en même temps. Donc, autant utiliser un seul cache, qui sert alternativement de cache de vertex et de cache de texture, afin d'économiser des circuits. Une fois les deux fusionnés, on obtient un cache de donnée généraliste, capable de traiter sommets et pixels, voire d'autres données. La seule difficulté tient au filtrage de textures et à sa décompression, mais cela demande juste de router les données lues vers l'unité de texture ou directement vers les registres/unités de calcul, ce qui est facile à faire.

La cohérence des caches sur un GPU modifier

Une carte graphique moderne est, pour simplifier, un gros processeur multicœurs. Dans les grandes lignes, si on omet les circuits spécialisés pour le rendu 3D comme les circuits de la rastérisation ou les unité de textures, c'est une définition parfaite. La différence est que les GPU ont un très grand nombre de cœurs, bien supérieur aux misérables 4 à 16 cœurs d'un CPU. Par contre, cela signifie que les problèmes rencontrés sur les processeurs multicœurs sont aussi présents sur les GPU. Le principal est ce qu'on appelle la cohérence des caches. Pour comprendre à quoi cela fait référence, il faut faire un rappel sur les caches d'un processeur multicœurs.

Un processeur multicœurs dispose de beaucoup de caches. Certains sont des caches dédiés, c'est à dire qu'ils sont reliés à un seul cœur, pas aux autres. D'autres sont des caches partagés entre plusieurs cœurs, voire entre tous les cœurs de la puce. Les GPU contiennent les deux, comme les CPU multicœurs, avec cependant des différences. La règle, valable pour les CPU et GPU, est que les caches de plus haut niveau (L1, parfois L2) sont dédiés à un cœur, alors que les caches de plus bas niveau (L2, L3) sont partagés entre plusieurs cœurs, voire tous. Typiquement, on a des caches L1 dédiés, des caches L2 partagés entre plusieurs cœurs, et un cache L3 partagé entre tous les cœurs. Mais cette règle est parfois violée, notamment sur les GPU. Sur les CPU multicœurs, les caches L1, de données et d'instruction, sont dédiés à un cœur. Et autant c'est le cas sur la plupart des GPU, ont a vu plus haut que certains GPU partagent leur cache L1 d’instructions.

Le fait que certains caches soient dédiés ou partagés entre un nombre limité de cœurs entraine des problèmes. Prenons deux processeurs qui ont chacun une copie d'une donnée dans leur cache. Si un processeur modifie sa copie de la donnée, l'autre ne sera pas mise à jour. L'autre processeur manipule donc une donnée périmée : il n'y a pas cohérence des caches. Pour corriger ce problème, les ingénieurs ont inventé des protocoles de cohérence des caches pour détecter les données périmées et les mettre à jour. Il existe beaucoup de protocoles de cohérence des caches et la plupart utilisent des techniques dites d'espionnage du bus où chaque cache étudie ce qui se passe sur le bus mémoire. Mais autant ces techniques sont faisables avec un nombre limité de cœurs, autant elles sont impraticables avec une centaine de coeurs. Les GPU doivent donc limiter la cohérence des caches à un niveau praticable.

 
Cohérence des caches

En pratique, les caches d'un GPU sont gardés incohérents et aucun protocole de cache de cache n'est utilisé. Et ce n'est pas un problème, car le rendu 3D implique un parallélisme de donnée : des processeurs/cœurs différents sont censés travailler sur des données différentes. Il est donc rare qu'une donnée soit traitée en parallèle par plusieurs cœurs, et donc qu'elle soit copiée dans plusieurs caches. La cohérence des caches est donc un problème bien moins important sur les GPU que sur les CPU. En conséquence, les GPU se contentent d'une cohérence des caches assez light, gérée par le programmeur. Si jamais une opération peut mener à un problème de cohérence des caches, le programmeur doit gérer cette situation de lui-même.

Pour cela, les GPU supportent des instructions machines spécialisées, qui vident les caches. Par vider les caches, on veut dire que leur contenu est rapatrié en mémoire RAM, et qu'ils sont réinitialisé. Les accès mémoire qui suivront l'invalidation trouveront un cache vide, et devront recharger leurs données depuis la RAM. Ainsi, si une lecture/écriture peut mener à un défaut de cohérence problématique, le programmeur doit insérer une instruction pour invalider le cache dans son programme avant de faire l'accès mémoire potentiellement problématique. Ainsi, on garantit que la donnée chargée/écrite est lue depuis la mémoire vidéo, donc qu'il s'agit d'une donnée correcte. Nous avons vu plus haut que c'est cette technique qui est utilisée pour les caches de textures. Ce cas est assez particulier car les textures sont censées être accédée en lecture uniquement, sauf dans de rares cas de techniques de render-to-texture. Aussi, ce modèle d'invalidation du cache au besoin est parfaitement adapté. Les autres caches spécialisés fonctionnent sur le même principe. Même chose pour les caches généralistes, bien que certains GPU modernes commencent à implémenter des méthodes plus élaborées de cohérence des caches.

Les local stores modifier

En plus d'utiliser des caches, les processeurs de la carte graphique utilisent des local stores, des mémoires RAM intermédiaires entre la RAM principale et les caches/registres. Typiquement, chaque processeur de flux possède sa propre mémoire locale. Ces local stores peuvent être vus comme des caches, mais que le programmeur doit gérer manuellement.

 
Local stores d'un GPU.

La faible capacité de ces mémoires, tout du moins comparé à la grande taille de la mémoire vidéo, les rend utile pour stocker temporairement des résultats de calcul "peu imposants". L'utilité principale est donc de réduire le trafic avec la mémoire centrale, les écritures de résultats temporaires étant redirigés vers les local stores. Ils sont surtout utilisé hors du rendu 3D, pour les applications de type GPGPU, où le GPU est utilisé comme architecture multicœurs pour du calcul scientifique.

Les cartes graphiques très récentes fusionnent les local stores avec le cache L1. L'avantage est une économie de transistors assez importante. De plus, cette technologie permet de partitionner le cache/local store suivant les besoins. Par exemple, si la moitié du local store est utilisé, l'autre moitié peut servir de cache L1. Si le local store n'est pas utilisé, comme c'est le cas pour la majorité des rendu 3D, le cache/local store est utilisé intégralement comme cache L1.


Le processeur de commandes

Dans ce chapitre, nous allons parler de tous les intermédiaires qui se placent entre une application de rendu 3D et les circuits de rendu 3D proprement dit. En premier lieu, on trouve le pilote de la carte graphique, aux fonctions multiples et variées. En second lieu, nous allons parler d'un circuit de la carte graphique qui fait l'interface entre le logiciel et les circuits de rendu 3D de la carte graphique : le processeur de commandes. Ce processeur de commandes est un circuit qui sert de chef d'orchestre, qui pilote le fonctionnement global de la carte graphique. API 3D, pilote de carte graphique et processeur de commande travaillent de concert pour que l'application communique avec la carte graphique. Nous allons d'abord voir le pilote de la carte graphique, avant de voir le fonctionnement du processeur de commande.

Le pilote de carte graphique modifier

Le pilote de la carte graphique est un logiciel qui s'occupe de faire l'interface entre le logiciel et la carte graphique. De manière générale, les pilotes de périphérique sont des intermédiaires entre les applications/APIs et le matériel. 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é. À la place, le système d'exploitation ne gère pas directement certains périphériques, mais fournit de quoi ajouter ce qui manque. 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. Pour une carte graphique, il a diverses fonctions que nous allons voir ci-dessous.

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 (de quoi afficher l’interface de base du système d'exploitation, comme le bureau de Windows). Et même pour du rendu 2D, la plupart des fonctionnalités de la carte graphique ne sont pas disponibles. Par exemple, certaines résolutions ne sont pas disponibles, notamment les grandes résolutions. Ou encore, 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.

La gestion des interruptions modifier

Une fonction particulièrement importante du pilote de carte graphique est la réponse aux interruptions lancées par la carte graphique. Pour rappel, les interruptions sont une fonctionnalité qui permet à un périphérique de communiquer avec le processeur, tout en économisant le temps de calcul de ce dernier. Typiquement, lorsqu'un périphérique lance une interruption, le processeur stoppe son travail et exécute automatiquement une routine d'interruption, un petit programme qui s'occupe de gérer le périphérique, de faire ce qu'il faut, ce que l'interruption a demandé. Il y a plusieurs interruptions possibles, chacune ayant son propre numéro et sa fonction dédiée, chacune ayant sa propre routine d'interruption. Après tout, la routine qui gère un débordement de la mémoire vidéo n'est pas la même que celle qui gère un changement de fréquence du GPU ou la fin du calcul d'une image 3D. Le pilote fournit l'ensemble des routines d'interruptions nécessaires pour gérer la carte graphique.

La compilation des shaders modifier

Le pilote de carte graphique est aussi chargé de traduire les shaders en code machine. 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, mais ce n'est pas ce code source qui est compilé par le pilote de carte graphique. À la place, les shaders sont pré-compilés vers un langage dit intermédiaire, avant d'être compilé par le pilote en code machine. 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, non pas par l'utilisateur. Par exemple, les shaders d'un jeu vidéo sont fournis déjà pré-compilés : les fichiers du jeu ne contiennent pas le code source GLSL/HLSL, mais du code intermédiaire.

L'avantage de cette méthode est que le travail du pilote est fortement simplifié. Le pilote de périphérique pourrait compiler directement du code HLSL/GLSL, mais le temps de compilation serait très long et cela aurait un impact sur les performances. Avec l'usage du langage intermédiaire, le gros du travail à été fait lors de la première passe de compilation et le pilote graphique ne fait que finir le travail. Les optimisations importantes ont déjà été réalisées lors de la première passe. Il doit bien faire la traduction, ajouter quelques optimisations de bas niveau par-ci par-là, mais rien de bien gourmand en processeur. 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. Par exemple, les cartes graphiques récentes n'ont pas de circuits fixes pour traiter la géométrie. Autant les anciennes cartes graphiques avaient des circuits de T&L qui s'en occupaient, autant tout cela doit être émulé sur les machines récentes. 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, présents dans le pilote de carte graphique.

L'arbitrage de l'accès à la carte graphique modifier

Il n'est pas rare que plusieurs applications souhaitent accéder en même temps à la carte graphique. Imaginons que vous regardez une vidéo en streaming sur votre navigateur web, avec un programme de cloud computing de type Folding@Home qui tourne en arrière-plan, sur Windows. Le décodage de la vidéo est réalisé par la carte graphique, Windows s'occupe de l'affichage du bureau et des fenêtres, le navigateur web doit afficher tout ce qui est autour de la vidéo (la page web), le programme de cloud computing va lancer ses calculs sur la carte graphique, etc. Des situations de ce genre sont assez courantes et c'est le pilote qui s'en charge.

Autrefois, de telles situations étaient gérées simplement. Chaque programme avait accès à la carte graphique durant quelques dizaines ou centaines de millisecondes, à tour de rôle. Si le programme finissait son travail en moins de temps que la durée impartie, il laissait la main au programme suivant. S’il atteignait la durée maximale allouée, il était interrompu pour laisser la place au programme suivant. Et chaque programme avait droit d'accès à la carte graphique chacun à son tour. Un tel algorithme en tourniquet est très simple, mais avait cependant quelques défauts. De nos jours, les algorithmes d'ordonnancement d'accès sont plus élaborés, bien qu'il soit difficile de trouver de la littérature ou des brevets sur le sujet.

Les autres fonctions modifier

Le pilote a aussi bien d'autres fonctions. Par exemple, il s'occupe d'initialiser la carte graphique, de fixer la résolution, d'allouer la mémoire vidéo, de gérer le curseur de souris matériel, etc.

Le processeur de commandes modifier

Tous les traitements que la carte graphique doit effectuer, qu'il s'agisse de rendu 2D, de calculs 2D, du décodage matérielle d'un flux vidéo, ou de calculs généralistes, sont envoyés par le pilote de la carte graphique, sous la forme de commandes. Ces commandes demandent à la carte graphique d'effectuer une opération 2D, ou une opération 3D, ou encore le rendu d'une vidéo.

Les commandes graphiques en question varient beaucoup selon la carte graphique. Les commandes sont régulièrement revues et chaque nouvelle architecture a quelques changements par rapport aux modèles plus anciens. Des commandes peuvent apparaître, d'autres disparaître, d'autre voient leur fonctionnement légèrement altéré, etc.

Mais dans les grandes lignes, on peut classer ces commandes en quelques grands types. Les commandes les plus importantes s'occupent de l'affichage 3D : afficher une image à partir de paquets de sommets, préparer le passage d'une image à une autre, changer la résolution, etc. Plus rare, d'autres commandes servent à faire du rendu 2D : afficher un polygone, tracer une ligne, coloriser une surface, etc. Sur les cartes graphiques qui peuvent accélérer le rendu des vidéos, il existe des commandes spécialisées pour l’accélération des vidéos.

Pour donner quelques exemples, prenons les commandes 2D de la carte graphique AMD Radeon X1800.

Commandes 2D Fonction
PAINT Peindre un rectangle d'une certaine couleur
PAINT_MULTI Peindre des rectangles (pas les mêmes paramètres que PAINT)
BITBLT Copie d'un bloc de mémoire dans un autre
BITBLT_MULTI Plusieurs copies de blocs de mémoire dans d'autres
TRANS_BITBLT Copie de blocs de mémoire avec un masque
NEXTCHAR Afficher un caractère avec une certaine couleur
HOSTDATA_BLT Écrire une chaîne de caractères à l'écran ou copier une série d'image bitmap dans la mémoire vidéo
POLYLINE Afficher des lignes reliées entre elles
POLYSCANLINES Afficher des lignes
PLY_NEXTSCAN Afficher plusieurs lignes simples
SET_SCISSORS Utiliser des coupes (ciseaux)
LOAD_PALETTE Charger la palette pour affichage 2D

Le tampon de commandes modifier

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 tant que la carte graphique est occupée. Les pilotes de la carte graphique vont les mettre en attente dans une file (une zone de mémoire dans laquelle on stocke des données dans l'ordre d'ajout) : le tampon de commandes.

Ce tampon de commandes est ce qu'on appelle une file, une zone de mémoire dans laquelle on stocke des données dans l'ordre d'ajout. Si le tampon de commandes est plein, le driver n'accepte plus de demandes en provenance des applications. Un tampon de commandes plein 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 le tampon 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.

Le processeur de commandes modifier

Le processeur de commande est un circuit qui gère les commandes envoyées par le processeur. Il lit la commande la plus ancienne dans le tampon de commande, l’interprète et l’exécute, et recommence ainsi de suite. Le processeur de commande est chargé de piloter les circuits de la carte graphique. Un point important est qu'il répartit le travail entre les différents circuits de la carte graphique et assure que l'ordre du pipeline est respecté. En soi, le processeur de commande est un circuit assez compliqué. Sur les cartes graphiques anciennes, c'était un circuit séquentiel complexe, fabriqué à la main et était la partie la plus complexe du processeur graphique. Sur les cartes graphiques modernes, c'est un véritable microcontrôleur, avec un processeur, de la mémoire RAM, etc.

Le processeur de commandes récupère les commandes dans le tampon de commande, en mémoire RAM, pour les recopier dans la mémoire vidéo et/ou une mémoire interne. Cette copie se fait via la technologie DMA, une technologie de transfert de données entre mémoire RAM et périphérique qui n'utilise pas le processeur principal. Une fois la copie faite, le processeur de commande décode la commande et l’exécute sur la carte graphique. Il garde en mémoire l'état de traitement de chaque commande : est-ce qu'elle est en train de s’exécuter sur le processeur graphique, est-ce qu'elle est mise en pause, est-ce qu'elle attend une donnée en provenance de la mémoire, est-ce qu'elle est terminée, etc. De plus, le processeur de commande peut communiquer avec le processeur via ce qu'on appelle des interruptions (les mêmes interruptions qui permettent à un périphérique d'interrompre le processeur pour exécuter une routine de traitement). Cela sert pour signaler qu'une commande s'est terminée ou a échouée, mais ce n'est pas la seule utilité de ce mécanisme.

La fonction principale, sur les cartes modernes, est de répartir le travail entre les différents circuits. Le processeur de commande décide à quel processeur ou circuit doit être envoyé une commande. Sa tâche est compliquée par le fait que les cartes graphiques actuelles dupliquent leurs unités pour pouvoir faire beaucoup de calculs en parallèle. Elles ont plusieurs unités de traitement des sommets et des pixels, plusieurs ROP, plusieurs unités de textures, etc. Et c'est en partie le travail du command buffer de répartir les calculs sur ces différentes unités. Dans le cas le plus simple, les unités sont alimentées en vertex/pixels les unes après les autres (principe du round-robin), mais des stratégies plus optimisées sont la règle de nos jours. Cela est très important sur les cartes graphiques : répartir plusieurs commandes sur plusieurs processeurs est une tâche difficile.

Et cette répartition est d'autant plus complexe que les cartes graphiques gèrent plusieurs commandes simultanées. Par plusieurs commandes simultanées, on veut dire qu'une carte graphique supporte plusieurs commandes qui en sont à des étapes différentes du pipeline. On peut avoir une commande qui est en cours dans l'étage de gestion de la géométrie, une autre dans le rastériseur, et une autre dans les unités de traitement des pixels. Le fait que les étapes du pipeline graphique sont à effectuer dans un ordre bien précis permet ce genre de chose assez facilement.

Parallélisme et synchronisation avec le CPU modifier

Sur les cartes graphiques modernes, le processeur de commandes peut démarrer une commande avant que les précédentes soient terminées. Il est même possible que la carte graphique puisse exécuter plusieurs commandes en même temps, dans des circuits séparés. Par exemple, il est possible d’exécuter une commande ne requérant que des calculs, en même temps qu'une commande qui ne fait que faire des copies en mémoire : les deux commandes utilisent des circuits différents. En soi, exécuter plusieurs commandes en même temps permet un gain de performances et une meilleure utilisation du processeur graphique. Si une commande n'utilise que 70% du processeur graphique, alors on peut remplir les 30% restants avec une seconde commande. Évidemment, le processeur de commande doit être modifié pour permettre ce genre d'optimisation : il doit gérer plusieurs commandes en exécution, gérer plusieurs tampons de commandes, etc. De plus, cette parallélisation du processeur de commandes a un désavantage : celui-ci doit gérer les synchronisations entre commandes.

Avec un processeur de commande gérant le parallélisme, celui-ci doit gérer les synchronisations entre commandes. Par exemple, imaginons que Direct X décide d'allouer et de libérer de la mémoire vidéo. Direct X et Open GL ne savent pas quand le rendu de l'image précédente se termine. Comment éviter d'enlever une texture tant que les commandes qui l'utilisent ne sont pas terminées ? Ce problème ne se limite pas aux textures, mais vaut pour tout ce qui est placé en mémoire vidéo.

De manière générale, Direct X et Open GL doivent savoir quand une commande se termine. Un moyen pour éviter tout problème serait d'intégrer les données nécessaires à l'exécution d'une commande dans celle-ci : par exemple, on pourrait copier les textures nécessaires dans chacune des commandes. Mais cela gâche de la mémoire, et ralentit le rendu à cause des copies de textures. Les cartes graphiques récentes incorporent des commandes de synchronisation : les barrières (fences). Ces barrières vont empêcher le démarrage d'une nouvelle commande tant que la carte graphique n'a pas fini de traiter toutes les commandes qui précèdent la barrière. Pour gérer ces barrières, le tampon de commandes contient des registres, qui permettent au processeur de savoir où la carte graphique en est dans l’exécution de la commande.

Un autre problème provient du fait que les commandes se partagent souvent des données, et que de nombreuses commandes différentes peuvent s'exécuter en même temps. Or, si une commande veut modifier les données utilisées par une autre commande, il faut que l'ordre des commandes soit maintenu : la commande la plus récente ne doit pas modifier les données utilisées par une commande plus ancienne. Pour éviter cela, les cartes graphiques ont introduit des instructions de sémaphore, qui permettent à une commande de bloquer tant qu'une ressource (une texture) est utilisée par une autre commande.

Commandes de synchronisation Fonction
NOP Ne rien faire
WAIT_SEMAPHORE Attendre la synchronisation avec un sémaphore
WAIT_MEM Attendre que la mémoire vidéo soit disponible et inoccupée par le CPU

L'ordonnancement et la gestion des ressources modifier

Une commande utilise divers circuits de la carte 3D, de la mémoire vidéo, et d'autres ressources au sens large : des processeurs de shader, notamment. C'est le processeur de commande qui s'occupe de la gestion des ressources et qui attribue telle ressource à telle commande. A chaque instant, le processeur de commande répartit les shaders sur les processeurs de shaders, en faisant en sorte qu'ils soient utilisés le plus possible. Il envoie aussi certaines commandes aux circuits fixes, comme l’input assembler. Là encore, il faut en sorte que les circuits fixes soient utilisés le plus possible et évite au maximum les situations où ces circuits n'ont rien à faire. C'est là un problème assez compliqué pour le processeur de commande et ce pour tout un tas de raisons que nous ne ferons que survoler. Les contraintes d'ordonnancement de l'API et les règles de celle-ci sont une partie de la réponse. Mais d'autres raisons sont intrinsèques au rendu 3D ou à la conception des circuits.

Le processeur de commande doit souvent mettre en pause certains circuits du pipeline, ainsi que les circuits précédents. Si tous les processeurs de vertex shader sont occupés, l’input assembler ne peut pas charger le paquet suivant car il n'y a aucun processeur de libre pour l’accueillir. Dans ce cas, l'étape d’input assembly est mise en pause en attendant qu'un processeur de shader soit libre. La même chose a lieu pour l'étape de rastérisation : si aucun processeur de shader n'est libre, elle est mise en pause. Sauf que pour la rastérisation, cela a des conséquences sur les circuits précédents la rastérisation. Si un processeur de shader veut envoyer quelque chose au rastériseur alors qu'il est en pause, il devra lui aussi attendre que le rastériseur soit libre. On a la même chose quand les ROP sont occupés : les pixel shader ou l'unité de texture doivent parfois être mis en pause, ce qui peut entrainer la mise en pause des étapes précédentes, etc. En clair : plus un étape est situé vers la fin du pipeline graphique, plus sa mise en pause a de chances de se répercuter sur les étapes précédentes et de les mettre en pause aussi. Le processeur de commande doit gérer ces situations.

Dans les cartes graphiques les plus rudimentaires, le processeur de commande exécute une commande çà la fois et attend qu'une commande soit terminé pour en lancer une autre. Mais les processeurs de commande actuels sont capables d’exécuter plusieurs commandes en même temps. Mais c'est là un défi compliqué à relever pour le processeur de commande, qui donne lieu à son lot de problèmes. Le problème principal est le partage de ressources entre commandes. Par exemple, on peut prendre le cas où une commande n'utilise pas beaucoup les processeurs de shaders. On pourrait imaginer lancer une seconde commande en parallèle, afin que la seconde commande utiliser les processeurs de shaders inutilisés. Mais cela n'est possible que si les circuits fixes sont capables d'accepter une seconde commande. Si la première commande sature les circuits fixe, le lancement de la seconde commande sera empêché. Mais si ce n'est pas le cas, les deux commandes pourront être lancées en même temps. Pareil pour le débit mémoire, qui doit alors être partagé entre deux commandes simultanées, ce qui est à prendre en compte.


Les unités de gestion de la géométrie

Nous allons maintenant voir les circuits chargés de gérer la géométrie. Il existe deux grands types de circuits chargés de traiter la géométrie : l'input assembler charge les vertices depuis la mémoire vidéo, et les circuits de traitement de vertices les traitent. Ceux-ci effectuent plusieurs traitements, qui peuvent être synthétisés en plusieurs grandes étapes.

  • L'étape de chargement des sommets/triangles, qui sont lus depuis la mémoire vidéo et injectés dans le pipeline graphique.
  • L'étape de transformation effectue deux changements de coordonnées pour chaque sommet. Premièrement, elle place les objets au bon endroit dans la scène 3D, ce qui demande de mettre à jour les coordonnées de chaque sommet de chaque modèle. C'est la première étape de calcul : l'étape de transformation des modèles 3D. Ensuite, elle effectue un changement de coordonnées pour centrer l'univers sur la caméra, dans la direction du regard. C'est l'étape de transformation de la caméra.
  • La phase d’éclairage (en anglais lighting) attribue une couleur à chaque sommet, qui définit son niveau de luminosité : est-ce que le sommet est fortement éclairé ou est-il dans l'ombre ?

Nous avons déjà vu l'étape d'éclairage dans un chapitre précédent, aussi nous ne reviendrons pas dessus.

L'input assembler modifier

L'input assembler charge les informations géométriques, présentes dans en mémoire vidéo, dans les unités de traitement des sommets. C'est une unité d'accès mémoire un peu particulière, mais 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. Pour faire son travail, il a besoin d'informations mémorisées dans des registres, à savoir 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).

 
Input assembler

Avant leur traitement, les objets géométriques présents dans la scène 3D sont mémorisés dans la mémoire vidéo, sous une forme plus ou moins structurée. Rappelons que les objets 3D sont représentés comme un assemblage de triangles collés les uns aux autres, l'ensemble formant un maillage. La position d'un triangle est déterminée par la position de chacun de ses sommets. Avec trois coordonnées x, y et z pour un sommet, un triangle demande donc 9 cordonnées. De plus, il faut ajouter des informations sur la manière dont les sommets sont reliés entre eux, quel sommet est relié à quel autre, comment les arêtes sont connectées, etc. Toutes ces informations sont stockées dans un tableau en mémoire vidéo : le tampon de sommets.

 
Cube en 3D

Le contenu du tampon de sommet dépend de la représentation utilisée. Il y a plusieurs manières de structure les informations dans le tampon de sommet, qui ont des avantages et inconvénients divers. Toutes ces représentations cherchent à résoudre un problème bien précis : comment indiquer comment les sommets doivent être reliés entre triangles. Le point crucial est qu'un sommet est très souvent partagé par plusieurs triangles. Par exemple, prenez le 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. Pour éviter ce gâchis, les concepteurs d'API et de cartes graphiques ont inventé des représentations pour les maillages, qui visent à limiter la consommation de mémoire ou faciliter la traversée du tampon de sommet.

Les techniques anciennes : Triangle strip et Triangle fan modifier

Pour gérer le partage des sommets entre triangles, la représentation la plus simple est appelée le maillage sommet-sommet (Vertex-Vertex Meshes). L'idée est que chaque sommet précise, en plus de ses trois coordonnées, quels sont les autres sommets auxquels il est relié. Les sommets sont regroupés dans un tableau, et les autres sommets sont identifiés par leur position dans le tableau, leur indice. Les informations sur les triangles sont implicites et doivent être reconstruites à partir des informations présentes dans le tampon de sommets. Autant dire que niveau praticité et utilisation de la puissance de calcul, cette technique est peu efficace. Par contre, le tampon de sommet a l'avantage, avec cette technique, d'utiliser peu de mémoire. Les informations sur les arêtes et triangles étant implicites, elles ne sont pas mémorisées, ce qui économise de la place.

 
Vertex-Vertex Meshes (VV)

Dans la représentation précédente, les arêtes sont présentes plus ou moins directement dans le tampon de sommets. Mais il existe des méthodes pour que les informations sur les arêtes soient codées de manière implicite. L'idée est que deux sommets consécutifs dans le tampon de sommet soient reliés par une arête. Ainsi, les informations sur les arêtes n'ont plus à être codées dans le tampon de sommet, mais sont implicitement contenues dans l'ordre des sommets. Ces représentations sont appelées des Corner-tables. Dans le domaine du rendu 3D, deux techniques de ce genre ont été utilisées : la technique du triangle fans et celle des triangle strips.

La technique des triangles strip permet d'optimiser le rendu de triangles placés en série, qui ont une arête et deux sommets en commun. L'optimisation consiste à ne stocker complètement que le premier triangle le plus à gauche, les autres triangles étant codés avec un seul sommet. Ce sommet est combiné avec les deux derniers sommets chargés par l'input assembler pour former un triangle. Pour gérer ces triangles strips, l'input assembler doit mémoriser dans un registre les deux derniers sommets utilisées. En mémoire, le gain est énorme : au lieu de trois sommets pour chaque triangle, on se retrouve avec un sommet pour chaque triangle, sauf le premier de la surface.

 
Triangle strip

La technique des triangles fan fonctionne comme pour le triangle strip, sauf que le sommet n'est pas combiné avec les deux sommets précédents. Supposons que je crée un premier triangle avec les sommets v1, v2, v3. Avec la technique des triangles strips, les deux sommets réutilisés auraient été les sommets v2 et v3. Avec les triangles fans, les sommets réutilisés sont les sommets v1 et v3. Les triangles fans sont utiles pour créer des figures comme des cercles, des halos de lumière, etc.

 
Triangle fan

Le tampon d'indices modifier

Enfin, nous arrivons à la dernière technique, qui permet de limiter l'empreinte mémoire tout en facilitant la manipulation de la géométrie. Cette technique est appelée la représentation face-sommet. Elle consiste à stocker les informations sur les triangles et sur les sommets séparément. Le tampon de sommet contient juste les coordonnées des sommets, mais ne dit rien sur la manière dont ils sont reliés. Les informations sur les triangles sont quant à elles mémorisées dans un tableau séparé appelé le tampon d'indices. Ce dernier n'est rien de plus qu'une liste de triangles.

Dit comme cela, on ne voit pas vraiment où se trouve le gain en mémoire. On se retrouve avec deux tableaux : un pour les indices, un pour les vertices. Mais l'astuce tient au codage des données dans le tampon d'indices. Dans le tampon d'indices, un sommet n'est pas codé par ses trois coordonnées. Les sommets étant partagés entre plusieurs triangles, il y aurait beaucoup de redondance avec cette méthode. Pour un sommet partagé entre N triangles, on aurait N copies du sommet, une par triangle. Pour éviter cela, chaque sommet est codé par un indice, un numéro qui indique la position du sommet dans le tampon de sommet. Avec la technique du tampon d'indice, les coordonnées sont codées en un seul exemplaire, mais le tampon d'indice contiendra N exemplaires de l'indice. L'astuce est qu'un indice prend moins de place qu'un sommet : entre un indice et trois coordonnées, le choix est vite fait. Et entre 7 exemplaires d'un sommet, et 7 exemplaires d'un indice et un sommet associé, le gain en mémoire est du côté de la solution à base d'index.

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.
 
Représentation face-sommet.

Avec un tampon d'indices, un sommet peut être chargé plusieurs fois depuis la mémoire vidéo. Pour exploiter cette propriété, les cartes graphiques intercalent une mémoire cache pour mémoriser les sommets déjà chargés : le cache de sommets. Chaque sommet est stocké dans ce cache avec son indice en guise de Tag. Pour profiter le plus possible de ce cache, les concepteurs de jeux vidéo peuvent changer l'ordre des sommets en mémoire. Sur les cartes graphiques assez anciennes, ce cache est souvent très petit, à peine 30 à 50 sommets. Et c'était de plus un cache très simple, allant d'une simple mémoire FIFO à des caches basiques (pas de politique de remplacement complexe, caches directement adressé, ...). Notons que ce cache a cependant été fortement modifié depuis que les unités de vertex shader ont été fusionnées avec les unités de pixel shaders. Un tel cache se mariait bien avec des unités géométriques séparées des circuits de gestion des pixels, en raison de sa spécialisation.

 
Cache de sommets.

L'étape de transformation modifier

L'étape de transformation-projection regroupe plusieurs manipulations différentes, mais qui ont pour point commun de demander des changements de repères. Par changement de repères, on veut dire que l'on passe d'un système de coordonnées à un autre. Par exemple, une manipulation va passer d'un système de coordonnées centré sur le milieu de la scène 3D à un système de coordonnées centrées sur la caméra. En tout, il existe trois changements de repères distincts qui sont regroupés dans l'étape de transformation : un premier qui place chaque objet 3D dans la scène 3D, un autre qui centre la scène du point de vue de la caméra, et un autre qui corrige la perspective.

Les trois étapes de changement de repère modifier

Un modèle 3D est représentée par un ensemble de sommets, qui sont reliés pour former sa surface. Les données du modèle 3D indiquent, pour chaque sommet, sa position par rapport au centre de l'objet qui a les coordonnées (0, 0, 0). Mais le modèle 3D est placé à un endroit bien précis dans une scène 3D, endroit qui a une position de coordonnées (X, Y, Z) déterminées par le moteur physique. Une fois placé dans la scène 3D, le centre de l'objet passe donc des coordonnées (0, 0, 0) aux coordonnées (X, Y, Z) et tous les sommets de l'objet doivent être mis à jour. De plus, l'objet a une certaine orientation : il faut aussi le faire tourner. Enfin, l'objet peut aussi subir une mise à l'échelle : on peut le gonfler ou le faire rapetisser, du moment que cela ne modifie pas sa forme, mais simplement sa taille. En clair, le modèle 3D subit une translation, une rotation et une mise à l'échelle.

Une fois le placement des différents objets effectué, la carte graphique effectue un dernier changement de coordonnées. Au lieu de considérer un des bords de la scène 3D comme étant le point de coordonnées (0, 0, 0), il va passer dans le référentiel de la caméra. Après cette transformation, le point de coordonnées (0, 0, 0) sera la caméra. La direction de la vue du joueur sera alignée avec l'axe de la profondeur (l'axe Z).

 
Étape de transformation dans un environnement en deux dimensions : avant et après. On voit que l'on centre le monde sur la position de la caméra et dans sa direction.

Enfin, il faut aussi corriger la perspective, ce qui est le fait de l'étape de projection, qui modifie la forme du view frustum sans en modifier le contenu. Différents types de perspective existent et celles-ci ont un impact différent les unes des autres sur le view frustum. Dans le cas qui nous intéresse, le view frustum passe d’une forme de trapèze tridimensionnel à une forme de pavé dont l'écran est une des faces.

Les changements de coordonnées se font via des multiplications de matrices modifier

Chaque étape demande de faire des changements de coordonnées, chaque sommet voyant ses coordonnées altérées et remplacées par de nouvelles. Or, un changement de coordonnée s'effectue assez simplement, avec des matrices, à savoir des tableaux organisés en lignes et en colonnes avec un nombre dans chaque case. un changement de coordonnées se fait simplement en multipliant le vecteur (X, Y, Z) des coordonnées d'un sommet par une matrice adéquate. Il existe des matrices pour la translation, la mise à l'échelle, d'autres pour la rotation, une autre pour la transformation de la caméra, une autre pour l'étape de projection, etc.

Un petit problème est que les matrices qui le permettent sont des matrices avec 4 lignes et 4 colonnes, et que la multiplication demande que le nombre de coordonnées du vecteur soit égal au nombre de colonnes. Pour résoudre ce petit problème, on ajoute une 4éme coordonnée aux sommets, la coordonnée homogène, qui ne sert à rien, et est souvent mise à 1, par défaut.

Les cartes graphiques contiennent un ou plusieurs circuits de multiplication de matrices spécialisés dans l'étape de transformation. Chacun de ces circuits prend un sommet et renvoie le sommet transformé. Ils sont composés d'un gros paquet de multiplieurs et d'additionneurs flottants. Pour plus d'efficacité, les cartes graphiques comportent plusieurs de ces circuits, afin de pouvoir traiter plusieurs sommets en même temps. Il s'agit d'unités de calculs fixes, non-programmables. Elles sont configurables, car on peut préciser les matrices nécessaires pour faire les calculs, mais guère plus. Elles prennent en entrée les données provenant de l'input assembler, ainsi que les matrices nécessaires, et fournissent en sortie les coordonnées des sommets transformés.

Les vertex shaders : des exemple du processeur de shaders modifier

Il y a peu de généralités spécifiques pour les processeurs de vertex shaders, tout a déjà été dit dans le chapitre sur les processeurs de shaders. Tout au plus peut on dire que les cartes graphiques avaient autrefois des processeurs spécialisés dans l’exécution des vertex shaders, distincts des processeurs pour les pixel shaders. Mais cette époque est révolue avec les cartes graphiques actuelles. Par contre, on peut étudier un exemple de processeur de vertex shader d’antan.

Le jeu d’instruction du GPU de la Geforce 3 modifier

La première carte graphique commerciale destinée aux gamers à disposer d'une unité de vertex programmable est la Geforce 3. Celui-ci respectait le format de vertex shader 1.1. L'ensemble des informations à savoir sur cette unité est disponible dans l'article "A user programmable vertex engine", disponible sur le net. Le processeur de cette carte était capable de gérer un seul type de données : les nombres flottants de norme IEEE754. Toutes les informations concernant la coordonnée d'une vertice, voire ses différentes couleurs, doivent être encodées en utilisant ces flottants.

Le processeur de la Geforce 3 est capable d’exécuter 17 instructions différentes, dont voici les principales :

OpCode Nom Description
Opérations mémoire
MOV Move vector -> vector
ARL Address register load miscellaneous
Opérations arithmétiques
ADD Add vector -> vector
MUL Multiply vector -> vector
MAD Multiply and add vector -> vector
MIN Minimum vector -> vector
MAX Maximum vector -> vector
SLT Set on less than vector -> vector
SGE Set on greater or equal vector -> vector
LOG Log base 2 miscellaneous
EXP Exp base 2 miscellaneous
RCP Reciprocal scalar-> replicated scalar
RSQ Reciprocal square root scalar-> replicated scalar
Opérations trigonométriques
DP3 3 term dot product vector-> replicated scalar
DP4 4 term dot product vector-> replicated scalar
DST Distance vector -> vector
Opérations d'éclairage géométrique
LIT Phong lighting miscellaneous

L'instruction la plus intéressante est clairement la dernière : elle applique l'algorithme d'illumination de Phong sur un sommet. Les autres instructions permettent d'implémenter un autre algorithme si besoin, mais l'algo de Phong est déjà là à la base.

Les autres instructions sont surtout des instructions arithmétiques : multiplications, additions, exponentielles, logarithmes, racines carrées, etc. Pour les instructions d'accès à la mémoire, on trouve une instruction MOV qui déplace le contenu d'un registre dans un autre et une instruction de calcul d'adresse, mais aucune instruction d'accès à la mémoire sur le processeur de la Geforce 3. Plus tard, les unités de vertex shader ont acquis la possibilité de lire des données dans une texture.

On remarque que la division est absente. Il faut dire que la contrainte qui veut que toutes ces instructions s’exécutent en un cycle d'horloge pose quelques problèmes avec la division, qui est une opération plutôt lourde en hardware. À la place, on trouve l'instruction RCP, capable de calculer 1/x, avec x un flottant. Cela permet ainsi de simuler une division : pour obtenir Y/X, il suffit de calculer 1/X avec RCP, et de multiplier le résultat par Y.

L'unité de calcul de la Geforce 6800 modifier

Maintenant, passons à l'architecture des processeurs de vertex de la Geforce 6800. Le jeu d'instruction a certes changé depuis la Geforce 3, mais nous n'allons pas refaire une revue complète du jeu d'instruction. A la place, nous allons dire ce qui a changé et comment les registres étaient organisés. L'architecture globale de ce processeur est indiquée ci-dessous.

 
GeForce 6800 Vertex processor.

Comme on peut le voir, le processeur dispose de beaucoup de registres : des registres d'entrée, qui réceptionnent les sommets lus par l'input assembler, des registres de sortie, dans lesquelles le processeur stocke les sommets transformés et éclairés, des registres de constantes qui servent à stocker des constantes, mais aussi des registres généraux/temporaires pour les résultats intermédiaires des calculs. Le shader à exécuter est mémorisé dans une instruction RAM, une sorte de mémoire locale spécialisée dans le stockage du shader proprement dit, du stockage des instructions à exécuter.

L'unité de calcul de ce processeur est découpée en plusieurs circuits séparés. Premièrement, on voit une unité de calcul capables de réaliser une multiplication et une addition. Ensuite, on voit une unité pour les fonctions arithmétiques spéciales, comme les logarithmes, exponentielles ou les calculs trigonométriques, les produits scalaires et autres. Enfin, on voit une unité d'accès aux textures, ce qui veut dire que le vertex shader a la possibilité de lire des textures en mémoire vidéo, ce qui facilite l'implémentation de certaines techniques de rendu. On remarque aussi la présence d'un cache de texture intégré dans le processeur de vertex shader.


Les unités de gestion des primitives

À ce stade, les sommets ont été éclairés et transformés par les unités de traitement géométriques. Vient alors l'étape de gestion des primitives, durant laquelle les sommets sont assemblés en primitives, qui sont elle-mêmes éventuellement altérés. Avant toute chose, il faut rappeler ce que le terme "primitive" veut dire dans le contexte du rendu 3D. 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. De nos jours, la quasi-totalité des jeux vidéos utilisent des triangles, mais il a existé des jeux vidéo ou des cartes graphiques qui acceptaient d'autres primitives, comme des carrés ou d'autres polygones. 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. Le regroupement des sommets en primitives s'explique par le fait que l'étape suivante du pipeline graphique, l'étape de rastérisation, associe des primitives et des pixels. On ne peut pas faire de la rastérisation avec des sommets isolés.

L'assemblage de primitives modifier

La première étape est celle de l'assemblage de primitives aussi appelée primitive assembly. En sortie de l'étage précédent, on n'a que des sommets éclairés et colorisés, pas des primitives. Les sommets qui sortent de l'étape de Transformation-éclairage ne sont pas dans l'ordre : deux sommets qui en sortent à la suite ne sont pas forcément dans le même triangle. Pour recréer des primitives, 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, 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.

Formellement, les primitives sont déjà prises en compte dans l'étape d'input assembler. Toute carte graphique gére au minimum les primitives de l'API 3D qu'elle implémente. Par exemple, une application compatible avec Direct 3D 1.0 doit implémenter les primitives supportées par Direct X 1.0. Et les primitives en question sont nombreuses. N'allez pas croire que les cartes graphiques, anciennes ou modernes, ne gèrent que des triangles ou rien. Elles gèrent au minimum les points, les lignes et les triangles. Les anciennes API géraient aussi les quads, à savoir des rectangles ou carrés. En outre, il faut aussi gérer les triangle-strip et triangle-fan vu dans le chapitre précédent. L'étape d'assemblage de primitive doit gérer de primitives. Elle eut même gérér l'équivalent mais pour les lignes ou les quads. Pour information, voici les primitives gérées par les premières versions d'OpenGL :

 
Primitives supportées par OpenGL.

Le rendu avec des quads est aujourd’hui tombé en désuétude, mais il est parfois supporté par les cartes graphiques actuelles, bien que ce soit souvent par émulation (un quad est rendu avec deux triangles). Aujourd'hui, OpenGL et DirectX ne gèrent plus les quads, et ne vont pas au-delà des triangles.

Le tampon de primitives modifier

L'étape d'assemblage de primitives est suivie par un tampon de primitives, dans lequel les primitives sont accumulées avant d'entrer dans le rastériseur.

 
Carte graphique en rendu immédiat

Le contenu du tampon de primitive varie suivant la carte graphique, mais il y a deux possibilités principales. La première est simplement un paquet de sommets avec un petit tampon d'indices associé. L'autre est simplement un paquet de sommets, avec des sommets dupliqués s'ils sont partagés par plusieurs primitives. La première solution fait un meilleur usage de la mémoire du tampon de primitive, l'autre est plus simple et plus rapide, plus simple d'utilisation pour le rastériseur.

Les geometry shaders modifier

A la suite de l'assemblage des primitives, on trouve deux étapes optionnelles, implémentée avec des shaders. La première étape est réalisée par les geometry shaders, alors que la seconde étape qui s'occupe de la tesselation est le fait de plusieurs types de shaders différents. 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.

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). Les geometry shaders sont assez limités, ce qui fait qu'ils sont assez peu utilisés. 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, comme on le verra plus bas, mais leurs limitations font que ce n'est pas pratique.

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

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. C'est assez intuitif : ils manipulent des primitives qui sont fournies par l'étape d'assemblage des primitives, donc ils sont placés après. 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

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.

 
Stream output

Notons que le stream output fournit un flux de primitives, pas de sommets. 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.

La tessellation modifier

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

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. 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 et 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 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. En fait, DirectX 11 rajoute une étape programmable avant le découpage des triangles par l'unité matérielle configurable.



Le rasterizeur

 
Illustration du principe de la rasterization. La surface correspondant à l'écran est subdivisée en pixels carrés, de coordonnées x et y. La caméra est placée au point e. Pour chaque pixel, on trace une droite qui part de la caméra et qui passe par le pixel considéré. L'intersection entre une surface et cette droite se fait en un point, appartenant à un triangle.

À ce stade du pipeline, les sommets ont été regroupés en primitives. Vient alors l'étape de rasterization, durant laquelle chaque pixel de l'écran se voit attribuer un ou plusieurs triangle(s). Cela signifie que sur le pixel en question, c'est le triangle attribué au pixel qui s'affichera. Pour mieux comprendre quels triangles sont associés à tel pixel, imaginez une ligne droite qui part de caméra et qui passe par un pixel sur le plan de l'écran. Cette ligne intersecte 0, 1 ou plusieurs objets dans la scène 3D. Les triangles situés ces intersections entre cette ligne et les objets rencontrés seront associé au pixel correspondant. C'est lors de cette phase que la perspective est gérée, en fonction de la position de la caméra. L'étape de rastérisation contient plusieurs étapes distinctes, que nous allons voir dans ce chapitre.

Le clipping-culling modifier

A la suite l'assemblage des primitives, plusieurs phases de culling éliminent les triangles non-visibles depuis la caméra.

La première d'entre elle est le back-face culling, qui agit sur les primitives assemblées par l'étape précédente. Elle fait en sorte que les primitives qui tournent le dos à la caméra soient éliminées. Ces primitives appartiennent aux faces à l'arrière d'un objet opaque, qui sont cachées par l'avant. Elles ne doivent donc pas être rendues et sont donc éliminées du rendu.

Ensuite, vient le view frustum culling, dont le nom indique qu'il s'agit de l'élimination de tout ce qui est situé en-dehors du view frustum. Elle fait que ce qui est en-dehors du champ de vision de la caméra n'est pas affiché à l'écran n'est pas calculé ou rendu, dans une certaine mesure. Un soin particulier doit être pris pour les triangles dont une partie seulement est dans le champ de vision. Ils doivent être découpés en plusieurs triangles, tous présents intégralement dans le champ de vision. Les algorithmes pour ce faire sont assez nombreux et il serait compliqué d'en faire une liste exhaustive, aussi nous laissons le sujet de côté pour le moment.

 
Clipping/View frustum culling dans le cadre d'un écran de forme carrée (en gris).

La rastérisation modifier

 
Exemple de rastérisation, où les formes géométriques sont une ligne droite, un arc de cercle et un polygone.

Une fois tous les triangles non-visibles éliminés, la carte graphique attribue les primitives restantes à des pixels : c'est l'étape de rastérisation proprement dite, aussi appelée étape de Triangle Setup. Un exemple de rastérisation est donné dans l'illustration ci-contre. On voit que la géométrie de la scène est ici en 2D et décrit : une ligne droite, un arc de cercle et un polygone. La rastérisation dit quels pixels de l'écran correspondent à la ligne, l'arc de cercle et le polygone. La même chose a lieu pour une scène 3D, sans grandes différences particulières.

Il est rare qu'on ne trouve qu'un seul triangle sur la trajectoire d'un pixel : c'est notamment le cas quand plusieurs objets sont l'un derrière l'autre. Si vous tracez une demi-droite dont l'origine est la caméra, et qui passe par le pixel, il arrive qu'elle intersecte la géométrie en plusieurs points. Et dans ce cas, n'allez pas croire que seul l'objet situé devant les autres détermine à lui seul la couleur du pixel. N'oubliez pas que certains objets sont transparents ! Avec la transparence, la couleur finale d'un pixel dépend de la couleur de tous les points d'intersection en question. Cela demande de calculer un pseudo-pixel pour chaque point d'intersection et de combiner leurs couleurs pour obtenir le résultat final. Les pseudo-pixels en question sont appelés des fragments. Les fragments attribués à un même pixel sont combinés pour obtenir la couleur finale de ce pixel. Mais cela s'effectuera assez loin dans le pipeline graphique, et nous reviendrons dessus en temps voulu.

La rastérisation de type scan-line modifier

 
Rendu en Scan-line.

Si vous avez déjà eu la chance de programmer un moteur de jeu purement logiciel, il est possible que vous ayez eu à coder un rastériseur logiciel. La manière la plus simple est d'utiliser une boucle qui traite l'écran pixel par pixel. Une autre méthode effectue la rastérisation triangle par triangle, mais elle n'est pas pratique et est peu utilisée. Une autre méthode, très populaire en logiciel, traite l'écran ligne par ligne avec un algorithme de type rendu par scalines (scanline rendering).

Malheureusement, le rendu par scanline n'est pas du tout adapté pour une implémentation en matériel. Un premier défaut de cette approche est que les unités de texture ont besoin que le rendu se fasse par blocs de 4 pixels, par carrés de 2 pixels de côté. La raison à cela sera expliquée dans le prochaine chapitre sur les textures, aussi nous ne pouvons pas en parler plus en détail ici. Mais cela se marie mal avec un rendu ligne par ligne. On peut certes adapter l'algorithme de manière à ce qu'il traite deux lignes en parallèle, mais on tombe alors sur des subtilités qui nuisent à une implémentation efficiente. Un autre défaut est que cet algorithme est asymétrique sur l'axe x et sur l'axe y, vu que le rendu est ligne par ligne. Et cela empêche de le paralléliser facilement. Il est en effet très important pour le matériel de pouvoir effectuer des calculs en parallèle, plutôt que les uns après les autres. Et cet algorithme marche assez mal de ce point de vue.

Il existe néanmoins quelques consoles de jeux qui ont implémenté cet algorithme en matériel. Un bon exemple est la console Nintendo 3DS et ses dérivés, qui utilisaient ce genre de rastérisation. Mais la quasi-totalité du matériel récent utilise une autre méthode de rastérisation, plus compatible avec des circuits et plus facilement parallélisable.

La rastérisation basée sur des fonctions de contours et les équations de droite d'un triangle modifier

Par définition, un triangle est une portion du plan délimitée par trois droites, chaque droite passant par un côté. Et chaque droite coupe le plan en deux parties : une à gauche de la droite, une autre à sa droite. Un triangle est définit par l'ensemble des points qui sont du bon côté de chaque droite. Par exemple, si je prend un triangle délimité par trois droites d1, d2 et d3, les points de ce triangle sont ceux qui sont situé à droite de d1, à gauche de d2 et à droite de d3. La rastérisation matérielle profite de cette observation pour déterminer si un pixel appartient à un triangle.

L'idée est de calculer si un pixel est du bon côté de chaque droite, et de combiner les trois résultats pour prendre une décision. Pour chaque droite, on crée une fonction de contours, qui indique de quel côté de la droite se situe le pixel. La fonction de contours va, pour chaque point sur l'image, renvoyer un nombre entier :

  • zéro si le point est placé sur la droite ;
  • un nombre négatif si le point est placé du mauvais côté de la droite ;
  • un nombre positif si le point est placé du bon côté.

Comment calculer cette fonction ? Tout d'abord, nous allons dire que le point que nous voulons tester a pour coordonnées   sur l'écran. La droite passe quant à elle par deux sommets : le premier de coordonnées   et l'autre de coordonnées  . La fonction est alors égale à :

 

Pour savoir si un pixel appartient à un triangle, il suffit de tester le résultat des trois fonctions de contours, une pour chaque droite. A l'intérieur du triangle, les trois fonctions (une par côté) donneront un résultat positif. A l'extérieur, une des trois fonctions donnera un résultat négatif.

L'étape de Triangle traversal modifier

L'usage des fonctions de contours permet de tester un couple pixel-triangle. Pour l'utiliser dans un algorithme de rastérisation, il faut choisir quels pixels tester pour quel triangle. Dans sa version la plus naïve, tous les pixels de l'écran sont testés pour chaque triangle. Mais si le triangle est assez petit, une grande quantité de pixels seront testés inutilement. Pour éviter cela, diverses optimisations ont été inventées. Leur but est de limiter le nombre de pixels à tester. Une autre source d'optimisation matérielle tient dans l'ordre de traitement des pixels. L'algorithme de rastérisation a une influence sur l'ordre dans lequel les pixels sont envoyés aux unités de texture. Et l'ordre de traitement des pixels impacte l'ordre dans lequel on traite les texels (les pixels des textures). Suivant l'ordre de traitement des pixels, les texels lus seront proches ou dispersés en mémoire, ce qui peut permettre de profiter ou non du cache de textures.

La première optimisation consiste à déterminer le plus petit rectangle possible qui contient le triangle, et à ne tester que les pixels de ce rectangle. On économise ainsi beaucoup de calculs, et améliore un peu l'ordre de traitement des pixels. Non seulement on calcule moins de pixels, mais les pixels calculés sont assez proches les uns des autres, bien que ce ne soit pas parfait. L'économie de calcul est assez large, surtout pour les petits triangles. L'amélioration de l'accès aux textures marche surtout pour les petits triangles, mais marche très mal pour les gros triangles.

 
Smallest rectangle traversal

De nos jours, les cartes graphiques actuelles se basent sur une amélioration de cette méthode. Le principe consiste à prendre ce plus petit rectangle, et à le découper en morceaux carrés. Tous les pixels d'un carré seront testés simultanément, dans des circuits séparés, ce qui est plus rapide que les traiter uns par uns. Ce découpage de l'écran en carrés de pixels s'appelle l'algorithme du tiled traversal. Il a pour avantage qu'il se marie très bien avec la gestion des textures. En effet, les textures sont stockées en mémoire d’une manière particulière : elles sont découpées en carrés de quelques pixels de côté, et les carrés sont répartis dans la mémoire d'une manière assez spécifique. Les carrés des textures ont la même taille que les carrés de la rastérisation. Cela garantit que la rastérisation d'un carré de pixel a de bonnes chances de tomber sur un carré de texture, ce qui permet de profiter parfaitement du cache de texture.

 
Tiled traversal

La coordonnée de profondeur modifier

Chaque fragment correspond à un point d'intersection entre le regard et la géométrie de la scène. La rastérisation lui attribue une position sur l'écran, codée avec deux coordonnées x et y. Mais elle ajoute aussi une troisième coordonnée : la coordonnée de profondeur, aussi appelée coordonnée z. Le nom de cette coordonnée trahit sa signification : elle précise à quelle distance se situe le fragment de la caméra. Plus précisément, elle précise la distance par rapport au plan du viewfrustum qui le plus proche de la caméra. Plus elle est petite, plus le fragment en question est associé à un triangle proche de la caméra.

L'élimination précoce des fragments cachés modifier

La coordonnée z permet de savoir si un objet est situé devant un autre ou non : entre deux fragments de même coordonnée x et y, c'est celui avec le plus petit z qui est devant, car plus proche de la caméra. En théorie, cela peut permettre de savoir si un fragment doit être rendu ou non. Logiquement, si un objet est derrière un autre, il n'est pas visible et ses fragments n'ont pas à être calculés/rendus. Les concepteurs de cartes graphiques usuelles ont donc inventé des techniques d'élimination précoce pour éliminer certains fragments dès qu'on connait leur coordonnée de profondeur, à savoir une fois l'étape de rastérisation/interpolation terminée. Ainsi, on est certain que le fragment en question n'est pas texturé et ne passe pas dans les pixels shaders, ce qui est un gain en performance non-négligeable. Il faut certes prendre en compte la transparence des fragments qui sont devant, mais rien d'insurmontable.

Mais ces techniques peuvent causer un rendu anormal quand la coordonnée de profondeur ou de transparence d'un pixel est modifiée après l'étape de rastérisation, typiquement dans les pixels shaders. Il est rare que les shaders bidouillent la profondeur ou la transparence d'un pixel, mais cela peut arriver. C'est pour cela que l’élimination des fragments invisibles est traditionnellement réalisé à la toute fin du pipeline graphique, dans les circuit de ROP, juste avant d’enregistrer les pixels dans le framebuffer. Pour éliminer tout problème, on doit recourir à des solutions qui activent ou désactivent l'élimination précoce des pixels suivant les besoins. La plus simple est de laisser le choix aux drivers de la carte graphique. Ils analysent les shaders et décident si le test de profondeur précoce peut être effectué ou non. De nos jours, les APIs graphiques comme DirectX et OpenGl permettent de marquer certains shaders comme étant compatibles ou incompatibles avec l'élimination précoce. C'est possible depuis DirectX 11.

La duplication des circuits de gestion de profondeur modifier

Il existe plusieurs techniques d'élimination précoce', qui sont présentes depuis belle lurette dans les cartes graphiques modernes. La plus simple effectue le même calcul que dans les ROP. Les circuits de gestion de la profondeur sont ainsi dupliqués : un exemplaire est dans le ROP, l'autre en sortie de l'étape de rastérisation. On a donc ajouté une unité d'élimination précoce en sortie du circuit de rastérisation. Le choix d'utiliser l'élimination précoce est laissé au driver ou à l'API 3D par le biais des commandes. La carte graphique est configurée par le processeur de commande de manière à utiliser l'un ou l'autre circuit suivant les shader à exécuter.

Néanmoins, rappelons que la carte graphique change régulièrement de shader à exécuter. Et il arrive qu'on passe d'un shader compatible avec l'élimination précoce à un shader incompatible ou inversement. Passer d'un shader qui est compatible avec l'élimination précoce à un qui ne l'est n'est pas un problème. Il suffit de désactiver l'unité d'élimination précoce lors du changement de shader. Mais dans le cas inverse, quelques problèmes de synchronisation peuvent apparaitre. Il faut activer l'élimination précoce quand les pixels du nouveau shader sortent du circuit de rastérisation, ce qui n'est pas exactement le même temps que le changement de shader. En effet, le shader précédent a encore des pixels qui traversent le pipeline et qui sont en cours de calcul dans les pixels shaders ou dans les ROP. Le processeur de commande doit donc faire attendre les processeurs de shader et quelques autres circuits. Typiquement, il faut attendre que la commande précédente se termine, avant d'en relancer une autre avec le nouveau shader.

L'interpolation des pixels modifier

 
Interpolation des pixels.

Une fois l'étape de triangle setup terminée, on sait donc quels sont les pixels situés à l'intérieur d'un triangle donné. Mais il faut aussi remplir l'intérieur des triangles : les pixels dans le triangle doivent être coloriés, avoir une coordonnée de profondeur, etc. Or, à cette étape du rendu, seuls les sommets sont coloriés et ont une coordonnée de profondeur. Pour les pixels situés exactement sur les sommets, on peut reprendre la couleur et la profondeu_r du sommet associé. Mais ces pixels sont très rares et il faut trouver une solution pour les autres pixels. Pour les autres pixels, nous sommes obligés d'extrapoler la couleur et la profondeur à partir des données situées aux sommets. C'est le rôle de l'étape d'interpolation, qui calcule les informations des pixels qui ne sont pas pile-poil sur un sommet. Par exemple, si j'ai un sommet vert, un sommet rouge, et un sommet bleu, le triangle résultant doit être colorié comme indiqué dans le schéma de droite.

Les coordonnées barycentriques modifier

Pour calculer les couleurs et coordonnées de chaque fragment, on va utiliser les coordonnées barycentriques. Pour faire simple, ces coordonnées sont trois coordonnées notées u, v et w. Pour les déterminer, nous allons devoir relier le fragment aux trois autres sommets du triangle, ce qui découpe le triangle initial en trois triangles. Les coordonnées barycentriques sont simplement proportionnelles aux aires de ces trois triangles. Par proportionnelles, il faut comprendre que les coordonnées barycentriques ne dépendent pas de la valeur absolue de l'aire des trois triangles. A la place, ces trois aires sont divisées par l'aire totale du triangle, et c'est ce rapport qui est utilisé pour calculer les coordonnée barycentriques.

 
Coordonnées barycentriques.

La carte graphique calcule ces trois coordonnées en commençant par normaliser l'aire du triangle. C'est à dire qu'elle fait en sorte que l'aire totale du triangle soit d'une unité d'aire, qu'elle fasse 1. Les aires des trois triangles sont alors calculées en proportion de l'aire totale, ce qui fait que leur valeur est comprise dans l'intervalle [0, 1]. Cela signifie que la somme de ces trois coordonnées vaut 1 :

 

En conséquence, on peut se passer d'une des trois coordonnées dans nos calculs, vu que :

 

Les trois coordonnées permettent de faire l'interpolation directement . Il suffit de multiplier la couleur/profondeur d'un sommet par la coordonnée barycentrique associée, et de faire la somme de ces produits. Si l'on note C1, C2, et C3 les couleurs des trois sommets, la couleur d'un pixel vaut :

 

La gestion de la perspective modifier

Maintenant, parlons un petit peu des coordonnées de texture. Pour rappel, les coordonnées de texture permettent d'appliquer une texture à un modèle 3D. Il y a un ensemble coordonnée de texture par sommet, qui précise quelle texture appliquer, mais aussi où se situe le texel à appliquer dans la texture. Par exemple, la coordonnée de texture peut dire : je veux le pixel qui est à ligne 5, colonne 27 dans cette texture. Dans les faits, on n'utilise pas de coordonnées entières de ce type, mais deux nombres flottants compris entre 0 et 1. La coordonnée 0,0 correspond au texel en bas à gauche, celui de coordonnée 1,1 est tout en haut à droite. Les deux coordonnées de texture sont notées u,v avec DirectX, ou encore s,t dans le cas général : u est la coordonnée horizontale, v la verticale.

Lors de la rastérisation, chaque fragment se voit attribuer un triangle, et les coordonnées de texture qui vont avec. Si un pixel est situé pile sur un sommet, les coordonnées de texture de ce sommet est attribuée au pixel. Si ce n'est pas le cas, les coordonnées de texture sont interpolées à partir des coordonnées des trois sommets du triangle rastérisé. Il existe plusieurs moyens de faire cette interpolation, mais le plus simple est l'interpolation affine, identique à celle effectuée pour les autres valeurs interpolées. Concrètement, on fait une moyenne pondérée des coordonnées de texture u et v des trois sommets pour obtenir les coordonnées de textures finales. Par contre, en faisant cela, la perspective n'est pas correctement rendue. Tout objet penché ou qui ne fait pas face à la caméra donnera un effet graphique assez bizarre. L'image ci-dessous le montre assez clairement, avec l'un côté une texture qui face à l'écran à gauche, le résultat de l'interpolation linéaire au milieu, le résultat correct à droite. Intuitivement, on pouvait le deviner : la coordonnée de profondeur (z) n'était pas prise en compte dans le calcul de l’interpolation.

 
Correction de perspective.

L'interpolation affine était utilisée sur la console Playstation 1 de Sony. Ce choix quelque peu étrange marchait pourtant pas trop mal avec une interpolation affine. D'autres consoles utilisaient cette interpolation affine, masi s'en sortaient mieux grâce à un stratagème. Au lieu d'utiliser des triangles, le rendu utilisait des carrés/rectangles. Avec des primitives rectangulaires, le résultat a l'air visuellement, meilleur, car l'interpolation donne un bon résultat pour ce qui va à l'horizontal, seule les objets à la verticale de la caméra donnant une perspective légèrement déformée. Tout cela est bien illustré ci-dessous. Cependant, l'interpolation est alors plus lourde en calculs, car elle demande d'interpoler quatre sommets au lieu de trois. Le cout en calculs n'est pas négligeable.

 
Affine texture mapping tri vs quad

Si on garde un rendu avec des triangles, moins gourmand en calculs, on pourrait résoudre le problème en interpolant aussi la coordonnée z, mais le rendu est alors aussi peu convainquant qu'avant. Par contre, en interpolant 1/z, et en calculant z à partir de cette valeur interpolée, les problèmes disparaissent. Plus précisément, il faut remplacer les coordonnées u,v,z (les deux coordonnées de texture u,v et la profondeur) par les coordonnées suivantes : u/z, v./z et 1/z. En faisant cela, on s'assure que la perspective est rendue à la perfection. l'explication mathématique de pourquoi cette formule fonctionne est cependant assez compliquée... De plus, cette rastérisation demande d'effectuer des divisions flottantes et est très gourmandes. Raison pour laquelle les vielles cartes vidéo n'utilisaient pas cette interpolation. Mais les cartes graphiques récentes ont des circuits dédiés capables de faire ces lourds calculs sans trop de problèmes.



Les unités de texture

 
Texture mapping

Les textures sont des images que l'on va plaquer sur la surface d'un objet, du papier peint en quelque sorte. Les cartes graphiques supportent divers formats de textures, qui indiquent comment les pixels de l'image sont stockés en mémoire : RGB, RGBA, niveaux de gris, etc. Une texture est donc composée de "pixels", comme toute image numérique. Pour bien faire la différence entre les pixels d'une texture, et les pixels de l'écran, les pixels d'une texture sont couramment appelés des texels.

Le placage de textures modifier

Pour rappel, plaquer une texture sur un objet consiste à attribuer un texel à chaque sommet, ce qui est fait lorsque les créateurs de jeu vidéo conçoivent le modèle de l'objet. Chaque sommet est associé à des coordonnées de texture, qui précisent quelle texture appliquer, mais aussi où se situe le texel à appliquer dans la texture. Par exemple, la coordonnée de texture peut dire : je veux le pixel qui est à ligne 5, colonne 27 dans cette texture. Dans les faits, on n'utilise pas de coordonnées entières de ce type. Dans la pratique, les coordonnées de texture sont deux nombres flottants compris entre 0 et 1. La coordonnée 0,0 correspond au texel en bas à gauche, celui de coordonnée 1,1 est tout en haut à droite. Les deux coordonnées de texture sont notées u,v avec DirectX, ou encore s,t dans le cas général : u est la coordonnée horizontale, v la verticale. Le nom donnée à cette technique de description des coordonnées de texture s'appelle l'UV Mapping.

 
UV Mapping
Les API 3D modernes gèrent des textures en trois dimensions, ce qui ajoute une troisième coordonnée de texture notée w. Dans ce qui va suivre, nous allons passer les textures en trois dimensions sous silence. Elles ne sont pas très utilisées, la quasi-totalité des jeux vidéo et applications 3D utilisant des textures en deux dimensions. Par contre, le matériel doit gérer les textures 3D, ce qui le rend plus complexe que prévu. Il faut ajouter quelques circuits pour, de quoi gérer la troisième coordonnée de texture, etc.

Lors de la rastérisation, chaque fragment se voit attribuer un sommet, et donc la coordonnée de texture qui va avec. Si un pixel est situé pile sur un sommet, la coordonnée de texture de ce sommet est attribuée au pixel. Si ce n'est pas le cas, la coordonnée de texture finale est interpolée à partir des coordonnées des trois sommets du triangle rastérisé. L'interpolation en question a lieu dans l'étape de rastérisation, comme nous l'avons vu dans le chapitre précédent. Le fait qu'il y ait une interpolation fait que les coordonnées du pixel gagent à être des nombres flottants. On pourrait faire une interpolation avec des coordonnées de texture entières, mais les arrondis et autres imprécisions de calcul donneraient un résultat graphiquement pas terrible, et empêcheraient d'utiliser les techniques de filtrage de texture que nous verrons dans ce chapitre.

À partir de ces coordonnées de texture, la carte graphique calcule l'adresse du texel qui correspond, et se charge de le lire. Et toute la magie a lieu dans ce calcul d'adresse, qui part de coordonnées de texture flottante, pour arriver à une adresse mémoire. Le calcul de l'adresse du texel se fait en plusieurs étapes, que nous allons voir ci-dessous. La première étape convertit les coordonnées flottantes en coordonnées entières, qui disent à quel ligne et colonne se trouve le texel voulu dans la texture. L'étape suivante transforme ces coordonnées x,y entières en adresse mémoire.

La normalisation des coordonnées modifier

 
Clamp tile

J'ai dit plus haut que les coordonnées de texture sont des coordonnées flottantes, comprises entre 0 et 1. Mais il faut savoir que les pixels shaders peuvent modifier celles-ci pour mettre en œuvre certains effets graphiques. Et le résultat peut alors se retrouver en-dehors de l'intervalle 0,1. C'est quelque chose de voulu et qui est traité par la carte graphique automatiquement, sans que ce soit une erreur. Au contraire, la manière dont la carte graphique traite cette situation permet d'implémenter des effets graphiques comme des textures en damier ou en miroir. Il existe globalement trois méthodes très simples pour gérer cette situation, qui sont appelés des modes d'adressage de texture.

  • La première méthode est de faire en sorte que le résultat sature. Si une coordonnée est inférieur à 0, alors on la remplace par un zéro. Si elle est supérieure à 1, on la ramène à 1. Avec cette méthode, tout se passe comme si les bords de la texture étaient étendus et remplissaient tout l'espace autour de la texture. Le tout est illustré ci-dessous. Ce mode d'accès aux textures est appelé le clamp.
  • Une autre solution retire la partie entière de la coordonnée, elle coupe tout ce qui dépasse 1. Pour le dire autrement, elle calcule le résultat modulo 1 de la coordonnée. Le résultat est que tout se passe comme si la texture était répétée à l'infini et qu'elle pavait le plan.
  • Une autre méthode remplit les coordonnées qui sortent de l’intervalle 0,1 avec une couleur préétablie, configurée par le programmeur.

La conversion des coordonnées de textures flottantes en coordonnées entières modifier

Une fois la normalisation effectuée, les coordonnées de texture sont transformées en coordonnées entières. Pour cela, on multiplie les coordonnées u,v par la résolution de la texture accédée. Pour un écran de résolution  , le calcul est le suivant :

 
 

Le résultat est un nombre avec une partie entière et une partie fractionnaire. La partie entière des deux coordonnées donne la position x,y voulue, et la partie fractionnaire est abandonnée. Elle est utile lors de certaines opérations de filtrage de textures, mais passons cela sous silence pour le moment.

La représentation mémoire des textures modifier

Le calcul de l'adresse d'un texel a besoin de connaitre la position du texel en mémoire, par rapport au début de la texture. Et calculer cette position intra-texture dépend de la manière dont les texels sont stockés en mémoire. Les programmeurs qui lisent ce cours s'attendent certainement à ce que la texture soit stockée en mémoire ligne par ligne, chaque ligne étant stockée pixel par pixel en partant de la gauche. C'est une méthode simple, intuitive, que nous allons voir en premier, mais ce n'est pas celle qui est utilisée de nos jours.

Les textures naïves modifier

En général, les images sont stockées lignes par ligne, ou colonne par colonne. Cela veut dire que le premier pixel en partant d'en haut à gauche est stocké en premier, puis celui immédiatement à sa droite, puis celui encore à droite, et ainsi de suite. Une fois qu'on arrive à la fin d'une ligne, on passe à la ligne suivante, en-dessous. Cette organisation ligne par ligne s'appele l'organisation row major order. On peut faire pareil, mais colonne apr colonne, ce qui donne le column major order.

 
Row et column major order.

Maintenant, supposons que la texture commence à l'adresse  , qui est l'adresse du premier texel. La texture a une résolution de  , soit   texels de large, et   texels de haut. Par définition, les coordonnées X et Y des texels commencent à 0, ce qui fait que le pixel en haut à gauche a les coordonnées 0,0. L'adresse d'un pixel se calcule facilement si on sait combine d'octets prend un texel en RAM. Supposons que la taille d'un pixel en mémoire soit de T. L'adresse du pixel se calcule comme suit :

 

La formule se comprend assez facilement. On part de l'adresse du début de la texture. Ensuite, on se déplace de Y lignes. La taille d'une ligne en mémoire est de  , ce qui fait que la y-ème ligne se situe   octets plus loin. Ensuite, on se déplace encore de x pixels dans la ligne, ce qui donne un déplacement de   octets. La formule se réecrit comme suit :

 

Mais cette organisation n'est pas utilisée pour les textures. En effet, les opérations de filtrage de texture que nous allons voir dans ce qui suit, font que le filtrage d'un texel dépend de ses voisins immédiat, que ce soit les voisins du dessus ou du dessous. De même, il est rare que l'on parcoure une texture ligne par ligne, ou colonne par colonne. Cela n'arrive que dans des cas bien précis : une texture vue de face, sans angle, sans filtrage de textures. Mais les textures ne sont pas forcément visibles de face sur l'écran : elles peuvent être penchées, tordues, voire tout simplement déformées par la perspective. L'affichage de la texture ne se fait alors pas ligne par ligne, mais en parcourant la texture en diagonale, l'angle de la diagonale correspondant approximativement à l'angle que fait la verticale de la texture avec le regard. Le fait que la texture n'est pas forcément parcourue ligne par ligne fait que stocker une texture ligne par ligne n'est pas l'idéal. De plus, vu qu'on ne connait pas à l'avance l'angle que fera la diagonale de parcours, on doit ruser.

Les textures tilées modifier

Une première solution à ce problème est celle des textures tilées. Avec ces textures, l'image de la texture est découpée en tiles, des rectangles ou en carrés de taille fixe, généralement des carrés de 4 pixels de côté. Les tiles sont alors mémorisée les unes après les autres dans le fichier de la texture.

 
Texture tilée

Une solution revient à adapter la formule vue plus haut, pour les textures organisées en ligne par ligne. La formule précédente marche donc, mais à condition que l'on change la taille d'un texel par la taille d'une tile, et que la largeur de la texture soit une largeur en nombre de tiles. De plus, on doit adapter les coordonnées des texels pour donner des coordonnées de tile. Généralement, les tiles sont des carrés de N pixels de côté, ce qui fait qu'on peut regrouper les lignes et les colonnes par paquets de N. Il suffit donc de diviser Y et X pour obtenir les coordonnées de la tile, de même que la larguer. La formule pour calculer la position de la énième tile est alors la suivante :

 

On peut réécrire le tout comme suit :

 , avec K une constante connue à la compilation des ahders.
Les tiles ont une largeur et une longueur égales, afin de simplifier les calculs : on divise X et Y par le même nombre. De plus, leur largeur et leur longueur sont une puissance de deux, afin de simplifier les calculs d'adresse. En faisant cela, la multiplication par la taille d'une tile en mémoire se simplifie : on passe d'une multiplication entière à des décalages de bits. Même chose pour le calcul de l'adresse de la tile à partir des coordonnées x,y : ils impliquent des divisions par une puissance de deux, qui deviennent de simples décalages.

La position d'un pixel dans une tile dépend du format de la texture, mais peut se calculer avec quelques calculs arithmétiques simples. Dans les cas les plus simples, les pixels sont mémorisés ligne par ligne, ou colonne par colonne. Mais ce n'est pas systématiquement le cas. Toujours est-il que les calculs pour déterminer l'adresse sont simples, et ne demandent que quelques additions ou multiplications. Mais avec les formats de texture utilisés actuellement, les tiles sont chargées en entier dans le cache de texture, sans compter que diverses techniques de compression viennent mettre le bazar, comme on le verra dans la suite de cours.

Un avantage de l'organisation en tiles est qu'elle se marie bien avec le parcours des textures. On peut parcourir une texture dans tous les sens, horizontal, vertical, ou diagonal, on sait que les prochains pixels ont de fortes chances d'être dans la même tile. Si on rentre dans une tile par la gauche en haut, on a encore quelques pixels à parcourir dans la tile, par exemple. De même, le filtrage de textures est facilité. On verra dans ce qui va suivre que le filtrage de texture a besoin de lire des blocs de 4 texels, des carrés de 2 pixels de côté. Avec l'organisation en tile, on est certain que les 4 texels seront dans la même tile, sauf s'ils ont le malheur d'être tout au bord d'une tile. Ce dernier cas est assez rare, et il l'est d'autant plus que les tiles sont grandes. Enfin, un dernier avantage est que les tiles sont généralement assez petites pour tenir tout entier dans une ligne de cache. Le cache de texture est donc utilisé à merveille, ce qui rend les accès aux textures plus rapides.

Les textures basées sur des z-order curves modifier

 
Construction d'une Z-order curve.

Les formats de textures théoriquement optimaux utilisent une Z-order curve, illustrée ci-dessous. L'idée est de découper la texture en quatre rectangles identiques, et de stocker ceux-ci les uns à la suite des autres. L'intérieur de ces rectangles est lui aussi découpé en quatre rectangles, et ainsi de suite. Au final, l'ordre des pixels en mémoire est celui illustré ci-dessous.

 
Construction d'une Z-order curve.

Les texels sont stockés les uns à la suite des autres dans la mémoire, en suivant l'ordre donnée par la Z-order curve. Le calcul d'adresse calcule la position du texel en mémoire, par rapport au début de la texture, et ajoute l'adresse du début de la texture. Mais tout le défi est de calculer la position d'un texel en mémoire, à partir des coordonnées x,y. Le calcul peut sembler très compliqué, mais il n'en est rien. Le calcul demande juste de regarder les bits des deux coordonnées et de les combiner d'une manière particulièrement simple. Il suffit de placer le bit de poids fort de la coordonnée x, suivi de celui de la coordonnée y, et de faire ainsi de suite en passant aux bits suivants.

 
Calcul de la position d'un élément dans une Z-order curve à partir des coordonnées x et y.

L'avantage d'une telle organisation est que la textures est découpées en tiles rectangulaires d'une certaine taille, elles-mêmes découpées en tiles plus petites, etc. Et il se trouve que cette organisation est parfaite pour le cache de texture. L'idéal pour le cache de texture est de charger une tile complète dans le cache de textures. Quand on accède à un texel, on s'assure que la tile complète soit chargée. Mais cela demande de connaitre à l'avance la taille d'une tile. Les formats de texture fournissent généralement une tile carré de 4 pixels de côté, mais cela donnerait un cache trop petit pour être vraiment utile. Avec cette méthode, on s'assure qu'il y ait une tile avec la taille optimale. Les tiles étant découpées en tiles plus petites, elles-mêmes découpées, et ainsi de suite, on s'assure que la texture est découpées en tiles de taille variées. Il y aura au moins une tile qui rentrera tout pile dans le cache.

Le calcul de l'adresse finale modifier

Le calcul de l'adresse d'un texel prend la position du texel par rapport au début de la texture, et ajoute l'adresse du début de la texture. L'adresse mémoire de la texture est mémorisée dans un registre spécialisé. L'adresse est connue au moment où le pilote de la carte graphique place la texture dans la mémoire vidéo, et cette information est transmise au matériel par l'intermédiaire du processeur de commande, puis passée aux processeurs de shaders et à l'unité de texture. Le tout est couplé à d'autres informations, la plus importante étant la taille de la texture en octets, pour éviter de déborder lors des accès à la texture.

Le mip-mapping modifier

Vous pourriez croire qu'il n'y a rien à dire de pertinent sur l'adresse de départ de la texture, mais ce n'est pas le cas. En réalité, c'est l'endroit idéal pour parler d'une technique appelée le mip-mapping, qui a pour but de légèrement améliorer les graphismes des objets lointains, tout en rendant les calculs de texture plus rapides. Le problème résolu par le mip-mapping est le rendu des textures lointaines. Si une texture est plaquée sur un objet lointain, une bonne partie des détails est invisible pour l'utilisateur. Par exemple, un objet assez lointain peut très bien ne prendre que quelques dizaines de pixels à l'écran. Dans ces conditions, plaquer une texture de 512 pixel de côté serait vraiment du gâchis en terme de performance : il faudrait charger tous les pixels de la texture, les traiter, et n'en garder que quelque uns. De plus, ça a tendance à créer des artefacts visuels : les textures affichées ont tendance à pixeliser. Et le mip-mapping permet de réduire ces deux problèmes en même temps.

L'idée est d'utiliser plusieurs exemplaires d'une même texture à des résolutions différentes, chaque exemplaire étant adapté à une certaine distance. Par exemple, une texture sera stocké avec un exemplaire de 512 * 512 pixels, un autre de 256 * 256, un autre de 128 * 128 et ainsi de suite jusqu’à un dernier exemplaire de 32 * 32. Les objets les plus proches utiliseront l'exemplaire à haute résolution, alors que les plus lointains gagneront à utiliser les exemplaires de plus basse résolution. La résolution utilisée diminue d'autant plus que l'objet est situé loin de la caméra. Les objets proches seront rendus avec la texture 512*512, ceux plus lointains seront rendus avec la texture de résolution 256*256, les textures 128*128 seront utilisées encore plus loin, et ainsi de suite jusqu'aux objets les plus lointains qui sont rendus avec la texture la plus petite de 32*32. Chaque exemplaire correspond à un niveau de détail, aussi appelé Level Of Detail (abrévié en LOD).

 
Exemples de mip-maps.

Le mip-mapping améliore grandement la qualité d'image. L'image d'exemple ci-dessous le montre assez bien.

 
Exemple de mipmapping.

Évidemment, cette technique consomme de la mémoire vidéo, vu que chaque texture est dupliquée en plusieurs exemplaires, en plusieurs LOD. Dans le détail, la technique du mip-mapping prend au maximum 33% de mémoire en plus (sans compression). Cela vient du fait que chaque LOD prend 4 fois de pixels que l'image immédiatement supérieure : 2 fois moins de pixels en largeur, et 2 fois moins en hauteur. Donc, si je pars d'une texture de base contenant X pixels, la totalité des LODs, texture de base comprise, prendra X + (X/4) + (X/16) + (X/256) + … Un petit calcul de limite donne 4/3 * X, soit 33% de plus.

Pour faciliter les calculs d'adresse, les LOD d'une même texture sont stockées les uns après les autres en mémoire (dans un tableau, comme diraient les programmeurs). Ainsi, pas besoin de se souvenir de la position en mémoire de chaque LOD : l'adresse de la texture de base, et quelques astuces arithmétiques suffisent. Prenons le cas où la texture de base a une taille L. le premier exemplaire est à l'adresse 0, le second niveau de détail est à l'adresse L, le troisième à l'adresse L + L/2, le suivant à l'adresse L + L/2 + L/4, et ainsi de suite. Le calcul d'adresse est alors assez simple et demande juste connaître le niveau de détails souhaité, ainsi que l'adresse de base de la texture. Le niveau de détail voulu est calculé par les pixel shaders, en fonction de la coordonnée de profondeur du pixel à traiter.

Formellement, le mip-mapping est censé être vu avec les autres techniques de filtrage de texture. Mais nous en parlons à part car il s'agit d'une technique de filtrage de texture un peu à part, qui est surtout liée au calcul d'adresse. Les unités de texture ont des circuits dédiés aux filtrage de texture, qui sont relativement séparés des circuits de mip-mapping et de calcul d'adresse, d'où le fait que nous en parlons séparément.

Le cube-mapping modifier

 
Exemple de reflets environnementaux.

Il n'y a pas que les techniques de mip-mapping qui interférent avec les calculs d'adresse. L'autre technique à le faire est l'environnement-mapping, une technique pour gérer divers effets graphiques liés à l'environnement. L'idée est de plaquer une texture pré-calculée pour simuler l'environnement. Il en existe plusieurs versions différentes, mais la seule utilisée de nos jours est le cube-mapping, où la texture de l'environnement est plaquée sur un cube, d'où son nom. Le cube en question est utilisé différemment suivant ce que l'on cherche à faire avec le cube-mapping. Les deux utilisations principales sont le rendu du ciel et des décors, et les réflexions sur la surface des objets. Dans les deux cas, l'idée est de précalculer ce que l'on voit du point de vue de la caméra. On place la caméra dans la scène 3D, on place un cube centré sur la caméra, le cube est texturé avec ce que l'on voit de l'environnement depuis la caméra/l'objet de son point de vue.

 
L'illustration montre en premier lieu une cubemap avec les six faces mises en évidence, puis quel environnement 3D elle permet de simuler, le troisième illustration montrant comment la cubemap est utilisée pour simuler l'environnement.

Le rendu du ciel et des décors lointains dans les jeux vidéo se base sur des skybox, à savoir un cube centré sur la caméra, sur lequel on ajoute des textures de ciel ou de décors lointains. Le cube est recouvert par une texture, qui correspond à ce que l'on voit quand on dirige le regard de la caméra vers cette face. Contrairement à ce qu'on pourrait croire, la skybox n'est pas les limites de la scène 3D, les limites du niveau d'un jeu vidéo ou quoique ce soit d'autre de lié à la physique de la scène 3D. La skybox est centrée sur la caméra, elle suit la caméra dans son mouvement. Centrer la skybox sur la caméra permet de simuler des décors très lointains, suffisamment lointain pour qu'on n'ait pas l'illusion de s'en rapprocher en se déplaçant dans la map. De plus, cela évite d'avoir à faire trop de calculs à chaque fois que l'on bouge la caméra. La texture plaquée sur le cube est une texture unique, elle-même découpée en six sous-textures, une par face du cube.

 
Exemple de Skybox.
 
Réflexions calculées par une cubemap.

Le cube-mapping est aussi utilisé pour des reflets. L'idée est de simuler les reflets en plaquant une texture pré-calculée sur l'objet réflecteur. La texture pré-calculée est un dessin de l'environnement qui se reflète sur l'objet, un dessin du reflet à afficher. En la plaquant la texture sur l'objet, on simule ainsi des reflets de l'environnement, mais on ne peut pas calculer d'autres reflets comme les reflets objets mobiles comme les personnages. Et il se trouve que la texture pré-calculée est une cubemap. Pour les environnements ouverts, c'est la skybox qui est utilisée, ce qui permet de simuler les reflets dans les flaques d'eau ou dans des lacs/océans/autres. Pour les environnements intérieurs, c'est une cubemap spécifique qui utilisée. Par exemple, pour l'intérieur d'une maison, on a une cubemap par pièce de la maison. Les reflets se calculent en précisant quelle cubemap appliquer sur l'objet en fonction de la direction du regard.

 
Cube map de l'intérieur d'une pièce d'un niveau de jeux vidéo.

Toujours est-il que les textures utilisées pour le cubemmapping, appelées des cubemaps, sont en réalité la concaténation de six textures différentes. En mémoire vidéo, la cubemap est stockée comme six textures les unes à la suite des autres. Lors du rendu, on doit préciser quelle face du cube utiliser, ce qui fait 6 possibilités. On a le même problème qu'avec les niveaux de détail, sauf que ce sont les faces d'une cubemap qui remplacent les textures de niveaux de détails. L'accès en mémoire doit donc préciser quelle portion de la cubemap il faut accéder. Et l'accès mémoire se complexifie donc. Surtout que l'accès en question varie beaucoup suivant l'API graphique utilisée, et donc suivant la carte graphique.

Les API 3D assez anciennes ne gérent pas nativement les cubemaps, qui doivent être émulées en logiciel en utilisant six textures différentes. Le pixel shader décide donc quelle cubemap utiliser, avec quelques calculs sur la direction du regard. L'accès se fait d'une manière assez simple : le shader choisit quelle texture utiliser. Les API 3D récentes gèrent nativement les cubemaps. Dans le cas le plus simple,pour les versions les plus vielles de ces API, les six faces sont numérotées et l'accès à une cubemap précise quel face utiliser en donnant son numéro. La carte graphique choisit alors automatiquement la bonne texture, mais cela demande de laisser le calcul de la bonne face au pixel shader. D'autres API 3D et cartes graphiques font autrement. Dans les API 3D modenres, les cubemap sont gérées comme des textures en trois dimensions, adressées avec trois coordonnées u,v,w. La carte graphique utilise ces trois coordonnées de manière à en déduire quelle est la face pertinente, mais aussi les coordonnées u,v dans la texture de la face.

L'implémentation matérielle du placage de textures modifier

Pour résumer, la lecture d'un texel demande d'effectuer plusieurs étapes. Dans le cas le plus simple, sans mip-mapping ou cubemapping, on doit effectuer les étapes suivantes :

  • Il faut d'abord normaliser les coordonnées de texture pour qu'elles tombent dans l'intervalle [0,1] en fonction du mode d'adressage désiré.
  • Ensuite, les coordonnées u,v doivent être converties en coordonnées entières, ce qui demande une multiplication flottante.
  • Enfin, l'adresse finale est calculée à partir des coordonnées entières et en ajoutant l'adresse de base de la texture (et éventuellement avec d'autres calculs arithmétiques suivant le format de la texture).

Tout cela pourrait être fait par le pixel shaders, mais cela implique beaucoup de calculs répétitifs et d'opérations arithmétiques assez lourdes, avec des multiplications flottantes, des additions et des multiplications entières, etc. Faire faire tous ces calculs par les shaders serait couteux en performance, sans compter que les shaders deviendraient plus gros et que cela aurait des conséquences sur le cache d'instruction. De plus, certaines de ces étapes peuvent se faire en parallèle, comme les deux premières, ce qui colle mal avec l'aspect sériel des shaders.

Aussi, les processeurs de shaders incorporent une unité de calcul d'adresse spéciale pour faire ces calculs directement en matériel. L'unité de texture contient au minimum deux circuits : un circuit de calcul d'adresse, et un circuit d'accès à la mémoire. Toute la difficulté tient dans le calcul d'adresse, plus que dans le circuit de lecture. Le calcul d'adresse est conceptuellement réalisé en deux étapes. La première étape qui transforme les coordonnées u,v en coordonnées x,y qui donne le numéro de la ligne et de la colonne du texel dans la texture. La seconde étape prend ces deux coordonnées x,y, l'adresse de la texture, et détermine l'adresse de la tile à lire.

 
Unité de texture simple

Le mip-mapping est lui aussi pris en charge par l'unité de calcul d'adresse, car cette technique change l'adresse de base de la texture. La gestion du mip-mapping est cependant assez complexe. Il est possible de laisser le pixel shader calculer quel niveau de détail utiliser, en fonction de la coordonnée de profondeur z du pixel à afficher. La carte graphique détermine alors automatiquement quelle texture lire, quel niveau de détail, automatiquement. Elle détermine aussi la bonne résolution pour la texture, qui est égal à la résolution de la texture de base, divisée par le niveau de détail. Pour résumer, le niveau de détail est envoyé aux unités de texture, qui s'occupent de calculer l'adresse de base et la résolution adéquates. Quelques calculs arithmétiques simples, donc, qui s'implémentent facilement avec quelques circuits.

Mais une autre méthode laisse la carte graphique déterminer le niveau de détail par elle-même. Dans ce cas, cela demande, outre les deux coordonnées de texture, de calculer la dérivée de ces deux coordonnées dans le sens horizontal et vertical, ce qui fait quatre dérivées (deux dérivées horizontales, deux verticales). Les quatre dérivées sont les suivantes :

 ,  ,  ,  

Un bon moyen pour obtenir ces informations demande de regrouper les pixels par groupes de 4 et de faire la différence entre leurs coordonnées de texture respectives. On peut calculer les deux dérivées horizontales en comparant les deux pixels sur la même ligne, et les deux dérivées verticales en comparant les deux pixels sur la même colonne. Mais cela demande de rastériser les pixels par groupes de 4, par quads. Et c'est ce qui est fait sur les cartes graphiques actuelles, qui rastérisent des groupes de 4 pixels à la fois.

 
Unité de texture avec mipmapping.

Le filtrage de textures modifier

Plaquer des textures sans autre forme de procès ne suffit pas à garantir des graphismes d'une qualité époustouflante. La raison est que les sommets et les texels ne tombent pas tout pile sur un pixel de l'écran : le sommet associé au texel peut être un petit peu trop en haut, ou trop à gauche, etc. Une explication plus concrète fait intervenir les coordonnées de texture. Souvenez-vous que lorsque l'on traduit une coordonnée de texture u,v en coordonnées x,y, on obtient un résultat qui ne tombe pas forcément juste. Souvent, le résultat a une partie fractionnaire. Si celle-ci est non-nulle, cela signifie que le texel/sommet n'est pas situé exactement sur le pixel voulu et que celui-ci est situé à une certaine distance. Concrètement, le pixel tombe entre quatre texels, comme indiqué ci-dessous.

 
Position du pixel par rapport aux texels.

Pour résoudre ce problème, on doit utiliser différentes techniques d'interpolation, aussi appelées techniques de filtrage de texture, qui visent à calculer la couleur du pixel final en fonction des texels qui l'entourent. Il existe de nombreux types de filtrage de textures, qu'il s'agisse du filtrage linéaire, bilinéaire, trilinéaire, anisotropique et bien d'autres.

Tous ont besoin d'avoir certaines informations qui sont généralement fournies par les circuits de calcul d'adresse. La première est clairement la partie fractionnaire des coordonnées x,y. La seconde est la dérivée de ces deux coordonnées dans le sens horizontal et vertical., ce qui fait quatre dérivées (deux dérivées horizontales, deux verticales). Toujours est-il que le filtrage de texture est une opération assez lourde, qui demande beaucoup de calculs arithmétiques. On pourrait en théorie le faire dans les pixels shaders, mais le cout en performance serait absolument insoutenable. Aussi, les cartes graphiques intègrent toutes un circuit dédié au filtrage de texture, le texture sampler. Même les plus anciennes cartes graphiques incorporent une unité de filtrage de texture, ce qui nous montre à quel point cette opération est importante.

 
Unité de texture.

On peut configurer la carte graphique de manière à ce qu'elle fasse soit du filtrage bilinéaire, soit du filtrage trilinéaire, on peut configurer le niveau de filtrage anisotropique, etc. Cela peut se faire dans les options de la carte graphique, mais cela peut aussi être géré par l'application. La majorité des jeux vidéos permettent de régler cela dans les options. Ces réglages ne concernent pas la texture elle-même, mais plutôt la manière dont l'unité de texture doit fonctionner. Ces réglages sur l'état de l'unité de texture sont mémorisés quelque part, soit dans l'unité de texture elle-même, soit fournies avec la ressource de texture elle-même, tout dépend de la carte graphique. Certaines cartes graphiques mémorisent ces réglages dans les unités de texture ou dans le processeur de commande, et tout changement demande alors de réinitialiser l'état des unités de texture, ce qui prend un peu de temps. D'autres placent ces réglages dans les ressources de texture elles-mêmes, ce qui rend les modifications de configuration plus rapides, mais demande plus de circuits. D'autres cartes graphiques mélangent les deux options, certains réglages étant globaux, d'autres transmis avec la texture. Bref, difficile de faire des généralités, tout dépend du matériel et le pilote de la carte graphique cache tout cela sous le tapis.

Maintenant que cela est dit, voyons quelles sont les différentes méthodes de filtrage de texture et comment la carte graphique fait pour les calculer.

Le filtrage au plus proche modifier

La méthode de filtrage la plus simple consiste à colorier avec le texel le plus proche. Cela revient tout simplement à ne pas tenir compte de la partie fractionnaire des coordonnées x,y, ce qui est très simple à implémenter en matériel. C'est ce que l'on appelle le filtrage au plus proche, aussi appelé nearest filtering. Autant être franc, le résultat est assez pixelisé et peu agréable à l’œil. Par contre, le résultat est très rapide à calculer, vu qu'il ne demande aucun calcul à proprement parler. Elle ne fait pas appel à la parti fractionnaire des coordonnées entières de texture, ni aux dérivées de ces coordonnées. On peut combiner cette technique avec la technique du mip-mapping, qui est formellement une technique de filtrage de texture un peu à part. Le résultat est alors déjà bien meilleur, bien que loin d'être satisfaisant. Au passage, toutes les techniques de filtrage de texture peuvent se combiner avec du mip-mapping, certaines ne pouvant pas faire sans.

 
Filtrage de texture au plus proche.

Le filtrage linéaire modifier

Le plus simple de ces filtrage est le filtrage linéaire, qui effectue une interpolation linéaire entre deux mip-maps, deux niveaux de détails. Pour comprendre l'idée, nous allons prendre une situation très simple, où on souhaite faire une interpolation linéaire entre deux texels. L'interpolation par du principe que la couleur varie entre les deux texels en suivant une fonction affine, illustrée ci-dessous. Ce ne serait évidemment pas le cas dans le monde réel, mais on supposer cela donne une bonne approximation de ce à quoi ressemblerait une texture à plus haute résolution. On peut alors calculer la couleur du pixel par une simple moyenne pondérée par la distance.

 
Interpolation linéaire.

Mais où trouver deux pixels pour faire cette moyenne ? C'est là que le mip-mapping rentre en jeu. L'idée est de prendre un texel dans chaque mip-map. Chaque pixel pixel est rendu avec deux niveaux de détail proches, chacun fournissant un texel. Les deux texels sont ensuite interpolés avec la moyenne vu au-dessus, ce qui donne comme résultat moyen intermédiaire entre les deux texels. Le résultat n'est pas très différent de ce qu'on obtient avec une absence d'interpolation et juste du mip-mapping. L'effet visuel est que les transitions entre deux niveaux de détails sont plus lisses, moins abruptes.

Le filtrage bilinéaire modifier

Le filtrage bilinéaire effectue une sorte de moyenne pondérée des quatre texels les plus proches du pixel à afficher. La moyenne est calculée à partir d'interpolations linéaires. Avec 4 pixels, nous allons devoir calculer la couleur de deux points intermédiaires. La couleur de ces deux points se calcule par interpolation linéaire, et il suffit d'utiliser une troisième interpolation linéaire pour obtenir le résultat.

 
Filtrage bilinéaire de texture.

Vous noterez que le filtrage bilinéaire accède à 4 pixels en même temps. Fort heureusement, les textures sont stockées de manière à ce qu'on puisse charger les 4 pixels en une fois. Les 4 pixels sont proches en mémoire, ils sont généralement consécutifs avec un peu de chance. C'est une autre raison pour laquelle on ne stocke pas les textures comme les autres images, à savoir ligne par ligne ou colonne par colonne, mais qu'on les découpe en tiles de petite taille. Le filtrage bilinéaire a de fortes chances que les 4 pixels filtrés soient dans la même tile, la seule exception étant quand ils sont tout juste sur le bord d'une tile.

Le circuit qui permet de faire ce genre de calcul est particulièrement simple. On trouve un circuit de chaque pour chaque composante de couleur de chaque texel : un pour le rouge, un pour le vert, un pour le bleu, et un pour la transparence. Chacun de ces circuit est composé de sous-circuits chargés d'effectuer une interpolation linéaire, reliés comme suit.

 
Unité de filtrage bilinéaire.
La console de jeu Nintendo 64 n'utilise que trois pixels au lieu de quatre dans son interpolation bilinéaire, qui en devient une interpolation quasi-bilinéaire. La raison derrière ce choix est une question de performances, comme beaucoup de décisions de ce genre. Le résultat est un rendu imparfait de certaines textures.

Le filtrage trilinéaire modifier

Avec le mip-mapping, des discontinuités apparaissent lorsqu'une texture est appliquée répétitivement sur une surface, comme quand on fabrique un carrelage à partir de carreaux tous identiques. Par exemple, pensez à une texture de sol : celle-ci est appliquée plusieurs fois sur toute la surface du sol. A une certaine distance, le LOD utilisé change brutalement et passe par exemple de 512*512 à 256*256, ce qui est visible pour un joueur attentif. Le filtrage trilinéaire permet d'adoucir ces transitions. Il consiste à faire « une moyenne » pondérée entre deux niveaux de détails adjacents. Le filtrage trilinéaire effectue d'abord deux filtrages bilinéaires : un sur la texture du niveau de détail adapté, et un autre sur la texture de niveau de détail inférieur. Les deux textures obtenues par filtrage subissent ensuite une interpolation linéaire.

Le circuit de filtrage trilinéaire existe en plusieurs versions. La plus simple, illustrée ci-dessous, effectue deux filtrages bilinéaires en parallèle, dans deux circuits séparés, puis combine leurs résultats avec un circuit d'interpolation linéaire. Seul problème : ce genre de circuit nécessite de charger 8 texels simultanément. Qui plus est, ces 8 texels ne sont pas consécutifs en mémoire,car dans deux niveaux de détails/mip-maps différents. Ce qui fait qu'il n'est pas rare que seuls les 4 premiers pixels soient disponibles rapidement, et que les 4 texels restants mettent du temps avant d'arriver. Les 4 premiers texels doivent donc être mis en attente dans des registres, avant que l'unité de filtrage de texture puisse faire son travail.

 
Unité de filtrage trilinéaire parallèle.

Une amélioration du circuit précédent tient compte de cette mise en attente. Il est constitué d'un circuit effectuant un filtrage bilinéaire, de deux registres, d'un interpolateur linéaire, et de quelques circuits de gestion, non-représentés. Son fonctionnement est simple : ce circuit charge 4 texels d'une mip-map, les filtre, et stocke le tout dans un registre. Il recommence l'opération avec les 4 texels de la mip-map de niveau de détail inférieure une fois ces derniers disponibles, puis stocke le résultat dans un autre registre. Enfin, le tout passe par un circuit qui interpole les couleurs finales en tenant compte des coefficients d'interpolation linéaire, mémorisés dans des registres. On économise donc un circuit d'interpolation bilinéaire, sans que les performances soient trop impactées.

 
Unité de filtrage trilineaire série.

Modifier le circuit de filtrage ne suffit pas. Comme je l'ai dit plus haut, la dernière étape d'interpolation linéaire utilise des coefficients, qui lui sont fournis par des registres. Seul problème : entre le temps où ceux-ci sont calculés par l'unité de mip-mapping, et le moment où les texels sont chargés depuis la mémoire, il se passe beaucoup de temps. Le problème, c'est que les unités de texture sont souvent pipelinées : elles peuvent démarrer une lecture de texture sans attendre que les précédentes soient terminées. À chaque cycle d'horloge, une nouvelle lecture de texels peut commencer. La mémoire vidéo est conçue pour supporter ce genre de chose. Cela a une conséquence : durant les 400 à 800 cycles d'attente entre le calcul des coefficients, et la disponibilité des texels, entre 400 et 800 coefficients sont produits : un par cycle. Autant vous dire que mémoriser 400 à 800 ensembles de coefficient prend beaucoup de registres.

Le filtrage anisotrope modifier

D'autres artefacts peuvent survenir lors de l'application d'une texture, la perspective pouvant déformer les textures et entraîner l'apparition de flou. La raison à cela est que les techniques de filtrage de texture précédentes partent du principe que la texture est vue de face. Prenez une texture carrée, par exemple. Vue de face, elle ressemble à un carré sur l'écran. Mais tournez la caméra, de manière à voir la texture de biais, avec un angle, et vous verrez que la forme de la texture sur l'écran est un trapèze, pas un carré. Cette déformation liée à la perspective n'est pas prise en compte par les méthodes de filtrage de texture précédentes. Pour le dire autrement, les techniques de filtrage précédentes partent du principe que les 4 texels qui entourent un pixel forment un carré, ce qui est vrai si la texture est vue de face, sans angle, mais ne l'est pas si la texture n'est pas perpendiculaire à l'axe de la caméra. Du point de vue de la caméra, les 4 texels forment un trapèze d'autant moins proche d'un carré que l'angle est grand.

Pour corriger cela, les chercheurs ont inventé le filtrage anisotrope. En fait, je devrais plutôt dire : LES filtrages anisotropes. Il en existe un grand nombre, dont certains ne sont pas utilisés dans les cartes graphiques actuelles, soit car ils trop gourmand en accès mémoires et en calculs pour être efficaces, soit car ils ne sont pas pratiques à mettre en œuvre. Il est très difficile de savoir quelles sont les techniques de filtrage de texture utilisées par les cartes graphiques, qu'elles soient récentes ou anciennes. Beaucoup de ces technologies sont brevetées ou gardées secrètes, et il faudrait vraiment creuser les brevets déposés par les fabricants de GPU pour en savoir plus. Les algorithmes en question seraient de plus difficiles à comprendre, les méthodes mathématiques cachées derrière ces méthodes de filtrage n'étant pas des plus simple.

 
Exemple de filtrage anisotrope.

La compression de textures modifier

Les textures les plus grosses peuvent aller jusqu'au mébioctet, ce qui est beaucoup. Pour limiter la casse, les textures sont compressées. La compression de texture réduit la taille des textures, ce qui peut se faire avec ou sans perte de qualité. Elle entraîne souvent une légère perte de qualité lors de la compression. Toutefois, cette perte peut être compensée en utilisant des textures à résolution plus grande. Mais il s'agit là d'une technique très simple, beaucoup plus simple que les techniques que nous allons voir dans cette section. Nous allons voir quelque algorithmes de compression de textures de complexité intermédiaire, mais n'allons pas voir l'état de l'art. Il existe des formats de texture plus récents que ceux qui nous allons aborder, comme l'Ericsson Texture Compression ou l'Adaptive Scalable Texture Compression, plus complexes et plus efficaces.

Notons que les textures sont compressées dans les fichiers du jeu, mais aussi en mémoire vidéo. Les textures sont décompressées lors de la lecture. Pour cela, la carte graphique contient alors un circuit, capable de décompresser les textures lorsqu'on les lit en mémoire vidéo. Les cartes graphiques supportent un grand nombre de formats de textures, au niveau du circuit de décompression. Du fait que les textures sont décompressées à la volée, les techniques de compression utilisées sont assez particulières. La carte graphique ne peut pas décompresser une texture entière avant de pouvoir l'utiliser dans un pixel shader. A la place, on doit pouvoir lire un morceau de texture, et le décompresser à la volée. On ne peut utiliser les méthodes de compression du JPEG, ou d'autres formats de compression d'image. Ces dernières ne permettent pas de décompresser une image morceau par morceau.

Pour permettre une décompression/compression à la volée, les textures sont des textures tilées, généralement découpées en tiles de 4 * 4 texels. Les tiles sont compressées indépendamment les unes des autres. Et surtout, avec ou sans compression, la position des tiles en mémoire ne change pas. On trouve toujours une tile tous les T octets, peu importe que la tile soit compressée ou non. Par contre, une tile compressée n'occupera pas T octets, mais moins, là où une tile compressée occupera la totalité des T octets. En clair, compresser une tile fait qu'il y a des vides entre deux tiles dans al mémoire vidéo, mais ne change rien à leur place en mémoire vidéo qui est prédéterminée, peu importe que la texture soit compressée ou non. L'intérêt de la compression de textures n'est pas de réduire la taille de la texture en mémoire vidéo, mais de réduire la quantité de données à lire/écrire en mémoire vidéo. Au lieu de lire T octets pour une tile non-compressée, on pourra en lire moins.

La palette indicée et la technique de Vector quantization modifier

La technique de compression des textures la plus simple est celle de la palette indicée, que l'on a entraperçue dans le chapitre sur les cartes d'affichage. La technique de vector quantization peut être vue comme une amélioration de la palette, qui travaille non pas sur des texels, mais sur des tiles. À l'intérieur de la carte graphique, on trouve une table qui stocke toutes les tiles possibles. Chaque tile se voit attribuer un numéro, et la texture sera composé d'une suite de ces numéros. Quelques anciennes cartes graphiques ATI, ainsi que quelques cartes utilisées dans l’embarqué utilisent ce genre de compression.

Les algorithmes de Block Truncation coding modifier

La première technique de compression élaborée est celle du Block Truncation Coding, qui ne marche que pour les images en niveaux de gris. Le BTC ne mémorise que deux niveaux de gris par tile, que nous appellerons couleur 1 et couleur 2, les deux niveaux de gris n'étant pas le même d'une tile à l'autre. Chaque pixel d'une tile est obligatoirement colorié avec un de ces niveaux de gris. Pour chaque pixel d'une tile, on mémorise sa couleur avec un bit : 0 pour couleur 1, et 1 pour couleur 2. Chaque tile est donc codée par deux entiers, qui codent chacun un niveau de gris, et une suite de bits pour les pixels proprement dit. Le circuit de décompression est alors vraiment très simple, comme illustré ci-dessous.

 
Block Truncation coding.

La technique du BTC peut être appliquée non pas du des niveaux de gris, mais pour chaque composante Rouge, Vert et Bleu. Dans ces conditions, chaque tile est séparée en trois sous-tiles : un sous-bloc pour la composante verte, un autre pour le rouge, et un dernier pour le bleu. Cela prend donc trois fois plus de place en mémoire que le BTC pur, mais cela permet de gérer les images couleur.

Le format de compression S3TC / DXTC modifier

L'algorithme de Color Cell Compression, ou CCC, améliore le BTC pour qu'il gère des couleurs autre que des niveaux de gris. Ce CCC remplace les deux niveaux de gris par deux couleurs. Une tile est donc codée avec un entier 32 bits par couleur, et une suite de bits pour les pixels. Le circuit de décompression est identique à celui utilisé pour le BTC.

 
Color Cell Compression.
 
Dxt1 et color cell compression.

Le format de compression de texture utilisé de base par Direct X, le DXTC, est une version amliorée de l'algorithme précédent. Il est décliné en plusieurs versions : DXTC1, DXTC2, etc. La première version du DXTC est une sorte d'amélioration du CCC : il ajoute une gestion minimale de transparence, et découpe la texture à compresser en tiles de 4 pixels de côté. La différence, c'est que la couleur finale d'un texel est un mélange des deux couleurs attribuée au bloc. Pour indiquer comment faire ce mélange, on trouve deux bits de contrôle par texel.

Si jamais la couleur 1 < couleur2, ces deux bits sont à interpréter comme suit :

  • 00 = Couleur1
  • 01 = Couleur2
  • 10 = (2 * Couleur1 + Couleur2) / 3
  • 11 = (Couleur1 + 2 * Couleur2) / 3

Sinon, les deux bits sont à interpréter comme suit :

  • 00 = Couleur1
  • 01 = Couleur2
  • 10 = (Couleur1 + Couleur2) / 2
  • 11 = Transparent
 
DXTC.

Le circuit de décompression du DXTC ressemble alors à ceci :

 
Circuit de décompression du DXTC.

Les format DXTC 2, 3, 4 et 5 : l'ajout de la transparence modifier

Pour combler les limitations du DXT1, le format DXT2 a fait son apparition. Il a rapidement été remplacé par le DXT3, lui-même replacé par le DXT4 et par le DXT5. Dans le DXT3, la transparence fait son apparition. Pour cela, on ajoute 64 bits par tile pour stocker des informations de transparence : 4 bits par texel. Le tout est suivi d'un bloc de 64 bits identique au bloc du DXT1.

 
Dxt 2 et 3.

Dans le DXT4 et le DXT5, la méthode utilisée pour compresser les couleurs l'est aussi pour les valeurs de transparence. L'information de transparence est stockée par un en-tête contenant deux valeurs de transparence, le tout suivi d'une matrice qui attribue trois bits à chaque texel. En fonction de la valeur des trois bits, les deux valeurs de transparence sont combinées pour donner la valeur de transparence finale. Le tout est suivi d'un bloc de 64 bits identique à celui qu'on trouve dans le DXT1.

 
Dxt 4 et 5.

Le format de compression PVRTC modifier

Passons maintenant à un format de compression de texture un peu moins connu, mais pourtant omniprésent dans notre vie quotidienne : le PVRTC. Ce format de texture est utilisé notamment dans les cartes graphiques de marque PowerVR. Vous ne connaissez peut-être pas cette marque, et c'est normal : elle travaille surtout dans les cartes graphiques embarquées. Ses cartes se trouvent notamment dans l'ipad, l'iPhone, et bien d'autres smartphones actuels.

Avec le PVRTC, les textures sont encore une fois découpées en tiles de 4 texels par 4, mais la ressemblance avec le DXTC s’arrête là. Chacque tile est codée avec :

  • une couleur codée sur 16 bits ;
  • une couleur codée sur 15 bits ;
  • 32 bits qui servent à indiquer comment mélanger les deux couleurs ;
  • et un bit de modulation, qui permet de configurer l’interprétation des bits de mélange.

Les 32 bits qui indiquent comment mélanger les couleurs sont une collection de 2 paquets de 2 bits. Chacun de ces deux bits permet de préciser comment calculer la couleur d'un texel du bloc de 4*4.


Les Render Output Target

Pour rappel, les étapes précédentes du pipeline graphiques manipulaient non pas des pixels, mais des fragments. Pour rappel, la distinction entre fragment et pixel est pertinente quand plusieurs objets sont l'un derrière l'autre. Si vous tracez une demi-droite dont l'origine est la caméra, et qui passe par le pixel, il arrive qu'elle intersecte la géométrie en plusieurs points. La couleur finale dépend de la couleur de tous ces points d'intersection. Intuitivement, l'objet le plus proche est censé cacher les autres et c'est donc lui qui décide de la couleur du pixel, mais cela demande de déterminer quel est l'objet le plus proche. De plus, certains objets sont transparents et la couleur finale est un mélange de la couleur de plusieurs points d'intersection.

Tout demande de calculer un pseudo-pixel pour chaque point d'intersection et de combiner leurs couleurs pour obtenir le résultat final. Les pseudo-pixels en question sont des fragments. Chaque fragment possède une position à l'écran, une coordonnée de profondeur, une couleur, ainsi que quelques autres informations potentiellement utiles. Les fragments attribués à un même pixel, qui sont à la même position sur l'écran, sont donc combinés pour obtenir la couleur finale de ce pixel. Pour résumer, la profondeur des fragments doit être gérée, de même que la transparence, etc. Et c'est justement le rôle de l'étage du pipeline que nous allons voir maintenant. Ces opérations sont réalisées dans un circuit qu'on nomme le Render Output Target, situé à la toute fin du pipeline graphique, qui enregistre l'image finale dans la mémoire vidéo.

L'architecture matérielle d'un ROP modifier

 
R.O.P des GeForce 6800.

Un ROP est typiquement organisé comme illustré ci-contre. Il récupère les fragments calculés par les pixels shaders et/ou les unités de texture, via un circuit d'interconnexion spécialisé. Chaque ROP est connecté à toutes les unités de shader, même si la connexion n'est pas forcément directe. Toute unité de shader peut envoyer des pixels à n'importe quel ROP. Les circuits d'interconnexion sont généralement des réseaux d'interconnexion de type crossbar, comme illustré ci-contre (le premier rectangle rouge). Le ROP gère aussi l'antialiasing, de concert avec l'unité de rastérisation. Ensuite, les ROP contiennent des circuits pour gérer la profondeur des fragments et déterminer quel fragment est devant l'autre. Ils contiennent aussi des circuits pour calculer la couleur finale des pixels, ce qui revient à gérer la transparence des fragments. Le tout est suivi d'une unité qui enregistre le résultat final en mémoire.

Notons que les circuits de gestion de la profondeur et ceux pour la transparence sont séparés dans des unités distinctes, par commodité. Il faut dire que la gestion de la profondeur et la gestion de la transparence sont deux opérations assez séparées, qui gagnent à être effectuées en parallèle l'une de l'autre. On gagne en performance si on effectue les deux opérations en même temps, au lieu de les faire une à la fois. On peut aussi noter que les circuits de gestion de la profondeur et de la couleur gèrent diverses techniques de compression pour économiser de la mémoire et de la bande passante mémoire. Ajoutons à cela que ces deux unités contiennent des caches spécialisés, qui permettent de réduire fortement les accès mémoires, très fréquents dans cette étape du pipeline graphique.

La gestion de la profondeur (tests de visibilité) modifier

Le premier rôle du ROP est de trier les fragments du plus proche au plus éloigné, pour gérer les situations où un triangle en cache un autre (quand un objet en cache un autre, par exemple). Prenons un mur rouge opaque qui cache un mur bleu. Dans ce cas, un pixel de l'écran sera associé à deux fragments : un pour le mur rouge, et un pour le bleu. Vu que le mur de devant est opaque, seul le fragment de ce mur doit être choisi : celui du mur qui est devant. Pour cela, chaque fragment a une coordonnée de profondeur, appelée la coordonnée z, qui indique la distance de ce fragment à la caméra. La profondeur est calculée à la rastérisation, ce qui fait que les ROP n'ont pas à la calculer, juste à trier les fragments en fonction de leur profondeur.

Petite précision : il est assez rare qu'un objet soit caché seulement par un seul objet. En moyenne, un objet est caché par 3 à 4 objets dans un rendu 3d de jeu vidéo.

La coordonnée z est un nombre qui est d'autant plus petit que l'objet est près de l'écran. On peut préciser qu'il existe des techniques alternatives pour coder la coordonnée de profondeur, qui se distinguent par le fait que la coordonnée z n'est pas proportionnelle à la distance entre le fragment et la caméra. Avec eux, la précision est meilleure pour les fragments proches de la caméra, et plus faible pour les fragments éloignés. Mais il s'agit là de détails assez mathématiques que je me permets de passer sous silence. Dans ce qui suit, nous allons juste parler de profondeur pour regrouper toutes ces techniques, conventionnelles ou alternatives.

Le Z-buffer modifier

 
Z-buffer correspondant à un rendu

Pour savoir quels fragments sont à éliminer (car cachés par d'autres), notre carte graphique va utiliser ce qu'on appelle un tampon de profondeur. Il s'agit d'un tableau, stocké en mémoire vidéo, qui mémorise la coordonnée z de l'objet le plus proche pour chaque pixel. Par défaut, ce tampon de profondeur est initialisé avec la valeur de profondeur maximale. Au fur et à mesure que les objets seront calculés, le tampon de profondeur est mis à jour, conservant ainsi la trace de l'objet le plus proche de la caméra. Si jamais un fragment a une coordonnée z plus grande que celle du tampon de profondeur, cela veut dire qu'il est situé derrière un objet déjà rendu et le tampon de profondeur n'a pas à être mis à jour. Dans le cas contraire, le fragment est plus près de la caméra et sa coordonnée z remplace l'ancienne valeur z dans le tampon de profondeur.

 
Illustration du processus de mise à jour du Z-buffer.

Rappelons que la coordonnée de profondeur est codée sur quelques bits, généralement de 16 à 32 bits. De nos jours, les Z-buffer de 16 bits sont abandonnés et toutes les cartes graphiques utilisent des coordonnées z de 24 à 32 bits. La raison est que les Z-buffer de 16 bits ont une précision insuffisante, ce qui fait que des artefacts peuvent survenir. Si deux objets sont suffisamment proches, le tampon de profondeur n'a pas la précision suffisante pour discriminer les deux objets. Pour lui, les deux objets sont à la même place. Conséquence : il faut bien choisir un des deux objets et ce choix se fait pixel par pixel, ce qui fait des artefacts visuels apparaissent. On parle alors de z-fighting. Voici ce que cela donne :

 
Z-fighting

Le circuit de gestion de la profondeur modifier

La profondeur est gérée par un circuit spécialisé, qui met à jour le tampon de profondeur. Pour chaque fragment, le ROP lit le tampon de profondeur, récupère la coordonnée z du pixel de destination dedans, compare celle-ci avec celle du fragment, et décide s'il faut mettre à jour ou non le tampon de profondeur. En conséquence, ce circuit effectue beaucoup de lectures et écritures en mémoire vidéo. Or, la bande passante mémoire est limitée et de nombreuses optimisations permettent d'optimiser le tout.

La z-compression modifier

Une première solution pour économiser la bande passante mémoire est la technique de z-compression, qui compresse le tampon de profondeur. Les techniques de z-compression découpent le tampon de profondeur en tiles, en blocs carrés, qui sont compressés séparément les uns des autres. Par exemple, la z-compression des cartes graphiques ATI radeon 9800, découpait le tampon de profondeur en tiles de 8 * 8 fragments, et les encodait avec un algorithme nommé DDPCM (Differential differential pulse code modulation). Le découpage en tiles ressemble à ce qui est utilisé pour les textures, pour les mêmes raisons : le calcul d'adresse est simplifié, compression et décompression sont plus rapides, etc.

Précisons que cette compression ne change pas la taille occupée par le tampon de profondeur, mais seulement la quantité de données lue/écrite dans le tampon de profondeur. La raison à cela est simple : les tiles ont une place fixe en mémoire. Par exemple, si une tile non-compressée prend 64 octets, on trouvera une tile tous les 64 octets en mémoire vidéo, afin de simplifier les calculs d'adresse, afin que le ROP sache facilement où se trouve la tile à lire/écrire. Avec une vraie compression, les tiles se trouveraient à des endroits très variables d'une image à l'autre. Par contre, la z-compression réduit la quantité de données écrite dans le tampon de profondeur. Par exemple, au lieu d'écrire une tile non-compressée de 64 octets, on écrira une tile de seulement 6 octets, les 58 octets restants étant pas lus ou écrits. On obtient un gain en performance, pas en mémoire.

Le format de compression ajoute souvent deux bits par tile, qui indiquent si la tile est compressée ou non, et si elle vaut zéro ou non. Le bit qui indique si la tile est compressée permet de laisser certaines tiles non-compressés, dans le cas où la compression ne permet pas de gagner de la place. Pour le bit qui indique si la tile ne contient que des 0, elle accélère la remise à zéro du tampon de profondeur. Au lieu de réellement remettre tout le tampon de profondeur à 0, il suffit de réécrire un bit par bloc. Le gain en nombre d'accès mémoire peut se révéler assez impressionnant.

 
AMD HyperZ

Le cache de profondeur modifier

Une autre solution complémentaire ajoute une ou plusieurs mémoires caches dans le ROP, dans le circuit de profondeur. Ce cache de profondeur stocke des portions du tampon de profondeur qui ont été lues ou écrite récemment. Comme cela, pas besoin de les recharger plusieurs fois : on charge un bloc une fois pour toutes, et on le conserve pour gérer les fragments qui suivent. Il s'agit d'un cache séparé, qui n'est relié à rien d'autre qu'aux circuits de profondeur. Sur certaines cartes graphiques, les données dans le cache de profondeur sont stockées sous forme compressées dans le cache de profondeur, là encore pour augmenter la taille effective du cache. D'autres cartes graphiques ont un cache qui stocke des données décompressées dans le cache de profondeur. Tout est question de compromis entre accès rapide au cache et augmentation de la taille du cache.

Il faut savoir que les autres unités de la carte graphique peuvent lire le tampon de profondeur, en théorie. Cela peut servir pour certaines techniques de rendu, comme pour le shadowmapping. De ce fait, il arrive que le cache de profondeur contienne des données qui sont copiées dans d'autres caches, comme les caches des processeurs de shaders. Le cache de profondeur n'est pas gardé cohérent avec les autres caches du GPU, ce qui signifie que les écritures dans le cache de profondeur ne sont pas propagées dans les autres caches du GPU. Si on modifie des données dans ce cache, les autres caches qui ont une copie de ces données auront une version périmée de la donnée. C'est souvent un problème, sauf dans le cas du cache de profondeur, pour lequel ce n'est pas nécessaire. Cela évite d'implémenter des techniques de cohérence des caches couteuses en circuits et en performance, alors qu'elles n'auraient pas d'intérêt dans ce cas précis.

La gestion de la transparence (Blending) modifier

En plus de la profondeur, il faut aussi gérer la transparence. La transparence/opacité d'un pixel/texel est codée par un nombre, la composante alpha, qui est ajouté aux trois couleurs RGB. Plus la composante alpha est élevée, plus le pixel est opaque. En clair, tout fragment contient une quatrième couleur en plus des couleurs RGB, qui indique si le fragment est plus ou moins transparent. Si un fragment transparent est placé devant un autre fragment, la couleur du pixel sera un mélange de la couleur du fragment transparent, et de la couleur du (ou des) fragments placé·s derrière. Le calcul à effectuer est très simple, et se limite en une simple moyenne pondérée par la transparence de la couleur des deux pixels.

 
Application de textures.

Le ROP peut aussi ajouter des effets de brouillard dans une scène 3D. Les premières cartes graphiques calculaient le brouillard dans les ROP, en fonction de la coordonnée de profondeur du fragment. De nos jours, il est calculé par les pixel shaders et les ROP n'incorporent plus de technique de brouillard spécialisés. Pour calculer le brouillard, on mélange la couleur finale du pixel avec une couleur de brouillard, la couleur de brouillard étant pondérée par la profondeur. Au-delà d'une certaine distance, l'objet est intégralement dans le brouillard : le brouillard domine totalement la couleur du pixel. En dessous d'une certaine distance, le brouillard est à zéro. Entre les deux, la couleur du brouillard et de l'objet devront toutes les deux être prises en compte dans les calculs. La formule de calcul exacte varie beaucoup, elle est souvent linéaire ou exponentielle.

Le circuit de gestion de la transparence modifier

Les fragments arrivant par paquets, calculés uns par uns par les unités de texture et de shaders, le calcul des couleurs est effectué progressivement. Pour cela, la carte graphique doit mettre en attente les résultats temporaires des mélanges pour chaque pixel. C'est le rôle du tampon de couleur, l'équivalent du tampon de profondeur pour les couleurs des pixels. À chaque fragment reçu, le ROP lit la couleur du pixel associé dans le tampon de couleur, fait ou non la moyenne pondérée avec le fragment reçu et enregistre le résultat. Ces opérations de test et de blending sont effectuées par un circuit spécialisé qui travaille en parallèle des circuits de calcul de la profondeur.

Et comme toujours, ce circuit fait beaucoup d'accès mémoire et cela peut être corrigé par des optimisations diverses. Là encore, comme pour le circuit de profondeur, on peut ajouter une mémoire cache spécialisée, ou compresser le tampon de couleurs.

Il est à noter que sur certaines cartes graphiques, l'unité en charge de calculer les couleurs peut aussi servir à effectuer des comparaisons de profondeur. Ainsi, si tous les fragments sont opaques, on peut traiter deux fragments à la fois. C'était le cas sur la Geforce FX de Nvidia, ce qui permettait à cette carte graphique d'obtenir de très bonnes performances dans le jeu DOOM3.


L'antialiasing

 
Effet d'escalier sur les lignes.

L'antialiasing est une technologie qui permet d'adoucir les bords des objets. Le fait est que dans les jeux vidéos, les bords des objets sont souvent pixelisés, ce qui leur donne un effet d'escalier illustré ci-contre. Le filtre d'antialiasing rajoute une sorte de dégradé pour adoucir les bords des lignes. Il existe un grand nombre de techniques d'antialiasing différentes. Toutes ont des avantages et des inconvénients en termes de performances ou de qualité d'image.

Le supersampling modifier

La plus simple de ces techniques, le SSAA - Super Sampling Anti Aliasing - calcule l'image à une résolution supérieure, avant de la réduire. Par exemple, pour rendre une image en 1280 × 1024 en antialiasing 4x, la carte graphique calcule une image en 2560 × 2048, avant de la réduire. Si vous regardez les options de vos pilotes de carte graphique, vous verrez qu'il existe plusieurs réglages pour l'antialiasing : 2X, 4X, 8X, etc. Cette option signifie que l'image calculé par la carte graphique contient respectivement 2, 4, ou 8 fois plus de pixels que l'image originale. Cette technique filtre toute l'image, y compris l'intérieur des textures, mais augmente la consommation de mémoire vidéo et de processeur (on calcule 2, 4, 8, 16 fois plus de pixels).

Le rendu de l'image se fait à une résolution 2, 4, 8, 16 fois plus grande. La résolution n'apparait qu'après le rastériseur, et impacte tout le reste du pipeline à sa suite : pixel shaders, unités de textures et ROP. Le rastériseur produit 2, 4, 8, 16 fois plus de pixels, les unités de textures vont 2, 4, 8, 16 fois plus de travail, idem pour les pixels shaders. Par contre, la réduction de l'image s'effectue dans les ROP. Pour effectuer la réduction de l'image, le ROP découpe l'image en rectangles de 2, 4, 8, 16 pixels, et « mélange » les pixels pour obtenir une couleur uniforme. Ce « mélange » est généralement une simple moyenne pondérée, mais on peut aussi utiliser des calculs plus compliqués comme une série d'interpolations linéaires similaire à ce qu'on fait pour filtrer des textures. Pour simplifier les explications, nous allons appeler "sous-pixels" les pixels de l'image rendue dans le pipeline, et pixels les pixels de l'image finale écrite dans le framebuffer. On parle aussi de samples au lieu de sous-pixels.

 
Supersampling
 
Supersampling

La position des sous-pixels modifier

Un point important concernant la qualité de l'antialiasing concerne la position des sous-pixels sur l'écran. Comme vous l'avez vu dans le chapitre sur la rastérisation, notre écran peut être vu comme une sorte de carré, dans lequel on peut repérer des points. Reste que l'on peut placer ces pixels n'importe où sur l'écran, et pas forcément à des positions que les pixels occupent réellement sur l'écran. Pour des pixels, il n'y a aucun intérêt à faire cela, sinon à dégrader l'image. Mais pour des sous-pixels, cela change tout. Toute la problématique peut se résumer en une phrase : où placer nos sous-pixels pour obtenir une meilleure qualité d'image possible.

  • La solution la plus simple consiste à placer nos sous-pixels à l'endroit qu'ils occuperaient si l'image était réellement rendue avec la résolution simulée par l'antialiasing. Cette solution gère mal les lignes pentues, le pire cas étant les lignes penchées de 45 degrés par rapport à l'horizontale ou la verticale.
  • Pour mieux gérer les bords penchés, on peut positionner nos sous-pixels comme suit. Les sous-pixels sont placés sur un carré penché (ou sur une ligne si l'on dispose seulement de deux sous-pixels). Des mesures expérimentales montrent que la qualité optimale semble être obtenue avec un angle de rotation d'arctan(1/2) (26,6 degrés), et d'un facteur de rétrécissement de √5/2.
  • D'autres dispositions sont possibles, notamment une disposition de type Quincunx.

Le multisampling (MSAA) modifier

Le Multi-Sampling Anti-Aliasing, abrévié en MSAA est une amélioration du SSAA qui économise certains calculs. Pour simplifier, c'est la même chose que le SSAA, sauf que les pixels shaders ne calculent pas l'image à une résolution supérieure, alors que tout le reste (rastérisation, ROP) le fait. Avec le MSAA, l'image à afficher est rendue dans une résolution supérieure, mais les fragments sont regroupés en carrés qui correspondent à un pixel. L'application des textures se fait par pixel et non pour chaque sous-pixel, de même que le pixel shader manipule des pixels, mais ne traite pas les sous-pixels. Avec le SSAA, chaque sous-pixel se verrait appliquer un morceau de texture et un pixel shader, alors qu'on applique la texture sur un pixel complet avec le MSAA.

Le calcul de la couleur finale du pixel se fait dans le ROP. Pour cela, le ROP a besoin d'une information quant à la position des sous-pixels. Le pixel final est associé à un triangle précis par la rastérisation. Cependant, cela ne signifie pas que tous les sous-pixels sont associés à ce triangle. En effet, les sous-pixels ne sont pas à la même place que le pixel dans la résolution inférieure. La position des sous-pixels est une chose dont nous parlerons plus en détail ci-dessous. Toujours est-il que l'étape de rastérisation précise si chaque sous-pixel est associé au triangle du pixel. Il se peut que tous les sous-pixels soient sur le triangle, ce qui est signe qu'on est pile sur l'objet, que les sous-pixels sont tous l'intérieur du triangle. Par contre, si certains sous-pixels sont en dehors, c'est signe que l'on est au bord d'un objet. Par exemple, le sous-pixel le plus à gauche sort du triangle, alors que les autres sont dessus. L'unité de rastérisation calcule un masque de couverture, qui précise, pour chaque pixel, quels sont les sous-pixels qui sont ou non dans le triangle. Si un pixel est composé de N sous-pixels, alors ces N sous-pixels sont numérotés de 0 à N-1 en passant dans l'ordre des aiguilles d'une montre. Le masque est un nombre dont chaque bit est associé à un sous-pixel. Le bit associé est à 1 si le sous-pixel est dans le triangle, 0 sinon.

Une fois calculé par l'unité de rastérisation, le masque de couverture est transmis aux pixels shaders. Les pixels shaders peuvent utiliser le masque de couverture pour certaines techniques de rendu, mais ce n'est pas une nécessité. Dans la plupart des cas, les pixels shaders ne font rien avec le masque de couverture et le passent tel quel aux ROP. Le ROP prend la couleur calculée par le pixel shader et le masque de couverture. Si un sous-pixel est complétement dans le triangle, sa couleur est celle de la texture. Si le sous-pixel est en dehors du triangle, sa couleur est mise à zéro. Le ROP fait la moyenne des couleurs des sous-pixels du bloc comme avec le SSAA. La seule différence avec le SSAA, c'est que la couleur du pixel calculée par le pixel shader est juste pondérée par le nombre de sous-pixels dans le triangle. Le résultat est que le MSAA ne filtre pas toute l'image, mais seulement les bords des objets, seuls endroit où l'effet d'escalier se fait sentir.

Les avantages et inconvénients comparé au SSAA modifier

Niveau avantages, le MSAA n'utilise qu'un seul filtrage de texture par pixel, et non par sous-pixel comme avec le SSAA, ce qui est un gain en performance notable. Le gain en calculs niveau pixel shader est aussi très important, tant que les techniques de rendu utilisant le masque de couverture ne sont pas utilisées. Le gain est d'autant plus important que la majorité des pixels sont situés en plein dans un triangle, les bords d'un objet ne concernant qu'une minorité de pixels/sous-pixels. Mais ce gain en performance a un revers : la qualité de l'antialiasing est moindre. Par définition, le MSAA ne filtre pas l'intérieur des textures, mais seulement les bords des objets.

Un défaut de cette technique est que la texture est plaquée au centre du pixel testé. Or, il se peut que le centre du pixel ne soit pas dans la primitive, ce qui arrive si la primitive ne recouvre qu'une petite partie du pixel. Dans un cas pareil, le pixel n'aurait pas été associé à la primitive sans antialiasing, mais il l'est quand l'antialiasing est activé. Un défaut est donc que la texture est appliquée là où elle ne devrait pas l'être. Le résultat est l'apparition d'artefacts graphiques assez légers, mais visibles sur certaines images. Une solution est d'altérer la position des sous-pixels sur le bord des objets pour qu'ils soient dans la primitive. Les sous-pixels sont alors disposés suivant un motif dit centroïde, où tous les sous-pixels sont déplacés de manière à être dans la primitive. Mais un défaut est que les dérivées, le niveau de détail et d'autres données nécessaires au plaquage de texture sont elles aussi altérées, ce qui peut gêner le filtrage de texture. Un autre problème de l'antialiasing tient dans la gestion des textures transparentes, que nous allons détailler dans la section suivante.

L'antialiasing sur les textures transparentes modifier

Pour les textures partiellement transparentes, l’antialiasing de type MSAA ne donne pas de bons résultats. Les textures partiellement transparentes servent à rendre des feuillages, des grillages, ou d'autres objets du genre. Prenons l'exemple d'un grillage. La texture de grillage est posée sur une surface carrée, les portions transparentes de la texture correspondant aux trous du grillage entre les grilles, et les portions opaques au grillage lui-même. Dans ce cas, les portions transparentes sont situées dans l'objet et ne sont pas antialiasées. Pourtant, un grillage ou un feuillage sont l'exemple type d'objets où l'effet d’escalier se manifeste. Le problème est surtout visible sur les textures rendues avec la technique de l'alpha-testing, où un pixel shader abandonne le rendu d'un pixel si sa transparence dépasse un certain seuil. Les pixels sont coloriés avec une texture, et les pixels trop transparents ne sont pas rendus, alors que les autres pixels sont rendus normalement, avec alpha-blending dans les ROP et autres.

Tout cela a poussé les fabricants de cartes graphiques à inventer diverses techniques pour appliquer l'antialiasing à l'intérieur des textures transparentes. L'idée la plus simple pour cela est d'appliquer le MSAA sur toute l'image, mais de passer en mode SSAA pour les portions de l'image où on a une texture transparente. Le SSAA n'a pas de problèmes pour filtrer l'intérieur des textures, là où le MSAA ne filtre pas l'intérieur des textures. Cela demande cependant de détecter les textures transparentes au niveau du pixel shader, et de les rendre à plus haute résolution façon SSAA. Cette technique a été utilisée sur les cartes NVIDIA sous le nom de transparency adaptive anti-aliasing (TAAA) et sur les cartes AMD sous le nom d'adaptive anti-aliasing.

Une autre méthode est la technique dite d'alpha to coverage, abrévié ATC. Son principe s'explique assez bien en comparant ce qu'on a avec ou sans ATC. Imaginons qu'un pixel soit colorié avec une texture transparente, sans ATC : le pixel se voit attribuer une composante alpha provenant de la texture transparente et passe le test alpha pour savoir s'il doit être rendu avant ou non. Avec ATC, le pixel shader génère un masque de couverture à partir de la composante alpha de la texture lue. Le masque de couverture ainsi généré est alors utilisé par les ROP et le reste du pipeline pour faire l'antialiasing. Cela garantit que les textures transparentes soient antialiasées.

Les optimisations du multisampling modifier

Avec l'antialiasing, l'image est rendue à une résolution supérieure, avant de subir un redimensionnement pour rentrer dans la résolution voulue. Cela a des conséquences sur le framebuffer. Le framebuffer a la taille nécessaire pour la résolution finale, cela ne change pas. Mais le z-buffer et les autres tampons utilisés par le ROP sont agrandis, afin de rendre l'image de résolution supérieure. De plus, le rendu de l'image intermédiaire à haute résolution se fait dans une sorte de pseudo-framebuffer temporaire. L'antialiasing rend l'image de haute résolution dans ce framebuffer temporaire, puis la redimensionne pour donner l'image finale dans le framebuffer final. Si on prend un antialiasing 4x, soit avec 4 fois plus de pixels que la résolution initiale, le z-buffer prend 4 fois plus de place, le framebuffer temporaire aussi.

Évidemment, cela prend beaucoup de mémoire vidéo, sans compter que rendre une image à une résolution supérieure prend beaucoup de bande passante, et diverses optimisations ont été inventées pour limiter la casse. Avec le multisampling, il n'est pas rare que plusieurs sous-pixels aient la même couleur. Autant les pixels situés sur les bords d'un objet/triangle ont tendance à avoir des sous-pixels de couleurs différentes, autant les pixels situés à l'intérieur d'un objet sont de couleur uniforme. Cela permet une certaine forme d'optimisation, qui vise à tenir compte de ce cas particulier. L'idée est de compresser le framebuffer de manière ne pas mémoriser la couleur de chaque sous-pixel pour un pixel uniforme. Au lieu d'écrire quatre couleurs identiques pour 4 sous-pixels, on écrit une seule fois la couleur pour le pixel entier.

Notons cependant qu'il existe un type de GPU pour lesquels ce genre d'optimisation n'est pas nécessaire. Rappelez-vous qu'il existe deux types de GPU : ceux en mode immédiat, sujet de ce cours, et ceux en rendu à tile. Avec ces derniers, l'écran est découpé en tiles qui sont rendues séparément, soit l'une après l'autre, soit en parallèle. Le traitement d'une tile fait que l'on n'a pas besoin d'un z-buffer pour toute l'image, mais d'un z-buffer par tile. Même chose pour le framebuffer temporaire, qui doit mémoriser la tile, pas plus. Les deux sont tellement petits qu'ils peuvent être mémorisés dans une SRAM intégrée aux ROP, et non en mémoire vidéo. L'antialiasing est donc réalisé intégralement dans les ROP, sans passer par la mémoire vidéo.

Le multisampling amélioré : Coverage Sampled Anti-Aliasing (CSAA) et de Enhanced Quality Anti-Aliasing (EQAA) modifier

Les techniques de multisampling précédentes rendaient l’image à une résolution supérieure, sauf dans les pixels shaders et l'étape de plaquage de textures. Mais la résolution supérieure était la même dans tous les pipeline de la carte graphique. Des techniques améliorées partent du même principe que le multisampling, mais changent la résolution suivant les étapes du pipeline. Concrètement, la résolution utilisée par le rastériseur n'est pas la même que dans les pixels shaders/textures, qui elle-même n'est pas la même que dans le z-buffer, qui n'est pas la même que celle du framebuffer temporaire, etc. C'est le principe des techniques de Coverage Sampled Anti-Aliasing (CSAA) et de Enhanced Quality Anti-Aliasing (EQAA).

Un nombre de sous-pixel par pixel qui varie suivant l'étape du pipeline modifier

Au lieu d'utiliser la résolution, nous allons utiliser le nombre de sous-pixels par pixel. Pour le dire autrement, on peut avoir 16 sous-pixels par pixel en sortie du rastériseur, mais 8 sous-pixels par pixel pour le masque de couverture, puis 4 sous-pixels pour le z-buffer et le framebuffer. Nous allons donner 4 caractéristiques :

  • le nombre de sous-pixels par pixel en sortie de la rastérisation ;
  • le nombre de sous-pixels traités par le pixel shader et/ou le laquage de textures ;
  • le nombre de sous-pixels par pixel dans le tampon de profondeur ;
  • le nombre de sous-pixels par pixel dans le color buffer, le framebuffer temporaire.

Ces 5 paramètres seront notés respectivement RSS, SSS, DSS, CSS et CCS.

Mode d'AA RSS SSS DSS CSS
Supersampling 8x 8 8 8 8
Multisampling 8x 8 1 8 8
Coverage Sampled Antialiasing 8x 8 1 4 4
Coverage Sampled Antialiasing 16x 16 1 4 16
Coverage Sampled Antialiasing 16xQ 16 1 8 16
Enhanced Quality Antialiasing 2f4x 4 1 2 4
Enhanced Quality Antialiasing 4f8x 8 1 4 8
Enhanced Quality Antialiasing 4f16x 16 1 4 16
Enhanced Quality Antialiasing 8f16x 16 1 8 16

En général, si on omet l'étape de pixel shading, la résolution diminue au fur et à mesure qu'on progresse dans le pipeline. La résolution est maximale en sortie du rastériseur et elle diminue ou reste constante à chaque étape suivante. Elle reste constante pour le multisampling pur, mais diminue dans les autres techniques. Ces dernières fusionnent plusieurs sous-pixels rastérisés en plus gros sous-pixels, qui eux sont stockés dans le framebuffer et le tampon de profondeur.

La compression du framebuffer temporaire modifier

De plus, ces techniques utilisent des techniques de compression similaires à celles utilisées pour les textures sont aussi utilisées. L'idée est simple : il est rare que tous les sous-pixels aient chacun une couleur différente. Prenons par exemple le cas d'un antialiasing 4x, donc un groupe de 4 sous-pixels par pixel : deux sous-pixels vont avoir la même couleur, les deux auront une autre couleur. Dans ce cas, pas besoin de mémoriser 4 couleurs : on a juste à mémoriser deux couleurs et un tableau de 4 bits qui précise quelle pixel a telle couleur (0 pour la première couleur, 1 pour l'autre). On peut adapter la technique avec un nombre plus élevé de sous-pixels et de couleurs.

Les techniques de compression les plus simples font que l'on mémorise 2 couleurs par tile de sous-pixels, de la même manière que le font les formats de compression de textures. D'autres techniques peuvent mémoriser 4 couleurs pour 8 sous-pixels, etc.

Mode d'AA Nombre de sous-pixels par pixel Nombre de couleurs par pixel
Supersampling et Multisampling 8x 8 8
Coverage Sampled Antialiasing 8x 8 4
Coverage Sampled Antialiasing 16x 16 4
Coverage Sampled Antialiasing 8xQ 8 8
Coverage Sampled Antialiasing 16xQ 16 4
Enhanced Quality Antialiasing 2f4x 4 2
Enhanced Quality Antialiasing 4f8x 8 4
Enhanced Quality Antialiasing 4f16x 16 4
Enhanced Quality Antialiasing 8f16x 16 8

L'antialiasing temporel modifier

L'antialiasing temporel (TAA pour temporal Anti-Aliasing) est une technique qui fonctionne comme le MSAA, mais répartie sur plusieurs frames, sur plusieurs images. L'idée de l'antialiasing temporel est que chaque image est mélangée avec les images rendues avant elle pour donner un effet d'antialiasing. Mais il ne s'agit pas d'un mélange bête et méchant où chaque image est la moyenne des précédentes. L'antialiasing temporel subdivise chaque pixel en sous-pixel, sauf qu'au lieu de traiter tous les sous-pixels à chaque image comme le font le super-sampling et le MSAA, elle ne traite qu'un sous-pixel par pixel à chaque image. Concrètement, si on prend un antialiasing 4x, où chaque pixel est subdivisé en 4 sous-pixels, le premier sous-pixel sera calculé par la première image, le second sous-pixel par la seconde image, etc. Il reste ensuite à appliquer l'opération de mélange sur les 4 images rendues auparavant. Naïvement, on pourrait croire que le filtre de mélange des sous-pixels est effectué toutes les 4 images, mais on peut le faire à chaque rendu d'image en prenant les 4 images précédemment calculées.

 
Exemple de la trainée de mouvement observée avec le TAA avec des objets en mouvement rapide.

L'avantage du TAA est qu'il est relativement léger en calculs. Si le filtre de mélange est calculé toutes les images, comme dans les techniques précédentes, on n'a pas à calculer tous les sous-pixels à chaque image, seulement 1 par pixel. La carte graphique rend chaque image à la même résolution que l'écran, mais chaque image a une position légèrement différente de la précédente, ce qui fait que le mélange de plusieurs images consécutives permet d'affiner la qualité d'image. Cette forme d'antialiasing améliore la qualité de toute l'image, contrairement au MSSA, mais comme le SSAA. Si le TAA marche très bien pour des scènes statiques, il se débrouille assez mal sur les scènes où la caméra bouge vite. Des mouvements trop rapides font que l'image a un flou de mouvement très important, sans compter que les objets en mouvement laissent une sorte de trainée de mouvement visible derrière eux. Pour éviter cela, les moteurs de jeux ont des méthodes pour éviter de rendre les objets en mouvement à certaines images bien placées, mais l'effet peut quand même se faire sentir. Notons cependant que le TAA marche d'autant mieux en qualité que le nombre d'images par secondes est élevé.

L'antialiasing par post-processing modifier

L'antialiasing par post-processing regroupe plusieurs techniques d'antialiasing différentes du SSAA et du MSAA. Avec elles, l'image n'est pas rendue à plus haute résolution avant d'être redimensionnée. A la place, l'image est calculée normalement, à sa résolution finale. Une fois l'image finale mémorisée dans le framebuffer, on lui applique un filtre d'antialiasing spécial. Le filtre en question varie selon la technique utilisée, mais l'idée générale est la même. C'est donc des techniques dites de post-processing, où on calcule l'image, avant de lui faire subir des filtres pour l'embellir. Le filtre en question peut être effectué par les ROP ou par un pixel shader, mais c'est surtout la seconde solution qui est retenue de nos jours. L'algorithme des filtres est généralement assez complexe, ce qui rend sont implémentation en matériel peu pertinente.

Les avantages et inconvénients modifier

Contrairement au MSAA, l'antialiasing par post-processing n'a aucune connaissance de la géométrie de la scène, n'a aucune connaissance des informations données par la rastérisation, n'utilise même pas de sous-pixels. C'est un avantage, car le FXAA filtre la totalité de la scène 3D, même à l'intérieur des textures, et même à l'intérieur des textures transparentes.

Par contre, cela peut causer des artefacts graphiques sur certaines portions de l'image. Quand le FXAA est activé, le texte affiché sur une image devient légèrement moins lisible, par exemple. Les techniques de post-processing ont l'avantage de mieux marcher avec les moteurs de jeux qui utilisent des techniques de rendu différés, dans lesquels une bonne partie des traitements d'éclairage se font sur l'image finale rendue dans le framebuffer.

Les différentes méthodes d'antialiasing par post-processing modifier

Le Fast approximate anti-aliasing (FXAA), et le Subpixel Morphological Antialiasing (SMAA) sont les premières techniques d'antialiasing par post-processing à avoir été intégrées dans les cartes graphiques modernes. Pour le FXAA, le filtre détermine les zones à filtrer en analysant le contraste. Les zones de l'image où le contraste évolue fortement d'un pixel à l'autre sont filtrées, alors que les zones où le contraste varie peu sont gardées intactes. La raison est que les zones où le contraste varie rapidement sont généralement les bords des objets, ou du moins des zones où l'effet d'escalier se fait sentir. l'algorithme exact est dans le domaine public et on peut le trouver facilement sur le net. Par contre, il est difficile d'en expliquer le fonctionnement, pourquoi il marche, aussi je passe cela sous silence.

Les cartes graphiques récentes utilisent des techniques basées sur des réseaux de neurones pour effectuer de l'antialiasing. La première d'entre elle est le Deep learning dynamic super resolution (DLDSR), qui consiste à rendre l'image en plus haute résolution, puis à lui appliquer un filtre pour en réduire la résolution. C'est un peu la même chose que le supersampling, sauf que le supersampling est réalisé dans les ROP, alors que le DLDSR est effectué avec une phase de rendu complète, suivie par l’exécution d'un shader qui redimensionne l'image. Une technique opposée est le Deep learning super sampling (DLSS), qui rend l'image à une résolution inférieure, mais applqiue un filtre qui redimensionne l'image à une plus haute résolution. La première version utilisait un filtre de post-processing, mais les versions suivantes utilisent de lantialising temporel. Quoique en soit, toutes les versions de DLSS appliquent une forme d'antialiasing, même si elles upscalent aussi l'image.


Le multi-GPU

 
Illustration du multi-GPU où deux cartes graphiques communiquent via un lien indépendant du bus PCIExpress. On voit que le débit du lien entre les deux cartes graphique est ajouté au débit du bus PCIExpress.

Combiner plusieurs cartes graphiques dans un PC pour gagner en performances est la base des techniques dites de multi-GPU, tels le SLI et le Crossfire. Ces technologies sont surtout destinées aux jeux vidéo, même si les applications de réalité virtuelle, l'imagerie médicale haute précision ou les applications de conception par ordinateur peuvent en tirer profit. C'est ce genre de choses qui se cachent derrière les films d'animation ou les effets spéciaux créés par ordinateur : Pixar ou Disney ont vraiment besoin de rendre des images très complexes, avec beaucoup d'effets, ce qui demande la coopération de plusieurs cartes graphiques.

Contrairement à ce qu'on pourrait penser, le multi-GPU n'est pas une technique récente. Pensez donc qu'en 1998, il était possible de combiner dans un même PC deux cartes graphiques Voodoo 2, de marque 3dfx (un ancien fabricant de cartes graphiques, aujourd'hui racheté par NVIDIA). Autre exemple : dans les années 2006, le fabricant de cartes graphiques S3 avait introduit cette technologie pour ses cartes graphiques Chrome.

Le multi-GPU peut se présenter sous plusieurs formes, la plus simple consistant à placer plusieurs GPU sur une même carte graphique. Mais il est aussi possible d'utiliser plusieurs cartes graphiques séparées, connectées à la carte mère via PCI-Express. Si les deux cartes ont besoin d’échanger des informations, les transferts passent par le bus PCI-Express ou par un connecteur qui relie les deux cartes (ce qui est souvent plus rapide). Il n'y a pas de différences de performances avec la solution utilisant des cartes séparées reliées avec un connecteur. Tout le problème des solutions multi-GPU est de répartir les calculs sur plusieurs cartes graphiques, ce qui est loin d'être chose facile. Il existe diverses techniques, chacune avec ses avantages et ses inconvénients, que nous allons aborder de suite.

Le Split Frame Rendering modifier

Le Split Frame Rendering découpe l'image en morceaux, qui sont répartis sur des cartes graphiques différentes. Ce principe a été décliné en plusieurs versions, et nous allons les passer en revue. Nous pouvons commencer par faire la différence entre les méthodes de distribution statiques et dynamiques. Avec les méthodes statiques, la manière de découper l'image est toujours la même : celle-ci sera découpée en blocs, en lignes, en colonnes, etc; de la même façon quel que soit l'image. Avec les techniques dynamiques, le découpage s'adapte en fonction de la complexité de l'image. Nous allons commencer par aborder les méthodes statiques.

Le Scanline interleave modifier

Historiquement, la première technique multi-GPU inventée s'appelait le Scan Line Interleave et elle fût utilisée par les cartes graphiques Voodoo 2. Avec cette technique, chaque carte graphique calculait une ligne sur deux, la première carte rendait les lignes paires et l'autre les lignes impaires. Notons que cette technique ressemble à la technique de l'entrelacement vue dans le tout premier chapitre. Cependant, on peut adapter la technique à un nombre arbitraire de GPU, en faisant calculer par chaque GPU une ligne sur 3, 4, 5, etc.

 
Scanline interleave

Cette technique avait un avantage certain quand la résolution des images était limitée par la quantité de mémoire vidéo, ce qui était le cas de la Voodoo 2, qui ne pouvait pas dépasser une résolution de 800 * 600. Avec le scan line interleave, les deux framebuffers des deux cartes étaient combinés en un seul framebuffer plus gros, capable de supporter des résolutions plus élevées. Cette technique a toutefois un gros défaut : l’utilisation de la mémoire vidéo n'est pas optimale. Comme vous le savez, la mémoire vidéo sert à stocker les objets géométriques de la scène à rendre, les textures, et d'autres choses encore. Avec le scan line interleave, chaque objet et texture est présent dans la mémoire vidéo de chaque carte graphique. Il faut dire que ces objets et textures sont assez grands : la carte graphique devant rendre une ligne sur deux, il est très rare qu'un objet doive être rendu totalement par une des cartes et pas l'autre. Avec d'autres techniques, cette consommation de mémoire peut être mieux gérée.

Le Checker board modifier

La technique du Checker Board découpe l'image non en lignes, mais en carrés de plusieurs pixels. Dans le cas le plus simple, les carrés ont une taille fixe, de 16 pixels de largeur par exemple. Si les carrés sont suffisamment gros, il arrive qu'ils puissent contenir totalement un objet géométrique. Dans ces conditions, une seule carte graphique devra calculer cet objet géométrique et charger ses données, qui ne seront donc pas dupliquées dans les deux cartes. Le gain en terme de mémoire peut être appréciable si les blocs sont suffisamment gros. Mais il arrive souvent qu'un objet soit à la frontière entre deux blocs : il doit donc être rendu par les deux cartes, et sera stocké dans les deux mémoires vidéos.

Pour plus d'efficacité, on peut passer d'un découpage statique, où tous les carrés ont la même taille, à un découpage dynamique, dans lequel on découpe l'image en rectangles dont la longueur et la largeur varient. En faisant varier le mieux possible la taille et la longueur de ces rectangles, on peut faire en sorte qu'un maximum de rectangles contiennent totalement un objet géométrique. Le gain en terme de mémoire et de rendu peut être appréciable. Néanmoins, découper des blocs dynamiquement est très complexe, et le faire efficacement est un casse-tête pour les développeurs de drivers.

Le Screen spiting modifier

Il est aussi possible de simplement couper l'image en deux : la partie haute de l'image ira sur un GPU, et la partie basse sur l'autre. Cette technique peut être adaptée avec plusieurs GPU, en découpant l'image en autant de parties qu'il y a de GPU. Vu que de nombreux objets n'apparaissent que dans une portion de l'image, le drivers peut ainsi répartir les données de l'objet pour éviter toute duplication entre cartes graphiques. Cela demande du travail au driver, mais cela en vaut la peine, le gain en terme de mémoire étant appréciable.

 
Screen spliting

Le découpage de l'image peut reposer sur une technique statique : la moitié haute de l'image pour le premier GPU, et le bas pour l'autre. Ceci dit, quelques complications peuvent survenir dans certains jeux, les FPS notamment, où le bas de l'image est plus chargé que le haut. C'est en effet dans le bas de l'image qu'on trouve un sol, des murs, les ennemis, ou d'autres objets géométriques complexes texturés, alors que le haut représente le ciel ou un plafond, assez simple géométriquement et aux textures simples. Ainsi, le rendu de la partie haute sera plus rapide que celui du bas, et une des cartes 3D finira par attendre l'autre.

Mieux répartir les calculs devient alors nécessaire. Pour cela, on peut choisir un découpage statique adapté, dans lequel la partie haute envoyée au premier GPU est plus grande que la partie basse. Cela peut aussi être fait dynamiquement : le découpage de l'image est alors choisi à l’exécution, et la balance entre partie haute et basse s'adapte aux circonstances. Comme cela, si vous voulez tirer une roquette sur une ennemi qui vient de prendre un jumper (vous ne jouez pas à UT ou Quake ?), vous ne subirez pas un gros coup de lag parce que le découpage statique était inadapté. Dans ce cas, c'est le driver qui gère ce découpage : il dispose d'algorithmes plus ou moins complexes capables de déterminer assez précisément comment découper l'image au mieux. Mais il va de soit que ces algorithmes ne sont pas parfaits.

L'Alternate Frame Rendering modifier

L'alternate Frame Rendering consiste à répartir des images complètes sur les différents GPUs. Dans sa forme la plus simple, un GPU calcule une image, et l'autre GPU calcule la suivante en parallèle. Les problèmes liés à la répartition des calculs entre cartes graphiques disparaissent alors. Cette technique est supportée par la majorité des cartes graphiques actuelles. Cette technique a été inventé par ATI, sur ses cartes graphiques Rage Fury, afin de faire concurrence à la Geforce 256. Évidemment, on retrouve un vieux problème présent dans certaines des techniques vues avant : chaque objet géométrique devra être présent dans la mémoire vidéo de chaque carte graphique, vu qu'elle devra l'afficher à l'écran. Il est donc impossible de répartir les différents objets dans les mémoires des cartes graphiques. Mais d'autres problèmes peuvent survenir.

Un des défauts de cette approche est le micro-stuttering. Dans des situations où le processeur est peu puissant, les temps entre deux images peuvent se mettre à varier très fortement, et d'une manière beaucoup moins imprévisible. Le nombre d'images par seconde se met à varier rapidement sur de petites périodes de temps. Alors certes, on ne parle que de quelques millisecondes, mais cela se voit à l’œil nu. Cela cause une impression de micro-saccades, que notre cerveau peut percevoir consciemment, même si le temps entre deux images est très faible. Suivant les joueurs, des différences de 10 à 20 millisecondes peuvent rendre une partie de jeu injouable. Pour diminuer l'ampleur de ce phénomène, les cartes graphiques récentes incorporent des circuits pour limiter la casse. Ceux-ci se basent sur un principe simple : pour égaliser le temps entre deux images, et éviter les variations, le mieux est d’empêcher des images de s'afficher trop tôt. Si une image a été calculée en très peu de temps, on retarde son affichage durant un moment. Le temps d'attente idéal est alors calculé en fonction de la moyenne du framerate mesuré précédemment.

Ensuite, il arrive que deux images soient dépendantes les unes des autres : les informations nées lors du calcul d'une image peuvent devoir être réutilisées dans le calcul des images suivantes. Cela arrive quand des données géométriques traitées par la carte graphique sont enregistrées dans des textures (dans les Streams Out Buffers pour être précis), dans l'utilisation de fonctionnalités de DirectX ou d'Open GL qu'on appelle le Render To Texture, ainsi que dans quelques autres situations. Évidemment, avec l'AFR, cela pose quelques problèmes : les deux cartes doivent synchroniser leurs calculs pour éviter que l'image suivante rate des informations utiles, et soit affichée n'importe comment. Sans compter qu'en plus, les données doivent être transférées dans la mémoire du GPU qui calcule l'image suivante.


Les architectures de type tiled rendering

 
Tiled rendering architecture.

Les architectures en tiles sont une classe de carte 3D légèrement différente de celles vues précédemment. Sur ces architectures, l'écran/image à rendre est découpé en rectangles, rendus indépendamment, uns par uns. Ces rectangles sont appelés des tiles, d'où le nom d'architectures à tiles donné à ce type de cartes graphiques.Le rendu commence par calculer tout ce qui a trait à la géométrie, en général, sans tenir compte des tiles. Le résultat des calculs géométriques est alors mémorisé en mémoire vidéo dans une display list, où les triangles sont répartis suivant leur tile de destination. Ensuite, les tiles sont rastérisées et texturées indépendamment, l'une après l'autre.

L'architecture globale d'une carte graphique à tiles change peu comparé à une carte à rastérisation, si ce n'est que l'étape de primitive assembly est modifiée pour devenir une pahse où les triangles sont répartis dans des tiles de destination. Le vrai changement tient dans le fait que le rendu d'une tile se fait avec l'aide d'une mémoire intégrée qui mémorise la tile en cours de rendu et dans laquelle on effectue les tests de profondeur et autres opérations.

Les avantages et inconvénients modifier

L'avantage des architectures en tiles est qu'elles réduisent fortement l'utilisation de la mémoire. De nombreux accès mémoire disparaissent, notamment tous les accès effectués par le ROP pour les calculs de profondeur et de transparence. Une architecture à tile a juste besoin d'un Z-Buffer pour la tile en cours de traitement, là où les cartes graphiques normales ont besoin d'un Z-buffer pour toute l'image. De plus, les tiles sont tellement petites que l'on peut stocker tout le Z-Buffer dans une mémoire cache intégrée dans l'ISP. La carte graphique finalise le calcul du tile dans ce cache et n'écrit que la tile finale en mémoire vidéo. Ce cache réduit fortement les besoins en bande passante et en débit mémoire, ce qui rend inutile de nombreuses optimisations, comme la compression du Z-buffer et élimine de nombreux caches, comme le cache de couleur ou le cache de profondeur. Le rendu en mode immédiat ne permet pas ce genre de facéties

De plus, elles permettent d'éliminer rapidement les portions non-affichées de la scène 3D, dès la sortie de la rastérisation. L'élimination des pixels et triangles cachés s'effectue dès que la profondeur est disponible, c'est à dire à l'étape de rastérisation. L'Image Synthesis Processor remplace en quelque sorte le Z-Buffer et les circuits d'élimination des pixels cachés.

Le principal défaut du rendu en tiles est que le rendu se fait en deux passes, avec une mémorisation du résultat de la première passe en mémoire vidéo. Et cette mémorisation demande beaucoup de lectures et d'écritures : d'écritures pour mémoriser le résultat de la première passe, de lectures pour l'utiliser dans la seconde passe. La mémoire vidéo est donc beaucoup utilisée, ce qui est un désavantage pour les cartes graphiques à haute performance. L'usage de mémoires cache compense cependant ce désavantage pour les architectures à tiles. Finalement, ce qui est économisé d'un côté est gaspillé de l'autre et tout est histoire de compromis. De plus, diverses optimisations spécifiques permettent d'éliminer des lectures/écritures "superflues", ce qui complexifie la comparaison avec une carte graphique normale.


Le support matériel du lancer de rayons

 
Image rendue avec le lancer de rayons.

Les cartes graphiques actuelles utilisent la technique de la rastérisation, qui a été décrite en détail dans le chapitre sur les cartes accélératrices 3D. Mais nous avions dit qu'il existe une seconde technique générale pour le rendu 3D, totalement opposée à la rastérisation, appelée le lancer de rayons. Cette technique a cependant été peu utilisée dans les jeux vidéo, jusqu'à récemment. La raison est que le lancer de rayons demande beaucoup de puissance de calcul, sans compter que créer des cartes accélératrices pour le lancer de rayons n'est pas simple.

Mais les choses commencent à changer. Quelques jeux vidéos récents intègrent des techniques de lancer rayons, pour compléter un rendu effectué principalement en rastérisation. De plus, les cartes graphiques modernes incorporent quelques circuits pour accélérer le lancer de rayons, même s'ils restent marginaux des compléments au rendu par rastérisation. S'il a existé des cartes accélératrices totalement dédiées au rendu en lancer de rayons, elles sont restées confidentielles. Aussi, nous allons nous concentrer sur les cartes graphiques récentes, et allons peu parler des cartes accélératrices dédiées au lancer de rayons.

Le lancer de rayons modifier

Le lancer de rayons et la rastérisation. commencent par générer la géométrie de la scène 3D, en plaçant les objets dans la scène 3D, et en effectuant l'étape de transformation des modèles 3D, et les étapes de transformation suivante. Mais les ressemblances s'arrêtent là. Le lancer de rayons effectue l'étape d'éclairage différemment, sans compter qu'il n'a pas besoin de rastérisation.

Le ray-casting : des rayons tirés depuis la caméra modifier

La forme la plus simple de lancer de rayon s'appelle le ray-casting. Elle émet des lignes droites, des rayons qui partent de la caméra et qui passent chacun par un pixel de l'écran. Les rayons font alors intersecter les différents objets présents dans la scène 3D, en un point d'intersection. Le moteur du jeu détermine alors quel est le point d'intersection le plus proche, ou plus précisément, le sommet le plus proche de ce point d'intersection. Ce sommet est associé à une coordonnée de textures, ce qui permet d'associer directement un texel au pixel associé au rayon.

En somme, l'étape de lancer de rayon et le calcul des intersections remplacent l'étape de rasterisation, mais les étapes de traitement de la géométrie et des textures existent encore. Après tout, il faut bien placer les objets dans la scène 3D/2D, faire diverses transformations, les éclairer. On peut gérer la transparence des textures assez simplement, si on connait la transparence sur chaque point d'intersection d'un rayon, information présente dans les textures.

 
Exemple de rendu en ray-casting 2D dans un jeu vidéo.

En soi, cet algorithme est simple, mais il a déjà été utilisé dans pas mal de jeux vidéos. Mais sous une forme simple, en deux dimensions ! Les premiers jeux IdSoftware, dont Wolfenstein 3D et DOOM, utilisaient cette méthode de rendu, mais dans un univers en deux dimensions. Cet article explique bien cela : Comment DOOM et Wolfenstein affichaient leurs graphismes. Au passage, si vous faites des recherches sur le raycasting, vous verrez que le terme est souvent utilisé pour désigner la méthode de rendu de ces vieux FPS, alors que ce n'en est qu'un cas particulier.

 
Simple raycasting with fisheye correction

Le raytracing proprement dit modifier

Le lancer de rayon proprement dit est une forme améliorée de raycasting dans la gestion de l'éclairage et des ombres est modifiée. Le lancer de rayon calcule les ombres assez simplement, sans recourir à des algorithmes compliqués. L'idée est qu'un point d'intersection est dans l'ombre si un objet se trouve entre lui et une source de lumière. Pour déterminer cela, il suffit de tirer un trait entre les deux et de vérifier s'il y a un obstacle/objet sur le trajet. Si c'est le cas, le point d'intersection n'est pas éclairé par la source de lumière et est donc dans l'ombre. Si ce n'est pas le cas, il est éclairé avec un algorithme d'éclairage.

Le trait tiré entre la source de lumière et le point d'intersection est en soi facile : c'est rayon, identique aux rayons envoyés depuis la caméra. La différence est que ces rayons servent à calculer les ombres, ils sont utilisés pour une raison différente. Il faut donc faire la différence entre les rayons primaires qui partent de la caméra et passent par un pixel de l'écran, et les rayon d'ombrage qui servent pour le calcul des ombres.

 
Principe du lancer de rayons.

Les calculs d'éclairage utilisés pour éclairer/ombrer les points d'intersection vous sont déjà connus : la luminosité est calculée à partir de l'algorithme d'éclairage de Phong, vu dans le chapitre "L'éclairage d'une scène 3D : shaders et T&L". Pour cela, il faut juste récupérer la normale du sommet associé au point d'intersection et l'intensité de la source de lumière, et de calculer les informations manquantes (l'angle normale-rayons de lumière, autres). Il détermine alors la couleur de chaque point d'intersection à partir de tout un tas d'informations.

Le raytracing récursif modifier

 
Image rendue avec le lancer de rayons récursif.

La technique de lancer de rayons précédente ne gère pas les réflexions, les reflets, des miroirs, les effets de spécularité, et quelques autres effets graphiques de ce style. Pourtant, ils peuvent être implémentés facilement en modifiant le raycasting d'une manière très simple.

Il suffit de relancer des rayons à partir du point d'intersection. La direction de ces rayons secondaires est calculée en utilisant les lois de la réfraction/réflexion vues en physique. De plus, les rayons secondaires peuvent eux-aussi créer des rayons secondaires quand ils sont reflétés/réfractés, etc. La technique est alors appelée du lancer de rayons récursif, qui est souvent simplement appelée "lancer de rayons".

 
Lancer de rayon récursif.

Les avantages et désavantages comparé à la rastérisation modifier

L'avantage principal du lancer de rayons est son rendu de l'éclairage, qui est plus réaliste. Les ombres se calculent naturellement avec cette méthode de rendu, là où elles demandent des ruses comme des lightmaps, des textures pré-éclairées, divers algorithmes d'éclairage géométriques, etc. Les réflexions et la réfraction sont gérées naturellement par le lancer de rayon récursif, alors qu'elles demandent des ruses de sioux pour obtenir un résultat correct avec la rastérisation.

 
Volume délimité par la caméra (view frustum).

Pour ce qui est des performances théoriques, le lancer de rayons se débrouille mieux sur un point : l'élimination des pixels/surfaces cachés. La rastérisation a tendance à calculer inutilement des portions non-rendues de la scène 3D, et doit utiliser des techniques de culling ou de clipping pour éviter trop de calculs inutiles. Ces techniques sont très puissantes, mais imparfaites. De nombreux fragments/pixels calculés sont éliminés à la toute fin du pipeline, grâce au z-buffer, après avoir été calculés et texturés. Le lancer de rayons se passe totalement de ces techniques d'élimination des pixels cachés, car elle ne calcule pas les portions invisibles de l'image par construction : pas besoin de culling, de clipping, ni même de z-buffer.

Mais tous ces avantages sont compensés par le fait que le lancer de rayons est plus lent sur un paquet d'autres points. Déjà, les calculs d'intersection sont très lourds, ils demandent beaucoup de calculs et d'opérations arithmétiques, plus que pour l'équivalent en rastérisation. Ensuite, de nombreuses optimisations présentes en rastérisation ne sont pas possibles. Notamment, le lancer de rayon utilise assez mal la mémoire, dans le sens où les accès en mémoire vidéo sont complétement désorganisés, là où le rendu 3D a des accès plus linéaires qui permettent d'utiliser des mémoires caches.

Les optimisations du lancer de rayons liées aux volumes englobants modifier

Les calculs d'intersections sont très gourmands en puissance de calcul. Sans optimisation, on doit tester l'intersection de chaque rayon avec chaque triangle. Mais diverses optimisations permettent d'économiser des calculs. Elles consistent à regrouper plusieurs triangles ensemble pour rejeter des paquets de triangles en une fois. Pour cela, la carte graphique utilise des structures d'accélération, qui mémorisent les regroupements de triangles, et parfois les triangles eux-mêmes.

Les volumes englobants modifier

 
Objet englobant : la statue est englobée dans un pavé.

L'idée est d'englober chaque objet par un pavé appelé un volume englobant. Le tout est illustré ci-contre, avec une statue représentée en 3D. La statue est un objet très complexe, contenant plusieurs centaines ou milliers de triangles, ce qui fait que tester l'intersection d'un rayon avec chaque triangle serait très long. Par contre, on peut tester si le rayon intersecte le volume englobant facilement : il suffit de tester les 6 faces du pavé, soit 12 triangles, pas plus. S'il n'y a pas d'intersection, alors on économise plusieurs centaines ou milliers de tests d'intersection. Par contre, s'il y a intersection, on doit vérifier chaque triangle. Vu que les rayons intersectent souvent peu d'objets, le gain est énorme !

L'usage seul de volumes englobant est une optimisation très performante. Au lieu de tester l'intersection avec chaque triangle, on teste l'intersection avec chaque objet, puis l'intersection avec chaque triangle quand on intersecte chaque volume englobant. On divise le nombre de tests par un facteur quasi-constant, mais de très grande valeur.

Il faut noter que les calculs d'intersection sont légèrement différents entre un triangle et un volume englobant. Il faut dire que les volumes englobant sont généralement des pavées, ils utilisent des rectangles, etc. Les différences sont cependant minimales.

Les hiérarchies de volumes englobants modifier

Mais on peut faire encore mieux. L'idée est de regrouper plusieurs volumes englobants en un seul. Si une dizaine d'objets sont proches, leurs volumes englobants seront proches. Il est alors utile d'englober leurs volumes englobants dans un super-volume englobant. L'idée est que l'on teste d'abord le super-volume englobant, au lieu de tester la dizaine de volumes englobants de base. S'il n'y a pas d'intersection, alors on a économisé une dizaine de tests. Mais si intersection, il y a, alors on doit vérifier chaque sous-volume englobant de base, jusqu'à tomber sur une intersection. Vu que les intersections sont rares, on y gagne plus qu'on y perd.

Et on peut faire la même chose avec les super-volumes englobants, en les englobant dans des volumes englobants encore plus grands, et ainsi de suite, récursivement. On obtient alors une hiérarchie de volumes englobants, qui part d'un volume englobant qui contient toute la géométrie, hors skybox, qui lui-même regroupe plusieurs volumes englobants, qui eux-mêmes...

 
Hiérarchie de volumes englobants.

Le nombre de tests d'intersection est alors grandement réduit. On passe d'un nombre de tests proportionnel aux nombres d'objets à un nombre proportionnel à son logarithme. Plus la scène contient d'objets, plus l'économie est importante. La seule difficulté est de générer la hiérarchie de volumes englobants à partir d'une scène 3D. Divers algorithmes assez rapides existent pour cela, ils créent des volumes englobants différents. Les volumes englobants les plus utilisés dans les cartes 3D sont les axis-aligned bounding boxes (AABB).

La hiérarchie est mémorisée en mémoire RAM, dans une structure de données que les programmeurs connaissent sous le nom d'arbre, et précisément un arbre binaire ou du moins d'un arbre similaire (k-tree). Traverser cet arbre pour passer d'un objet englobant à un autre plus petit est très simple, mais a un défaut : on saute d'on objet à un autre en mémoire, les deux sont souvent éloignés en mémoire. La traversée de la mémoire est erratique, sautant d'un point à un autre. On n'est donc dans un cas où les caches fonctionnent mal, ou les techniques de préchargement échouent, où la mémoire devient un point bloquant car trop lente.

Le matériel pour accélérer le lancer de rayons modifier

Le lancer de rayons a toujours besoin de calculer la géométrie, d'appliquer des textures et de faire des calculs d'éclairage. Sachant cela, beaucoup de chercheurs se sont dit qu'il n'était pas nécessaire d'utiliser du matériel spécialisé pour le lancer de rayons, et qu'utiliser les GPU actuels était suffisant. En théorie, il est possible d'utiliser des shaders pour effectuer du lancer de rayons, mais la technique n'est pas très performante.

Une carte graphique spécialement dédiée au lancer de rayons est donc quasiment identique à une carte graphique normale. Les deux contiennent des unités de texture et des processeurs de shaders, des unités géométriques, un input assembler, et tout ce qui va avec. Seuls les circuits de rasterisation et le z-buffer sont remplacés par des circuits dédiés au lancer de rayon, à savoir des unités de génération des rayons et des unités pour les calculs d'intersection. D'ailleurs, il est aussi possible d'ajouter des circuits de génération/intersection de rayons à une carte graphique existante.

Au niveau matériel, la gestion de la mémoire est un peu plus simple. Le lancer de rayon n'écrit que dans le framebuffer et n'a pas besoin de z-buffer, ce qui a beaucoup d'avantages. Notamment, les caches sont en lecture seule, leurs circuits sont donc assez simples et performants, les problèmes de cohérence des caches disparaissent. L'absence de z-buffer fait que de nombreuses écritures/lectures dans le framebuffer sont économisées. Les lectures et écritures de textures sont tout aussi fréquentes qu'avec la rasterisation, et sont même plus faible en principe car de nombreux effets de lumières complexes sont calculés avec le lancer de rayons, alors qu'ils doivent être précalculés dans des textures et lus par les shaders.

Les circuits spécialisés pour les calculs liés aux rayons modifier

Toute la subtilité du lancer de rayons est de déterminer si un rayon intersecte un triangle. Pour cela, il existe des algorithmes basés sur du calcul vectoriel pour déterminer si intersection il y a, et quelles sont ses coordonnées. Il est possible de tester un grand nombre d'intersections de triangles en parallèles, chacun dans une unité de calcul séparée. L'algorithme de lancer de rayons se parallélise donc très bien. En soi, les circuits de détection des intersections sont très simples et se résument à un paquet de circuits de calcul (addition, multiplications, division, autres), connectés les uns aux autres. Il y a assez peu à dire dessus. Mais les autres circuits sont très intéressants à étudier.

La génération des rayons primaire/secondaire/d'ombrage modifier

La génération des rayons n'est pas une étape qui demande beaucoup de puissance de calcul, et elle peut être faite par un processeur de shaders sans problèmes. Pour du rendu en raycasting, il y a juste à générer les rayons primaires, rien de plus. Et la génération des rayons primaires peut être faite par une unité spécialisée, même si ce n'est pas le cas sur les cartes 3D modernes. La génération des rayons d'ombrage et des rayons secondaires est par contre plus complexe.

Les rayons d'ombrage et secondaires sont générés à partir du résultat des intersections des rayons précédents. Et dans les faits, ce sont les shaders qui s'occupent de générer les rayons secondaires. Suivant la nature de la surface (opaque, transparente, réfléchissante, mate, autre), ils décident s'il faut ou non émettre un rayon secondaire, et génère ce rayon s'il le faut. Le rayon est alors envoyé aux unités de traversée et d'intersection.

La traversée des structures d'accélérations et des BVH modifier

Les cartes graphiques modernes incorporent des circuits pour accélérer les accès mémoire aux BVH. Ces circuits se trouvent vraisemblablement dans les unités de texture, si on en croit quelques brevets déposés par les fabricants de carte graphiques, ce qui est logique vu qu'il s'agit d'un des seuls endroits où la carte graphique gère des accès mémoire une fois la géométrie rendue. Et ces unités effectuent un travail essentiel. Car si le calcul des intersections est une chose facile pour la carte graphique, la gestion des structures d'accélération ne l'est pas du tout. Et ce pour plusieurs raisons.

Déjà, les BVH se marient assez mal avec les mémoires caches et la hiérarchie mémoire. Ce sont des structures de données qui dispersent les données en mémoire, dans le sens où chaque volume englobant est située dans sa propre portion de mémoire et celles-ci ne sont pas collées les unes aux autres en mémoire RAM. A l'inverse, les autres structures de données utilisées par la carte graphique, comme les tampons de sommets ou d'indice, les textures, sont des tableaux, des structures de données compactes qui forment un seul bloc en RAM. Traverser un BVH demande de faire des sauts en mémoire RAM, et ce sont des accès mémoire imprévisibles que l'on ne peut pas optimiser, pour lesquels les caches sont inopérants.

L'autre raison, très liée à la précédente, est que traverser un BVH pour trouver le triangle intersectant demande d'effectuer beaucoup de branchements. On doit tester l'intersection avec un volume englobant, puis décider s'il faut passer au suivant, et si oui lequel, et rebelotte. Et dans du code informatique, cela demande beaucoup de IF...ELSE, de branchements, de tests de conditions, etc. Et les cartes graphiques sont assez mauvaises à ça. Les shaders peuvent en faire, mais sont très lents pour ces opérations.

La génération des structures d'accélération et BVH modifier

La plupart des cartes graphiques ne peuvent pas générer les BVH d'elles-mêmes. Pareil pour les autres structures d'accélération. Elles sont calculées par le processeur, stockées en mémoire RAM, puis copiées dans la mémoire vidéo avant le démarrage du rendu 3D. Cependant, quelques rares cartes spécialement dédiées au lancer de rayons incorporent des circuits pour générer les BVH/AS. Elles lisent le tampon de sommet envoyé par le processeur, puis génèrent les BVH complémentaires. L'unité de génération des structures d'accélération est complétement séparée des autres unités. Un exemple d'architecture de ce type est l'architecture Raycore, dont nous parlerons dans les sections suivantes.

Les cartes graphiques dédiées au lancer de rayon modifier

Les premières cartes accélératrices de lancer de rayons sont assez anciennes et datent des années 80-90. Leur évolution a plus ou moins suivi la même évolution que celle des cartes graphiques usuelles, sauf que très peu de cartes pour le lancer de rayons ont été produites. Par même évolution, on veut dire que les cartes graphiques pour le lancer de rayon ont commencé par tester deux solutions évidentes et extrêmes : les cartes basées sur des circuits programmables d'un côté, non programmables de l'autre.

La première carte pour le lancer de rayons était la TigerShark, et elle était tout simplement composée de plusieurs processeurs et de la mémoire sur une carte PCI. Elle ne faisait qu'accélérer le calcul des intersections, rien de plus.

Les autres cartes effectuaient du rendu 3D par voxel, un type de rendu 3D assez spécial que nous n'avons pas abordé jusqu'à présent, dont l'algorithme de lancer de rayons n'était qu'une petite partie du rendu. Elles étaient destinées au marché scientifique, industriel et médical. On peut notamment citer la VolumePro et la VIZARD et II. Les deux étaient des cartes pour bus PCI qui ne faisaient que du raycasting et n'utilisaient pas de rayons d'ombrage. Le raycasting était exécuté sur un processeur dédié sur la VIZARD II, sur un circuit fixe implémenté par un FPGA sur la Volume Pro Voici différents papiers académiques qui décrivent l'architecture de ces cartes accélératrices :

La puce SaarCOR (Saarbrücken's Coherence Optimized Ray Tracer) et bien plus tard par la carte Raycore, étaient deux cartes réelement dédiées au raycasting pur, sans usage de voxels, et étaient basées sur des FPGA. Elles contenaient uniquement des circuits pour accélérer le lancer de rayon proprement dit, à savoir la génération des rayons, les calculs d'intersection, pas plus. Tout le reste, à savoir le rendu de la géométrie, le placage de textures, les shaders et autres, étaient effectués par le processeur. Voici des papiers académiques sur leur architecture :

Le successeur de la SaarCOR, le Ray Processing Unit (RPU), était une carte hybride : c'était une SaarCOR basée sur des circuits fixes, qui gagna quelques possibilités de programmation. Elle ajoutait des processeurs de shaders aux circuits fixes spécialisés pour le lancer de rayons. Les autres cartes du genre faisaient pareil, et on peut citer les cartes ART, les cartes CausticOne, Caustic Professional's R2500 et R2100.

Les cartes 3D modernes permettent de faire à la fois de la rasterisation et du lancer de rayons. Pour cela, les GPU récents incorporent des unités pour effectuer des calculs d'intersection, qui sont réalisés dans des circuits spécialisés. Elles sont appelées des RT Cores sur les cartes NVIDIA, mais les cartes AMD et Intel ont leur équivalent, idem chez leurs concurrents de chez Imagination technologies, ainsi que sur certains GPU destinés au marché mobile. Peu de choses sont certaines sur ces unités, mais il semblerait qu'il s'agisse d'unités de textures modifiées.

De ce qu'on en sait, certains GPU utilisent des unités pour calculer les intersections avec les triangles, et d'autres unités séparées pour calculer les intersections avec les volumes englobants. La raison est que, comme dit plus haut, les algorithmes de calcul d'intersection sont différents dans les deux cas. Les algorithmes utilisés ne sont pas connus pour toutes les cartes graphiques. On sait que les GPU Wizard de Imagination technology utilisaient des tests AABB de Plücker. Ils utilisaient 15 circuits de multiplication, 9 additionneurs-soustracteurs, quelques circuits pour tester la présence dans un intervalle, des circuits de normalisation et arrondi. L'algorithme pour leurs GPU d'architecture Photon est disponible ici, pour les curieux : [1].

  GFDL Vous avez la permission de copier, distribuer et/ou modifier ce document selon les termes de la licence de documentation libre GNU, version 1.2 ou plus récente publiée par la Free Software Foundation ; sans sections inaltérables, sans texte de première page de couverture et sans texte de dernière page de couverture.