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. Les cartes graphiques modernes incorporent aussi des circuits de calcul pour accélérer du rendu 2D ou 3D. Dans ce chapitre, nous allons faire une introduction et expliquer ce qu'est une carte graphique, nous allons parler des cartes dédiées/intégrées, et surtout : nous allons voir ce qu'il y a à l'intérieur, du moins dans les grandes lignes.

Les cartes graphiques dédiées, intégrées et soudées

modifier

Vous avez sans doute déjà démonté votre PC pour en changer la carte graphique, vous savez sans doute à quoi elle ressemble. Sur les PC modernes, il s'agit d'un composant séparé, qu'on branche sur la carte mère, sur un port spécialisé. Du moins, c'est le cas si vous avez un PC fixe assez puissant. Mais il y a deux autres possibilités.

 
Carte graphique dédiée PX 7800 GTX I

La première est celle où la carte graphique est directement intégrée dans le processeur de la machine ! C'est quelque chose qui se fait depuis les années 2000-2010, avec l'amélioration de la technologie et la miniaturisation des transistors. Il est possible de mettre tellement de transistors sur une puce de silicium que les concepteurs de processeur en ont profité pour mettre une carte graphique peut puissante dans le processeur.

Une autre possibilité, surtout utilisée sur les consoles de jeu et les PC portables, est celle où la carte graphique est composée de circuits soudés à la carte mère.

Pour résumer, il faut distinguer trois types de cartes graphiques différentes :

  • Les cartes graphiques dédiées, séparées dans une carte d'extension qu'on doit connecter à la carte mère via un connecteur dédié.
  • Les cartes graphiques intégrées, qui font partie du processeur.
  • Les cartes graphiques soudées à la carte mère.

Vous avez sans doute vu qu'il y a une grande différence de performance entre une carte graphique dédiée et une carte graphique intégrée. La raison est simplement que les cartes graphiques intégrées ont moins de transistors à leur disposition, ce qui fait qu'elles contiennent moins de circuits de calcul. Les cartes graphiques dédiées et soudées n'ont pas de différences de performances notables. Les cartes soudées des PC portables sont généralement moins performantes car il faut éviter que le PC chauffe trop, vu que la dissipation thermique est moins bonne avec un PC portable (moins de gros ventilos), ce qui demande d'utiliser une carte graphique moins puissante. Mais les cartes soudées des consoles de jeu n'ont pas ce problème : elles sont dans un boitier bien ventilés, on peut en utiliser une très puissante.

Vous vous demandez comment est-ce possible qu'une carte graphique soit soudée ou intégrée dans un processeur. La raison est que les trois types de cartes graphiques sont très similaires, elles sont composées des mêmes types de composants, ce qu'il y a à l'intérieur est globalement le même, comme on va le voir dans ce qui suit.

L'intérieur d'une carte graphique

modifier

Au tout début de l'informatique, le rendu graphique était pris en charge par le processeur : celui-ci calculait l'image à afficher à l'écran, et l'envoyait pixel par pixel à l'écran. 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 très complexes

modifier

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.

Une carte graphique moderne contient donc plusieurs sous-circuits, chacun dédié à une fonction précise.

  • La mémoire vidéo est une mémoire RAM intégrée à la carte graphique, qui a des fonctions multiples.
  • L'interface écran, ou Display interface, consiste en tous les connecteurs, ainsi que tous les circuits permettant d'afficher l'image à l'écran. Il y a beaucoup d'intelligence dedans, avec des circuits de contrôle qui lisent l'image à afficher dans la mémoire vidéo, puis l'envoient bit par bit sur les interfaces/connecteurs.
  • Les circuits de calcul graphique qui s'occupent du rendu 3D. L'accélération du rendu 2D est elle fortement liée à l'interface écran, comme on le verra dans quelques chapitres.
  • Les cartes graphiques fabriquées après les années 2000 incorporent aussi des circuits de décodage vidéo, pour améliorer la performance du visionnage de vidéos.
  • Le circuit d'interface avec le bus existe uniquement sur les cartes dédiées et éventuellement sur quelques cartes soudées. Il s'occupe des transmissions sur le bus PCI/AGP/PCI-Express, le connecteur qui relie la carte mère et la carte graphique.
  • D'autres circuits annexes, comme des circuits pour gérer la consommation électrique, un BIOS vidéo, etc.
 
Architecture d'une carte graphique moderne.

Un historique rapide des cartes graphiques

modifier

Les toutes premières cartes graphiques ne géraient pas le rendu 3D. Elles étaient beaucoup plus simples : on copiait une image dans leur mémoire vidéo, et elles l'affichaient. Il n'y avait pas de circuits de calcul graphique, ni de circuits de décodage vidéo. Juste de quoi afficher une image à l'écran. Et mine de rien, il est intéressant d'étudier de telles cartes graphiques anciennes. De telles cartes graphiques sont ce que j'ai décidé d'appeler des cartes d'affichage.

Elles disposaient très souvent de circuits pour accélérer le rendu 2D. Vous imaginez peut-être que les cartes d'affichage sont apparues en premier, qu'elles ont gagné en puissance et en fonctionnalités, avant d'évoluer pour devenir des cartes accélératrices 2D. C'est une suite assez logique, intuitive. Et ce n'est pas du tout ce qui s'est passé ! En réalité, les premières cartes d’affichage étaient des cartes hybrides entre carte d'affichage et cartes de rendu 2D. D'ailleurs, dans les chapitres suivants, nous parlerons grandement des techniques d'accélération 2D utilisées dans les anciennes consoles de jeu rétro et dans les anciens PC. Elles sont très liées à l'affichage de l'image à l'écran, qui est en soi une fonctionnalité 2D quand on y pense...

Les cartes d'affichage proprement dit sont une invention des premiers PC. Sur les consoles de jeu ou les microordinateurs anciens, il n'y avait pas de cartes d'affichage séparée, qu'on insérait dans la carte mère. A la place, le système vidéo d'un ordinateur était un ensemble de circuits soudés sur la carte mère. Les consoles de jeu, ainsi que les premiers micro-ordinateurs, avaient une configuration fixée une fois pour toute et n'étaient pas upgradables. Mais avec l'arrivée de l'IBM PC, les cartes d’affichages se sont séparées de la carte mère. Leurs composants étaient soudés sur une carte qu'on pouvait clipser et détacher de la carte mère si besoin. Et c'est ainsi que l'on peut actuellement changer la carte graphique d'un PC, alors que ce n'est pas le cas sur une console de jeu.

La différence entre les deux se limite cependant à cela. Les composant d'une carte d'affichage ou d'une console de jeu sont globalement les mêmes. Aussi, dans ce qui suit, nous parlerons de carte d'affichage pour désigner cet ensemble de circuits, peu importe qu'il soit soudé à la carte mère ou placé sur une carte d’affichage séparée. C'est un abus de langage qu'on ne retrouvera que dans ce cours.

Les cinq prochains chapitres vont parler des cartes d'affichage et des cartes accélératrices 2D, les deux étant fortement liées. C'est seulement daNs les chapitres qui suivront que nous parlerons des cartes 3D. Les cartes 3D sont composées d'une carte d'affichage à laquelle on a rajouté des circuits de calcul, ce qui fait qu'il est préférable de faire ainsi : on voit ce qui est commun entre les deux d'abord, avant de voir le rendu 3D ensuite. De plus, le rendu 3D est plus complexe que l'affichage d'une image à l'écran, ce qui fait qu'il vaut mieux voir cela après.

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. 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. Le circuit de contrôle était appelé autrefois le CRTC, car il commandait des écrans dit CRT, mais ce n'est plus d'actualité de nos jours. Sur les cartes graphiques dédiées, on trouve un circuit d'interfaçage avec le bus directement relié au connecteur de la carte graphique.

 
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.

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

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 framebuffer

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.

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 codage des pixels dans le framebuffer

modifier

Tout pixel est codé sur un certain nombre de bits, qui dépend du standard d'affichage utilisé. Dans un fichier image, les données sont compressées avec des algorithmes compliqués, ce qui a pour conséquence qu'un pixel est codé sur un nombre variable de bits. Certains vont l'être sur 5 bits, d'autres sur 16, d'autres sur 4, etc. Mais dans une carte graphique, ce n'est pas le cas. Une carte graphique n’intègre pas d'algorithme de compression d'image dans le framebuffer, les images sont stockées décompressées. Tout pixel prend alors le même nombre de bit, ils ont tous une taille en binaire qui est fixe.

Le codage des images monochromes

modifier

À 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
       

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).

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.

L'implémentation de la palette indicée demande d'ajouter à la carte graphique une table de correspondance pour traduire les couleurs au format RGB. Elle s'appelait la Color Look-up Table (CLT) et elle est placée immédiatement après la mémoire vidéo. Tout pixel qui sort de la mémoire vidéo est envoyé à la CLT, qui fournit en sortie le pixel coloré. La Color Look-up Table était parfois fusionnée avec le DAC qui convertissait les pixels numériques en données analogiques : le tout formait ce qui s'appelait le RAMDAC.

Au tout début, la Color Look-up Table était une ROM qui mémorisait la couleur RGB pour chaque numéro envoyé en adresse. De ce fait, 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. La CLT était alors une mémoire RAM, ce qui permettait de changer la palette à la volée. 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

Le framebuffer peut être organisé plusieurs manières différentes, mais deux grandes méthodes se dégagent. La toute première est celle du packed framebuffer, ou encore du 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. Et elle est moins intuitive à comprendre et va nécessiter quelques explications.

Le framebuffer planaires

modifier

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 de chaque pixel, et une autre image qui contient seulement le second bit. L'image est donc répartie sur deux framebuffers séparés. Le principe se généralise pour des pixels codés sur N bits, sauf qu'il faudra alors N images. Les N images sont appelées des bitplanes.

 
Exemple de framebuffer planaire, provenant de l'ordinateur soviétique Vector-06c.

Disons-le clairement, la méthode est compliquée et pas intuitive, elle 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 autre. La majorité des mémoires mémorisent des octets, chacun étant adressable. Et si ce n'est pas le cas, le cas général est d'associer un ou plusieurs octets par adresse. Encoder un pixel demande donc d'utiliser un ou plusieurs octets avec un framebuffer compact. Avec un framebuffer planaire, on n'a pas ce problème. L'avantage est très limité depuis que les cartes d'affichage se sont standardisées avec une taille des pixels multiple d'un octet. 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.

Dans le meilleur des cas, il y a une mémoire RAM par framebuffer planaire. Un pixel est alors répartit sur plusieurs mémoires, qu'il faut lire ou écrire simultanément. L’inconvénient que lire un pixel consomme plus d'énergie dans le cas général, car on accède à plusieurs mémoires simples au lieu d'une. Par contre, il est possible de modifier un bitplane indépendamment des autres, ce qui permet de faire certains effets graphiques simplement.

Un exemple d'utilisation d'un framebuffer planaire est 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.

L'exemple du framebuffer des micro-ordinateurs/console Amiga

modifier

Pour donner un exemple d'utilisation de planar framebuffer est l'ancien ordinateur/console de jeu Amiga Commodore. L'Amiga possédait 5 bits par pixel, donc disposait de 5 mémoires distinctes, et affichait 32 couleurs différentes. L'Amiga permettait de changer le nombre de bits nécessaires à la volée. Par exemple, si un jeu n'avait besoin que de quatre couleurs, seule deux plans/mémoires étaient utilisées. En conséquence, tout était plus rapide : les écritures dedans étaient alors accélérées, car on n'écrivait que 2 bits au lieu de 5. Et la RAM utilisée était limitée : au lieu de 5 bits par pixel, on n'en utilisait que 2, ce qui laissait trois plans de libre pour rendre des effets graphiques ou tout autre tache de calcul. Tout cela se généralise avec 3, 4, voire 1 seul bit d'utilisé.

Un sixième bit était utilisé pour le rendu dans certains modes d'affichage.

  • Dans le mode Extra-Half Brite (EHB), le sixième bit indique s'il faut réduire la luminosité du pixel codé sur les 5 autres bits. S'il est mit à 1, la luminosité du pixel est divisée par deux, elle est inchangée s'il est à 0.
  • En mode double terrain de jeu, les 6 bits sont séparés en deux framebuffer de 3 bits, qui sont modifiés indépendamment les uns des autres. Le calcul de l'image finale se fait en mélangeant les deux framebuffer d'une manière assez précise qui donne un rendu particulier. Les deux framebuffer sont scrollables séparément.
  • Le mode Hold-And-Modify (HAM) interprète les 6 bits en tant que 4 bits de couleur et 2 bits de contrôle qui indiquent comment modifier la couleur du pixel final.

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.

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.

Les accès concurrents au framebuffer

modifier

Sur les anciens micro-ordinateurs et les anciennes consoles de jeu, la mémoire vidéo était soudée sur la carte mère, à côté du processeur, des circuits vidéos, des circuits audio, etc. Le processeur a sa propre mémoire RAM, séparée de la mémoire vidéo. Tous les systèmes ne fonctionnaient pas ainsi, certains avaient une seule mémoire qui servait à la fois de mémoire vidéo et de mémoire RAM pour le CPU, mais les explications qui vont suivre marchent aussi pour ce genre de systèmes.

Le processeur a accès à la mémoire vidéo. Il peut y écrire les sprites, l'arrière-plan, l'image à afficher, ou bien d'autres choses. La carte d'affichage ou les autres circuits vidéos accèdent eux à la mémoire vidéo pour récupérer les pixels à afficher à l'écran. Le processeur comme la carte d'affichage lisent et écrivent dedans, avec cependant une spécificité : le processeur accède à la mémoire vidéo principalement en écriture, alors que la carte d'affichage y accède surtout en lecture. Et il faut éviter que les deux se marchent sur les pieds ! Diverses optimisations visent à faciliter l'accès à la mémoire par deux composants, ici le processeur et la carte d'affichage. Voyons lesquelles.

Les mémoires vidéo double port

modifier

Sur les premières consoles de jeu et les premières cartes graphiques, le framebuffer était mémorisé dans une mémoire vidéo spécialisée appelée une mémoire vidéo double port. Par double port, on veut dire qu'elles avaient deux entrée-sorties sur lesquelles on pouvait lire ou écrire leur contenu simultanément.

Le premier port était connecté au processeur ou à la carte graphique, alors que le second port était connecté à un écran CRT. Aussi, nous appellerons ces deux port le port CPU/GPU et l'autre sera appelé le port CRT. Le premier port était utilisé pour enregistrer l'image à calculer et faire les calculs, alors que le second port était utilisé pour envoyer à l'écran l'image à afficher. Le port CPU/GPU est tout ce qu'il y a de plus normal : on peut lire ou écrire des données, en précisant l'adresse mémoire de la donnée, rien de compliqué. Le port CRT est assez original : il permet d'envoyer un paquet de données bit par bit.

De telles mémoires étaient des mémoires dont le support de stockage était organisé en ligne et colonnes. Une ligne à l'intérieur de la mémoire correspond à une ligne de pixel à l'écran, ce qui se marie bien avec le fait que les anciens écrans CRT affichaient les images ligne par ligne. L'envoi d'une ligne à l'écran se fait bit par bit, sur un câble assez simple comme un câble VGA ou autre. Le second port permettait de faire cela automatiquement, en permettant de lire une ligne bit par bit, les bits étant envoyés l'un après l'autre automatiquement.

Pour cela, les mémoires vidéo double port incorporaient un registre capable de stocker une ligne entière. Le registre en question était un registre à décalage, à savoir un registre dont le contenu est décalé d'un rang à chaque cycle d'horloge. Le bit sortant est récupéré sur une sortie du registre, sortie qui était directement connectée au port CRT. Lors de l'accès au second port, la carte graphique fournissait un numéro de ligne et la ligne était chargée dans le tampon de ligne associé à l'écran. La carte graphique envoyait un signal d'horloge de même fréquence que l'écran, qui commandait le tampon de ligne à décalage : un bit sortait à chaque cycle d'écran et les bits étaient envoyé dans le bon ordre.

Le multiplexage temporel des accès mémoire

modifier

Les mémoires double port n'étaient pas si rares, mais elles n'étaient pas la solution la plus utilisée. La majorité des micro-ordinateurs et consoles utilisaient une mémoire vidéo normale, simple port, bien plus courante et bien moins chère. Mais il ajoutaient de circuits annexes ou utilisaient des ruses pour éviter que le processeur et la carte d'affichage se marchent sur les pieds. L'idée est de garantir que le processeur et la carte d'affichage n'accèdent pas à la mémoire en même temps. On parle de multiplexage temporel.

Un première mise en œuvre fait en sorte que la moitié des cycles d'horloge de la mémoire soit réservé au processeur, l'autre à la carte d'affichage. En clair, on change d’utilisateur à chaque cycle : si un cycle est attribué au processeur, le suivant l'est à la carte d'affichage. L'implémentation la plus simple utilise une mémoire qui va à une fréquence double de celle du processeur et de la carte d'affichage, les deux étant cadencés à la même fréquence. Un exemple est celui du micro-ordinateur BBC Micro, qui avait une fréquence de 4 MHz avec un processeur à 2 MHz et une carte d'affichage de 2 MHz lui aussi. Les fréquences du CPU et de la carte d'affichage étaient décalées d'une moitié de cycle, ce qui fait que leurs cycles correspondaient à des cycles mémoire différents. Le défaut est que cette technique demande une RAM très rapide, ce qui est un un gros problème.

Une autre solution laissait le processeur accéder en permanence à la mémoire vidéo. La carte d'affichage ne peut pas accéder à la mémoire vidéo quand le CPU écrit dedans, car des circuits annexes désactivent la carte d'affichage quand le processeur écrit dedans. Le micro-ordinateur TRS-80 faisait ainsi. Un défaut de cette méthode est qu'elle cause des artefacts graphiques à l'écran. Des pixels ne sont pas affichés et des écritures processeur trop longues peuvent causer des lignes noires à l'écran.

Enfin, une autre solution utilisait les mécanismes d'arbitrage du bus, qui gèrent les accès concurrents sur un bus. Le processeur et la mémoire sont reliés à la mémoire par le même ensemble de fils, et non par des ports séparés. La carte d'affichage et la mémoire envoient des demandes d'accès mémoire sur le bus, et elles sont ou non acceptées selon l'état de la mémoire. La carte d'affichage a la priorité, ce qui fait que si le processeur lance une demande d'accès à la mémoire pendant que la carte d'affichage y accède, le bus lui envoie un signal indiquant que le bus est occupé. Le processeur se met en attente tant que ce signal est à 1.

L'utilisation de la synchronisation verticale et des périodes de blanking

modifier

Une autre idée part du principe que l'affichage d'une image se fait à fréquence régulière. La carte d'affichage accède à la mémoire vidéo durant un certain temps pour envoyer l'image à l'écran, mais la laisse libre le reste du temps. Par exemple, sur un écran à 60 Hz, avec une image accédée toute les 16.66666 millisecondes, la carte d'affichage accède à la RAM vidéo pendant 5 à 10 millisecondes, le reste du temps est laissé au processeur.

De même, il y a un certain temps de libre entre l'affichage de deux lignes, le temps que le canon à électron du CRT se repositionne au début de la ligne suivante. Cela laissait un petit peu de temps au processeur pour changer la configuration de la carte graphique, par exemple pour changer la palette de couleur, changer des sprites, écrire dans la mémoire vidéo, ou tout autre chose. Le tout est très utile pour rendre certains effets graphiques.

Si le processeur sait quand la carte d'affichage affiche une image/ligne à l'écran, il sait quand la mémoire est libre et peut alors accéder à la mémoire vidéo. Reste à indiquer au processeur que la carte d'affichage n'utilise pas la mémoire vidéo. Une solution assez simple utilisait un registre de statut dans la carte d'affichage, qui indiquait si la carte d'affichage affichait une ligne ou non. Avant d’accéder à la mémoire vidéo, le processeur vérifiait ce registre pour savoir si la carte d'affichage faisait quelque chose. Si c'est le cas, il lui laisse la mémoire vidéo. Sinon, le processeur accédait à la mémoire vidéo.

Une autre mise en œuvre utilise une fonctionnalité du processeur appelée les interruptions. Pour rappel, les interruptions sont des fonctionnalités du processeur, qui interrompent temporairement l’exécution d'un programme pour réagir à un événement extérieur (matériel, erreur fatale d’exécution d'un programme…). Lors d'une interruption, le processeur suit la procédure suivante :

  • arrête l'exécution du programme en cours et sauvegarde l'état du processeur (registres et program counter) ;
  • exécute un petit programme nommé routine d'interruption ;
  • restaure l'état du programme sauvegardé afin de reprendre l'exécution de son programme là ou il en était.
 
Interruption processeur

Les interruptions matérielles, aussi appelées IRQ, sont des interruptions déclenchées par un périphérique et ce sont celles qui vont nous intéresser dans ce qui suit. Les IRQ qui nous intéressent sont générées par la carte graphique quand c'est nécessaire. Pour que la carte graphique puisse déclencher une interruption sur le processeur, on a juste besoin de la connecter à une entrée sur le processeur, appelée l'entrée d'interruption, souvent notée INTR ou INT. Lorsque la carte graphique envoie un 1 dessus, le processeur passe en mode interruption.

Si vous avez déjà lu un cours d'architecture des ordinateurs, vous savez sans doute que les choses sont assez compliquées, qu'un ordinateur moderne contient un contrôleur d'interruption pour gérer les interruptions de plusieurs périphériques, mais nous n'avons pas besoin de parler de tout cela ici. Nous avons juste besoin de voir le cas simple où la carte graphique est connectée directement sur le processeur.

Les cartes graphiques d'antan géraient deux types d'interruptions, qui sont regroupées sous le terme de Raster Interrupt. Grâce à ces interruptions, le processeur sait quand la mémoire vidéo est libre.

  • La première indiquait que la carte graphique a fini d'afficher une image. Elle s'appelle la Vertical blank interrupt (VBI). Elle servait à implémenter la synchronisation verticale.
  • Le second type est l'horizontal blank interrupt, qui indique que l'écran a fini d'afficher une ligne à l'écran, et donc que la mémoire vidéo est libre le temps que le canon à l'électron se mette en place.

La Vertical blank interrupt elle était parfois utilisée pour d'autres choses qui n'ont rien à voir avec l'écran ou le rôle d'une carte graphique. Par exemple, sur les anciens ordinateurs qui ne disposaient pas de timers sur la carte mère, la VBI était utilisée pour timer les échanges avec le clavier et la souris. A chaque VBI, la routine d'interruption vérifiait si le clavier ou la souris avaient envoyé quelque chose à l'ordinateur.

L'horizontal blank interrupt était utilisée pour changer les sprites d'une image ou les repositionner, afin de donner l'illusion que le matériel supporte pus de sprites que prévu. Les programmeurs utilisaient ce genre de ruses pour afficher plus de sprites à l'écran que ne peut en supporter la console. En changeant la position d'un sprite au bon moment, on peut dupliquer ce sprite sur l'image finale. Il est aussi possible de changer la couleur de l'arrière-plan à partir d'une certaine ligne. Et bien d'autres effets graphiques sont rendus possibles grâce à cela.

L'usage de tampons de synchronisation FIFO

modifier

Une dernière solution est l'usage de mémoires tampon entre le processeur et la mémoire vidéo. Le processeur n'écrivait pas directement dans la mémoire vidéo, mais dans une mémoire intermédiaire. La mémoire intermédiaire est une mémoire FIFO, à savoir qu'elle mémorise les données à écrire et leur adresse dans leur ordre d'arrivée. Elle sert à mettre en attente les accès mémoire du processeur tant que la mémoire vidéo est occupée.

Ainsi, si la mémoire vidéo est libre, le processeur peut écrire directement dans la mémoire vidéo, sans intermédiaire. Mais si la carte d'affichage accède à la mémoire vidéo, les écritures du processeur sont mises en attente dans la mémoire FIFO. Elles s'accumulent tant que la mémoire vidéo est occupée, elles sont conservées dans l'ordre d'envoi par le processeur. Dès que la mémoire vidéo se libère, les données présentes dans la FIFO sont écrites dans la mémoire vidéo, au rythme d'une écriture par cycle d'horloge de la VRAM : la mémoire FIFO se vide progressivement.

Si la mémoire FIFO est pleine, elle prévient le processeur en lui envoyant un bit/signal, et le processeur agit en conséquence en cessant les écritures et en se mettant en pause.

 
FIFO d'écriture en mémoire vidéo

Sur les cartes d'affichage, le processeur n'adresse pas la mémoire vidéo directement. A la place, le processeur envoie des données sur le bus, sur le connecteur de la carte d'affichage. La carte d'affichage récupère les données transmises sur le bus et les mets en attente dans une mémoire FIFO assez similaire. Elle les écrit en mémoire vidéo si besoin quand elle est libre. En conséquence, les cartes graphiques modernes n'ont pas besoin de raster interrupts, qui étaient utilisées sur les premiers PC ou les premières consoles. A la place, c'est la carte graphique qui s'occupe de tout, et notamment son circuit de contrôle qui gère la mémoire vidéo. D'ailleurs, c'est ce circuit de contrôle qui gère la synchronisation verticale, pas le processeur, pas besoin de vertical blanking interrupt.


Le Video Display Controler

Dans les années 70-80, un système vidéo pouvait être fabriqué de deux grandes manières différentes. La première concevait la carte d'affichage à partir de composants très simples, comme des portes logiques ou des transistors, à partir de zéro, sans réutiliser de matériel existant. De telles cartes vidéos avaient des performances et des fonctionnalités très variables, mais étaient très complexes à concevoir et coutaient cher. La seconde catégorie utilisait des Video Display Controler (VDC), des circuits déjà tout près, placés dans un boitier, produits en masse, qu'il suffisait de compléter avec une mémoire vidéo et quelques autres circuits pour obtenir un système vidéo. De tels circuits permettaient d'obtenir des performances décentes, voire très bonnes, pour un prix nettement inférieur. Les deux fonctionnent de la même manière, peu importe qu'il s'agisse d'un VDC ou d'un circuit fait main. Les deux contiennent globalement les mêmes circuits, ils fonctionnent de la même manière.

Dans le chapitre sur les cartes d'affichage, nous avons vu qu'une carte d'affichage contient trois à quatre circuits distincts : un framebuffer, un circuit de contrôle, le circuit d’interfaçage électrique avec l'écran (le RAMDAC) et éventuellement une connexion avec le bus. Le VDC correspond au circuit de contrôle. Les fonctionnalités d'un VDC sont très variables. Ils s'occupent des choses de base, comme gérer la résolution, l'envoi de l'image à afficher à l'écran, ce genre de choses. Il ne s'occupe pas de la transmission avec le bus, il ne gère pas vraiment l’interfaçage électrique.

 
Architecture d'une carte d'affichage avec VDC

Si la plupart des VDC communiquent avec la mémoire vidéo, il existe quelques exceptions qui se débrouillent sans mémoire vidéo ! C'est le cas des Video shifters dont nous parlerons plus tard. Les Video shifters sont vraiment à part des autres VDP, leur design basé sur l'absence de mémoire vidéo est responsable de différences vraiment profondes comparé aux autres VDP, ce qui fait qu'ils auront droit à leur propre section à la fin du chapitre.

La meilleure manière d'aborder les VDC est de d'abord les voir comme des espèces de boite noire, dont on ne se préoccupe pas du contenu en premier lieu. Un VDC communique avec l'écran, le processeur, et éventuellement avec la mémoire vidéo. Dans ce chapitre, nous allons voir comment il communique avec l'écran, puis voyons l'interface avec le processeur. C'est dans le chapitre suivant que nous allons voir ce qu'il y a à l'intérieur. La raison à cela est que plusieurs signaux de commandes émis par le VDC, certains à destination du processeur et d'autres à destination de l'écran, sont générés par les mêmes circuits.

L'interface du VDC avec l'écran

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

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 et l'entrelacement

modifier

L'écran peut afficher une image en utilisant deux modes principaux : le balayage progressif, et le balayage entrelacé.

Avec le balayage progressif, la carte graphique doit envoyer les pixels ligne par ligne, colonne par colonne : de haut en bas et de gauche à droite. 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 est transmise à l'écran, mais 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 pixels bleus, et l'autre sur les pixels verts.
 
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.

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).

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 fréquence de rafraichissement

modifier

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.

La gestion des timings pour la communication avec 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 des écrans LCD, 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. Pour les écrans CRT, l'écran affiche les pixels reçus immédiatement dès leur réception sur l'entrée. Dans les deux cas, il faut envoyer les pixels dans un certain ordre bien précis.

Un 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.

Déjà, 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 le temps de transmission d'un pixel. Pour cela, le VDC envoie un signal d'horloge dont la période correspond au temps de transmission/affichage d'un pixel. En, clair, le VDC envoie un pixel à chaque cycle d'horloge.

Ensuite, il faut prévenir l'écran qu'on a fini de transmettre une image avec un signal de synchronisation verticale, qui indiquait à l'écran qu'une image entière vient d'être transmise. Le VDC transmet l'image pixel par pixel, et lève ce signal de synchronisation verticale une fois l'image intégralement transmise. Ce signal était transmis sur un fil spécialisé, qu'on trouve sur la plupart des connecteurs VGA. De nos jours, sur les standards HDMI, DisplayPort, et autres, les choses sont plus compliquées, mais ce signal est quand même transmis, bien que pas forcément sur un fil spécialisé.

Enfin, il faut aussi tenir compte d'autres timings pour gérer la résolution. Les pixels sont envoyés ligne par ligne, mais 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. Pour cela, le VDC envoie un signal de synchronisation horizontale une fois qu'il a fini d'envoyer une ligne.

En tout, cela fait au minimum trois signaux : une horloge pour la transmission des pixels, un signal de synchronisation verticale, et un signal de synchronisation horizontale. Sans cela, impossible d'envoyer des pixels à l'écran ou de gérer la résolution convenablement. Et il y a d'autres contraintes de timings dont nous parlerons plus bas, qui ne sont pas évidentes pour le moment. Par exemple, sur les écrans CRT, il y a un temps de latence à la fin d'une ligne pour que le canon à électron se déplace sur le début de la ligne suivante. Et cela impose de ne pas démarrer l'envoie de la ligne suivante avant un certain temps. Cela il n'existe plus sur les écrans LCD, mais il fallait le prendre en compte à l'époque.

L'exemple du standard VGA

modifier

Un bon exemple est le standard VGA, qui était le seul utilisé pour connecter les écrans CRT, mais qui est encore utilisé de nos jours sur les écrans LCD. Avec ce standard, le connecteur contenait trois fils R, G, et B pour envoyer la couleur, codée en analogique. 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. Jusque là, rien de surprenant, c'est une redite de ce qu'on a dit plus haut. On trouve aussi plusieurs fils pour la masse, à savoir le 0 Volt, ainsi qu'une tension d'alimentation. Il y a une masse générale, ainsi que plusieurs masses, une par signal RGB.

Et enfin, il faut citer la connexion DDE/DDC qui permet de communiquer des informations de configuration à l'écran. Quand vous branchez l'écran à une carte graphique, celle-ci communique avec l'écran pour savoir quelles sont les résolutions supportées, quelle fréquence de rafraichissement est supporté, si l'écran supporte des couleurs 32 bits, etc. Sans cela, impossible de configurer la résolution. Pour cela, l'écran contient une petite mémoire ROM, dont le contenu est standardisé, qui contient toutes les informations nécessaires pour configurer l'écran.LA carte graphique lit cette ROM en passant par un bus appelé le bus Display Data Channel, qui permet à la carte graphique de lire cette ROM, d'interroger l'écran sur les résolutions et fonctionnalités supportées. Le bus est un dérivé du bus I²c, et a trois fils dédiés : un pour l'horloge, l'autre pour la transmission des données, et une masse dédiée.

 
Connecteurs VGA

Les premières subtilités du standard VGA viennent des timings des signaux HSYCN et VSYNC. Le signal HSYNC n'est pas envoyé dès la fin de la ligne : il y a un temps d'attente de quelques microsecondes entre la fin de la ligne et l'envoie du signal HSYNC. Le signal HSYNC est maintenu durant quelques microsecondes, la durée d'envoi est fixe. Puis, on a encore un nouveau temps d'attente avant l'envoi de la prochaine ligne, durant lequel le signal HSYNC n'est pas envoyé. Durant ces trois périodes (deux temps d'attentes, envoi de HSYNC), aucun pixel n'est envoyé à l'écran.

 
VGA 640×480 horizontal timings. Les durées vertes et jaunes sont des temps d'attentes où rien n'est envoyé sur le connecteur, rouge correspond à l'envoi du signal HSYCN, bleu est l'envoi de la ligne.

Et il y a la même chose avec les signaux VSYNC, même si les timings sont différents. 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.

L'interface du VDC du point de vue du processeur

modifier

Pour le processeur, le VDC a une interface similaire à celle de n'importe quel périphérique : un paquet de registres, et éventuellement des mémoires SRAM intégrées. La mémoire vidéo peut être intégrée dans le VDP ou être séparée, les deux sont possibles.

Les registres de configuration du VDC

modifier

La programmation d'un VDC se fait par en configurant des registres de configuration interne, qui permettent de configurer la résolution, la fréquence d’affichage, la position du curseur de souris, etc. Le processeur a juste à écrire dans ces registres, pour configurer la carte d'affichage comme souhaité.

En général, les registres de configuration sont accessibles directement par le processeur. Quelques adresses mémoire sont détournées et ne servent pas à adresser la mémoire, mais correspondent aux registres de configuration. Ce n'est ni plus ni moins que la technique des entrée-sorties mappées en mémoire que vous connaissez sans doute si vous avez déjà lu un cours d'architecture des ordinateurs. Il y a typiquement une adresse mémoire par registre, le processeur a juste à écrire dans cette adresse pour configurer le registre.

Plus rarement, les registres ne sont pas mappés en mémoire directement, mais sont accessibles par le processeur via une adresse. Le processeur écrit à une adresse précise, associée à la carte graphique. La configuration d'un registre se fait en deux temps : il écrit le numéro du registre à configurer, puis la donnée à écrire. La carte graphique reçoit ces deux informations l'une après l'autre, et les utilise pour configurer le registre elle-même.

Le registre de statut

modifier

Le VDC incorpore presque toujours un registre d'état, ou un registre de statut qui permet au processeur de connaitre l'état du VDC. Il permet de savoir si le VDC est libre, s'il est en train d'afficher une ligne, si une erreur a eu lieu et laquelle. Le processeur a juste à lire le registre en question, pour vérifier l'état de la carte graphique. Chaque bit du registre de statut a une interprétation fixée à l'avance et fournit une information précise.

Plusieurs bits du registre de statut sont réservés au traitement des erreurs. Si le VDC rencontre une erreur, il met une valeur bien précise dans ces bits, appelée le code d'erreur. Typiquement, la valeur 0 indique qu'il n'y a pas d'erreur, les autres valeurs précisent une erreur. Le code d'erreur dépend de l'erreur en question et du VDC, il n'y a pas de standard pour ça.

Sur les VDC qui n'utilisent pas de raster interrupt, le registre de statut permet de savoir si le VDC est en train d'afficher une ligne à l'écran. Pour cela, le registre de statut du VDC contient un bit qui précise que l'écran est en train d'afficher une ligne. Il est appelé le bit de blanking horizontal. En général, ce bit est à 0 quand le VDC est en train de transmettre une ligne à l'écran, à 1 quand la mémoire vidéo est libre. Notons que ce signal n'est pas équivalent au signal HSYNC. Pour reprendre l'exemple du standard VGA, il y a deux temps d'attente avant et après l'envoi du signal HSYNC, où l'écran n'envoie pas de données. Le signal HSYNC est alors à 0, alors que le bit de blanking est bien à 1.

Les raster interrupts

modifier

