Les cartes graphiques/Version imprimable
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
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
modifierVous 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.
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
modifierAu 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
modifierLes 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.
Un historique rapide des cartes graphiques
modifierLes 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
modifierUne 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.
La carte graphique communique via un bus, un vulgaire tas de fils qui connectent la carte graphique à la carte mère. Les premières cartes graphiques utilisaient un bus nommé ISA, qui fût rapidement remplacé par le bus PCI, plus rapide. Viennent ensuite le bus AGP, puis le bus PCI-Express. Ce bus est géré par un contrôleur de bus, un circuit qui se charge d'envoyer ou de réceptionner les données sur le bus. Les circuits de communication avec le bus permettent à l'ordinateur de communiquer avec la carte graphique, via le bus PCI-Express, AGP, PCI ou autre. Il contient quelques registres dans lesquels le processeur pourra écrire ou lire, afin de lui envoyer des ordres du style : j'envoie une donnée, transmission terminée, je ne suis pas prêt à recevoir les données que tu veux m'envoyer, etc. Il y a peu à dire sur ce circuit, aussi nous allons nous concentrer sur les autres circuits.
Le circuit d'interfaçage électrique se contente de convertir les signaux de la carte graphique en signaux que l'on peut envoyer à l'écran. Il s'occupe notamment de convertir les tensions et courants : si l'écran demande des signaux de 5 Volts mais que la carte graphique fonctionne avec du 3,3 Volt, il y a une conversion à faire. De même, le circuit d'interfaçage électrique peut s'occuper de la conversion des signaux numériques vers de l'analogique. L'écran peut avoir une entrée analogique, surtout s'il est assez ancien.
Les anciens écrans CRT ne comprenaient que des données analogiques et pas le binaire, alors que c'est l'inverse pour la carte graphique, ce qui fait que le circuit d'interfaçage devait faire la conversion. La conversion était réalisée par un circuit qui traduit des données numériques (ici, du binaire) en données analogiques : le convertisseur numérique-analogique ou DAC (Digital-to-Analogue Converter). Au tout début, le circuit d’interfaçage était un DAC combiné avec des circuits annexes, ce qu'on appelle un RAMDAC (Random Access Memory Digital-to-Analog Converter). De nos jours, les écrans comprennent le binaire sous réserve qu'il soit codé suivant le standard adapté et les cartes graphiques n'ont plus besoin de RAMDAC.
Il y a peu à dire sur les circuits d'interfaçage. Leur conception et leur fonctionnement dépendent beaucoup du standard utilisé. Sans compter qu'expliquer leur fonctionnement demande de faire de l'électronique pure et dure, ce qui est rarement agréable pour le commun des mortels. Par contre, étudier le circuit de contrôle et la mémoire vidéo est beaucoup plus intéressant. On peut donner quelques généralités particulièrement utiles sur ces deux circuits, qui forment le cœur de la carte d'affichage. Aussi, les deux sections qui suivent seront consacrées à la mémoire vidéo et au circuit de contrôle.
Le 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
modifierTout 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
modifierAvec 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.
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.
Le standard RGB et ses dérivés
modifierLa 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
modifierLe 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
modifierPour 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.
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
modifierPour 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
modifierSur 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
modifierPour é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.
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
modifierLors 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é.
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
modifierDiverses 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.
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
modifierLa 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
modifierSur 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
modifierSur 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
modifierLes 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
modifierUne 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.
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 finit 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 finit 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
modifierUne 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.
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.
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
modifierUn é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
modifierL'é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.
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.
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).
La fréquence de rafraichissement
modifierMê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
modifierLe 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
modifierUn 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.
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.
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.
L'interface du VDC du point de vue du processeur
modifierPour 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
modifierLa 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
modifierLe 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
modifierLe 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
modifierLes 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
modifierAfficher 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
modifierSi 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.
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
modifierLes 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.
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
modifierLes 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
modifierLe 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
modifierReprenons 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.
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.
La génération de l'adresse à envoyer au framebuffer
modifierLe 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.
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
modifierUne 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
modifierUne 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.
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.
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
modifierLes Video Shifters incorporent juste de quoi générer les signaux de commande. Les CRTC font de même, mais incorporent ausis 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.
Les fonctionnalités avancées des VIC
modifierIl arrive qu'ils 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.
Il a existé des VIC qui n'avaient rien à voir avec le travail d'une carte graphique. Prenons par exemple le VDP 7360/8360 TExt Display (TED). Il était un VIC capable de gérer mode texte et graphique, avec une résolution allant jusqu’au 320 × 200, avec gestion de couleur intégrée, 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).
Un exemple : le NEC μPD7220
modifierUn 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, une sorte de stylet pour écrans CRT, aujourd'hui disparus. Beaucoup de VIC incorporaient cette fonctionnalité, très fréquente pour l'époque.
Les co-processeurs intégrés au VDC
modifierLes Video Display Co-Processor sont des VIC intègrent un processeur, éventuellement connecté à sa propre mémoire RAM. Le processeur permet d'afficher certains effets graphiques, comme du rendu 2D ou 3D. Mais il peut aussi servir pour des fonctionnalités plus basiques. Dns ce qui suit, nous parlerons de VDC à co-processeur pour parler des Video Display Co-Processor.
Un VDC à co-processeur lit une série d'instructions dans la mémoire RAM. 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. La liste d'instruction s'appelle la display list, terme qui reviendra dans quelques chapitres.
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.
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
modifierLes 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
modifierLes 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.
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.
Les sprites matériels
modifierIl 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
modifierSur 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.
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 stockage des sprites et de l’arrière-plan : les tiles
modifierLes 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.
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.
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.
L'accélération matérielle du curseur de souris
modifierLe 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 !
L'accélération matérielle du défilement
modifierLe 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
modifierUne 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.
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
modifierLa 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
modifierPour 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.
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.
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
modifierLa 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
modifierL'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.
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
modifierPassons 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
modifierOutre 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
modifierL'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
modifierUn 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
modifierD'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
modifierPour 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
modifierEn 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.
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.
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
modifierParadoxalement, 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.
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.
La table des caractères
modifierLes 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.
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
modifierLe 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.
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.
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
modifierFaire 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
modifierLa 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.
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
modifierUne 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.
Les cartes accélératrices 3D
Le premier jeu à utiliser de la "vraie" 3D fût le jeu Quake, premier du nom. Et depuis sa sortie, la grande majorité des jeux vidéo utilisent de la 3D, même s'il existe encore quelques jeux en 2D. Face à la prolifération des jeux vidéo en 3D, les fabricants de cartes graphiques ont inventé les cartes accélératrices 3D, des cartes vidéo capables d'accélérer le rendu en 3D. Dans ce chapitre, nous allons voir comment elles fonctionnent et comment elles ont évolué dans le temps. Pour comprendre comment celles-ci fonctionnent, il faut faire quelques rapides rappels sur les bases du rendu 3D.
Les bases du rendu 3D
modifierUne 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
modifierLes objets placés dans la scène 3D sont composés de formes de base, combinées les unes aux autres pour former des objets complexes. En théorie, les formes géométriques en question peuvent être n'importe quoi : des triangles, des carrés, des courbes de Béziers, etc. En général, on utilise des polygones par simplicité, les autres solutions étant peu pratiques et plus complexes. Dans la quasi-totalité des jeux vidéo actuels, les objets sont modélisés par un assemblage de triangles collés les uns aux autres, ce qui porte le nom de maillage, (mesh en anglais). Il a été tenté dans le passé d'utiliser des carrés/rectangles (rendu dit en quad) ou d'autres polygones, mais les contraintes techniques ont fait que ces solutions n'ont pas été retenues.
Les modèles 3D sont définis par leurs sommets, aussi appelés vertices dans le domaine du rendu 3D. Chaque sommet possède trois coordonnées, qui indiquent sa position dans la scène 3D : abscisse, ordonnée, profondeur. Un triangle est donc composé de 9 coordonnées, 3 par sommet. Ensuite, les sommets sont reliés entre eux. Un segment qui connecte une paire de sommets s'appelle une arête, comme en géométrie élémentaire. Si plusieurs arêtes délimitent une surface fermée, celle-ci est appelée une face, ou encore une primitive. Je le répète, mais les seules faces actuellement utilisées en rendu 3D sont les triangles. Quand plusieurs faces sont sur un même plan, elles forment un "polygone", bien que le terme ne soit utilisé comme en mathématique. Un assemblage de plusieurs "polygones" donne une surface. Dans la suite du cours, nous parlerons surtout des sommets et des primitives.
La représentation exacte d'un objet est donc une liste plus ou moins structurée de sommets, pouvant contenir une certaine redondance ou des informations en plus. La liste doit préciser les coordonnées de chaque sommet, ainsi que comment les relier pour former des triangles. Pour cela, l'objet est représenté par une structure qui contient la liste des sommets, mais aussi de quoi savoir quels sont les sommets reliés entre eux par un segment. Nous en dirons plus dans le chapitre sur le rendu de la géométrie.
Les textures
modifierTout 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.
Notons que les textures sont des images comme les autres, codées pixel par pixel. Pour faire la différence entre les pixels de l'écran et les pixels d'une texture, on appelle ces derniers des texels. Ce terme est assez important, aussi profitez-en pour le mémoriser, nous le réutiliserons dans quelques chapitres.
Un autre point lié au fait que les textures sont des images est leur compression, leur format. N'allez pas croire que les textures sont stockées dans un fichier .jpg, .png ou tout autre format de ce genre. Les textures utilisent des formats spécialisés, comme le DXTC1, le S3TC ou d'autres, plus adaptés à leur rôle de texture. Mais qu'il s'agisse d'images normales (.jpg, .png ou autres) ou de textures, toutes sont compressées. Les textures sont compressées pour prendre moins de mémoire. Songez que la compression de texture est terriblement efficace, souvent capable de diviser par 6 la mémoire occupée par une texture. S'en est au point où les textures restent compressées sur le disque dur, mais aussi dans la mémoire vidéo ! Nous en reparlerons dans le chapitre sur la mémoire d'une carte graphique.
Le placage de textures
modifierPlaquer une texture sur un objet peut se faire de deux manières. La première part d'une texture et associe un sommet à chaque texel. Mais ce n'est pas la technique utilisée actuellement. De nos jours, on fait l'inverse : on attribue un texel à chaque sommet. Le nom donné à cette technique de description des coordonnées de texture s'appelle l'UV Mapping, ou encore de placage de texture inverse.
Avec l'UV Mapping, chaque sommet est associé à des coordonnées de texture, qui précisent quelle texture appliquer, mais aussi où se situe le texel à appliquer dans la texture. Par exemple, la coordonnée de texture peut dire : je veux le pixel qui est à ligne 5, colonne 27 dans cette texture. La correspondance entre texture et géométrie est réalisée lorsque les créateurs de jeu vidéo conçoivent le modèle de l'objet.
Dans les faits, on n'utilise pas de coordonnées entières de ce type, mais deux nombres flottants compris entre 0 et 1. La coordonnée 0,0 correspond au texel en bas à gauche, celui de coordonnée 1,1 est tout en haut à droite. L'avantage est que ces coordonnées sont indépendantes de la résolution de la texture, ce qui aura des avantages pour certaines techniques de rendu, comme le mip-mapping. Les deux coordonnées de texture sont notées u,v avec DirectX, ou encore s,t dans le cas général : u est la coordonnée horizontale, v la verticale.
Le placage de texture inverse est opposé à une technique qui part d'une texture et associe un sommet à chaque texel. Elle s'appelle le forward texture mapping, ce qui peut se traduire par placage de texture direct. Nous n'en parlerons pas beaucoup, car la majorité des cartes graphiques utilise le placage de texture inverse, mais quelques rares cartes graphiques ont utilisé du placage de texture direct. C'était le cas sur la console 3DO, la Sega Saturn, maiss aussi sur la toute première carte graphique de NVIDIA (la NV1).
La caméra : le point de vue depuis l'écran
modifierOutre 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.
Ce qui est potentiellement visible du point de vue de la caméra est localisé dans un volume, situé entre le plan de l'écran et le plan limite, appelé le view frustum. Suivant la perspective utilisée, ce volume n'a pas la même forme. Avec la perspective usuelle, le view frustum ressemble à un trapèze en trois dimensions, délimité par plusieurs faces attachées au bords de l'écran et au plan limite. Le tout est parfois appelée, bien que par abus de langage, la pyramide de vision. Avec d'autres perspectives moins utilisées, le view frustum est un pavé, mais nous n'en parlerons pas plus dans le cadre de ce cours car elles ne sont presque pas utilisés dans les jeux vidéos actuels.
Le culling et le clipping
modifierUn 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.
La première forme de culling est le view frustum culling, dont le nom indique qu'il s'agit de l'élimination de tout ce qui est situé en-dehors du view frustum. Elle fait que ce qui est en-dehors du champ de vision de la caméra n'est pas affiché à l'écran n'est pas calculé ou rendu, dans une certaine mesure. Les autres formes de culling visent à éliminer ce qui est dans le view frustum, mais qui n'est pas visible depuis la caméra. Par exemple, il se peut que certains objets situés dans le view frustum ne soient pas visibles ou alors seulement partiellement. Pensez à des objets cachés par un autre objet plus proche, par exemple. Ou encore, pensez aux faces à l'arrière d'un objet opaque qui sont cachées par l'avant. ces deux cas correspondent à deux types de culling. L'élimination des objets masqués par d'autres est appelé l'occlusion culling. L'élimination des parties arrières d'un objet est appelé le back-face culling. Enfin, certains objets qui sont trop loin ne sont tout simplement pas calculés et remplacé par du brouillard ou un autre artefact graphique, voire pas remplacé du tout.
Pour résumer, les portions de la scène 3D qui ne sont pas visibles à l'écran ne doivent pas être calculées. Les cartes graphiques embarquent divers méthodes de culling pour abandonner les calculs quand elles s’aperçoivent que ceux-ci portent sur une partie non-affichée de l'image. Cela fait des économies de puissance de calcul assez appréciables et un gain en performance assez important. Précisons que le culling peut être plus ou moins précoce suivant le type de rendu 3D utilisé, mais nous verrons cela dans la suite du chapitre.
Les effets de brouillard
modifierLes effets de brouillard sont des effets graphiques assez intéressants. Ils sont nécessaires dans certains jeux vidéo pour l'ambiance (pensez à des jeux d'horreur comme Siletn Hill), mais ils ont surtout été utilisés pour économiser des calculs. Sur les vieux jeux vidéo, notamment sur d'anciennes consoles de jeux, les effets de brouillards étaient utilisés pour ne pas calculer les graphismes au-delà d'une certaine distance.
L'idée est de réduire la taille du view frustum, en faisant en sorte que le plan limite au-delà duquel on ne voit pas les objets soit assez proche de la caméra. Mais si le plan limite est trop proche, cela donnera une cassure dans le rendu qui se verra et sera non seulement inesthétique, mais pourrait aussi passer pour un bug. Pour masquer cette cassure, les programmeurs ajoutaient un effet de brouillard. Les objets au-delà du plan limite étaient totalement dans le brouillard, puis ce brouillard se réduisait progressivement en se rapprochant de la caméra, avant de s'annuler à partir d'une certaine distance.
La rastérisation et le pipeline graphique
modifierLes techniques de rendu 3D sont nombreuses, mais on peut les classer en deux grands types : le lancer de rayons et la rasterization. Sans décrire les deux techniques, sachez cependant que le lancer de rayon n'est pas beaucoup utilisé pour les jeux vidéo. Il est surtout utilisé dans la production de films d'animation, d'effets spéciaux, ou d'autres rendu spéciaux. Dans les jeux vidéos, il est surtout utilisé pour quelques effets graphiques, la rasterization restant le mode de rendu principal.
La raison est que le lancer de rayons demande beaucoup de puissance de calcul, sans compter que créer des cartes accélératrices pour le lancer de rayons n'est pas simple. Il a existé des cartes accélératrices permettant d'accélérer le rendu en lancer de rayons, mais elles sont restées confidentielles. Les cartes graphiques modernes incorporent quelques circuits pour accélérer le lancer de rayons, mais ils restent marginaux des compléments au rendu par rastérization. Tout cela explique que les jeux vidéo utilisent la rasterization.
La rasterization calcule une scène 3D intégralement, avant de faire des transformations pour n'afficher que ce qu'il faut à l'écran. Le calcul d'une image rendue en 3D passe par une suite d'étapes consécutives qui doivent s'enchainer dans un ordre bien précis. L'ensemble de ces étapes est appelé le pipeline graphique. Précisons cependant que la succession d'étape que nous allons voir n'est pas aussi stricte que vous pouvez le penser. En fait, il n'existe pas un pipeline graphique unique : chaque API 3D fait à sa sauce, le matériel ne respecte pas forcément cette succession d'étapes, et j'en passe. Cependant, toutes les API 3D modernes sont organisées plus ou moins de la même manière, ce qui fait que le pipeline que nous allons décrire colle assez bien avec les logiciels 3D anciens et modernes, ainsi qu'avec l'organisation des cartes graphiques (anciennes ou modernes).
Le cas le plus simple ne demande que trois étapes :
- une étape purement logicielle, effectuée par le processeur, où le moteur physique calcule la géométrie de la scène 3D ;
- une étape de traitement de la géométrie, qui gère tout ce qui a trait aux sommets et triangles ;
- une étape de rastérisation qui effectue beaucoup de traitements différents, mais dont le principal est d'attribuer chaque triangle à un pixel donné de l'écran.
Voyons cela plus en détail. Précisons avant toute chose que nous allons omettre certaines étapes facultatives, afin de simplifier l’exposé. Par exemple, nous n'allons pas parler de l'étape de tesselation, qui permet de rajouter de la géométrie, ce qui sert à déformer les objets ou à augmenter leur réalisme.
Le traitement de la géométrie
modifierLe traitement de la géométrie se fait en plusieurs étapes.
La première étape est appelée étape de transformation effectue plusieurs changements de coordonnées pour chaque sommet.
- Premièrement, elle place les objets au bon endroit dans la scène 3D, ce qui demande de mettre à jour les coordonnées de chaque sommet de chaque modèle. C'est la première étape de calcul : l'étape de transformation des modèles 3D.
- Ensuite, elle effectue un changement de coordonnées pour centrer l'univers sur la caméra, dans la direction du regard. C'est l'étape de transformation de la caméra.
- Ensuite, une correction de la perspective est effectuée, qui corrige la forme du view frustum : c'est l'étape de projection.
Lors de la seconde étape de traitement de la géométrie, les sommets sont éclairés dans une phase d'éclairage. Lors de cette phase, tout sommet se voit attribuer une couleur, qui définit son niveau de luminosité. Celui-ci indique si le sommet est éclairé ou est dans l'ombre.
Éventuellement, diverses opérations de culling sont ensuite effectuées à la fin de cette étape, notamment le clipping et le view frustum-culling.
Après toutes ces étapes, une fois que les sommets ont tous étés traités, ils sont assemblés en primitives, c'est à dire en triangles ou en carrés/rectangles. L'assemblage est absolument nécessaire pour la prochaine étape du pipeline graphique, qui manipule des primitives. Il s'agit de l'étape d'assemblage de primitives (primitive assembly).
L'étape de rastérisation
modifierL'étape de rastérisation effectue beaucoup de choses différentes, même pour faire un rendu basique. Elle fait le lien entre triangle et pixels, diverses opérations d'interpolation, plaque les textures, effectues divers tests de visibilité, mélange les couleurs, écrit dans le framebuffer.
Mais l'idée structurante est que rastérisation et placage de textures sont deux opérations très liées entre elles, et nous devons les voir en même temps. Nous avons dit plus haut qu'il existait deux manières de lier les textures à la géométrie : la méthode directe et la méthode inverse (UV Mapping). Et les deux font que la rastérisation se fait de manière très différente.
L'étape de rastérisation avec placage de texture inverse
modifierAvec le placage de texture inverse, la rastérisation se fait grosso-modo en trois étapes : la rastérisation proprement dite, le placage de textures, et les opérations finales. Au niveau du matériel, ainsi que dans la plupart des API 3D, les trois étapes sont réalisées par des circuits séparés. Cette séparation en trois étapes est vraiment importante. Elle est commune à tous les API 3D modernes, en plus d'avoir une certaine logique. Après, les trois étapes en question sont elles-mêmes subdivisées en plusieurs étapes distinctes, qui s'enchainent dans un ordre bien précis.
Pour simplifier, la première étape s'occupe la traduction des formes (triangles) rendues dans une scène 3D en un affichage pixelisé à l'écran. Elle détermine à quoi ressemble la scène visible sur l'écran. C'est aussi lors de cette étape que sont appliquées certaines techniques de culling, qui éliminent les portions non-visibles de l'image, ainsi qu'une correction de la perspective et diverses opérations d'interpolation dont nous parlerons dans plusieurs chapitres. Mais le principal n'est pas là.
Lors de cette étape, chaque pixel de l'écran se voit attribuer un ou plusieurs triangle(s). Pour mieux comprendre quels triangles sont associés à tel pixel, imaginez une ligne droite qui part de caméra et qui passe par un pixel sur le plan de l'écran. Cette ligne intersecte 0, 1 ou plusieurs objets dans la scène 3D. Les triangles situés ces intersections entre cette ligne et les objets rencontrés seront associés au pixel correspondant.
La rastérisation attribue un sommet à un sommet, on peut en déduire directement quelle texture appliquer sur ce pixel, quel texel. La rastérisation associe un triangle à chaque pixel, les coordonnées de texture des sommets de ce triangle sont aussi transférées au pixel. Une fois que la carte graphique sait quelles sont les coordonnées de texture associée au pixel, elle lit la texture avec ces coordonnées pour colorier le pixel. On associe donc un texel à chaque pixel, on travaille pixel par pixel.
Après l'étape de placage de textures, la carte graphique enregistre le résultat en mémoire. Lors de cette étape, divers traitements de post-processing sont effectués et divers effets peuvent être ajoutés à l'image. Uun effet de brouillard peut être ajouté, des tests de profondeur sont effectués pour éliminer certains pixels cachés, l'antialiasing est ajouté, on gère les effets de transparence, etc. Un chapitre entier sera dédié à ces opérations.
La rastérisation avec placage de texture direct
modifierLa rastérisation avec placage de texture direct se fait d'une manière totalement différente. Avec elle, on n'associe pas une texture à un pixel, mais on fait l'inverse. Pour cela, la carte graphique parcourt les textures texel par texel. Pour chaque texel, elle récupère les coordonnées du sommet associé, puis détermine quel pixel est associé. Le placage de texture se fait donc en même temps que la rastérisation, ou avant, suivant comme on l'interprète.
L'avantage de cette technique est qu'on parcourt les textures dans un ordre bien précis. Par exemple, on peut parcourir la texture ligne par ligne, l'exploiter par blocs de 4*4 pixels, etc. Et accéder à une texture de manière prédictible se marie bien avec l'usage de mémoires caches, ce qui est un avantage en matière de performances. De plus, on est certain que chaque texture est accédée une seule fois lors du rendu, alors que la rastérisation accède à une même texture plusieurs fois.
Mais le désavantage est que certains texels qui n'auraient pas été rendus avec la rastérisation sont parcours et testés. De plus, déterminer quel pixel est associé à quel sommet est beaucoup plus complexe. Le clipping et le culling sont aussi plus complexes à implémenter.
Les API 3D
modifierDe 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
modifierL'API ne fait pas que fournir des morceaux de code que les programmeurs peuvent utiliser. Elles fournissent des contraintes et des règles de programmation assez importantes. Notamment, elles décrivent le pipeline graphique à utiliser. Pour résumer, le pipeline graphique comprend plusieurs étapes : plusieurs étapes de traitement de la géométrie, une phase de rastérisation, puis plusieurs étapes de traitement des pixels. Une API 3D comme DirectX ou OpenGl décrète quelles sont les étapes à faire, ce qu'elles font, et l'ordre dans lesquelles il faut les exécuter. Cela signifie que le pipeline graphique varie suivant l'API. Par exemple les API récentes supportent certaines étapes comme l'étape de tesselation, qui sont absentes des API anciennes.
Pour donner un exemple, je vais prendre l'exemple d'OpenGL 1.0, une des premières version d'OpenGL, aujourd'hui totalement obsolète. Le pipeline d'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.
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.
L'implémentation peut être logicielle ou matérielle
modifierLa mise en œuvre d'une API 3D peut se faire en logiciel, en matériel ou les deux. En théorie, elles peuvent être implémentées en logiciel, le processeur peut faire tout le travail et envoyer le résultat à une carte d'affichage. Et c'était le cas avant l'invention des premières cartes accélératrices 3D. Le rôle des API 3D était de fournir des morceaux de code et un pipeline graphique, afin de simplifier le travail des développeurs, pas de déporter des calculs sur une carte accélératrice 3D.
D'ailleurs, OpenGl et Direct X sont apparues avant que les premières cartes graphiques grand public soient inventées. Par exemple, les accélérateurs 3D sont arrivés sur le marché quelques mois après la toute première version de Direct X, et Microsoft n'avait pas prévu le coup. OpenGL était lui encore plus ancien et ne servait pas initialement pour les jeux vidéos, mais pour la production d'images de synthèses et dans des applications industrielles (conception assistée par ordinateur, imagerie médicale, autres). OpenGL était l'API plébiscitée à l'époque, car elle était déjà bien implantée dans le domaine industriel, la compatibilité avec les différents OS de l'époque était très bonne, mais aussi car elle était assez simple à programmer.
Un tournant dans l'évolution d'OpenGL fut la sortie du jeu Quake, d'IdSoftware. Celui-ci pouvait fonctionner en rendu logiciel, mais le programmeur responsable du moteur 3D (le fameux John Carmack) ajouta une version OpenGL du jeu. Le fait que le jeu était programmé sur une station de travail compatible avec OpenGL faisait que ce choix n'était si stupide, même si aucune carte accélératrice de l'époque ne supportait OpenGL. C'était là un choix qui se révéla visionnaire. En théorie, le rendu par OpenGL aurait dû se faire intégralement en logiciel, sauf sur quelques rares stations de travail adaptées. Mais 3dfx ajouta un support minimal d'OpenGL, contenant uniquement les fonctionnalités nécessaires pour Quake. John Carmack le dit lui-même, dans le readme.txt de GLQuake :
« | |
Theoretically, glquake will run on any compliant OpenGL that supports the texture objects extensions, but unless it is very powerfull hardware that accelerates everything needed, the game play will not be acceptable. If it has to go through any software emulation paths, the performance will likely by well under one frame per second.
At this time (march ’97), the only standard opengl hardware that can play glquake reasonably is an intergraph realizm, which is a VERY expensive card. 3dlabs has been improving their performance significantly, but with the available drivers it still isn’t good enough to play. Some of the current 3dlabs drivers for glint and permedia boards can also crash NT when exiting from a full screen run, so I don’t recommend running glquake on 3dlabs hardware. 3dfx has provided an opengl32.dll that implements everything glquake needs, but it is not a full opengl implementation. Other opengl applications are very unlikely to work with it, so consider it basically a “glquake driver”. | |
» | |
Par la suite, de nombreux autres fabricants de cartes 3D firent la même chose que 3dfx. OpenGL et Direct 3D étaient partiellement implémentées en logiciel, car les cartes graphiques ne supportaient pas toutes les fonctionnalités de l'API. C'était l'époque du miniGL, des implémentations partielles d'OpenGL, fournies par les fabricants de cartes 3D, implémentées dans les pilotes de périphériques de ces dernières. Les fonctionnalités d'OpenGL implémentées dans ces pilotes étaient presque toutes exécutées en matériel, par la carte graphique. Un cap fût franchi avec la sortie de la fameuse Geforce 256, qui incorpora la phase de T&L (Transform & Lighting) d'OpenGL directement en hardware. La phase de traitement géométrique d'OpenGl était réalisée directement par la carte graphique, au lieu de l'être en logiciel.
Avec l'évolution du matériel, les pilotes de périphériques devinrent de plus en plus complets, au point de devenir des implémentations totales d'OpenGL. De nos jours, les API sont surtout implémentées en matériel, la quasi-totalité des fonctionnalités des API étant implémentées soit avec des shaders, soit avec des circuits non-programmables.
Mais au-delà d'OpenGL, chaque fabricant de carte graphique avait sa propre API propriétaire, qui était gérée par leurs pilotes de périphériques (drivers). Par exemple, les premières cartes graphiques de 3dfx interactive, les fameuses voodoo, disposaient de leur propre API graphique, l'API Glide. Elle facilitait la gestion de la géométrie et des textures, ce qui collait bien avec l'architecture de ces cartes 3D. Mais ces API propriétaires tombèrent rapidement en désuétude avec l'évolution de DirectX et d'OpenGL.
L'API impose des contraintes sur le matériel
modifierPlus haut, j'ai dit que les API imposent un ordonnancement précis, décrit par les étapes du pipeline. Le matériel doit respecter cet ordonnancement et faire comme si tout le pipeline graphique décrit par l'API était respecté. Je dis faire comme si, car il se peut que le matériel ne respecte pas cet ordre à la lettre. Il le fera dans les grandes lignes, mais il arrive que la carte graphique fasse certaines opérations en avance, comparé à l'ordre imposé par l'API, pour des raisons de performance. Typiquement, effectuer du culling ou les tests de profondeur plus tôt permet d'éviter de rejeter de nombreux pixels invisibles à l'écran, et donc d'éliminer beaucoup de calculs inutiles. Mais la carte graphique doit cependant corriger le tout de manière à ce que pour le programmeur, tout se passe comme l'API 3D l'ordonne. Les écarts à l'ordonnancement de l'API sont corrigés d'une manière ou une autre par le hardware, par des circuits spécialisés de remise en ordre.
De manière générale, sans même se limiter à l'ordonnancement des étapes du pipeline graphique, les règles imposées par les API 3D sont des contraintes fortes, qui contraignent les cartes graphiques dans ce qu'elles peuvent faire. De nombreuses optimisations sont rendues impossibles à cause des contraintes des API 3D.
L'architecture d'une carte 3D
modifierToute carte graphique contient obligatoirement des circuits essentiels qui ne sont pas liés au rendu 3D proprement dit, mais sont nécessaires pour qu'elle fonctionne : de la mémoire vidéo, des circuits de communication avec le bus, des circuits d’interfaçage avec l'écran, et d'autres circuits. À cela, il faut ajouter les circuits pour le rendu 3D proprement dit, qui comprennent un ensemble hétérogène de circuits aux fonctions fort différentes. Les circuits non-liés au rendu 3D sont globalement les mêmes que pour une carte d'affichage 2D, aussi nous n'en reparlerons pas ici et nous allons nous concentrer sur les circuits du pipeline graphique.
Les architectures en mode immédiat et en tiles
modifierIl existe deux types de cartes graphiques : celles en mode immédiat, et celles avec un rendu en tiles. Les deux ont sensiblement les mêmes circuits, à quelques différences près, mais elles les utilisent d'une manière fort différente. Dans la suite de ce cours, nous parlerons surtout des cartes graphiques en mode immédiat, les architectures en tile étant reléguées à la fin du cours. Nous faisons ainsi car les cartes graphiques des ordinateurs de bureau ou portables sont toutes en mode immédiat, alors que les cartes graphiques des appareils mobiles, smartphones et autres équipements embarqués ont un rendu en tiles.
Les raisons à cela sont multiples. La première est que les architectures en tiles sont considérées comme moins performantes que celles en mode immédiat, et elles sont d'autant moins performantes que la géométrie de la scène 3D est complexe. Les architectures en tiles sont donc utilisées pour les équipements où la performance n'est pas une priorité, comme les appareils mobiles, alors que le rendu en mode immédiat est utilisé pour les ordinateurs performants (de bureau ou portable). Par contre, le rendu en tiles est plus simple, plus facile à implémenter en matériel. Une autre caractéristique est un avantage du rendu en tile pour le rendu en 2D, comparé aux architectures en mode immédiat, qui se marie bien aux besoins des smartphones et autres objets connectés.
Mais expliquons donc quelle est la différence entre ces deux types de cartes graphiques. Avec le rendu en tiles, la géométrie est intégralement rendue avant de faire la rastérisation. De plus, la rastérisation découpe l'écran en l'écran en carrés/rectangles, appelés des tiles, qui sont rendus séparément, les unes après les autres. Une carte graphique en tile calcule toute la géométrie, mémorise le résultat du rendu géométrique en mémoire vidéo, découpe l'écran en tiles qui sont rastérisées et texturées une par une. Cette forme de rendu est compatible avec la séparation du pipeline en deux étapes : une étape de traitement géométrique, et une étape de rastérisation proprement dite. Mais les cartes graphiques en mode immédiat ne font pas cela : la rastérisation se fait pixel par pixel, pas par tiles.
Pour comprendre quelles sont les implications en termes de performance, nous allons séparer la carte graphique en deux circuits : un qui gère la géométrie, et un qui gère la rastérisation au sens large. Nous allons les appeler l'unité géométrique et le rastériseur, respectivement. Les deux lisent ou écrivent des données dans la mémoire vidéo. L'unité géométrique lit en mémoire vidéo la scène 3D, qui est mémorisée dans un tampon de sommets. De même, les textures sont lues dans la mémoire vidéo. Enfin, une fois qu'on a finit de calculer un pixel, il doit être enregistré dans le framebuffer. Cela est vrai pour les cartes graphiques en rendu immédiat et en rendu à tiles. Les cartes graphiques ajoutent souvent des mémoires caches pour la géométrie et les textures, afin de rendre leur accès plus rapide. Le tout est illustré ci-dessous. Nous n'avons pas parlé de la manière dont communiquent le rastériseur et l'unité géométrique, volontairement, car c'est là que se situe la différence entre les deux types de cartes 3D.
Avec les cartes graphiques en mode immédiat, l'unité géométrique envoie ses résultats à l'unité de rastérisation, sans passer par la mémoire. Et là, un premier problème survient. Un triangle dans une scène 3D correspond généralement à plusieurs pixels. Ce qui fait qu'il y a un déséquilibre entre géométrie et rastérisation : la rastérisation prend plus de temps de calcul que la géométrie. Une conséquence est qu'il arrive fréquemment que le circuit de rastérisation soit occupé, alors que l'unité de géométrie veut lui envoyer des données. Dans ce cas, on doit mettre en attente les résultats géométriques dans une mémoire tampon, appelé le tampon de primitives. De plus, la gestion de la rastérisation demande d'utiliser des informations assez lourdes, qui sont mémorisées en mémoire vidéo, comme le z-buffer dont nous parlerons à la fin du cours.
Avec les cartes graphiques en rendu à tiles, les choses sont totalement différentes. Premièrement, l'unité géométrique n'envoie pas ses résultats au rastériseur, mais mémorise le tout en mémoire vidéo. Plus précisément, les résultats sont enregistrés tile par tile. Le rastériseur lit alors la géométrie d'une tile depuis la mémoire vidéo, et la rastérise. L'avantage est que la tile est tellement petite qu'elle est intégralement rastérisée sans avoir à passer par la mémoire vidéo. En effet, le rastériseur incorpore une petite mémoire SRAM qui mémorise la tile et toutes les informations nécessaires pour effectuer le rendu. Pas besoin d’accéder à un gigantesque z-buffer pour toute l'image, juste besoin d'un z-buffer minuscule pour la tile en cours de traitement, qui tient totalement dans la SRAM pour la tile.
Maintenant, faisons le bilan au niveau des accès mémoire. La performance d'une carte graphique est limitée par la quantité d'accès mémoire qu'on peut effectuer par seconde. Autant dire que les économiser est primordial. Avec une architecture en mode immédiat, le tampon de primitives évite d'avoir à passer par la mémoire vidéo. Par contre, la rastérisation utilise énormément d'accès mémoire pour rendre les pixels, notamment à cause de cette histoire de z-buffer. Peu d'accès mémoire liés à la géométrie, mais la rastérisation est gourmande en accès mémoire. Avec les architectures à tile, c'est l'inverse. Toute la géométrie est enregistré en mémoire vidéo, mais la rastérisation ne fait que très peu d'accès mémoire grâce à la SRAM pour les tiles. Au final, les deux architectures sont optimisées pour deux types de rendus différents. Les cartes à rendu en tuile brillent quand la géométrie n'est pas trop compliquée, mais que la rastérisation et les traitements des pixels sont lourds. Les cartes en mode immédiat sont elles douées pour les scènes géométriquement lourdes, mais avec peu d'accès aux pixels. Le tout est limité par divers caches qui tentent de rendre les accès mémoires moins fréquents, sur les deux types de cartes, mais sans que ce soit une solution miracle.
Un avantage des GPU en rendu à tile est que l’antialiasing est plus rapide. Pour ceux qui ne le savent pas, l'antialiasing est une technique qui améliore la qualité d’image, en simulant une résolution supérieure. Une image rendue avec antialiasing aura la même résolution que l'écran, mais n'aura pas certains artefacts liés à une résolution insuffisante. Et l'antialiasing a lieu intégralement dans les circuits situés dans l'étape de rastérisation ou après. Tout se passe donc dans le rastériseur. Les GPU en mode immédiat disposent de techniques d'optimisations pour l’antialiasing, mais il n’empêche que le GPU doit mémoriser des informations en mémoire vidéo pour faire un antialiasing correct. Alors qu'avec le rendu en tiles, la SRAM qui mémorise la tile en cours suffit pour l'antialiasing. L'antialiasing est donc plus rapide.
Les carte graphique modernes sont partiellement programmables
modifierLes cartes graphiques modernes sont capables d’exécuter des programmes informatiques, comme le ferait un processeur d'ordinateur normal. Les programmes informatiques exécutés par la carte graphique sont appelés des shaders. Les shaders sont classifiés suivant les données qu'ils manipulent. On parle de pixel shader pour ceux qui manipulent des pixels, de vertex shaders pour ceux qui manipulent des sommets, de geometry shader pour ceux qui manipulent des triangles, de hull shaders et de domain shaders pour la tesselation, de compute shader pour des opérations sans lien avec le rendu 3D, etc. Ils sont écrits dans un langage de haut-niveau, le HLSL pour les shaders Direct X et le GLSL pour les shaders Open Gl, puis sont ensuite traduit (compilés) en instructions machines compréhensibles par la carte graphique.
Au début, ces langages et la carte graphique supportaient uniquement des opérations simples. Mais au fil du temps, les spécifications de ces langages sont devenues de plus en plus riches à chaque version de Direct X ou d'Open Gl, et le matériel en a fait autant. Les premiers shaders apparus étaient les vertex shaders, avec la Geforce 3 qui a rendu la gestion de la géométrie programmable. Par la suite, l'étape de traitement des pixels est elle aussi devenue programmable et les pixels shaders ont fait leur apparition. Puis d'autres formes de shaders sont apparues, pour effectuer des calculs géométriques complexes ou des calculs non-graphiques.
Les shaders sont exécutés par un ou plusieurs processeurs intégrés à la carte graphique, qui portent le nom de processeurs de shaders. Les cartes graphiques récentes contiennent un grand nombre de processeurs de shaders, plusieurs centaines. Mais une carte graphique moderne ne contient pas que des processeurs de shaders, mais aussi des circuits spécialisés dans le rendu 3D qui prennent en charge une étape du pipeline graphique, appelés des unités de traitement graphiques. On trouve ainsi une unité pour le placage de textures, une unité pour l'étape de transformation, une pour la gestion de la géométrie, une unité de rasterization, une unité d'enregistrement des pixels en mémoire appelée ROP, etc. Les unités sont divisés en deux catégories : d'un côté les circuits non-programmables (dits fixes) et de l'autre les circuits programmables (les processeurs de shaders). Globalement, la gestion de la géométrie et des pixels est programmable, mais la rasterization, le placage de texture, le culling et l'enregistrement du framebuffer ne l'est pas.
Outre les unités du paragraphe précédent, une carte graphique contient d'autres circuits qui s'occupent de la logistique, du transfert de données ou de la répartition du travail entre les unités de traitement proprement dites. Le tampon de primitive vu plus haut en est un exemple et d'autres mémoires tampon de ce type existent. L'un de ces circuits de logistique est le processeur de commandes, dont nous parlerons dans quelques chapitres. Pour simplifier, il commande les différentes étapes du pipeline graphique et s'assure que les étapes s’exécutent dans le bon ordre.
Ce qu'il faut comprendre, c'est qu'une carte graphique moderne est un mélange de circuits non-programmables et de circuits programmables. L'assemblage de circuits fixes et de processeurs de shaders peut sembler contradictoire. Pourquoi ne pas tout rendre programmable ? Ou au contraire, pourquoi ne pas utiliser seulement des circuits fixes ? Le choix entre les deux parait simple. Les circuits programmables sont légèrement plus lents, mais plus flexibles, plus simples à concevoir et facilitent la vie aux programmeurs. Par contre, les circuits fixes sont plus rapides, plus économes en énergie, utilisent moins de transistors, mais sont plus compliqués à concevoir et moins flexibles. Mélanger les deux semble étrange.
La réponse rapide est qu'il s'agit d'un compromis entre flexibilité et performance qui permet d'avoir le meilleur des deux mondes. Rendre la gestion de la géométrie ou des pixels programmable est très intéressant pour le programmeur, ce qui explique qu'on a des processeurs de shader pour les vertex shaders et pixel shaders. par contre, le programmeur ne gagne pas grand chose à avoir un rastériseur programmable, mais les performances de rastérisation sont cruciales. Aussi, ce circuits est laissé non-programmable et conçu avec un circuit fixe plus rapide. Dans la fin de ce chapitre, et dans le chapitre suivant, nous allons étudier comment cette recherche du compromis a donné naissance aux cartes graphiques modernes. Nous allons voir l'évolution dans le temps des cartes graphiques à la fin de ce chapitre. Dans le chapitre suivant, nous allons voir pourquoi les vertex shaders et les pixels shaders sont apparus, en prenant l'exemple des algorithmes d'éclairage.
Un historique simplifié du hardware des cartes graphiques
modifierLe hardware des cartes graphiques a fortement évolué dans le temps, ce qui n'est pas une surprise. Les évolutions de la technologie, avec la miniaturisation des transistors et l'augmentation de leurs performances a permit aux cartes graphiques d'incorporer de plus en plus de circuits avec les années. Avant l'invention des cartes graphiques, toutes les étapes du pipeline graphique étaient réalisées par le processeur : il calculait l'image à afficher, et l’envoyait à une carte d'affichage 2D. Au fil du temps, de nombreux circuits furent ajoutés, afin de déporter un maximum de calculs vers la carte vidéo. Les GPU actuels s'occupent de tous les traitements liés au moteur graphique.
Dès le début de la 3D, le choix entre circuits fixe et circuits programmables s'est fait ressentir. Les toutes premières cartes graphiques se classaient en deux camps : d'un côté les cartes graphiques complètement non-programmables, de l'autre des GPU totalement programmables. La toute première carte 3D était du second camp : c’était la Rendition Vérité V1000. Elle contenait un processeur MIPS cadencé à 25 MHz, 4 mébioctets de RAM, une ROM pour le BIOS, et un RAMDAC, rien de plus. C'était un vrai ordinateur complètement programmable de bout en bout. Les programmeurs ne pouvaient cependant pas utiliser cette programmabilité avec des shaders, mais elle permettait à Rnedition d'implémenter n'importe quelle API 3D, que ce soit son API propriétaire ou OpenGL, voire DirectX. Mais les performances s'en ressentaient. Réaliser la rastérisation et le placage de texture en logiciel n'est pas efficace, pareil pour les opérations de fin de pipeline comme l'antialiasing. Au final, sa programmabilité était un plus pour la compatibilité logicielle, mais qui avait un cout en termes de performances. Le manque d'unités fixes très rapides pour la rastérisation, le placage de texture ou les opérations de fin de pipeline était clairement un gros défaut.
Le second camp, celui des cartes graphiques totalement non-programmables et ne contenant que des circuits fixe, regroupe les Voodoo de 3dfx, les Riva TNT de NVIDIA, les Rage/3D d'ATI, la Virge/3D de S3, et la Matrox Mystique. Elles contenaient des circuits pour gérer les textures, quelques effets graphiques (brouillard) et l'étape d'enregistrement des pixels en mémoire. Par la suite, ces cartes s’améliorèrent en ajoutant plusieurs circuits de gestion des textures, pour colorier plusieurs pixels à la fois. Cela permettait aussi d'utiliser plusieurs textures pour colorier un seul pixel : c'est ce qu'on appelle du multitexturing. Les performances avec ces cartes étaient nettement meilleures que pour la Rendition V1000. Le fait d'avoir des circuits câblés rapides pour certaines tâches bien choisie donnait de bonnes performances, et personne n'avait la moindre utilité pour des circuits programmables à l'époque.
Les cartes suivantes ajoutèrent une gestion des étapes de rasterization directement en matériel. Les cartes ATI rage 2, les Invention de chez Rendition, et d'autres cartes graphiques supportaient ces étapes en hardware. De nos jours, ce genre d'architecture est commun chez certaines cartes graphiques intégrées dans les processeurs ou les cartes mères.
La première carte graphique capable de gérer la géométrie fût la Geforce 256, la toute première Geforce. Son unité de gestion de la géométrie n'est autre que la bien connue T&L (Transform And Lighting). Elle implémentait des algorithmes d'éclairage de la scène 3D assez simples, comme un éclairage de Phong, qui étaient directement câblés dans ses circuits. Mais le défaut de cette approche était que ces cartes graphiques incorporaient des algorithmes d'éclairage très spécifiques, alors qu'il existe de très nombreux algorithmes d'éclairage, au point où on ne peut pas tous les mettre dans une carte graphique. Les programmeurs avaient donc le choix entre programmer les algorithmes d’éclairage qu'ils voulaient ou utiliser ceux de la carte graphique.
À partir de la Geforce 3 de Nvidia, les unités de traitement de la géométrie sont devenues programmables. L'avantage était que les programmeurs n'étaient pas limités aux algorithmes implémentés dans la carte 3D pour obtenir de bonnes performances. Ils pouvaient programmer l'algorithme d'éclairage qu'ils voulaient et pouvaient le faire exécuter directement sur la carte graphique. Les unités de traitement de la géométrie deviennent donc des processeurs indépendants, capables d’exécuter des programmes appelés Vertex Shaders. Ces shaders sont écrits dans un langage de haut-niveau, le HLSL ou le GLSL, et sont traduits (compilés) par les pilotes de la carte graphique avant leur exécution. Au fil du temps, les spécifications de ces langages sont devenues de plus en plus riches et le matériel en a fait autant. D'autres types de shaders ont été inventés : pixels shaders pour le traitement de pixels, shaders de géométrie, shaders génériques, etc. Les premiers à faire leur apparition furent les pixel shaders, suivis par les geometry shaders et les shaders pour la tesselation.
Pour résumer, l'évolution des cartes 3D s'est faite d'une manière assez complexe. Le compromis entre circuits fixes et programmables a été difficile à trouver. Les concepteurs ont procédé par essais et erreurs, jusqu'à trouver le compromis actuel. Les premières cartes graphiques sont parties sur deux pistes totalement opposées : d'un côté une carte totalement programmable, de l'autre des cartes avec des circuits fixes non-programmables. L'échec de la Rendition Vérité 1000, totalement programmable mais avec de trop faibles performances, a orienté le marché dans la seconde direction. Les cartes 3D ont alors incorporé des circuits de rastérisation et de placage/filtrage de texture, qui sont toujours là de nos jours. Elles ont ensuite intégré des circuits de calculs géométrique, avant de revenir en arrière pour les remplacer par des circuits programmables. Leur programmabilité s'est ensuite accrue, en incorporant des processeurs capables d’exécuter n'importe quel algorithme, mais sans revenir sur les circuits fixes établis. Expliquer pourquoi demande cependant d'y passer un chapitre entier, ce qui est le sujet du chapitre suivant.
L'éclairage d'une scène 3D : shaders et T&L
Dans le chapitre précédent, nous avons vu que les cartes graphiques ont progressivement évoluées dans le temps. Elles ont d'abord intégré des circuits pour les textures et la rastérisation, puis des circuits géométriques non-programmables. Mais les circuits géométriques fixes ont ensuite été remplacés par des processeurs programmables. Les cartes graphiques se sont mis à exécuter des shaders, des programmes informatiques spécialisés dans le rendu 3D. Les shaders ont remplacé les circuits géométriques, avant de gagner en fonctionnalité et de devenir indispensable pour tout moteur 3D. Pour comprendre pourquoi ce revirement, nous devons étudier le cas particulier du rendu de l'éclairage d'une scène 3D. L'éclairage d'une scène 3D calcule les ombres, mais aussi la luminosité de chaque pixel, ainsi que bien d'autres effets graphiques.
Dans le chapitre précédent, on a vu que le pipeline graphique contient une étape d'éclairage durant la phase de traitement de la géométrie. Elle attribue une illumination/couleur à chaque triangle ou à chaque sommet de la scène 3D, le plus souvent à chaque sommet de la scène 3D. Mais l'étape d'éclairage géométrique du pipeline graphique ne suffit pas à elle seule à éclairer la scène comme voulu. En effet, il faut distinguer deux types différents d'éclairage dans les jeux vidéos : le vertex lighting, où l'éclairage est calculé pour chaque sommet/triangle d’une scène 3D, et l'éclairage par pixel (per-pixel lighting), où l'éclairage est calculé pixel par pixel.
L'étape d'éclairage géométrique du pipeline graphique est suffisante pour calculer du vertex lighting, mais ne peut pas faire du per-pixel lighting à elle seule. Avec le per-pixel lighting, l’éclairage est finalisé soit dans l'étape de rastérisation, soit par des pixels shaders. Pour le dire autrement, l'éclairage ne se fait pas qu'au niveau de la géométrie, comme il a pu être dit au chapitre précédent. Même des algorithmes simples demandent une intervention des pixels shaders pour calculer l'éclairage pixel par pixel. Autant dire que cette distinction a été importante dans l'évolution du matériel graphique et l'introduction des shaders. Mais pour comprendre cela, il faut voir comment fonctionnent les algorithmes d'éclairage et voir comment le hardware peut aider à les implémenter.
Les sources de lumière et les couleurs associées
modifierDans 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).
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).
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.
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
modifierL'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).
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.
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.
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
modifierMaintenant que l'on sait comment est calculé l'illumination d'un point de surface, passons maintenant aux algorithmes d'éclairage proprement dit. C’est une bonne chose de savoir comment les points de surface (sommets ou triangles) sont éclairés, mais rappelons que ce sont des pixels qui s'affichent à l'écran. L'étape d'éclairage réalisée par la géométrie peut calculer la luminosité/couleur d'un sommet/triangle, mais pas celle d'un pixel. Pour cela, il faut déterminer la luminosité d'un pixel à partir de la luminosité/couleur des sommets du triangle associé. Pour cela, il existe plusieurs algorithmes qui font ce calcul. les trois plus connus sont appelés l'éclairage plat, l'éclairage de Gouraud, et l'éclairage de Phong. Notons que ce dernier n'est pas identique à l'éclairage de Phong vu précédemment.
Les trois algorithmes principaux d'éclairage
modifierL'é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.
L'éclairage de Gouraud et l'éclairage plat sont des cas particulier de vertex lighting, où l'éclairage est calculé pour chaque sommet d’une scène 3D. De l'autre côté, l'éclairage de Phong est un cas particulier d'éclairage par pixel (per-pixel lighting), où l'éclairage est calculé pour chaque pixel à partir des informations géométriques. Il existe d'autres techniques de per-pixel lighting, mais celles-ci sont surtout des techniques logicielles, comme le rendu différé.
Les résultats, avantages et inconvénients
modifierPour simplifier fortement, l'éclairage plat calcule la couleur triangle par triangle, l'éclairage de Gouraud calcule la couleur sommet par sommet, et l'éclairage de Phong calcule la couleur pixel par pixel. La différence entre les trois algorithmes se voit assez facilement. L'éclairage plat donne un éclairage assez carré, avec des frontières assez nettes. l'éclairage de Gouraud donne des ombres plus lisses, dans une certaine mesure, mais pèche à rendre correctement les reflets spéculaires. L'éclairage de Phong est de meilleure qualité, surtout pour les reflets spéculaires.
En termes de calculs à effectuer, l'algorithme le plus rapide est l'éclairage plat, suivi par l'éclairage de Gouraud, puis par l'éclairage Phong. Cela tient à la quantité de calculs d'éclairage à effectuer. L’éclairage plat calcule les vecteurs une fois par triangle, alors que l'algorithme de Gouraud fait les mêmes calculs pour chaque sommet. Pour l'algorithme de Phong, on doit faire plus de calculs que pour les deux autres. Certes, il demande de faire moins de calculs géométriques : on a juste à calculer les normales des surfaces et les positions des sommets, pas de calculer l'éclairage au niveau géométrique. Par contre, on doit faire plus de calculs au niveau des pixels : on doit interpoler les normales et calculer l'éclairage sur toutes les normales. Vu qu'il y a plus de normales au final, donc plus de points de surface, on doit faire plus de calculs d'éclairage au total. Bien sûr, tout cela, ne tient pas compte des possibilités d'accélération par la carte graphique, juste du nombre total de calculs à effectuer.
En général, le per-pixel lighting a une qualité d'éclairage supérieure aux techniques de vertex lighting, mais il est aussi plus gourmand. Le per-pixel lighting est utilisé dans presque tous les jeux vidéo depuis DOOM 3 (l'un des premiers jeux à utiliser ce genre d'éclairage), en raison de sa meilleure qualité, les ordinateurs actuels étant assez performants.
L'implémentation en hardware de ces trois algorithmes
modifierPour ce qui est de l'implémentation en hardware, les trois algorithmes ne sont pas équivalents. Le plus simple à implémenter est l'éclairage plat, qui demande juste d'avoir de quoi effectuer l'algorithme d'illumination de Phong. Les cartes graphiques anciennes comme la Geforce 256 savaient le faire en matériel. L'éclairage de Gouraud s'implémente de la même manière, sauf que l'algorithme d'illumination de Phong doit se faire sur les triangles, ce qui ne change pas chose et est à la portée de toutes les cartes graphiques depuis la Geforce 256. Il faut juste rajouter un circuit d'interpolation, mais celui-ci fait normalement partie de l'unité de rastérisation, comme on le verra plus tard dans le chapitre sur celle-ci. Pour donner un exemple, la console de jeu Playstation 1 gérait l'éclairage plat et de Gouraud directement en matériel, dans son GPU.
Avec l’éclairage de Phong, l'éclairage se fait en deux étapes, séparées par l'étape de rastérisation. Et les unités de calculs géométriques non-programmables n'étaient pas prévues pour ce genre de calculs. Si le programmeur a besoin des normales pour chaque pixel mais que l'unité géométrique ne te les fournit pas, l'implémentation de l’algorithme de Phong ne peut se faire qu'en logiciel, (ou en trichant un peu avec le pipeline graphique, mais les performances sont alors rarement au rendez-vous). Il aurait été facile de corriger cela, mais l'unité géométrique aurait dû avoir deux modes de fonctionnements : un où elle fait du vertex lighting, et un autre où elle renvoie les normales des sommets. Mais la vraie difficulté aurait été la seconde partie de l'algorithme, après l'étape de rastérisation. La séparation de l'algorithme en deux circuits séparés par le circuit de rastérisation aurait été compliquée et c'est pour cette raison que cet algorithme n'a pas été implémenté en hardware sur les anciennes cartes graphiques. La seule manière de l'implémenter proprement est d'utiliser les shaders.
Pour résumer, les unités de calculs géométrique non-programmables des Geforce se limitaient au vertex lighting, rendu avec un algorithme d'illumination de Phong, appliqué triangle par triangle ou sommet par sommet, guère plus. Elles permettaient d'implémenter certains algorithmes d'éclairage assez facilement, mais n'étaient pas assez flexibles pour en implémenter d'autres, comme l'éclairage de Phong. Les algorithmes d'éclairages complexes demandent une coordination entre les calculs géométriques et les calculs après rastérisation, ce qui n'est possible qu'avec les vertex et pixels shaders. C'est pour cela que durant longtemps, les moteurs de jeux vidéo ont préféré utiliser des techniques comme le lightmapping au lieu des unités géométriques non-programmables. La meilleure qualité du per-pixel lighting a eu raison des unités de vertex lighting matérielle.
Conclusion et remarques quant à l'évolution des cartes graphiques
modifierPour résumer, le manque de flexibilité des unités de T&L matérielle faisait qu'elles ne servaient pas à grand chose et ne pouvaient accélérer que des algorithmes d'éclairages géométriques pas très agréables visuellement. Implémenter un grand nombre d'algorithmes directement en hardware aurait eu un cout trop important en termes de transistors et de complexité. Cela a poussé les concepteurs de cartes graphiques à passer aux shaders pour les calculs géométriques et quelques autres opérations.
Tout cela est à l'exact opposé de ce qui s'est passé pour d'autres circuits, comme les circuits pour l'étape de transformation, de rastérisation ou de placage de texture, d'antialiasing, etc. Il n'y a pas 36 façons de rastériser une scène 3D et la flexibilité n'est pas un besoin important pour cette opération, alors que les performances sont cruciales. Même chose pour le placage/filtrage de textures, ou l'antialiasing et les autres opérations du genre. En conséquences, les unités de transformation, de rastérisation et de placage de texture sont toutes implémentées en matériel. Faire ainsi permet de gagner en performance sans que cela ait le moindre impact pour le programmeur.
Le parallèle est assez intéressant à étudier entre les unités géométriques abandonnées et le reste des circuits non-programmables d'une carte graphique. Au final, une carte graphique est un mélange de circuits programmables, et de circuits qui n'ont pas besoin de l'être mais qui doivent être ultra-optimisés pour une opération bien précise et quasi immuable. L'architecture d'une carte graphique est un compromis entre performance et flexibilité, qui est conçu pour répondre au mieux aux exigences du rendu 3D et des logiciels. Est mis en hardware ce qui a besoin d'être performant, est rendu programmable ce qui a besoin de l'être pour le programmeur.
Les processeurs de shaders
Les shaders sont des programmes informatiques exécutés par la carte graphique, et plus précisément par des processeurs de shaders. 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.
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.
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 processeurs de shaders modernes : les processeurs SIMD
modifierLe 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 :
- Graphics Core Next 1 instruction set ;
- Graphics Core Next 2 instruction set ;
- Graphics Core Next 3 and 4 instruction sets ;
- Graphics Core Next 5 instruction set ;
- "Vega" 7nm instruction set architecture (also referred to as Graphics Core Next 5.1) ;
- Jeu d'instruction des GPU de type RDNA3 d'AMD.
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
modifierLes 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.
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.
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.
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
modifierUn 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 pour les processeurs de shaders, ce n'est pas le cas. Le rendu 3D demande pas mal de calculs trigonométriques, de produits scalaires ou d'autres opérations. Aussi, les processeurs de shaders disposent souvent d'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
modifierBeaucoup 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.
La prédication et le SIMT
modifierLes 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.
La prédication avec une pile SIMT
modifierAu 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é
modifierUne 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
modifierAprè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
modifierLes 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.
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 de chez NVIDIA et AMD/ATI
modifierLes processeurs VLIW les plus simples étaient des hubrides 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. Le processeur de vertices de la Geforce 3, le tout premier processeur de shader au monde, était l'un d’entre eux. Un autre 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 uen 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 possiiblités de co-issue plus développées. Un exemple est celui du processeur de pixel shader de la Geforce 6800, qui regroupait 4 opérations scalaires, une opération d'accès aux textures, et une opération transcendantale. En tout, 6 opérations pouvaient s'exécuter simultanément. Il était possible de simuler une opération SIMD en utilisant plusieurs opérations scalaires. Avec les 4 opérations scalaires, on pouvait émuler : une opération SIMD agissant sur des vecteurs de 4 éléments, une opération vectorielle sur 3 flottants, regroupé avec une opération scalaire, deux opérations vectorielles sur deux éléments chacune. Mais il s'agissait d'un jeu d'instruction VLIW proprement dit.
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.
L'abandon des architectures VLIW
modifierLes 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.
Les registres des processeurs de shaders
modifierUn processeur de shaders contient beaucoup de registres, sans quoi il ne pourrait pas faire son travail efficacement. Les anciennes cartes graphiques avaient beaucoup de registres spécialisés, c'est à dire que chaque registre avait une fonction bien définie et ne pouvait stocker qu'un type de donnée bien précise. Mais avec l'évolution de Direct X et d'Open GL, les registres sont devenus de moins en moins spécialisés et sont devenues des registres généraux, interchangeables, capables de stocker des données arbitraires. De nos jours, les registres spécialisés sont devenus anecdotiques, car leur a perdu de son intérêt avec l'unification des shaders : la plupart des registres spécialisés n'avaient de sens que pour les vertices et rien d'autre.
Les anciennes cartes graphiques : beaucoup de registres spécialisés
modifierSur les processeurs de vertices des anciennes cartes 3D, il y avait bien quelques registres généraux, sans fonction préétablie, mais ils étaient secondés par un grand nombre de registres spécialisés assez nombreux. Les registres des processeurs de vertices peuvent se classer en plusieurs types.
Les registres d'entrée réceptionnent les vertices ou pixels. Les registres de sortie sont là où le processeur stocke ses résultats finaux. Les registres d'entrée sont en lecture seule, du point de vue du processeur de shader, les registres de sorties sont en écriture seule. Ils servent d'interface avec le reste du pipeline graphique, notamment le rastérizeur et les ROPs.
Viennent ensuite les registres généraux, aussi appelés registres temporaires, qui peuvent mémoriser tout type de données. Les registres temporaires sont les registres du processeur proprement dit, ceux qu'il peut manipuler à loisir. Il faut noter que la lecture des textures passe souvent par ces registres : la lecture d'un texel charge celui-ci dans un registre temporaire. A la rigueur, un texel lu peut passer directement dans un registre de sortie, mais c'est signe que le pixel shader ne fait aucun traitement dessus.
Les registres de constantes servent pour stocker 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.
L'adressage des registres de constante est quelque peu particulier. Dans les faits, 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.
Un exemple classique est celui des processeurs de vertices de la Geforce 3. Ses registres sont des 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".
Les cartes graphiques modernes : un grand nombre de registres vectoriels
modifierLes cartes graphiques modernes contiennent un très grand nombre de registres SIMD. Un point important est que les registres SIMD sont séparés des registres entiers. Les registres scalaires stockent chacun un seul entier, éventuellement un seul flottant. Ils sont manipulés par les instructions scalaires, que ce soit des calculs simples, des comparaisons, des branchements, des opérations logiques ou autres. A l'opposé, les registres SIMD sont manipulés par des instructions SIMD et seulement celles-ci. La séparation entre registres SIMD/scalaires va de pair avec la séparation entre instructions scalaires/SIMD.
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
modifierSi 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.
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
modifierUn 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
modifierLes 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.
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.
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
modifierDepuis 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.
- 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
modifierL’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
modifierAvec 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
modifierLa 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.
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.
Les architectures actuelles font les deux
modifierLa 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
modifierPlus 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
modifierEn 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.
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.
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
modifierEn 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
modifierPour 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.
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.
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.
Les unités de calcul d'un processeur de shader SIMD
modifierPour 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
modifierUn 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
modifierIl 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
modifierLes 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
modifierL'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
modifierUn 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
modifierToutes 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)
modifierMais 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
modifierUne 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
modifierUn 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
modifierLa 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. A 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 multiplication, 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.
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ée "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é vectorielles, 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).
Le multithreading matériel
modifierLes 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
modifierLe 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.
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
modifierAprè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.
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.
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).
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
modifierAvant 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
modifierAvec 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.
La mémoire virtuelle sur les GPUs dédiés
modifierLa 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.
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
modifierQuand 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é.
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.
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
modifierLa 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
modifierSur 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
modifierLe 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.
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
modifierLa 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
modifierLes 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
modifierLe 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
modifierLa hiérarchie mémoire des GPU modernes ressemble de plus en plus à celle des CPU, avec toute une hiérarchie de caches, avec des caches L1, L2, L3, etc. Pour rappel, les processeurs multicœurs modernes ont pleins de mémoires cache, avec au minimum deux niveaux de cache, le plus souvent trois. Les trois niveaux de cache sont appelés les caches L1, L2 et L3. Pour le premier niveau, on trouve deux caches spécialisés par cœur/processeur : un cache pour les instructions et un cache pour les données. Pour le second niveau, on a un cache L2 par cœur/processeur, qui peut stocker indifféremment données et instructions. Le cache L3 est un cache partagé entre tous les cœurs/processeurs. Les GPU ont une organisation similaire, sauf que le nombre de cœurs est beaucoup plus grand que sur un processeur moderne.
Les caches d'instruction des GPU sont adaptés aux contraintes du rendu 3D. Le principe du rendu 3D est d'appliquer un shader assez simple sur un grand nombre de données, alors que les programmes généralistes effectuent un grand nombre d'instructions sur une quantité limitée de données. 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
modifierUne 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.
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
modifierEn 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.
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.
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
modifierVous 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.
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.
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
modifierLe 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
modifierUne 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
modifierLe 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
modifierIl 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
modifierLe 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
modifierTous 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
modifierL'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
modifierLe 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.
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
modifierLe 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
modifierDans 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
modifierIl 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
modifierLancer 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
modifierL'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).
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.
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
modifierPour 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.
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.
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.
Le tampon d'indices
modifierEnfin, 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.
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.
L'étape de transformation
modifierL'étape de transformation-projection regroupe plusieurs manipulations différentes, mais qui ont pour point commun de demander des changements de repères. Par changement de repères, on veut dire que l'on passe d'un système de coordonnées à un autre. Par exemple, une manipulation va passer d'un système de coordonnées centré sur le milieu de la scène 3D à un système de coordonnées centrées sur la caméra. En tout, il existe trois changements de repères distincts qui sont regroupés dans l'étape de transformation : un premier qui place chaque objet 3D dans la scène 3D, un autre qui centre la scène du point de vue de la caméra, et un autre qui corrige la perspective.
Les trois étapes de changement de repère
modifierUn modèle 3D est représentée par un ensemble de sommets, qui sont reliés pour former sa surface. Les données du modèle 3D indiquent, pour chaque sommet, sa position par rapport au centre de l'objet qui a les coordonnées (0, 0, 0). Mais le modèle 3D est placé à un endroit bien précis dans une scène 3D, endroit qui a une position de coordonnées (X, Y, Z) déterminées par le moteur physique. Une fois placé dans la scène 3D, le centre de l'objet passe donc des coordonnées (0, 0, 0) aux coordonnées (X, Y, Z) et tous les sommets de l'objet doivent être mis à jour. De plus, l'objet a une certaine orientation : il faut aussi le faire tourner. Enfin, l'objet peut aussi subir une mise à l'échelle : on peut le gonfler ou le faire rapetisser, du moment que cela ne modifie pas sa forme, mais simplement sa taille. En clair, le modèle 3D subit une translation, une rotation et une mise à l'échelle.
Une fois le placement des différents objets effectué, la carte graphique effectue un dernier changement de coordonnées. Au lieu de considérer un des bords de la scène 3D comme étant le point de coordonnées (0, 0, 0), il va passer dans le référentiel de la caméra. Après cette transformation, le point de coordonnées (0, 0, 0) sera la caméra. La direction de la vue du joueur sera alignée avec l'axe de la profondeur (l'axe Z).
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
modifierChaque étape demande de faire des changements de coordonnées, chaque sommet voyant ses coordonnées altérées et remplacées par de nouvelles. Or, un changement de coordonnée s'effectue assez simplement, avec des matrices, à savoir des tableaux organisés en lignes et en colonnes avec un nombre dans chaque case. un changement de coordonnées se fait simplement en multipliant le vecteur (X, Y, Z) des coordonnées d'un sommet par une matrice adéquate. Il existe des matrices pour la translation, la mise à l'échelle, d'autres pour la rotation, une autre pour la transformation de la caméra, une autre pour l'étape de projection, etc.
- Un petit problème est que les matrices qui le permettent sont des matrices avec 4 lignes et 4 colonnes, et que la multiplication demande que le nombre de coordonnées du vecteur soit égal au nombre de colonnes. Pour résoudre ce petit problème, on ajoute une 4éme coordonnée aux sommets, la coordonnée homogène, qui ne sert à rien, et est souvent mise à 1, par défaut.
Les cartes graphiques contiennent un ou plusieurs circuits de multiplication de matrices spécialisés dans l'étape de transformation. Chacun de ces circuits prend un sommet et renvoie le sommet transformé. Ils sont composés d'un gros paquet de multiplieurs et d'additionneurs flottants. Pour plus d'efficacité, les cartes graphiques comportent plusieurs de ces circuits, afin de pouvoir traiter plusieurs sommets en même temps. Il s'agit d'unités de calculs fixes, non-programmables. Elles sont configurables, car on peut préciser les matrices nécessaires pour faire les calculs, mais guère plus. Elles prennent en entrée les données provenant de l'input assembler, ainsi que les matrices nécessaires, et fournissent en sortie les coordonnées des sommets transformés.
Les vertex shaders : des exemple du processeur de shaders
modifierIl y a peu de généralités spécifiques pour les processeurs de vertex shaders, tout a déjà été dit dans le chapitre sur les processeurs de shaders. Tout au plus peut on dire que les cartes graphiques avaient autrefois des processeurs spécialisés dans l’exécution des vertex shaders, distincts des processeurs pour les pixel shaders. Mais cette époque est révolue avec les cartes graphiques actuelles. Par contre, on peut étudier un exemple de processeur de vertex shader d’antan.
Le jeu d’instruction du GPU de la Geforce 3
modifierLa première carte graphique commerciale destinée aux gamers à disposer d'une unité de vertex programmable est la Geforce 3. Celui-ci respectait le format de vertex shader 1.1. L'ensemble des informations à savoir sur cette unité est disponible dans l'article "A user programmable vertex engine", disponible sur le net. . Le processeur de cette carte était capable de gérer un seul type de données : les nombres flottants de norme IEEE754. Toutes les informations concernant la coordonnée d'une vertice, voire ses différentes couleurs, doivent être encodées en utilisant ces flottants.
Le processeur de la Geforce 3 est capable d’exécuter 17 instructions différentes, dont voici les principales :
OpCode | Nom | Description |
---|---|---|
Opérations mémoire | ||
MOV | Move | vector -> vector |
ARL | Address register load | miscellaneous |
Opérations arithmétiques | ||
ADD | Add | vector -> vector |
MUL | Multiply | vector -> vector |
MAD | Multiply and add | vector -> vector |
MIN | Minimum | vector -> vector |
MAX | Maximum | vector -> vector |
SLT | Set on less than | vector -> vector |
SGE | Set on greater or equal | vector -> vector |
LOG | Log base 2 | miscellaneous |
EXP | Exp base 2 | miscellaneous |
RCP | Reciprocal | scalar-> replicated scalar |
RSQ | Reciprocal square root | scalar-> replicated scalar |
Opérations trigonométriques | ||
DP3 | 3 term dot product | vector-> replicated scalar |
DP4 | 4 term dot product | vector-> replicated scalar |
DST | Distance | vector -> vector |
Opérations d'éclairage géométrique | ||
LIT | Phong lighting | miscellaneous |
L'instruction la plus intéressante est clairement la dernière : elle applique l'algorithme d'illumination de Phong sur un sommet. Les autres instructions permettent d'implémenter un autre algorithme si besoin, mais l'algo de Phong est déjà là à la base.
Les autres instructions sont surtout des instructions arithmétiques : multiplications, additions, exponentielles, logarithmes, racines carrées, etc. Pour les instructions d'accès à la mémoire, on trouve une instruction MOV qui déplace le contenu d'un registre dans un autre et une instruction de calcul d'adresse, mais aucune instruction d'accès à la mémoire sur le processeur de la Geforce 3. Plus tard, les unités de vertex shader ont acquis la possibilité de lire des données dans une texture.
On remarque que la division est absente. Il faut dire que la contrainte qui veut que toutes ces instructions s’exécutent en un cycle d'horloge pose quelques problèmes avec la division, qui est une opération plutôt lourde en hardware. À la place, on trouve l'instruction RCP, capable de calculer 1/x, avec x un flottant. Cela permet ainsi de simuler une division : pour obtenir Y/X, il suffit de calculer 1/X avec RCP, et de multiplier le résultat par Y.
L'unité de calcul de la Geforce 6800
modifierMaintenant, passons à l'architecture des processeurs de vertex de la Geforce 6800. Le jeu d'instruction a certes changé depuis la Geforce 3, mais nous n'allons pas refaire une revue complète du jeu d'instruction. A la place, nous allons dire ce qui a changé et comment les registres étaient organisés. L'architecture globale de ce processeur est indiquée ci-dessous.
Comme on peut le voir, le processeur dispose de beaucoup de registres : des registres d'entrée, qui réceptionnent les sommets lus par l'input assembler, des registres de sortie, dans lesquelles le processeur stocke les sommets transformés et éclairés, des registres de constantes qui servent à stocker des constantes, mais aussi des registres généraux/temporaires pour les résultats intermédiaires des calculs. Le shader à exécuter est mémorisé dans une instruction RAM, une sorte de mémoire locale spécialisée dans le stockage du shader proprement dit, du stockage des instructions à exécuter.
L'unité de calcul de ce processeur est découpée en plusieurs circuits séparés. Premièrement, on voit une unité de calcul capables de réaliser une multiplication et une addition. Ensuite, on voit une unité pour les fonctions arithmétiques spéciales, comme les logarithmes, exponentielles ou les calculs trigonométriques, les produits scalaires et autres. Enfin, on voit une unité d'accès aux textures, ce qui veut dire que le vertex shader a la possibilité de lire des textures en mémoire vidéo, ce qui facilite l'implémentation de certaines techniques de rendu. On remarque aussi la présence d'un cache de texture intégré dans le processeur de vertex shader.
Les unités de gestion des primitives
À ce stade, les sommets ont été éclairés et transformés par les unités de traitement géométriques. Vient alors l'étape de gestion des primitives, durant laquelle les sommets sont assemblés en primitives, qui sont elle-mêmes éventuellement altérés. Avant toute chose, il faut rappeler ce que le terme "primitive" veut dire dans le contexte du rendu 3D. Les primitives sont tout simplement les triangles ou les polygones utilisés pour décrire les modèles 3D, les surfaces de la scène 3D. De nos jours, la quasi-totalité des jeux vidéos utilisent des triangles, mais il a existé des jeux vidéo ou des cartes graphiques qui acceptaient d'autres primitives, comme des carrés ou d'autres polygones. Les moteurs de rendu acceptent aussi des primitives simples, comme des points (utiles pour les particules), ou les lignes (utiles pour le rendu 2D). Les primitives sont toutes définies par un ou plusieurs points : trois sommets pour un triangle. Le regroupement des sommets en primitives s'explique par le fait que l'étape suivante du pipeline graphique, l'étape de rastérisation, associe des primitives et des pixels. On ne peut pas faire de la rastérisation avec des sommets isolés.
L'assemblage de primitives
modifierLa première étape est celle de l'assemblage de primitives aussi appelée primitive assembly. En sortie de l'étage précédent, on n'a que des sommets éclairés et colorisés, pas des primitives. Les sommets qui sortent de l'étape de Transformation-éclairage ne sont pas dans l'ordre : deux sommets qui en sortent à la suite ne sont pas forcément dans le même triangle. Pour recréer des primitives, on doit lire les sommets dans l'ordre adéquat : par paquets de trois pour obtenir des triangles. C'est le rôle de l'étape d'assemblage de primitives, qui regroupe les sommets appartenant au même triangle, à la même primitive. L'assemblage des primitives est réalisée par un circuit fixe, non-programmable, qui utilise le tampon d'indice pour regrouper les sommets en primitives.
Formellement, les primitives sont déjà prises en compte dans l'étape d'input assembler. Toute carte graphique gére au minimum les primitives de l'API 3D qu'elle implémente. Par exemple, une application compatible avec Direct 3D 1.0 doit implémenter les primitives supportées par Direct X 1.0. Et les primitives en question sont nombreuses. N'allez pas croire que les cartes graphiques, anciennes ou modernes, ne gèrent que des triangles ou rien. Elles gèrent au minimum les points, les lignes et les triangles. Les anciennes API géraient aussi les quads, à savoir des rectangles ou carrés. En outre, il faut aussi gérer les triangle-strip et triangle-fan vu dans le chapitre précédent. L'étape d'assemblage de primitive doit gérer de primitives. Elle eut même gérér l'équivalent mais pour les lignes ou les quads. Pour information, voici les primitives gérées par les premières versions d'OpenGL :
Le rendu avec des quads est aujourd’hui tombé en désuétude, mais il est parfois supporté par les cartes graphiques actuelles, bien que ce soit souvent par émulation (un quad est rendu avec deux triangles). Aujourd'hui, OpenGL et DirectX ne gèrent plus les quads, et ne vont pas au-delà des triangles.
Le tampon de primitives
modifierL'é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.
Les geometry shaders
modifierA 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
modifierLes geometry shaders sont exécutés par les processeurs de shaders normaux. Leur place dans le pipeline graphique est quelque peu étrange. En théorie, ils sont placés après l'assembleur de primitive. C'est assez intuitif : ils manipulent des primitives qui sont fournies par l'étape d'assemblage des primitives, donc ils sont placés après. Mais le résultat fournit par les geometry shaders doit être retraité par l'assembleur de primitive. En effet, j'ai menti plus haut en disant que les geometry shaders fournissent en entrée de 0 à plusieurs primitives : la sortie d'un geometry shader est un ensemble de sommets, non-regroupés en primitives. Le résultat est que l'assembleur de primitive doit refaire son travail après le passage d'un geometry shader, pour déterminer les primitives finales. Et il faut aussi refaire le culling, au cas où les primitives générées ne soient pas visibles depuis la caméra. Heureusement, la sortie d'un geometry shader est soit un point, soit une ligne, soit un triangle strip, ce qui simplifie la seconde phase d'assemblage des primitives. Avec les geometry shaders, il y a donc deux phases d'assemblage des primitives : une phase avant, décrite dans la section précédente, et une seconde phase simplifiée après les geometry shaders.
Il n'y a pas que la phase d'assemblage de primitives qui est dupliquée : le tampon de primitives l'est aussi. On trouve donc un tampon de primitives à l'entrée des geometry shaders et un autre à la sortie.
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
modifierUne fonctionnalité des geometry shaders est la possibilité d'enregistrer leurs résultats en mémoire. Il s'agit de la fonctionnalité du stream output. On peut ainsi remplir une texture ou le vertex buffer dans la mémoire vidéo, avec le résultat d'un geometry shader. Notons que celle-ci mémorise un ensemble de primitives, pas autre chose. Cette fonctionnalité est utilisée pour certains effets ou rendu bien précis, mais il faut avouer qu'elle n'est pas très souvent utilisée. Aussi, les concepteurs de cartes graphiques n'ont pas optimisé cette fonctionnalité au maximum. Le stream output n'a généralement pas accès prioritaire à la mémoire, comparé aux ROP, et n'a souvent accès qu'à une partie limitée de la bande passante mémoire. Notons qu'il existe deux formes de stream output : une qui permet aux vertex shader d'écrire dans une texture, l'autre qui permet aux geometry shaders de le faire.
Notons que le stream output fournit un flux de primitives, pas de sommets. 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
modifierLa 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.
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
modifierLes premières tentatives utilisaient des algorithmes matériels de tesselation, et non des shaders. Par exemple, la première carte graphique commerciale avec de la tesselation matérielle était la Radeon 8500, de l'entreprise ATI (aujourd'hui rachetée par AMD), avec la technologie de tesselation TrueForm. Elle utilisait un circuit non-programmable, qui tessellait certaines surfaces et interpolait la forme de la surface entre les sommets. ATI améliora ensuite le TrueForm pour que des informations de tesselation soient lues depuis une texture, ce qui permet une implémentation de la technologie dite du displacement mapping. En même temps, Matrox ajouta un algorithme de tesselation basé sur la technique de N-patch dans ses cartes graphiques. Mais ces techniques se basaient sur des algorithmes matériels non-programmables, ce qui rendait ces technologies insuffisamment flexibles et impraticables. De plus, c'était des technologies propriétaires, que les autres fabricants de cartes graphiques n'ont pas adopté. Elles sont donc tombées en désuétude.
La tesselation a eu un regain d'intérêt à l'arrivée des geometry shaders dans DirectX 10 et OpenGL 3.2. Et il y avait de quoi, de tels shaders pouvant en théorie implémenter une forme limitée de tesselation. Mais le cout en performance est trop important, sans compter que les limitations de ces shaders n'a pas permis leur usage pour de la tesselation généraliste. Une nouvelle étape a été franchie avec l'AMD Radeon HD2000 et le GPU de la Xbox 360, qui permettaient une tesselation partiellement programmable. La tesselation se faisait en deux étapes : une étape de découpage des triangles et une étape de modification des sommets créés. La première étape était un algorithme matériel configurable mais non-programmable, alors que la seconde était programmable. Mais le manque de support logiciel et le fait qu'on ne pouvait pas utiliser la tesselation en même temps que les geometry shader, ainsi que la non-utilisation de cette technique par NVIDIA, a fait que cette technique n'a pas été reprise dans les GPU suivants. Il fallut attendre l'arrivée des tesselation shaders dans OpenGL 4.0 pour que des shaders adéquats arrivent sur le marché commercial. La tesselation sur ces cartes graphiques se fait en trois étapes : deux shaders et un algorithme matériel fixe. En fait, DirectX 11 rajoute une étape programmable avant le découpage des triangles par l'unité matérielle configurable.
Le rasterizeur
À 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
modifierA 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.
La rastérisation
modifierUne 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
modifierSi 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
modifierPar 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
modifierL'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.
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.
La coordonnée de profondeur
modifierChaque 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
modifierLa 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
modifierIl 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.
Rappelons que la carte graphique change régulièrement de shader à exécuter. Et il arrive qu'on passe d'un shader compatible avec l'élimination précoce à un shader incompatible ou inversement. Passer d'un shader qui est compatible avec l'élimination précoce à un qui ne l'est n'est pas un problème. Il suffit de désactiver l'unité d'élimination précoce lors du changement de shader. Mais dans le cas inverse, quelques problèmes de synchronisation peuvent apparaitre.
Il faut activer l'élimination précoce quand les pixels du nouveau shader sortent du circuit de rastérisation, ce qui n'est pas exactement le même temps que le changement de shader. En effet, le shader précédent a encore des pixels qui traversent le pipeline et qui sont en cours de calcul dans les pixels shaders ou dans les ROP. Le processeur de commande doit donc faire attendre les processeurs de shader et quelques autres circuits. Typiquement, il faut attendre que la commande précédente se termine, avant d'en relancer une autre avec le nouveau shader.
L'interpolation des pixels
modifierUne fois l'étape de triangle setup terminée, on sait donc quels sont les pixels situés à l'intérieur d'un triangle donné. Mais il faut aussi remplir l'intérieur des triangles : les pixels dans le triangle doivent être coloriés, avoir une coordonnée de profondeur, etc. Or, à cette étape du rendu, seuls les sommets sont coloriés et ont une coordonnée de profondeur. Pour les pixels situés exactement sur les sommets, on peut reprendre la couleur et la profondeur du sommet associé. Mais il faut trouver une solution pour les autres pixels.
Pour les autres pixels, nous sommes obligés d'extrapoler la couleur et la profondeur à partir des données situées aux sommets. C'est le rôle de l'étape d'interpolation, qui calcule les informations des pixels qui ne sont pas pile-poil sur un sommet. Par exemple, si j'ai un sommet vert, un sommet rouge, et un sommet bleu, le triangle résultant doit être colorié comme indiqué dans le schéma de droite.
Les coordonnées barycentriques
modifierPour 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.
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
modifierMaintenant, parlons un petit peu des coordonnées de texture. Pour rappel, les coordonnées de texture permettent d'appliquer une texture à un modèle 3D. Il y a un ensemble coordonnée de texture par sommet, qui précise quelle texture appliquer, mais aussi où se situe le texel à appliquer dans la texture. Par exemple, la coordonnée de texture peut dire : je veux le pixel qui est à ligne 5, colonne 27 dans cette texture. Dans les faits, on n'utilise pas de coordonnées entières de ce type, mais deux nombres flottants compris entre 0 et 1. La coordonnée 0,0 correspond au texel en bas à gauche, celui de coordonnée 1,1 est tout en haut à droite. Les deux coordonnées de texture sont notées u,v avec DirectX, ou encore s,t dans le cas général : u est la coordonnée horizontale, v la verticale.
Lors de la rastérisation, chaque fragment se voit attribuer un triangle, et les coordonnées de texture qui vont avec. Si un pixel est situé pile sur un sommet, les coordonnées de texture de ce sommet est attribuée au pixel. Si ce n'est pas le cas, les coordonnées de texture sont interpolées à partir des coordonnées des trois sommets du triangle rastérisé. Il existe plusieurs moyens de faire cette interpolation, mais le plus simple est l'interpolation affine, identique à celle effectuée pour les autres valeurs interpolées. Concrètement, on fait une moyenne pondérée des coordonnées de texture u et v des trois sommets pour obtenir les coordonnées de textures finales. Par contre, en faisant cela, la perspective n'est pas correctement rendue. Tout objet penché ou qui ne fait pas face à la caméra donnera un effet graphique assez bizarre. L'image ci-dessous le montre assez clairement, avec l'un côté une texture qui face à l'écran à gauche, le résultat de l'interpolation linéaire au milieu, le résultat correct à droite. Intuitivement, on pouvait le deviner : la coordonnée de profondeur (z) n'était pas prise en compte dans le calcul de l’interpolation.
L'interpolation affine était utilisée sur la console Playstation 1 de Sony. Ce choix quelque peu étrange marchait pourtant pas trop mal avec une interpolation affine. D'autres consoles utilisaient cette interpolation affine, masi s'en sortaient mieux grâce à un stratagème. Au lieu d'utiliser des triangles, le rendu utilisait des carrés/rectangles. Avec des primitives rectangulaires, le résultat a l'air visuellement, meilleur, car l'interpolation donne un bon résultat pour ce qui va à l'horizontal, seule les objets à la verticale de la caméra donnant une perspective légèrement déformée. Tout cela est bien illustré ci-dessous. Cependant, l'interpolation est alors plus lourde en calculs, car elle demande d'interpoler quatre sommets au lieu de trois. Le cout en calculs n'est pas négligeable.
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
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
modifierPour 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.
- 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
modifierJ'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
modifierUne 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
modifierLe 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
modifierEn 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.
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
modifierUne 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.
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
modifierLes 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.
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.
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
modifierLe 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
modifierVous 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).
Le mip-mapping améliore grandement la qualité d'image. L'image d'exemple ci-dessous le montre assez bien.
É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
modifierIl 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.
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.
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.
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
modifierPour 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.
L'implémentation du mip-mapping
modifierLe 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.
La gestion des accès mémoire
modifierEnfin, 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.
É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
modifierIl 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.
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
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
modifierPlaquer 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.
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.
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
modifierLa 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.
Le filtrage linéaire
modifierLe 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.
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
modifierLe 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.
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.
- 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
modifierAvec 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.
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.
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
modifierD'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.
La compression de textures
modifierLes 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
modifierLa 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
modifierLa 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.
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
modifierL'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.
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
Le circuit de décompression du DXTC ressemble alors à ceci :
Les format DXTC 2, 3, 4 et 5 : l'ajout de la transparence
modifierPour 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.
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.
Le format de compression PVRTC
modifierPassons 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
modifierLes 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é)
modifierLe 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.
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.
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 :
La gestion de la transparence : test alpha et alpha blending
modifierEn 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.
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
modifierLe 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 autres fonctions des ROPs
modifierLe ROP peut aussi ajouter des effets de brouillard dans une scène 3D. Les premières cartes graphiques calculaient le brouillard dans les ROP, en fonction de la coordonnée de profondeur du fragment. De nos jours, il est calculé par les pixel shaders et les ROP n'incorporent plus de technique de brouillard spécialisés.
Pour calculer le brouillard, on mélange la couleur finale du pixel avec une couleur de brouillard, la couleur de brouillard étant pondérée par la profondeur. Au-delà d'une certaine distance, l'objet est intégralement dans le brouillard : le brouillard domine totalement la couleur du pixel. En dessous d'une certaine distance, le brouillard est à zéro. Entre les deux, la couleur du brouillard et de l'objet devront toutes les deux être prises en compte dans les calculs. La formule de calcul exacte varie beaucoup, elle est souvent linéaire ou exponentielle. 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.
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
modifierLes 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.
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
modifierLa 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
modifierUne 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.
Le cache de profondeur
modifierUne 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
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
modifierLa 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.
La position des sous-pixels
modifierUn 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.