Le VDC contient aussi une sortie dédiée aux interruptions, connectée à l'entrée d'interruption du CPU (directement ou par l'intermédiaire d'un contrôleur d'interruption).

Les signaux de raster interrupt ne sont pas identiques aux signaux de synchronisation verticale et horizontale, ni aux signaux de blanking, même s'ils se ressemblent. La différence est que les signaux de synchronisation verticale/horizontale ont des contraintes de timing différents. Par exemple, le standard VGA impose que ces deux signaux soient maintenus durant un certain temps à l'écran, alors que les raster interrupts sont remises à zéro dès que le processeur est a pris en compte.

La gestion de la palette indicée

modifier

Les VDC les plus complexes prennent en charge la palette indicée eux-mêmes. ILs sont assez rares, mais ils existent. Pour cela, ils incorporent une petite mémoire SRAM, dans laquelle est stockée la palette, à savoir la table de correspondance entre numéro de la couleur, et la couleur elle-même. Quand on envoie le numéro de couleur sur l'entrée d'adresse de cette SRAM, la sortie de donnée fournit la couleur codée en RGB. Le Processeur peut remplir cette SRAM lui-même, ce qui lui permet de configurer la palette.

La SRAM est soit mappée en mémoire, soit accessible de manière indirecte par des commandes spécialisées.

L'interface du VDC avec la mémoire vidéo

modifier

Afficher une image à l'écran demande de prendre l'image dans le framebuffer et de l'afficher à l'écran. L'interface écran demande qu'on envoie les pixels les uns après les autres. Pour cela, le VDC doit parcourir le framebuffer pour lire les pixels un par un, dans le bon ordre.

Les VDC les plus simples ne permettent pas de faire cela automatiquement. Le processeur doit accéder à la mémoire vidéo et générer les adresses lui-même ! Les VDC de ce type sont appelés des Video shifters. Ils fournissent des signaux de commande à l'écran, mais n’accèdent pas à la mémoire vidéo. D'autres VDC plus complexes sont capables de générer des adresses mémoire et accèdent directement à la mémoire vidéo, ils gèrent d'eux-mêmes le parcours du framebuffer.

Les VDC avec gestion des accès mémoire

modifier

Si on omet les video shifters, les VDC sont capables de lire les pixels à envoyer à l'écran depuis la mémoire vidéo. Pour cela, ils génèrent l'adresse du pixel à lire, au rythme d'un pixel par cycle d'horloge. La génération d'adresse est assez simple, surtout si le framebuffer est coopératif. Il suffit de démarrer à une adresse bien précise, celle où commence le framebuffer, et parcourir la mémoire dans l'ordre, en passant à l'adresse suivante à chaque cycle. Un simple compteur fait l'affaire.

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

Un point important est que la mémoire vidéo est quasi-systématiquement une mémoire de type DRAM, similaire aux mémoires SDRAM ou DDR des PC actuels. Elles tendent à perdre leur contenu au bout d'un certain temps, ce qui fait qu'elles doivent être rafraichies régulièrement pour éviter cet effacement. Les VDP les plus complexe peuvent incorporer des circuits pour effectuer ce rafraichissement automatiquement. Mais d'autres VDC plus simples font sans et ajoutent des circuits de rafraichissement mémoire séparés du VDC.

Et outre le rafraichissement, l'accès à une mémoire DRAM est plus complexe que l'accès aux autres mémoires RAM. Les timings des accès mémoire sont complexes, sans compter que l'adresse doit être envoyée en deux fois. Pour cela, un circuit appelé le contrôleur mémoire est nécessaire pour communiquer avec une DRAM. Le contrôleur traduit les adresses mémoires et signaux de commandes en commandes compréhensibles par la mémoire DRAM. Notamment, il reçoit une adresse mémoire et l'envoie en deux fois. Le contrôleur mémoire peut être intégré au VDC, ou être un circuit séparé, tout dépend du VDC en question.

Les Video shifters : des cartes d’affichage sans mémoire vidéo

modifier

Les VDP les plus simples n'ont pas vraiment de noms, vu que ce sont des circuits très simples, mais un terme possible serait celui de Video shifters. Ce nom trahit le fait qu'ils reçoivent des données pixel par pixel, et les transforment en un flux de bit, voire un flux analogique (ils intègrent alors un RAMDAC). Ils ne peuvent pas envoyer de commandes/adresses à la mémoire vidéo, ils ne parcourent pas le framebuffer et ne lisent pas les pixels à envoyer à l'écran dedans, c'est le processeur qui le fait à leur place. Leur avantage est qu'ils se passent de mémoire vidéo dédiée et d'utiliser une mémoire unique pour le processeur et le système vidéo.

Ils sont très rares, les seuls à avoir été utilisé sur un microordinateur sont le RCA CDP1861 du micro-ordinateur soviétique COSMAC VIP, et le système vidéo du Sinclair ZX-81 (bien qu'il soit fabriqué avec des portes logiques et non avec un VDP). Sur les consoles de jeu, on peut citer le Television Interface Adaptor de l'Atari 2600.

Pour résumer, voici les fonctions possibles de ces circuits :

  • RAMDAC et transformation octet/pixel en flux de bits ;
  • génération des signaux de commande pour l'écran ;
  • génération des raster interrupts.

Prenons l'exemple du RCA CDP1861. Il générait les signaux HSYNC et VSYNC de synchronisation verticale et horizontale pour l'écran, et transformait un octet reçu en flux de bits envoyés à l'écran (monochrome). L'intérieur de ce circuit était très simple : un registre à décalage pour la transformation parallèle-série, des compteurs pour la ligne et la colonne, quelques circuits de contrôle associés.

Le RCA CDP1861 était relié au processeur et à la mémoire comme indiqué ci-dessous. Le rendu d'une image se faisait en utilisant les raster interrupt. Le RCA CDP1861 générait une interruption à chaque fin de ligne, grâce à un compteur de ligne interne. Le processeur affichait une image grâce à une routine d'interruption, aidé par un contrôleur DMA intégré dans le processeur. La routine d'interruption configurait le contrôleur DMA, qui s'occupait d'envoyer l'image au RCA CDP1861, octet par octet. Il y avait un signal de synchronisation entre RCA CDP1861 et processeur/DMA : un bit émis par le RCA CDP1861 à destination du processeur indiquait qu'il était prêt à recevoir un nouvel octet.

 
VDP shifter RCA CDP1861


Le Video Display Controler : implémentation

Nous avions vu dans le chapitre précédent qu'il existe plusieurs types de VDC. Les VDC les plus simples, appelés des Video shifters, ne sont pas connectés à la mémoire vidéo. Ils fournissent des signaux de commande à l'écran, mais n’accèdent pas à la mémoire vidéo. D'autres VDC plus complexes sont capables de générer des adresses mémoire et accèdent directement à la mémoire vidéo, ils gèrent d'eux-mêmes le parcours du framebuffer. Parmi ces derniers, on distingue généralement plusieurs sous-types, suivant leur complexité et leurs fonctionnalités supportées.

La première catégorie est celle des Cathode Ray Tube Controler, ou CRTC. Leur nom vient du fait qu'ils servaient autrefois d'interface écran pour des écrans CRT. Ils gèrent des choses comme la résolution de l'écran, la fréquence d'affichage, le nombre de couleurs utilisés pour chaque pixel, etc.

Au-delà des Video shifters et des CRTC, les Video Interface Controler intègrent des fonctionnalités supplémentaires, comme de quoi gérer une palette indicée, ainsi que des circuits d'accélération 2D qu'on verra dans le prochain chapitre. En clair, ils regroupent tout ce qui est nécessaire pour faire une carte d'affichage, sauf la mémoire vidéo et éventuellement le RAMDAC. Ils peuvent gérer des RAMS séparées pour la gestion de la palette de couleur ou les caractères, voire peuvent l'intégrer dans leurs circuits. Là encore, ils peuvent souvent gérer à la fois mode texte et graphique, on peut les configurer pour choisir lequel utiliser.

La génération des signaux de commande pour l'écran

modifier

Les VDC contiennent tous de quoi générer les signaux de commande à destination de l'écran. Et cela demande de générer pas mal de signaux, ainsi que des signaux d'horloge à la fréquence définie. Le premier signal à générer est le signal d'horloge transmission des pixels, à savoir le signal d'horloge dont la période est égale au temps mis pour envoyer un pixel à l'écran. Ce signal est souvent transmis à l'écran, via un fil dédié. Les VDC contiennent de quoi générer cette fréquence, grâce à un circuit oscillateur dédié. Mais il faut aussi générer les signaux de synchronisation verticale/horizontale.

La génération des signaux de synchronisation verticale/horizontale

modifier

Le VDC gère les signaux de synchronisation verticale ou horizontale. Pour cela, ils intègrent deux compteurs (des circuits qui comptent de 0 à N). Le premier compteur compte les lignes transmises, l'autre les pixels dans une ligne, 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 et sont incrémentés automatiquement quand on passe d'un pixel à l'autre, ou bien d'une ligne à l'autre. Quand le compteur atteint la valeur adéquate, il émet un signal de synchronisation verticale/horizontale. Au passage à la ligne suivante, le compteur de colonne est réinitialisé à 0, idem pour le compteur de ligne quand une image a été affichée totalement.

Ils sont configurés de manière à prendre en compte la résolution de l'écran, mais pas de la manière dont vous le pensez. Par exemple, pour une résolution de 640 par 480 : vous imaginez sans doute que le compteur de colonne est configuré pour compter de 0 à 639, alors que l'autre compte de 0 à 479. Par exemple, pour une résolution de 640 par 480, les deux compteurs sont initialisés à 0. Le compteur de colonne est incrémenté à chaque envoi de pixel, et il déclenche le signal de synchronisation horizontale une fois que le compteur atteint 640. Le compteur de colonne est alors réinitialisé après un certain temps, alors que le compteur de ligne est incrémenté. Le compteur de ligne est donc incrémenté à chaque nouvelle ligne. De plus, il émet un signal de synchronisation verticale quand il atteint 480, et est réinitialisé après cela.

Il est possible de faire ainsi, mais ce n'est pas la solution idéale. En réalité, il faut tenir compte du fait que les signaux de HSYNC et VSYNC, qui sont eux aussi générés par les deux compteurs. Imaginons que le signal HSYNC prenne 20 cycles d'horloge, et le signal VSYNC 150 cycles. Pour une résolution de 640 par 480, on utilise un compteur de colonne qui compte de 0 à 640 + 20, et un compteur de ligne qui compte de 0 à 480 + 150.

L'idée est d'utiliser des comparateurs pour générer les signaux HSYNC et VSYNC, un pour le signal HSYNC et un autre pour le signal VSYNC. En reprenant les valeurs mentionnées précédemment, on utilise un comparateur qui vérifie si le compteur de colonne est supérieur ou égal à 640, et un autre comparateur qui vérifie si le compteur de ligne est égal ou dépasse 480. La sortie des deux comparateurs fournit directement les signaux HSYNC et VSYNC.

Une autre solution remplace les comparateurs par une mémoire ROM. L'idée est d'envoyer les compteurs sur l'entrée d'adresse, la ROM fournit en sortie les signaux de commande destinés à l'écran. En remplissant la ROM avec les valeurs adéquates, la technique fonctionne à merveille et on peut se passer des circuits comparateurs. Pour les haute résolutions, il est possible d'utiliser deux ROMs : une pour le compteur de ligne, une pour le compteur de colonne.

Le VDC peut gérer plusieurs résolutions différentes, et les timings sont différents suivant les résolutions. Idéalement, il faut envoyer quelques bits de commande pour choisir la résolutions en entrée de la mémoire ROM pour choisir les bons timings. Avec des comparateurs, la technique demande d'utiliser les mêmes comparateurs, mais d'ajouter des circuits pour gérer les différentes résolutions.

Les mêmes compteurs ou la ROM sont souvent utilisés pour générer les raster interrupts et le bit de blanking, qui permettent de prévenir le processeur quand la carte d'affichage a terminé d'envoyer une ligne et/ou une image entière à l'écran. Notons qu'il est possible d'implémenter les interruptions à partir du bit de blanking, cela demande juste aux compteurs de générer ce bit de blanking et de l'utiliser pour générer les raster interrupt. Au passage, les compteurs de ligne et colonne ne servent pas qu'à générer des signaux : on verra dans la section sur le CRTC que quand on dispose de ces deux compteurs, ajouter de quoi parcourir le framebuffer est trivial !

L'exemple des timings du standard VGA

modifier

Reprenons l'exemple du 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 compteur de colonne est cadencé à une fréquence bien précise, qui détermine le temps mis pour passer d'un pixel à 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. Et cela permet d'implémenter facilement les deux temps d'attente avant et après l'affichage d'une ligne. Les temps d'attente de 1,54 et 0,64 µs correspondent respectivement à 38 et 16 cycles du compteur, la durée de 3,8 µs du signal H-sync 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 après le signal H-sync. Puis, au 640 + 16ème 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, avant le prochain envoi de ligne. 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. L'adresse n'est pas calculée si le compteur n'a pas une valeur comprise entre 0 et la largeur indiquée par la résolution.

La même logique s'applique avec le signal V-sync, mais avec des timings différents, illustrés plus haut.

Pour implémenter tout cela, il suffit de combiner les deux compteurs avec des circuits comparateurs, qui vérifient si la valeur du compteur est dans tel ou tel intervalle. Il faut au minimum deux circuits comparateurs, un pour le signal HSYNC, un autre pour le signal VSYNC. D'autres compteurs peuvent être utilisés pour générer les bits de blanking ou pour réinitialiser le compteur à la valeur adéquate. Les comparateurs peuvent être remplacés par une mémoire ROM, comme dit plus haut.

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

La génération de l'adresse à envoyer au framebuffer

modifier

Le CRTC est un VDC capable de générer les adresses mémoire à destination du framebuffer. Pour résumer ce qu'il fait, les pixels sont lus les uns après les autres, ligne par ligne, en balayant le framebuffer. Pour cela, il réutilise les deux compteurs précédents et utilise leur contenu pour forger l'adresse mémoire adéquate à chaque cycle.

 
Architecture globale d'une carte d'affichage, avec CRTC.
 
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. Chaque pixel a 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.

Avec le balayage progressif, la carte graphique doit envoyer les pixels ligne par ligne, colonne par colonne : de haut en bas et de gauche à droite. 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.

Le pointeur de framebuffer

modifier
 
Tableau bidimensionnel.

Une méthode simple pour l'implémenter se base sur le fait que l'image à envoyer est stockée ligne par ligne dans la mémoire, avec les pixels d'une étant mémorisés dans l'ordre de balayage progressif. 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.

Pour cela, il suffit d'utiliser un simple compteur d'adresse. Le compteur contient l'adresse, à savoir la position du pixel en mémoire. Il est initialisé avec l'adresse du premier pixel, il est incrémenté à chaque envoi de pixel, et il s’arrête une fois que l'image est totalement envoyée. La méthode en question est appelée la méthode du framebuffer pointer, ou pointeur de framebuffer.

Le problème est qu'il faut gérer l'application des signaux de synchronisation verticale/horizontale. Le compteur d'adresse doit arrêter de compter pendant que ces signaux sont transmis. De plus, il faut tenir compte des timings, comme le temps pour remettre le canon à électrons d'un CRT au début de la ligne suivante. Rien d'insurmontable, mais il faut ajouter un circuit qui détermine si un signal de synchronisation HSYNC/VSYNC est à envoyer à l'écran, et stoppe le compteur si c'est le cas.

La réutilisation des compteurs de ligne/colonne

modifier

Une autre solution, qui se marie mieux avec les signaux de synchronisation, combine un pointeur de framebuffer avec les compteurs de ligne et de colonne vus dans la section précédente. Le contenu des compteurs de ligne et de colonne est envoyé à un circuit de calcul d'adresse, qui détermine la position du pixel à envoyer. L'adresse mémoire du pixel à afficher est calculée à partir de la valeur des deux compteurs, et de l'adresse du premier pixel. Le calcul de l'adresse prend en compte les timings, en n'accédant pas à la mémoire quand la valeur des compteurs dépasse celle de la résolution à rendre. Par exemple, pour une résolution de 640 par 480, le calcul d'adresse ne donne pas de résultat si le compteur de colonne dépasse 640 : c'est signe que le compteur envoie des signaux de synchronisation horizontale.

 
CRTC et calcul d'adresse.

Le tout peut être amélioré pour implémenter le double buffering. Pour cela, il suffit d'utiliser deux registres pour l'adresse de base : un pour le front buffer et un autre pour le back buffer. La carte vidéo choisit le bon registre à utiliser, ce qui permet de passer de l'un à l'autre en quelques cycles d'horloge. En changeant l'adresse pour la faire pointer vers l'ancien back buffer, l’interversion se fait automatiquement.

 
Circuit de contrôle et double buffering

L'entrelacement est géré par le VDC, qui lit l'image à afficher une ligne sur deux en mémoire vidéo. 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.

Les fonctionnalités supplémentaires : les Video Interface Controlers

modifier

Les Video Shifters incorporent juste de quoi générer les signaux de commande. Les CRTC font de même, mais incorporent aussi de quoi générer l'adresse mémoire à destination du framebuffer. Les Video Interface Controlers sont des VDC très complexes, qui incorporent des fonctionnalités avancées. La plupart de ces fonctionnalités ne sont pas liées au rendu graphique lui-même. Il est courant qu'ils incorporent une carte son rudimentaire, de quoi gérer les entrées clavier-souris, etc.

 
Illustration d'un Video Interface Controler.

Prenons par exemple le VDP 7360/8360 TExt Display (TED). Niveau affichage, il gérait mode texte et graphique, avec une résolution allant jusqu’au 320 × 200, avec une SRAM pour la palette de couleur intégrée. Il ressemble beaucoup au MOS Technology VIC-II de la Commorodre 64, mais avec des fonctionnalités de rendu 2D en moins. Mais contrairement à la VIC-II, il incorpore des circuits de génération sonore, avec de quoi générer un signal sonore dit carré ou du bruit blanc. Il incorporait aussi des circuits d'interface avec le clavier, ainsi que des timers (des circuits capables de compter une durée précise).

 
Engineer working on CAD workstation with light pen Hughes Aircraft Company

Une fonctionnalité très courante des VIC est la gestion d'un light pen, une sorte de stylet pour écrans CRT, aujourd'hui disparus. Beaucoup de VIC incorporaient cette fonctionnalité, très fréquente pour l'époque. Il faut dire que les light pen étaient très pratiques pour toute application gérant autre chose que tu texte, comme le dessin assisté par ordinateur. Mais même pour ls applications texte, le light pen était utile, pour sélectionner une lettre ou un mot, éventuellement pour le corriger. Les VIC géraient le light pen en interne et présentaient au processeurs deux registres, qui indiquaient la position du light pen à l'écran.

Les fonctionnalités graphiques avancées des VIC

modifier

Il arrive que les VIC intègrent de quoi gérer une palette indicée, et incorporent donc une petite mémoire RAM spécialisée pour. En clair, ils ne font pas que CRTC, mais gèrent tout ce qui est au-delà du framebuffer et qui demande de modifier ou d'interpréter les pixels.

La plupart gèrent des fonctionnalités de rendu 2D qu'on verra dans le chapitre suivant, sur les cartes accélératrices 2D. La gestion matérielle des sprites était assez courante, et la gestion d'un curseur de souris matériel n'était pas rare. Des techniques de tracé de ligne sont aussi souvent présentes. Par exemple, certains intègrent un contrôleur DMA amélioré, pour gérer les copies de blocs de mémoire dans la mémoire vidéo.

De plus, beaucoup d'entre eu incorporent des circuits liés à la gestion de la mémoire vidéo. Pour rappel, l'accès à une mémoire DRAM est plus complexe que l'accès aux autres mémoires RAM, notamment, car les timings des accès mémoire sont complexes et que l'adresse doit être envoyée en deux fois. Pour cela, un circuit appelé le contrôleur mémoire est souvent intégré au VIC. Le contrôleur mémoire contient aussi de quoi gérer le rafraichissement mémoire ! En effet, la mémoire vidéo est souvent une mémoire de type DRAM, similaire aux mémoires SDRAM ou DDR des PC actuels. Elles tendent à perdre leur contenu au bout d'un certain temps, ce qui fait qu'elles doivent être rafraichies régulièrement pour éviter cet effacement. Les VIC peuvent incorporent des circuits pour effectuer ce rafraichissement automatiquement.

Les Video Display Co-Processor, aussi appelés VDC à co-processeur, sont des VIC intègrent un processeur, éventuellement connecté à sa propre mémoire RAM. Un VDC à co-processeur lit une série d'instructions dans la mémoire RAM. La liste d'instruction s'appelle la display list, terme qui reviendra dans quelques chapitres. En règle générale, une instruction commande l'affichage d'une ligne à l'écran. Elle dit ce qu'il faut afficher et comment, dans quelle résolution, dans quel mode graphique, etc. Du moins, c'est le cas sur les VDC à co-processeur les plus complexes, les plus simples ont des instructions très simples, qui ne gèrent pas autant de fonctionnalités. L

Un exemple : le NEC μPD7220

modifier
 
NECuPD7220BlockDiagram

Un bon exemple de VIC est le NEC μPD7220 de NEC. Le schéma ci-contre illustre ce qu'il y a à l'intérieur. On voit qu'il y a plusieurs circuits à 'intérieur, chacun spécialisé dans une tâche précise. Pour résumer, il contient : un CRTC, des circuits mémoire, et des circuits d'accélération 2D.

Le CRTC, qui contient lui-même plusieurs sous-circuits :

  • Un circuit de génération des signaux de commande pour l'écran (HSYNC, VSYNC, autres).
  • Des registres de configuration et de status.
  • Une mémoire ROM et une mémoire RAM pour le circuit de contrôle.

Les circuits liés à la mémoire vidéo sont les suivants :

  • Un circuit qui gère les timings pour les accès mémoire.
  • Un contrôleur mémoire avec un circuit de rafraichissement intégré.
  • Un contrôleur DMA pour la communication avec le bus ou les copies de blocs mémoire.
  • Une mémoire FIFO pour gérer les accès simultanés à la mémoire du CPU et du VDP (voir chapitre sur le framebuffer).

Les circuits d'accélération 2D regroupent :

  • Un circuit de tracé de lignes et de figures géométriques.
  • Un circuit pour les zooms et certaines opérations graphiques similaires.

Enfin, il faut citer le circuit pour la gestion d'un light pen.


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 défilement ou encore un support matériel du curseur de la souris, toutes dérivées des techniques d'accélération de rendu 2D.

La base d'un rendu en 2D 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ère-plan, 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.

Il existe plusieurs techniques d'accélération graphique pour le rendu en 2D :

  • L'accélération des copies dans la mémoire vidéo grâce à un circuit dédié. Elle aide à implémenter de manière rapide le défilement ou les sprites, ou encore le tracé de certaines figures géométriques. Mais elle est moins performante que les trois suivantes, bien qu'elle lui soit complémentaire.
  • L'accélération matérielle des sprites, où les sprites sont stockés dans des registres dédiés et où la carte graphique les gère séparément de l'arrière-plan.
  • L'accélération matérielle du défilement, une opération très couteuse.
  • L'accélération matérielle du tracé de lignes/segments/figures géométriques simples.

Les quatre techniques ne sont pas exclusives, mais complémentaires.

Le circuit de Blitter : les copies en mémoire

modifier

Les cartes 2D ont introduit un circuit pour accélérer les copies d'images en mémoire, appelé le blitter. Les copies de données en mémoire vidéo sont nécessaires pour ajouter les sprites sur l'arrière-plan, mais aussi lorsqu'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.

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.

Pour ceux qui ont quelques connaissances avancées en architecture des ordinateurs, on peut le voir comme une sorte de contrôleur DMA amélioré, avec une gestion d'opérations annexes autres que la copie. D'ailleurs, il est souvent combiné à un contrôleur DMA, voire fusionné avec lui.

La superposition des sprites sur l'arrière-plan

modifier

Les cartes 2D sans sprites matériels effectuent leur rendu en deux étapes : elles copient l'image d'arrière-plan dans le framebuffer, puis chaque sprite est copié à la bonne palce en mémoire pour le placer au bon endroit sur l'écran. Les copies de sprites sont généralement prises en charge par le blitter, qui est spécialement optimisé pour cela. L'optimisation principale est le fait que le blitter peut effectuer une opération bit à bit entre les données à copier et une donnée fournie par le programmeur appelé un masque.

Pour voir à quoi cela peut servir, rappelons que les sprites sont souvent des images rectangulaires sans transparence ! Le sans transparence est très important pour ce qui va suivre. Idéalement, les sprites devraient contenir des zones transparentes pour laisser la place à l'arrière-plan, mais le hardware ne gère pas forcément des pixels transparents à l'intérieur des sprites. Emuler la transparence peut s'émuler facilement en utilisant un masque, une donnée qui indique quelles parties de l'image sont censées être transparentes.

Par exemple, l'image correspondant à notre bon vieux pacman ressemblerait à celle ci-dessous, à gauche. Les parties noires de l'image du pacman sont censées être transparentes et ne pas être copiées au-dessus de l'arrière-plan, il ne faut le faire que pour les pixels jaunes. Le masque qui indique quels pixels sont à copier ou non, est celui situé à droite.

 
Image de Pacman.
 
Masque de Pacman.

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). L'application d'un ET logique entre masque et arrière-plan met à zéro les pixels modifiés par le sprite et uniquement ceux-ci, ils sont mis à la couleur noire. Le OU copie le sprite dans le vide laissé. La première étape est nécessaire, car un OU avec un arrière-plan non-noir donnerait un mélange bâtard entre arrière-plan et sprite. Au final, l'image finale est bel et bien celle qu'on attend.

 
Sprite rendering by binary image mask

Les sprites matériels

modifier

Il existe des cartes 2D sur lesquelles les sprites sont gérés directement en matériel, sans passer par un blitter, sans être 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'accès au framebuffer par le CRTC.

Les circuits d'accélération matérielle des sprites

modifier

Sur ces cartes 2D, les sprites sont stockés dans des registres, alors que l'image d'arrière-plan est stockée dans un framebuffer, dans une mémoire 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. 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ée 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.

Le support des sprites est parfois utilisé dans un cadre particulièrement spécialisé : la prise en charge du curseur de la souris, ou le rendu de certaines polices d'écritures ! Le curseur de souris est alors traité comme un sprite spécialisé, surimposé au-dessus de tout le reste.

Les cartes graphiques modernes gèrent 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

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.

Le zoom et la rotation des sprites

modifier

Il a existé des VDC qui pouvaient zoomer sur des sprites, voire les faire tourner. Le ZOOM des sprites permet de faire grossir ou de réduire la taille d'un sprite dynamiquement. L'effet de zoom pouvait servir pour générer des effets de profondeur. Par exemple, quand on veut simuler un personnage qui se rapproche de l'écran en marchant/courant, il suffit de prendre son sprite et de grossir le zoom progressivement pour donner l'illusion d'un personnage qui se rapproche. Idem pour n'importe quel sprite : plus le sprite est loin, plus on en réduit la taille. L'idée est de gérer la taille des sprites en fonction de la profondeur du sprite. L'effet donnait un rendu en pseudo-3D. On parle alors de Sprite Scaling.

Les implémentations les plus simples permettaient de faire multiplier les dimensions d'un sprite par 2, 4, 8 fois. En clair, la largeur et la longueur du sprite étaient multipliés par 2, 4, 8, etc. Il y avait possibilité de séparer zoom vertical et horizontal. Quelques VDC ne supportaient que le zoom horizontal, d'autre que en vertical, mais la plupart de ceux qui supportaient le zoom supportaient les deux.

 
ZOOM Entier sur un "sprite".

Les implémentations plus complexes supportaient un zoom plus fluide, avec tous les intermédiaires possibles entre *1 et *X. Pour cela, le sprite devait subir des opérations d'interpolation pour avoir un rendu agréable à l'écran. Pour comprendre ce que cela veut dire : imaginez que vous souhaitez multiplier la taille d'un sprite par X. Pour multiplier par 2, 3, 4 ou tout autre entier, cela demande juste de dupliquer chaque pixel. Mais pour les valeurs fractionnaires, les choses sont plus compliquées. Vous devrez appliquer un filtre pour faire le zoom, filtre qui prend les valeurs des pixels et les mélange pour calculer les pixels finaux. Nous reviendrons sur les filtres possibles dans le chapitre sur le filtrage de texture. Vous avez bien lu : le zoom des sprites n'est pas si différent du filtrage de texture, les algorithmes utilisés pour zoomer sur les sprites et filtrer des textures sont d'ailleurs très similaires, voire identiques.

La rotation des sprites

modifier

Une autre possibilité est de faire tourner les sprites. La rotation des sprites regroupe plusieurs techniques diverses, qui vont de simples effets de miroir à des techniques de rotation complexes.

La technique la plus simple est un effet de miroir, à savoir que le sprite est inversé horizontalement, il est tourné dans l'autre sens. Pour vous donner une idée de l'utilité de cette technique, imaginez que vous jouez à Mario : si vous allez à droite, Mrio sera tourné vers la droite. Mais si vous allez à gauche, le sprite sera tourné à gauche. Mais il s'agit d'un seul sprite, qui est tourné dans un sens ou l'autre avec un effet de miroir. Il est aussi possible d'inverser le sprite verticalement, pour le retourner.

Les techniques plus évoluées permettent de faire tourner un sprite, pour faire comme si on voyait un objet de biais. Un sprite rectangulaire représente un objet vu soit de profil, soit de face. Mais si on veut donner l'illusion d'un objet faisant un angle différent, il faut modifier la forme du sprite. Le sprite rectangulaire est transformé en trapèze, qui est d'autant moins proche d'un rectangle que l'angle est important. Déformer un sprite rectangulaire en trapèze est un effet de rotation général. Le tout donne un effet de perspective.

 
Mode 7, test.
 
Mode 7.

L'implémentation de la perspective est assez simple. Le sprite est une image formée de plusieurs lignes de pixels. L'idée est que la taille des lignes est d'autant plus réduite que la ligne est censée être loin. Les portions proches du sprite seront de taille normale, les autres sont réduites. Mais la technique demande cependant de multiplier la taille de chaque ligne par un coefficient, qui n'est pas forcément entier. Là encore, des algorithmes permettent de lisser les images des sprites pour les mettre à l'échelle. L'algorithme peut faire à la fois la gestion de la perspective et le zoom des sprites, et utiliser des algorithmes proches de ceux du filtrage de textures. Une implémentation complexe est celle du mode 7 de la Super Nintendo.

L'implémentation matérielle est paradoxalement assez simple. Mais la comprendre demande de faire la distinction entre les pixels du framebuffer et les pixels du sprite. Le sprite est une image qui est composée de pixels. Pour faire la distinction avec les pixels du framebuffer, nous allons appeler les pixels du sprite des texels. Terme qui est normalement utilisé pour les textures, mais il y a un lien qui sera fait dans quelques chapitres.

Placer le sprite sur l'arrière-plan demande de prendre les coordonnées de chaque texel et de faire un calcul qui donne les coordonnées finales x,Y dans le framebuffer. Sans rotation et sans zoom, le calcul est simple : on additionne la position x,y du sprite et la coordonnée du texel dans le sprite. Pour faire tourner les sprites, il faut faire des calculs impliquant des matrices (des objets mathématiques en forme de tableaux de nombre), que je ne détaillerais pas du tout. Pour le dire très rapidement, il faut juste multiplier les coordonnées du texel par une matrice qui encode à la fois la rotation et le zoom, mais aussi le placement au bon endroit sur l'écran (la translation). La matrice est calculée par le processeur, le VDC ne fait que faire la multiplication matricielle.

L'accélération matérielle du défilement

modifier
 
Exemple de scrolling.

Le défilement permet de faire défiler l'environnement sur l'écran, spécialement quand le joueur se déplace. Les jeux de plateforme rétro utilisaient énormément le défilement, le joueur se déplaçait généralement de gauche à droite, ce qui fait que l'on parle de défilement horizontal. Mais il y avait aussi le défilement vertical, utilisé dans d'autres situations. Peu utilisé dans les jeux de plateforme, le défilement vertical est absolument essentiel pour les Shoot 'em up !

Les cartes accélératrices intégraient souvent des techniques pour accélérer le défilement. La première optimisation est l'usage du blitter. En effet, défiler ne demande pas de régénérer toute l'image à afficher à partir de zéro. L'idéal est de déplacer l'image de quelques pixels vers la gauche, puis de dessiner ce qui manque. Pour cela, on peut utiliser le blitter pour déplacer l'image dans le framebuffer. L'optimisation est souvent très intéressante, mais elle est imparfaite et n'était pas suffisante sur les toutes premières consoles de jeu. Elle l'était sur les consoles plus récentes, ou disons plutôt : moins rétro. Les consoles moins rétros avaient des mémoires RAM plus rapides, ce qui rendait l'usage du blitter suffisante.

Mais certains VDC implémentaient une forme de défilement accéléré en matériel totalement différent. Les implémentations sont multiples, mais elles ajoutaient toutes des registres de défilement dans le VDC, qui permettaient de défiler facilement l'écran. Il faut noter que l'implémentation du défilement vertical est bien plus simple que pour le défilement horizontal. En effet, les images sont stockées dans le framebuffer ligne par ligne ! Et le défilement vertical demande de déplacer l'écran d'une ou plusieurs lignes. Les deux vont donc bien ensemble.

Le défilement vertical implémenté dans le CRTC

modifier

Une technique utilise le fonctionnement d'un CRTC, couplé avec un framebuffer amélioré. Nous l’appellerons la technique du framebuffer étendu, terme de mon invention qui aide à comprendre en quoi consiste cette technique. Elle utilise cependant plus de mémoire vidéo qu'un framebuffer normal. Elle fonctionne très bien pour le défilement vertical, elle demande quelques adaptations pour le défilement horizontal.

 
Implémentation du défilement vertical accéléré en hardware via un pointeur de framebuffer

La méthode demande d'utiliser un framebuffer beaucoup plus grand que l'image à afficher. Par exemple, imaginez qu'une console ait une résolution de 640*480, avec des couleurs sur 16 bits. L'image à afficher prend donc 640*480*2 = 600 Kilooctets. Maintenant, imaginez que la mémoire vidéo pour l'arrière-plan fasse 2 mégaoctets. On peut y stocker l’image à rendre, et ce qu'il y a hors de l'écran. Si une scène est assez petite, l'arrière-plan tient entièrement dans la mémoire vidéo, changer l'adresse de base permet de défiler l'écran sur une distance assez longue, voire pour toute la scène. L'idée est de tout simplement dire au CRTC de demander à afficher l'image à partir de la ligne numéro X !

Avec cette technique, il faut faire la différence entre le framebuffer et le viewport. Le viewport est la portion du framebuffer qui mémorise l'image à afficher à l'écran. Le framebuffer, lui, mémorise plus que ce qu'il faut afficher à l'écran, il mémorise quelques lignes en plus, voire un niveau entier ! Le pointeur de framebuffer et la résolution indiquent la position du viewport dans le framebuffer, qui monte ou descend en fonction du sens de défilement.

Pour la comprendre, prenez le cas où on souhaite défiler d'un pixel, verticalement vers le bas. La première ligne de l'image disparait, une nouvelle apparait en bas de l'image. Cas simple, un peu irréaliste, mais qui permet de bien comprendre l'idée. Pour rappel, un CRTC incorpore deux compteurs de ligne et de colonne, ainsi qu'un registre pour l'adresse de départ de l'image dans le framebuffer. L'idée est que l'adresse de départ de l'image est augmentée de manière à pointer sur la ligne suivante, en l'augmentant de la taille d'une ligne. Il ne reste plus qu'à remplir la ligne suivante, pas besoin de faire la moindre copie en mémoire vidéo ! Et défiler vers le haut demande au contraire de retrancher la taille d'une ligne de l'adresse. On peut généraliser le tout pour du défilement vertical.

L'implémentation la plus complexe demande d'ajouter un registre de défilement vertical, dans lequel on place le numéro de ligne à partir de laquelle il faut dessiner l'image. L'image en mémoire vidéo a plus de lignes que l'écran peut en afficher, le CRTC parcourt autant de ligne que ce que demande la résolution, le registre de défilement vertical indique à partir de quelle ligne commencer l'affichage. Le calcul de l'adresse prend en compte le contenu de ce registre pour parcourir le framebuffer.

Le défilement vertical infini : le warp-around des adresses

modifier

La technique précédente fonctionne bien, mais elle ne permet pas d'avoir du défilement infini, à savoir avec des maps dont la longueur n'est pas limitée par la mémoire vidéo. Mais on peut l'adapter pour obtenir du défilement vertical, en utilisant un comportement particulier du calcul des adresses. Le comportement en question est le warp-around.

Pour le comprendre, prenons l'exemple suivant. La mémoire vidéo peut contenir 1000 lignes, la résolution verticale est de 300 lignes. Si on démarre l'affichage à la 700ème ligne, tout va bien, on n'a pas besoin de warp-around. Mais maintenant, imaginons que le défilement vertical fasse démarrer l'affichage à partir de la 900ème ligne. L'affichage se fera normalement pour 100 lignes, mais la 101ème débordera hors du framebuffer, il n'y aura pas d'adresse associée. Le warp-around fait repartir les adresses à zéro lors d'un tel débordement. Ainsi, la 101ème adresse sera en fait l'adresse 0, la 102ème sera l'adresse 1, etc. En clair, si on commence à afficher l'image à la 900ème ligne, les 100 premières seront prises dans les 100 lignes à la fin du framebuffer, alors que les 200 suivantes seront les 200 lignes au début du framebuffer.

En faisant cela, on peut avoir un défilement vertical infini. Quand l'image affichée est démarrée assez loin, le début du framebuffer est libre, il contient des lignes qui ne seront sans doute pas affichées par la suite. Dans ce cas, on écrit dedans la suite du niveau, celle située après la ligne à la fin du framebuffer. Il suffit que le programmeur se charge de modifier le framebuffer de cette manière et on garantit que tout va bien se passer.

Avec cette technique, on peut avoir un défilement infini en utilisant seulement deux fois plus de mémoire vidéo que ce qui est nécessaire pour stocker une image à l'écran. Il est même possible de tricher pour utiliser moins de mémoire vidéo. Mais laissons cela de côté, tout cela est laissé à l’appréciation du programmeur.

Le défilement horizontal et vertical implémenté par le CRTC

modifier

Pour le défilement horizontal, il faut procéder de la même manière, mais en trichant un peu. Là encore, il y a une différence entre viewport et framebuffer, et les deux n'ont pas la même résolution. Mais cette fois-ci, outre la résolution verticale qui est plus grande, la résolution horizontale l'est aussi. Par exemple, si je prends la console de jeux NES, elle a une résolution pour l'écran de 256 par 240, alors que l'arrière-plan a une résolution de 512 par 480.

L'implémentation utilise des compteurs de ligne et de colonne séparés. L'idée est d'avoir des lignes plus longues dans le framebuffer que ce qui est indiqué dans le registre de résolution. On peut alors mémoriser une ligne plus longue que ce qui est affiché à l'écran, avec des portions non-affichées à l'écran.

L'idée est là encore d'initialiser le compteur de colonne avec une valeur qui est incrémentée d'un pixel à chaque fois qu'on défile vers la droite, décrémentée quand on va vers la gauche. Le registre pour la résolution horizontale, qui vérifie si la fin de la ligne/colonne est atteinte, est lui aussi incrémenté. La même méthode peut être utilisée pour le défilement horizontal en faisant la même chose pour le compteur de ligne. L'implémentation demande d'ajouter un registre de défilement horizontal, en plus du registre de défilement vertical, le principe derrière est le même mais pour le numéro de colonne et non de ligne.

 
Implémentation du défilement horizontal avec in viewport

Notons que le comportement de warp-around peut aussi être implémenté pour les adresses et compteurs de colonne. Cela permet d'avoir du défilement horizontal infini.

La fonctionnalité était disponible sur les cartes EGA, les toutes premières cartes d'affichage datant d'avant même le standard VGA. Tout était en place sur ces cartes graphiques pour implémenter la technique : une mémoire vidéo assez grande, un framebuffer potentiellement plus grand que ce qui est affiché à l'écran, une adresse de départ qu'on peut incrémenter ligne par ligne, par incréments de 1 pixel. Le logiciel devait cependant utiliser cette faculté adéquatement pour implémenter le défilement.

 
Défilement avec parallaxe.
 
Défilement avec parallaxe : illustration des plans.

Maintenant, voyons les défauts des techniques précédentes. Le problème principal est que toute l'image bouge ! Or, le défilement demande généralement que certains éléments bougent, mais pas tous. Les sprites sont souvent gérés par un système de sprite matériel, ce qui fait qu'ils sont ou non concernés par le défilement et ce n'est pas tellement un problème. Mais d'autres problèmes surviennent en-dehors des sprites.

Le vrai problème vient des techniques de défilement à parallaxe. Le défilement à parallaxe utilise plusieurs arrière-plans superposés qui défilent à des vitesses différentes. Il donne une illusion de profondeur dans l'environnement. La technique précédente fait bouger le framebuffer en bloc, tout d'un coup, et ne gère donc pas le défilement à parallaxe.

De plus, la technique ne marche pas vraiment avec un rendu à base de tiles. La solution la plus simple permet de faire du défilement tile par tile, mais le résultat à l'écran est tout simplement pas fluide du tout. Du moins, c'est le cas sans modifications assez importantes de la technique de défilement, qui ne sont pas faites en hardware. Par contre, il existe des techniques logicielles pour implémenter du défilement en tenant compte des tiles, mais qui utilisent surtout des techniques logicielles. La solution à ce problème a été trouvée par John Carmack, le programmeur derrière les moteurs des jeux IDSoftware comme DOOM, Quake, Wolfenstein 3D, etc. Il a inventé la technique de l'Adaptive tile refresh qui permet justement d'avoir un défilement fluide sur des cartes sans gestion hardware du défilement.

L'accélération matérielle du tracé de ligne et de figures géométriques

modifier
 
Tracé d'une ligne sur un écran.

La dernière optimisation du rendu 2D est le tracé de lignes et de figures géométriques accéléré en matériel. Quelques VDC incorporent cette optimisation, dont le nom est assez clair sur ce qu'elle fait. Tracer une ligne, un segment, est l'opération la plus courante sur de tels VDC, le tracé de cercles est déjà plus rare. Tracer des polygones est entre les deux, : plus rare que le tracé de ligne pur, moins que le tracé de cercles.

Les circuits de tracé de ligne

modifier

L'algorithme le plus utilisé par le matériel pour tracer des lignes est l'algorithme de Bresenham. Il est très simple et s'implémente très facilement dans un circuit électronique. Il fut dite que cet algorithme utilise seulement des additions, des soustractions et des décalages, opérations très simples à implémenter en hardware. Il est de plus un des tout premiers algorithme découvert dans le domaine du graphisme sur ordinateur. Il existe de nombreuses modifications de cet algorithmes, qui vont de mineures à assez profondes. Et certaines d'entre elles sont plus faciles à implémenter en hardware que d'autres.

 
Coordonnées du pixel de départ et d'arrivée.
 
Tracé d'une ligne sur un écran, pixel par pixel.

Le fonctionnement du circuit de tracé de ligne est le suivant. Premièrement, on précise le pixel de départ et le pixel d'arrivée en configurant des registres à l'intérieur du circuit, avec une paire de registre pour les coordonnées du pixel de départ, une autre parie pour les coordonnées du pixel d'arrivée. Le circuit dessine la ligne pixel par pixel, avec un pixel dessiné par cycle d'horloge.

Le tracé de figures géométriques

modifier

Passons maintenant aux tracé de figures géométrique. Le tracé se fait là encore pixel par pixel, sur le principe. Généralement, le tracé est limité à des figures géométriques simples, que vous avez tous vu quand vous étiez au collège, en cours de maths. Tracer des carrés/rectangles/pentagones/trapèzes ou autres polygone est très simple : il suffit de tracer plusieurs lignes les unes à la suite des autres.

La vraie utilité est l'implémentation de courbes, comme des cercles, des ellipses, ou autres. L'algorithme de Bressenham peut être modifié pour implémenter des cercles, ce qui donne le Midpoint circle algorithm. D'autres extensions permettent de dessiner des ellipses, et même des courbes plus complexes comme des courbes de Bezier ou d'autres courbes assez complexes à expliquer.

Le tracé et le remplissage de figures géométriques par le blitter

modifier

Outre le tracé des figures géométriques, il est aussi possible de gérer en hardware les opérations de remplissage. Cela veut dire dessiner l'intérieur d'une figure géométrique, comme remplir un carré ou un rectangle, avec une couleur uniforme. Par exemple : dessiner un carré en rouge, remplir un rectangle existant de bleu clair, etc. Le remplissage est souvent disponible pour certaines formes géométriques simples, comme des carrés ou rectangles, rarement plus. Pour le remplissage des triangles ou d'autres figures géométriques, le support matériel est encore plus rare, ne parlons même pas des cercles.

Le remplissage de rectangles est souvent réalisé par le blitter. Pour faire une comparaison, la méthode utilisée est globalement la même que celle utilisée pour lire le viewport dans le framebuffer, mais cette fois-ci réalisé par le blitter. Le viewport est remplaé par le rectangle à remplir, et la lecture du pixel à envoyer à l'écran est remplacée par l'écriture d'une couelur précisée dans un reistre.

Pour cela, on précise la couleur, la coordonnée X,Y et la largeur et la hauteur du rectangle dans des registres dédiés. Le remplissage commence à la coordonnée X,Y. L'adresse mémoire est alors incrémentée jusqu'à ce que la largeur voulue soit atteinte. On incrémente alors le compteur de ligne pour passer à la suivante, le compteur de colonne est réinitialisé avec la coordonnée X de la première colonne. Le remplissage s’arrête une fois que la hauteur voulue est atteinte.

Un exemple est le blitter des anciennes consoles Amiga, qui gère nativement des blocs en forme de rectangles, et de trapèzes dessinés à l'horizontale. Ils sont remplis en fournissant plusieurs informations : la position de leur premier pixel, une largeur qui est un multiple de 16 bits, une hauteur mesurée en nombre de lignes, un décalage qui indique de combien de pixels sont décalées deux lignes consécutives. Le décalage est ajouté une fois le compteur de colonne réinitialisé à sa valeur précédente (au démarrage de la ligne précédente).

L'organisation des circuits d'accélération 2D

modifier

L'implémentation des optimisations du rendu 2D peuvent se faire soit directement dans le VDC, soit dans un circuit séparé. Le cas le plus fréquent est une intégration dans le VDC. Mais quelques consoles de jeu faisaient autrement : elles utilisaient un circuit séparé pour les optimisations du rendu 2D. Il est intéressant de donner quelques exemples réels de consoles de jeux qui utilisaient de l'accélération 2D. Nous allons voir que chaque console faisait à sa sauce.

Le Television Interface Adaptor des consoles Atari 2600

modifier

Un premier exemple de carte 2D est celui d'une des toutes premières consoles Atari, notamment celle de l'Atari Video Computer System et l'Atari 2600. La carte graphique en question s'appelait la Television Interface Adaptor, abrévié TIA. Elle a une architecture foncièrement différente des autres consoles de l'époque : elle ne dispose pas du tout de mémoire vidéo et doit se débrouiller autrement ! À la place, le rendu d'une image est effectué ligne par ligne, et le TIA contient seulement de quoi mémoriser une ligne, dans quelques registres. Le processeur doit modifier ces registres entre l'affichage de deux lignes pour pouvoir afficher une image digne de ce nom. Il s'aggit d'une technique appelée le racing the beam.

Elle gère un arrière-plan très limité, stocké dans un registre. Le registre d'arrière-plan est un registre de 20 bits, qui est répliqué autant de fois que nécessaire sur tout la ligne. En clair, la ligne de l'écran est découpée en blocs de 20 pixels, chaque pixel étant codé sur un bit. Vous pourriez croire que la couleur codée dans ce registre est un simple noir et blanc, mais c'est plus compliqué. Un registre séparé contient la couleur affichée, elle-même encodée grâce à un système de palette indicé. Le registre d'arrière-plan indique, pour chaque pixel d'un bloc, si la couleur doit être affichée ou non.

Le TIA gère 5 sprites matériels, grâce à 5 registres capables chacun de mémoriser une ligne de pixel. Ces registres sont spécialisés, avec deux registres pour les avatars des joueurs, deux missiles et une balle. Ne vous fiez pas aux noms, la différence entre les 5 est la taille des sprites stocké dedans. Les sprites des avatars sont de 8 pixels de large, peuvent être dupliquées ou étendus. La balle est un sprite de même couleur que l'arrière-plan, de 1, 2, 4 ou 8 pixels de large. Les deux missiles sont identiques à la balle, sauf qu'ils ont la même couleur que le sprite du joueur associé.

Mettre à jour les registres du TIA à chaque ligne n'est pas si différent de modifier une mémoire vidéo entre deux lignes, ce qui fait que les solutions vues plus haut peuvent en théorie s'appliquer. Un défaut des consoles ATARI est que le processeur n'utilisait pas d'interruptions pour gérer l'affichage des lignes. À la place, le bus indiquait au processeur quand le TIA acceptait qu'on modifie ses registres. Le bus avait un signal RDY (READY) qui indiquait si le TIA était occupé à afficher une ligne ou non.

Un autre défaut est que le TIA ne génère pas totalement les signaux de synchronisation verticale, qui servent à indiquer à l'écran qu'il a fini d'afficher une image. La raison est qu'il n'a pas de compteur de ligne ! Il y a bien un compteur pour les colonnes, pour générer les signaux de synchronisation horizontale, mais pas plus. C'est le processeur qui doit générer le signal de synchronisation verticale de lui-même ! Pour cela, le processeur doit écrire dans une adresse dédie associée au TIA, ce qui déclenche un signal de synchronisation verticale immédiat.

Les VDC ANTIC et CTIA des consoles Atari post-2600

modifier

D'autres consoles avaient un VDC très complexe, scindé en deux VDC reliés entre eux. Tel est le cas des consoles Atari 8 bits, qui ont succédé à l"Atari 2600. Elles n'utilisaient pas le VDC TIA, mais un autre VDC, plus complexe, composé de deux circuits séparés. Les deux circuits séparés sont appelés respectivement ANTIC et CTIA (Color Television Interface Adaptor). L'ANTIC gérait les copies en mémoire et correspondait à un blitter amélioré, alors que le CTIA était un VDC qui intégrait l'accélération matérielle des sprites et qui gérait aussi la palette indicée.

Le fonctionnement de l'ANTIC est assez complexe, mais on peut le voir comme une sorte de pseudo-processeur, qui exécute un programme de rendu 2D. Le programme en question est une suite d’opération de rendu 2d à exécuter dans l'ordre, qui permet de calculer l'image ligne par ligne. Il est appelé la Display List, et est stocké dans la mémoire de la console. L'ANTIC lit le programme instruction par instruction, grâce à un contrôleur DMA intégré dans l'ANTIC, qui contient un program counter interne à l'ANTIC. L'exécution d'une Display List sur l'ANTIC permet de décharger le processeur des tâches de rendu 2D, du moins dans une certaine mesure.

Une instruction de la display list permet de calculer une ligne de l'écran, en précisant comment combiner les sprites et l'arrière-plan, tous deux stockés dans la mémoire vidéo. La display list gére entre 0 et 240 instructions, ce qui limite la résolution verticale à 240 pixels. Les instructions peuvent se classer en quatre types principaux : des instructions pour écrire une ligne de pixels colorés, des instructions pour écrire une ligne de caractères, des instructions pour émettre les signaux de synchronisation de blanking, et enfin des branchements qui agissent sur le program counter de l'ANTIC. Les instructions pour afficher des pixels/caractères peuvent activer le défilement horizontal ou vertical suivant l'instruction.

Une instruction est codée sur 8 bits minimum.

Bit 7 Bit 6 Bit 5 Bit 4 Bit 3 Bit 2 Bit 1 Bit 0
DLI LMS VS HS Mode

Les 4 bits de mode précisent quelle est l'instruction à utiliser, ce qui fait 16 instructions en tout, réparties dans les 4 classes mentionnées plus haut. Les bits HS et VS activent ou désactivent le défilement vertical et horizontal. Le bit LMS permet à l'instruction de préciser l'adresse où se trouvent les tableaux de données à utiliser, comme la place de l'arrière-plan en mémoire RAM, ce genre de choses. Pour cela, le bit est placé à 1 et l'adresse est dans les 2 octets qui suivent l'instruction. Le bit DLI gère les interruptions dites de Display List, ou interruptions DLI, que nous allons voir dans ce qui suit.

Les interruptions DLI sont l'équivalent sur l'ANTIC des interruptions d'horizontal blanking, déclenchées à la fin de l'affichage d'une ligne. Elles préviennent le processeur qu'une ligne entière a été affichée. Le processeur peut alors écrire en mémoire vidéo durant un court laps de temps s'il en a besoin. Il peut alors changer les sprites à la volée, ce qui permet d'afficher plus de sprites que ce que supporte le chip CTIA/GTIA. Les interruptions sont déclenchées pour certaines lignes seulement, celles où l'instruction qui affiche la ligne le précise. Si l'instruction a son bit DLI mis à 1, alors une interruption est déclenchée à la fin de la ligne. S'il est à 0, pas d'interruption. La gestion des interruptions DLI est donc totalement programmable.

Niveau registres, l'ANTIC contient un program counter et de quoi configurer le contrôler DMA. Il contient aussi deux registres pour configurer le défilement horizontal et vertical. Il a aussi des registres pour localiser les données importantes en mémoire, qui sont regroupées dans des tableaux séparés. Il y a notamment un registre pour l'adresse de l'arrière-plan, un autre pour l'adresse des sprites en mémoire RAM. Pour gérer la synchronisation avec le CPU, l'ANTIC incorpore un compteur de ligne, ainsi qu'un registre de synchronisation horizontale qui est à 1 quand une ligne est en train d'être affichée (mécanisme redondant avec les interruptions DLI). Il y a aussi plusieurs registres pour gérer les interruptions, avec un registre de statut que le CPU peut lire à loisir pour savoir quelle est la raison qui a déclenché l'interruption, ainsi qu'un registre de configuration des interruptions.

Les anciens micro-ordinateurs Amiga

modifier
 
Amiga Original Chipset diagram

Pour finir ce chapitre, nous allons voir les anciens ordinateurs de marque Amiga, qui servaient aussi de console de jeu. Ces consoles disposaient d'un processeur Motorola MC 68000, couplé à une mémoire RAM principale, et d'un chipset qui gère tout ce qui a trait aux entrée-sorties (vidéo, sonore, interruptions, clavier et souris, lecteur de disquette). Il contient plusieurs circuits appelés respectivement Paula, Denise et Agnus.

  • Agnus gère la communication avec la mémoire vidéo et contient notamment un blitter et un co-processeur qui commande le blitter.
  • Denise est une carte graphique de rendu 2D qui gère 8 sprites matériels. Il s'occupe aussi de la souris et du joystick.
  • Paula est un circuit multifonction qui fait à la fois carte son, controleur du lecteur de disquette, gestion des interruptions matérielles, du joystick analogique, du port série et de toutes les autres entrées-sorties.

Anus contient un blitter, un CRTC, et un co-processeur spécialisé qu'on détaillera plus bas. Le fait qu'il intègre un CRTC fait qu'il gère aussi la génération des timings vidéos pour l'écran, le signal d'horloge du chipset, et quelques autres signaux temporels.

Agnus incorpore un circuit de gestion de la mémoire assez complexe. Il intégre notamment un contrôleur mémoire de DRAM, pour communiquer avec la mémoire, qui lui-même incorpore un circuit de rafraichissement mémoire. Tous les contrôleurs DRAM de l'époque ne géraient pas le rafraichissement mémoire, mais c'est le cas d'Agnus. Il n'a donc pas besoin de déléguer cette fonction au processeur. De plus, le circuit de gestion mémoire gère l'arbitrage de la mémoire, à savoir qu'il empêche le processeur et les circuits vidéo/audio de se marcher sur les pieds. Il privilégie les accès provenant du CRTC aux accès initiés par le blitter, l'affichage à l'écran ayant la priorité sur tout le reste. Agnus alloue un cycle sur deux au CRTC, le reste est alloué suivant les besoins, soit au CPU, soit au blitter.

Le blitter des anciennes consoles Amiga fonctionne suivant trois modes de fonctionnement : copie mémoire, remplissage de polygone, et tracé de ligne. Leurs noms sont assez parlants. Le mode de tracé de ligne permet de tracer une ligne en utilisant l'algorithme de Bresenham. Les deux autres modes se ressemblent, dans le sens où ils font des écritures en mémoire, pour remplir un bloc entier de forme rectangulaire.

Le premier mode copie un bloc de mémoire dans un autre, mais peut aussi effectuer une opération logique entre plusieurs blocs. Le blitter prend en entrée 0, 1, 2 ou 3 blocs, et écrit le résultat d'une opération logique dans un quatrième bloc. Les quatre blocs peuvent se recouvrir partiellement. Les blocs en question sont rectangulaires et sont définis par les informations suivantes : une longueur mesurée en multiples de 16 bits, une largeur exprimée en nombre de lignes, et un stride qui indique la distance entre deux lignes dans le framebuffer.

Pour ce qui est du remplissage de polygone, il s'agit du remplissage de figures géométriques simples avec une couleur uniforme. Les figures géométriques sont soit en forme de rectangles, soit des trapèzes dessinés à l'horizontale. Ils sont dessinés en fournissant plusieurs informations : une largeur qui est un multiple de 16 bits, une hauteur mesurée en nombre de lignes, un décalage qui indique de combien de pixels sont décalées deux lignes consécutives, la position de leur premier pixel. Il peut aussi remplir un polygone d'une couleur uniforme.

Enfin, Agnus contient un co-processeur spécialisé appelé Copper. Il s'agit plus d'une machine à état que d'un co-processeur, mais passons ce détail sous silence. Il exécute une display list, à savoir une liste d'instructions, un programme. Les instructions en question sont de trois type : MOV, WAIT et SKIP. L'instruction MOV écrit dans les registres de configuration du VDC, ce qui lui permet d'initialiser des transferts DMA, de modifier les sprites, etc. L'instruction WAIT attend que le l'écran en soit arrivé au rendu du pixel de coordonnée X,Y, ce qui peut remplacer partiellement les raster interrupts. L'instruction SKIP est techniquement un branchement qui passe outre certaines instructions de la display list si l'écran a dépassé le pixel de coordonnée X,Y.

Le co-processeur Copper peut modifier les registres du blitter, ce qui lui permet de démarrer des copies mémoire sans que le CPU ne soit impliqué. Sans lui, le CPU devrait configurer les registres du blitter pour initier une copie. Ici, le CPU n'a rien à faire : tout est précisé dans la display list. Copper peut modifier les registres de sprites à la volée, il suffit de coder la display list adéquate. Copper peut aussi générer des raster interrupts, encore que leur utilité soit ici moindre, vu que blitter et registres de sprites sont gérés par la display list. Mais elles sont supportées pour gérer la synchronisation CPU-mémoire vidéo.


Annexe : le mode texte

Les toutes premières cartes d'affichage portaient le nom de cartes d'affichage en mode texte. Comme leur nom l'indique, elles sont spécifiquement conçues pour afficher du texte, pas des images. Elles étaient utilisées sur les ordinateurs personnels ou professionnels, qui n'avaient pas besoin d'afficher des graphismes, seulement des lignes de commandes, tableurs, traitements de textes rudimentaires, etc. Elles ont rapidement été remplacées par des cartes graphiques avec un framebuffer. Il s'agit d'une avancée énorme, qui permet beaucoup plus de flexibilité dans l'affichage.

L'existence de telles cartes en mode texte tient au fait que la mémoire vidéo était chère et qu'on ne pouvait pas en mettre beaucoup dans une carte d'affichage ou dans une console de jeu. Aussi, les fabricants de cartes graphiques devaient ruser. La petitesse de la mémoire faisait qu'il n'y avait pas de framebuffer proprement dit. On a vu au chapitre précédent qu'il existe des techniques de rendu 2D qui se passent de framebuffer, hé bien les premières cartes d'affichage les utilisaient sous une forme détournée.

Le mode texte

modifier

En mode texte, 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ères 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.

 

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.

 
Illustration du mode texte.

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 du code ASCII du caractère.

Le 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 inimitables.

Pour faire une comparaison, les caractères des cartes en mode texte sont équivalents aux tiles des cartes 2D. Une carte en mode texte peut être vue comme une carte 2D dont chaque caractère est une tile, et où il n'y a qu'un arrière-plan correspondant au texte à afficher et pas de sprite. 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. D'ailleurs, le hardware de ces deux types de carte se ressemble beaucoup, attendez-vous à voir quelques ressemblances.

 
Rendu d'une image en mode semi-graphique.

A ce propos, il est possible d'émuler un rendu graphique à parti du mode texte, en trichant un petit peu. L'idée est de remplacer les caractères par des dessins équivalents à des tiles. C'est possible sur les cartes en mode texte sur lesquelles les caractères sont configurables, c’est-à-dire qu'on peut préciser à quoi ressemblent les caractères. Sur de telles cartes en mode texte, on peut fournir un dessin rectangulaire de quelques pixels de côté à la carte graphique et lui dire : ceci est le caractère numéro 40. On a alors un mode de rendu appelé mode semi-graphique.

L'architecture d'une carte d'affichage en mode texte

modifier

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 termes 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 et avec framebuffer ont une architecture assez similaire. Pour rappel, une carte graphique avec un framebuffer est composée de plusieurs composants : un circuit d’interfaçage avec le bus, un circuit de contrôle appelé le CRTC, une mémoire vidéo, un DAC qui convertit les pixels en signal analogique, et quelques circuits annexes.

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

Une carte en mode texte a les mêmes composants, avec quelques modifications. Le tampon de texte (text buffer) est la mémoire vidéo. Dans celle-ci, les caractères à afficher sont placés les uns à la suite des autres. Chaque caractère est stocké en mémoire avec deux octets : un octet pour le code ASCII, suivi d'un octet pour les attributs. 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.

Le CRTC est modifié de manière tenir compte de l'organisation du tampon de texte. De plus, quelques circuits sont ensuite utilisés pour faire la conversion texte-image, comme on le verra plus bas. Le circuit de conversion texte-pixel est une petite mémoire ROM appelée la mémoire de caractère, qui mémorise, pour chaque caractère, sa représentation sous forme de pixels. La carte graphique contient aussi un circuit chargé de gérer les 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.

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

La table des caractères

modifier

Les images de chaque caractère sont mémorisées dans une mémoire : la table des caractères, aussi appelée mémoire des caractères dans les schémas au-dessus. Dans cette mémoire, chaque caractère est représenté par une matrice de pixels, avec un bit par pixel. Certaines cartes graphiques permettent à l'utilisateur de créer ses propres caractères en modifiant cette table, ce qui en fait une mémoire ROM/EEPROM ou RAM.

On pourrait croire que la table des caractères 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 pixels en sortie, soit facilement plusieurs centaines de bits, 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 pixel, le pixel 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. Les deux numéros sont fournis par le CRTC, comme on le verra plus bas.

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

La table des caractères ressemble, sur le principe, à la tilemap des cartes à rendu 2D sans framebuffer. Les deux convertissent un caractère/tile en pixels. La méthode d'adressage est fortement similaire, l'utilisation l'est aussi. La seule différence est leur contenu, la table des caractères stockant des caractères, la tilemap stockant des tiles graphiques.

Le CRTC sur une carte en mode texte

modifier

Le mode texte impose de modifier le CRTC de manière à ce qu'il adresse le tampon de texte correctement. Il contient toujours deux compteurs, pour localiser la ligne et la colonne du pixel à afficher, mais doit transformer cela en adresse de caractère. Le CRTC 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, et le CRTC fournit de quoi 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 calculs à 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ères 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.
 
Motorola 6845

Un exemple de CRTC est le Motorola 6845. Ce VDP génère l'adresse à lire dans la mémoire vidéo, ainsi que les signaux de synchronisation horizontale et verticale, mais ne fait pas grand-chose d'autre. Lire la mémoire vidéo, extraire les pixels et envoyer le tout à l'écran n'est pas de son ressort.

Il gère le mode texte uniquement, mais on peut supporter le mode graphique en trichant. Il supporte le mode entrelacé et le mode non-entrelacé, et est compatible aussi bien avec les moniteurs en PAL qu'en NTSC. Il contient 18 registres dont le contenu permet de configurer le VDP, pour configurer la résolution, la fréquence d'affichage, et d'autres choses encore.

 
Motorola MC6845P

D'autres CRTC plus évolués gèrent à la fois le mode texte et le mode graphique, on peut les configurer de manière à choisir lequel utiliser. Il est ainsi possible de prendre un VDP CRTC pour le mettre avec une mémoire assez petite pour gérer uniquement des graphiques en mode texte. Ou au contraire, de prendre un VDP CRTC et de le combiner avec une mémoire importante pour l'utiliser comme framebuffer.

Le défilement du texte

modifier

Faire défiler du texte est une opération très courante en mode texte. Concrètement, cela revient à descendre de quelques lignes dans le texte, ce qui demande de bouger tout le texte à l'écran. Et il est très utile, car défiler du texte vers le bas ou le haut est une opération très courante ! Et il s'agit d'une optimisation très couteuse. Aussi, même les premières cartes graphiques pour PC disposaient de techniques similaires pour accélérer le défilement vertical. Les toutes premières cartes graphiques MBA (monochromes) et CGA n'incorporaient pas de défilement vertical optimisé, mais les cartes suivantes, EGA et VGA, le faisaient. Les optimisations en question sont nombreuses, mais nous allons en voir deux.

Les optimisations du défilement basées sur un viewport

modifier

La première optimisation que nous allons voir réutilise les techniques vues dans le chapitre sur le rendu 2D. Précisément, il s'agit de l'optimisation du défilement vertical. En effet, le texte est généralement défilé de haut en bas, verticalement, le défilement horizontal étant plus rare. Même sur les écrans de l'époque, qui avaient des colonnes limitées à 80/100 caractères, le texte était conçu de manière à ce que les lignes de texte ne débordent pas de l'écran. Aussi, les optimisations qui nous intéressent sont surtout les optimisations du défilement vertical.

 
Implémentation du défilement vertical accéléré en hardware via un pointeur de framebuffer

L'idée pour un rendu en pixels est d'avoir un framebuffer organisé en lignes et colonnes, avec plus de lignes que ce que prévoit l'écran. Ce qui est visible à l'écran est une protion du framebuffer, appelée le viewport. Ainsi, les lignes à afficher au-dessus ou en dessous du viewport sont déjà en mémoire vidéo. Il y a juste à fournir un registre qui point vers la position du viewport dans la carte graphique, ce qui permet de faire bouger le viewport à volonté. Le tout est complété avec un warp-around des adresses, à savoir que si le viewport arrive à la toute fin du framebuffer et descend encore, il reprend au tout début du framebuffer.

Le défilement en mode texte se base sur le même principe, sauf qu'on saute d'une ligne de caractère à une autre directement. Le défilement ne se fait pas ligne de pixel par ligne de pixel, mais par paquets de plusieurs lignes. Par exemple, si un caractère de texte fait 8 pixels de haut, alors on saute les lignes par paquets de 8.

L'usage d'une display list

modifier

Une autre solution, plus économe en RAM, fait usage d'un VDC à co-processeur. Pour rappel, ce sont des VDC qui incorporent un processeur qui exécute un programme d'affichage. Le programme, appelé la display list, afficher une image à l'écran, ou du texte, ou tout ce qu'il faut afficher. Chaque instruction de la display list dit quoi afficher sur une ligne à l'écran. Ici, elle dit quoi afficher sur une ligne de texte, une ligne de caractère, et non une ligne de pixels comme dans les chapitres précédents.

Un exemple de VDC à co-processeur en mode texte est celui des ordinateurs Amstrad PCW. Il s'agissait d'ordinateurs qui ne géraient que le mode texte, ils ne pouvaient pas afficher d'image pixel par pixel, ils n'avaient pas de framebuffer. Le texte à afficher à l'écran n'était pas stocké dans un tableau unique, ligne par ligne, dans l'ordre de rendu, comme c'est le cas sur les autres cartes en mode texte. A la place, chaque ligne était stocké en mémoire séparément les unes des autres, ou presque.

L'affichage était gouverné par une display list qui disait quelle ligne afficher, et dans quel ordre. La display list était une liste d'adresses, chacun pointant vers le début d'une ligne en mémoire vidéo. Toutes les lignes faisaient la même taille, ce qui fait que la display list avait juste à encoder l'adresse de départ de la ligne pour l'afficher correctement. Le processeur du VDC lisait la display list adresse par adresse et rendait les lignes dans l'ordre précisé par la display list.

Le défilement était alors simple à implémenter : il suffisait de modifier le contenu de la display list. Par exemple, pour défiler d'une ligne vers le cas, on décalait son contenu de la display list d'un cran et on modifiait la ligne la plus en haut. L'avantage est que modifier une display list est plus rapide que de faire défiler l'ensemble du text buffer.

La display list était stockée dans une mémoire RAM spécialisée de 512 octets, ce qui permettait de stocker 256 adresses de 16 bits chacune. La display list avait 256 lignes, ce qui collait exactement à la résolution de 720 par 256 pixels de l'Amstrad PCW. LA RAM qui mémorisait la display list s'appelait la roller RAM. Elle était utilisée par le processeur 280 de la machine pour gérer le rendu de l'affichage. Le VDC utilisé était en effet très simple et se résumait sans doute à un vulgaire CRTC en mode texte.


Le rendu d'une scène 3D : shaders et T&L

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
 
Illustration d'un dauphin, représenté avec des triangles.

Dans la quasi-totalité des jeux vidéo actuels, les objets et la scène 3D 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 quadrilatères (rendu dit en quad) ou d'autres polygones, mais les contraintes techniques ont fait que ces solutions n'ont pas été retenues.

 
Exemple de modèle 3D.
En général, il est possible de créer un modèle 3D avec autre chose que des triangles ou des quadrilatères, avec des polygones concaves, des courbes de Béziers, et bien d'autres techniques. Mais ces solutions sont peu pratiques et plus complexes.

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.

Un segment qui connecte une paire de sommets s'appelle une arête, comme en géométrie élémentaire. Plusieurs arêtes délimitent une surface fermée, celle-ci est appelée une face, ou encore une primitive.

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

Les API 3D supportent des primitives assez diverses. Elles gèrent au minimum les points, les lignes et les triangles. Elles gèrent éventuellement les triangle-strip et triangle-fan. Aujourd'hui, OpenGL et DirectX ne gèrent plus 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). Pour information, voici les primitives gérées par les premières versions d'OpenGL :

 
Primitives supportées par OpenGL.

Précisons cependant que le support d'une primitive par une API 3D ne signifie pas que la carte graphique supporte ces primitives. Il se peut que les primitives soient découpées en triangles par la carte graphique lors de l'exécution, alors qu'elles sont supportées par l'API 3D.

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.

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.

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.

Plaquer une texture sur un objet peut se faire de deux manières, qui portent les noms de placage de texture inverse et direct. Le placage de texture direct a été utilisé au tout début de la 3D, sur des bornes d'arcade et quelques consoles de jeu. La 3DO, la PS1, la Sega Saturn, utilisaient ce genre de rendu. Avec ce genre de rendu, les textures ne sont pas plaquer sur les objets, mais sont en fait écrites directement dans le framebuffer, après avoir été tournées et redimensionnés. Il s'agit d'un rendu très particulier que nous aborderons dans ce cours dans des chapitres dédiés. De nos jours, on utilise uniquement la technique de placage de texture inverse, qui sera décrite plus bas.

La différence entre rastérisation et lancer de rayons

modifier
 
Même géométrie, plusieurs rendus différents.

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 principale est que le lancer de rayons demande beaucoup de puissance de calcul. Une autre raison est 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. Un chapitre entier sera dédié aux cartes accélératrices de lancer de rayons et nous verrons pourquoi le lancer de rayons est difficile à implémenter avec des performances convenables, ce qui explique que les jeux vidéo utilisent la rasterization.

La rastérisation est structurée autour de trois étapes principales :

  • 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.

L'étape de traitement de la géométrie et celle de rastérisation sont souvent réalisées dans des circuits ou processeurs séparés, comme on le verra plus tard. Il existe plusieurs rendus différents et l'étape de rastérisation dépend fortement du rendu utilisé. Il existe des rendus sans textures, d'autres avec, d'autres avec éclairage, d'autres sans, etc. Par contre, l'étape de calcul de la géométrie est la même quelque soit le rendu ! Mieux : le calcul de la géométrie se fait de la même manière entre rastérisation et lancer de rayons, il est le même quelque soit la technique de rendu 3D utilisée.

Mais quoiqu'il en soit, le rendu d'une image est décomposé en une succession d'étapes, chacune ayant un rôle bien précis. Le traitement de la géométrie est lui-même composé d'une succession de sous-étapes, la rasterisation est elle-même découpée en plusieurs sous-étapes, et ainsi de suite. Le nombre d'étapes pour une carte graphique moderne dépasse la dizaine. La rastérisation calcule un rendu 3D avec 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.

Le calcul de la géométrie

modifier

Le traitement de la géométrie se fait lui-même en plusieurs étapes. La principale est l'étape de transformation, qui place les objets dans la scène 3D. Elle combine les différents modèles 3D avec le monde, pour obtenir la géométrie à afficher à l'écran. Elle en profite aussi pour faire d'autres changements de coordonnées, notamment pour centrer l'univers sur la caméra et corriger la perspective.

Le calcul de la géométrie regroupe plusieurs manipulations différentes. La principale demande juste de placer les modèles 3D dans la scène, de placer les objets dans le monde. Puis, il faut centrer la scène 3D sur la caméra. Les deux changements 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. 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 transformation

modifier

La première étape place les objets 3D dans la scène 3D. 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). La première étape place l'objet 3D à une position dans la scène 3D, déterminée par le moteur physique, qui a des coordonnées (X, Y, Z). 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, les trois impliquant une modification des coordonnées des sommets..

 
Transformations géométriques possibles pour chaque triangle.

Une fois le placement des différents objets effectué, la carte graphique effectue un changement de coordonnées pour centrer le monde sur la caméra. 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

Les trois étapes précédentes demande de faire des changements de coordonnées, chaque sommet voyant ses coordonnées 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.

Il se trouve que multiplier des matrices amène certaines simplifications. Au lieu de faire plusieurs multiplications de matrices, il est possible de fusionner les matrices en une seule, ce qui permet de simplifier les calculs. Ce qui fait que le placement des objets, changement de repère pour centrer la caméra, et d'autres traitements forts différents sont regroupés ensemble.

Le traitement de la géométrie implique, sans surprise, des calculs de géométrie dans l'espace. Et cela implique des opérations mathématiques aux noms barbares : produits scalaires, produits vectoriels, et autres calculs impliquant des vecteurs et/ou des matrices. Et les calculs vectoriels/matriciels impliquent beaucoup d'additions, de soustractions, de multiplications, de division, mais aussi des opérations plus complexes : calculs trigonométriques, racines carrées, inverse d'une racine carrée, etc. Au final, un simple processeur peut faire ce genre de calculs, si on lui fournit le programme adéquat, l'implémentation est assez aisée.

Mais on peut aussi implémenter le tout avec un circuit spécialisé, non-programmable. Les deux solutions sont possibles, tant que le circuit dispose d'assez de puissance de calcul. Les cartes graphiques anciennes contenaient 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.

L'élimination des surfaces cachées

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.

Les différentes formes de culling/clipping

modifier

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. De même, certains objets qui sont trop loin ne sont tout simplement pas calculés et remplacé par du brouillard, voire pas remplacé du tout.

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

Les autres formes de culling visent à éliminer ce qui est dans le view frustum, mais qui n'est pas visible depuis la caméra. 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. Dans les deux cas, nous parlerons d'élimination des surfaces cachées.

 
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 techniques de culling/clipping

modifier

Il existe plusieurs techniques pour effectuer le culling. Le view frustum culling est assez trivial : il suffit d'éliminer ce qui n'est pas dans le view frustum, quelques calculs de coordonnées assez simples le permette assez facilement. Par contre, l'élimination des surfaces cachées est plus compliqué. Le lancer de rayons n'a pas besoin d'éliminer les surfaces cachées, il ne calcule que les surfaces visibles. Par contre, la rastérisation demande d'éliminer les surfaces cachées. Sans cela, le rendu est incorrect dans le pire des cas, ou alors le rendu calcule des surfaces invisibles pour rien. Il existe de nombreux algorithmes logiciels pour implémenter l'élimination des surfaces cachées.

L'implémentation de ces algorithmes demande de connaitre la distance à la caméra de chaque triangle. La distance à la caméra est appelée la profondeur du triangle. Elle est déterminée à l'étape de rastérisation et est calculée à chaque sommet. Lors de la rastérisation, chaque sommet se voit attribuer trois coordonnées : deux coordonnées x et y qui indiquent sa position à l'écran, et une coordonnée de profondeur notée z.

 
Polygons cross
 
Painters problem

Pour éliminer les surfaces cachées, la solution la plus simple consiste simplement à rendre les triangles du plus lointain au plus proche. L'idée est que si deux triangles se chevauchent, on doit dessiner celui qui est derrière, puis celui qui est devant. Le dessin du second va recouvrir le premier. Quelque chose qui devrait vous rappeler le rendu 2D, où les sprites sont rendus du plus lointain au plus proche. Un problème est que la solution ne marche pas avec certaines configurations particulières, dans le cas où des polygones un peu complexes se chevauchent plusieurs fois. Mais il se présente rarement dans un rendu 3D normal.

Les anciens jeux en 2.5D comme DOOM ou les DOOM-like, utilisaient une amélioration de l'algorithme précédent. L'"amélioration variait suivant le moteur de jeu utilisé, et donnait soit une technique dite de portal rendering, soit un système de Binary Space Partionning, assez complexes et difficiles à expliquer.

Une autre solution utilise ce qu'on appelle un tampon de profondeur, aussi appelé un z-buffer. 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 triangles sont rastérisé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 triangle 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 : il est éliminé et ne poursuit pas sa route au-delà de l'unité de rastérisation. Le tampon de profondeur n'a pas à être mis à jour. Dans le cas contraire, l'objet 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.

Toutes les cartes graphiques modernes utilisent un système de z-buffer. C'est la seule solution pour avoir des performances dignes de ce nom.

La rastérisation

modifier

L'étape de rastérisation est difficile à expliquer, surtout que son rôle exact dépend de la technique de rendu utilisée. Pour simplifier, elle projette un rendu en 3D sur un écran en 2D. Une autre explication tout aussi vague est qu'elle s'occupe la traduction des triangles en un affichage pixelisé à l'écran. Elle détermine à quoi ressemble la scène visible sur l'écran. C'est par exemple 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.

L'étape de rastérisation effectue aussi beaucoup de traitements différents, mais l'idée structurante est que rastérisation et placage de textures sont deux opérations très liées entre elles. Il existe deux manières principales pour 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. Précisons cependant que les rendus les plus simples n'utilisent pas de textures du tout. Ils se contentent de colorier les triangles, voire d'un simple rendu en fil de fer basé sur du tracé de lignes. Dans la suite de cette section, nous allons voir les quatre types de rendu principaux : le rendu en fils de fer, le rendu colorié, et deux rendus utilisant des textures.

Le rendu en fil de fer

modifier
 
Rendu en fil de fer d'un objet 3D.

Le rendu 3D en fils de fer est illustré ci-contre. Il s'agit d'un rendu assez ancien, utilisé au tout début de la 3D, sur des machines qu'on aurait du mal à appeler ordinateurs. Il se contente de tracer des lignes à l'écran, lignes qui connectent deux sommets, qui ne sont autres que les arêtes de la géométrie de la scène rendue. Le tout était suffisant pour réaliser quelques jeux vidéos rudimentaires. Les tout premiers jeux vidéos utilisaient ce rendu, l'un d'entre eux étant Maze War, le tout premier FPS.

 
Maze war
 
Maze representation using wireframes 2022-01-10

Le monde est calculé en 3D, il y a toujours un calcul de la géométrie, la scène est rastérisée normalement, les portions invisbles de l'image sont retirées, mais il n'y a pas d'application de textures après rastérisation. A la place, un algorithme de tracé de ligne trace les lignes à l'écran. Quand un triangle passe l'étape de rastérisation, l'étape de rastérisation fournit la position des trois sommets sur l'écran. En clair, elle fournit les coordonnées de trois pixels, un par sommet. A la suite, un algorithme de tracé de ligne trace trois lignes, une par paire de sommet.

L'implémentation demande juste d'avoir une unité de calcul géométrique, une unité de rastérisation, et un VDC qui supporte le tracé de lignes. Elle est donc assez simple et ne demande pas de circuits de gestion des textures ni de ROP. Le VDC écrit directement dans le framebuffer les lignes à tracer.

 
Evans & Sutherland LDS-1 (1)

Il a existé des proto-cartes graphiques spécialisées dans ce genre de rendu. Un exemple est le Line Drawing System-1 de l'entreprise Eans & Sutherland, qui n'est ni plus ni moins que le toute premier circuit graphique séparé du processeur ayant existé. C'est en un sens la toute première carte graphique, le tout premier GPU. Il prenait la forme d'un périphérique qui se connectait à l'ordinateur d'un côté et était relié à l'écran de l'autre. Il était compatible avec un grand nombre d'ordinateurs et de processeurs existants.

L'intérieur du circuit était assez simple : un circuit de multiplication de matrice pour les calculs géométriques, un rastériser simplifié (le clipping diviser, un circuit de tracé de lignes, et un processeur de contrôle pour commander les autres circuits. Le fait que ces trois circuits soient séparés permettait une implémentation en pipeline, où plusieurs portions de l'image pouvaient être calculées en même temps : pendant que l'une est dans l'unité géométrique, l'autre est dans le rastériseur et une troisième est en cours de tracé.

 
Architecture du LDS-1. Le processeur de contrôle n'est pas représenté.

Le processeur de contrôle exécute un programme qui se charge de commander l'unité géométrique et les autres circuits. Le programme en question est fourni par le programmeur, le LDS-1 est donc totalement programmable. Il lit directement les données nécessaires pour le rendu dans la mémoire de l’ordinateur et le programme exécuté est lui aussi en mémoire principale. Il n'a pas de mémoire vidéo dédiée, il utilise la RAM de l'ordinateur principal.

Le multiplieur de matrices est plus complexe qu'on pourrait s'y attendre. Il ne s'agit pas que d'un circuit arithmétique tout simple, mais d'un véritable processeur avec des registres et des instructions machine complexes. Il contient plusieurs registres, l'ensemble mémorisant 4 matrices de 16 nombres chacune (4 lignes de 4 colonnes). Un nombre est codé sur 18 bits. Les registres sont reliés à un ensemble de circuits arithmétiques, des additionneurs et des multiplieurs. Le circuit supporte des instructions de copie entre registres, pour copier une ligne d'une matrice à une autre, des instructions LOAD/STORE pour lire ou écrire dans la mémoire RAM, etc. Il supporte aussi des multiplications en 2D et 3D.

Le clipping divider est un circuit assez complexe, contenant un processeur à accumulateur, une mémoire ROM pour le programme du processeur. Le programme exécuté par le processeur est un petit programme de 62 instructions, stocké dans la ROM. L'algorithme du clipping divider est décrite dans le papier de recherche "A clipping divider", écrit par Robert Sproull.

Un détail assez intéressant est que le résultat en sortie de l'unité géométrique et du rastériseur peuvent être envoyés à l'ordinateur en parallèle du rendu. C'était très utile sur les anciens ordinateurs qui étaient connectés à plusieurs terminaux. Le LDS-1 calculait la géométrie et le rendu, et le tout pouvait petre envoyé à d'autres composants, comme des terminaux, une imprimante, etc.

Le rendu à primitives colorées

modifier
 
Exemple de rendu pouvant être obtenu avec des sommets colorés.

Une amélioration du rendu précédent utilise des triangles/quads coloriés. Chaque triangle ou quad est associé à une couleur, et cette couleur est dessinée sur le triangle/quadaprès la rastérisation. Le rendu est une amélioration du rendu en fils de fer. L'idée est que chaque triangle/quad est associé à une couleur, qui est dessinée sur le triangle/quad après la rastérisation. La technique est nommée colored vertices en anglais, nous parlerons de rendu à maillage coloré.

 
Maillage coloré.

La couleur est propagée lors des calculs géométriques et de la rastérisation, sans subir de modifications. Une fois un rendu en fils de fer effectué, la couleur du triangle est récupérée. Le triangle/quad rendu correspond à un triangle/quad à l'écran. Et l'intérieur de ce triangle/quad est colorié avec la couleur transmise. Pour cela, on utilise encore une fois une fonction du VDC : celle du remplissage de figure géométrique. Nous l’avions vu en parlant des VDC à accélération 2D, mais elle est souvent prise en charge par les blitters. Ils peuvent remplir une figure géométrique avec une couleur unique, on réutilise cette fonction pour colorier le triangle/quad. L'étape de rastérisation fournit les coordonnées des sommets de la figure géométrique, le blitter les utilise pour colorier la figure géométrique.

Niveau matériel, quelques bornes d'arcade ont utilisé ce rendu. Dès 1988, les cartes d'arcades Namco System 21 et les bornes d'arcades Sega Model 1 utilisaient ce genre de rendu. On peut s'en rendre compte en regardant les graphismes des jeux tournant sur ces bornes d'arcade. Des jeux comme Virtua Racing, Virtua Fighter ou Virtua Formula sont assez parlants à ce niveau. Leurs graphismes sont assez anguleux et on voit qu'ils sont basés sur des triangles uniformément colorés.

La Namco System 2 implémentait ce rendu en calculant la géométrie dans des processeurs dédiés, 4 DSP de marque Texas Instruments TMS320C25, cadencés à 24,576 MHz. La carte d'arcade Sega Model 1 utilisait quant à elle un DSP spécialisé dans les calculs géométriques. Les deux cartes n'utilisaient pas de circuit géométrique fixe, mais l'émulaient avec des processeurs programmés avec un firmware/microcode spécialisé pour implémenter le pipeline géométrique et le T&L en logiciel.

Le placage de textures direct

modifier

Les deux rendus précédents sont très simples et il n'existe pas de carte graphique qui les implémente. Cependant, une amélioration des rendus précédents a été implémentée sur des cartes graphiques assez anciennes. Le rendu en question est appelé le rendu par placage de texture direct, que nous appellerons rendu direct dans ce qui suit. Le rendu direct a été utilisé sur les anciennes consoles de jeu et sur les anciennes bornes d'arcade, mais il est aujourd'hui abandonné. Il n'a servi que de transition entre rendu 2D et rendu 3D.

L'idée est assez simple et peut utiliser aussi bien des triangles que des quads, mais nous allons partir du principe qu'elle utilise des quads, à savoir que les objets 3D sont composés de quadrilatères. Lorsqu'un quad est rastérisé, sa forme à l'écran est un rectangle déformé par la perspective. On obtient un rectangle si le quad est vu de face, un trapèze si on le voit de biais. Et le sprite doit être déformé de la même manière que le quad.

L'idée est que tout quad est associé à une texture, à un sprite. La figure géométrique qui correspond à un quad à l'écran est remplie non pas par une couleur uniforme, mais par un sprite rectangulaire. Il suffit techniquement de recopier le sprite à l'écran, c'est à dire dans la figure géométrique, au bon endroit dans le framebuffer. Le rendu direct est en effet un intermédiaire entre rendu 2D à base de sprite et rendu 3D moderne. La géométrie est rendue en 3D pour générer des quads, mais ces quads ne servent à guider la copie des sprites/textures dans le framebuffer.

 
Exemple caricatural de placage de texture sur un quad.

La subtilité est que le sprite est déformé de manière à rentrer dans un quadrilatère, qui n'est pas forcément un rectangle à l'écran, mais est déformé par la perspective et son orientation en 3D. Le sprite doit être déformé de deux manières : il doit être agrandi/réduit en fonction de la taille de la figure affichée à l'écran, tourné en fonction de l'orientation du quad, déformé pour gérer la perspective. Pour cela, il faut connaitre les coordonnées de profondeur de chaque bord d'un quad, et de faire quelques calculs. N'importe quel VDC incluant un blitter avec une gestion du zoom/rotation des sprites peut le faire.

Si on veut avoir de beaux graphismes, il vaut mieux appliquer un filtre pour lisser le sprite envoyé dans le trapèze, filtre qui se résume à une opération d'interpolation et n'est pas très différent du filtrage de texture qui lisse les textures à l'écran.

Un autre point est que les quads doivent être rendus du plus lointain au plus proche. Sans cela, on obtient rapidement des erreurs de rendu. L'idée est que si deux quads se chevauchent, on doit dessiner celui qui est derrière, puis celui qui est devant. Le dessin du second va recouvrir le premier. L'écriture du sprite du second quad écrasera les données du premier quad, pour les portions recouvertes, lors de l'écriture du sprite dans le framebuffer. Quelque chose qui devrait vous rappeler le rendu 2D, où les sprites sont rendus du plus lointain au plus proche.

Le rendu inverse utilise très souvent des triangles pour la géométrie, alors que le rendu direct a tendance à utiliser des quads, mais il ne s'agit pas d'une différence stricte. L'usage de triangles/quads peut se faire aussi bien avec un rendu direct comme avec un rendu inverse. Cependant, le rendu en quad se marie très bien au rendu direct, alors que le rendu en triangle colle mieux au rendu inverse.

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. Mais un même pixel du framebuffer est écrit plusieurs fois quand plusieurs quads se superposent, alors que le rendu inverse gère la situation avec une seule écriture (sauf si usage de la transparence). De plus, la gestion de la transparence était compliquée et les jeux devaient ruser en utilisation des solutions logicielles assez complexes.

Niveau implémentation matérielle, une carte graphique en rendu direct demande juste trois circuits. Le premier est un circuit de calcul géométrique, qui rend la scène 3D. Le tri des quads est souvent réalisé par le processeur principal, et non pas par un circuit séparé. Toutes les étapes au-delà de l'étape de rastérisation étaient prises en charge par un VDC amélioré, qui écrivait des sprites/textures directement dans le framebuffer.

Géométrie Processeurs dédiés programmé pour émuler le pipeline graphique
Tri des quads du plus lointain au plus proche Processeur principal (implémentation logicielle)
Application des textures Blitter amélioré, capable de faire tourner et de zoomer sur des sprites.

L'implémentation était très simple et réutilisait des composants déjà existants : des VDC 2D pour l'application des textures, des processeurs dédiés pour la géométrie. Les unités de calcul de la géométrie étaient généralement implémentées avec un ou plusieurs processeurs dédiés. Vu qu'on savait déjà effectuer le rendu géométrique en logiciel, pas besoin de créer un circuit sur mesure. Il suffisait de dédier un processeur spécialisé rien que pour les calculs géométriques et on lui faisait exécuter un code déjà bien connu à la base. En clair, ils utilisaient un code spécifique pour émuler un circuit fixe. C'était clairement la solution la plus adaptée pour l'époque.

Les unités géométriques étaient des processeurs RISC, normalement utilisés dans l'embarqué ou sur des serveurs. Elles utilisaient parfois des DSP. Pour rappel, les DSP des processeurs de traitement de signal assez communs, pas spécialement dédiés aux rendu 3D, mais spécialisé dans le traitement de signal audio, vidéo et autre. Ils avaient un jeu d'instruction assez proche de celui des cartes graphiques actuelles, et supportaient de nombreuses instructions utiles pour le rendu 3D.

 
Sega ST-V Dynamite Deka PCB 20100324

L'accélération du rendu 3D sur les bornes d'arcade était déjà bien avancé dès les années 90. Dès 1988, les bornes d'arcade Namco System 21 et Sega Model 1 géraient les calculs géométriques, grâce à plusieurs DSP dédiés au calcul de la géométrie. Quelques années plus tard, les cartes graphiques se sont mises à supporter un éclairage de Gouraud et du placage de texture. Le Namco System 22 et la Sega model 2 supportaient des textures 2D et comme le filtrage de texture (bilinéaire et trilinéaire), le mip-mapping, et quelques autres. Par la suite, elles ont réutilisé le hardware des PC et autres consoles de jeux.

Outre les bornes d'arcade, quelques consoles de 5ème génération utilisaient le rendu direct, avec les mêmes solutions matérielles. La géométrie était calculée sur plusieurs processeurs dédiés. Le reste du pipeline était géré par un VDC 2D qui implémentait le placage de textures. Deux consoles étaient dans ce cas : la 3DO, et la Sega Saturn.

La Sega Saturn incorpore trois processeurs et deux VDC. Les deux VDC sont nommés le VDP1 et le VDP2. Le VDP1 s'occupe des textures/sprites, le VDP2 s'occupe uniquement de l'arrière-plan. La géométrie est calculée par les trois processeurs. Le troisième processeur, la Saturn Control Unit, est un processeur de type DSP, utilisé exclusivement pour les calculs géométriques. Il avait sa propre mémoire RAM dédiée de 32 KB de SRAM. Les transferts entre cette RAM et le reste de l'ordinateur étaient gérés par un contrôleur DMA intégré dans le DSP.

Le placage de textures inverse

modifier

Le rendu précédent, le rendu direct, permet d'appliquer des textures directement dans le framebuffer. Mais comme dit plus haut, il existe une seconde technique pour plaquer des textures, appelé le placage de texture inverse, aussi appelé l'UV Mapping. Elle associe une texture complète pour un modèle 3D,contrairement au placage de tecture direct qui associe une texture par quad/triangle. L'idée est que l'on attribue un texel à chaque sommet. Plus précisémment, 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.

 
Exemple de placage de 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. 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

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 qui écrivent un pixel dans le framebuffer. 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.

 
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.

Lors de la rasterisation, chaque triangle se voit attribuer un ou plusieurs pixels à l'écran. Pour bien comprendre, 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. L'étape de rastérisation prend en entrée un triangle et renvoie la coordonnée x,y du pixel associé.

Il s'agit là d'une simplification, car un triangle tend à occuper plusieurs pixels sur l'écran. L'étape de rastérisation fournit la liste de tous les pixels occupés par un triangle, et les traite un par un. Quand un triangle est rastérisé, le rasteriseur détermine la coordonnée x,y du premier pixel, applique une texture dessus, puis passe au suivant, et rebelote jusqu'à ce que tous les pixels occupés par le triangles aient été traités.

L'implémentation matérielle du placage de texture inverse est beaucoup plus complexe que pour les autres techniques. Pour être franc, nous allons passer le reste du cours à parler de l'implémentation matérielle du placage de texture inverse, ce qui prendra plus d'une dizaine de chapitres. L'implémentation simplifiée est que l'on a des unités géométriques, une unité de rastérisation, un circuit de placage de textures et enfin un ROP pour gérer les opérations finales. Du moins sur le principe, car les cartes graphiques modernes ont fortement optimisé l'implémentation et n'ont pas hésité à fusionner certains circuits. Mais nous verrons cela en temps voulu, nous n'allons pas résumer plusieurs décennies d'innovation technologique en quelques paragraphes.

L'éclairage d'une scène 3D

modifier

L'éclairage d'une scène 3D calcule les ombres, mais aussi la luminosité de chaque pixel, ainsi que bien d'autres effets graphiques. Les algorithmes d'éclairage ont longtemps été implémentés directement en matériel, les cartes graphiques géraient l'éclairage dans des circuits spécialisés. Aussi, il est important de voir ces algorithmes d'éclairage. Il est possible d'implémenter l'éclairage à deux endroits différents du pipeline : juste avant la rastérisation, et après la rastérisation.

Il est possible d'ajouter une étape d'éclairage dans la phase de traitement de la géométrie. Elle attribue une illumination/couleur à chaque triangle ou à chaque sommet de la scène 3D. L'éclairage obtenu est appelé du vertex lighting, terme qui désigne toute technique où l'éclairage est calculé pour chaque sommet/triangle d’une scène 3D. Il est assez rudimentaire et donne un éclairage très brut, qui ne suffit pas à elle seule à éclairer la scène comme voulu.

L'éclairage par pixel (per-pixel lighting), est calculé pixel par pixel. En clair, l’éclairage est finalisé après l'étape de rastérisation, il ne se fait pas qu'au niveau de la géométrie. Même des algorithmes d'éclairage par pixel très simples demandent une intervention des pixels shaders. 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.

En général, l'éclairage par pixel a une qualité d'éclairage supérieure aux techniques d'éclairage par sommet, mais il est aussi plus gourmand. L'éclairage par pixel 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. Mais cela n'aurait pas été possible si le matériel n'avait pas évolué de manière à incorporer des algorithmes d'éclairage matériel assez puissants, avant de basculer sur un éclairage programmable.

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

modifier

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 au centre des triangles 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.

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.

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 formes d'illumination 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. Il existe plusieurs sous-types d'illumination d'origine directionnelles, les deux principales étant les deux suivantes :
    • 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).
 
Illustration de la dispersion de la lumière directionnelle : les deux premiers sont des exemples d'illumination diffuse, calculés avec des algorithmes différents. Le troisième cas est l'illumination spéculaire.

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. La couleur de la source de lumière est une version améliorée de l'intensité de la source lumineuse, dans le sens où on a une intensité pour chaque composante RGB.

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 à partir de la lumière ambiante, mais elle n'est pas strictement égale à celle-ci, 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 basiques : par triangle, par sommet et par pixel

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.

 
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.

Pour simplifier, 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
 
Phong Shading
 
Flat shading
 
Gouraud Shading
 
Phong Shading
 
Flat shading
 
Gouraud Shading
 
Phong Shading
 
Flat shading
 
Gouraud Shading
 
Phong Shading

L'éclairage de Gouraud et l'éclairage plat sont des cas particulier d'éclairage par sommet (vertex lighting), où l'éclairage est calculé sur la géométrie d'une scène 3D. Les calculs géométriques se ressemblent beaucoup aux calculs d'éclairage plat/Gouraud, car ils impliquent tous deux des calculs vectoriels, comme des produits scalaires et des produits vectoriels, qui agissent sur des sommets/triangles.

L'implémentation des calculs géométriques peut se faire de deux manières : soit avec un processeur dédié, soit avec un circuit fixe, non-programmable. La seconde solution utilise un circuit de Transform & Lightning, qui effectue tous les calculs géométriques, éclairage par sommetinclus (d'où le L de T&L). La première carte graphique à avoir intégré un circuit de T&L était la Geforce 256, qui fût la première à intégrer un circuit dit de Transform & Lightning. A la même époque, les consoles de jeu n'avaient pas de circuit de T&L.

L'éclairage de Gouraud demande, en plus des circuits de T&L, un circuit d'interpolation qui fait normalement partie de l'unité de rastérisation, comme on le verra plus tard dans le chapitre dédié. Pour donner un exemple, la console de jeu Playstation 1 gérait l'éclairage de Gouraud directement en matériel, mais seulement partiellement. Elle n'avait pas de circuit de T&L, mais intégrait un circuit pour interpoler les couleurs de chaque sommet. Sans ce circuit d'interpolation, l'éclairage de Gouraud aurait été impossible, même si le processeur était capable de calculer l'éclairage des sommets.

L'éclairage par pixel demande lui d'agir après l'étape de rastérisation, avant ou après l'unité de textures. Son implémentation demande d'ajouter un circuit d'éclairage par pixel dédié, placé après l'unité de texture. Des fonctionnalités d'éclairage par pixel matériel ont été ajoutées aux cartes graphiques, au cours des années 2000. Mais il s'agissait d'approximations basée sur l'usage de plusieurs textures spécialisées.

Il a existé quelques cartes graphiques capables de faire de l'éclairage de Phong en matériel, sans utiliser de circuit programmable. Un exemple est la carte graphique de la Nintendo DS, la PICA200. Créée par une startup japonaise, elle avait des capacités très intéressantes. Il s'agissait d’une carte graphique avec T&L matérielle, des unités de calcul géométrique programmables et des circuits d'éclairage par pixel. Elle implémentait matériellement un éclairage de Phong, du cel shading, des techniques de normal-mapping, de Shadow Mapping, de light-mapping, du cubemapping, de nombreux effets de post-traitement (bloom, effet de flou cinétique, motion blur, rendu HDR, et autres). Un autre exemple est celui de la Geforce 3, dont l'unité géométrique implémentait un algorithme de Phong pour calculer les normales, et qui envoyait cela au rastériseur, qui s'occupait de calculer l'éclairage pixel par pixel.

Le bump-mapping et autres approximations de l'éclairage par pixel

modifier

Les techniques dites de bump-mapping visent à ajouter du relief et des détails sur des surface planes en jouant sur l'éclairage. Les reliefs et autres détails ne sont pas présents dans la géométrie, mais sont ajoutés lors de l'étape d'éclairage. Ils sont mémorisés dans une texture appelée la bump-map, qui est appliquée au-dessus que la texture normale. L'implémentation hardware du bump-mapping demande juste de pouvoir appliquer plusieurs textures sur une surface, et de faire les traitements adéquats sur la bump-map. Les pipelines fixes en sont capables s'ils sont conçu pour.

 
Bump mapping

La technique du normal mapping est assez simple à expliquer, sans compter que plusieurs cartes graphiques l'ont implémentée avec des circuits fixes. Plus haut, nous avions vu que l'éclairage de Phong demande de calculer les lumières pour chaque pixel. Le normal-mapping consiste à précalculer les normales d'une surface dans une texture, appelée la normal-map, qui est utilisée lors de l'application des textures. Avec cette technique, l'éclairage n'est pas géré par pixel, mais par texel, ce qui fait qu'il a une qualité de rendu inférieure à un vrai éclairage de Phong, mais le résultat est assez appréciable.

 
Normal Maps.
 
Différence sans et avec normal-mapping.

L'implémentation du normal-mapping demande d'avoir de quoi lire une normal-map et d'utiliser les normales dans cette texture dans des calculs d'éclairage. Les unités de texture normale sont utilisées pour lire la normal-map, des circuits dédiés sont utilisés pour les calculs d'éclairage. Puis, les pixels shaders sont arrivés, ce qui nous amène assez naturellement au chapitre suivant.

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 rappel, le pipeline graphique comprend plusieurs étapes : plusieurs étapes de traitement de la géométrie, une phase de rastérisation, puis plusieurs étapes de traitement des pixels. Une API 3D comme DirectX ou OpenGl décrète quelles sont les étapes à faire, ce qu'elles font, et l'ordre dans lesquelles il faut les exécuter.

Il n'existe pas un pipeline graphique unique : chaque API 3D fait à sa sauce. Mais en général, on retrouve les mêmes étapes de base, complétées par des étapes facultatives sont sont apparues après. 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'OpenGL 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 des schémas qui décrivent le pipeline de DirextX 10 et 11. Vous voyez que le nombre d'étapes n'est pas le même, que les étapes elles-mêmes sont légèrement différentes, etc. Toutes les API 3D modernes sont organisées plus ou moins de la même manière, ce qui fait que le pipeline des schémas ci-dessous colle assez bien avec les logiciels 3D anciens et modernes, ainsi qu'avec l'organisation des cartes graphiques (anciennes ou modernes).

 
D3D Pipeline
 
Pipeline de D3D 11

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

modifier

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.

Les API imposent 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.


Les cartes graphiques : architecture de base

Dans ce chapitre, nous allons voir l'architecture de base d'une carte accélératrice 3D, et voir quelle est la distinction entre une carte accélératrice et un GPU. Dans ce chapitre, nous allons faire le lien avec le rendu tel que décrit dans le chapitre précédent. Les cartes graphiques modernes implémentent des circuits programmables, qui seront partiellement laissé de côté dans ce chapitre. Nous allons aussi nous concentrer sur les cartes graphiques à placage de texture inverse, le placage de texture direct ayant déjà été abordé dans le chapitre précédent.

L'architecture d'une carte graphique 3D

modifier

Une carte graphique 3D est composée de plusieurs circuits spécialisés, regroupé dans une même puce électronique pour les GPU modernes. 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.

L'un d'entre eux est un VDC qui est connecté à l'interface avec l'écran. Vous avez bien lu : le VDC est là, mais ne s'occupe pas du traitement de la 3D. Du moins, c'est le cas sur les cartes à placage de texture inverse. Le placage de texture direct utilise au contraire un VDC avec accélération 2D très performant, comme nous l'avons vu au chapitre précédent. Mais nous mettons ce cas particulier de côté.

Les cartes 3D à placage de texture inverse sont des cartes d'affichages auxquelles ont a ajouté des circuits pour le rendu de la 3D. 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.

En plus des circuits de rendu 3D, 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. 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.

 
Architecture globale d'une carte 3D

Les circuits de rendu 3D : une séparation entre géométrie et rastérisation

modifier

Les circuits pour le rendu 3D proprement dit comprennent un ensemble hétérogène de circuits aux fonctions fort différentes. Il est d'usage de découper la carte graphique en circuits 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é de traitement de la géométrie, une unité de rasterization, une unité d'enregistrement des pixels en mémoire appelée ROP, etc. Les anciennes cartes graphiques fonctionnaient ainsi, mais on verra que les cartes graphiques modernes font un petit peu différemment.

Pour simplifier les explications, 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 cartes graphiques modernes fusionnent les deux, dans une certaine mesure, mais oublions cela pour le moment. Cette séparation sera pertinente dans ce qui suit, car les deux circuits ont évolués séparément.

 
Architecture simplifiée d'une carte 3D

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 et les pixels calculés sont enregistrés dans le framebuffer. 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. De même, elles utilisent la technique du z-buffer, vue dans le chapitre précédent, pour éliminer les surfaces cachées.

 
Carte graphique, généralités

Le circuit de rastérisation et de plaquage de textures

modifier

Le circuit de rastérisation est assez complexe car il regroupe des fonctionnalités très différentes. Globalement, on peut la scinder en trois étapes, sur les cartes graphiques les plus simples. Les trois étapes sont réalisées par trois circuits distincts : l'unité de rastérisation, l'unité de placage de texture, et les Raster Operation Pipeline.

Le premier circuit s'occupe de la rastérisation proprement dite, qui projette une scène 3D sur l'écran. L'opération fait passer d'une scène 3D à un écran en 2D avec des pixels. Lors de la rastérisation, chaque sommet est associé à un ou plusieurs pixels, à savoir les pixels qu'il occupe à l'écran. Elle fournit aussi diverses informations qui sont utiles pour la suite du pipeline graphique, notamment la profondeur du sommet associé au pixel. Elle fournit notamment les coordonnées de textures associée au sommet, qui permettent de colorier le pixel.

L'étape de placage de texture lit la texture associée au modèle 3D et identifie le texel adéquat avec les coordonnées textures, pour colorier le pixel. On travaille pixel par pixel, on récupère le texel associé à chaque pixel. Soit l'inverse du placage de texture direct, qui traversait une texture texel par texel, pour recopier le texel dans le pixel adéquat.

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-traitement sont effectués et divers effets peuvent être ajoutés à l'image. Un 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.

 
Unité post-1.5éométrie d'une carte graphique sans elimination des surfaces cachées

Les circuits d'élimination des pixels cachés

modifier

L'élimination des surfaces cachées élimine les triangles invisibles à l'écran car cachés par un objet. Elle est prise en charge par un circuit dont la position dans le pipeline graphique varie, mais qui est toujours situé après l'étape de rastérisation. En effet, elle demande de connaitre la profondeur d'un pixel, qui est calculée par l'étape de rastérisation. Elle est réalisée soit immédiatement après la rastérisation, soit lors des ROPs.

Elle est généralement réalisée dans les ROPs car cela permet de gérer la transparence. Dès que de la transparence est présente, on ne peut pas éliminer les triangles invisibles avant le placage de textures. En effet, on ne sait pas si une texture transparente sera plaquée sur le triangle ou non. La seule option est alors de gérer la transparence et l'élimination des surfaces cachées dans les ROP.

 
Unité post-géométrie d'une carte graphique avec élimination des surfaces cachées dans les ROPs

Mais la plupart des cartes graphiques incorporent des circuits d'élimination des surfaces cachées juste après la rastérisation, pour gérer le cas où on sait d'avance que les textures ne sont pas transparentes. Cela permet d'éliminer à l'avance les triangles dont on sait qu'ils ne seront pas rendus.

 
Unité post-géométrie d'une carte graphique

Les deux possibilités coexistent sur les cartes graphiques modernes. Une carte graphique moderne peut éliminer les surfaces cachées soit après la rastérisation, soit avant, suivant que les textures sont marquées comme transparentes. Mais cela demande d'implémenter quelques techniques d'realy-z dont nous parlerons plus tard, dans un chapitre dédié sur la rastérisation.

Les circuits d'éclairage

modifier

Les circuits de rastérisation précédents correspondent au cas d'une carte graphique très simple, qui ne gère pas les techniques d'éclairage. Mais les cartes graphiques de ce genre n'ont tout simplement pas existé. Elles étaient toutes capables de gérer une forme d'éclairage limitée. Cependant, il faut faire la différence entre l'éclairage par pixel que l'éclairage par sommet. Pour rappel, l'éclairage par sommet attribue une couleur et une luminosité à chaque sommet. Et vu qu'un sommet prend plusieurs pixels à l'écran, l'éclairage est donc assez grossier. L'éclairage par pixel est lui plus fin car il attribue une luminosité pour chaque pixel de l'écran.

 
Implémentation de l'éclairage sur les cartes graphiques

Les deux formes d'éclairage sont implémentées différemment. L'éclairage par sommet est réalisé principalement dans l'unité de traitement de la géométrie, alors que l'éclairage par pixel demande une coopération entre l'unité de rastérisation et l'unité de traitement de la géométrie.

L'éclairage par sommet est réalisé par un circuit incorporé dans l'unité de traitement géométrique. Il est réalisé typiquement après ou pendant le calcul de la géométrie proprement dit. En général, un triangle est éclairé juste après avoir être calculé, mais le traitement se fait triangle par triangle.

Mais une implémentation complète demande de modifier l'unité de rastérisation. En effet, l'éclairage par sommet calcule une luminosité pour chaque triangle/sommet, comprise entre 0 (très sombre) et 1 (très brillant). Et elle doit être combinée avec le pixel texturé pour faire son œuvre. Typiquement, le pixel texturé est multiplié par la luminosité calculée par l'unité de traitement géométrique. On lit la texture, et le texel lu est alors modifié suivant la luminosité du triangle/sommet associé. Il y a donc un circuit de combinaison situé après l'unité de texture qui effectue la combinaison/multiplication.

 
Implémentation de l'éclairage par sommet avec des combiners

L'éclairage par pixel est implémenté de la même manière que l'éclairage par sommet. L'unité de texture est suivie par une unité d'éclairage par pixel dédiée. Dans le cas le plus simple, elle travaille à partir des données fournies par l'unité géométrique. Mais elle n'utilise pas la luminosité calculée par sommet. A la place, elle récupère les normales de chaque sommet, qui ont été interpolées par l'unité de rastérisation, et effectue un calcul d'éclairage pour calculer la luminosité. Et c'est cette luminosité qui est multipliée par le combiner.

 
Implémentation de l'éclairage par pixel avec des combiners

Parfois, l'opération de combinaison n'est pas une multiplication, mais une addition. Dans ce cas, on additionne le pixel texturé avec la couleur calculée par l'unité de traitement. Il est alors possible de choisir quelle opération effectuer pour combiner texture et luminosité/couleur d'un triangle/sommet. Le circuit de combinaison situé après les textures est alors configurable. Un tel circuit de combinaison s'appelle alors un combiner, dans la vielle nomenclature graphique de l'époque des années 90-2000.

les cartes graphiques ont depuis fortement modifié leurs circuits d'éclairage par pixel. De nos jours, les circuits d'éclairage, combiner inclus, n'existent plus vraiment. Ils ont été remplacés par un processeur dit de pixel shader, qui implémente des algorithmes d'éclairage avancés, voire des techniques de rendu non-liées à l'éclairage. L'unité de texture est inclue dans ce processeur de shader. L'avantage est que l'éclairage est maintenant programmable par le programmeur, et n'est plus limité à quelques algorithmes d'éclairage par pixel choisit par le constructeur de la carte graphique. Le programme d'éclairage est appelé un shader, il s'exécute sur un processeur et combine l'éclairage avec les textures.

 
Eclairage avec des pixels shaders

L'évolution de circuits d'éclairage fixes vers des processeurs de shaders s'est faite assez lentement, à force d'amélioration des circuits précédents, et nous détaillerons cela dans le prochains chapitre. Pour en faire un bref résumé, il a d'abord fallu que les techniques de multitexturing, qui appliquent plusieurs textures sur une surface, fassent surface. Puis, ces techniques de multitexturing ont évolué vers toujours plus de programmation, avant de laisser la main aux shaders. Le processus est alors compliqué à expliquer et n'a de sens que si on retrace l'histoire des cartes graphiques, ce qui est le sujet du chapitre suivant. Passons maintenant à autre chose.

Les cartes graphiques en mode immédiat et à tuile

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.

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 sont donc utilisées pour les équipements où la performance n'est pas une priorité, comme les appareils mobiles. Par contre, le rendu en tiles est 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.

Les cartes graphiques en mode immédiat effectuent la rasterisation triangle par tringle, pixel par pixel. L'unité géométrique envoie ses résultats à l'unité de rastérisation, sans passer par la mémoire. Entre l'unité géométrique et le rasteriseur, se trouve une mémoire tampon, appelé le tampon de primitives. Le tampon de primitives est nécessaire car il y a un déséquilibre entre géométrie et rastérisation. Un triangle dans une scène 3D correspond généralement à plusieurs pixels. La rastérisation prend donc 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. Le tampon de sommet permet d'accumuler les sommets calculés quand l'unité de rastérisation est occupé.

 
Carte graphique en rendu immédiat

Avec le rendu en tiles, la géométrie est intégralement rendue avant de faire la rastérisation et le résultat est mémorisé en mémoire vidéo. 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. L'unité géométrique mémorise la scène 3D en mémoire vidéo en tenant compte de la rasterisation, en regroupant les triangles 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 tout ce qui est nécessaire pour rendre une tile. 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

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 du 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. La géométrie est enregistrée 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, la SRAM qui mémorise la tile en cours suffit pour l'antialiasing. L'antialiasing est donc plus rapide.


Les cartes accélératrices 3D

Il est intéressant d'étudier le hardware des cartes graphiques en faisant un petit résumé de leur évolution dans le temps. En effet, leur hardware a fortement évolué dans le temps. Et il serait difficile à comprendre le hardware actuel sans parler du hardware d'antan. En effet, une carte graphique moderne est partiellement programmable. Certains circuits sont totalement programmables, d'autres non. Et pour comprendre pourquoi, il faut étudier comment ces circuits ont évolués.

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.

Le rendu 3D moderne est basé sur le placage de texture inverse, avec des coordonnées de texture, une correction de perspective, etc. Mais les anciennes consoles et bornes d'arcade utilisaient le placage de texture direct. Et cela a impacté le hardware des consoles/PCs de l'époque. Avec le placage de texture direct, il était primordial de calculer la géométrie, mais la rasterisation était le fait de VDC améliorés. Aussi, les premières bornes d'arcade 3D et les consoles de 5ème génération disposaient processeurs pour calculer la géométrie et de circuits d'application de textures très particuliers. A l'inverse, les PC utilisaient un rendu inverse, totalement différent. Sur les PC, les premières cartes graphiques avaient un circuit de rastérisation et des unités de textures, mais pas de circuits géométriques.

Les précurseurs : les cartes graphiques des bornes d'arcade

modifier
 
Sega ST-V Dynamite Deka PCB 20100324

L'accélération du rendu 3D sur les bornes d'arcade était déjà bien avancé dès les années 90. Les bornes d'arcade ont toujours été un segment haut de gamme de l'industrie du jeu vidéo, aussi ce n'est pas étonnant. Le prix d'une borne d'arcade dépassait facilement les 10 000 dollars pour les plus chères et une bonne partie du prix était celui du matériel informatique. Le matériel était donc très puissant et débordait de mémoire RAM comparé aux consoles de jeu et aux PC.

La plupart des bornes d'arcade utilisaient du matériel standardisé entre plusieurs bornes. A l'intérieur d'une borne d'arcade se trouve une carte de borne d'arcade qui est une carte mère avec un ou plusieurs processeurs, de la RAM, une carte graphique, un VDC et pas mal d'autres matériels. La carte est reliée aux périphériques de la borne : joysticks, écran, pédales, le dispositif pour insérer les pièces afin de payer, le système sonore, etc. Le jeu utilisé pour la borne est placé dans une cartouche qui est insérée dans un connecteur spécialisé.

Les cartes de bornes d'arcade étaient généralement assez complexes, elles avaient une grande taille et avaient plus de composants que les cartes mères de PC. Chaque carte contenait un grand nombre de chips pour la mémoire RAM et ROM, et il n'était pas rare d'avoir plusieurs processeurs sur une même carte. Et il n'était pas rare d'avoir trois à quatre cartes superposées dans une seule borne. Pour ceux qui veulent en savoir plus, Fabien Sanglard a publié gratuitement un livre sur le fonctionnement des cartes d'arcade CPS System, disponible via ce lien : The book of CP System.

Les premières cartes graphiques des bornes d'arcade étaient des cartes graphiques 2D auxquelles on avait ajouté quelques fonctionnalités. Les sprites pouvaient être tournés, agrandit/réduits, ou déformés pour simuler de la perspective et faire de la fausse 3D. Par la suite, le vrai rendu 3D est apparu sur les bornes d'arcade, avec des GPUs totalement programmables.

Dès 1988, la carte d'arcade Namco System 21 et Sega Model 1 géraient les calculs géométriques. Les deux cartes n'utilisaient pas de circuit géométrique fixe, mais l'émulaient avec un processeur programmé avec un programme informatique qui implémentait le T&L en logiciel. Elles utilisaient plusieurs DSP pour ce faire.

Quelques années plus tard, les cartes graphiques se sont mises à supporter un éclairage de Gouraud et du placage de texture. Le Namco System 22 et la Sega model 2 supportaient des textures 2D et comme le filtrage de texture (bilinéaire et trilinéaire), le mip-mapping, et quelques autres. Par la suite, elles ont réutilisé le hardware des PC et autres consoles de jeux.

La 3D sur les consoles de quatrième génération

modifier

Les consoles avant la quatrième génération de console étaient des consoles purement 2D, sans circuits d'accélération 3D. Leur carte graphique était un simple VDC 2D, plus ou moins performant selon la console. Pourtant, les consoles de quatrième génération ont connus quelques jeux en 3D. Par exemple, les jeux Star Fox sur SNES. Fait important, il s'agissait de vrais jeux en 3D qui tournaient sur des consoles qui ne géraient pas la 3D. La raison à cela est que la 3D était calculée par un GPU placé dans les cartouches du jeu !

Par exemple, les cartouches de Starfox et de Super Mario 2 contenait un coprocesseur Super FX, qui gérait des calculs de rendu 2D/3D. Un autre exemple est celui du co-processeur Cx4, cousin du Super FX, qui était spécialisé dans les calculs trigonométriques et diverses opérations utiles pour le rendu 2D/3D. En tout, il y a environ 16 coprocesseurs pour la SNES et on en trouve facilement la liste sur le net.

La console était conçue pour, des pins sur les ports cartouches étaient prévues pour des fonctionnalités de cartouche annexes, dont ces coprocesseurs. Ces pins connectaient le coprocesseur au bus des entrées-sorties. Les coprocesseurs des cartouches de NES avaient souvent de la mémoire rien que pour eux, qui était intégrée dans la cartouche.

L'arrivée des consoles de cinquième génération

modifier

Par la suite, les consoles de jeu se sont mises à intégrer des cartes graphiques 3D. Les premières consoles de jeu capables de rendu 3D sont les consoles dites de 5ème génération. Il y a diverses manières de classer les consoles en générations, la plus commune place la 3D à la 5ème génération, mais détailler ces controverses quant à ce classement nous amènerait trop loin. Les cartes graphiques des consoles de jeu utilisaient le rendu inverse, avec quelques exceptions qui utilisaient le rendu inverse.

La Nintendo 64 : un GPU avancé

modifier

La Nintendo 64 avait le GPU le plus complexe comparé aux autres consoles, et dépassait même les cartes graphiques des PC. Son GPU était très novateur pour une console sortie en 1996. Il incorporait une unité pour les calculs géométriques, un circuit pour la rasterisation, une unité pour les textures et un ROP final pour les calculs de transparence/brouillard/anti-aliasing, ainsi qu'un circuit pour gérer la profondeur des pixels. En somme, tout le pipeline graphique était implémenté dans le GPU de la Nintendo 64, chose très en avance sur son temps, même comparé au PC !

Le GPU est construit autour d'un processeur dédié aux calculs géométriques, le Reality Signal Processor (RSP), autour duquel on a ajouté des circuits pour le reste du pipeline graphique. L'unité de calcul géométrique est un processeur MIPS R4000, un processeur assez courant à l'époque, mais auquel on avait retiré quelques fonctionnalités inutiles pour le rendu 3D. Il était couplé à 4 KB de mémoire vidéo, ainsi qu'à 4 KB de mémoire ROM. Le reste du GPU était réalisé avec des circuits fixes. La Nintendo 64 utilisait déjà un mélange de circuits programmables et fixes.

Un point intéressant est que le programme exécuté par le RSP pouvait être programmé ! Le RSP gérait déjà des espèces de proto-shaders, qui étaient appelés des micro-codes dans la documentation de l'époque. La ROM associée au RSP mémorise cinq à sept programmes différents, des microcodes de base, aux fonctionnalités différentes. Ils géraient le rendu 3D de manière différente et avec une gestion des ressources différentes. Très peu de studios de jeu vidéo ont développé leur propre microcodes N64, car la documentation était mal faite, que Nintendo ne fournissait pas de support officiel pour cela, que les outils de développement ne permettaient pas de faire cela proprement et efficacement.

La Playstation 1

modifier

Sur la Playstation 1 le calcul de la géométrie était réalisé par le processeur, la carte graphique gérait tout le reste. Et la carte graphique était un circuit fixe spécialisé dans la rasterisation et le placage de textures. Elle utilisait, comme la Nintendo 64, le placage de texture inverse, qui est apparu ensuite sur les cartes graphiques.

La 3DO et la Sega Saturn

modifier

La Sega Saturn et la 3DO étaient les deux seules consoles à utiliser le rendu direct. La géométrie était calculée sur le processeur, même si les consoles utilisaient parfois un CPU dédié au calcul de la géométrie. Le reste du pipeline était géré par un VDC 2D qui implémentait le placage de textures.

La Sega Saturn incorpore trois processeurs et deux GPU. Les deux GPUs sont nommés le VDP1 et le VDP2. Le VDP1 s'occupe des textures et des sprites, le VDP2 s'occupe uniquement de l'arrière-plan et incorpore un VDC tout ce qu'il y a de plus simple. Ils ne gèrent pas du tout la géométrie, qui est calculée par les trois processeurs.

Le troisième processeur, la Saturn Control Unit, est un processeur de type DSP, à savoir un processeur spécialisé dans le traitement de signal. Il est utilisé presque exclusivement pour accélérer les calculs géométriques. Il avait sa propre mémoire RAM dédiée, 32 KB de SRAM, soit une mémoire locale très rapide. Les transferts entre cette RAM et le reste de l'ordinateur était géré par un contrôleur DMA intégré dans le DSP. En somme, il s'agit d'une sorte de processeur spécialisé dans la géométrie, une sorte d'unité géométrique programmable. Mais la géométrie n'était pas forcément calculée que sur ce DSP, mais pouvait être prise en charge par les 3 CPU.

L'historique des cartes graphiques des PC, avant l'arrivée de Direct X et Open Gl

modifier

Sur PC, l'évolution des cartes graphiques a eu du retard par rapport aux consoles. Les PC sont en effet des machines multi-usage, pour lesquelles le jeu vidéo était un cas d'utilisation parmi tant d'autres. Et les consoles étaient la plateforme principale pour jouer à des jeux vidéo, le jeu vidéo PC étant plus marginal. Mais cela ne veut pas dire que le jeu PC n'existait pas, loin de là !

Un problème pour les jeux PC était que l'écosystème des PC était aussi fragmenté en plusieurs machines différentes : machines Apple 1 et 2, ordinateurs Commdore et Amiga, IBM PC et dérivés, etc. Aussi, programmer des jeux PC n'était pas mince affaire, car les problèmes de compatibilité étaient légion. C'est seulement quand la plateforme x86 des IBM PC s'est démocratisée que l'informatique grand public s'est standardisée, réduisant fortement les problèmes de compatibilité. Mais cela n'a pas suffit, il a aussi fallu que les API 3D naissent.

Les API 3D comme Direct X et Open GL sont absolument cruciales pour garantir la compatibilité entre plusieurs ordinateurs aux cartes graphiques différentes. Aussi, l'évolution des cartes graphiques pour PC s'est faite main dans la main avec l'évolution des API 3D. Il a fallu que Direct X et Open GL progressent suffisamment pour que les problèmes de compatibilité soient partiellement résolus. Les fonctionnalités des cartes graphiques ont évolué dans le temps, en suivant les évolutions des API 3D. Du moins dans les grandes lignes, car il est arrivé plusieurs fois que des fonctionnalités naissent sur les cartes graphiques, pour que les fabricants forcent la main de Microsoft ou d'Open GL pour les intégrer de force dans les API 3D. Passons.

L'introduction des premiers jeux 3D : Quake et les drivers miniGL

modifier

L'histoire de la 3D sur PC commence avec 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 les premières cartes graphiques étaient déjà dans les starting blocks.

La toute première carte 3D pour PC est la Rendition Vérité V1000, sortie en Septembre 1995, soit quelques mois avant l'arrivée de la Nintendo 64. La Rendition Vérité V1000 était purement programmable, contrairement aux autres cartes graphiques de l'époque. 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 à Rendition d'implémenter n'importe quelle API 3D, que ce soit OpenGL, DirectX ou même sa son API propriétaire.

La Rendition Vérité avait de bonnes performances pour ce qui est de la géométrie, mais pas pour le reste. Autant les calculs géométriques sont assez rapides quand on les exécute sur un CPU, autant 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. 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. Les autres cartes graphiques avaient implémenté l'exact inverse : de bonnes performances pour le placage de textures et la rastérization, mais les calculs géométriques étaient réalisés par le CPU. Au final, la carte graphique qui s'en sortait le mieux était la Nintendo 64 qui avait un CPU dédié pour les calculs géométriques et des circuits fixes pour le reste...

Les autres cartes graphiques étaient totalement non-programmables et ne contenant que des circuits fixes, 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, mais aussi une étape d'enregistrement des pixels en mémoire. Elle était gérait le z-buffer en mémoire vidéo, mais aussi quelques effets graphiques comme les effets de brouillard. L'unité d'enregistrement des pixels en mémoire s'appelle le ROP pour Raster Operation Pipeline.

 
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 la rasterisation en hardware.

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

Pour exploiter les unités de texture et le circuit de rastérisation, 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. 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.

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.

Direct X était une API dans l'ombre d'Open GL. La première version de Direct X qui supportait la 3D était DirectX 2.0 (juin 2, 1996), suivie rapidement par DirectX 3.0 (septembre 1996). Elles dataient d'avant le jeu Quake, et elles étaient très éloignées du hardware des premières cartes graphiques. Elles utilisaient un système d'execute buffer pour communiquer avec la carte graphique, Microsoft espérait que le matériel 3D implémenterait ce genre de système. Ce qui ne fu pas le cas.

Direct X 4.0 a été abandonné en cours de développement pour laisser à une version 5.0 assez semblable à la 2.0/3.0. Le mode de rendu laissait de côté les execute buffer pour coller un peu plus au hardware de l'époque. Mais rien de vraiment probant comparé à Open GL. Même Windows utilisait Open GL au lieu de Direct X maison... C'est avec Direct X 6.0 que Direct X est entré dans la cours des grands. Il gérait la plupart des technologies supportées par les cartes graphiques de l'époque.

Le multi-texturing de l'époque Direct X 6.0 : combiner plusieurs textures

modifier

Une technologie très importante standardisée par Dirext X 6 est la technique du multi-texturing. Avec ce qu'on a dit dans le chapitre précédent, vous pensez sans doute qu'il n'y a qu'une seule texture par objet, qui est plaquée sur sa surface. Mais divers effet graphiques demandent d'ajouter des textures par dessus d'autres textures. En général, elles servent pour ajouter des détails, du relief, sur une surface pré-existante.

Un exemple intéressant vient des jeux de tir : ajouter des impacts de balles sur les murs. Pour cela, on plaque une texture d'impact de balle sur le mur, à la position du tir. Il s'agit là d'un exemple de decals, des petites textures ajoutées sur les murs ou le sol, afin de simuler de la poussière, des impacts de balle, des craquelures, des fissures, des trous, etc. Les textures en question sont de petite taille et se superposent à une texture existante, plus grande. Rendre des decals demande de pouvoir superposer deux textures.

Direct X 6.0 supportait l'application de plusieurs textures directement dans le matériel. La carte graphique devait être capable d'accéder à deux textures en même temps, ou du moins faire semblant que. Pour cela, elle doublaient les unités de texture et adaptaient les connexions entre unités de texture et mémoire vidéo. La mémoire vidéo devait être capable de gérer plusieurs accès mémoire en même temps et devait alors avoir un débit binaire élevé.

 
Multitexturing

La carte graphique devait aussi gérer de quoi combiner deux textures entre elles. Par exemple, pour revenir sur l'exemple d'une texture d'impact de balle, il faut que la texture d'impact recouvre totalement la texture du mur. Dans ce cas, la combinaison est simple : la première texture remplace l'ancienne, là où elle est appliquée. Mais les cartes graphiques ont ajouté d'autres combinaisons possibles, par exemple additionner les deux textures entre elle, faire une moyenne des texels, etc.

Les opérations pour combiner les textures était le fait de circuits appelés des combiners. Concrètement, les combiners sont de simples unités de calcul. Les conbiners ont beaucoup évolués dans le temps, mais les premières implémentation se limitaient à quelques opérations simples : addition, multiplication, superposition, interpolation. L'opération effectuer était envoyée au conbiner sur une entrée dédiée.

 
Multitexturing avec combiners

S'il y avait eu un seul conbiner, le circuit de multitexturing aurait été simplement configurable. Mais dans la réalité, les premières cartes utilisant du multi-texturing utilisaient plusieurs combiners placés les uns à la suite des autres. L'implémentation des combiners retenue par Open Gl, et par le hardware des cartes graphiques, était la suivante. Les combiners étaient placés en série, l'un à la suite de l'autre, chacun combinant le résultat de l'étage précédent avec une texture. Le premier combiner gérait l'éclairage par sommet, afin de conserver un minimum de rétrocompatibilité.

 
Texture combiners Open GL

Voici les opérations supportées par les combiners d'Open GL. Ils prennent en entrée le résultat de l'étage précédent et le combinent avec une texture lue depuis l'unité de texture.

Opérations supportées par les combiners d'Open GL
Replace Pixel provenant de l'unité de texture
Addition Additionne l'entrée au texel lu.
Modulate Multiplie l'entrée avec le texel lu
Mélange (blending) Moyenne pondérée des deux entrées, pondérée par la composante de transparence La couleur de transparence du texel lu et de l'entrée sont multipliées.
Decals Moyenne pondérée des deux entrées, pondérée par la composante de transparence. La transparence du résultat est celle de l'entrée.

Il faut noter qu'un dernier étage de combiners s'occupait d'ajouter la couleur spéculaire et les effets de brouillards. Il était à part des autres et n'était pas configurable, c'était un étage fixe, qui était toujours présent, peu importe le nombre de textures utilisé. Il était parfois appelé le combiner final, terme que nous réutiliserons par la suite.

Mine de rien, cela a rendu les cartes graphiques partiellement programmables. Le fait qu'il y ait des opérations enchainées à la suite, opérations qu'on peut choisir librement, suffit à créer une sorte de mini-programme qui décide comment mélanger plusieurs textures. Mais il y avait une limitation de taille : le fait que les données soient transmises d'un étage à l'autre, sans détours possibles. Par exemple, le troisième étage ne pouvait avoir comme seule opérande le résultat du second étage, mais ne pouvait pas utiliser celui du premier étage. Il n'y avait pas de registres pour stocker ce qui sortait de la rastérisation, ni pour mémoriser temporairement les texels lus.

Le Transform & Lighting matériel de Direct X 7.0

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

La première carte graphique pour PC capable de gérer la géométrie en hardware 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 unité T&L (Transform And Lighting). Elle implémentait des algorithmes d'éclairage de la scène 3D assez simples, comme un éclairage de Gouraud, qui étaient directement câblés dans ses circuits. Mais contrairement à la Nintendo 64 et aux bornes d'arcade, elle implémentait le tout, non pas avec un processeur classique, mais avec des circuits fixes.

Avec Direct X 7.0 et Open GL 1.0, l'éclairage était en théorie limité à de l'éclairage par sommet, l'éclairage par pixel n'était pas implémentable en hardware. Les cartes graphiques ont tenté d'implémenter l'éclairage par pixel, mais cela n'est pas allé au-delà du support de quelques techniques de bump-mapping très limitées. Par exemple, Direct X 6.0 implémentait une forme limitée de bump-mapping, guère plus.

Un autre problème est que l'éclairage peut s'implémenter de plusieurs manières différentes, aux résultats visuels différents. Les unités de T&L étaient souvent en retard sur les algorithmes logiciels. Les programmeurs avaient le choix entre programmer les algorithmes d’éclairage qu'ils voulaient et les exécuter en logiciel ou utiliser ceux de l'unité de T&L, et choisissaient souvent la première option. Par exemple, Quake 3 Arena et Unreal Tournament n'utilisaient pas les capacités d'éclairage géométrique et préféraient utiliser leurs calculs d'éclairage logiciel fait maison.

Cependant, le hardware dépassait les capacités des API et avait déjà commencé à ajouter des capacités de programmation liées au multi-texturing. Les cartes graphiques de l'époque, surtout chez NVIDIA, implémentaient un système de register combiners, une forme améliorée de texture combiners, qui permettait de faire une forme limitée d'éclairage par pixel, notamment du vrai bump-mampping, voire du normal-mapping. Mais ce n'était pas totalement supporté par les API 3D de l'époque.

Les registers combiners sont des texture combiners mais dans lesquels ont aurait retiré la stricte organisation en série. Il y a toujours plusieurs étages à la suite, qui peuvent exécuter chacun une opération, mais tous les étages ont maintenant accès à toutes les textures lues et à tout ce qui sort de la rastérisation, pas seulement au résultat de l'étape précédente. Pour cela, on ajoute des registres pour mémoriser ce qui sort des unités de texture, et pour ce qui sort de la rastérisation. De plus, on ajoute des registres temporaires pour mémoriser les résultats de chaque combiner, de chaque étage.

Il faut cependant signaler qu'il existe un combiner final, séparé des étages qui effectuent des opérations proprement dits. Il s'agit de l'étage qui applique la couleur spéculaire et les effets de brouillards. Il ne peut être utilisé qu'à la toute fin du traitement, en tant que dernier étage, on ne peut pas mettre d'opérations après lui. Sa sortie est directement connectée aux ROPs, pas à des registres. Il faut donc faire la distinction entre les combiners généraux qui effectuent une opération et mémorisent le résultat dans des registres, et le combiner final qui envoie le résultat aux ROPs.

L'implémentation des register combiners utilisait un processeur spécialisés dans les traitements sur des pixels, une sorte de proto-processeur de shader. Le processeur supportait des opérations assez complexes : multiplication, produit scalaire, additions. Il s'agissait d'un processeur de type VLIW, qui sera décrit dans quelques chapitres. Mais ce processeur avait des programmes très courts. Les premières cartes NVIDIA, comme les cartes TNT pouvaient exécuter deux opérations à la suite, suivie par l'application de la couleurs spéculaire et du brouillard. En somme, elles étaient limitées à un shader à deux/trois opérations, mais c'était un début. Le nombre d'opérations consécutives est rapidement passé à 8 sur la Geforce 3.

L'arrivée des shaders avec Direct X 8.0

modifier
 
Architecture de la Geforce 3

Les register combiners était un premier pas vers un éclairage programmable. Paradoxalement, l'évolution suivante s'est faite non pas dans l'unité de rastérisation/texture, mais dans l'unité de traitement de la géométrie. La Geforce 3 a remplacé l'unité de T&L par un processeur capable d'exécuter des programmes. Les programmes en question complétaient l'unité de T&L, afin de pouvoir rajouter des techniques d'éclairage plus complexes. Le tout a permis aussi d'ajouter des animations, des effets de fourrures, des ombres par shadow volume, des systèmes de particule évolués, et bien d'autres.

À partir de la Geforce 3 de Nvidia, les cartes graphiques sont devenues capables d'exécuter des programmes appelés shaders. Le terme shader vient de shading : ombrage en anglais. Grace aux shaders, l'éclairage est devenu programmable, il n'est plus géré par des unités d'éclairage fixes mais été laissé à la créativité des programmeurs. Les programmeurs ne sont plus vraiment limités par les algorithmes d'éclairage implémentés dans les cartes graphiques, mais peuvent implémenter les algorithmes d'éclairage qu'ils veulent et peuvent le faire exécuter directement sur la carte graphique.

Les shaders sont classifiés suivant les données qu'ils manipulent : pixel shader pour ceux qui manipulent des pixels, vertex shaders pour ceux qui manipulent des sommets. Les premiers sont utilisés pour implémenter l'éclairage par pixel, les autres pour gérer tout ce qui a trait à la géométrie, pas seulement l'éclairage par sommets.

Direct X 8.0 avait un standard pour les shaders, appelé shaders 1.0, qui correspondait parfaitement à ce dont était capable la Geforce 3. Il standardisait les vertex shaders de la Geforce 3, mais il a aussi renommé les register combiners comme étant des pixel shaders version 1.0. Les register combiners n'ont pas évolués depuis la Geforce 256, si ce n'est que les programmes sont passés de deux opérations successives à 8, et qu'il y avait possibilité de lire 4 textures en multitexturing. A l'opposé, le processeur de vertex shader de la Geforce 3 était capable d'exécuter des programmes de 128 opérations consécutives et avait 258 registres différents !

Des pixels shaders plus évolués sont arrivés avec l'ATI Radeon 8500 et ses dérivés. Elle incorporait la technologie SMARTSHADER qui remplacait les registers combiners par un processeur de shader un peu limité. Un point est que le processeur acceptait de calculer des adresses de texture dans le pixel shader. Avant, les adresses des texels à lire étaient fournis par l'unité de rastérisation et basta. L'avantage est que certains effets graphiques étaient devenus possibles : du bump-mapping avancé, des textures procédurales, de l'éclairage par pixel anisotrope, du éclairage de Phong réel, etc.

Avec la Radeon 8500, le pixel shader pouvait calculer des adresses, et lire les texels associés à ces adresses calculées. Les pixel shaders pouvaient lire 6 textures, faire 8 opérations sur les texels lus, puis lire 6 textures avec les adresses calculées à l'étape précédente, et refaire 8 opérations. Quelque chose de limité, donc, mais déjà plus pratique. Les pixel shaders de ce type ont été standardisé dans Direct X 8.1, sous le nom de pixel shaders 1.4. Encore une fois, le hardware a forcé l'intégration dans une API 3D.

Les shaders de Direct X 9.0 : de vrais pixel shaders

modifier

Avec Direct X 9.0, les shaders sont devenus de vrais programmes, sans les limitations des shaders précédents. Les pixels shaders sont passés à la version 2.0, idem pour les vertex shaders. Concrètement, ils sont maintenant exécutés par un processeur de shader dédié, aux fonctionnalités bien supérieures à celles des registers combiners. Les shaders pouvaient exécuter une suite d'opérations arbitraire, dans le sens où elle n'était pas structurée avec tel type d'opération au début, suivie par un accès aux textures, etc. On pouvait mettre n'importe quelle opération dans n'importe quel ordre.

De plus, les shaders ne sont plus écrit en assembleur comme c'était le cas avant. Ils sont dorénavant écrits dans un langage de haut-niveau, le HLSL pour les shaders Direct X et le GLSL pour les shaders Open Gl. Les shaders 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.

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

L'après Direct X 9.0

modifier

Avant Direct X 10, les processeurs de shaders ne géraient pas exactement les mêmes opérations pour les processeurs de vertex shader et de pixel shader. Les processeurs de vertex shader et de pixel shaderétaient séparés. Depuis DirectX 10, ce n'est plus le cas : le jeu d'instructions a été unifié entre les vertex shaders et les pixels shaders, ce qui fait qu'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.

 
Architecture de la GeForce 6800.

Avec Direct X 10, de nombreux autres shaders sont apparus. Les plus liés au rendu 3D sont les geometry shader pour ceux qui manipulent des triangles, de hull shaders et de domain shaders pour la tesselation. De plus, les cartes graphiques modernes sont capables d’exécuter des programmes informatiques qui n'ont aucun lien avec le rendu 3D, mais sont exécutés par la carte graphique comme le ferait un processeur d'ordinateur normal. De tels shaders sans lien avec le rendu 3D sont appelés des compute shader.

Les cartes graphiques d'aujourd'hui

modifier

Avec l'arrivée des shaders, les circuits d'une carte graphique sont divisés en deux catégories : d'un côté les circuits non-programmables et de l'autre les circuits programmables. Pour exécuter les shaders, la carte graphique incorpore des processeurs de shaders, des processeurs similaires aux processeurs des ordinateurs, aux CPU, mais avec quelques petites différences qu'on expliquera dans le prochain chapitre. A côté des processeurs de shaders, il reste quelques circuits non(programmables appelés des circuits fixes. De nos jours, la gestion de la géométrie et des pixels est programmable, mais la rastérisation, le placage de texture, le culling et l'enregistrement du framebuffer ne l'est pas. Il n'en a pas toujours été ainsi.

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

Une carte graphique contient donc un mélange de circuits fixes et de processeurs de shaders, qui peut sembler contradictoire. Pourquoi ne pas tout rendre programmable ? Ou au contraire, utiliser seulement des circuits fixes ? 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. Mais ce compromis a fortement évolué dans le temps, comme on va le voir plus bas.

Rendre la gestion de la géométrie ou des pixels programmable permet d'implémenter facilement un grand nombre d'effets graphiques sans avoir à les implémenter en hardware. Avant les shaders, seul le hardware récent gérait les dernières fonctionnalités. Les effets graphiques derniers cri n'étaient disponibles que sur les derniers modèles de carte graphique. Avec des vertex/pixel shaders, ce genre de défaut est passé à la trappe. Si un nouvel algorithme de rendu graphique est inventé, il peut être utilisé dès le lendemain sur toutes les cartes graphiques modernes. De plus, implémenter beaucoup d'algorithmes d'éclairage différents est difficile. Le cout en termes de transistors et de complexité était assez important, utiliser des circuits programmable a un cout en hardware plus limité.

Tout cela est à l'exact opposé de ce qu'on a avec les autres circuits, comme les circuits pour la rastérisation ou le placage de texture. 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. 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.


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. Un point très important à comprendre est que chaque triangle ou pixel d'une scène 3D peut être traité indépendamment des autres. Le tout se résume comme suit :

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.

En conséquence, il est possible de traiter chaque instance d'un shader en parallèle des autres, en même temps, au lieu de traiter les instances l'une après l'autre.

La conséquence est que les cartes graphiques sont des architectures massivement parallèles, à savoir qu'elles sont capables d'effectuer un grand nombre de calculs indépendants en même temps. 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. Les cartes graphiques récentes incorporent toutes les techniques de parallélisme de donnée au niveau matériel, et nous allons toutes les détailler dans ce chapitre. S'il fallait résumer, elles ont plusieurs processeurs/cœurs, chaque cœur est capable d’exécuter des instructions SIMD (ils ne font que cela, à vrai dire), les cœurs sont fortement multithreadés, et j'en passe.

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

Le premier point est qu'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.

L'architecture d'une carte graphique récente est illustrée ci-dessous. Rien de bien déroutant pour qui a déjà étudié les architectures à parallélisme de données, mais quelques rappels ou explications ne peuvent pas faire de mal. Le premier point est la présence d'un grand nombre de processeurs/cœurs, les rectangles en bleu/rouge. Chacun d'entre eux contient un grand nombre de circuits de calculs, avec des circuits de calcul simples mais nombreux en rouge, et une unité pour les calculs complexes (trigonométriques, racines carrées, autres) en rouge. Le tout est relié à une hiérarchie mémoire indiquée en vert, comprenant des mémoires locales en complément de la mémoire vidéo principale. Le tout est alimenté par une unité de répartition, le Thread Execution Control Unit en jaune, qui répartit les différentes instances du shader sur les différents processeurs. Elle est aussi appelée le processeur de commandes, comme nous le verrons dans quelques chapitres. Nous utiliserons le terme processeur de commande dans ce qui suit.

 
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.

Les portions bleu, jaune et verte du schéma précédent méritent chacune un chapitre séparé. La hiérarchie mémoire en vert fera l'objet d'un chapitre ultérieur. Quant au répartiteur en jaune, il sera détaillé en profondeur dans le prochain chapitre. Dans ce chapitre, nous allons voir comment fonctionnent les processeurs de shaders, la partie bleue. 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.

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 plus intuitifs sont les registres généraux, aussi appelés registres temporaires, qui servent à mémoriser des résultats temporaires. Les registres temporaires sont les registres du processeur proprement dit, ceux qu'il peut manipuler à loisir. Tout processeur digne de ce nom en possède. Mais un processeur de shader dispose aussi de registres spécialisés, qu'on ne trouve que sur les processeurs de shaders, qui servent à l'interfacer avec le reste du pipeline graphique.

Les registres d'interface avec le pipeline graphique

modifier

Un processeur de shader reçoit des données provenant de l'unité de rastérisation, et envoie son résultat final aux ROPs. Il y a donc des registres d'entrée et de sortie spécialisés pour faire l'interface entre les deux. Ils servent d'interface avec le reste du pipeline graphique, notamment le rastérizeur et les ROPs, mais aussi avec les unités de texture.

 
Architecture carte graphique vertex avec texture

Les registres d'entrée réceptionnent les vertices/pixels provenant de l'unité de rastérisation. Les registres d'entrée sont en lecture seule, du point de vue du processeur de shader, seule l'unité de rastérisation peut écrire dedans. Ils sont initialisés avant l'exécution du shader.

Les registres de sortie sont là où le processeur stocke les résultats à envoyer aux ROP. Les registres de sorties sont en écriture seule. Avant l'apparition des shaders unifiés de DIrect X 10, les registres de sortie étaient différents entre les vertex shaders et les pixel shaders. Les pixel shaders n'avaient que deux registres de sorties : un pour la couleur à envoyer aux ROP, un autre pour la profondeur du pixel. Les vertex shaders avaient eu beaucoup plus de registres de sorties, vu que l'unité de rastérisation avait besoin de beaucoup d'information. Il y avait au minimum un registre pour la position du sommet dans l'espace (trois coordonnées), un autre pour la couleur/luminosité du sommet, un autre pour la couleur du brouillard, un autre pour les coordonnées de texture.

Registres de sortie des pixel/vertex shaders
Vertex shader Pixel shader
Couleur du pixel Couleur du sommet
Profondeur du pixel Position du sommet
Coordonnées de texture du sommet
Couleur de brouillard.

Il y a aussi des registres de texture , qui servent d'interface avec la mémoire pour la gestion des textures. Ils mémorisent les texels lus par l'unité de texture. L'unité de texture lit un texel, plusieurs avec multitexturing, et les place dans ces registres de texture. Les registres de texture sont parfois initialisés avant l'exécution du shader, mais la plupart sont initialisé quand le shader termine une instruction de lecture de texture. Ils sont généralement en lecture seule, mais il y a des exceptions.

Les registres de constante et d'adresse de constantes

modifier

Les registres de constantes servent pour stocker des constantes utiles pour le shader. Par exemple, pour les vertex shaders, ils stockent les matrices servant aux différentes étapes de transformation ou d'éclairage. Ces constantes sont placées dans ces registres peu après le chargement du vertex shader dans la mémoire vidéo. Toutefois, le vertex shader peut écrire dans ces registres, au prix d'une perte de performance particulièrement violente.

Les pixel/vertex shaders 1.0 ne géraient que des constantes flottantes pour les vertex shaders, entières pour les pixel shaders. Mais les pixel/vertex shaders 2.0 et 3.0 avaient des registres de constantes séparés pour les nombres entiers, les nombres flottants, et même les nombres booléens. Les constantes entières et booléennes étaient utilisées pour gérer les boucles, guère plus. Aussi, il y en avait 16, comparé aux centaines de registres de constantes flottants. Mais avec les pixel/vertex shaders 4.0 et plus, les registres de constante ont été fusionnés et n'ont plus de type prédéterminé, le programmeur gère ces registres comme il l'entend.

L'adressage des registres de constante est quelque peu particulier. Il faut dire qu'il y en a plusieurs milliers sur les processeurs de shaders modernes, au point qu'il serait plus juste de parler de mémoire RAM des constantes. Les registres de constante sont en effet un local store un peu spécial, intégré directement dans le processeur. Et le processeur accède à ce local store en utilisant une mode d'adressage semblable à celui utilisé pour la mémoire, avec un mode d'adressage indirect. L'adresse à lire dans ce local store est dans un registre, séparé du reste, appelé le registre d'adresse de constante.

Les registres de contrôle de flot

modifier

Depuis les pixel/vertex shaders 3.0, les shaders sont capables d'effectuer des boucles et d'autres structures de contrôle familières pour les programmeurs. Et deux registres ont été intégrés afin d'améliorer les performances des structures de contrôle. Le premier est un registre à prédicat, qui sera vu dans la section sur le SIMD avec prédication. Le second est un registre compteur de boucle, qui mémorise l'indice d'une boucle. Il est initialisé à 0, et est incrémenté à chaque fois qu'une boucle s'exécute.

Les processeurs de shaders modernes : les processeurs SIMD

modifier

Le jeu d'instruction des GPU NVIDIA n'est pas encore connu à l'heure où j'écris ces lignes, la documentation du constructeur n'est pas disponible. Quelques chercheurs ont tenté de faire de la rétro-ingénierie du code de divers shaders pour retrouver le jeu d'instruction des divers GPU NVIDIA, ce qui fait qu'on a cependant une idée de ce dernier. Mais rien d'officiel. Par contre, AMD fournit librement cette documentation sur le net. Ce qui fait qu'on peut trouver des documents de ce genre :

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. Dans cette section, nous allons voir les processeurs SIMD.

Avant d'expliquer à quoi correspondent ces deux termes, sachez juste que l'usage de processeurs VLIW dans les cartes graphiques n'est plus très courant de nos jours. Il a existé des cartes graphiques assez anciennes qui utilisaient des processeurs de type VLIW, mais ce n'est plus en odeur de sainteté de nos jours. De nos jours, les processeurs de shaders sont tous des processeurs SIMD ou des dérivés (la technique dite du SIMT est une sorte de SIMD amélioré). Cependant, il arrive que même en étant des processeurs SIMD, certaines de leurs instructions soient inspirées des instructions VLIW.

Les instructions SIMD

modifier

Les instructions 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, qui sont stockés dans des registres spécialisés. En général, tous les vecteurs ont une taille fixe, peu importe leur contenu. Cela implique que suivant la taille des données à manipuler, on pourra en placer plus ou moins dans un vecteur. Par exemple, un vecteur de 128 bits pourra contenir 4 entiers de 32 bits, 4 flottants 32 bits, ou 8 entiers de 16 bits.

 
Contenu d'un vecteur en fonction du type de données utilisé.

Les vecteurs sont stockés dans des registres vectoriels, aussi appelés registres SIMD. Un registre vectoriel peut contenir un vecteur complet, pas plus. En conséquence, ils ont une taille assez importante : ils font généralement 128, 256, voire 512 bits, comparé aux 32/64 bits des registres des CPU. Les cartes graphiques modernes contiennent un très grand nombre de registres SIMD.

Comparaison entre un processeur sans registres vectoriels, et avec registres vectoriels.
 
CPU Non-SIMD
 
CPU SIMD

Une instruction SIMD traite chaque donnée 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.

 
Instructions SIMD

Sur les cartes graphiques modernes, les vecteurs sont généralement des vecteurs qui regroupent plusieurs nombres flottants. De plus, les flottants en question sont des flottants dits simple précision, codés sur 32 bits. Mais il y a quelques exceptions, comme certains GPU d'Apple, qui ne gèrent majoritairement que des flottants codés sur 16 bits, avec des fonctionnalités pour la simple précision. Les anciennes cartes graphiques ne géraient pas du tout de vecteurs contenant des nombres entiers.

Les instruction scalaires entières, typiques des CPU

modifier

Un processeur SIMD gère donc des instructions SIMD, et les anciennes cartes graphiques ne disposaient que d'instructions de ce type. Mais depuis au moins une décennie, les processeurs de shaders gèrent des instructions normales, non-SIMD. De telles instructions sont appelées des instruction scalaires. En clair, il s'agit des instructions qu'on retrouve normalement tous les processeurs principaux (les CPU).

Il s'agit généralement d'instructions entières, agissent sur des registres entiers non-SIMD. Elles ne traitent pas de vecteur, mais de simples nombres entiers indépendants, sans regroupement d'aucune sorte. Typiquement, il s'agit d'opérations d'addition, de soustraction, des opérations logiques, des comparaisons, guère plus. On trouve aussi des opérations un peu originales, comme des calculs de valeur absolue, du minimum/maximum de deux opérandes, des opérations à prédicat comme une instruction CMOV, etc. Les cartes graphiques supportent rarement la multiplication, mais les plus récentes supportent des multiplications sur des opérandes de 16/32 bits. Par contre, aucune ne gère de division entière.

Les GPU modernes gèrent aussi des instructions de test et de branchement, là encore sur des nombres entiers. Les instructions de test et branchement sont généralement considérées comme à part des instructions de calcul, mais ce sont des opérations scalaires. Les comparaisons se font entre deux entiers scalaires, pas entre deux vecteurs. Retenez bien ce détail, car il sera très important pour la suite.

Les GPU modernes gèrent aussi des instructions flottantes scalaires, à savoir que des instructions qui ont pour opérandes des nombres flottants isolés, qui ne sont pas dans un vecteur. Les processeurs principaux (CPU) d'un ordinateur sont capables de faire beaucoup de calculs arithmétiques simples sur des nombres flottants, comme des additions, des multiplications, des opérations bit-à-bit, éventuellement des divisions, etc. Il en est de même sur les GPUS. Mais ces derniers gèrent aussi de nombreuses instructions flottantes que les CPU n'incorporent presque pas.

Il est rare que les CPU soient capables de faire des opérations flottantes 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, alors que les calculs arithmétiques simples y sont légion. Mais le rendu 3D demande pas mal de calculs trigonométriques, de produits scalaires ou d'autres opérations. Par exemple, dans les chapitres précédents, nous avions abordé les calculs d'éclairage et avions vu qu'ils font beaucoup de calculs vectoriels avec des vecteurs comme la normale d'un sommet. Et ces calculs demandent de calculer des produits scalaires et vectoriels, qui eux-mêmes demandent des calculs trigonométriques comme le cosinus ou le sinus.

Aussi, les processeurs de shaders disposent souvent d'instructions flottantes spécialisées dans les calculs complexes : exponentielle/logarithme, racine carrée, racine carrée inverse, autres. Nous appellerons ces instructions des instructions transcendantales, car elles effectuent des calculs de ce type.

Il faut noter que le processeur incorpore des registres dédiés aux scalaires, séparés des registres SIMD. Par séparés, on veut dire que ce sont des registres différents, adressés différemment, mais qu'ils sont aussi physiquement séparés dans le processeur, ils sont des bancs de registres différents.

Les instructions en co-issue

modifier

Beaucoup de cartes graphiques récentes comme anciennes incorporent des instructions de co-issue qui ne se trouvent que sur les cartes graphiques et n'ont aucun équivalent sur les CPUs. Les instructions de co-issue regroupent plusieurs opérations par instruction. Par exemple, elles peuvent combiner une opération vectorielle avec une opération scalaire. Ou encore, elles peuvent regrouper une opération scalaire, une opération vectorielle et un branchement. Il s'agit d'instructions qui ressemblent grandement à ce qu'on trouve sur les processeurs VLIW.

Un point important est que les cartes graphiques modernes disposent d'instructions à co-issue en plus des instructions normales. Les instructions à co-issue sont complémentaire des instructions normales, elles ne les remplacent pas. Les deux peuvent s'utiliser en même temps, dans un même shader. Il a cependant existé des cartes graphiques assez anciennes sur lesquelles toutes les instructions étaient des instructions à co-issue : certains processeurs de shaders VLIW anciens sont de ce type.

Il y a de nombreuses contraintes quant au regroupement des deux opérations. On ne peut pas regrouper n'importe quelle opération avec n'importe quelle autre. L'exemple type de co-issue est la co-issue entre opérations scalaires et vectorielles : il n'est pas possible de regrouper deux instructions scalaires ou deux instructions vectorielles. La seule possibilité est de regrouper une opération scalaire et une opération vectorielle. La raison à cela est qu'opérations scalaires et vectorielles sont calculées dans des circuits séparés : le processeur incorpore une unité de calcul scalaire et une unité de calcul SIMD, et peut utiliser les deux en parallèle, en même temps. Mais nous verrons cela dans quelques chapitres.

Pour simplifier, cette technique permettait d’exécuter deux opérations arithmétiques en même temps, en parallèle : une opération vectorielle appliquée aux couleurs R, G, et B, et une opération scalaire 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.

Un exemple : le jeu d’instruction du GPU de la Geforce 3

modifier

La première carte graphique commerciale grand public à 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.

Les processeurs de vertices de la Geforce 3 disposent de registres registres SIMD qui font 128 bits, soit 4 flottants de 32 bits. Elle contient 16 registres d'entrée, 16 registres de sortie, 32 registres généraux. La mémoire des constantes contient 512 "registres".

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.

La prédication et le SIMT

modifier

Les cartes graphiques récentes peuvent effectuer des branchements, mais ceux-ci sont tout sauf performants. Dès qu'un branchement survient, le processeur est obligé de traiter chaque élément du vecteur un par un, au lieu de tous les traiter en même temps en parallèle. Les performances s'en ressentent, ce qui fait que les branchements sont à éviter le plus possible. Pour améliorer la gestion des conditions, les cartes graphiques modernes incorporent des instructions spécialisées qui permettent de remplacer des codes remplis de branchements par des codes plus simples, compatibles avec l'organisation des données en vecteurs.

Si on met de côté le support de certaines instructions courantes, comme la valeur absolue, ou le calcul du minimum/maximum, la technique la plus importante est la technique dite de prédication. L'idée est que quand une instruction effectue un calcul sur un ou deux vecteurs, certains éléments du vecteur sont ignorés. Les éléments à ignorer sont choisis suivant le résultat d'une instruction de comparaison, qui effectue un test : les éléments pour lesquels ce test est respecté sont pris en compte, ceux qui ne passent pas le test sont ignorés.

Pour donner un exemple d'utilisation, imaginons que l'on ait un vecteur dans lequel on veut remplacer toutes les valeurs négatives par des 0. Dans ce cas, on utilise :

  • une instruction de comparaison, qui compare chaque élément du vecteur avec 0 et génère plusieurs bits de résultat ;
  • suivi d'une instruction à prédicat qui met à zéro les éléments pour lesquels les bits de résultat précédents sont à 1.

Elle est implémentée 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

La prédication avec une pile SIMT

modifier

Au niveau du jeu d’instruction, les architectures SIMT implémentent de la prédication, sous une forme améliorée. Les processeurs SIMT actuels sont surtout utilisées sur les processeurs intégrés aux cartes graphiques. Et ces derniers gèrent très mal les branchements, et encore : beaucoup de cartes graphiques, même récentes, ne gèrent tout simplement pas les branchements. Elles doivent donc se débrouiller avec uniquement la prédication, là où les processeurs SIMD utilisent des branchements normaux en complément de la prédication. Insistons sur le fait que cet usage exclusif de la prédication n'est présent que sur une sous-partie des architectures SIMT, le seul exemple que l'auteur de ce wikilivre connait étant celui des cartes graphiques.

Les architectures SIMT sans branchements doivent donc trouver des solutions pour gérer les structures de contrôle imbriquées, à savoir une boucle placée à l'intérieur d'une autre boucle, un IF...ELSE dans un autre IF...ELSE, etc. Elles utilisent pour cela la prédication, combinée avec des mécanismes annexes. Le premier d'entre eux est l'usage de plusieurs registres de masques organisés d'une manière bien précise, l'autre est l'usage de compteurs d'activité. Voyons ces deux techniques.

La pile de masques remplace le ou les registres de masque. Sans elle, le processeur SIMD incorpore un registre de masque qui est adressé implicitement ou explicitement. Éventuellement, le processeur peut contenir plusieurs registres de masque séparés adressables via un nom de registre. Avec elle, le processeur SIMD incorpore plusieurs registres de masque organisé en pile. Le registre de masque est donc remplacé par une mémoire LIFO, une pile, dans laquelle plusieurs masques sont empilés.

Le tout forme une pile, similaire à la pile d'appel, sauf qu'elle est utilisée pour empiler des masques. Un masque est calculé et empilé à chaque entrée dans une structure de contrôle, puis dépilé une fois la structure de contrôle exécutée. L'empilement et le dépilement des masques est effectué par des instructions PUSH et POP, présentes dans le jeu d'instruction du processeur SIMD.

Le calcul des masques doit répondre à plusieurs impératifs.

  • Premièrement, chaque masque se calcule en faisant un ET entre le masque précédent et le masque calculé par l'instruction de test. Cela permet de ne pas réveiller d’élément au beau milieu d'une structure imbriquée. Si in IF désactive certains éléments du vecteur, une condition imbriquée dans ce IF ne doit pas réveiller cet élément. Le fait de faire un ET entre les masques garantit cela.
  • Deuxièmement, les masques doivent être empilés et dépilés correctement. Au moment de rentrer dans une structure de contrôle, on effectue une instruction de test associée à la structure de contrôle, qui calcule un masque, et on empile le masque calculé. Au moment de sortir de la structure de contrôle, on dépile le masque en question.

L'implémentation demande d'utiliser une mémoire LIFO pour stocker la pile de masques, et quelques circuits annexes. Il faut notamment un circuit relié à l'ALU qui récupère les conditions, les résultats des comparaisons, et qui effectue le ET pour combiner les masques.

Pour donner un exemple, prenons le code suivant, qui est volontairement simpliste et ne sert qu'à des fins d'explication :

if ( condition 1 )
{
    if ( condition 2 )
    {
        ...
    }
    else
    {
        ...
    }

    Autres instructions
}

Instructions après le IF...

Imaginons que l'on traite des vecteurs de 8 éléments.

Pour le vecteur considéré, la première condition (a > 0) n'est respectée que par les 4 premiers éléments. L'instruction de condition calcule alors le masque correspondant : 1111 0000. Le masque est alors calculé, puis empilé au sommet de la pile.

La seconde instruction de test, qui teste la variable b, est maintenant valide pour les 4 bits du milieu du masque. Mais n'allez pas croire que le masque correspondant soit 0011 11100 : il faut tenir compte de la condition précédente, qui a éliminé les 4 derniers éléments. Pour cela, on fait un ET logique entre le masque précédent, et le masque calculé par la condition. Le masque au sommet de la pile est donc lu, combiné avec le masque calculé par l'instruction, ce qui donne le masque final. Le masque final est alors empilé au sommet de la pile.

On exécute alors l'instruction du IF, en tenant compte du masque qui est au sommet de la pile. Si le IF était plus compliqué, toutes les instructions suivantes tiendraient compte du masque. En fait, le masque est pris en compte tant qu'il n'est pas dépilé. Une fois que le IF est terminé, le masque est dépilé.

On passe alors au ELSE, et rebelotte. Le masque pour le ELSE est calculé en combinant le masque au sommet de la pile avec la condition du ELSE. Le masque au sommet de la pile est celui calculé à l'entrée du premier IF, pas le second qui a été dépilé. Les instructions du ELSE sont alors exécutées en tenant compte de ce masque. Une fois qu'elles sont toutes exécutées, le masque est dépilé.

Puis vient l'exécution des instructions après le ELSE. Elles utilisent le masque empilé au sommet de la pile, qui correspond à celui à l'entrée du IF.

Puis vient le moment d'exécuter les instructions après le IF : pas de masque, on exécute sur tout le vecteur.

Les compteurs d'activité

modifier

Une variante de la technique précédente remplace la pile de masques par des compteurs d'activité. La technique est similaire, si ce n'est qu'elle utilise moins de circuits. Avant , on avait une pile de masques de même taille, dont les bits sont à 0 ou 1 suivant que la condition est remplie. La pile de masque ressemble donc à ceci :

masque 1 1 1 1 1
masque 2 0 1 1 1
masque 3 0 1 1 1
masque 4 0 0 0 1
masque 1 vide

Une manière équivalente de représenter cette pile de masque est de compter combien de bits sont à 0 dans chaque colonne. Attention : j'ai bien dit à 0 ! On obtient alors :

masque 1 3 1 1 0

Et c'est le principe caché derrière la technique des compteurs d'activité. Chaque élément dans un vecteur, chaque place, se voit attribuer un compteur. Un compteur non-nul indique qu'il ne faut pas prendre en compte l’élément. Ce n'est qu'une fois que le compteur est nul que l'on effectue des opérations sur l’élément associé du vecteur.

A chaque fois qu'on entre dans une structure de contrôle, on teste une condition sur chaque élément. Si la condition est respectée pour un élément, alors le compteur ne change pas. Mais si la condition n'est pas respectée, alors on incrémente le compteur associé. En sortant de la structure de contrôle, on décrémente le compteur associé. Notons que les compteurs qui n'ont pas été incrémentés en entrant dans la structure de contrôle ne sont pas décrémentés en sortant. En clair, là où on empilait/dépilait un masque, on se contente d'incrémenter/décrémenter un compteur.

Utiliser un compteur en lieu et place d'une colonne entière dans la pile de masque utilise moins de bits. Et c'est sans doute pour cette raison que certaines cartes graphiques, comme les cartes graphiques intégrées d'Intel depuis 2004, utilisent cette technique.

Les processeurs de shaders anciens : des processeurs VLIW

modifier

Après avoir vu les processeurs de shaders de type SIMD, nous allons voir les processeurs de shaders de type VLIW. Les cartes graphiques AMD assez anciennes utilisaient des processeurs de type VLIW, sur la microarchitecture Terascale, avant le passage à l'architecture GCN en 2012. NVIDIA utilisait apparemment aussi la technique sur les Geforce 6 et 7, et même auparavant sur les Geforce 3/4 et FX. Globalement, les processeurs de shader VLIW datent de l'ère de Dirext 9, et ont été abandonnés avec l'arrivée de DirextX 10/11. Aucune carte graphique DirextX 12 n'utilise de processeurs VLIW.

Avant d'expliquer ce qu'est un processeur VLIW, il faut faire un petit interlude sur l'intérieur d'un processeur, quelques rappels. Un processeur moderne contient plusieurs circuits de calcul, chacun étant relativement spécialisé. Par exemple, un processeur moderne peut incorporer une dizaine de circuits capables de faire des additions/soustractions, 3 circuits pour faire des multiplications, un circuit pour faire des divisions, une dizaine de circuits pour les opérations logiques et bit à bit, etc. De tels circuits sont appelés des unités de calcul.

Il est possible de lancer plusieurs opérations, une par unité de calcul. C'est possible sur les processeurs dits superscalaires, ceux à exécution dans le désordre, mais aussi sur des processeurs plus simples qui ont juste un pipeline (ils sont dits à émission dans l'ordre). En général, les processeurs disposent de circuits pour répartir les opérations/instructions sur les unités de calcul adéquates. Les circuits en question portent des noms à coucher dehors : unité d'émission, scoreboard, fenêtre d'instruction, et j'en passe. Mais les processeurs VLIW arrivent à répartir les instructions sur plusieurs unités de calcul sans utiliser le moindre matériel : tout est réalisé en logiciel. Un indice pour comprendre comment : les instructions en co-issue le font nativement, comme on l'a vu plus haut.

Les processeurs VLIW : généralités

modifier

Les processeurs VLIW peuvent être vus comme des processeurs dont toutes les instructions sont des instructions à co-issue sous stéroïdes. Le terme VLIW, terme qui désigne tous les processeurs qui regroupent plusieurs opérations en une seule instruction. La différence est que sur ces processeurs, toutes les instructions sont des instructions à co-issue, sans exception.

Les processeurs VLIW regroupent plusieurs instructions/opérations dans des sortes de super-instructions appelées des faisceaux d'instruction (aussi appelés bundle). Le faisceau est chargé en une seule fois et est encodé comme une instruction unique. En clair, les processeurs VLIW chargent "plusieurs instructions à la fois" et les exécutent sur des unités de calcul séparées (les guillemets sont là pour vous faire comprendre que c'est en réalité plus compliqué).

Une autre manière de voir les choses est que les faisceaux d'instruction regroupent plusieurs opérations en une seule super-instruction machine. Là où les instructions machines usuelles effectuent une seule opération, les faisceaux d'instruction VLIW exécutent plusieurs opérations indépendantes en même temps, dans des unités de calcul séparées.

 
Pipeline simplifié d'un processeur VLIW. On voit que le faisceau est chargé en un cycle d'horloge, mais que les instructions sont exécutées en même temps dans des unités de calcul séparées.

Il y a de nombreuses contraintes quant au regroupement des opérations. On ne peut pas regrouper n'importe quelle opération avec n'importe quelle autre, il faut que les unités de calcul permettent le regroupement. Prenons l'exemple d'un processeur VLIW disposant de deux circuits d'addition et d'un circuit pour les multiplications : il sera possible de regrouper deux additions avec une multiplication, mais pas deux multiplications ou trois additions. Il y a aussi des contraintes sur les registres : les instructions d'un faisceau ne peuvent pas écrire dans les mêmes registres, il y a des contraintes qui font que si telle opération utilise tel registre, alors certains autres registres seront interdits pour l'autre opération, etc.

Sur les processeurs de shaders anciens, on pouvait que regrouper jusqu’à 5/6 opérations. Mais la plupart du temps, le regroupement était de 4 opérations : trois opérations identiques, et une quatrième. Pour simplifier, cette technique permettait d’exécuter une opération appliquée aux couleurs R, G, et B, et une autre qui sera appliquée à la couleur de transparence.

Les processeurs VLIW pour les shaders proprement dit

modifier

Les processeurs VLIW plus évolués étaient des hybrides SIMD/VLIW qui pouvaient exécuter une opération SIMD en co-issue avec une opération arithmétique flottante très complexe, à savoir une opération transcendantale. Un exemple est le processeur de vertices de la Geforce 6880, qui lui aussi pouvait faire une opération SIMD sur des flottants de 32 bits, en co-issue avec une opération transcendantale sur des flottants de 32 bits. Les processeurs de vertices simples étaient souvent de ce type. Par contre, les processeurs de pixel shader avaient des possibilités de co-issue plus développées.

Un exemple est celui du processeur de pixel shader de la Geforce 6800. Il disposait de plusieurs unités de calcul utilisables en parallèle. La première est une unité de calcul capable de réaliser une multiplication et une addition SIMD, portant sur des vecteurs de 4 éléments. La seconde effectue des fonctions arithmétiques spéciales, comme les logarithmes, exponentielles ou les calculs trigonométriques, les produits scalaires et autres. Enfin, il y a 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.

Intuitivement, on se dit que le processeur est capable de faire une opération SIMD, une opération d'accès aux textures, et une opération transcendantale. Sauf qu'en réalité, l'unité de calcul multiplication/addition était beaucoup plus flexible. Elle permttait de faire : soit une opération SIMD agissant sur des vecteurs de 4 éléments, soit une opération vectorielle sur 3 flottants co-issue avec une opération scalaire, deux opérations vectorielles sur deux éléments chacune.

 
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.

Les processeurs VLIW sur les cartes graphiques AMD

modifier

Les cartes graphiques AMD et ATI assez anciennes, d'architectures R300, de la série des Radeon 9700, étaient aussi des processeurs VLIW. Elles incorporaient ces instructions pour faciliter les calculs de produits vectoriels combinés avec de l'éclairage. Elles combinaient trois opérations : une opération SIMD sur des vecteurs de 4 flottants, avec une opération scalaire. Les contraintes de combinaisons des instructions sont assez complexes.

  • Certaines opérations sont disponibles à la fois pour l'opération scalaire et vectorielle. C'est le cas pour les opérations entières suivante : les comparaisons, les additions, les soustractions, les opérations logiques, les décalages, les opérations bit à bit, les instructions CMOV. Les mêmes opérations, mais sur des opérandes flottants, sont disponibles aussi bien pour l’opération scalaire que vectorielle.
  • L'opération vectorielle pouvait être une des opérations précédente, mais gérait aussi des multiplications et additions flottante, une opération MAD, des produits vectoriels ou scalaires, et diverses opérations d'arrondis ou de conversion entre flottants.
  • L'opération scalaire était : soit une opération de conversion entier-flottant, soit une opération transcendantale (entière ou flottante), soit une multiplication entière 32 bits, soit une multiplication flottante 32 bits.

Le tout était appelé du VLIW-5 par AMD/ATI. VLIW-5, car on pouvait effectuer 4 calculs flottants en parallèle avec l’opération SIMD d'un cinquième (entier ou flottant). Le jeu d'instruction est rendu public dans la documentation d'AMD, le voici : [1]

Par la suite, les cartes graphiques AMD ont changé les possibilités de combinaisons entre opérations. Le changement a réduit le nombre d'opérations simultanées à deux. La seconde opération est une opération scalaire flottante, la possibilité de faire une opération entière a été retirée. La première opération est soit une opération transcendantale, soit une opération vectorielle sur trois flottants. L'origine de ce changement, peu intuitif, sera expliqué dans le chapitre sur la microarchitecture des processeurs de shaders. Pour résumer, le processeur peut faire au choix :

  • 4 opérations flottantes en parallèle : 3 calculs flottants via SIMD, plus un par l’opération scalaire.
  • une opération transcendantale couplée à une opération flottante.

Le tout donna une architecture appelée par AMD : VLIW-4. 4, car le processeur peut faire au grand max 4 opérations flottantes en parallèle.

Un cas particulier : les register combiners

modifier

La toute première utilisation de processeurs VLIW sur un GPU était la Geforce 256, avec l'usage des register combiners vus dans le chapitre précédent. Pour rappel, les register combiners sont des opérations qui permettaient de mélanger plusieurs textures entre elles, le mélange étant partiellement programmable. Pour cela, les cartes graphiques de l'époque de Direct X 6 incorporaient un processeur VLIW très particulier.

Il disposait d'un nombre limité de registres, une dizaine en tout. Lors d'une opération de multitexturing, les registres étaient initialisés avec les données adéquates, lues depuis les textures ou fournies par l'unité de rastérisation. Quelques registres étaient en lecture seule, d'autres étaient modifiables par les instructions VLIW. Les registres contiennent tous des couleurs au format RGB-A, à savoir une couleur RGB codée sur trois entiers, et une composante de transparence codée avec un entier.

Les quatre registres constants, en lecture seule, sont les suivants :

  • un registre zéro, contenant toujours 0 ;
  • un registre fog contenant la couleur du brouillard ;
  • deux registres de couleur configurables par l'utilisateur.

Les registres modifiables sont les suivants :

  • Des registres de texel, un par unité de texture, qui mémorise le texel lu lors du placage de texture ;
  • Des registres généraux qui n'ont pas de fonction prédéterminée ;
  • Deux registres d'éclairage par sommet qui mémorisent respectivement les couleurs spéculaire et diffuse fournies par l'unité de rastérisation.

L'implémentation du circuit est inconnue, mais son interface l'est très bien. Tout se passe comme si le processeur incorporait deux unités de calcul : une appelée l'unité RGB, l'autre appelée l'unité alpha. Leur nom trahit ce qu'elles font : l'une calcule un résultat RGB, l'autre calcule la composant de transparence alpha. Elles fonctionnent en parallèle, ce qui fait qu'elles peuvent faire deux opérations simultanément. Enfin, opération, le terme est vite dit car chaque unité de calcul peut faire plusieurs opérations simultanées.

Les deux unités prennent quatre opérandes notées A, B, C et D. Ce sont sont des opérandes flottantes codées sur 32 bits, qui peuvent être lues dans tous les registres. Rappelons cependant qu'un registre contient 4 flottants : trois pour le codage d'une couleur RGB, un autre pour la transparence A. Les opérandes n'ont pas à provenir du même registre. Par exemple, il est parfaitement possible de lire la composant A d'un registre, la composant R d'un second registre, et les composantes R V d'un troisième.

Intuitivement, on s'attend à ce que l'unité RGB lise les registres R, G et B, et écrire ses résultats dans les mêmes registres. Et pour l'unité alpha, on s'attend à ce qu'elle prenne ses opérandes dans les registres A et écrive ses résultats dans les mêmes registres. Mais ce n'est pas du tout ce qui se passe. L'unité RGB peut lire les registres R, G et B, mais aussi les registres A pour la transparence. Il peut lire toutes les composantes de tous les registres, sauf un : la composant alpha du registre de brouillard. Pour l'unité alpha, elle peut lire les registres A pour la transparence, mais elle peut aussi lire les couleurs bleues, la portion B d'un registre RGBA. En clair, sur les registres RGBA, les registres B et A servent comme opérande pour l'unité alpha.

L'unité alpha est capable de faire des multiplications, deux multiplications à la fois. Elle peut faire trois opérations en même temps et possède donc trois sorties. La première sortie fournit le résultat de la multiplication A*B, la seconde sortie le produit C*D. La troisième sortie est plus complexe. Elle peut faire deux opérations. La première fait l'addition des deux produits A * B + C * D. La seconde fournit soit A * B, soit C * D, suivant la valeur de transparence du registre de texture voulu : elle fournit A*B si l'alpha de la texture est supérieur à 0.5, C*D sinon. Pour mieux comprendre son fonctionnement, voici une implémentation possible :

 
Implementation de l'unité alpha des registers combiners

L'unité RGB est capable de faire des produits scalaires en plus des multiplications. Elle prend quatre opérandes entières notées A, B, C et D, qui peuvent être lues dans tous les registres, et peuvent être lues à la fois dans les portions RGB et A d'un registre. Elle peut faire maximum trois opérations en même temps et possède donc trois sorties. Les deux premières sorties peuvent fournir soit un produit scalaire, soit une multiplication. La troisième sortie ne change pas comparé à l'unité alpha, mais elle est désactivée si l'unité RGB effectue au moins un produit scalaire.

 
Implementation de l'unité RGB des registers combiners

Il faut noter que les sorties des deux unités de calcul sont connectées à une mini-ALU qui met à l'échelle le résultat. La mise à l'échelle multiplie les trois résultats par 0.5, 1, 2, 4, au choix. De plus, le résultat peut subir une soustraction spécifique, à savoir qu'on peut lui retirer 0.5, mais seulement si on multiplie le résultat par 1 et 2.

Multiplication Biais facultatif
0.5 X
1 - 0.5 facultatif
2 -0.5 facultatif
4 X

L'abandon des architectures VLIW

modifier

Les architectures VLIW étaient utilisés sur les premiers processeurs de shaders, et ont été abandonnées par la suite. Si de telles architectures semblent intéressantes sur le papier, cela complexifie fortement la traduction à la volée des shaders en instructions machine. Raison pour laquelle cette technique a été abandonnée.

Les processeurs de shader disposent de circuits de calcul séparés, appelés des unités de calcul. Chaque unité de calcul peut faire des additions/soustractions/comparaisons, parfois des multiplications, voire d'autres opérations. Et avec le VLIW, chaque unité de calcul fonctionne séparément des autres, elles peuvent effectuer chacun un calcul différent des autres unités de calcul. L'ensemble est beaucoup plus flexible qu'avec le SIMD, où toutes les unités de calcul doivent faire le même calcul.

Mais le problème est que cette flexibilité est peu utilisée. En effet, le compilateur doit analyser les shaders pour vérifier si des instructions peuvent être regroupées dans un bundle. Le shader décrit une suite d'instructions machines, que le driver de la carte graphique analyse pour vérifier s'il peut faire des regroupements. Et disons-le clairement : les compilateurs de shaders sont assez mauvais pour ça. Ce qui fait que la flexibilité des processeurs VLIW est peu utile en pratique.

Un avantage des processeurs VLIW est qu'ils ont pour particularité d'avoir un hardware très simple, avec peu de circuits de contrôle. Le compilateur se charge de vérifier que des opérations indépendantes sont regroupées dans une instruction. Alors qu'avec un processeur SIMD, il y a des circuits de détection des dépendances entre instructions, qu'on abordera dans quelques chapitres. L'absence de ces circuits fait que les processeurs VLIW étaient utilisés sur les premières cartes graphiques : autant utiliser les transistors pour placer le plus de circuits de calcul possible au lieu d'en dépenser dans des circuits de contrôle. Mais avec l'évolution de la technologie, il est devenu plus rentable d'ajouter de tels circuits pour gagner en performance.


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é d'input 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 répartition du travail pour les autres tâches que le rendu 3D

modifier

Plus haut, nous avions dit que le rasteriseur et linput assembler s'occupent de la répartition du travail sur les différents processeurs. Ils agissent sous la supervision du processeur de commande qui se charge de répartir le travail sur les différents circuits de la carte graphique. Il commande le rasteriseur, linput assembler, les processeurs de shader, et les autres circuits. Le processeur de commande est impliqué dans la répartition du travail de manière générale, mais s'occupe surtout du rendu 3D et 2D. Il y aura un chapitre spécialisé sur le fonctionnement du processeur de commande, qui parlera de son rôle en général.

Outre le rendu 3D, les cartes graphiques modernes sont utilisées dans le calcul scientifique, pour accélérer les applications d'IA et bien d'autres. L'usage d'une carte graphique pour autre chose que le rendu 3D porte le nom de GPGPU (General Processing GPU). Le GPGPU est utilisé pour diverses tâches : calcul scientifique, tout ce qui implique des réseaux de neurones, imagerie médicale, etc. De manière générale, tout calcul faisant usage d'un grand nombre de calculs sur des matrices ou des vecteurs est concerné.

En soi, le GPGPU est assez logique : une carte graphique est un monstre de puissance qu'il vaut mieux utiliser dès que possible. Bien que conçues spécifiquement avec le rendu 3D en tête, elles n'en restent pas moins des processeurs multicœurs SIMD/VLIW assez complexes et puissants, qu'on peut parfaitement utiliser en tant que processeur multicœur.

Pour le GPGPU, la programmation est assez simple. L'idée est d'écrire des shaders qui font des calculs génériques, à savoir qu'ils ne travaillent pas sur des pixels ou des vertices provenant du rastériseur ou de l'input assembler. Les données manipulées par le shader sont simplement lues ou écrites en mémoire, directement, sans passer par le moindre circuit fixe. Les shaders deviennent alors des programmes comme les autres, les processeurs de shaders sont utilisés comme n'importe quel processeur SIMD normal. Reste à répartir le travail sur les différents processeurs de shaders. Il suffit de programmer un shader qui effectue le calcul voulu et de configurer le GPU en mode GPGPU.

La répartition du travail en GPGPU n'est pas celle du mode graphique

modifier

En mode GPGPU, le shader s'exécute directement, il n'y a pas de passage par le rasterizeur, les ROP ou l'input assembler, ou tout autre circuit fixe impliqué dans le rendu 3D. Il s'agit donc d'un mode différent du mode graphique. La répartition du travail en GPGPU et en mode graphique n'est pas du tout la même.

Du point de vue du GPGPU, l'architecture d'une carte graphique récente est illustrée ci-dessous. Les processeurs/cœurs sont les rectangles en bleu/rouge, avec des unités de calcul SIMD en bleu et des unité de calcul pour les calculs complexes (trigonométriques, racines carrées, autres) en rouge. La hiérarchie mémoire est indiquée en vert. Le tout est alimenté par un processeur de commande spécialisé, le Thread Execution Control Unit en jaune, qui répartit les différentes instances des shader sur les différents processeurs.

 
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.

Les anciennes cartes graphiques se débrouillaient avec des processeurs de commande optimisés pour le rendu graphique, ce qui était sous-optimal. Mais de nos jours, les processeurs de commande se sont améliorés et sont capable de gérer des commandes de calcul génériques, qui n'utilisent que les processeurs de shaders et contournent les circuits fixes dédiés au rendu graphique.

Un premier point important est que certaines cartes graphiques ont des processeurs de commande séparés pour le GPGPU et le mode graphique. Un exemple est celui de l'architecture GCN d'AMD, qui disposait d'un processeur de commande pour le mode graphique, avec plusieurs processeurs de commande spécifiques au GPGPU. Les processeurs de commande spécifique au GPGPU étaient appelés des ACE, et il y en avait 8, 16 ou 32 selon l'architecture, le nombre ayant augmenté au cours du temps.

 
GCN command processing

Les cartes graphiques modernes peuvent gérer un rendu 3D simultané avec des calculs GPGPU. Les deux ont lieu en même temps, dans des processeurs de shaders séparés. Mais pour ne pas se marcher sur les pieds, le GPU dispose de mécanismes compliqués de répartition, mais aussi de priorité. Sur les cartes graphiques avant l'architecture AMD RDNA 1, les tâches de GPGPU avaient la priorité sur le rendu graphique. Maintenant, les cartes AMD modernes gérent un système de priorité plus complexe. De plus, elles ont la possibilité de suspendre complétement l'exécution d'un shader pour passer à une tâche graphique/GPGPU, ou autre, histoire faire passer avant tout les calculs prioritaires.

La répartition du travail pour le GPGPU

modifier

En GPGPU, un shader s'exécute sur des regroupements de données bien connus des programmeurs : des tableaux. Les tableaux peuvent être de simples tableaux, des matrices ou tout autre tableau, peu importe. Le processeur envoie à la carte graphique un ensemble comprenant un shader à exécuter, ainsi que les données/tableaux à manipuler. Les tableaux ont une taille variable, mais sont presque toujours de très grande taille, au moins un millier d’éléments, parfois un bon million, si ce n'est plus.

Il faut préciser que la terminologie du GPGPU est quelque peu trompeuse. Dans la terminologie GPGPU, un thread correspond à l'exécution d'un shader sur une seule donnée, un seul entier/flottant provenant du tableau. Beaucoup de monde s'imagine que les processeurs de shader exécutent des threads, qui sont regroupés à l'exécution en warps par le matériel. Le regroupement fait que tous les threads s'exécutent de manière synchrone sur des données différentes, avec un program counter partagé par tous les threads. Si on sait peu de choses sur la manière dont fonctionnent réellement les GPU modernes, ce n'est vraisemblablement pas ce qui se passe. Il est plus correct de dire qu'un GPU moderne est une sorte de processeur multicœurs amélioré, qui gère des tableaux/vecteurs de taille variable, mais les découpe en vecteurs de taille fixe à l'exécution et répartit le tout sur des processeurs SIMD.

Le processus de répartition du travail est globalement le suivant. Le processeur de commande reçoit une commande de calcul, qui précise quel shader exécuter, et fournit l'adresse de plusieurs tableaux, ainsi que des informations sur le format des données (entières, flottantes, tableau en une ou deux dimensions, autres). Le processeur de commande se charge de découper le ou les tableaux en vecteurs de taille fixe, que le processeur peut gérer. Par exemple, si un processeur SIMD gère des vecteurs de 32 entiers/flottants, alors le tableau est découpé en morceaux de 32 entiers/flottants, et chaque processeur exécute une instance du shader sur des morceaux de cette taille.

La répartition du travail telle que définie par CUDA

modifier

Pour les GPU NVIDIA, le processus de découpage n'est pas très bien connu. Mais on peut en avoir une idée en regardant l'interface logicielle utilisée pour le GPGPU. Chez NVIDIA, celle-ci s'appelle CUDA et ses versions donnent une idée de comment le découpage s'effectue. Premièrement, les shaders sont appelés des kernels. Les tableaux de taille variable sont appelés des grids. Les données individuelles sont appelées, de manière extrêmement trompeuse, des threads. Rappelons qu'un thread est, pour simplifier, censé désigner un programme en cours d'exécution, pas des données entières/flottantes regroupées dans un tableau. Aussi, pour éviter toute confusion, je vais renommer les thraeds CUDA en scalaires.

Les grids sont eux-même découpés en thread blocks, qui contiennent entre 512 et 1024 données entières ou flottantes, 512 et 1024 scalaires. La taille, 512 ou 1024, dépend de la version de CUDA utilisée, elle-même liée au modèle de carte graphique utilisé. Plutôt que d'utiliser le terme thread blocks, je vais parler de bloc de scalaires. Le bloc de scalaire est une portion d'un tableau, il correspond donc à un bloc de mémoire, à une suite d'adresse consécutives. Il a donc une adresse de départ.

Chaque scalaire d'un bloc de scalaire a un indice qui permet de déterminer son adresse, qui est calculé par le processeur de commande. Le calcul de l'indice peut se faire de différentes manières, suivant que le tableau soit un tableau unidimensionnel (une suite de nombre) ou bidimensionnel( une matrice). CUDA gère les deux cas et les dernières cartes graphiques gèrent aussi des tableaux à trois dimensions. Le calcul de l'adresse d'un scalaire se fait en prenant l'adresse de départ du bloc de scalaire, et la combinant avec les indices.

 
Découpage des tableaux avec CUDA.

Un bloc de scalaires s'exécute sur un processeur de shader, qui est aussi appelé un streaming multiprocessor (terme encore une fois trompeur, vu qu'il s'agit d'un processeur unique, bien que multithreadé. Une fois lancé sur un processeur de shader, il y reste définitivement : il ne peut pas migrer sur un autre processeur en cours d'exécution. Un processeur de shader peut exécuter plusieurs instances de shaders travaillant sur des bloc de scalaires différents. Dans le meilleur des cas, il peut traiter en parallèle environ 8 à 16 bloc de scalaires différents en même temps.

 
Exécution des thread blocks sur les processeurs de shaders.

Le bloc de scalaires est découpé en vecteurs d'environ 32 éléments. Les vecteurs en question sont appelés des warps dans la terminologie NVIDIA/CUDA, des wavefronts dans la terminologie AMD. Ces termes sont aussi utilisés pour décrire non pas les données, les vecteurs, mais aussi l'instance du shader qui s'occupe de faire des calculs dessus. La terminologie est donc assez confuse. Un warp/wavefront est donc en réalité un thread, un programme, une instance de shader, qui manipule des vecteurs SIMD de 32 éléments. Il y a le même problème avec le terme bloc de scalaires, qui désigne aussi bien les données que les instances du shader qui les manipule.

Cela fait en tout 16 à 32 vecteurs suivant la version de CUDA utilisée. Les 16 à 32 warps sont exécutés en même temps sur le processeur de shader, via multithreading matériel. Un warp exécute des instructions SIMD sur des vecteurs de taille fixe, le processeur ne connait que des vecteurs et des warps/wavefronts, il ne connait pas de shader qui travaille sur des données uniques non-SIMD. Le découpage en warps est encore une fois le fait du processeur de commande. Même le processeur de commande ne traite jamais de thread sur des données uniques non-SIMD, il gère des tableaux de taille variable.

Pour résumer, plus un GPU contient de processeurs de shaders, plus le nombre de blocs de scalaires qu'il peut traiter en même temps est important. Par contre, la taille des warps ne change pas trop et reste la même d'une génération sur l'autre. Cela ne signifie pas que la taille des vecteurs reste la même, mais elle est assez contrainte.


La microarchitecture des processeurs de shaders

La conception interne (aussi appelée microarchitecture) des processeurs de shaders possède quelques particularités idiosyncratiques. La microarchitecture des processeurs de shaders est 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.

Les unités de calcul d'un processeur de shader SIMD

modifier

Pour rappel, un processeur de shader supporte plusieurs types d'instructions. Au minimum, il supporte des instructions SIMD. Mais il peut aussi gérer des instructions scalaires, à savoir qu'elles travaillent sur des entiers/flottants isolés, non-regroupés dans un vecteur SIMD. Typiquement, il y a plusieurs types d'instructions scalaires : les calculs entiers, les calculs flottants simples, les calculs flottants complexes dit transcendantaux (calculs trigonométriques, des exponentielles, des logarithmes, des racines carrées ou racines carrées inverse).

Et ces différentes instructions ont toutes des unités de calcul dédiées, ce qui fait qu'un processeur de shader contient un grand nombre d'unités de calcul très différentes. Le cœur est une unité de calcul SIMD, qui se charge des instructions SIMD. Mais d'autres unités de calcul sont présentes, surtout sur les architectures modernes. Il n'est pas rare de trouver une unité de calcul entière, ou des unités de calcul flottantes spécialisées pour les opérations transcendantales.

Les unités de calcul SIMD

modifier
 
Une unité de calcul SIMD.

Un processeur de shader incorpore une unité de calcul SIMD, qui effectue plusieurs calculs en parallèle. Elle regroupe plusieurs ALU flottantes regroupées ensemble et avec quelques circuits pour gérer les débordements et d'autres situations. En théorie, une unité de calcul SIMD regroupe autant d'unité de calcul qu'il y a d'entiers/flottants dans un vecteur. Par exemple, pour additionner deux vecteurs contenant chacun 16 flottants, il faut utilise 16 additionneurs flottants. Ce qui fait qu'une opération sur un vecteur est traité en une seule fois, en un cycle d'horloge. Une contrainte importante est que toutes les sous-ALU effectuent la même opération : ce ne sont pas des ALU séparées qu'on peut commander indépendamment, mais une seule ALU regroupant des circuits de calcul distinct.

Le cout en circuit est d'autant plus grand que les vecteurs sont longs et le cout est approximativement proportionnel à la taille des vecteurs. Entre des vecteurs de 128 et 256 bits, l'unité de calcul utilisera globalement deux fois plus de circuits avec 256 bits qu'avec 128. Même chose pour les registres, mais c'est là un cout commun à toutes les architectures.

Il y a quelques unités de calcul SIMD où le calcul se fait en deux fois, car on n'a que la moitié des unités de calcul. Par exemple, pour un vecteur de 32 flottants, on peut utiliser 16 unités de calcul, mais le temps de calcul se fait en deux cycles d'horloge. Les opérations sur les vecteurs sont donc faites en deux fois : une première passe pour les 16 premiers éléments, une seconde passe pour les 16 restants. L'implémentation demande cependant qu'une instruction de calcul soit décodée en deux micro-opérations. Par exemple, une instruction SIMD sur des vecteurs de 32 éléments est exécutée par deux micro-instructions travaillant sur des vecteurs de 16 éléments. On économise ainsi pas mal de circuits, mais cela se fait au détriment de la performance globale.

L'avantage est que cela se marie bien avec l'abandon des opérations pour lesquelles masques dont tous les bits sont à 0. Par exemple, prenons une instruction travaillant sur 16 flottants, exécutée en deux fois sur 8 flottants. Si le masque dit que les 8 premières opérations ne sont pas à exécuter, alors l'ALU ne fera que le calcul des 8 derniers flottants. Pour cela, le décodeur doit lire le registre de masque lors du décodage pour éliminer une micro-instruction si besoin, voire les deux si le masque est coopératif.

Plusieurs unités SIMD, liées au format des données

modifier

Il faut préciser qu'il y a une séparation entre unités SIMD flottantes simple et double précision. Pour le dire plus clairement, il y a des unités SIMD pour les flottants 32 bits, d'autres pour les flottants 64 bits, et même d'autres pour les flottants 16 bits. Les flottants 64 bits sont utilisés dans les applications GPGPU, les flottants 16 et 32 bits le sont dans le rendu 3D, et les flottants 16 bits pour tout ce qui lié à l'IA. Malheureusement, on doit utiliser des ALU flottantes séparées pour chaque taille, le format des flottants n'aidant pas.

Depuis plus d'une décennie, les cartes graphiques ont des unités SIMD à la fois pour les calculs entiers et flottants. Elles sont censées être séparées. Pour NVIDIA, avant l'architecture Turing, les unités SIMD entières et flottantes sont décrites comme séparées dans leurs white papers, avec des unités INT32 et FLOAT32 séparées, combinées à d'autres unités de calcul. L'architecture Volta a notamment des unités INT32, FLOAT32 et FLOAT64 séparées. A partir de l'architecture Ampere, il semblerait que les unités SIMD soient devenues capables de faire à la fois des calculs flottants et entiers, pour la moitié d'entre elles. Mais il se pourrait simplement qu'elles soient physiquement séparées, mais reliées aux reste du processeur de manière à ne pas être utilisables en même temps.

Il est cependant possible que sur d'anciennes architectures, les unités entières et flottantes partagent des circuits, notamment pour ce qui est de la multiplication. En effet, une unité de calcul flottante contient des circuits pour faire des calculs entiers pour additionner/multiplier les mantisses et les exposants. Il est possible d'utiliser ce circuit Un exemple est le cas de l'architecture GT200 d'NVIDIA, sur laquelle les "pseudo-ALU" entières SIMD étaient limitées à des multiplications d'opérandes 24 bits, ce qui correspond à la taille d'une mantisse d'un flottant 32 bits. Le design exact des ALU n'est pas connu.

Les unités de calcul scalaires

modifier

Les GPU modernes incorporent une unité de calcul entière scalaire, séparée de l'unité de calcul SIMD. Elle gère des calculs scalaires, à savoir qu'elle ne travaille pas sur des vecteurs. Elle gère divers calculs, comme des additions, soustractions, comparaisons, opérations bit à bit, etc. Elle exécute les instructions de calcul entière sur des nombres entiers isolés, de plus en plus fréquentes dans les shaders.

Elle est parfois accompagnée d'une unité de calcul pour les branchements. Par branchements, on veut parler des vrais branchements similaires à ceux des CPU, qui effectuent des tests sur des entiers et effectuent des branchements conditionnels. Ils n'ont rien à voir avec les instructions à prédicat qui elles sont spécifiques à l'unité de calcul vectorielles. Ce sont des instructions séparées, totalement distinctes

Les processeurs de shaders incorporent aussi une unité de calcul flottante scalaire, utilisée pour faire des calculs sur des flottants isolés. L'unité de calcul gère généralement des calculs simples, comme les additions, soustractions, multiplications et divisions. Il s'agit typiquement d'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), qui ne gère pas la division.

L'unité de calcul flottante est souvent accompagnée d'une unité de calcul spécialisée qui gère les calculs transcendantaux, avec une gestion des calculs trigonométriques, de produits scalaires ou d'autres opérations. Elle porte le nom d'unité de calcul spéciale (Special Function Unit), ou encore d'unité de calcul transcendantale, et elle a d'autres appellations. Elle est composée de circuits de calculs accompagnés par une table contenant des constantes nécessaires pour faire les calculs.

Rappelons que les registres SIMD et les registres scalaires sont séparés et ne sont pas adressés par les mêmes instructions. Les registres scalaires sont placés dans un banc de registre physiquement séparé du banc de registres SIMD. Le banc de registre scalaire est relié à sa propre ALU scalaire, il y a vraiment une séparation physique entre registres scalaires et SIMD. Il existe cependant un système d'interconnexion qui permet d'envoyer un scalaire aux unités SIMD, ce qui est utile pour les opérations de produits scalaire ou autres.

L'unité de texture/lecture/écriture

modifier

L'unité d'accès mémoire s'occupe des lectures et écriture en général, et elle prend en charge les accès aux textures, le filtrage de textures et tout un tas de fonctionnalités liées aux textures. La seule différence entre un accès aux textures et une lecture/écriture en mémoire est que les circuits de filtrage de texture sont contournés dans une lecture/écriture normale. Dans ce qui suit, nous allons l'appeler l'unité de texture par souci de simplification.

La double émission et l'exécution avec un scoreboard

modifier

Un processeur de shader SIMD contient donc beaucoup d'unité de calcul, qui peuvent en théorie fonctionner en parallèle. Il est en théorie possible d'exécuter des instructions séparés dans des unités de calcul séparées. Cette possibilité est appelée l'émission multiple, car on "émet" plusieurs instructions à la fois dans des unités de calcul séparées.

Son support sur les cartes graphique a beaucoup varié dans le temps, mais les anciennes cartes graphiques comme les nouvelles en dispose. La différence est que l'émission multiple est gérée de manière différente entre les anciennes cartes graphiques SIMD et VLIW. Le VLIW est naturellement à émission multiple, pas le SIMD. Mais il est possible d'ajouter de l'émission multiple sur des processeurs SIMD.

Les lectures non-bloquantes

modifier

Toutes les cartes graphiques gèrent une technique d'émission multiple appelée les lectures non-bloquantes. L'idée est simple : elle permet d'utiliser l'unité de texture en parallèle des autres unités de calcul. En clair, pendant qu'on effectue une lecture dans l'unité de texture, on peut faire des calculs en parallèle dans les ALU de calcul. Vu que les lectures de texture prennent énormément de temps, cette optimisation permet de faire des calculs en avance, en attendant que le texel arrive. La technique s'applique aussi aux lectures normales, à savoir quand on lit autre chose qu'une texture.

Mais la technique n'est possible que si les instructions de calcul n'ont pas comme opérande un texel en cours de lecture. Il ne faut pas qu'il y a ai une dépendance entre les instructions exécutées dans l'ALU et le texel en cours de lecture, voire un texel pas encore lu. Pour cela, la carte graphique fait des vérifications sur les registres. La lecture charge le texel dans un registre, appelé le registre de destination (sous entendu, de destination de la lecture). Les instructions qui n'utilisent pas ce registre peuvent s'exécuter sans problèmes : elles sont indépendantes de la lecture. Mais les autres ont une dépendance et ne sont pas exécutées.

Détecter les dépendances demande juste de mémoriser le registre de destination dans un registre temporaire, et de faire des comparaisons. L'implémentation demande de rajouter un circuit d'émission entre le décodeur d'instruction et les unités de calcul, qui vérifie si l'instruction décodée peut s'exécuter ou non, en comparant les registres utilisés par l'instruction avec le registre de destination. Si ce n'est pas le cas, elle est mise en attente. La mise en attente bloque alors toutes les instructions suivantes. En clair, dès qu'on tombe sur une instruction dépendante, le processeur cesse d'émettre des instructions dans l'ALU, et attend la fin de la lecture. Il n'a pas vraiment le choix. Faire autrement serait possible, mais demanderait d'implémenter des techniques d'exécution dans le désordre très gourmandes en circuits.

La double émission (dual issue)

modifier

Mais l'émission multiple ne se limite pas aux lectures non-bloquantes. Par exemple, reprenons l'exemple de l'unité de vertices de la Geforce 6800, mentionné au-dessus. Elle dispose d'une unité de calcul SIMD MAD, et d'une unité de texture, ainsi que d'une unité de calcul scalaire transcendantale. Il en en théorie possible de faire, en même temps, un calcul dans chaque ALU. Pendant que l'unité transcendantale fait un calcul trigonométrique quelconque, l'autre ALU effectue des calculs SIMD sur d'autres données.

La seule contrainte est que les deux opérations doivent pouvoir s'exécuter en parallèle. Par exemple, il ne faut pas que le résultat de la première dépende de la seconde. De même, il ne faut pas qu'elles écrivent dans un même registre, sous peine de perdre le résultat d'une des opération. Les deux cas précédents sont regroupés sous le terme de dépendances de données. S'il y a une dépendance de données entre deux instruction, on ne peut pas les exécuter en parallèle. Et c'est encore une fois le rôle de l'unité d'émission que de détecter ces dépendances. Pour cela, elle vérifie si deux instructions utilisent les mêmes registres.

De plus, elle vérifie d'autres formes de dépendances qui empêchent une exécution simultanée de deux opérations/instructions. Elle vérifie si les deux instructions sont du bon type. Elle vérifie aussi que les unités de calcul sont libres, qu'elles n'exécutent pas déjà une instruction. C'est possible si l'instruction en question est multicycle, qu'elle met plusieurs cycles pour calculer le résultat. Une unité d'émission de ce type est appelée un scoreboard.

Les processeurs de shaders VLIW

modifier

Une autre forme d'émission multiple est l'usage d'un jeu d'instruction VLIW et/ou d'instructions en co-issue, abordés dans les chapitres précédents. Pour rappel, un processeur VLIW regroupe plusieurs opérations en une seule instruction machine. L'instruction encode les calculs à faire en parallèle, en co-issue. Elle précise les registres, l'opcode de l'instruction, et tout ce qu'il faut pour faire les calculs, et attribue implicitement une unité de calcul à chaque opération.

Les opérations regroupées sont garanties indépendantes par le compilateur, ce qui fait que le décodeur d'instruction envoie chaque opération à l'unité de calcul associée, sans avoir à faire la moindre vérification de dépendances entre instructions. L'unité d'émission est donc grandement simplifiée, elle n'a pas à découvrir les dépendances entre instructions.

Au passage, cela explique pourquoi les premières cartes graphiques étaient de type VLIW, alors que les modernes sont de type SIMD. Les anciennes cartes graphiques préféraient se passer de scoreboard. Mieux valait utiliser le peu de transistors dont elles disposaient pour des unités de calcul. De plus, DirectX 8 et 9 profitaient pas mal de la présence de co-issue. Par contre, il fallait un compilateur performant pour en profiter, ce qui n'était pas vraiment le cas. Les compilateurs n'exploitaient pas beaucoup la co-issue, ce qui fait que les fabricant de GPU ont préféré déléguer cette tâche à un scoreboard matériel.

Les architectures VLIW pures, sans unité SIMD

modifier

Un processeur VLIW contient un grand nombre d'unités de calcul. En théorie, il incorpore plusieurs unités de calcul scalaires séparés et n'a pas d'unité de calcul SIMD. Il y a cependant quelques exceptions, mais nous les verrons plus tard. Pour le moment, concentrons-nous sur les processeurs de shaders VLIW sans aucune unité de calcul SIMD.

Un exemple est celui des anciennes cartes graphiques AMD, d'architecture TeraScale/VLIW-5, à savoir les Radeon HD 2000/3000/4000/5000/6000. L'architecture était une architecture VLIW, pas SIMD, d'où la distinction. Il n'y avait pas d'unité de calcul SIMD, mais plusieurs unités de calcul scalaires. Un point important est que les unités de calculs scalaires pouvaient faire des opérations différentes. Par exemple, la première pouvait faire une addition flottante, la seconde une addition entière, la troisième une soustraction, etc.

Elles disposaient de six unités de calcul : cinq unités de calcul scalaires, et une unité de calcul dédiée aux branchements. Sur les 5 unités de calcul, une était une unité de calcul hybride scalaire/transcendentale. Nous allons donc faire la distinction entre unité de calcul spéciale et les 4 unités de calcul basiques.

Toutes les unités de calculs pouvaient faire les opérations suivantes, sur des flottants et entiers sur 32 bits : comparaisons, additions, soustractions, opérations logiques, décalages, opérations bit à bit et instructions CMOV. Les unités de calcul basiques gèrent aussi les multiplications, opérations MAD et produits vectoriels/scalaires, mais seulement pour des opérandes flottantes. L'unité de calcul spéciale gérait des multiplications et division entières sur des opérandes 32 bits, ainsi que des instructions transcendantales entières/flottantes.

Par la suite, avec l'architecture VLIW-4, l'unité de calcul transcendantale a été retirée. Mais les calculs transcendantaux n'ont pas disparus. En effet, il ne resta que 4 ALU flottantes, qui ont été augmentées pour gérer partiellement les opérations transcendantales. Tout se passait comme si l'ALU transcendantale avait été éclatée en morceaux répartis dans chaque ALU flottante/entière. Et c'est globalement ce qui s'est passé : les diverses tables matérielles utilisées pour les calculs transcendantaux ont été dispersés dans les ALU, afin de faire des calculs transcendantaux approchés. En combinant les résultats approchés, on pouvait calculer le résultat exact.

Les hybrides SIMD/VLIW et les instructions à co-issue

modifier

La gestion des instructions en co-issue peut aussi utiliser les techniques des processeurs VLIW. Pour rappel, l'exemple typique d'instruction en co-issue regroupe une opération SIMD avec une opération scalaire, pour profiter de la présence d'une ALU scalaire séparée de l'ALU SIMD. Les cartes graphiques modernes gérent la co-issue aisni, avec la possibilité de co-issue une opération scalaire et une opération vectorielle. L'opération scalaire est souvent une opération entière, éventuellement flottante. Les processeurs de shaders qui supportent de telles instructions sont un hybride entre VLIW et SIMD.

Sur les anciennes cartes graphiques disposant d'une unité SIMD, la co-issue fonctionnait différemment, car les unités de calcul entières n'étaient pas présentes. De plus, le faible budget en transistor faisait que l'on ajoutait pas d'unité flottante scalaire, ce qui ne servait pas à grand-chose. Par contre, l'unité de calcul transcendantale était systématiquement présente, car très utile. Aussi, une forme plus limitée de co-issue était possible : on pouvait exécuter une opération transcendantale en parallèle d'une instruction SIMD. Le cas le plus simple est le processeur de vertices de la Geforce 3, avec une unité SIMD et une unité transcendantale. Il n'y avait pas d'unité de calcul scalaire entière, ni même flottante.

Un autre 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/textures, avec deux unités de calcul. La première est une unité de calcul scalaire flottante, qui gère les opérations flottantes transcendantales. Elle travaille sur des opérandes de 32 bits. À côté, on trouve une unité de calcul SIMD, qui gère des vecteurs de 4 nombres flottants 32 bits. Elle permet de faire des additions, des multiplications, des opérations MAD, des produits vectoriels, et quelques autres opérations comme le calcul du maximum/minimum de deux nombres. Il n'y a aucune gestion d'instructions flottantes scalaires ou d'opérations entières, la carte graphique est trop ancienne pour cela.

 
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.

Le processeur de pixel shader de la même carte graphique était lui aussi un hybride entre VLIW et SIMD. Cependant, il lorgnait plus du côté VLIW que SIMD. Il pouvait faire soit une opération SIMD le vecteur, soit couper le vecteur en deux et effectuer deux opérations différentes sur chaque morceau. Par exemple, il pouvait faire une opération sur 3 pixels, et une opération scalaire sur le quatrième, ou deux opérations vectoriels chacune sur deux pixels. Le processeur travaille sur des blocs de 4 pixels, appelés des quads. Chaque pixel est codé avec 4 flottants 32 bits, cela fait en tout 4 vecteurs de 4 flottants, avec co-issue à l'intérieur de chaque vecteur. Précisons que les registres temporaires du processeur mémorisent chacun un vecteur de 4 flottants, un pixel, par un quad.

Niveau unités de calcul, le tout était assez complexe. Il contenait tout d'abord une unité de texture, et plusieurs unités VLIW/SIMD. Elles sont appelées "unités SIMD" dans les schémas qui vont suivre, mais elles sont en réalité un mix entre unité SIMD véritable et unités scalaires du VLIW. La documentation NVIDIA elle-même parle d'unité vectorielle, mais le terme est quelque peu trompeur, car elles sont plus flexibles que de simples unités SIMD.

Il y en a deux, la première envoyant son résultat à la seconde. La première est capable de faire des opérations de multiplications/MAD, mais elle peut aussi être utilisée pour la correction de perspective grâce à son support des opérations 1/x. Elle peut aussi normaliser des nombres flottants sur 16 bits. Il faut noter que la première unité SIMD/VLIW ne peut pas être utilisée si un accès mémoire/texture est en cours. La seconde unité SIMD/VLIW est capable de faire des opérations de MAD, et un produit vectoriel/scalaire DOT4.

Le résultat de la seconde ALU est ensuite envoyé à une unité de branchement qui décide s'il faut ré-exécuter une autre passe d'instructions ou non. Il s'agit vraisemblablement d'une unité qui gère la prédication des résultats. Une fois le fragment/pixel final calculé, il est envoyé à une unité de calcul du brouillard, qui est une unité de calcul spécialisée travaillant sur des nombres entiers (en réalité, en virgule fixe, mais c'est pareil).

 
Processeur de pixel shader de la Geforce 6800

Le multithreading matériel

modifier

Les processeurs de shader sont connectés à une mémoire vidéo très lente, avec un temps d'accès élevé, qui se rattrape avec un débit binaire important. La conséquence est qu'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. Et cs derniers ne peuvent pas recourir à des techniques avancées communes sur les CPU, comme l’exécution dans le désordre : le cout en circuit serait trop important. Fort heureusement, les processeurs de shaders disposent d'optimisations pour remplir ces temps d'attente avec des calculs indépendants de la texture lue. A la place, ils utilisent du multithreading matériel.

Le multithreading matériel vient du monde des processeurs, où cette technique a été appliquée à de nombreuses reprises. Vous connaissez sans doute l'hyperthreading d'Intel ? C'est une version du multithreading matériel. L'idée était d'exécuter plusieurs programmes en même temps sur le même processeur, le processeur commutant de l'un à l'autre suivant les besoins. L'idée est que si un thread est bloqué par un accès mémoire, d'autres threads exécutent des calculs dans l'unité de calcul en parallèle de l'accès mémoire.

Les programmes en question étaient appelés des threads, mais c'était des threads d'instructions non-SIMD. Or, un thread d'instructions SIMD est un warp, ni plus ni moins, les mêmes techniques peuvent s'appliquer. L'idée est de permettre à un processeur de shader d'exécuter plusieurs warps sur le même processeur, mais pas exactement en même temps, le processeur commutant régulièrement d'un warp à l'autre. L'idée est encore une fois que si un thread lit une texture, d'autres threads exécutent des calculs dans l'unité de calcul en parallèle de l'accès mémoire.

Le Fine Grained Multithreading pur

modifier

Le Fine Grained Multithreading (FGMT) change de programme/thread/warp à chaque cycle d'horloge. L'avantage principal est que l'implémentation matérielle est très simple.

 
Fine Grained Multithreading.

De tels processeurs permettent de masquer la latence des accès mémoire, sous conditions. Par exemple, imaginons que les lectures prennent 8 cycles. Si le processeur gère 8 threads et en change à chaque cycle, alors deux instructions d'un même programme seront espacées de 8 cycles de distance. Le résultat de la lecture est disponible immédiatement pour l'instruction suivante, sans avoir à bloquer le pipeline.

Un défaut est que le FGMT demande d'utiliser énormément de registres. En effet, prenons le cas d'un processeur sans FGMT avec 8 registres (ce qui est peu). Maintenant, ajoutons un support du FGTM : les registres devront être dupliqués pour que chaque thread ait ses propres registres rien qu'à lui. Sans cela, impossible de passer rapidement d'un thread à l'autre à chaque cycle. Un processeur capable de gérer 128 threads devra multiplier ses registres par 128 ! Ca vous parait beaucoup ? Sachez qu'un processeur de shader peut exécuter entre 16 et 32 thrads/warps, ce qui multiplie le nombre de registres par 16/32. Et chaque thread dispose de pas mal de registres, pour des raisons de performances, qui sont eux-même dupliqués. Les processeurs de shaders modernes ont bien 32 à 64 kilioctets de registres Et les cartes graphiques modernes ayant plusieurs processeurs de shaders ont facilement entre 32768 et 65536 registres de 32 bits, ce qui est énorme !

Le Fine Grained Multithreading à émission dans l'ordre

modifier

Après, impossible de gérer des accès mémoire d'une centaine de cycles avec cette méthode : il faudrait plusieurs centaines de threads. Du FGMT capable de gérer 16/32 warps simultannés ne permet que de gérer des accès mémoires de 16/32 cycles, ce qui est bien en-deça des 150 à 200 cycles d'un accès mémoire sur un GPU.

Une autre forme de Fine Grained Multithreading permet au processeur s'exécuter un même thread durant plusieurs cycles avant d'en changer. La techniques est beaucoup utilisée sur les cartes graphiques modernes, et sur certains processeurs spécialisés. Elle permet de mieux masquer les accès mémoire.

 
Full multithreading.

Un exemple historique assez ancien est le processeur Tera MTA (MultiThreaded Architecture), qui introduit la technique de l'anticipation de dépendances explicite (Explicit-Dependence lookahead). L'idée est que chaque instruction contient quelques bits pour dire au processeur : tu peux lancer 1, 2, 3 instructions à la suite sans problème. Et elle est utilisée sur les cartes graphiques depuis la sortie des cartes graphiques NVIDIA de microarchitecture Kepler.

Une autre méthode, elle aussi utilisée sur les cartes graphiques, est de séparer le processeur de shader en deux sections séparées par une mémoire tampon qui met en attente les instructions SIMD. Les instructions SIMD sont chargées et décodées, puis sont placées dans une file d'instruction. Là, elles attendent leur tour, le temps que leurs opérandes soient disponibles, que les données à manipuler soient enfin disponibles. Une unité d'émission vérifie à chaque cycle si chaque instruction est prête à s'exécuter. Les instructions prêtes sont soit envoyées aux unités de calcul, soit attendent leur tour. Les instructions qui ont besoin d'un résultat pas encore calculé ou lu depuis la mémoire attendent juste.

 
Instruction buffer et SMT

Il faut noter que le chargement des instructions depuis la mémoire et leur exécution dans les unités de calcul se font en même temps : le processeur charge des instructions pendant qu'il en exécute d'autres. Ce fonctionnement dit en pipeline a de nombreuses conséquences peu intuitives. Et le pipeline des processeurs de shaders est en réalité encore plus compliqué que ça. Le chargement d'une instruction et son décodage prend facilement entre 2 et 20 d'étapes d'un cycle chacune, pareil pour son exécution, et j'en passe.

Si un thread accède à la mémoire, il est mis en pause et le processeur ne l'exécute plus tant que l'accès mémoire n'est pas terminé. Mais les autres threads continuent de s'exécuter sur le processeur. Et si plusieurs threads sont en pause, ils sont retirés du pool de thread chargés à chaque cycle. L'avantage est que si un thread est mis en pause par un accès mémoire, les autres threads récupèrent les cycles d'horloge du thread mis en pause. Par exemple, sur un processeur qui gère 16 threads concurrents, si l'un d'eux est mis en pause, le processeur changera de thread tous les 15 cycles au lieu de 16. Ou encore, un thread aura droit à deux cycles consécutifs pour s’exécuter.

Mais pour cela, il faut que ces derniers aient des instructions en attente dans la file d'instruction, sans quoi la technique marche nettement moins bien. Le processeur doit avoir chargé assez d'instructions en avance, il faut que chaque thread ait assez de réserve pour exécuter plusieurs instructions consécutives. En soi, le découplage du chargement et de l'exécution est nécessaire pour cela. L'unité de chargement lit des instructions depuis le cache d'instruction, elle change de thread à chaque cycle ou presque. Il arrive qu'elle charge plusieurs instructions consécutives d'un même thread, mais cela ne dure que quelques cycles d'horloge. Pareil pour l'unité d'émission : soit elle change de thread à chaque cycle, soit elle peut envoyer aux ALU plusieurs instructions consécutives d'un même thread, tout dépend du GPU considéré.

De plus, il faut ajouter au processeur une unité d'émission pour vérifier si deux instructions consécutives peuvent s'exécuter de manière consécutive, pour détecter les dépendances entre instructions. Pour les connaisseurs, l'unité d'émission est généralement très simple, basée sur un scoreboard simplifié (un vecteur de registre), qui ne gère que l’exécution dans l'ordre. Mais le grand nombre de registres et de threads fait que le scoreboard devient rapidement impraticable, même s'il est censé être très simple sur le principe. Aussi, une implémentation alternative est utilisée, bien que les détails ne soient pas connus à ce jour. Quelques brevets donnent des détails d'implémentation,; mais on ne sait pas s'ils sont à jour. Les curieux peuvent tenter de lire le brevet américain numéro #7,634,621, nommé "Register File Allocation", déposé par NVIDIA durant décembre 2009.


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.

L'espace d'adressage est l'ensemble des adresses géré par le processeur ou la carte graphique. En théorie, l'espace d'adressage du processeur et de la carte graphique sont séparés, ils ne communiquent pas entre eux. Mais des standards comme l'Heterogeneous System Architecture permettent au processeur et à une carte graphique de partager le même espace d'adressage. Une adresse mémoire est donc la même que ce soit pour le processeur ou la carte graphique.

Mémoire vidéo dédiée Mémoire vidéo unifiée
Sans HSA    
Avec HSA    

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.

Les GPUs récents contiennent aussi des caches tout court qui ne sont spécialisés dans les textures ou vertices. De plus, les caches sont complétés par des Local Store, des mémoires normales de petite taille. Elles sont gérés par le logiciel, le programmeur, alors que les caches sont gérés par des circuits de contrôle qui s'occupent du chargement ou de l'éviction des données du cache. Elles servent donc de cache géré par le logiciel, si on peut dire. Un GPU moderne dispose de plusieurs local store : au moins par cœur.

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.

La mémoire vidéo est très lente

modifier

La mémoire vidéo a donc un débit très élevé. Mais par contre, elle a un temps d'accès très lent. Concrètement, cela veut dire qu'un accès mémoire va prendre beaucoup de temps. Par exemple, si je veux lire une texture, entre le moment où j'envoie une demande de lecture à la mémoire vidéo, et le moment celle-ci me renvoie les premiers texels, il va se passer entre 200 à 1000 cycles d'horloge processeur. Par contre, une fois les premiers texels reçus, les texels suivants sont disponibles au cycle suivant, et ainsi de suite. En clair, les données lues mettent du temps avant d'arriver, mais elles arrivent par gros paquets une fois ce temps d'attente passé.

La différence entre débit et temps d'accès est primordiale sur les GPU modernes comme anciens. Toute l'architecture de la carte graphique est conçue de manière à prendre en compte ce temps d'attente. Les techniques employées sont multiples, et ne sont pas inconnues à ceux qui ont déjà lu un cours d'architecture des ordinateurs : mémoire caches, hiérarchie de caches, multithrading matériel au niveau du processeur, optimisations des accès mémoire comme des Load-Store Queues larges, des coalesing write buffers, etc. Mais toutes ces techniques sont techniquement incorporées dans les processeurs de shaders et dans les circuits fixes. Aussi nous ne pouvons pas en parler dans ce chapitre. A une exception près : l'usage de caches et de local stores.

Les caches d'un GPU

modifier

Les cartes graphiques sont censées avoir peu de caches. Les anciennes cartes graphiques se débrouillaient avec 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. 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, capables de stocker n'importe quelle forme de données.

Les caches en question sont intégrés dans les processeurs de shaders sur les GPU modernes. Même les caches de texture ou de sommets. Les deux sont d'ailleurs fusionnés sur les GPU modernes, vu que leur jeu d'instruction est unifié et qu'ils peuvent exécuter aussi bien des vertex shaders que des pixel shaders. Sur les GPU plus anciens, avec des circuits fixes, ces caches étaient intégrés aux circuits non-programmables de gestion des textures et de la géométrie. Les caches de sommet et de texture étaient alors séparés.

Le cache de textures

modifier

Le cache de textures, comme son nom l'indique, est un cache spécialisé dans les textures. Toutes les cartes graphiques modernes disposent de plusieurs unités de texture, qui disposent chacune de son ou ses propres caches de textures. Pas de cache partagé, ce serait peu utile et trop compliqué à implémenter.

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.

 
Partage des caches sur un processeur multicoeurs

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. Les shaders sont donc des programmes assez légers, qui ont peu d'instructions. Les caches d'instructions L1 sont généralement assez petits, généralement quelques dizaines ou centaines de kilooctets. Et malgré cela, il n'est pas rare qu'un shader tienne tout entier dans le cache d'instruction, 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. Les processeurs de shaders ont beaucoup de registres, ce qui fait que 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.

La mémoire partagée : un local store

modifier

En plus d'utiliser des caches, les GPU modernes utilisent des local stores, aussi appelés scratchpad memories. Ce sont des mémoires RAM intermédiaires entre la RAM principale et les registres. Ces local stores peuvent être vus comme des caches, mais que le programmeur doit gérer manuellement. Dans la réalité, ce sont des mémoires RAM très rapides mais de petite taille, qui sont adressées comme n'importe quelle mémoire RAM, en utilisant des adresses directement.

 
Scratch-Pad-Memory (SPM).

Sur les GPU modernes, chaque processeur de shader possède un unique local store, appelée la mémoire partagée. Il n'y a pas de hiérarchie des local store, similaire à la hiérarchie des caches.

 
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és hors du rendu 3D, pour les applications de type GPGPU, où le GPU est utilisé comme architecture multicœurs pour du calcul scientifique.

L'implémentation des local store

modifier

Vous vous attendez certainement à ce que je dise que les local store sont des mémoires séparées des mémoires caches et qu'il y a réellement des puces de mémoire RAM distinctes dans les processeurs de shaders. Mais en réalité, ce n'est pas le cas pour tous les local store. Le dernier niveau de local store, la mémoire partagée, est bel et bien une mémoire SRAM à part des autres, avec ses propres circuits. Mais les cartes graphiques très récentes fusionnent la mémoire locale 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.

Et si vous vous demandez comment c'est possible de fusionner un cache et une mémoire RAM, voici comment le tout est implémenté. L'implémentation consiste à couper le cache en deux circuits, dont l'un est un local store, et l'autre transforme le local store en cache. Ce genre de cache séparé en deux mémoires est appelé un phased cache, pour ceux qui veulent en savoir plus, et ce genre de cache est parfois utilisés sur les processeurs modernes, dans des processeurs dédiés à l'embarqué ou pour certaines applications spécifiques.

Le premier circuit vérifie la présence des données à lire/écrire dans le cache. Lors d'un accès mémoire, il reçoit l'adresse mémoire à lire, et détermine si une copie de la donnée associée est dans le cache ou non. Pour cela, il utilise un système de tags qu'on ne détaillera pas ici, mais qui donne son nom à l'unité de vérification : l'unité de tag. Son implémentation est très variable suivant le cache considéré, mais une simple mémoire RAM suffit généralement.

En plus de l'unité de tags, il y a une mémoire qui stocke les données, la mémoire cache proprement dite. Par simplicité, cette mémoire est une simple mémoire RAM adressable avec des adresses mémoires des plus normales, chaque ligne de cache correspondant à une adresse. La mémoire RAM de données en question n'est autre que le local store. En clair, le cache s'obtient en combinant un local store avec un circuit qui s'occupe de vérifier de vérifier les succès ou défaut de cache, et qui éventuellement identifie la position de la donnée dans le cache.

 
Phased cache

Pour que le tout puisse servir alternativement de local store ou de cache, on doit contourner ou non l'unité de tags. Lors d'un accès au cache, on envoie l'adresse à lire/écrire à l'unité de tags. Lors d'un accès au local store, on envoie l'adresse directement sur la mémoire RAM de données, sans intervention de l'unité de tags. Le contournement est d'autant plus simple que les adresses pour le local store sont distinctes des adresses de la mémoire vidéo, les espaces d'adressage ne sont pas les mêmes, les instructions utilisées pour lire/écrire dans ces deux mémoires sont aussi potentiellement différentes.

 
Hydride cache - local store

Il faut préciser que cette organisation en phased cache est assez naturelle. Les caches de texture utilisent cette organisation pour diverses raisons. Vu que cache L1 et cache de texture sont le même cache, il est naturel que les caches L1 et autres aient suivi le mouvement en conservant la même organisation. La transformation du cache L1 en hydride cache/local store était donc assez simple à implémenter et s'est donc faite facilement.


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.

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. La fonction principale du processeur de commande 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. 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. 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.

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. 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 processeur de commande que de répartir les calculs sur ces différentes unités.

 
Architecture de base d'une carte 3D - 1

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.

L'ordonnancement et la gestion des ressources

modifier

Le processeur de commande doit souvent mettre en pause certains circuits du pipeline, ainsi que les circuits précédents. Par exemple, 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.

Le pipelining des commandes

modifier

Dans les cartes graphiques les plus rudimentaires, le processeur de commande exécute les commandes dans l'ordre. Il exécute une commande çà la fois et attend qu'une commande soit terminé pour en lancer une autre. Plusieurs commandes sont accumulées dans le tampon de commande, le processeur de commande les traite de la plus ancienne vers la plus récente. Du moins, les anciennes cartes graphiques faisaient comme cela, les cartes graphiques modernes sont un peu plus complexes. Elles n'attendent pas qu'une commande soit totalement terminée avant d'en lancer une nouvelle. Les processeurs de commande actuels sont capables d’exécuter plusieurs commandes en même temps.

L'intérêt est un gain en performance assez important. Pour ceux qui ont déjà eu un cours d'architecture des ordinateurs avancé, lancer plusieurs commandes en même temps permet une forme de pipeline, dont les gains sont substantiels. Par exemple, imaginez qu'une commande de rendu 3D soit bien avancée et n'utilise que les processeurs de shaders et les ROP, mais laisse les unités géométriques libres. Il est alors possible de démarrer la commande suivante en avance, afin qu'elle remplisse les unités géométriques inutilisées. On a alors deux commandes en cours de traitement : une commande qui utilise la fin du pipeline uniquement, une autre qui utilise seulement le début du pipeline graphique.

Le processeur de commande gère donc plusieurs commandes simultanées, à savoir 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.

Une autre possibilité est de faire des rendus en parallèle. Par exemple, imaginez qu'on ait des circuits séparés pour le rendu 2D et 3D. Prenons l'exemple où le calcul d'une image finale demande de faire un rendu 3D suivi d'un rendu 2D, par exemple un jeu vidéo en 3D qui a un HUD dessiné en 2D. Dans ce cas, on peut démarrer le calcul de l'image suivante dans le pipeline 3D pendant que la précédente est dans les circuits 2D, au lieu de laisser inutilisé le pipeline 3D pendant le rendu 2D.

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.

La synchronisation de l’exécution des commandes avec le processeur

modifier

Il arrive que le processeur doive savoir où en est le traitement des commandes, ce qui est très utile pour la gestion de la mémoire vidéo.

Un premier exemple : comment éviter de saturer une carte graphique de commande, alors qu'on ne sait pas si elles traite les commandes rapidement ou non ? L'idéal est de regarder où en est la carte graphique dans l'exécution des commandes. Si elle n'en est qu'au début du tampon de commande et que celui-ci est bien remplit, alors il vaut mieux lever le pied et ne pas envoyer de nouvelles commandes. A l'inverse, si le tampon de commande est presque vide, envoyer des commandes est la meilleure idée possible.

Un autre exemple un peu moins parlant est celui de la gestion de la mémoire vidéo. Rappelons qu'elle est réalisée par le pilote de la carte graphique, sur le processeur principal, qui décide d'allouer et de libérer de la mémoire vidéo. Pour donner un exemple d'utilisation, imaginez que le pilote de la carte graphique doive libérer de la mémoire pour pouvoir ajouter une nouvelle commande. 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.

Il faut donc que la carte graphique trouve un moyen de prévenir le processeur que le traitement de telle commande est terminée, que telle commande en est rendue à tel ou tel point, etc. Pour cela, on a globalement deux grandes solutions. La première est celle des interruptions, abordées dans les chapitres précédents. Mais elles sont assez couteuses et ne sont utilisées que pour des évènements vraiment importants, critiques, qui demandent une réaction rapide du processeur. L'autre solution est celle du pooling, où le processeur monitore la carte graphique à intervalles réguliers. Deux solutions bien connues de ceux qui ont déjà lu un cours d'architecture des ordinateurs digne de ce nom, rien de bien neuf : la carte graphique communique avec le processeur comme tout périphérique.

Pour faciliter l'implémentation du pooling, la carte graphique contient des registres de statut, dans le processeur de commande, qui mémorisent tout ou partie de l'état de la carte graphique. Si un problème survient, certains bits du registre de statut seront mis à 1 pour indiquer que telle erreur bien précise a eu lieu. Il existe aussi un registre de statut qui mémorise le numéro de la commande en cours, ou de la dernière commande terminée, ce qui permet de savoir où en est la carte graphique dans l'exécution des commandes. Et certaines registres de statut sont dédiés à la gestion des commandes. Ils sont totalement programmable, la carte graphique peut écrire dedans sans problème.

Les cartes graphiques récentes incorporent des commandes pour modifier ces registres de statut au besoin. Elles sont appelées des commandes de synchronisation : les barrières (fences). Elles permettent d'écrire une valeur dans un registre de statut quand telle ou telle condition est remplie. Par exemple, si jamais une commande est terminée, on peut écrire la valeur 1 dans tel ou tel registre. La condition en question peut être assez complexe et se résumer à quelque chose du genre : "si jamais toutes les shaders ont fini de traiter tel ensemble de textures, alors écrit la valeur 1024 dans le registre de statut numéro 9".

La synchronisation de l’exécution des commandes intra-GPU

modifier

Lancer plusieurs commandes en parallèle dans le pipeline permet de gagner en performance.Mais cela a un gros défaut : il n'est pas garantit que les commandes se terminent dans l'ordre. Si on démarre une seconde commande après la première, il arrive que la seconde finisse avant. Pire : il n'est pas garantit qu'elles s'exécutent dans l'ordre. Dans la plupart des cas, cela ne pose pas de problèmes. Mais dans d'autres, cela donne des résultats erronés.

Pour donner un exemple, utilisons les commandes de rendu 2D vues plus haut, histoire de simplifier le tout. Imaginez que vous codez un jeu vidéo et que le rendu se fait en 2D, partons sur un jeu de type FPS. Vous avez une série de commandes pour calculer l'image à rendre à l'écran, suivie par une série de commandes finales pour dessiner le HUB. Pour dessiner le HUD, vous utilisez des commandes HOSTDATA_BLT, dont le but est d'écrire une chaîne de caractères à l'écran ou copier une série d'image bitmap dans la mémoire vidéo. Et bien ces commandes HOSTDATA_BLT doivent démarrer une fois que le rendu de l'image est complétement terminé. Imaginez que vous dessiniez le HUD sur une portion de l'image pas encore terminée et que celui-ci est effacé par des écritures ultérieures !

Pour éviter tout problème, les GPU incorporent des commandes de synchronisation intra-GPU, destinées au processeur de commande, aussis appelées des commandes de sémaphore. Elles permettent de mettre en pause le lancement d'une nouvelle commande tant que les précédentes ne sont pas totalement ou partiellement terminées. Les plus simples empêchent le démarrage d'une commande tant que les précédentes ne sont pas terminées. D'autres permettent de démarrer une commande si et seulement si certaines commandes sont terminées. D'autres sont encore plus précises : elles empêchent le démarrage d'une nouvelle commande tant qu'une commande précédente n'a pas atteint un certain stade de son exécution. Là encore, de telles commandes sont des commandes du style "attend que le registre de statut numéro N contienne la valeur adéquate avant de poursuivre l'exécution des commandes".

Les commandes en question peuvent être sur le même modèle que précédemment, à savoir des commandes qui lisent les registres de statut. Mais elles peuvent aussi se baser sur le fait que telle ou telle ressource de rendu est libre ou non. Des commandes successives 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. Avec ce modèle, les sémaphores bloquent une commande 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


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 T&L et les vertex shaders

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. 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 3D du point de vue de la caméra, et un autre qui corrige la perspective.

Un changement de coordonnée s'effectue assez 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. Mais oublions de détail.

L'étape de transformation est souvent fusionnée avec l'étape d'éclairage par sommet, les deux demandant de faire des calculs assez similaires. Sur PC, la geforce 256 a été la première à intégrer une unité non-programmable pour gérer transformation et éclairage, appelée unité de T&L (Transform & Lighting). L'unité de T&L incorporait un ou plusieurs circuits de multiplication de matrices spécialisés pour l'étape de transformation. Ils sont composés d'un gros paquet de multiplieurs et d'additionneurs flottants. 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.

L'unité de T&L est devenue programmable dès la Geforce 3, première carte graphique à supporter les vertex shaders. 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.


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. 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

En sortie de l'étage précédent, on n'a que des sommets éclairés et colorisés, pas des primitives. Et les sommets 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 (primitive assembly), qui regroupe les sommets appartenant au même triangle, à la même primitive. L'assemblage des primitives est réalisée par un circuit fixe, non-programmable, qui utilise le tampon d'indice pour regrouper les sommets en primitives.

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. 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.

 
Carte graphique en rendu immédiat

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, car ils manipulent les primitives fournies par l'étape d'assemblage des primitives. Mais le résultat fournit par les geometry shaders doit être retraité par l'assembleur de primitive.

En effet, j'ai menti plus haut en disant que les geometry shaders fournissent en entrée de 0 à plusieurs primitives : la sortie d'un geometry shader est un ensemble de sommets, non-regroupés en primitives. Le résultat est que l'assembleur de primitive doit refaire son travail après le passage d'un geometry shader, pour déterminer les primitives finales. Et il faut aussi refaire le culling, au cas où les primitives générées ne soient pas visibles depuis la caméra. Heureusement, la sortie d'un geometry shader est soit un point, soit une ligne, soit un triangle strip, ce qui simplifie la seconde phase d'assemblage des primitives.

Avec les geometry shaders, il y a donc deux phases d'assemblage des primitives : une phase avant, décrite dans la section précédente, et une seconde phase simplifiée après les geometry shaders. Il n'y a pas que la phase d'assemblage de primitives qui est dupliquée : le tampon de primitives l'est aussi. On trouve donc un tampon de primitives à l'entrée des geometry shaders et un autre à la sortie.

 
Implémentation matérielle des geometry shaders

L'implémentation des tampons de primitive est assez compliquée par la spécification des geometry shaders. Un geometry shader fournit un résultat très variable en fonction de ses entrées. Pour une même entrée, la sortie peut aller d'une simple primitive à plusieurs dizaines. Le geometry shader précise cependant un nombre limite de sommets qu'il ne peut pas dépasser en sortie. Il peut ainsi préciser qu'il ne sortira pas plus de 16 sommets, par exemple. Mais ce nombre est généralement très élevé, bien plus que la moyenne réelle du résultat du geometry shader.

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

La fonctionnalité de stream output

modifier

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, 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.

L'étape de rastérisation contient plusieurs étapes distinctes, que nous allons voir dans ce chapitre. C'est lors de cette phase que la perspective est gérée, en fonction de la position de la caméra. Diverses opérations de clipping et de culling, qui éliminent les triangles non-visibles à l'écran, se font aussi après la rasterization ou pendant.

La quasi-totalité des cartes graphiques récentes incorporent un circuit de zastérization, appelé le rasterizeur. Les seules exceptions sont les cartes graphiques très anciennes, mais aussi certaines cartes graphiques intégrées des processeurs Intel datant des années 2010. De nos jours, aucune carte graphique, même bas de gamme ou intégrée, n'est dans ce cas.

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 view frustum qui est 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 ROPs, 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. L'implémentation peut utiliser soit deux unités de profondeur, soit une seule unité partagée. La geforce 6800 a apparemment opté pour la seconde solution.

 
GeForce 6800

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 coordonnées de texture

modifier

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é. Il faut alors texturer le triangle. Pour les pixels situés exactement sur les sommets, on peut reprendre la coordonnée de texture et la profondeur du sommet associé. Mais pour les autres pixels, nous sommes obligés d'extrapoler les coordonnées 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, sans prendre en compte la coordonnée de profondeur. Par contre, en faisant cela, la perspective n'est pas correctement rendue, comme illustré ci-dessous. L'interpolation affine était utilisée sur la console Playstation 1 de Sony, d'où des textures un peu bizarres sur cette console.

 
Correction de perspective.

D'autres consoles utilisaient l'interpolation affine, mais s'en sortaient mieux car elles utilisaient non pas des triangles, mais des quads (des 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 inverse

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. 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

L'implémentation du mip-mapping

modifier

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.

La gestion des accès mémoire

modifier

Enfin, l'unité de texture doit tenir compte du fait que la mémoire vidéo met du temps à lire une texture. En théorie, l'unité de texture ne devrait pas accepter de nouvelle demande de lecture tant que celle en cours n'est pas terminée. Mais faire ainsi demanderait de bloquer tout le pipeline, de l'input assembler au unités deshaders, ce qui est tout sauf pratique et nuirait grandement aux performances.

Une solution alternative consiste à mettre en attente les demandes de lectures de texture pendant que la mémoire est occupée. 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. Cela implique que les accès mémoire s’exécutent dans l'ordre demandé par le shader et/ou l'unité de rastérisation, 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.

 
Accès mémoire simultanés.

Évidemment, quand la mémoire FIFO est pleine, le pipeline est alors totalement bloqué. Le rasteriser est prévenu que l'unité de texture ne peut pas accepter de nouvelle lecture de texture. En pratique, la FIFO est généralement d'une taille respectable et permet de mettre en attente beaucoup de demandes de lecture de texture. Il faut de plus noter qu'il y a une FIFO par processeur de shader sur les cartes graphiques modernes. Quand elle est pleine, le processeur cesse d'exécuter de nouveaux accès mémoire, mais peut continuer à exécuter des shaders dans les autres unités de calcul, pas besoin de bloquer complétement le pipeline.

L'intégration du cache de textures

modifier

Il faut noter que les unités de texture incorporent aussi un cache de texture, voire plusieurs. L'intégration des caches de texture avec la mémoire FIFO précédente est quelque peu compliqué, car il faut garantir que les lectures de texture se fassent dans le bon ordre. On ne peut pas exécuter une lecture dans le cache alors que des lectures précédentes sont en attente de lecture en mémoire vidéo. Et cela pose un gros problème : une lecture dans le cache de texture prend quelques dizaines de cycles d'horloge, alors qu'une lecture en mémoire vidéo en prend facilement 400 à 800 cycles, parfois plus. Et cela fait que l'ordre des accès mémoire peut s'inverser.

Prenons par exemple un accès au cache précédé et suivi par deux accès en mémoire vidéo. Le premier démarre au cycle 1, et se termine au cycle numéro 400. L'accès au cache commence au cycle 2 et se termine 20 cycles après, au cycle numéro 22. En clair, la lecture dans le cache s'est terminée avant l'accès mémoire qui le précède. Les textures ne sont donc plus lues dans l'ordre. Et il faut trouver une solution pour éviter cela.

La solution est de retarder les lectures dans le cache tant que tous les accès précédents ne sont pas terminés. Mais pour retarder les lectures en question, il faut d'abord savoir si la lecture atterrit dans le cache ou non, ce qui demande d'accéder au cache. On fait face à un dilemme : on veut retarder les accès au cache, mais les différencier des lectures déclenchant des accès mémoire demande d'accéder au cache en premier lieu. La solution est décrite dans l'article "Prefetching in a Texture Cache Architecture" par Igehy et ses collègues. Elle se base sur deux idées combinées ensemble.

La première idée est de séparer l'accès au cache en deux : une étape qui vérifie si les texels à lire sont dans le cache, et une étape qui accède aux données dans le cache lui-même. Un cache de texture est donc composé de deux circuits principaux. Le premier vérifie la présence des texels dans le cache. Il reçoit l'adresse mémoire à lire, et détermine si une copie de la donnée associée est dans le cache ou non. Pour cela, il utilise un système de tags qu'on ne détaillera pas ici, mais qui donne son nom à l'unité de vérification : l'unité de tag. Ensuite, en plus de l'unité de tags, il y a une mémoire qui stocke les données, la mémoire cache proprement dite. Par simplicité, cette mémoire est une simple mémoire RAM adressable avec des adresses mémoires des plus normales, chaque ligne de cache correspondant à une adresse. Ce genre de cache séparé en deux mémoires est appelé un phased cache, pour ceux qui veulent en savoir plus.

 
Phased cache

La seconde idée est de retarder l'accès au cache entre les deux phases. La première étape d'un accès mémoire vérifie si la donnée est dans le cache ou non. Puis, on retarde la lecture des données, pour attendre que toutes les lectures précédentes soient terminées. Et enfin, troisième étape : la lecture des texels dans la mémoire cache proprement dite. Les accès mémoire passant par la mémoire vidéo se font de la même manière, à une différence près : la lecture dans le cache est remplacée par la lecture en mémoire vidéo. Tout démarre avec une demande à l'unité de tags, qui vérifie si le texel est dans le cache ou non. Puis on retarde l'accès tant que la mémoire vidéo est occupée, puis on effectue la lecture en mémoire vidéo.

Si ce n'est pas le cas, l'accès mémoire est envoyé à la mémoire vidéo comme précédemment, à savoir qu'il est mis en attente dans une mémoire FIFO, puis envoyé à la mémoire vidéo dès que celle-ci est libre. Mais en sortie de la mémoire, la donnée lue est envoyée dans le cache de texture, par dans l'unité de filtrage. Pour savoir où placer la donnée lue, l'unité de tag a réservé une ligne de cache précise, une adresse bien précise. L'adresse en question est disponible en lisant une autre mémoire FIFO, qui a mis en attente l'adresse en question, en attendant que l'accès mémoire se termine. La donnée est alors écrite dans le cache, puis lue par l'unité de filtrage de textures.

Pour une lecture dans le cache, le déroulement est similaire, mais sans le passage par la mémoire. La lecture fait une demande à l'unité de tag, et celle-ci répond que la donnée est bien dans le cache. Elle place alors l'adresse à lire dans la file d'attente. Une fois que les accès mémoire précédents sont terminés, l'adresse sort de la file d'attente et est envoyée à la mémoire de données. La lecture s'effectue, les texels sont envoyés à l'unité de filtrage de textures. La seule différence avec un phased cache normal est l'insertion de l'adresse à lire dans une FIFO qui vise à mettre en attente

 
Unité de texture avec un cache de texture

Pour résumer, l'implémentation précédente garantit une exécution des lectures dans leur ordre d'arrivée. Et pour cela, elle retarde les lectures dans le cache tant que les lectures en mémoire précédentes ne sont pas terminées. L'accès au cache est plus rapide que l'accès en mémoire vidéo, mais le retard ajouté pour garantir l'ordre des lectures fait que le temps d'accès est très long.

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 Raster Operations Pipeline (ROP), aussi appelé Render Output Target, situé à la toute fin du pipeline graphique. Dans ce qui suit, nous utiliserons l'abréviation ROP pour simplifier les explications. Le ROP effectue quelques traitements sur les fragments, avant d'enregistrer l'image finale dans la mémoire vidéo.

Les fonctions des ROP

modifier

Les ROP incorporent plusieurs fonctionnalités qui sont assez diverses. Leur seul lien est qu'il est préférable de les implémenter en matériel plutôt qu'en logiciel, et en-dehors des unités de textures. Il s'agit de fonctionnalités assez simples, basiques, mais nécessaires au fonctionnement de tout rendu 3D. Elles ont aussi pour particularité de beaucoup accéder à la mémoire vidéo. C'est la raison pour laquelle le ROP est situé en fin de pipeline, proche de la mémoire vidéo. Voyons quelles sont ces fonctionnalités.

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. Et il s'agit là d'un exemple simple, mais il est fréquent qu'un objet soit caché par plusieurs objets. En moyenne, un objet est caché par 3 à 4 objets dans un rendu 3d de jeu vidéo.

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 coordonnée z est un nombre qui est d'autant plus petit que l'objet est près de l'écran. 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.

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.
 
Z-buffer correspondant à un rendu

Pour savoir quels fragments sont à éliminer (car cachés par d'autres), la carte graphique utilise 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, allant de 16 bits pour les anciennes cartes graphiques, à 24/32 bits pour les cartes plus récentes. 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

La gestion de la transparence : test alpha et alpha blending

modifier

En premier lieu, les ROPs s'occupent de la gestion de 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. La gestion de la transparence par les ROP est le fait de plusieurs fonctionnalités distinctes, les deux principales étant le test alpha et l'alpha blending.

L'alpha test est une technique qui permet d'annuler le rendu d'un fragment en fonction de sa transparence. Si la composante alpha est en-dessous d'un seuil, le fragment est simplement abandonné. Chaque fragment passe une étape de test alpha qui vérifie si la valeur alpha est au-dessus de ce seuil ou non. S'il ne passe pas le test, le fragment est abandonné, il ne passe pas à l'étape de test de profondeur, ni aux étapes suivantes. Il s'agit d'une technique binaire de gestion de la transparence, qui est complétée par d'autres techniques. De nos jours, cette technologie est devenue obsolète.

Elle optimisait le rendu de textures où les pixels sont soit totalement opaques, soit totalement transparents. Un exemple est le rendu du feuillage dans un jeu 3D : on a une texture de feuille plaquée sur un rectangle, les portions vertes étant totalement opaques et le reste étant totalement transparent. L'avantage est que cela évitait de mettre à jour le tampon de profondeur pour des fragments totalement transparents.

Maintenant, le test alpha ne permet pas de gérer des situations où on voit quelque chose à travers un objet 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. On parle alors d'alpha blending.

 
Application de textures.

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 d'alpha blending sont effectuées par un circuit spécialisé qui travaille en parallèle des circuits de calcul de la profondeur.

Il faut noter que le rendu de la transparence se marie assez mal avec l'usage d'un tampon de profondeur. Le tampon de profondeur marche très bien quand on a des fragments totalement opaques : il a juste à mémoriser la coordonnée z du pixel le plus proche. Mais avec des fragments transparents, les choses sont plus compliquées, car plusieurs fragments sont censés être visibles, et on ne sait pas quelle coordonnée z stocker. L'interaction entre profondeur et transparence est réglée par diverses techniques. Avec l'alpha blending, c'est la cordonnée du fragment le plus proche qui est mémorisée dans le tampon de profondeur.

Le tampon de stencil

modifier

Le stencil est une fonctionnalité des API graphiques et des cartes graphiques depuis déjà très longtemps. Il sert pour générer des effets graphiques très variés, qu'il serait vain de résumer ici. Il a notamment été utilisé pour combattre le phénomène de z-fighting mentionné plus haut, il est utilisé pour calculer des ombres volumétriques (le moteur de DOOM 3 en faisait grand usage à la base), des réflexions simples, des lightmaps ou shadowmaps, et bien d'autres.

Pour le résumer, on peut le voir comme une sorte de tampon de profondeur programmable, dans lequel on peut remplacer la coordonnée de profondeur par autre chose. La technique du z-buffer associe une coordonnée de profondeur à chaque pixel. Pour chaque fragment, elle compare la coordonnée z du fragment avec celle dans le z-buffer, et met à jour le z-buffer en fonction du résultat. Et bien la technique du stencil fait pareil, sauf qu'elle remplace la coordonnée z par une valeur arbitraire, dont le programmeur peut faire ce qu'il veut. La valeur est de plus une valeur entière, pas flottante. Et la mise à jour du z-buffer est elle aussi plus complexe, il ne s'agit pas d'un simple remplacement par la coordonnée z adéquate.

L'idée est que chaque pixel/fragment se voit attribuer une valeur entière, généralement codée sur un octet, que les programmeurs peuvent faire varier à loisir. L'octet ajouté est appelé l'octet de stencil. L'octet a une certaine valeur, qui est calculée par la carte graphique au fur et à mesure que les fragments sont traités. Il ne remplace pas la coordonnée de profondeur, mais s'ajoute à celle-ci.

L'ensemble des octets de stencil est mémorisée dans un tableau en mémoire vidéo, avec un octet par pixel du framebuffer. Le tableau porte le nom de tampon de stencil. Il s'agit d'un tableau distinct du tampon de profondeur ou du tampon de couleur, du moins en théorie. Dans les faits, les techniques liées au tampon de stencil font souvent usage du tampon de profondeur, pour beaucoup d'effets graphiques avancés. Aussi, le tampon de stencil est souvent fusionné avec le tampon de profondeur. L'ensemble forme un tableau qui associe 32 bits à chaque" pixel : 24 bits pour une coordonnée z, 8 pour l'octet de stencil.

Chaque fragment a sa propre valeur de stencil qui est calculée par la carte graphique, généralement par les shaders. Lors du passage d'un fragment les ROPs, la carte graphique lit le pixel associé dans le tampon de stencil. Puis il compare l'octet de stencil avec celui du fragment traité. Si le test échoue, le fragment ne passe pas à l'étape de test de profondeur et est abandonné. S'il passe, le tampon de stencil est mis à jour.

Par mis à jour, on veut dire que le ROP peut faire diverses manipulations dessus : l'incrémenter,le décrémenter, le mettre à O, inverser ses bits, remplacer par l'octet de stencil du fragment, etc. Les opérations possibles sont bien plus nombreuses qu'avec le tampon de profondeur, qui se contente de remplacer la coordonnée z par celle du fragment. C'est toujours possible, on peut remplacer l'octet de stencil dans le tampon de stencil par celui du fragment s'il passe le test. Mais pour les techniques de rendu plus complexes, c'est une autre opération qui est utilisée, comme incrémenter l'octet dans le tampon de stencil.

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 Silent Hill), mais ils ont surtout été utilisés pour économiser des calculs. L'idée est de ne pas calculer les graphismes au-delà d'une certaine distance, sans que cela se voie.

L'idée est d'avoir un view frustum limité : le plan limite au-delà duquel on ne voit pas les objets est assez proche de la caméra. Mais si le plan limite est trop proche, cela donnera une cassure inesthétique dans le rendu. 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.

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.

Notons que ce calcul implique à la fois de l'alpha blending mais aussi la coordonnée de profondeur, ce qui en fait que son implémentation dans les ROPs est l'idéal. Aussi, 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ée. Vu que les pixels shaders peuvent s'en charger, cela fait moins de circuits dans les ROPs pour un cout en performance mineur. Et ce d'autant plus que les effets de brouillard sont devenus assez rares de nos jours. Autant les émuler dans les pixels shaders que d'utiliser des circuits pour une fonction devenue anecdotique.

Les autres fonctions des ROPs

modifier

Les ROPs gèrent aussi des techniques de dithering, qui permettent d'adoucir des images lorsqu'elles sont redimensionnées et stockées avec une précision plus faible que la précision de calcul.

Les ROPS implémentent aussi des techniques utilisées sur les blitters des anciennes cartes d'affichage 2D, comme l'application d'opérations logiques sur chaque pixel enregistré dans le framebuffer. Les opérations logiques en question peuvent prendre une à deux opérandes. Les opérandes sont soit un pixel lu dans le framebuffer, soit un fragment envoyé au ROP. Les opérations logiques à une opérande peuvent inverser, mettre à 0 ou à 1 le pixel dans le framebuffer, ou faire la même chose sur le fragment envoyé en opérande. Les opérations à deux opérandes lisent un pixel dans le framebuffer, et font un ET/OU/XOR avec le fragment opérande (une des deux opérandes peut être inversée). Elles sont utilisées pour faire du traitement d'image ou du rendu 2D, rarement pour du rendu 3D.

Les ROPs gèrent aussi des masques d'écritures, qui permettent de décider si un pixel doit être écrit ou non en mémoire. Il est possible d'inhiber certaines écritures dans le tampon de profondeur ou le tampon de couleur, éventuellement le tampon de stencil. Inhiber la mise à jour d'un pixel dans le tampon de profondeur est utile pour gérer la transparence. Si un pixel est transparent, même partiellement, il ne faut pas mettre à jour le tampon de profondeur, et cela peut être géré par ce système de masquage. Les masquages des couleurs permettent de ne modifier qu'une seule composante R/G/B au lieu de modifier les trois en même temps, pour faire certains effets visuels.

L'architecture matérielle d'un ROP

modifier

Les ROP contiennent des circuits pour gérer la profondeur des fragments. Il effectuent un test de profondeur, à savoir que les fragments correspondant à un même pixel sont comparés pour savoir lequel est devant l'autre. Ils contiennent aussi des circuits pour gérer la transparence des fragments. Le ROP gère aussi l'antialiasing, de concert avec l'unité de rastérisation. D'autres fonctionnalités annexes sont parfois implémentées dans les ROP. Par exemple, les vielles cartes graphiques implémentaient les effets de brouillards dans les ROPs. Le tout est suivi d'une unité qui enregistre le résultat final en mémoire, où masques et opérations logiques sont appliqués.

Les différentes opérations du ROP doivent se faire dans un certain ordre. Par exemple, gérer la transparence demande que les calculs de profondeur se fassent globalement après ou pendant l'alpha blending. Ou encore, les masques et opérations logiques se font à la toute fin du rendu. L'ordre des opérations est censé être le suivant : test alpha, test du stencil, test de profondeur, alpha blending. Du moins, la carte graphique doit donner l'impression que c'est le cas. Elle peut optimiser le tout en traitant le tampon de profondeur, de couleur et de stencil en même temps, mais donner les résultats adéquats.

 
Render Output Pipeline-processor
 
R.O.P des GeForce 6800.

Un ROP est typiquement organisé comme illustré ci-dessous et 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).

Notons que les circuits de gestion de la profondeur et de la transparence sont séparés dans les schémas ci-contre et ci-dessous. Il s'agit là d'une commodité qui ne reflète pas forcément l'implémentation matérielle. Et si ces deux circuits sont séparés, ils communiquent entre eux, notamment pour gérer la profondeur des fragments transparents.

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.

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.

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.

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.


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 plaquage 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 l'antialising 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.


Le support matériel du 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.

 
Raycasting, rayon simple.

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 Catacomb, utilisaient cette méthode de rendu, mais dans un univers en deux dimensions. Cet article explique bien cela : Le moteur de rendu de Wolfenstein 3D. 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.

La cohérence des rayons

modifier

Les rayons primaires, émis depuis la caméra, sont proches les uns des autres, et vont tous dans le même sens. Mais ce n'est pas le cas pour des rayons secondaires. Les objets d'une scène 3D ont rarement des surfaces planes, mais sont composés d'un tas de triangles qui font un angle entre eux. La conséquence est que deux rayons secondaires émis depuis deux triangles voisins peuvent aller dans des directions très différentes. Ils vont donc intersecter des objets très différents, atterrir sur des sources de lumières différentes, etc. De tels rayons sont dits incohérents, en opposition aux rayons cohérents qui sont des rayons proches qui vont dans la même direction.

Les rayons incohérents sont peu fréquents avec le rayctracing récursif basique, mais deviennent plus courants avec des techniques avancées comme le path tracing ou les techniques d'illumination globale. En soi, la présende de rayons incohérents n'est pas un problème et est parfaitement normale. Le problème est que le traitement des rayons incohérent est plus lent que pour les rayons cohérents. Le traitement de deux rayons secondaires voisins demande d'accéder à des données différentes, ce qui donne des accès mémoire très différents et très éloignés. On ne peut pas profiter des mémoires caches ou des optimisations de la hiérarchie mémoire, si on les traite consécutivement. Un autre défaut est qu'une même instance de pixels shaders va traiter plusieurs rayons en même temps, grâce au SIMD. Mais les branchements n'auront pas les mêmes résultats d'un rayon à l'autre, et cette divergence entrainera des opérations de masquage et de branchement très couteuses.

Pour éviter cela, les GPU modernes permettent de trier les rayons en fonction de leur direction et de leur origine. Ils traitent les rayons non dans l'ordre usuel, mais essayent de regrouper des rayons proches qui vont dans la même direction, et de les traiter ensemble, en parallèle, ou l'un après l'autre. Cela ne change rien au rendu final, qui traite les rayons en parallèle. Ainsi, les données chargées dans le cache par le premier rayon seront celles utilisées pour les rayons suivants, ce qui donne un gain de performance appréciable. De plus, cela évite d'utiliser des branchements dans les pixels shaders. Les méthodes de tri de rayons sont nombreuses, aussi en faire une liste exhaustive serait assez long, sans compter qu'on ne sait pas quelles sont celles implémentées en hardware, ni commetn elles le sont.

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 et le tri 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.

Les cartes graphiques modernes incorporent des circuits de tri pour regrouper les rayons cohérents, ceux qui vont dans la même direction et proviennent de triangles proches. Ce afin d'implémenter les optimisations vues plus haut.

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.

Cette unité peut aussi modifier les BVH à la volée, si jamais la scène 3D change. Par exemple, si un objet change de place, comme un NPC qui se déplace, il faut reconstruire le BVH. Les cartes graphiques récentes évitent de reconstruire le BVH de zéro, mais se contentent de modifier ce qui a changé dans le BVH. Les performances en sont nettement meilleures.

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 : [2].

  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.