Aller au contenu

Les cartes graphiques/Version imprimable

Un livre de Wikilivres.

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


Les cartes graphiques

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

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

Les cartes d'affichage

Les cartes graphiques sont des cartes qui communiquent avec l'écran, pour y afficher des images. Les cartes graphiques modernes incorporent aussi des circuits de calcul pour accélérer du rendu 2D ou 3D. Dans ce chapitre, nous allons faire une introduction et expliquer ce qu'est une carte graphique et surtout : nous allons voir ce qu'il y a à l'intérieur, du moins dans les grandes lignes.

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

[modifier | modifier le wikicode]

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

Carte graphique dédiée PX 7800 GTX I

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

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

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

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

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

Un PC avec plusieurs GPU : la commutation de GPU

[modifier | modifier le wikicode]

De nos jours, il y a de très fortes chances que votre ordinateur intègre plusieurs cartes graphique, peu importe que ce soit un PC portable ou fixe. Tous les PC ont une carte graphique intégrée, de faible performance, qui consomme peu d'énergie/électricité. Et si je dis presque tous, c'est parce que tous les processeurs commerciaux modernes incorporent une carte graphique intégrée. Le marché du processeur grand public est ainsi, seuls quelques processeurs dédiés aux serveurs n'ont pas de carte graphique intégrée. Et en plus de la carte intégrée, une bonne partie des PC intègrent aussi soit une carte dédiée, soit une carte soudée. Soudée sur les PC portables, dédiée sur les PC fixe.

Dans le passé, il était possible de mettre plusieurs cartes graphiques dédiées dans un même PC, mais avec des conditions drastiques. ATI/AMD et NVIDIA avaient ajouté des fonctionnalités de multi-GPU, qui permettaient à deux GPU de travailler ensemble, afin de presque doubler les performances. Mais cela ne marchait qu'avec deux GPU NVIDIA ou deux GPU ATI/AMD, utiliser deux GPU de deux marques différentes ne marchait pas. Un chapitre entier sera dédié à ces techniques, mais nous n'en parlerons pas ici, car elles sont tombées en désuétude, aucun GPU grand public ne supporte ces technologies.

S'il y a deux cartes graphiques, cela ne signifie pas que les deux sont utilisées en même temps. En effet, selon les circonstances, le PC va privilégier l'une ou l'autre. Dans les années 2010, le choix se faisait dans le BIOS : une des deux carte graphique était désactivée pour de bon, typiquement la carte intégrée. Les PC avec une carte dédiée désactivaient la carte intégrée dans le processeur, pour éviter tout conflit entre les deux cartes.

De nos jours, les deux sont utilisables, mais pas en même temps. Le système d'exploitation, Windows ou linux, utilise soit la carte intégrée, soit la carte dédiée, suivant les besoins. La carte dédiée a de bonnes performance, mais elle consomme beaucoup d'énergie/électricité et chauffe plus. La carte graphique intégrée fait l'inverse : ses performances sont basses, mais elle consomme très peu et chauffe moins. La carte dédiée est donc utilisée quand on a besoin de performance, l'intégrée est utilisée quand elle suffit, afin de faire des économies. Prenons l'exemple d'un jeu vidéo : un jeu ancien et peu gourmand sera exécuté sur la carte intégrée, alors qu'un jeu récent/gourmand sera exécuté sur la carte dédiée. Le rendu du bureau de Windows/linux est réalisé par la carte graphique intégrée, pour économiser de l'énergie.

La connexion des cartes graphiques à l'écran

[modifier | modifier le wikicode]

Prenons un PC fixe avec deux cartes graphiques, une intégrée et une dédiée. En général, il y a deux connecteurs pour l'écran, un qui est relié à la carte graphique intégrée, un autre qui est sur la carte dédiée proprement dite. Suivant là où vous brancherez l'écran, vous n'utiliserez pas la même carte graphique. Le système d'exploitation se charge d'envoyer les images à afficher à la carte graphique adéquate.

Sur un PC portable gaming, les choses sont différentes. Il n'y a qu'un seul connecteur pour l'écran, pas deux. Et dans ce cas, il y a deux possibilités.

La première est la plus simple. Les deux cartes graphiques sont reliées au connecteur écran, par l'intermédiaire d'un circuit multiplexeur. Le circuit multiplexeur reçoit les images à afficher de la part des deux cartes graphiques et choisit l'une d'entre elle. C'est la solution la plus performante, car la carte dédiée peut afficher directement ses images à l'écran, sans avoir à les envoyer à la carte intégrée. Mais elle complexifie le câblage et demande d'ajouter un circuit multiplexeur, ce qui n'est pas gratuit.

Commutation de GPU avec un MUX

Avec la seconde solution, une seule carte graphique est connectée à l'écran, généralement la carte intégrée. Si la carte dédiée est utilisée, les images qu'elle calcule sont envoyées à la carte intégrée pour ensuite être affichées à l'écran. On passe par un intermédiaire, mais le câblage est plus simple.

Commutation de GPU sans MUX

Les cartes d'affichage et leur architecture

[modifier | modifier le wikicode]

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.

Au tout début de l'informatique, le rendu graphique était pris en charge par le processeur. Il calculait l'image à afficher et l'envoyait à l'écran, pixel par pixel. Le problème est que le processeur devait se synchroniser avec l'écran, pour envoyer les pixels au bon moment. Pour simplifier la vie des programmeurs, les fabricants de matériel ont inventé des cartes vidéo. Avec celles-ci, le processeur calcule l'image à envoyer à l'écran et la transmet à la carte d'affichage, sans avoir à se synchroniser avec l'écran. L'avantage est que le processeur n'a pas à se synchroniser avec l'écran, juste à envoyer l'image à une carte d'affichage.

Les cartes d'affichage ne géraient pas le rendu 3D. Le processeur calculait une image, la copiait dans la mémoire vidéo, puis la carte d'affichage l'envoyait à l'écran au bon moment. 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.

L'intérieur d'une carte d'affichage

[modifier | modifier le wikicode]

Une carte d'affichage contient 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, regroupe les connecteurs et tous les circuits permettant d'envoyer l'image à l'écran.
  • 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.
  • Un circuit de contrôle qui commande le tout, appelé le Video Display Controler.
Carte d'affichage - architecture.

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 Video Display Controler commande les autres circuits. Le Video Display Controler 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.

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, lui-même remplacé par 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 écran est au minimum un 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. Plusieurs chapitres seront dédiés à leur fonctionnement. Mais parlons maintenant des GPU modernes et passons à la section suivante.

Un historique rapide des cartes d’affichage

[modifier | modifier le wikicode]

Les cartes d'affichages sont opposées aux cartes accélératrices 2D et 3D, qui permettent de décharger le processeur d'une partie du rendu 2D/3D. Pour cela, elles intègrent des circuits spécialisés. Vous imaginez peut-être que les cartes d'affichage sont apparues en premier, puis qu'elles ont gagné en puissance et en fonctionnalités pour devenir d'abord des cartes accélératrices 2D, puis des cartes 3D. C'est une suite assez logique, intuitive. Et ce n'est pas du tout ce qui s'est passé !

Les cartes d'affichage pures, sans rendu 2D, sont une invention des premiers PC. Elles sont arrivées alors que les consoles de jeu avaient déjà des cartes hybrides entre carte d'affichage et cartes de rendu 2D depuis une bonne décennie. Sur les consoles de jeu ou les microordinateurs anciens, il n'y avait pas de cartes d'affichage séparée. À 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 différents types de cartes d'affichage

[modifier | modifier le wikicode]

Dans la suite du cours, nous allons voir que toutes les cartes d'affichage ne fonctionnent pas de la même manière. Et ces différences font qu'on peut les classer en plusieurs types distincts. Leur classement s'explique par un fait assez simple : une image prend beaucoup de mémoire ! Par exemple, prenons le cas d'une image en niveaux de gris d'une résolution de 320 par 240 pixels, chaque pixel étant codé sur un octet. L'image prend alors 76800 octets, soit environ 76 kiloctets. Mine de rien, cela fait beaucoup de mémoire ! Et si on ajoute le support de la couleur, cela triple, voire quadruple la taille de l'image.

Les cartes graphiques récentes ont assez de mémoire pour stocker l'image à afficher. Une partie de la mémoire vidéo est utilisée pour mémoriser l'image à afficher. La portion en question s'appelle le framebuffer, tampon d'image en français. Il s'agit là d'une solution très simple, mais qui demande une mémoire vidéo de grande taille. Les systèmes récents peuvent se le permettre, mais les tout premiers ordinateurs n'avaient pas assez de mémoire vidéo. Les cartes d'affichages devaient se débrouiller avec peu de mémoire, impossible de mémoriser l'image à afficher entièrement.

Pour compenser cela, les cartes d'affichage anciennes utilisaient diverses optimisations assez intéressantes. La première d'entre elle utilise pour cela le fonctionnement des anciens écrans CRT, qui affichaient l'image ligne par ligne. Pour rappel, l'image a afficher à l'écran a une certaine résolution : 320 pixels pour 240 pixels, par exemple. Pour l'écran CRT, l'image est composée de plusieurs lignes. Par exemple, pour une résolution de 640 par 480, l'image est découpée en 480 lignes, chacune faisant 640 pixels de long. L'écran est conçu pour qu'on lui envoie les lignes les unes après les autres, avec une petite pause entre l'affichage/envoi de deux lignes. Précisons que les écrans LCD ont abandonné ce mode de fonctionnement.

L'idée est alors la suivante : la mémoire vidéo ne mémorise que la ligne en cours d'affichage par l'écran. Le processeur met à jour la mémoire vidéo entre l'affichage de deux lignes. La mémoire vidéo n'est alors pas un tampon d'image, mais un tampon de ligne. Le défaut de cette technique est qu'elle demande que le processeur et la carte d'affichage soient synchronisés, de manière à ce que les lignes soient mises à jour au bon moment. L'avantage est que la quantité de mémoire vidéo nécessaire est divisée par un facteur 100, voire plus, égal à la résolution verticale (le nombre de lignes).

Une autre méthode, appelée le rendu en tiles, est formellement une technique de compression d'image particulière. L'image à afficher est stockée sous un format compressé en mémoire vidéo, mais est décompressée pixel par pixel lors de l'affichage. Il nous est difficile de décrire cette technique maintenant, mais un chapitre entier sera dédié à cette technique. Le chapitre en question abordera une technique similaire, appelée le rendu en mode texte, qui servira d'introduction propédeutique.

Le rendu en tiles et l'usage d'un tampon ligne sont deux optimisations complémentaires. Il est ainsi possible d'utiliser soit l'une, soit l'autre, soit les deux. En clair, cela donne quatre types de cartes d'affichage distincts :

  • les cartes d'affichage à tampon d'image ;
  • les cartes à tampon d'images en rendu à tiles ;
  • les cartes d'affichage à tampon de ligne ;
  • les cartes d'affichage à tampon de ligne en rendu à tiles.
Rendu normal Rendu à tile
Tampon d'image Cartes graphiques post-années 90, standard VGA sur PC Consoles de jeu 8 bits et 16 bits, standards CGA, MGA
Tampon de ligne Consoles de jeu ATARI 2600 et postérieures Console de jeu néo-géo

Lez prochains chapitres porteront surtout sur les cartes d'affichages à tampon d'image, plus simples à, expliquer. Deux chapitres seront dédiés respectivement aux cartes à rendu en tile, et aux cartes à tampon de ligne.

Les cartes graphiques actuelles sont très complexes

[modifier | modifier le wikicode]

Les cartes graphiques actuelles sont des cartes d'affichage améliorées auxquelles on a ajouté des circuits annexes, afin de leur donner des capacités de calcul pour le rendu 2D et/ou 3D, mais elles n'en restent pas moins des cartes d'affichages. La seule différence est que le processeur n’envoie pas une image à la mémoire vidéo, mais que l'image à afficher est calculée par la carte graphique 2D/3D. Les calculs autrefois effectués sur le CPU sont donc déportés sur la carte graphique.

Si vous analysez une carte graphique récente, vous verrez que les circuits des cartes d'affichage sont toujours là, bien que noyés dans des circuits de rendu 2D/3D. On retrouve une mémoire vidéo, une interface écran, un circuit d'interface avec le bus, un Video Display Controler. L'interface écran est par contre fusionnée avec le Video Display Controler. Mais à ces circuits d'affichage, sont ajoutés un GPU (Graphic Processing Unit), qui s'occupe du rendu 3D, du rendu 2D, mais aussi d'autres fonctions comme du décodage de fichiers vidéos.

Architecture d'une carte graphique avec un GPU

L'intérieur d'un GPU

[modifier | modifier le wikicode]

Le GPU est un composant assez complexe, surtout sur les cartes graphiques modernes. Il regroupe plusieurs circuits aux fonctions distinctes.

  • Un Video Display controler est toujours présent, mais ne sera pas représenté dans ce qui suit.
  • Des circuit de rendu 2D séparés peuvent être présents, mais sont la plupart du temps intégrés au VDC.
  • Les circuits de rendu 3D s'occupent du rendu 3D.
  • Les circuits de décodage vidéo, pour améliorer la performance du visionnage de vidéos.

Les trois circuits précédents sont gouvernés par un processeur de commande, un circuit assez difficile à décrire. Pour le moment, disons qu'il s'occupe de répartir le travail entre les différents circuits précédents. Le processeur de commande reçoit du travail à faire, envoyé par le CPU via le bus, et le redistribue dans les trois circuits précédents. Par exemple, si on lui envoie une vidéo encodée en H264, il envoie le tout au circuit vidéo. De plus, il le configure pour qu'il la décode correctement : il dit au circuit que c'est une vidéo encodée en H264, afin que le circuit sache comment la décoder. Ce genre de configuration est aussi présente sur les circuits de rendu 2D et 3D, bien qu'elle soit plus compliquée.

Microarchitecture simplifiée d'un GPU

Un GPU contient aussi un contrôleur mémoire, qui est une interface entre le GPU et la mémoire vidéo. Interface électrique, car GPU et mémoire n'utilisent pas les mêmes tensions et qu'en plus, leur interconnexion demande de gérer pas mal de détails purement électriques. Mais aussi interface de communication, car communiquer entre mémoire vidéo et GPU demande de faire pas mal de manipulations, comme gérer l'adressage et bien d'autres choses qu'on ne peut pas encore détailler ici.

Un GPU contient aussi d'autres circuits annexes, comme des circuits pour gérer la consommation électrique, un BIOS vidéo, etc. L'interface écran et l'interface avec le bus sont parfois intégrées au GPU.

Architecture d'une carte graphique moderne.

Un historique simplifié des pipelines 2D/3D/vidéo

[modifier | modifier le wikicode]

Avant les années 90, les cartes graphiques des ordinateurs personnels et des consoles de jeux se résumaient à des circuits d'accélération 2D, elles n'avaient pas de décodeurs vidéos ou de circuits de rendu 3D. C'est dans les années 90 qu'elles ont commencé à incorporer des circuits d'accélération 3D. Puis, après les années 2000-2010, elles ont commencé à intégrer des circuits de décodage vidéo, aux alentours des années 2010. En parallèle, les circuits de rendu 2D ont progressivement été réduits puis abandonnés.

Les circuits de rendu 2D sont des circuits qui ne sont pas programmables, mais qu'on peut configurer. On dit aussi que ce sont des circuits fixes. Ils sont opposés aux circuits programmables, basés sur des processeurs. Les circuits de rendu 3D étaient initialement des circuits fixes eux aussi, mais sont devenus de plus en plus programmables avec le temps. Les GPU d'après les années 2000 utilisent un mélange de circuits programmables et fixes assez variable. Nous reviendrons là-dessus dans un chapitre dédié.

Microarchitecture globale d'un GPU

Pour les circuits de décodage vidéo, les choses sont assez complexes. Initialement, ils s'agissait de circuits fixes, qu'on pouvait programmer. Un GPU de ce type est illustré ci-dessus. Mais de nos jours, les calculs de décodage vidéo sont réalisés sur les processeurs de shaders. Les circuits de rendu 3D et de décodage vidéo ont chacun des circuits fixes dédiés, mais partagent des processeurs.

GPU avec processeurs de shaders partagés entre rendu 3D et décodage vidéo

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.


Le Video Display Controler

Dans les années 70-80, un système vidéo pouvait être fabriqué de deux grandes manières différentes. La première concevait la carte d'affichage à partir de composants très simples, comme des portes logiques ou des transistors, à partir de zéro, sans réutiliser de matériel existant. De telles cartes vidéos avaient des performances et des fonctionnalités très variables, mais étaient très complexes à concevoir et coutaient cher.

La seconde catégorie utilisait des Video Display Controler (VDC), des circuits déjà tout près, placés dans un boitier, produits en masse, qu'il suffisait de compléter avec une mémoire vidéo et quelques autres circuits pour obtenir un système vidéo. De tels circuits permettaient d'obtenir des performances décentes, voire très bonnes, pour un prix nettement inférieur. Les deux fonctionnent de la même manière, peu importe qu'il s'agisse d'un VDC ou d'un circuit fait main. Les deux contiennent globalement les mêmes circuits, ils fonctionnent de la même manière.

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

Architecture d'une carte d'affichage avec VDC

Si la plupart des VDC communiquent avec la mémoire vidéo, il existe quelques exceptions qui se débrouillent sans mémoire vidéo, comme les Video shifters dont nous parlerons dans quelques chapitres. 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 avec la mémoire vidéo. Dans ce chapitre, nous allons voir comment il communique avec l'écran et le processeur. Nous laissons de côté l'interface avec la mémoire vidéo, car elle dépend du VDC et n'est pas la même selon que la carte d'affichage utilise ou non un framebuffer.

Le tout est illustré ci-dessous. L'interface VDC-écran correspond aux flèches en rouge et sera vue dans la première section. L'interface VDC-processeur correspond aux flèches en bleu et est le sujet de la seconde section. Enfin, l'interface entre mémoire vidéo et processeur correspond aux flèches en vert. En soi, elle n'est pas liée directement au VDC, mais nous allons quand même la voir dans ce chapitre.

Interface d'un VDC.

L'interface du VDC avec l'écran

[modifier | modifier le wikicode]
Coordonnées d'un pixel à l'écran.

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

Le balayage progressif et l'entrelacement

[modifier | modifier le wikicode]

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

Avec le balayage progressif, la carte graphique doit envoyer les pixels ligne par ligne, colonne par colonne : de haut en bas et de gauche à droite. Le balayage progressif est utilisé sur tous les écrans LCD moderne, mais il était plus adapté aux écrans CRT. Sur les écrans plats, l'image est transmise à l'écran, mais est affichée une fois qu'elle est intégralement reçue, d'un seul coup. Mais sur les anciens écrans de télévision, les choses étaient différentes.

Les vieux écrans CRT fonctionnaient sur ce principe : un canon à électrons balayait l'écran en commençant en haut à gauche, et balayait l'écran ligne par ligne. Ce scan progressif de l'image faisait apparaître l'image progressivement et profitait de la persistance rétinienne pour former une image fixe. L'image était donc affichée en même temps qu'elle était envoyée et le scan progressif correspondait à l'ordre d'allumage des pixels à l'écran.

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

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

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

L'entrelacement donne l'illusion de doubler la fréquence d'affichage, ce qui est très utile sur les écrans à faible fréquence de rafraîchissement. Pour comprendre pourquoi, il faut comparer ce qui se passe entre un écran à scan progressif non-entrelacé et un écran entrelacé. Avec l'écran non-entrelacé, l'image met un certain temps à s'afficher, qui correspond au temps que met le canon à électron à balayer la totalité de l'écran, ligne par ligne. Avec l'entrelacement, le temps mis pour balayer l'écran est le même, car le nombre de lignes à balayer reste le même, seul l'ordre change.

Sur l'écran entrelacé, l'image s'affiche à moitié une première fois (sur les lignes paires) avant que l'image complète s'affiche. La moitié d'image affichée par l'écran entrelacé a une résolution suffisante pour que le cerveau humain soit trompé et perçoive une image presque complète. En clair, le cerveau verra deux images par balayage complet : une image partielle lors du balayage des lignes paires et une image complète lors du balayage des lignes impaires. Sans entrelacement, le cerveau ne verra qu'une seule image lors de chaque balayage complet.

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

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

Entrelacement sur tube cathodique.
Entrelacement sur tube cathodique.

La fréquence de rafraichissement

[modifier | modifier le wikicode]

Même si cela commence à changer de nos jours, l'écran affiche un certain nombre d'images par secondes, le nombre en question étant désigné sous le terme de fréquence de rafraîchissement. Pour un écran avec une fréquence de rafraîchissement de 60 Hz (60 images par secondes), la carte graphique doit envoyer une nouvelle image tous les (1 seconde / 60) = 16,666... millisecondes.

Sur les écrans LCD, la fréquence de rafraîchissement ne dépend pas de la résolution utilisée, en raison de différences de technologie. Sur les anciens écrans CRT, la fréquence de rafraîchissement dépendait de la résolution utilisée, et la carte d'affichage devait alors gérer le couple résolution-fréquence elle-même et la gestion de la fréquence de rafraîchissement était donc plus compliquée.

Depuis environ 2016, quelques écrans supportent une fréquence de rafraichissement variable. Variable dans le sens : peut varier entre une fréquence minimale et une fréquence maximale selon les besoins. L'écran reçoit des images de part de la carte graphique, et les affiche immédiatement, sans attendre un signal de synchronisation vertical de fréquence fixe. Tant que la carte d'affichage ne va pas trop vite, l'écran suit, il affiche les images dès qu'il les reçoit. Par contre, au-delà d'un certain flux d'image, il bloque à une fréquence de rafraichissement maximale.

Les bénéfices d'une fréquence de rafraichissement variable sont nombreux. Déjà, le temps de latence est réduit, l'input lag si cher aux joueurs compétitifs est réduit de quelques millisecondes. De plus, la qualité d'image est améliorée du fait de l'absence de screen tearing sur lequel on reviendra plus tard.

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

[modifier | modifier le wikicode]

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

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

Déjà, il faut tenir compte des timings liés à la transmission de l'image elle-même. La carte graphique doit envoyer les pixels avec des timings tout aussi stricts, qui dépendent du standard vidéo utilisé. Chaque pixel doit être maintenu durant un certain temps bien précis, il y a un certain temps entre la transmission de deux pixels, etc. Et le circuit d’interfaçage doit gérer le temps de transmission d'un pixel. Pour cela, le VDC envoie un signal d'horloge dont la période correspond au temps de transmission/affichage d'un pixel. En, clair, le VDC envoie un pixel à chaque cycle d'horloge.

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

Enfin, il faut aussi tenir compte d'autres timings pour gérer la résolution. Les pixels sont envoyés ligne par ligne, mais une ligne de pixel n'a pas la même taille suivant la résolution : 640 pixels pour du 640 × 480, 1280 pour du 1280 × 1024, etc. La carte graphique doit donc indiquer quand commencent et se terminent chaque ligne dans le flux de pixels. Sans cela, on ne pourrait pas gérer des résolutions différentes. Pour cela, le VDC envoie un signal de synchronisation horizontale une fois qu'il a fini d'envoyer une ligne.

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

L'exemple du standard VGA

[modifier | modifier le wikicode]

Un bon exemple est le standard VGA, qui était le seul utilisé pour connecter les écrans CRT, mais qui est encore utilisé de nos jours sur les écrans LCD. Avec ce standard, le connecteur contenait trois fils R, G, et B pour envoyer la couleur, codée en analogique. Il existait un fil H-SYNC pour indiquer qu'on transmettait une nouvelle ligne et un fil V-SYNC pour indiquer qu'on envoie une nouvelle image. Une nouvelle ligne ou image est indiquée en mettant un 0 sur le fil adéquat. Jusque là, rien de surprenant, c'est une redite de ce qu'on a dit plus haut. On trouve aussi plusieurs fils pour la masse, à savoir le 0 Volt, ainsi qu'une tension d'alimentation. Il y a une masse générale, ainsi que plusieurs masses, une par signal RGB.

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

Connecteurs VGA

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

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

Et il y a la même chose avec les signaux VSYNC, même si les timings sont différents. On devait attendre un certain temps entre la transmission de deux lignes, ce qui introduisait des vides dans le flux de pixels. Même chose entre deux images, sauf que le temps d'attente était plus long que le temps d'attente entre deux lignes. Le tout est détaillé dans le schéma ci-dessous, qui détaille le cas pour une résolution de 640 par 480.

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

L'interface entre le processeur et le VDC

[modifier | modifier le wikicode]

Pour le processeur, le VDC a une interface similaire à celle de n'importe quel périphérique : un paquet de registres, et éventuellement de la RAM. La mémoire vidéo peut être intégrée dans le VDP ou être séparée, les deux sont possibles. Mais nous allons partir du principe que la mémoire vidéo est séparée. Dans cette section, nous allons voir ce qui a trait aux échanges entre CPU et VDC proprement dit, la communication VDC-VRAM sera le sujet d'une section ultérieure.

L'interface entre processeur et mémoire vidéo

[modifier | modifier le wikicode]

La mémoire vidéo est généralement visible par le processeur, à savoir qu'il peut lire ou écrire dedans. Il existe plusieurs méthodes pour cela, mais celle utilisée sur le hardware ancien est celle 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.

L'idée est que le processeur utilise la même interface pour la mémoire RAM et les périphériques. Des adresses mémoire sont détournées : elles ne pointent plus vers la mémoire RAM, mais vers la mémoire vidéo, la mémoire de la carte son, etc. Le processeur peut donc lire ou écrire directement dans la mémoire vidéo. Des circuits sur la carte mère s'occupent de ce détournement. Toute lecture ou écriture dans une adresse détournée est interceptée par la carte mère et redirigée vers le bus adéquat.

Espace d'adressage classique avec entrées-sorties mappées en mémoire

Les registres de configuration du VDC

[modifier | modifier le wikicode]

La programmation d'un VDC se fait par en configurant des registres de configuration, 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é.

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

En général, les registres de configuration sont accessibles directement par le processeur, comme l'est la mémoire vidéo. Des adresses mémoire sont détournées pour pointer, non pas vers la mémoire RAM, mais vers les registres de configuration. Dans le cas le plus simple, il y a une adresse mémoire par registre, le processeur a juste à écrire dans cette adresse pour configurer le registre.

Cependant, cette méthode ne marche pas trop bien s'il y a trop de registres de configuration. Par exemple, les cartes graphiques VGA intègrent plus de 300 registres de configuration, chacun faisant plusieurs octets. Il faudrait alors détourner presque un kilo-octet de mémoire, ce qui est un peu beaucoup. Pour éviter cela, les registres ne sont pas mappés directement en mémoire RAM, mais passent par un intermédiaire. L'adressage des registres de configuration se fait via une adresse unique, partagée entre tous les registres de configuration. Toute écriture ou lecture dans cette adresse est redirigée vers le bon registre, grâce à un mécanisme d'indicage qu'on va détailler immédiatement.

Les registres de configuration sont numérotés, afin de pouvoir les adresser. Le numéro en question est appelé un indice de registre. La configuration d'un registre se fait en deux temps : le processeur é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. Un défaut de cette approche est que configurer le VDC demande deux instructions : une pour écrire l'indice/numéro, une autre pour écrire la donnée. C'est deux fois plus que si on avait des registres de configuration mappés en mémoire. Par contre, le tout est beaucoup plus économe en adresses mémoire détournées.

Pour donner un exemple réel, prenons les cartes graphiques qui respectent le standard VGA. Une carte VGA contient plusieurs centaines de registres. Heureusement, ils ne sont pas tous mappés en mémoire, le mécanisme décrit précédemment est utilisé en lieu et place. Pour configurer la carte VGA, le processeur envoie l'indice de registre, puis la donnée. La carte VGA récupère ces deux informations, détermine quel registre de configuration est concerné grâce à l'indice, puis effectue l'écriture de la donnée. L'écriture de l'indice et de la donnée se fait grâce à deux registres séparés : un registre d'indice et un registre de données. Les deux sont mappés en mémoire RAM.

La description faite est cependant approximative. En réalité, une carte VGA dispose de 4 paires de registres, chaque paire contenant un registre d'indice et un registre de données. La raison est que le standard VGA est le successeur du standard EGA, avec lequel il maintient une certaine compatibilité descendante. Or, une carte EGA était composée de 4 circuits, chacun ayant ses propres registres : un CRTC, un RAMDAC, un Sequence Controller (SC) et un Graphics Controller (GC). Le VGA reprend cette séparation pour les registres de configuration.

Il y avait donc une séparation logique entre les registres du RAMDAC, ceux du CRTC, et des deux autres circuits. Les registres du RAMDAC avait droit à une paire de registres, avec un registre d'indice et un registre pour les transferts de données, idem pour les registres du CRTC, etc. Au-delà de ça, le standard VGA impose la présence de deux registres de status, d'un registre de sortie sur lequel le processeur peut lire une donnée, et de quelques registres annexes. Après, la carte VGA pouvait intégrer tous ces registres dans un VDC unique, et beaucoup de cartes VGA ne se gênaient pas.

La synchronisation entre CPU et VDC pour l'accès à la RAM vidéo

[modifier | modifier le wikicode]

Un point qui va nous intéresser dans ce qui suit est la gestion des accès mémoire. Aussi bien le processeur que le VDC accèdent à la mémoire vidéo. Et ils ne faut pas qu'ils se marchent sur les pieds. Les deux ne peuvent pas accéder en même temps à la mémoire vidéo, ils doivent y accéder à tour de rôle. Et pour cela, divers mécanismes sont implémentés. Le mécanisme le plus simple est le suivant : quand le VDC lit des pixels à afficher en mémoire vidéo, ils prévient le processeur que la RAM vidéo est occupée. Le processeur attend alors que le VDC libère la RAM vidéo.

L'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. Pour prévenir le processeur, deux méthodes sont utilisées : le pooling et les interruptions.

La synchronisation CPU-VDC par pooling

[modifier | modifier le wikicode]

La solution du pooling utilise le registre d'état de la carte d'affichage. Avant d’accéder à la mémoire vidéo, le processeur vérifiait ce registre pour savoir si le VDC accède à al mémoire vidéo. Si c'est le cas, le processeur attend que la mémoire vidéo soit libre. Sinon, le processeur accédait à la mémoire vidéo.

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.

La synchronisation CPU-VDC via raster interrupts

[modifier | modifier le wikicode]

L'usage d'un bit de blanking permet au VDC de prévenir le processeur qu'il ne peut pas écrire en RAM vidéo. Mais les VDC de ce type sont assez rudimentaires. Une autre méthode pour ce faire utilise une technique appelée les interruptions matérielles. Pour rappel, les interruptions sont des fonctionnalités du processeur, qui interrompent temporairement l’exécution d'un programme pour réagir à un événement extérieur (matériel, erreur fatale d’exécution d'un programme…). Lors d'une interruption, le processeur suit la procédure suivante :

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

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

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

Les cartes graphiques d'antan géraient plusieurs 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 plus importante s'appelle la Vertical blank interrupt (VBI). Elle indique que la carte graphique a fini d'afficher une image et servait à implémenter la synchronisation verticale. 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.

Le VDC contient donc 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.

L'usage de raster interrupts est très efficace, mais a pour défaut de beaucoup utiliser le processeur. Diverses optimisations permettent de se passer de raster interrupts, ou du moins d'en réduire le cout en performance. Mais ces techniques demandent de modifier la mémoire vidéo, précisément la manière dont le processeur communique avec la mémoire vidéo. Nous allons voir ces techniques dans ce qui suit.

Les mémoires vidéo double port

[modifier | modifier le wikicode]

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

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

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

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

Le multiplexage temporel des accès mémoire

[modifier | modifier le wikicode]

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

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

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

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

L'usage de tampons de synchronisation FIFO

[modifier | modifier le wikicode]

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

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

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

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

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

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

[modifier | modifier le wikicode]

Les VDC contiennent tous de quoi générer les signaux de commande à destination de l'écran, ainsi que des signaux d'interruption à destination du processeur. 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é.

Il faut aussi générer les signaux de synchronisation verticale/horizontale, ainsi que les raster interrupts. Et ils se trouve que les deux sont générés par les mêmes circuits, à peu de choses près. Dans ce qui va suivre, nous allons voir comment sont générés ces signaux, quels sont les circuits qui s'en chargent. Ils sont assez simples : ce sont de simples compteurs reliés à des comparateurs !

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

[modifier | modifier le wikicode]

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

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

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

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

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

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

L'exemple des timings du standard VGA

[modifier | modifier le wikicode]

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

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

Le compteur de colonne est cadencé à une fréquence bien précise, qui détermine le temps mis pour passer d'un pixel à l'autre. Le temps de transmission d'un pixel est de 25,6 µs / 640 = 0,04 µs, ce qui correspond à une fréquence de 25 MégaHertz. Et cela permet d'implémenter facilement les deux temps d'attente avant et après l'affichage d'une ligne. Les temps d'attente de 1,54 et 0,64 µs correspondent respectivement à 38 et 16 cycles du compteur, la durée de 3,8 µs du signal H-sync correspond à 95 cycles. En tout, cela fait 640 + 95 + 16 + 38 = 789. Il faut donc un compteur qui compte de 0 à 788.

La transmission des pixels commence quand le compteur commence à compter. Puis, le compteur continue de compter pendant 0,64 µs alors qu'aucun pixel n'est envoyé, afin de gérer le temps d'attente après le signal H-sync. Puis, au 640 + 16ème cycle, le signal H-sync est généré pendant 95 cycles. Enfin, le compteur continue de compter pendant 38 cycles pour le second temps d'attente, avant le prochain envoi de ligne. Le signal H-sync est donc généré quand le compteur a une valeur comprise entre 656 et 751 : il suffit d'ajouter un comparateur qui vérifie si le compteur est dans cet intervalle, et donc la sortie est à zéro si c'est le cas. L'adresse n'est pas calculée si le compteur n'a pas une valeur comprise entre 0 et la largeur indiquée par la résolution.

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

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

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

La génération des raster interrupts et des bits de blanking

[modifier | modifier le wikicode]

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 !


Les systèmes à framebuffer

Les cartes graphiques récentes utilisent une portion de la mémoire vidéo pour stocker l'image à afficher à l'écran. La portion de VRAM en question est appelée le framebuffer, ou encore le tampon d'image. La mémoire vidéo peut aussi stocker d'autres informations importantes : les textures et les vertices de l'image à calculer, ainsi que divers résultats temporaires. Mais pour le moment, concentrons-nous sur le tampon d'image.

La taille du framebuffer limite la résolution maximale atteignable. 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 = 307 200 pixels. Si chaque pixel est codé sur 32 bits, l'image prend donc 307 200 * 32 = 9 830 400 bits, soit 1 228 800 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. De manière générale, la mémoire prise par une image se calcule comme : nombre de pixels * taille d'un pixel, où le nombre de pixels de l’image se calcule à partir de la résolution (on multiplie la hauteur par la largeur de l'écran, les deux exprimées en pixels).

Le codage des pixels dans le framebuffer

[modifier | modifier le wikicode]

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

Le codage des images monochromes

[modifier | modifier le wikicode]

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

1 bit 2 bit 4 bit 8 bit

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

La technique de la palette indicée

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

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

Palette de l'IBM16.

L'implémentation de la palette indicée demande d'ajouter à la carte graphique une table de correspondance pour traduire les couleurs au format RGB. Elle s'appelait la Color Look-up Table (CLT) et est placée après la mémoire vidéo. Tout pixel qui sort de la mémoire vidéo est envoyé à la CLT, sur son entrée d'adresse. En réponse, elle fournit en sortie le pixel coloré, la couleur RGB voulue.

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 SRAM, 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. La SRAM est soit mappée en mémoire, soit accessible de manière indirecte par des commandes spécialisées. 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.

Color-lookup-table

Des applications différentes pouvaient ainsi utiliser des couleurs différentes, on pouvait adapter la palette en fonction de l'image à afficher, c'était aussi utilisé pour faire des animations sans avoir à modifier la mémoire vidéo. Les applications étaient multiples. En changeant le contenu de la palette, on pouvait réaliser des gradients mobiles, ou des animations assez simples : c'est la technique du color cycling.

Exemples d'animations obtenues avec du color Cycling

Le standard RGB et ses dérivés

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

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

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

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

RGB 16 bits RG 16 bits

L'organisation du framebuffer

[modifier | modifier le wikicode]

Le framebuffer peut être organisé plusieurs manières différentes, mais deux grandes méthodes se dégagent. Elles portent les noms de framebuffer compact et de framebuffer planaire. La première est bien plus intuitive que la seconde, c'est sans doute celle qui vous viendrait à l'esprit en premier si vous réfléchissiez à la manière de stocker une image dans une RAM vidéo. Mais elle a quelques défauts qui ne se manifestent que sur les anciennes cartes graphiques, qui devaient faire avec des limitations en RAM assez importantes. Voyons cela en détail.

Le framebuffer compact

[modifier | modifier le wikicode]

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.

Cependant, un framebuffer compact a quelques problèmes sur les vielles cartes graphiques. Le framebuffer est placé dans une mémoire RAM, qui est adressable par octet. En clair, la mémoire est composée d'octets, qui ont chacun une adresse mémoire. Le processeur et la carte graphique peuvent lire ou écrire un octet à la fois, mais on ne peut pas modifier 1 bit isolé, ni des groupes de 2 ou 4 bits. La conséquence est qu'idéalement, un pixel doit faire au moins un octet. Avec un octet par pixel, on a alors 256 couleurs possibles par pixel, ce qui était beaucoup dans les années 80-90. Il est possible de coder un pixel sur 2, 3, 4 octets, au prix d'une consommation de mémoire 2, 3, ou 4 fois plus importante.

Sur les cartes graphiques modernes, ca ne pose pas de problème. La mémoire vidéo est assez grande pour qu'on puisse attribuer entre 1 et 4 octets par pixel, parfois plus. Mais sur les anciennes cartes graphiques, ce n'était pas le cas. Par exemple, les cartes graphiques VGA disposaient de 256 kilo-octets de mémoire vidéo. Or, une simple image en résolution 640 par 480 contient 307 200 pixels, ce qui prend déjà 300 kilo-octets ! Il fallait donc limiter la taille des images, sans pour autant toucher à leur résolution. Et la seule solution était de réduire le nombre de bits par pixels.

Typiquement, une solution était de stocker plusieurs pixels par octet, les seules solutions étant d'utiliser 1, 2 ou 4 bits par pixel. Utiliser 4 bits par pixel permettait de stocker deux pixels de 16 couleurs par octets. Il était aussi possible de stocker 2 bits par pixel, de stocker 4 pixels de 4 couleurs par octets. Le cas avec des pixels de 1 bit permettait de stocker 8 pixels monochromes (deux couleurs, typiquement blanc et noir).

Mais c'était tout sauf pratique quand il fallait lire ou écrire des pixels en mémoire vidéo. L'écriture devait se faire par groupe de deux pixels, ce qui n'était pas pratique pour le programmeur. Pour écrire un seul pixel, la solution était de copier un octet dans un registre du processeur, de modifier uniquement un pixel dans cet octet, puis d'écrire le résultat en mémoire vidéo. Mais c'était très lent. Aussi, une autre solution a vu le jour, qui permettait d'encoder des pixels sur 2, 3, 4, 5, 6, 7 bits. Mais elle demandait de modifier la manière dont la mémoire RAM était utilisée pour stocker le framebuffer.

Les framebuffers planaires

[modifier | modifier le wikicode]

Une solution simple serait d'utiliser des mémoires qui ne sont pas composées d'octets, mais de groupes de 2/3/4/5/6/7 bits, chacun correspondant à un pixel. Mais il faudrait alors fabriquer ces mémoires sur-mesure pour une carte graphique bien précise. Une autre solution utilise des mémoires bit-adressables, à savoir qu'il y a non pas un octet, mais un bit par adresse mémoire. Une telle mémoire permet de lire/écrire bit par bit, et non octet par octet. L'idée est alors de prendre 2/3/4/5/6/7 mémoires bit-adressables, et de les combiner pour simuler une mémoire composée de groupes de 2/3/4/5/6/7 bits.

Prenons le cas où un pixel est codé sur 3 bits, qui sont appelés le bit de poids fort, le bit de poids intermédiaire et le bit de poids faible. Supposons qu'on veuille une mémoire qui soit capable de stocker 200 000 de pixels. Dans ce cas, on prend trois mémoires de 200 000 bits : la première mémoire stockera les bits de poids fort, une autre les bits de poids faible, et la dernière prendra les bits du milieu.

Le principe se généralise pour des pixels codés sur N bits, sauf qu'il faudra alors N mémoires RAM. Les N mémoires RAM de 1 bit sont appelées des bitplanes. Une telle organisation est ce qu'on appelle un planar framebuffer, aussi appelé un framebuffer planaire. Disons-le clairement, la méthode est compliquée et pas intuitive, mais elle permet de coder des pixels sur 2, 3, 4, 5, 6, 7, 9, 11 bits ou autre. Elle n'est plus utilisée depuis que les cartes d'affichage se sont standardisées avec une taille des pixels multiple d'un octet.

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

Avec un framebuffer planaire, un pixel est 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.

La technique a le bon gout d'être assez simple à implémenter au niveau du matériel. Le seul défaut de cette technique, au niveau matériel, est qu'elle demande d'utiliser des mémoires bit-adressables. Dans les faits, de telles mémoires ne sont pas rares, mais elles sont plus rares que les mémoires adressables par octet. Nous verrons plus bas, quand nous étudierons le standard VGA, qu'il est possible d'émuler des mémoires bit-adressable avec des mémoires adressables par octet. Mais laissons cela à plus tard.

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

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

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

Le multibuffering et la synchronisation verticale

[modifier | modifier le wikicode]

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

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

Le double buffering

[modifier | modifier le wikicode]

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

Double buffering

Quand l'image dans le back-buffer est complète, elle est copiée dans le front buffer pour être affichée. L'ancienne image dans le front buffer est donc éliminée au profit de la nouvelle image. Le remplacement peut se faire par une copie réelle, l'image étant copiée le premier tampon vers le second, ce qui est une opération très lente. C'est ce qui est fait quand le remplacement est réalisé par le logiciel, et non par la carte graphique elle-même. Par exemple, c'est ce qui se passait sur les très anciennes versions de Windows, pour afficher le bureau et l'interface graphique du système d'exploitation.

Mais une solution plus simple consiste à intervertir les deux tampons, le back buffer devenant le front buffer et réciproquement. Une telle interversion fait qu'on a pas besoin de copier les données de l'image. L'interversion des deux tampons peut se faire au niveau matériel.

La synchronisation verticale

[modifier | modifier le wikicode]

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

Tearing (simulé)

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

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

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

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

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

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

Le triple buffering et ses dérivés

[modifier | modifier le wikicode]

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

Triple buffering

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

Les améliorations de la synchronisation verticale

[modifier | modifier le wikicode]

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

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

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

Les VDC des systèmes à framebuffer : les CRTC

[modifier | modifier le wikicode]

Afficher une image à l'écran demande de prendre l'image dans le framebuffer et de l'envoyer à l'écran pixel par pixel. Pour cela, le VDC doit parcourir le framebuffer pour lire les pixels un par un, dans le bon ordre. Il existe des VDC qui sont capables de lire les pixels à envoyer à l'écran depuis la mémoire vidéo. Ils sont appelés 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.

Pour résumer ce que fait un CRTC, les pixels sont lus les uns après les autres, ligne par ligne, en balayant le framebuffer. 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 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. Pour cela, il utilise les deux compteurs de ligne et colonne pour forger l'adresse mémoire adéquate à chaque cycle.

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

Une carte d'affichage se résume donc à un CRTC, une mémoire vidéo pour le framebuffer, une mémoire SRAM pour la palette indicée, et éventuellement un convertisseur digital-analogique (DAC) sur les anciennes cartes d’affichage. Il faut évidemment ajouter un circuit de communication avec le bus, ainsi qu'une interface écran, pour compléter le tout.

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

Le pointeur de framebuffer

[modifier | modifier le wikicode]
Coordonnées d'un pixel à l'écran.

Rappelons qu'un écran est considéré par la carte graphique comme un tableau de pixels, organisé en lignes et en colonnes. Chaque pixel a deux coordonnées : sa position en largeur et en hauteur. Par convention, on suppose que le pixel de coordonnées (0,0) est celui situé tout haut et tout à gauche de l'écran. Le pixel de coordonnées (X,Y) est situé sur la X-ème colonne et la Y-ème ligne. Le tout est illustré ci-contre.

Avec le balayage progressif, la carte graphique doit envoyer les pixels ligne par ligne, colonne par colonne : de haut en bas et de gauche à droite. La carte graphique envoie le pixel (0,0) en premier, puis celui situé à gauche et ainsi de suite. Quand il a fini d'envoyer la ligne de pixel, il descend et reprend à la ligne suivante, tout à gauche. L'ordre de transfert est donc assez simple : ligne par ligne, de gauche à droite.

Tableau bidimensionnel.

Une méthode simple pour l'implémenter se base sur le fait que l'image à envoyer est stockée ligne par ligne dans la mémoire, avec les pixels d'une étant mémorisés dans l'ordre de balayage progressif. Les programmeurs appellent un tableau bi-dimensionnel. On peut récupérer un pixel en spécifiant les deux coordonnées X et Y, ce qui est l'idéal. Pour détailler un peu ce tableau bi-dimensionnel de pixels, c'est juste que les pixels consécutifs sur une ligne sont consécutifs en mémoire et les lignes consécutives sur l'écran le sont aussi dans la mémoire vidéo. En clair, il suffit de balayer la mémoire pixel par pixel en partant de l'adresse mémoire du premier pixel, jusqu’à atteindre la fin de l'image.

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

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

La réutilisation des compteurs de ligne/colonne

[modifier | modifier le wikicode]

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

CRTC et calcul d'adresse.

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

Circuit de contrôle et double buffering

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

Le mode écran divisé (splitscreen)

[modifier | modifier le wikicode]

L'affichage en splitscreen subdivise l'écran en rectangles qui affichent chacun une image différente. Les anciens jeux vidéo en faisaient usage pour le mode multijoueur local, où plusieurs manettes de jeu étaient reliées à une seule console de jeu, elle-même reliée à un seul écran. Pour un affrontement entre deux joueurs, l'écran était découpé en deux : la moitié haute de l'écran pour le premier joueur, la moitié basse pour le second joueur. Avec quatre joueurs, l'écran était découpé en quatre écrans. La console de jeu devait calculer 2 à 4 images, suivant le nombre de joueurs, et les combiner dans le framebuffer. Chaque image avait une résolution deux à quatre fois plus basse, ce qui fait que la console avait assez de puissance de calcul pour les calculer.

Speed Dreams splitscreen feature

Implémenter un affichage splitscreen se fait généralement sans support matériel. La console de jeu n'a pas grand-chose à faire pour le supporter, ce sont les jeux vidéos qui sont programmés pour effectuer un rendu de ce type. L'idée est de rendre plusieurs images de résolution inférieure et de les combiner pour obtenir le framebuffer final. Mais quelques cartes d'affichage implémentent des optimisations pour le splitscreen . Les optimisations ne permettent pas vraiment d'avoir un rendu plus rapide, du moins ce n'est pas leur objectif. Elles visent à rendre l'affichage splitscreen plus simple pour, le programmeur.

Le splitscreen horizontal

[modifier | modifier le wikicode]

L'optimisation la plus intuitive ne marche que pour un affichage divisé simple : l'écran est coupé en deux à l'horizontale, avec une moitié haute et une moitié basse. L'écran n'est pas forcément coupé en deux parties égales, la ligne de démarcation entre les deux écrans est configurable. Prenons l'exemple d'une résolution de 320 par 200 pixels, soit 200 pixels de haut. Le premier écran pouvait, par exemple, faire 56 pixels de hauteur, le second 144 pixels. Un tel splitscreen est appelé un splitscreen horizontal.

L'implémentation réutilise les registres du CRTC vus dans la section précédente : le compteur de ligne, le compteur de colonne, et les deux registres pour l'adresse de base. L'implémentation utilise deux framebuffers : un pour la moitié haute de l'écran, un pour la moitié basse. La ligne de démarcation est configurable grâce à un registre dédié, appelé le registre de splitscreen. Le programmeur y écrit à quelle ligne se trouve la ligne de démarcation, son numéro de ligne. Quand le compteur de ligne et le registre de splitscreen sont égaux, c'est signe qu'il faut changer d'écran. A ce moment là, le parcours du framebuffer reprend à l'adresse de base. Le changement demande de simplement changer de registre d'adresse de base, mais aussi de réinitialiser les compteurs de ligne et de colonne (pour repartir à zéro).

Sans registre de splitscreen, le splitscreen horizontal est implémenté avec des raster interrupts qui seront détaillés plus loin. Et il est possible de combiner raster interrupts et registre de splitscreen. Imaginez que vous vouliez découper un écran en plusieurs bandeaux horizontaux distincts. Un registre de splitscreen permet de couper l'écran en deux, mais pas en trois ou autre. Intuitivement, on se dit qu'il faudrait rajouter d'autres registres de splitscreen, mais cela aurait un cout en circuits pour une utilisation très rare. Une autre solution est de changer le contenu du registre de splitscreen lors de l'affichage, grâce à des raster interrupts.

L'idée part d'un écran initialement coupé en deux. Le passage à la seconde partie de l'écran déclenche une raster interrupt. Le processeur modifie alors les registres du CRTC, pour gérer l'affichage de la troisième portion de l'image. Le registre d'adresse de base, pointant initialement sur la première partie de l'image, pointe alors sur la troisième partie. Le registre de splitscreen est modifié de manière à pointer sur la seconde ligne de démarcation, celle entre la seconde partie de l'image et la troisième. Ainsi, lorsque la seconde ligne de démarcation sera atteinte, le hardware commutant d'image, et affichera automatiquement la troisième image. Supporter une quatrième image demande d'utiliser une autre raster interrupt lors du passage à la troisième partie de l'image, et ainsi de suite.


Les cartes accélératrices 2D

Dans le chapitre précédent, nous avons vu les CRTC. Il est maintenant temps de voir les Video Interface Controlers, des VDC qui gèrent des fonctionnalités de rendu 2D avancées, voire plus. 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.

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

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

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

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

Les quatre techniques ne sont pas exclusives, mais complémentaires. Elles 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.

Le circuit de Blitter : les copies en mémoire

[modifier | modifier le wikicode]

Les cartes 2D ont introduit un circuit pour accélérer les copies d'images en mémoire, appelé le blitter. Les copies de données en mémoire vidéo sont nécessaires pour ajouter les sprites sur l'arrière-plan, mais aussi lorsqu'un objet 2D se déplace sur l'écran. Déplacer une fenêtre sur votre bureau est un bon exemple : le contenu de ce qui était présent sur l'écran doit être déplacé vers le haut ou vers le bas. Dans la mémoire vidéo, cela correspond à une copie des pixels correspondant de leur ancienne position vers la nouvelle.

Sans blitter, les copies étaient donc à la charge du processeur, qui devait déplacer lui-même les données en mémoire. Le blitter est conçu pour ce genre de tâches, sauf qu'il n'utilise pas le processeur. Pour ceux qui ont quelques connaissances avancées en architecture des ordinateurs, on peut le voir comme une sorte de contrôleur DMA amélioré. D'ailleurs, il est souvent combiné à un contrôleur DMA, voire fusionné avec lui. Il pouvait non seulement faire des copies, mais aussi appliquer des opérations bit à bit sur les données copiées.

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

[modifier | modifier le wikicode]

Les cartes 2D sans sprites matériels effectuent leur rendu en deux étapes : elles copient l'image d'arrière-plan dans le framebuffer, puis chaque sprite est copié à la bonne place 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.

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, prenons l'image ci-dessous à gauche et son masque à droite. Les parties blanches du masque 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 noirs. Le masque indique quels pixels sont à copier ou non, ce qui demande simplement 1 bit par pixel. Le pixel est associé dans le masque à une couleur noire ou blanche, pas de niveaux de gris permis.

Image de Pacman.
Masque de Pacman.

Le blitter combine le sprite, l'arrière-plan et le masque. Pour chaque pixel, il effectue l'opération suivante : ((arrière-plan) AND (masque)) OR (image de pacman).

Sprite rendering by binary image mask

Imaginons qu'on veut placer l'image ci-dessus (le point vert), en plusieurs endroits de l'arrière-plan.

Image et masque.

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é, les parties noires sont ignorées. Au final, l'image finale est bel et bien celle qu'on attend.

Arrière-plan de l'exemple.
Application du masque - ET.
Application des sprites - OU.

Les sprites matériels

[modifier | modifier le wikicode]

Il existe des cartes 2D sur lesquelles les sprites sont gérés directement en matériel, sans passer par un blitter, sans être copiés sur un arrière-plan préexistant. À la place, l'image est rendue pixel par pixel, à la volée. La carte graphique décide, à chaque envoi de pixel, s'il faut afficher les pixels de l’arrière-plan ou du sprite pendant l'accès au framebuffer par le CRTC.

Il faut noter que les sprites matériel ont une taille assez petite. Ils sont typiquement des carrés de 8 pixels de côté, rarement plus. Par contre, les sprites utilisés dans les jeux vidéos sont souvent plus grands, certains font 16 pixels de côtés, parfois plus. Aussi, les sprites d'un jeu vidéo sont parfois composés de plusieurs sprites matériels placés les uns à côté des autres. Par exemple, un sprite de 16 pixels de hauteur et de 8 pixels de largeur est composé de deux sprites 8 par 8, placés l'un au-dessus de l'autre.

Le support des sprites est parfois utilisé dans un cadre particulièrement spécialisé : la prise en charge du curseur de la souris, ou le rendu de certaines polices d'écritures ! Le curseur de souris est alors traité comme un sprite spécialisé, surimposé au-dessus de tout le reste. Les cartes graphiques modernes gèrent un ou plusieurs sprites, qui représentent chacun un curseur de souris, et deux registres pour respectivement 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, 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 !

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

[modifier | modifier le wikicode]

Sur ces cartes 2D, les sprites sont stockés dans des registres, alors que l'image d'arrière-plan est stockée dans un framebuffer, dans une mémoire RAM. La RAM pour l'arrière-plan est généralement assez grosse, car l'arrière-plan a la même résolution que l'écran. Pour les sprites, la mémoire est généralement très petite, ce qui fait que les sprites ont une taille limitée.

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

Sprites matériels.

Les sprites allaient presque toujours de pair avec 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. 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.

Les VDC avaient des limitations en termes de sprites matériels supportés. Notamment, ils géraient un nombre maximal de sprites par image. Cette limitation par image était secondée par une limitation du nombre de sprites par ligne. Par exemple, la NES et la Master Sytem géraient 64 sprites par image, avec maximum 8 sprites par ligne. Cependant, ces limitations pouvaient être contournées par l'usage de raster interrupts, et précisément l'horizontal blank interrupt qui précise quand l'affichage d'une ligne précise est terminée. L'idée est de changer les sprites d'une image et de les repositionner, pendant le tracé de l'image, entre l'affichage de deux lignes. Les sprites qui ont déjà été affichés en haut de l'écran sont remplacés par des sprites à afficher plus bas. Les programmeurs utilisaient ce genre de ruses pour afficher plus de sprites à l'écran que ne peut en supporter la console.

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

Il faut noter que certaines consoles géraient des sprites situés sous l'arrière-plan. Quelques effets graphiques le requéraient. Par exemple, imaginez un personnage qui passe derrière un arbre. L'arbre est dessiné dans l'arrière-plan, afin d'économiser des sprites, alors que le personage est composé de plusieurs sprites. Avec des sprites pouvant passer derrière l'arrière-plan, on peut simuler cet effet de personnage qui passe derrière l'arbre, donc sous l'arrière-plan.

La palette indicée des sprites

[modifier | modifier le wikicode]

Certaines consoles de jeux utilisaient à la fois des sprites et une palette indicée. En soi, rien d'incompatible, les deux techniques sont totalement orthogonales. Mais il y a des consoles qui utilisaient deux palettes séparées : une pour les sprites, l'autre pour l'arrière-plan. En général, les deux palettes ont une couleur commune : la couleur transparente. Elle est codée deux fois, une fois dans chaque palette. Mais les autres couleurs des palettes sont potentiellement différentes dans les deux palettes. L'usage de deux palettes indicées séparées est assez commun sur les consoles 8 bits.

L'utilité de ce système est de gérer deux fois plus de couleurs, tout en réduisant les besoins en mémoire. En théorie, on pourrait gérer deux fois plus de couleurs en utilisant une palette unique deux fois plus grande, ce qui serait plus flexible. Mais en utilisant une palette deux fois plus grande, on doit ajouter un bit en plus par pixel, pour encoder deux fois plus de couleurs. Avec deux palettes séparées pour les sprites et l'arrière-plan, pas besoin : le bit est implicite, il vaut 0 pour les sprites et 1 pour l'arrière-plan.

La Master System a deux palettes : une réservée à l'arrière-plan, l'autre servant à la fois pour les sprites et le décor. Les sprites ont à disposition 16 couleurs qu'ils peuvent configurer comme ils le souhaitent. La couleur de transparence n'est pas obligatoire. A l'inverse, la palette pour l'arrière-plan dispose de 15 couleurs configurables, et une couleur de transparence fixe. L'arrière-plan a donc à sa disposition 31 couleurs configurables : 16 provenant de la palette des sprites, 15 provenant de l'autre palette, la 32ème couleur fixe étant la couleur de transparence.

La NES utilise un système de palette indicé assez complexe pour réduire encore plus le nombre de bits utilisé pour encoder un sprite. Pour simplifier, elle dispose de 4 palettes pour les sprites. Chaque palette gère 4 couleurs chacune, dont l'une est la couleur transparente, ce qui fait 3 couleurs configurables. Chaque sprite précise quelle palette utiliser avec deux bits de configuration précisés dans le registre associé au sprite. L'avantage est que cela réduit grandement la quantité de mémoire utilisée pour stocker un sprite : deux bits par pixel du motif, deux pixels pour préciser la palette utilisée pour le sprite. Au total, cela fait maximum 13 couleurs différentes : 3 couleurs fois 4 palettes plus la couleur de transparence. Même principe pour la palette de l'arrière-plan, qui est elle aussi découpée en 4 sous-palettes avec chacune ayant sa propre copie de la couleur de transparence.

De rares consoles 8 bits disposent de plus de deux palettes. Par exemple, la PC-Engine a 32 palettes indicées de 16 couleurs chacune. La première palette est toujours consacrée à l'arrière-plan, la dernière est toujours réservée aux sprites, les 30 palettes restantes sont utilisables à volonté par le programmeur. L'avantage est que l'on peut utiliser une palette différente par sprite, au lieu d'avoir une palette indicée partagée par tous les sprites. Mine de rien, trouver un ensemble de couleur partagé par plus d'une dizaine de sprite est assez compliqué. Les graphismes sont donc plus colorés, sans que cela demande d'utiliser une palette trop large. Et quand on veut modifier la palette à la volée pour un seul sprite, cette implémentation avec des palettes séparées est bien plus simple.

Les consoles comme la NES n'avaient que 4 couleurs par sprite. Mais il y avait moyen de contourner ces limitations en superposant plusieurs sprites de couleurs différentes. Par exemple, on peut simuler un sprite de 8 couleurs avec deux sprites de 4 couleurs chacun. Quelques consoles avaient même des fonctionnalités matérielles pour faciliter cette superposition. Par exemple, la MSX 2 fait automatiquement un OU entre deux sprites superposés.

Le zoom et la rotation des sprites

[modifier | modifier le wikicode]

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

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

ZOOM Entier sur un "sprite".

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

L'effet miroir ou flipping

[modifier | modifier le wikicode]

Une autre possibilité est de faire tourner les sprites. La technique la plus simple est un effet de miroir, à savoir que le sprite est inversé horizontalement, il est tourné dans l'autre sens. Pour vous donner une idée de l'utilité de cette technique, imaginez que vous jouez à Mario : si vous allez à droite, Mario sera tourné vers la droite. Mais si vous allez à gauche, le sprite sera tourné à gauche. Mais il s'agit d'un seul sprite, qui est tourné dans un sens ou l'autre avec un effet de miroir.

Il est aussi possible d'inverser le sprite verticalement, pour le retourner. Mais cette possibilité est plus rarement utilisée. La raison est que les rares jeux où cela pourrait être utile sont des jeux en vue de dessus, par exemple les Zelda, les Pokemon, et d'autres jeux dans le genre. Mais ils utilisent souvent un effet de perspective qui fait que la caméra n'est pas placée exactement à la verticale, ce qui fait qu'inverser un sprite verticalement ne respecte pas la perspective.

Toutes les consoles 8 bits supportaient l'effet de miroir directement en matériel, celui-ci étant très utile. Une des rares exceptions était la Master System de Sega. Sur cette dernière, le GPU était un VDC TMS9918 de Texas Instrument qui ne gérait pas l'effet miroir. Deux autres consoles utilisaient ce VDC : la colevision et la Sega SG-1000. Elles avaient le même problème. Cependant, il était possible de gérer l'effet miroir en utilisant le CPU.

L'avantage de la technique est qu'elle réduit le nombre de sprites nécessaires, ce qui entraine un gain en mémoire ROM. Les concepteurs peuvent ainsi faire rentrer un jeu sur une cartouche plus facilement, voire peuvent en profiter pour rajouter des sprites. Mais un défaut de cette méthode est que les personnages donnent l'impression d'être ambidextres ! Au passage, sur la Master System, les concepteurs de jeu n'avaient pas d'autre choix que d'utiliser des sprites séparés pour les personnages/ennemis vus de gauche et vu de droite. Et on peut le remarquer au fait que les ennemis/personnages ne sont pas ambidextres, les artistes ayant souvent pris en compte de détail.

Au passage, j'en profite pour vous renvoyer vers ce lien, qui détaille l'utilisation de l'effet de miroir dans plusieurs jeux :

La rotation des sprites

[modifier | modifier le wikicode]

La rotation des sprites regroupe plusieurs techniques diverses, qui vont de simples effets de miroir à des techniques de rotation complexes. L'effet miroir est techniquement une rotation de sprite particulière, mais ce n'est pas la seule. Les techniques plus évoluées permettent de faire tourner un sprite, pour faire comme si on voyait un objet de biais. Un sprite rectangulaire représente un objet vu soit de profil, soit de face. Mais si on veut donner l'illusion d'un objet faisant un angle différent, il faut modifier la forme du sprite. Le sprite rectangulaire est transformé en trapèze, qui est d'autant moins proche d'un rectangle que l'angle est important. Déformer un sprite rectangulaire en trapèze est un effet de rotation général. Le tout donne un effet de perspective.

Mode 7, test.
Mode 7.

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

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

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

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

[modifier | modifier le wikicode]
Exemple de scrolling.

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

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

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

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

[modifier | modifier le wikicode]

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

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

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

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

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

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

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

[modifier | modifier le wikicode]

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

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

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

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

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

[modifier | modifier le wikicode]

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

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

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

Implémentation du défilement horizontal avec in viewport

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

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

Les raster effects : défilement partiel et sprites supplémentaires

[modifier | modifier le wikicode]

Les raster effects sont des effets graphiques qui sont implémentés en modifiant le VDC pendant l'affichage de l'image. Par exemple, il est possible de changer la couleur de l'arrière-plan à partir d'une certaine ligne, afin de séparer le ciel du sol. Un exemple classique est celui qui permet de contourner le nombre maximal de sprites à l'écran.

La limite de sprites par écran

[modifier | modifier le wikicode]

Un VDC normal ne peut gérer qu'un nombre maximal de sprites par image/écran. Mais grâce aux raster effects, il est possible de dépasser cette limite, en changeant les sprites à la volée, pendant que l'image s'affiche. Par exemple, les sprites déjà affichés sont recyclés et remplacés par d'autres sprites. Leur coordonnée passe d'une portion affichée de l'image (typiquement en haut de l'écran) à une portion pas encore affichée (typiquement plus bas).

En faisant cela, on peut afficher plus de sprites que ce que gére le VDC. Par contre, on ne peut pas dépasser la limite de sprite par scanline. Par exemple, si un VDC gère maximum 64 sprites par imaeg et 8 sprites par ligne, la première limite est facilement contournée via les raster effects, mais pas la seconde. Autant changer des sprites entre l'affichage de deux lignes est possible, autant le faire pendant l'affichage d'une ligne ne l'est pas. En effet, le VDC n'est souvent pas reconfigurable par le processeur pendant l'affichage d'une ligne. Il l'est entre l'affichage de deux lignes, pas pendant.

Le défilement partiel

[modifier | modifier le wikicode]

De nombreux effets graphiques demandent de ne faire défiler qu'une partie de l'écran, le reste de l'écran reste en place. Dans ce cas, nous parlerons de défilement partiel, le terme partiel indiquant qu'il ne touche qu'une portion de l'image. De tels effets sont généralement implémentés avec des raster effect, mais il faut que le matériel le supporte pleinement.

Pour donner un exemple, prenez les phases d’ascenseur dans les jeux de type beat'them up, où votre personnage est sur une plateforme qui monte/descend, les ennemis arrivant dessus par vagues successives. Dans ce cas, la plateforme est immobile et le reste de l'image défile verticalement vers le haut (ascenseur descend) ou vers le bas (ascenseur monte).

Un cas particulier de défilement partiel est celui utilisé pour le HUD : les compteurs de vie, score, et autres. Il arrive que le HUD soit dessiné avec des sprites, mais ce n'est pas la majorité des cas. En réalité, le HUD est placé dans l'arrière-plan directement. Et il ne doit pas être défilé, pour rester en place. par exemple, si le HUD est placé au sommet de l'écran, il doit y rester et ne pas subir de défilement vertical ou horizontal.

D'autres effets demandent de changer le défilement ligne par ligne : des effets de vagues, d'ondulation, ou autre. Par exemple, c'est ce qui permet de créer des virages sur les jeux de course ! Il n'y a qu'une seule image de route en ligne droite, qu'on décale à chaque ligne pour simuler un virage !

D'autres effets demandent un défilement partiel à la verticale. Il a été utilisé pour le HUD, les Boss de grande taille de certains jeux, des effets d'eau qui monte et bien d'autres encore. Voici un article qui explique le tout en détail :

Un problème est que le défilement doit être reconfigurable lors de l'affichage de l'image. La quasi-totalité des consoles permettait cela, avec cependant quelques exceptions. Par exemple, la Master System permettait de changer les sprites à la volée, idem pour le défilement horizontal, mais ne permettait pas de changer le défilement vertical pendant l'affichage d'une image. Sur Master System, le registre de défilement vertical était pris en compte uniquement au début de l'affichage, le modifier en cours d'affichage n'avait pas le moindre effet sur l'affichage. On parle alors de Vlock. Il était possible de contourner le problème avec des solutions logicielles, avec un cout en performance et quelques limitations quant au résultat.

L'implémentation des raster effects

[modifier | modifier le wikicode]

Les VDC étaient souvent conçus avec les raster effects en tête. Ils supportent souvent des fonctionnalités qui facilitent l'implémentation des raster effects : compteurs de ligne, interruptions horizontales, et autres que nous allons voir dans ce qui suit. En théorie, les raster effects peuvent se faire même si le VDC ne supporte pas ces fonctionnalités. Mais cela demande de synchroniser le processeur et le VDC au cycle près, ou presque. Les programmeurs de l'Atari 2600 faisaient ainsi, mais il faut avouer que ce n'était pas pratique.

La première fonctionnalité est le compteur de ligne. Le nom est assez transparent : il s'agit d'un registre qui contient le numéro de la dernière ligne affichée. Par exemple, si le VDC a affiché la 20ème ligne et se prépare à afficher la 21ème, alors ce compteur contient le numéro 20. Le compteur de ligne permet au processeur/logiciel de savoir où il en est lors de l'affichage de l'écran. Il peut regarder régulièrement ce compteur et changer le défilement et/ou les sprites quand un certain numéro apparait dans ce compteur de ligne.

Le compteur de ligne en question existe déjà dans la plupart des VDC, vu qu'il s'agit de celui vu dans les chapitres précédents, qui sert pour l'adressage de la mémoire vidéo et quelques autres fonctionnalités. Mais il n'est pas forcément accesible par le processeur. Les VDC anciens ont un compteur de ligne interne, qui n'est accesible que par les circuits du VDC, mais le processeur n'y a pas accès. La fonctionnalité dont on parle ici rend ce registre visible par le processeur, un peu comme l'est le registre d'état.

Une seconde fonctionnalité, très liée au compteur de ligne, est l'horizontal blank interrupt, une cousine de l'interruption de vertical blank interrupt qui indique qu'une image complète a été affichée à l'écran. L'horizontal blank interrupt fait la même chose, mais au niveau des lignes et d'une manière configurable. L'idée est que l'on peut demander au VDC de déclencher une interruption quand il atteint la ligne numéro X, par exemple quand il a fini d'afficher la 20ème ligne, la 50ème, etc. Le programmeur peut précise à quelle ligne se déclenche l'interruption. Pour le dire autrement, cette interruption se déclenche quand le compteur de ligne atteint une valeur bien précise, configurable par le programmeur.

Prenons l'exemple où programmeur veut afficher un HUD de 30 pixels de hauteur au-dessus d'un arrière-plan qui défile horizontalement. L'image ne défile pas initialement et le jeu affiche d'abord le HUD. Mais le programmeur a configuré l'interruption pour se déclencher à la 30ème ligne. L'interruption réactive le défilement de l'arrière-plan et change l'affichage pour basculer du HUD vers l'arrière-plan.

Une autre fonctionnalité, spécifique à la SNES, est le HDMA, une amélioration de la fonctionnalité DMA (Direct Memory Access). Le DMA normal permet de faire des copies de la RAM vers la RAM, parfois de la RAM vers la mémoire vidéo (et inversement). Le HDMA de la SNES étend le comportement du DMA. Elle permet de faire copies RAM -> VDC, pour reconfigurer le VDC. De plus, les copies sont programmées à l'avance, elles ne se font pas en réaction à une interruption de blanking horizontal. Précisément, le VDC est reconfiguré à chaque période de blanking horizontal, entre l'affichage de deux lignes.

Pour cela, le processeur prépare une table HDMA qui précise comment configurer le VDC pour chaque ligne à l'écran. Pour simplifier, la table HDMA contient des entrées, chacune correspondant à une ligne de l'écran, qui indique quoi envoyer dans les registres de configuration du VDC. Le VDC lit les entrées de la première à la dernière, ligne par ligne. Le fonctionnement réel est un peu plus complexe. La table permet de conserver la même configuration pour N lignes consécutives, N allant de 1 à 128. Quand le VDC voit une de ces entrées, les données de configuration sont recopiées à l'identique pour N lignes consécutives.

L'usage de plusieurs arrière-plans et d'avant-plans

[modifier | modifier le wikicode]

Une carte graphique 2D basique gére un arrière-plan et un avant-plan contenant plusieurs sprites, avec éventuellement un avant-plan pour le HUD ou une interface sur laquelle afficher les vies, et d'autres informations. En tout, cela fait trois plans aux usages bien précis. Mais il est possible d'ajouter plusieurs arrière-plans, voir plusieurs avant-plans. L'utilité n'est pas évidente, mais cela sert pour de nombreux effets graphiques. Voyons lesquels.

Les décors à l'avant-plan

[modifier | modifier le wikicode]

Il faut noter que les cartes graphiques peuvent aussi gérer des avant-plans, à savoir des décors qui passent devant les sprites. Une forme d'avant-plan très utile est celui pour le HUD, les compteurs de vie, mana, munitions et autres. Implémenter un HUD avec du défilement est compliqué, car le HUD ne doit pas défiler : il doit rester à la même place sur l'écran, alors que le reste de l'écran défile.

Il faut noter que les avant-plans ne sont pas forcément séparés des arrière-plans. La seule différence entre les deux est la profondeur ! Et précisément la profondeur comparée à la couche pour les sprites. Plus profond que les sprites : c'est un arrière-plan. Moins profond, c'est un avant-plan. Les cartes graphiques gèrent souvent des "couches" (layers) qui se superposent, avec une couche spécialisée pour les sprites, et d'autres couches qu'on peut configurer à volonté comme arrière- ou avant-plan.

Les avant-plans sont aussi utilisés dans quelques jeux pour un effet purement esthétique, pour mettre un décor ou des objets devant les sprites. De plus, ils sont occasionnellement nécessaires pour rendre certains effets graphiques, ou pour ajouter de la profondeur à une scène. Les avant-plans défilent à une vitesse différente des arrière-plans, sans quoi le résultat est absolument affreux. Aussi, l'usage d'avant-plans est très fortement corrélé à l'usage du défilement à parallaxe qu'on va voir dans ce qui suit.

Le défilement horizontal à parallaxe

[modifier | modifier le wikicode]
Défilement avec parallaxe.
Défilement avec parallaxe : illustration des plans.

Le défilement à parallaxe donne une illusion de profondeur dans l'environnement? Pour cela, il utilise plusieurs arrière-plans superposés qui défilent à des vitesses différentes. Le défilement à parallaxe demande que les éléments à l'arrière-plan bougent à des vitesses différentes, mais pas les sprites à l'avant-plan. Le défilement sans parallaxe fait bouger le framebuffer en bloc, ce qui fait bouger tout l'arrière-plan tout d'un coup.

Pour implémenter la technique, il est possible d'utiliser un défilement partiel basé sur des raster effects. L'idée est de changer le défilement horizontal ou vertical pendant l'affichage. En général, l'écran est découpé en bandes qui défilent à des vitesses différentes, pour donner des effets parallaxe, parfois des effets de rouleau (le sol forme un tore) ou d'autres effets graphiques.

Une autre solution utilise des arrière-plans partiellement transparents qui se superposent. Les différents arrière-plans ont chacun une profondeur qui dit quel arrière-plan passe devant les autres. Il s'agit de l'implémentation la plus simple, mais elle demande que la carte graphique gère la transparence. Ce n'est cependant pas la seule option et quelques consoles de jeu se débrouillaient pour mélanger plusieurs arrière-plans sans pour autant gérer directement la transparence. Mais nous verrons cela plus tard.

L'émulation de la transparence, des ombres et lumières

[modifier | modifier le wikicode]

L'usage de plusieurs arrière-plans marche bien si la carte graphique gère la transparence. C’est-à-dire que chaque pixel indique s'il est totalement opaque, totalement transparent, ou partiellement opaque. En faisant cela, déterminer quel pixel afficher est simple : on prend en compte la profondeur de chaque arrière-plan, et on détermine quel est le premier pixel visible. Par un exemple, si un pixel transparent est situé devant un pixel opaque, c'est ce dernier qui s'affiche. Pour les pixels partiellement transparents, il faut mélanger les couleurs des pixels en faisant une simple moyenne.

Mais cela implique que la carte graphique gère la transparence, que chaque pixel contienne une couleur alpha qui indique son niveau de transparence, couleur ajoutée aux couleurs RGB existantes. Des consoles comme la SNES utilisaient un système totalement différent. Elles utilisaient des arrière-plans dont les pixels étaient codés en RGB, sans composante de transparence alpha. Mais elles arrivaient à combiner plusieurs arrière-plans entre eux sans problèmes.

Les arrière-plans étaient combinés entre eux, ce qui donnait l'image finale à afficher. La combinaison pouvait utiliser plusieurs opérations, qui étaient appliquées pixel par pixel. Les opérations de combinaison sont : l'addition, la soustraction, la moyenne additive, et une soustraction suivie d'une division par deux. Pour résumer, on peut additionner/soustraire des pixels à la même place dans les différents arrière-plans, puis éventuellement diviser par deux le résultat. Précisons que les additions/soustractions sont dites saturées : elles n'ont pas de débordements d'entiers.

Les différentes opérations permettaient surtout d'implémenter des effets d'éclairage, d'ombrage, de transparence, voire de masquage (n'afficher qu'une partie de l'écran). L'addition ne peut qu'augmenter la couleur du pixel final, en augmenter la luminosité, ce qui la rend idéale pour appliquer des effets de lumière. À l'inverse, la soustraction ne peut que réduire la couleur finale d'un pixel, ce qui permet d'appliquer des ombres. La moyenne mélange deux pixels entre eux, qui sont à la même place à l'écran, mais n'ont pas la même profondeur, vu qu'ils sont dans des arrière-plans différents. En clair, elle applique des effets de transparences assez limités. Les pixels de deux arrière-plans sont mélangés avec une proportion fixe de 50-50. Ce qui permet d'appliquer des effets de transparence très limités.

Avec l'addition, il suffit de calculer les lumières à appliquer dans un arrière-plan, qui est additionné avec le reste de l'image. Pour appliquer des ombres, il faut faire la même chose mais avec la soustraction. Les ombres sont calculées dans un arrière-plan à part, qui est soustrait du résultat du reste. Pour être plus précis, l'arrière-plan contenait une image d'ombrage, qui indique quelles sont les parties de l'image dans l'ombre et l'intensité de l'ombre pour chaque pixel. Plus un pixel est dans l'ombre, plus sa couleur est proche du blanc (en RGB) : la palette de couleur est inversée. En soustrayant cette seconde image d'ombrage à l'image du framebuffer principal, on ombre les pixels.

Il faut noter que certaines consoles, comme la NES, permettaient en plus d'additionner une couleur fixe pour tous les pixels de l'écran, voire de la soustraire, voire de soustraire le pixel à cette couleur fixe. Cela permettait de biaiser les couleurs de base avec une couleur de base. Là encore, l'utilité était une question d'ombrage, d'éclairage, de transparence, ou autre. Cela permettait d'affiner les couleurs finales. La couleur de base était mémorisée dans un registre. Notons que le registre pouvait être changé entre l'affichage de deux lignes, via des raster interrupts, ce qui permettait d'appliquer des dégradés de lumière verticaux, d'appliquer un effet d'horizon, d'ajouter du brouillard de distance.

L'implémentation matérielle et le cas particulier de la SNES

[modifier | modifier le wikicode]

Utiliser plusieurs arrière-plans demande que le matériel soit adapté. Il faut typiquement ajouter de quoi gérer et mémoriser plusieurs arrière-plans et ajouter des circuits pour les combiner entre eux. Le hardware pour faire cette combinaison détermine quel pixel passe devant les autres, il peut gérer de la transparence, etc. Les sprites sont souvent gérés par un système de sprite matériel, ce qui fait que le défilement n'est pas tellement un problème pour eux.

Les arrières/avant-plans sont typiquement dans des mémoires séparées afin de faciliter la gestion du défilement . Avec plusieurs mémoires, la technique précédente (le framebuffer étendu et un viewport rectangulaire) peut s'appliquer à chaque arrière-plan. Et on peut les faire défiler à des vitesses différentes. Il est aussi possible d'utiliser une seule mémoire pour cela, qui mémorise plusieurs arrière-plans, c'est tout à fait possible.

La console de jeu SNES gérait 4 arrière-plans différents, mais elle n'utilisait pas plusieurs mémoires. Les 4 arrière-plans étaient combinés entre eux, ce qui donnait l'image finale à afficher. Il était possible d'activer ou de désactiver les arrière-plans si besoin. Il était par exemple possible de n'utiliser que 2 arrière-plans, ou 3 arrière-plans dont un soustrait de l'addition des deux autres, etc.

Elle disposait de plusieurs modes, appelés mode 1, mode 2, ..., mode 7. Le mode 7 est un mode de rendu avec un seul arrière-plan, mais où les techniques des sections précédentes sont utilisables. Les optimisations pour le défilement horizontal et vertical sont présentes, et le viewport peut même être déformé, tourné, etc. Il peut utiliser un second arrière-plan très particulier, dérivé du premier. Il utilise les mêmes tiles, avec cependant la possibilité de se retrouver devant les sprites ou les autres arrière-plans. Il était utilisé pour gérer des tunnels vus de dessus, ou pour quelques effets graphiques où des élèments de décor se retrouvaient à l'avant-plan.

Mode vidéo BG 1 BG 2 BG 3 BG 4
0 4 couleurs (2 bits) 4 couleurs (2 bits) 4 couleurs (2 bits) 4 couleurs (2 bits)
1 16 couleurs (4 bits) 16 couleurs (4 bits) 4 couleurs (2 bits)
2 16 couleurs (4 bits) 16 couleurs (4 bits)
3 256 couleurs (8 bits) 16 couleurs (4 bits)
4 256 couleurs (8 bits) 4 couleurs (2 bits)
5 16 couleurs (4 bits) 4 couleurs (2 bits)
6 16 couleurs (4 bits)
7 256 couleurs (8 bits) EXTBG

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

[modifier | modifier le wikicode]
Tracé d'une ligne sur un écran.

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

Les circuits de tracé de ligne

[modifier | modifier le wikicode]

L'algorithme le plus utilisé par le matériel pour tracer des lignes est l'algorithme de Bresenham. Il est très simple et s'implémente très facilement dans un circuit électronique. Il fut dit 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 algorithmes découvert dans le domaine du graphisme sur ordinateur. Il existe de nombreuses modifications de cet algorithme, qui vont de mineures à assez profondes. Et certaines d'entre elles sont plus faciles à implémenter en hardware que d'autres.

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

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

Le tracé de figures géométriques

[modifier | modifier le wikicode]

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

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

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

[modifier | modifier le wikicode]

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

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

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


Le mode texte et le rendu en tiles

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 rendu en mode texte

[modifier | modifier le wikicode]

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

Tous les caractères sont des images de taille fixe, que ce soit en largeur ou en hauteur. Par exemple, un caractère peut faire 8 pixels de haut et 8 pixels de large, sur les écrans cathodiques. Sur les écrans LCD, les pixels sont carrés et les caractères font 16 pixels de haut par 8 de large pour avoir un aspect rectangulaire.

Illustration du mode texte.

Les attributs des caractères sont des informations qui indiquent si le caractère clignote, sa couleur, sa luminosité, si le caractère doit être souligné, etc. Une gestion minimale de la couleur est parfois présente. Le tout est mémorisé dans un octet, à la suite du code ASCII du caractère.

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

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

[modifier | modifier le wikicode]

Paradoxalement, les cartes d'affichage en mode texte sont de loin les moins intuitives et elles sont plus complexes que les cartes d'affichage en mode graphique. Les limitations de la technologie de l'époque rendaient plus adaptées les cartes en mode texte, notamment les limitations en termes de mémoire vidéo. La faible taille de la mémoire rendait impossible l'usage d'un framebuffer proprement dit, ce qui fait que les ingénieurs ont utilisé un moyen de contournement, qui a donné naissance aux cartes graphiques en mode texte. Par la suite, avec l'amélioration de la technologie des mémoires, les cartes d'affichage avec un framebuffer sont apparues et ont remplacé les complexes cartes d'affichage en mode texte.

Les cartes d'affichage en mode texte et avec framebuffer ont une architecture assez similaire. Pour rappel, une carte graphique avec un framebuffer est composée de plusieurs composants : un circuit d’interfaçage avec le bus, un circuit de contrôle appelé le CRTC, une mémoire vidéo, un DAC qui convertit les pixels en signal analogique, et quelques circuits annexes.

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

Une carte en mode texte a les mêmes composants, avec quelques modifications. Le tampon de texte (text buffer) est la mémoire vidéo. Dans celle-ci, les caractères à afficher sont placés les uns à la suite des autres. Chaque caractère est stocké en mémoire avec deux octets : un octet pour le code ASCII, suivi d'un octet pour les attributs. L'avantage du mode texte est qu'il utilise très peu de mémoire vidéo : on ne code que les caractères et non des pixels indépendants, et un caractère correspond à beaucoup de pixels.

Le CRTC est modifié de manière tenir compte de l'organisation du tampon de texte. De plus, quelques circuits sont ensuite utilisés pour faire la conversion texte-image, comme on le verra plus bas. Le circuit de conversion texte-pixel est une petite mémoire ROM appelée la mémoire de caractère, qui mémorise, pour chaque caractère, sa représentation sous forme de pixels. La carte graphique contient aussi un circuit chargé de gérer les attributs des caractères : l'ATC (Attribute Controller), aussi appelé le contrôleur d'attributs. Il est situé juste en aval du tampon de texte.

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

La table des caractères

[modifier | modifier le wikicode]

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

On pourrait croire que la table des caractères telle que si l'on envoie le code ASCII sur l'entrée d'adresse, on récupère en sortie l'image du caractère associé. Mais cette solution simple est irréaliste : un simple caractère monochrome de 8 pixels de large et de 8 pixels de haut demanderait près de 64 pixels en sortie, soit facilement plusieurs centaines de bits, ce qui est impraticable, surtout pour les mémoires de l'époque. En réalité, la mémoire de caractère a une sortie de 1 pixel, le pixel en question étant pris dans l'image du caractère sélectionné. L'entrée d'adresse s'obtient alors en concaténant trois informations : le code ASCII pour sélectionner le caractère, le numéro de la ligne et le numéro de la colonne pour sélectionner le bit dans l'image du caractère. Les deux numéros sont fournis par le CRTC, comme on le verra plus bas.

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

Le CRTC sur une carte en mode texte

[modifier | modifier le wikicode]

Le mode texte impose de modifier le CRTC de manière à ce qu'il adresse le tampon de texte correctement. Il contient toujours deux compteurs, pour localiser la ligne et la colonne du pixel à afficher, mais doit transformer cela en adresse de caractère. Le CRTC doit sélectionner le caractère à afficher, puis sélectionner le pixel dans celui-ci, ce qui demande la collaboration de la mémoire de caractères et le tampon de texte. Le CRTC va déduire à quel caractère correspond le pixel choisit, et le récupérer dans le tampon de texte. Là, le code du caractère est envoyé à la mémoire de caractère, et le CRTC fournit de quoi sélectionner le numéro de ligne et le numéro de colonne. Le pixel récupéré dans la mémoire de caractère est alors envoyé à l'écran.

Concrètement, les calculs à faire pour déterminer le caractère et pour trouver les numéros de ligne/colonne sont très simples, sans compter que les deux sont liés. Prenons par exemple un écran dont les caractères font tous 12 pixels de large et 8 pixels de haut. Le pixel de coordonnées X (largeur) et Y (hauteur) correspond au caractère de position X/12 et Y/8. Le reste de la première division donne la position de la colonne pour la mémoire de caractère, alors que le reste de la seconde division donne le numéro de ligne.

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

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

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

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

Motorola MC6845P

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

Le défilement du texte

[modifier | modifier le wikicode]

Faire défiler du texte est une opération très courante. Concrètement, cela fait descendre d'une ou plusieurs lignes dans le texte, ce qui demande de bouger tout le texte à l'écran. Le défilement ne se fait pas ligne de pixel par ligne de pixel, mais ligne par ligne, voire 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.

Il s'agit presque toujours de défilement vertical. 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.

Défiler du texte est une opération très courante qui gagne à être optimisé au niveau de la carte graphique. 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.

La première optimisation que nous allons voir réutilise les techniques vues dans le chapitre sur le rendu 2D. Pour cela, il utilise un tampon de texte organisé en lignes de texte consécutives. Le tampon de texte mémorise plus de lignes que ce qui est affiché à l'écran. Ce qui est visible à l'écran est une portion du tampon de texte, 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 pointe vers la position du viewport dans le tampon de texte, ce qui permet de faire bouger le viewport à volonté.

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

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

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

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

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

Le rendu en mode semi-graphique

[modifier | modifier le wikicode]
Rendu d'une image en mode semi-graphique.

Le rendu en mode texte ne permet en théorie de n'afficher que du texte. Cependant, il est possible d'émuler un rendu graphique à partir du mode texte, en trichant un petit peu. On a alors un mode de rendu appelé mode semi-graphique. Il y en a deux sous-types, appelés rendu graphique en bloc et pseudo-graphique, qui seront détaillés ci-dessous. Les mode semi-graphiques sont en soi des techniques logicielles, qui ne demandent pas de support particulier de la part du matériel. Ils servent cependant d'introduction propédeutique au rendu en motifs qui sera vu à la fin du chapitre.

La triche n'est possible que 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. L'idée est de remplacer les caractères par des dessins basiques, des motifs, qui sont assemblés pour fabriquer des "sprites", qui sont eux-même assemblés pour former l'image finale.

Le rendu à motif (tiles)

[modifier | modifier le wikicode]

Le rendu en motifs est un proche cousin du mode texte. Il n'est cependant pas spécialisé pour du texte, mais permet de compresser des images complètes. Il a été utilisé sur des consoles de jeu, afin d'outrepasser les contraintes en mémoire RAM. les consoles 8 bits avaient peu de mémoire et ne pouvaient pas utiliser de framebuffer, même en utilisant la technique de la palette indicée. Elles ne pouvaient pas non plus utiliser de tampon de ligne, car le processeur n'était pas assez puissant pour. Alors, elles utilisaient le rendu en motifs pour rendre des images avec peu de mémoire vidéo.

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

[modifier | modifier le wikicode]
Tile set de Ultima VI.

Le rendu en motifs force une certaine redondance à l'intérieur de l'arrière-plan et des sprites. L'idée est que les sprites et l'arrière-plan sont fabriqués à partir de motifs, aussi appelés tiles. Concrètement, ce sont des dessins carrés de 8, 16, 32 pixels de côtés, qui sont assemblés pour fabriquer un sprite ou l'arrière-plan.

L'ensemble des motifs est mémorisée dans un fichier unique, appelé le tile set, le tilemap, ou encore la table des motifs. La table des motifs est placée dans la cartouche de jeu, souvent dans une mémoire ROM dédiée. Ci-contre, vous voyez la table des motifs du jeu Ultima VI.

Les motifs sont numérotés, un numéro identifiant un motif parmi toutes les autres. L'image ne mémorise pas des pixels, mais des numéros de motifs. Le gain est assez appréciable : avec des motifs de 8 pixels de côté, au lieu de stocker X * Y pixels, on stocke X/8 * Y/8 numéros de motifs par image. La mémoire vidéo n'est donc pas un framebuffer, mais un tampon de motifs. L'image à afficher à l'écran est reconstituée par la carte graphique, lors de l'affichage, en plusieurs étapes.

Un avantage est que les motifs peuvent être utilisés en plusieurs endroits, ce qui garantit une certaine redondance. L'arrière-plan, qui est généralement l'image la plus redondante. Par exemple, un ciel est composé de motifs bleus identiques. Idem quand il faut rendre plusieurs petits ennemis identiques à l'écran : on n'utilise qu'un seul motif pour tous les ennemis. Pareil si un motifs est utilisé dans plusieurs sprites, il n'est stockée qu'une seule fois. Il s'agit donc d'une forme de compression d'image qui profite d'une certaine redondance.

Ajoutons à cela que les numéros de motifs 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 motifs, une mémoire ROM intégrée dans la cartouche de jeu. .

L'architecture d'une carte d'affichage en rendu à motifs

[modifier | modifier le wikicode]

Pour les consoles de 2ème, 3ème et 4ème génération, l'usage de motifs é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 motifs.

Le matériel affichait l'image finale ligne par ligne, pixel par pixel comme le ferait un CRTC. Sauf qu'il détermine : dans quelle motif se trouve le pixel à afficher, où se trouve le pixel dans le motif (quelle ligne, quelle colonne). Une fois cela fait, le VDC accède à la mémoire vidéo pour récupérer le numéro de motif. Une fois le numéro de motif connu, il lit la table des potifs en mémoire ROM, sur la cartouche. Puis, il sélectionne le pixel à afficher dans ce motif, et l'envoie à la palette indicée, puis à l'écran.

VDC à rendu à motif, sans gestion des sprites

Les motifs sont des équivalents des caractères dans le mode texte. Un motif peut être vu comme une sorte de super-caractère. Une carte d'affichage en mode texte et une carte en rendu à motifs sont d'ailleurs très similaires. Le tampon de motifs est l'équivalent pour le rendu à motifs du tampon de texte en mode texte. La table des motifs est l'équivalent de la table des caractères, 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 table des motifs stockant des motifs graphiques.

Une optimisation permettait de lire certains motifs 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 un motif. Par exemple, la moitié droite est générée par 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.

Les sprites sont presque toujours gérés avec le rendu à motifs, pour des raisons de performance. Les sprites les plus simples sont un seul motif, mais les autres sprites sont formés en assemblant plusieurs motifs. Typiquement, un sprite prend de 2 à 4 motifs. Par exemple, un sprite de 16 pixels de haut et 8 pixels de large est composé de deux motifs de 8 pixels de côté. Pour cela, les registres pour les sprites mémorisent des numéros de motif, pas des pixels.

Carte 2D avec un rendu en tile

Le défilement avec des motifs

[modifier | modifier le wikicode]

Le défilement se marie assez mal avec un rendu à base de motifs. La solution la plus simple fait du défilement motif par motif, par sauts de 8 pixels. L'implémentation est la même qu'avec le défilement, à savoir qu'on utilise un viewport matériel. La différence est que la mémoire vidéo contient des motifs et non des pixels, idem pour le viewport. Le résultat à l'écran n'est pas fluide du tout, il donne un défilement saccadé et désagréable. Pour corriger cela, il a existé des techniques pour implémenter du défilement du défilement pixel par pixel avec un rendu à motifs. Un défilement de ce type est appelé un défilement fluide, ou encore du défilement pixel par pixel.

La première implémentation du défilement fluide avec des motifs était purement logicielle, le VDC faisait lui du défilement motif par motif. Sur PC, la première implémentation 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. D'autres solutions logicielles similaires existaient sur console. Elles utilisaient 8 copies de chaque motif, chacun étant décalé d'un pixel. Le motif adéquat était choisi suivant un décalage dépendant du défilement.

Mais il est possible de faire du défilement pixel par pixel avec un rendu à motifs, en ajoutant du hardware spécifique au VDC. Elles étaient implémentées sur les consoles de 4ème génération et antérieures, mais pas sur les anciens PC. Une des toute première console à gérer le défilement pixel par pixel était l'Intellivision, une des toutes premières consoles, précisément une console de deuxième génération. Elle avait des motifs de 8 pixels de côté, et permettait de défiler au pixel près.

Pour cela, elle disposait d'un registre de décalage contenant une valeur allant de 0 à 7, qui indiquait de combien de pixels il fallait décaler l'image sur l'écran. Pour décaler d'une valeur plus grande, il fallait recourir au logiciel. Le logiciel devait alors faire défiler l'image en la déplaçant en mémoire vidéo. Il s'agissait donc d'une implémentation partielle du défilement avec des motifs. Le logiciel faisait du défilement motif par motif, et pouvait finir le travail en ajustant le tout pour obtenir un défilement pixel par pixel.

Les consoles suivantes géraient le défilement pixel par pixel d'une manière plus optimisée, en gérant à la fois le registre de décalage et le défilement motif par motif. Le défilement pixel par pixel était réalisé en faisant un défilement motif par motif, puis en finissant le travail avec un registre de décalage. Le défilement motif par motif était géré comme expliqué ci-dessus, avec un viewport.

Les glitchs graphiques sur les bords de l'écran

[modifier | modifier le wikicode]

Malgré leur support matériel, il arrive que le défilement au pixel près cause des glitchs graphiques sur les bords de l'écran. La raison est qu'avec un défilement par pixel, les motifs aux bords de l'écran sont partiellement affichés. Par exemple, un motif sur le bord gauche ait ses pixels gauche en-dehors de l'écran, ses pixels droits dans l'écran. Idem mais sur le bord droit : un motif a ses pixels de gauche dans l'écran, ceux de droite en-dehors. Le problème est que de tels motifs peuvent ne pas être affichés si le VDC ne le permet pas. En général, les motifs partiellement en-dehors de l'écran ne sont pas affichés, ce qui cause des glitchs graphiques.

Les VDC de l'époque ne pouvaient pas afficher un motif si ses coordonnées sont en-dehors de l'écran. En général, la coordonnée d'un motif est définie par rapport à une origine placée sur le pixel le plus en haut et le plus à gauche du motif. Dans ce cas, les motifs à gauche de l'écran ont une origine qui est en-dehors de l'écran, même si quelques pixels de droite sont affichés. Le motif n'est alors pas affiché. Ces pixels à droite disparaissent, ce qui fait qu'entre 0 et 7 pixels disparaissent pour un motif de 8 pixel de largeur, entre 0 et 15 pixels s'il a une largeur de 16 pixels, etc. Idem avec le défilement vertical, mais pour les motifs tout en haut de l'écran.

Si l'origine du motif est définie sur le bord droit, c'est les motifs à droite de l'écran qui disparaissent.

Il y a la même chose avec les sprites. Si leur origine est en-dehors de l'écran, mais que le reste du sprite est affiché, ils ne sont pas affichés. Une solution pour éviter les problèmes est de les faire rentrer d'un seul côté de l'écran, typiquement à droite si leur origine est définie à gauche. Si le défilement va dans le bon sens, aucun glitch graphique ne se manifeste.

Pour cacher ces glitchs graphiques, il est possible d'ajouter des bandes noires sur les deux côtés de l'écran. Les bandes noires sont verticales et/ou horizontales, elle ont une largeur égale à un motif. Le VDC peut gérer nativement ces bandes noires, en matériel. Pour cela, il réduit simplement la résolution pour couper les bords de l'écran.


Les accélérateurs de scanline

Les chapitres précédents ont abordé des VDC couplés à un framebuffer, mais tous les VDC ne sont pas dans ce cas. La raison est assez simple : une image prend beaucoup de mémoire ! Par exemple, prenons le cas d'une image en niveaux de gris d'une résolution de 320 par 240 pixels, chaque pixel étant codé sur un octet. L'image prend alors 76800 octets, soit environ 76 kiloctets. Mine de rien, cela fait beaucoup de mémoire ! Les tout premiers ordinateurs n'avaient pas assez de mémoire vidéo, les cartes d'affichages devaient se débrouiller avec beaucoup de mémoire.

Pour optimiser l'utilisation de la mémoire, les cartes d'affichage n'avaient pas de framebuffer et fonctionnaient autrement. Il existe plusieurs optimisations pour économiser de la mémoire. La première optimisation est appelée le rendu en tiles, que nous verrons dans le prochain chapitre. Il s'agit techniquement d'une forme de compression d'image spécialisée, qui est assez reliée au mode texte des anciennes cartes graphiques. Nous verrons le rendu en tile et le rendu en mode texte dans un chapitre dédié.

Une autre optimisation rend l'image ligne par ligne. La mémoire vidéo ne contient que la ligne en cours de rendu, pas une image complète. D'ailleurs, la mémoire vidéo est souvent appelée le line buffer, tampon de ligne en français. Il n'y a pas de terminologie officielle pour désigner de tels VDC, mais nous pouvons les appeler des scanline accelerators, accélérateurs de scanline.

Quelques VDC anciens étaient de ce type. Par exemple, les premières consoles ATARI utilisaient des accélérateurs de scanline, de même que la carte graphique de la console néo-géo. Voyons-en quelques exemples, avec notamment les VDC des consoles ATARI. Nous verrons dans quelques sections qu'ils sont assez complexes, avec une gestion des sprites matériels, du défilement, et quelques autres techniques d'accélération 2D.

Généralités sur les accélérateurs de scanline

[modifier | modifier le wikicode]

Avec un accélérateur de scanline, le processeur doit écrire la prochaine ligne à afficher dans le tampon de ligne, avec un timing assez serré. Il doit attendre que l'affichage de la ligne en cours soit terminé, mais doit finir son travail avant l'affichage de la prochaine ligne. Pour le dire autrement, le processeur met à jour la ligne à afficher pendant l'intervalle de horizontal blank. Il s'agit d'une technique appelée le racing the beam. Pour cela, le VDC émet une interruption matérielle à la fin de l’affichage de la ligne courante.

Les accélérateurs de scanline peuvent supporter les sprites matériels. Typiquement, ils supportent un certain nombre de sprite par ligne. Notez le "par ligne". En effet, le processeur peut en profiter pour changer les sprites entre l'affichage de deux lignes. L'intérêt est que cela permet d'afficher plus de sprites que ce que la console permet. Par exemple, si l’accélérateur de scanline supporte 8 sprites par ligne, il peut afficher plus de 8 sprite par image, en respectant la contrainte de 8 sprite par ligne. C'est juste qu'il y aura, par exemple, 8 sprites en haut de l'image, 8 au milieu, 8 en bas.

La display list

[modifier | modifier le wikicode]

Les accélérateurs de scanline les plus complexes permettent de réduire grandement le nombre de raster interrupts en intégrant une sorte de contrôleur DMA programmable. Dans le détail, ce contrôleur DMA exécute un programme composé d'une suite d'instructions simples, chacune commande l'affichage d'une ligne à l'écran. Le programme exécuté est une série d'instructions appelée la display list, terme qui reviendra dans quelques chapitres.

La display list commence par un préambule de configuration, qui indique la résolution, le mode graphique, etc. Puis, elle afficher l'image à l'écran, ligne par ligne, sans intervention du processeur. La display list a une instruction par ligne à afficher, l'instruction copie la ligne à afficher de la mémoire RAM principale vers la mémoire vidéo (le tampon de ligne), pendant une période de blanking horizontal. La display list s'occupe de séquencer les lignes une par une, sans intervention du processeur. Le processeur n'a pas besoin de copier lui-même chaque ligne, la display list le fait automatiquement pour toutes les lignes.

Un avantage est que le défilement vertical est très rapide avec une display list. Il suffit de rajouter ou d'enlever des lignes dans la display list. Pour défiler d'une ligne vers le bas, on rajoute une ligne au début de la display list et on enlève la dernière. Et inversement pour défiler vers le haut. Le processeur peut le faire sans se synchroniser fortement avec l'accélérateur de scanline. Nous verrons dans quelques chapitres que ce mécanisme a été utilisé sur les ordinateurs Amstrad PCW, mais nous ne pouvons pas en parler pour le moment, vu que leur carte d'affichage est une carte en mode texte, qu'on n'a pas encore vu.

Illustration d'un Video Interface Controler.

La display list fait que les raster interrupts sont inutiles, ou du moins grandement réduites. Les interruptions sont déclenchées pour certaines lignes seulement, celles où l'instruction qui affiche la ligne le précise. La gestion des interruptions est donc totalement programmable. La display list contient des instructions pour déclencher des raster interrupts, placées stratégiquement dans la display list par le programmeur. Par exemple, si le programmeur veut changer les sprites entre deux lignes, il peut déclencher une raster interrupts entre ces deux lignes, le processeur répondra à l'interruption en changeant les registres de sprites.

Le Television Interface Adaptor des consoles Atari 2600

[modifier | modifier le wikicode]

Un exemple d'accélérateur de scanline est celui utilisé sur l'Atari Video Computer System et l'Atari 2600. La carte graphique en question s'appelait la Television Interface Adaptor, abrévié TIA. Il incorpore un tampon de ligne, qui est modifié entre l'envoi à l'écran de deux lignes. Par contre, il n'y a pas d'interruption pour prévenir le processeur qu'il doit envoyer la ligne suivante.

Les contraintes de conception

[modifier | modifier le wikicode]

Le TIA a été conçu avec de nombreuses contraintes en tête, qui ne se comprennent qu'en remettant la console dans son contexte historique. L'époque est celle des consoles de première génération, des consoles qui étaient conçues pour un jeu bien précis. Pas de cartouche, de CD ou quoique ce soit d'autre. Le jeu était intégré directement dans les circuits de la console, qui n'était pas programmable. Il y avait une console pour le jeu Pong, une autre pour le jeu Tank, etc. Les jeux étaient alors très limités niveau gameplay.

Pong Game Test2

Les premières consoles ATARI étaient les premières consoles de seconde génération, conçues pour faire fonctionner plusieurs jeux, conçues pour être programmables. Elles étaient conçues pour faire fonctionner Pong, Tank et des variantes de ces deux jeux. Pour rappel, s'il faut faire un rappel, Pong était un jeu de tennis à deux joueurs. Il se limitait à un arrière-plan fixe sur lequel chaque joueur contrôle une barre verticale, sur laquelle la balle peut rebondir.

Tank était une variante de PONG, où deux joueurs controlaient chaun un tank et se tiraient des missiles dessus avec l'intention de détruire le tank de l'adversaire. Son adaptation sur ATARI s'appelait Combat!, en voici un screenshot :

Combat, jeu de l'Atari 2600.

La console est donc conçue pour gérer un arrière-plan, deux sprites pour les joueurs, un pour la balle, deux pour les missiles. Chacun est mémorisé dans un registre dédié, qui mémorise la ligne du sprite/arrière-plan à afficher à l'écran. De plus, le TIA incorpore un système pour détecter les collisions, afin de détecter si la balle rebondit sur la raquette dans PONG, sur un missile touche un tank dans TANK. La conception du TIA est très simple, sans compter que diverses optimisations permettent d'encore plus simplifier le TIA. Par exemple, l'arrière-plan est censé être symétrique : la partie gauche est recopiée sur le côté droit de l'écran, avec un effet miroir. Optimisation possible car PONG et TANK sont censé avoir un terrain de jeu symétrique, identique pour chaque joueur.

Avec une architecture aussi simple, il n'est pas possible de faire de miracles. Les jeux sur ATARI devaient faire avec les limitations du TIA : arrière-plan symétrique, moins d'une dizaine de sprites simultanés à l'écran.

Mini Golf, Atari 2600
Backgammon , Atari 2600
Human Cannonball, Atari 2600
Frogs & Flies, Atari 2600
Screenshot de Duck Attack !
Barnstorming Atari 2600

Quelques ruses de sioux ont cependant permis de dépasser ces limites techniques.

Spider Fighter Atari 2600
Robot Tank Atari 2600
Bradley Trainer screenshot

L'arrière-plan du TIA : un registre dédié de 20 bits

[modifier | modifier le wikicode]

Vu que le rendu s'effectue ligne par ligne, l'arrière-plan et les sprites sont en réalité des lignes, pas des images complètes. Le registre d'arrière-plan est un registre de 20 bits. Un bit correspond à 4 pixels de l'écran, il indique la couleur de ces 4 pixels consécutifs. Peut-être avez-vous remarquez que 20 bits * 4 pixels = 80 pixels. Pourtant, le TIA gére une résolution horizontale par défaut de 160 pixels. Soit deux fois plus que ce qui est géré par le registre d'arrière-plan. La raison est que l'arrière-plan est symétrique. Le registre d'arrière-plan ne définit que la moitié gauche de l'écran, la moitié droite en est le reflet miroir.

Vous pourriez croire que la couleur codée dans ce registre est un simple noir et blanc, mais c'est plus compliqué. Le registre d'arrière-plan indique, pour chaque bloc de 4 pixels, si le pixel prend la couleur de l'arrière-plan ou la couleur de fond par défaut. Un registre séparé contient la couleur de l'arrière-plan, elle-même encodée grâce à un système de palette indicé. La couleur de fond est elle aussi configurable.

En théorie, l'arrière-plan est censé être symétrique, mais quelques astuces permettent d'afficher un arrière-plan asymétrique. L'idée est simplement de modifier le registre d'arrière-plan une fois que la moitié gauche a été affichée. Cela demande un timing très précis, mais c'est possible et plusieurs jeux Atari 2600 le faisaient. Certains arrivaient même à simuler un défilement horizontal !

Les sprites matériels du TIA

[modifier | modifier le wikicode]

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

Un sprite matériel sur le TIA est codé sur un octet. Un sprite fait 8 pixels de large, chacun étant là-encore codé sur 1 bit. Il indique encore une fois si le pixel a la couleur de fond ou une couleur spécifique au sprite. Vu que cet octet écrit une ligne du sprite, il est changé à chaque ligne pour réellement afficher un sprite à deux dimensions. Vous remarquerez que le sprite est codé sur un octet, les informations de sa position X et Y à l'écran ne sont pas encodées. La raison est simple : un sprite est affiché quand le processeur écrit l'octet dedans.

L'affichage d'un sprite est donc commandé par le processeur et doit se faire au cycle d'horloge près. Le problème est que le processeur n'est pas précis au pixel près. Déjà, sa fréquence est égale à un tiers de la fréquence du TIA, ce qui fait qu'il est précis à 3 pixels près. De plus, le logiciel faisait l'écriture dans le registre de sprite est basé sur une boucle, dont les instructions prennent 5 cycles pour s'exécuter. Ce qui fait une imprécision de 15 cycles.

Pour compenser ce défaut, le TIA incorpore deux registres, un par avatar/player. Ils permettent de décaler le sprite de quelques pixels, la valeur de décalage étant précisée dans ce registre. La valeur est un entier allant de -7 à 8 cycles/pixels. L'affichage d'un sprite est donc en deux temps : on déclenche une écriture précise à 15 cycles près, puis on décale le sprite avec ces deux registres.

L'architecture du TIA

[modifier | modifier le wikicode]

Mettre à jour les registres du TIA à chaque ligne n'est pas si différent de modifier une mémoire vidéo entre deux lignes. Mais 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.

Si on devait faire un constat avec tout ce qui a été dit plus haut, il serait que le TIA est très simple, trop simple. Le processeur est très impliqué dans l'affichage à l'écran, notamment pour les sprites. La carte graphique est donc composé par l'association entre le TIA et le processeur proprement dit, bien que celui-ci soit le CPU. Ce qui explique l'absence de mécanismes de synchronisation entre TIA et CPU : c'est le CPU qui commande le TIA au cycle près.

Les VDC ANTIC et CTIA des consoles Atari post-2600

[modifier | modifier le wikicode]

Les consoles Atari 8 bits, qui ont succédé à l'Atari 2600, n'utilisaient un VDC TIA amélioré appelé le CTIA pour sa première version (Color Television Interface Adaptor), le GTIA pour sa seconde version (Graphic Television Interface Adaptor). Les deux étaient un accélérateur de scanline qui intégraient l'accélération matérielle de sprites et une palette indicée. Comme pour le TIA, les sprites servaient pour les joueurs et des missiles, sauf que leur nombre est doublé pour passer à 4 chacun.

Mais la révolution était qu'ils étaient associé à un autre circuit appelé l'ANTIC, qui commandait le CTIA/GTIA sans intervention du processeur CPU. Là où l'Atari 2600 utilisait le processeur pour l'affichage des sprites et beaucoup d'autres fonctions, une bonne partie de ces traitements est déléguées à l'ANTIC sur les machines suivantes. L'ANTIC est un blitter amélioré, en charge des copies en mémoire. Mais surtout, il s'occupe du séquencement des lignes envoyées au CTIA. Ce n'est pas le processeur qui s'occupe d'envoyer les lignes une par une au CTIA, cette fonction est déportée sur l'ANTIC.

La display list

[modifier | modifier le wikicode]

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.

Les registres de configuration de l'ANTIC

[modifier | modifier le wikicode]
Atari ANTIC (CO12296) pin-out

Niveau registres, l'ANTIC contient un program counter et de quoi configurer le contrôleur DMA. Il a aussi des registres pour localiser les données importantes en mémoire, qui sont regroupées dans des tableaux séparés.

Pour ce qui est du rendu, il contient un registre pour l'adresse de l'arrière-plan, un autre pour l'adresse des sprites en mémoire RAM. Il contient aussi deux registres pour configurer le défilement horizontal et vertical.

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.

Le système graphique de la console néo-géo

[modifier | modifier le wikicode]

La néo-géo est une console assez ancienne, de la période 16 bits, commercialisée en 1990. Elle avait pour caractéristique d'avoir le même hardware que les bornes d'arcade du même nom, les néo-géo MVS. Elle comprenait un processeur Motorola 68000, 64 kilooctets de mémoire RAM, un processeur sonore Zilog Z80, et un VDC avec 64 kilooctets de mémoire vidéo. Elle avait une palette de 65 536 couleurs, grâce à une palette indicée de 8 kilooctets.

L'avant-plan, l'arrière-plan et les sprites de la néo-géo

[modifier | modifier le wikicode]

Un point important est que la mémoire vidéo n'est pas un framebuffer, le VDC utilise un tampon de ligne. La console a pour particularité de ne pas gérer d’arrière-plan, l'image affichée est intégralement composée de sprites. Les sprites sont tous combinés pour donner l'image finale.

Un autre détail est que les sprites matériels sur la néo-géo sont différents des sprites matériels des consoles précédentes. Ils sont composés de plusieurs motifs, de plusieurs tiles. Les motifs font tous des carrés de 16 pixels de côtés, contenant 15 couleurs (plus la couleur de transparence). Un sprite est un rectangle qui a un pixel de large, mais dont la hauteur va de 1 à 32 motifs, donc de 16 à 512 pixels. Chaque sprite indique quels sont ses motifs, quelle palette utiliser, s'il faut appliquer l'effet miroir, etc. Un sprite logiciel est composé en concaténant horizontalement plusieurs sprites de néo-géo.

A défaut d'un arrière-plan, la console gère un pseudo-arrière-plan composé d'une couleur unie. Pour faire la différence avec une image d’arrière-plan, on peut plutôt parler d'une couleur de fond. Les sprites sont empilés les uns sur les autres au-dessus de cet arrière-plan. Au-dessus des sprites, il y a un avant-plan spécialisé, qui est rendu au-dessus de tout le reste, qui est utilisé pour rendre le HUD. Cet avant-plan est appelé la fix layer dans la documentation de la console. Les motifs de l'avant-plan n'ont pas la même taille que pour les sprites : ce sont des carrés de 8 pixels de côtés, pas 16 !

Les motifs de l'avant-plan sont placés dans une mémoire ROM de celle pour les motifs des sprites. En effet, une cartouche de néo-géo contient plusieurs mémoires ROM :

  • une pour les motifs des sprites ;
  • une pour les motifs de l'avant-plan ;
  • une pour le programme à exécuter sur le processeur M68000 principal ;
  • une pour le programme à exécuter sur le processeur sonore Z80 ;
  • une pour les échantillons audio, la musique et le son du jeu.

Le rendu d'une image

[modifier | modifier le wikicode]

Lors du rendu d'une ligne, la console détermine quels sprites sont affichés sur cette ligne, et les sprites adéquats sont lus depuis la ROM de la cartouche. Les pixels de la ligne sont déterminés par le VDC et écrit dans le tampon de ligne. La console supporte au maximum 96 sprites par ligne, ce qui dépasse de très loin les capacités des autres consoles 8 ou 16 bits.

La console dispose de deux tampons de lignes, ce qui permet d'afficher une ligne pendant qu'on prépare la suivante. L'idée est la suivante. La première ligne est placée dans un tampon de ligne, elle est envoyée à l'écran pixel par pixel par le VDC. En même temps, le processeur écrit la prochaine dans le second tampon de ligne. Une fois la ligne totalement affichée, le VDC échange les deux tampons de ligne. La ligne suivante est déjà prête dans le second tampon de ligne, elle est envoyée à l'écran pixel par pixel, le premier tampon de ligne est utilisé pour précalculer la ligne suivante, et ainsi de suite.

La gestion de la profondeur des sprites est assez simple : les sprites sont placés dans la mémoire vidéo selon leur profondeur. En clair, les sprites situés au fond de l'image sont placés au début de la mémoire vidéo, ceux situés à l'avant-plan sont placés à la fin de la mémoire vidéo. Pas besoin po_ur le VDC de gérer les priorités et la profondeur des sprites, tout est déjà réglé une fois les sprites chargés dans la mémoire vidéo.

Le console gère une mise à l'échelle des sprites matériels, mais ceux-ci ne peuvent qu'être réduits, pas zoomés. La mise à l'échelle est précise au pixel près. La mise à l'échelle peut se faire à l'horizontale et à la verticale, les deux sont séparés. Par exemple, on peut réduire un sprite d'un facteur 5 sur la verticale, d'un facteur 2 sur l'horizontale.


Les Video Display Controler atypiques

Nous avions vu dans le chapitre précédent qu'il existe plusieurs types de VDC. Dans les chapitres précédents, nous avons abordé les CRTC des systèmes à framebuffer, ainsi que des VDC à tile et ceux à tampon de ligne. Dans ce chapitre, nous allons voir des VDC ou des systèmes graphiques originaux.

Le rendu des sprites sur la borne d'arcade CPS-1

[modifier | modifier le wikicode]

La borne d'arcade CPS-1 avait une méthode rendu assez particulière. Contrairement aux VDC normaux, sa carte graphique rendait les sprites séparément. Elle avait un framebuffer dédié au rendu des sprite, qui était combiné avec l'arrière-plan pour donner l'image finale. La carte graphique lisait les sprites depuis la mémoire vidéo et les plaçait dans ce second framebuffer, pour donner une image sans l'arrière-plan. La combinaison se faisait au moment d'afficher l'image, lors du rendu d'une ligne à l'écran.

Un inconvénient de cette méthode est que le rendu du framebuffer pour les sprites se faisait pendant le rendu de l'image précédente. En clair, pendant que le GPU affichait une image à l'écran, le VDC accédait à la mémoire vidéo pour lire les sprites et calculer ce second framebuffer. Le résultat était qu'il y avait une sorte de retard d'une frame pour les sprites, donc un léger input lag. Un second inconvénient est le cout en hardware. Le second framebuffer avait sa propre mémoire RAM séparée du reste, d'une capacité mémoire de 384 kilo-octets. Cela coutait cher, mais les bornes d'arcade de l'époque se moquaient un peu de ces considérations, vu leur prix élevé.

L'avantage est que les limitations en matière de sprite par ligne n'existent pas sur ce système. Les autres systèmes de l'époque devaient faire avec une limite de sprite par ligne, que les développeurs contournaient en éteignant certains sprites une image sur deux, donnant un effet de clignotement. De son côté, le CPS-1 avait juste une limite par image, et une limite par image assez élevée qui plus est. Les jeux sur CPS-1 n'avaient donc pas d'effets de clignotement. Par contre, les développeurs devaient faire avec la limitation de sprite par image. Autant cette limite n'était pas pertinente, grâce au raster effects, autant elle limitait les graphismes sur la CPS-1 car le swap des sprites à la volée lors de l'affichage n'était pas possible. Pour résumer, la CPS-1 échangeait une limitation de sprite par ligne pour une limitation de sprite par image.

Le double VDC de la SuperGrafx

[modifier | modifier le wikicode]

La console de jeu SuperGrafx est le successeur spirituel de la console PC-Engine, aussi appelée Turbo Graphx en occident. Elle a le même CPU, la même quantité de mémoire RAM, le même VDC 2D. Voici l'architecture de la Turbo Graphx, qui est assez simple. Un CPU, une puce sonore, un VDC, un circuit pour la palette et l'interface écran séparé du VDC, une RAM pour le CPU et une mémoire vidéo reliée au VDC. La ROM du système est dans la cartouche de jeu.

Turbo graphx

La Super Graphx a une grosse différence : elle a deux VDC identiques ! L'objectif des concepteurs de la console était de doubler la puissance graphique et la solution la plus simple était évidemment de dupliquer le VDC... La console avait donc deux VDC identique à ceux de la PC-Engine, à savoir deux HuC6270. La mémoire vidéo est elle aussi dupliquée, à savoir que chaque VDC dispose de sa propre mémoire vidéo de 64 kilo-octets.

Les deux VDC calculent une image séparée chacun de leur côté, puis les deux images sont fusionnées pour donner l'image finale. L'intérêt est que cela permet d'avoir deux fois plus d'arrière-plan et de sprite, si la fusion est gérée correctement. La fusion est réalisée par un circuit séparé, appelé le VPC (Video Priority Controler). À chaque pixel de l'écran, il choisit si ce pixel provient du premier VDC ou du second. Le tout est géré par un système qui dit quel écran a la priorité, l'écran prioritaire étant codé sur un 1 bit par pixel. Le VPC gère aussi la palette indicée et le RAMDAC, il n'y a pas de VCE séparé comme sur la PC-Engine.

Super Graphx

Les Video shifters : des VDC sans mémoire vidéo associée

[modifier | modifier le wikicode]

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.

Les VDP les plus simples se passent de mémoire vidéo. Ils n'effectuent pas le rendu ligne par ligne, mais réellement pixel par pixel. Nous les appellerons des Video shifters, un nom qui 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).

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

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

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

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

VDP shifter RCA CDP1861

Les fonctionnalités supplémentaires

[modifier | modifier le wikicode]

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.

Les VDC peuvent gérer des fonctionnalités qui dépassent de loin le rendu 2D. Ils peuvent gérer des fonctionnalités très diverses, allant de la gestion de la mémoire vidéo, l'intégration d'un co-processeur graphique, la gestion du clavier et de la souris, une carte son rudimentaire, la gestion des signaux d'horloge du processeur, des timers et bien d'autres. Faisons un petit tour d'horizon de ces fonctionnalités.

Le contrôleur mémoire pour la RAM vidéo

[modifier | modifier le wikicode]

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.

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 fonctionnalités liées aux périphériques

[modifier | modifier le wikicode]

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

Ingénieur de Hughes Aircraft Company travaillant sur un ordinateur de CAO avec un crayon optique.

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

Un exemple : le NEC μPD7220

[modifier | modifier le wikicode]
NECuPD7220BlockDiagram

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

L'exemple des anciens micro-ordinateurs Amiga

[modifier | modifier le wikicode]

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

Amiga Original Chipset diagram

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

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

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


Les cartes d'affichage des anciens PC

Dans cette section, nous allons parler rapidement des cartes graphiques des anciens PC, qui étaient des systèmes à framebuffer. Les standards graphiques sur PC étaient le MDA, le CGA, l'EGA et le VGA, dans l'ordre chronologique. C'était les standards les plus communs, qui ont été introduits par les cartes graphiques suivantes :

  • IBM Color/Graphics Display Adapter (CGA)
  • IBM Monochrome Display and Printer Adapter (MDA or sometimes MDPA)
  • IBM Enhanced Graphics Adapter with Graphics Memory Expansion Card (EGA)
  • IBM PS/2 Display Adapter (VGA)

Le MDA et le CGA utilisaient des framebuffer compacts, alors que l'EGA et le VGA utilisaient des framebuffer planaires. Cependant, par souci de compatibilité, l'EGA et le VGA supportent les résolutions du CGA et du MDA, avec un framebuffer compact. La mémoire vidéo des cartes graphiques EGA et VGA pouvaient être utilisées soit de manière à avoir un framebuffer planaire ou compact, elle servait pour les deux.

Les cartes graphiques concurrentes étaient la Hercules Graphics Card, la Tandy Video II Graphic Adapter, la Multi-Color Graphics Array (MCGA) et quelques autres. Mais elles étaient peu disponibles et eurent peu de succès, à l'exception de la carte Hercules qui a été supportée dans quelques jeux vidéo.

Les standards MDA et CGA

[modifier | modifier le wikicode]

Les tout premiers IBM PC, utilisaient la carte graphique IBM Monochrome Display Adapter (MDA), qui était toujours connectée à un écran 5151 IBM PC Display, que nous appellerons IBM 5151 dans ce qui suit. La carte CGA (Color Graphics Adapter) étaient disponibles en même temps que la carte MDA, mais les écrans CRT qui pouvaient utiliser ses fonctionnalités ont mis un peu de temps avant d'arriver, ce qui fait qu'on considère parfois à tort que la carte MDA est arrivée avant la CGA. La carte MDA ne gérait que le mode texte, en monochrome, alors que la carte CGA gérait jusqu'à 16 couleurs avec un système de palette indicée.

Modes CGA officiels
Modes graphiques
320 par 200 pixels 4 couleurs par pixel
640 par 200 pixels 2 couleurs par pixel
Modes texte
320 par 200 pixels 40 par 25 caractères
640 par 200 pixels 80 par 25 caractères

Les cartes MDA et CGA avaient un hardware très semblable vu de loin. La carte MDA incorporait 4 kilo-octets de RAM vidéo, la carte CGA en avait 16 kilo-octets. Les deux incorporaient un VDC Motorola 6845, qui ne gérait que le mode texte. Les cartes CGA (Color Graphics Adapter) ont trouvé un moyen de supporter le mode graphique avec ce VDC, grâce à des circuits qui entouraient le VDC. Beaucoup de bidouilles étaient nécessaires pour lui faire afficher des graphismes pixel par pixel.

Il était possible d'utiliser une carte MDA en complément d'une carte CGA, dans le même PC. Les deux pouvaient être utilisées pour alimenter deux écrans : un écran monochrome pour la MDA et un écran couleur pour la CGA. Mais elles pouvaient aussi être utilisées pour alimenter un seul écran, la carte CGA se chargeant des calculs graphiques et la MDA pour afficher du texte.

Les alternatives aux cartes MDA/CGA étaient assez nombreuses. La quasi-totalité utilisait cependant le même VDC et un hardware très similaire. On parlait d'ailleurs de clones MDA/CGA. Elles se contentaient souvent d'ajouter des fonctionnalités, plus de mémoire vidéo, etc. Par exemple, la carte Hercules était totalement compatible avec la carte MDA, si ce n'est pour l'ajout d'un mode de résolution graphique capable de faire du pixel par pixel. Il y a cependant quelques exceptions comme la Professional Graphics Controller qui intégrait un processeur Intel 8088 en guise de VDC.

Une description détaille des cartes CGA, avec leurs registres de configuration, est disponible via ce lien. Je n'en conseille pas la lecture maintenant :

Le standard EGA

[modifier | modifier le wikicode]

Par la suite, les cartes graphiques MDA et CGA ont été remplacées par les cartes EGA. De nombreuses cartes graphiques sensiblement identiques à l'EGA, des clones hardware, ont envahi le marché, transformant l'EGA en standard de facto. Elles géraient entre 4 et 16 couleurs, suivant la résolution, avec un système de palette indicée.

Les cartes EGA ont remplacé le framebuffer compact des anciennes cartes MDA/CGA par un framebuffer planaire. La mémoire vidéo de la carte EGA était composée de 4 banques, chaque banque étant un bitplane. En clair, les modes gérant 16 couleurs codaient un pixel sur 4 bits, avec un bit par bitplane/banque. Cependant, la résolution 640 par 350 pixels n'utilisait que deux couleurs et utilisait un framebuffer compact. Le choix entre un framebuffer planaire et compact était réalisé en configurant un bit dans le registre de configuration adéquat, à savoir le registre Sequencer Memory Mode Register , faisant partie du Sequence Controller. Le bit nommé Chain 4 Bit valait 0 pour un framebuffer planaire, 1 pour un framebuffer compact.

Résolution Couleurs par pixel Framebuffer
Modes EGA
320 par 200 pixels 16 couleurs par pixel framebuffer planaire
640 par 200 pixels 16 couleurs par pixel
640 par 350 pixels 16 couleurs par pixel
Mode nécessitant la carte d'extension mémoire
640 par 350 pixels 2 couleurs par pixel framebuffer compact

L'architecture d'une carte EGA

[modifier | modifier le wikicode]

Une carte EGA était composée de 4 circuits, chacun ayant ses propres registres : le CRT Controller (CRTC), le Sequence Controller (SC), l'Attribute Controller (AC) et le Graphics Controller (GC).

  • Le CRTC est un CRTC basique mais ce n'est pas le MC6845 des cartes CGA/MDA, bien que la compatibilité entre eux soit garantie.
  • L'Attribute Controller est un RAMDAC amélioré.
  • Le Sequence Controller communique avec le RAMDAC et le CRTC pour accéder à la mémoire vidéo.
  • Le circuit restant, le Graphics Controller, sert d'interface entre le bus et la mémoire vidéo.

Les quatre composants précédents sont collés entre eux par un paquet de circuits qui servent d'interface entre eux. Une carte EGA contient de nombreux registres, multiplexeurs et autres circuits. Voici ce que donne l'ensemble en schéma :

Carte EGA.

Les cartes EGA intégraient 64 kilo-octets de mémoire vidéo sur la carte elle-même, mais on pouvait ajouter une seconde carte d'extension pour passer à 256 kilo-octets. Certaines résolutions ne fonctionnaient que si cette carte d'extension mémoire était présente.

Le multi-GPU EGA/CGA

[modifier | modifier le wikicode]

Les cartes EGA pouvaient fonctionner de concert avec une seconde carte CGA, MDA ou Hercules. Un PC de l'époque pouvait avoir une carte EGA sur un port d'extension, et une carte MDA/CGA sur un autre. Quand une carte CGA est installée, la carte EGA ne fonctionne qu'en mode monochrome. Par contre, quand la seconde carte est une carte MDA ou Hercules, la carte EGA ne fonctionne qu'avec les résolutions à 4 ou 16 couleurs. En clair, elle prend en charge l'affichage monochrome avec les cartes CGA (polychromes), coloré avec les cartes MDA monochromes.

L'utilisation d'une carte EGA avec une autre carte MDA/CGA s'explique par une histoire d'entrée-sorties mappées en mémoire. Pour rappel, des adresses mémoires, censées adresser la mémoire RAM, sont détournées pour adresser la mémoire vidéo. Il en est de même pour les registres de configuration de la carte graphique : des adresses mémoire sont détournées pour pointer vers ces registres. Si on regarde l'ensemble des adresses gérées par le processeur, certaines pointent vers la RAM, d'autres vers la carte graphique, d'autres vers des périphériques, d'autres vers le BIOS, etc. Les cartes EGA et les cartes MDA/CGA détournent des adresses différentes. La mémoire des PC de l'époque était découpée en blocs de 64 kilo-octets, que le système d'exploitation DOS allouait comme suit :

Espace d'adressage en mode réel
Numéro du bloc Contenu du bloc
0 Mémoire RAM, mémoire conventionnelle
1
2
3
4
5
6
7
8
9
10 Mémoire vidéo des cartes EGA
11 Mémoire vidéo des cartes MDA ou CGA
12 ROM d'extension des périphériques (XT, EGA, 3270 PC)
13 Autre, non-réservé
14 Autre, non-réservé
15 ROM du BIOS

Le standard VGA

[modifier | modifier le wikicode]

Le standard VGA fait suite au standard EGA et gère des résolutions jusqu'à 640 par 480. Il intègre aussi un support du double buffering, à savoir qu'il gère deux framebuffers séparés. Mais le double buffering n'est possible que dans les basses résolutions, car il n'y a pas assez de mémoire vidéo pour les deux framebuffers dans les hautes résolutions. Le VGA intègre aussi d'autres fonctionnalités qui seront détaillées dans le chapitre suivant, notamment pour ce qui est du défilement horizontal/vertical.

Les résolutions supportées par le VGA

[modifier | modifier le wikicode]

Le standard VGA décrit plusieurs modes de fonctionnement, chacun correspondant à une résolution avec un nombre de couleur associé. Ils sont numérotés en hexadécimal, les modes 1 à 16 (0xFh) correspondent aux résolutions supportées par les cartes MDA, CGA et EGA, les quatre modes restants sont spécifiques au VGA. D'autres modes non-standards sont supportés par la majorité des cartes VGA, comme le mode X, un mode non-officiel qui définit une résolution de 320 par 240 pixels, avec 256 couleurs par pixel.

Suivant le mode utilisé, le framebuffer sera soit de type compact, soit de type planaire ! Les modes gérant 256 couleurs par pixel utilisent un framebuffer compact, avec un octet par pixel. Par contre, les modes 16 couleurs codent un pixel sur 4 bits, ce qui colle mieux avec un framebuffer planaire à quatre bitplanes. Les modes 16 couleurs sont surtout utilisés avec des résolutions élevées, alors que les modes 256 utilisent des résolutions basses, pour des raisons de mémoire vidéo. Par exemple, une résolution de 640 par 480 fait qu'une image contient 307 200 pixels, ce qui prend au minimum 300 kilo-octets en mode 256 couleurs ! Or, les cartes graphiques VGA disposaient de seulement 256 kilo-octets de mémoire vidéo. Vous devinez que la carte graphique ne supportait pas 256 couleurs par pixels dans cette résolution, mais qu'elle codait un pixel sur moins d'un octet.

Le mode le plus utilisé est le mode 13h, d'une résolution de 320 par 200 pixels pour 256 couleurs par pixel. Il a été utilisé par la grosse majorité des jeux vidéos de l'époque, car son framebuffer compact était beaucoup plus pratique pour les programmeurs, malgré des performances moindres que les autres modes VGA. Quelques jeux vidéos ont cependant utilisé le mode 11h avec son framebuffer planaire, comme l'a fait DOOM 1 et sa suite DOOM 2. Cependant, les autres jeux basés sur le même moteur utilisaient le mode 13h, l'exemple typique étant celui des FPS Heretic et Hexen de Raven Software.

Mode Résolution Couleurs par pixel Framebuffer
Modes principaux
mode 10h 640 par 350 pixels 4 à 16 couleurs par pixel (dépend de la RAM vidéo installée) framebuffer planaire
mode 11h 640 par 480 pixels Monochrome, 2 couleurs par pixel framebuffer planaire
mode 12h 640 par 480 pixels 16 couleurs par pixel framebuffer planaire
mode 13h 320 par 200 pixels 256 couleurs par pixel framebuffer compact

L'organisation du framebuffer

[modifier | modifier le wikicode]

La mémoire vidéo fait 256 kilo-octets, répartis dans 4 mémoires RAM de 64 kibioctets chacune. En mode framebuffer compact, un pixel fait un octet. Chaque octet d'une banque correspond à un pixel.

VGA en mode framebuffer compact/linéaire
Octet banque 1 Pixel 1, codé sur 8 bits
Octet banque 2 Pixel 2, codé sur 8 bits
Octet banque 3 Pixel 3, codé sur 8 bits
Octet banque 4 Pixel 4, codé sur 8 bits

Avec un framebuffer planaire, les pixels sont codés sur 4 bits. Idéalement, chaque bitplane doit être une mémoire bit-adressable, à savoir que chaque adresse mémoire sélectionne un bit. Mais il s'agit là d'un cas idéal que les cartes VGA en respectaient pas. A la place, chaque bitplane est une mémoire adressable par octet, et non par bit. Heureusement, elles intégraient des circuits pour simuler une mémoire bit-adressable à partir d'une mémoire adressable par octet.

Les quatre banques sont accessibles en parallèles, ce qui permet de lire/écrire 4 octets en même temps, un octet par banque. Dans le mode framebuffer planaire, les 4 octets lus sont découpés en 8 pixels de 4 bits d'un coup. Le DAC situé en aval de la mémoire vidéo recevait 8 pixels de 4 bits chacun, et mémorisait le tout dans une petite mémoire tampon. Il fournissait, en sortie un pixel par cycle d'horloge. Une seule lecture dans la mémoire vidéo permettait au DAC d'afficher 8 pixels pour une seule lecture en mémoire vidéo.

VGA en mode framebuffer planaire
Pixel 1 Pixel 2 Pixel 3 Pixel 4 Pixel 5 Pixel 6 Pixel 7 Pixel 8
Octet banque 1 0 0 1 1 0 0 1 1
Octet banque 2 1 1 1 1 1 0 1 1
Octet banque 3 0 1 1 1 0 0 0 0
Octet banque 4 0 0 1 0 1 0 1 1

Le registre intermédiaire : les VGA latches

[modifier | modifier le wikicode]

Pour gérer au mieux les lectures et écritures, une carte VGA intègre un registre intermédiaire de 32 bits qui sert d'intermédiaire pour les lectures et écritures. Lors d'une lecture, un octet est lu dans chaque bitplane et les 4 octets lus sont alors enregistrés dans ce registre intermédiaire. Là, les 32 bits sont alors soit envoyé au RAMDAC pour être affichés à l'écran, soit envoyés au processeur lors d'une lecture.

VGA, lecture dans la mémoire vidéo

Pour les écritures, le registre intermédiaire est utilisé pour diverses opérations. Par exemple, supposez que le programmeur ne veut modifier qu'un seul pixel codé sur 4 bits. Dans ce cas, il doit lire 32 bits, ne modifier que le pixel dans ces 32 bits, et écrire le résultat en mémoire vidéo. Les bits à modifier sont précisés dans un registre de masque, le Bit Mask Register. Il fait 32 bits, soit la même taille que le registre intermédiaire, et chaque bit du registre de masque correspond à un bit du registre intermédiaire. Si le bit en question est à 1, le bit à écrire provient du registre intermédiaire. Sinon, il vient du pixel envoyé par le CPU.

Pour appliquer le masque, le registre intermédiaire est complété par un circuit de masquage, qui permettent de ne modifier que certains bits dans les 32 bits. Le circuit de masquage est concrètement un ensemble de multiplexeurs qui permettent de choisir, pour chaque bit à écrire dans une bitplane, s'il vient du registre intermédiaire, ou des données envoyées par le processeur. Les multiplexeurs sont commandés par un registre de masque.

Le VGA fournit deux modes de lecture et quatre modes d'écriture. Pour les écritures, il y a quatre modes nommés mode 0, 1, 2 et 3, que nous détaillerons dans la suite. L'utilisation des circuits de masquage dépend du mode d'écriture. Pour simplifier, il y a trois cas distincts.

  • Dans le mode d'écriture numéro 0, le processeur envoie un nouveau pixel par cycle. Le masque est mémorisé dans un registre et est stable d'une écriture sur l'autre. Il s'agit du fonctionnement intuitif, où le processeur écrit dans le framebuffer.
  • Dans le mode d'écriture numéro 3, c'est l'inverse. Le processeur envoie un nouveau masque par cycle, alors que le pixel à écrire est mémorisé dans un registre et reste le même d'une écriture sur l'autre. Ce mode sert à remplir une zone de l'écran avec une même couleur, que ce soit pour tracer des lignes, colorier une figure géométrique, remettre à zéro le framebuffer.
  • Dans le mode d'écriture numéro 1, le circuit de masquage est configuré pour recopier le registre intermédiaire dans la RAM directement. Il sert pour les copies mémoire-mémoire, à savoir copier des données de la mémoire vidéo vers un autre endroit.
Circuit de masquage du VGA

Les circuits annexes : ALU, SET/RESET et barrel shifter

[modifier | modifier le wikicode]

Les circuits de masquage en question sont complétés par une petite unité de calcul 32 bits, séparée du circuit de masquage, qui gère les opérations logiques OR, AND, XOR. La différence est que le circuit de masquage combine un masque stocké dans un registre dédié avec le registre intermédiaire, alors que l'ALU combine les données envoyées par le CPU et le registre intermédiaire.

Toute carte VGA permet aux écritures de n'écrire que dans certaines bitplanes/banques et de laisser les autres tranquilles. Pour cela, il incorpore un registre de 4 bits, le Map Mask register, qui indique quelles banques doivent être activées lors d'une écriture. Il y a un bit par banque, qui indique si elle est éteinte ou allumée lors d'une écriture, seules les banques allumées se font écrire dedans. Notons qu'avec cette technique, les bitplanes désactivées conservent les données précédentes. Ce n'est pas un problème si on souhaite appliquer des effets graphiques bien précis après avoir rendu une image complète dans le framebuffer planaire : on calcule une image, puis on applique l'effet en manipulant une ou deux bitplanes. Mais cela pose problème dans d'autres situations. Si jamais une image a été rendue précédemment, ses pixels resteront dans les bitplanes. Désactiver les écritures lors du rendu de l'image suivante donnera un mélange entre image précédente et image nouvelle.

Pour éviter cela, il faut ajouter une fonctionnalité qui remet à 0 le framebuffer, octet par octet. La remise à zéro en question peut effacer totalement le framebuffer, à savoir les quatre bitplanes, en remplissant ses octets avec la valeur 0000 0000. Il est aussi, possible de réinitialiser le framebuffer en mettant chaque octet à 1111 1111. Mais il est aussi intéressant de ne réinitialiser qu'une partie des bitplanes, par exemple seulement une, deux ou trois bitplanes. Pour cela, la carte VGA incorpore un circuit de SET/RESET avant l'ALU. Il agit sur les octets envoyés par le processeur et destinés aux bitplanes, à savoir qu'il peut les mettre soit à 0000 0000, soit à 1111 1111, soit ne pas les modifier. La commande de ce circuit est réalisée par deux registres dédiés.

Enfin, cartes VGA intègrent un barrel shifter, qui fait des opérations de rotations sur l'octet envoyé par le processeur (les décalages ne sont pas supportés). Il était rarement utilisé pour aligner les données au pixel près, plutôt qu'à l'octet près. Mais il était peu utilisé, car le processeur sait faire ce genre d'opérations nativement. L'ALU et le barell shifter sont commandés par un registre unique, le Data Rotate register, qui indique quelle opérations ils doivent faire. Le contenu de ce registre est découpés en deux champs : un qui dit au barell shifter de combien de rangs il faut rotater, et quelle opération l'ALU doit faire.

Les modes d'écriture VGA

[modifier | modifier le wikicode]

La différence entre les modes d'écriture 0, 1, 2 et 3 tient à l'activation ou non des circuits de SET/RESET, de masquage, et autres.

Le mode d'écriture 0 est celui où tous les circuits sont utilisables. Ils ne sont pas forcément tous utilisés, mais peuvent l'être. Les données envoyées par le CPU sont rotatées (ou non), puis passent par le circuit de SET/RESET (qui peut ne rien faire), puis dans l'ALU pour être combinées avec le registre intermédiaire (qui peut simplement recopier l'octet qu'elle recoit), le résultat est masqué (ou non) et enregistré dans les bitplanes actives, certaines pouvant être désactivées.

VGA latches

Le mode d'écriture numéro 1 est utilisé pour faire des copies en mémoire vidéo, de la mémoire vidéo vers elle-même. Ce mode d'écriture se contente de recopier le contenu du registre intermédiaire en mémoire vidéo, les octets envoyés par le processeur ne sont pas utilisés s'ils sont présents. On peut l'émuler à partir du mode 0 en configurant les circuits de masquage proprement.

Il faut noter que le registre intermédiaire maintien une donnée indéfiniment et quelques jeux vidéos ou applications utilisaient cette propriété. Par exemple, cela permet de remplir un framebuffer avec un arrière-plan, avant de re-dessiner dessus. L'idée est d'écrire le motif d'arrière-plan en mémoire vidéo, de le lire pour charger le registre intermédiaire avec, et de lancer une série d'écriture en mode 1 pour recopier le motif dans les adresses mémoires voulues.

VGA write mode 1

Le mode d'écriture numéro 2 est lui un mode de remplissage. Il permet d'écrire une couleur dans plusieurs pixels consécutifs, dans un framebuffer planaire. Au lieu de modifier chaque pixel un par un, avec des opérations de masquage, l'idée est alors de modifier plusieurs pixels consécutifs en une seule fois. On peut modifier jusqu'à 8 bits consécutifs en mémoire vidéo, vu que les bitplanes sont des mémoires adressables à l'octet. C'est très utile pour remplir le framebuffer avec une couleur par défaut, pour le réinitialiser/effacer, ou encore pour tracer des segments/lignes horizontales ou diagonales, par exemple.

Dans ce mode d'écriture, les circuits de SET/RESET sont au premier plan. Pour comprendre pourquoi, regardons ce qui se passe quand on écrit une même couleur dans 8 pixels consécutifs. Pour simplifier, on part du principe que les 8 pixels sont tous dans le même octet, on dit qu'ils sont alignés sur un octet. La couleur écrite est codée naturellement sur 4 bits, et vaut 0101.

VGA en mode framebuffer planaire
Pixel Pixel 1 Pixel 2 Pixel 3 Pixel 4 Pixel 5 Pixel 6 Pixel 7 Pixel 8
Octet banque 1 0 0 0 0 0 0 0 0 0
Octet banque 2 1 1 1 1 1 1 1 1 1
Octet banque 3 0 0 0 0 0 0 0 0 0
Octet banque 4 1 1 1 1 1 1 1 1 1

On voit que chaque bit du pixel a été recopié à l'identique dans un octet. Les octets écrits dans le framebuffer planaire valent donc soit 0000 0000, soit 1111 1111. Vous aurez deviné que c'est un cas parfait pour utiliser les circuits de SET/RESET, qui fournissent naturellement les octets 0000 0000 et 1111 1111 sur leur sortie. En clair, chaque bit d'un pixel commande l'octet envoyé sur la bitplane correspondante. Par exemple, si le bit de poids faible vaut 0, alors l'octet à écrire dans la bitplane associée vaut 0000 0000. Pour le dire autrement, l'octet envoyé par le processeur, le pixel envoyé, commande des circuits de SET/RESET : ses 4 bits de poids faible indiquent quels bitplanes sont à mettre à 0000 0000 et celles à mettre à 1111 1111.

Les octets mis à 0000 0000 ou 1111 1111 passent ensuite dans l'ALU et les circuits de masquage et sont enregistrés dans les bitplane actives. Les circuits de masquage sont actifs au cas où certaines zones de l'image ne doivent pas être remplies avec le pixel. Par exemple, imaginez qu'on souhaite remplir un carré avec une couleur par défaut, en utilisant le mode d'écriture 2. Dans ce cas, le masque désigne quels pixels sont dans le carré et ceux en-dehors.

VGA write mode 2

Le mode d'écriture numéro 3 est lui aussi utile pour faire du remplissage ou pour tracer des lignes, mais aussi pour écrire du texte transparent. Son principe est le suivant : on écrit une couleur par défaut dans le framebuffer dans certains pixels seulement, un masque décrit quels bits/pixels modifier pour ne pas effacer ce qui doit l'être. La différence est qu'avec le mode 3, chaque octet provenant du processeur est un nouveau masque à appliquer. Le mode 2 précise la couleur des pixels à modifier avec un masque préconfiguré, le mode 3 précise quel masque utiliser avec une couleur par défaut préconfigurée.

Là-encore, les circuits de SET/RESET sont programmés pour dessiner une couleur par défaut. Sauf que cette fois-ci, la couleur est précisée en configurant les registres adéquats, elle n'est pas fournie par l'octet provenant du processeur. Là encore, les circuits de masquage permettent de choisir entre ce qui est déjà dans le framebuffer planaire, et la couleur par défaut. La différence est que le masque est fournit par l'octet envoyé par le processeur.

Pour donner un exemple d'utilisation, prenez le cas où on veut écrire du texte au-dessus d'une image déjà calculée dans le framebuffer. L'écriture du texte se fait caractère par caractère, chaque caractère étant un octet envoyé par le processeur à la carte VGA. Le processeur lit d'abord les pixels sur lesquels écrire le caractère, afin de les copier dans le registre intermédiaire. Puis, il écrit le caractère avec le mode 3 : le calcul du masque donne les pixels à modifier.

VGA write mode 3 simplifié

Précisons cependant que le masque n'est pas l'octet envoyé par le processeur. A la place, le masque est calculé en prenant le masque présent dans le registre de masque, puis en faisant un ET logique avec l'octet envoyé par le processeur. L'octet est potentiellement rotaté avant, pour l'aligner au pixel près, mais c'est facultatif.

VGA write mode 3

Les modes de lectures VGA

[modifier | modifier le wikicode]

Pour la lecture deux modes sont possibles : un plutôt associé à un framebuffer planaire, le second adapté à un framebuffer compact.

Le premier mode de lecture, le read mode 0 lit les octets d'une bitplane un par un. La bitplane en question est sélectionnée en configurant deux bits du Read Map register : 2 bits, 4 valeurs, 4 bitplanes. Le processeur configure ce registre et peut adresser la bitplane directement. Par contre, il n'accède qu'à une bitplane à la fois. Lire un pixel entier demande de changer de bitplane quatre fois de suite, ce qui a un cout en performance très important. Le cout pour lire 8 pixels est le même, ce qui amorti la situation, mais sans faire de miracles.

Idéalement, il faudrait un mode de lecture qui simule un framebuffer compact, par exemple un mode qui permette de lire un pixel en allant piocher dans les 4 bitplanes, voire un mode qui permette de lire 8 pixels d'un seul coup. Mais les cartes VGA ne fournissent pas de mode de lecture aussi pratique. Par contre, ils ont un mode plus limité, appelé le read mode 1.

Il lit 8 pixels dans un framebuffer planaire et les compare à une couleur de référence, stockée dans le registre Color Compare Register. Le résultat est un groupe de 8 bits, chacun stockant le résultat de la comparaison pour un pixel. Lors de la comparaison, il est possible de désactiver des bitplanes. Un registre, le Color Don’t Care register, permet de configurer quelles bitplanesa activer ou désactiver. Il contient un bit par bitplane, qui permet d'activer la bitrplane s'il est à 1, la désactiver s'ils est à 0.

Le RAMDAC des cartes VGA

[modifier | modifier le wikicode]

Les cartes VGA utilisent un système de palette indicée et fournissent en sortie une couleur analogique. Pour cela, elles incorporent une mémoire SRAM pour mémoriser la palette indicée, ainsi qu'un convertisseur numérique vers analogique pour traduire le tout en signaux analogiques pour l'écran. Les deux circuits sont regroupés en un seul, dans un circuit unique appelé un RAMDAC, pour rappel. Le RAMDAC d'une carte VGA est cependant plus complexe qu'un RAMDAC normal, et ce pour une raison simple : le nombre de couleurs dépend de la résolution. Il est de 2, 4, 16, 256 couleurs. Les résolutions à 2, 4 et 16 couleurs sont gérés à par du mode 256 couleurs, et une partie est liée à la compatibilité EGA.

Pour commencer, regardons d'abord le mode 256 couleurs. Dans ce mode, la mémoire vidéo fournit un octet, qui indique quelle couleur afficher. L'octet est alors envoyé à un RAMDAC principal, qui convertit cette couleur 8 bits en signaux analogiques RGB. La conversion se fait en deux temps. La première étape est le passage par une palette indicée, qui traduit l'octet en entrée en une couleur codée sur 18 bits : 6 bits pour le rouge, 6 pour le vert, 6 pour le bleu. La SRAM de la palette indicée prend un octet sur l'entrée d'adresse et contient 256 registres de 18 bits. Les 18 bits sont alors envoyés aux circuits de conversion analogique, pour obtenir la couleur finale. Il y a donc un RAMDAC 8 bits, qui sert de RAMDAC principal.

La gestion des modes 2, 4, 16 couleurs se fait en utilisant le RAMDAC 8 bits. Avec ces modes, la couleur est codée sur 4 bits, et est lue depuis un framebuffer planaire. Les 4 bits sont alors étendus pour passer vers 8 bits, qui sont ensuite envoyés au RAMDAC 8 bits. La conversion vers l'analogique se fait donc en deux étapes : une première étape traduit un pixel de 4 bits en un octet, la seconde étape prend cet octet et le transforme en signaux analogiques. Le passage de 4 à 8 bits se fait en gardant la compatibilité avec les résolutions des cartes EGA, qui utilisaient une palette indicée spécifique.

Les cartes EGA avaient une palette indicée très simple, qui contenait 16 registres, chacun contenant une couleur codée sur 6 bits. Elle prenait en entrée un pixel codé sur 4 bits, qui adressait un des 16 registres de couleur. Il est possible d'émuler ce système en envoyant les 4 bits du pixels directement dans le RAMDAC 8 bits, en remplacant les 4 bits de poids fort manquant par des zéros. Une solution alternative découpe la palette de 256 couleurs du RAMDAC en 16 sous-palettes de 16 couleurs. Les 4 bits de poids fort décident quelle sous-palette utiliser, les 4 de poids fiable sélectionne une couleur dans la palette choisie. Les 4 bits pour sélectionner la palette proviennent du d'un registre appelé le Color Control Register

RAMDAC des cartes VGA en mode 16 couleurs pures

Néanmoins, il s'agit d'une émulation de la palette EGA. Les cartes VGA gérent un mode de compatibilité EGA avec un système de color paging qu'on va décrire ci-dessous. Les cartes VGA conservent la palette EGA, ce qui fait qu'il y a une première phase qui transforme un pixel en 6 bits, ce qui permet de coder 64 couleurs différentes. Pour obtenir l'octet à envoyer au RAMDAC 8 bits, les cartes VGA rajoutent deux bits à la couleur EGA, qui proviennent du Color Control Register.

Pour implémenter les deux modes d'écrit plus haut, les cartes VGA se débrouillent avec le système suivant. Les 4 bits de poids faible proviennent de la platte indicée, toujours. Les deux bits de poids fort proviennent du color control register. Les deux bits entre les deux viennent soit du color control register, soit de la palette EGA, un MUX choisit entre les deux. Le choix entre les deux dépend du bit 7 du registre de configuration AC Mode Register. Dans le cas où les deux bits de poids fort sont remplacés par le color control register, la palette du RAMDAC est découpée en 16 palettes indicées de 4 bits.

RAMDAC des cartes VGA

Le mode écran divisé (splitscreen)

[modifier | modifier le wikicode]

Les cartes VGA incorporent des optimisations pour supporter l'affichage en splitscreen, appelé en français affichage en "écran divisé", ou affichage divisé. Le standard VGA ne supportait qu'un affichage divisé simple : l'écran était coupé en deux à l'horizontale, avec une moitié haute et une moitié basse. L'écran n'était pas forcément coupé en deux parties égales, la ligne de démarcation entre les deux écrans était configurable. Prenons l'exemple d'une résolution de 320 par 200 pixels, soit 200 pixels de haut. Le premier écran pouvait, par exemple, faire 56 pixels de hauteur, le second 144 pixels.

Dans le chapitre "Les systèmes à framebuffer", nous avions vu comment un splitscreen de ce type est optimisé sur certains CRTC. Les cartes VGA utilisent des techniques similaires, mais avec cependant quelques différences. L'implémentation du splitscreeen demande d'avoir deux framebuffers : un pour la moitié haute de l'écran, un pour la moitié basse. Le standard VGA/EGA impose que le framebuffer de la moitié basse démarre à l'adresse zéro en mémoire vidéo. Le framebuffer pour la moitié haute est lui placé plus loin en mémoire vidéo. C'est contreintuitif, mais c'est en réalité tout à fait normal.

L'idée se comprend bien quand on se rappelle de ceci : le CRTC parcours le framebuffer en partant d'une adresse de base, qui lui est fournie dans un registre dédié. Le parcours du framebuffer se fait grâce à l'aide de deux registres, un compteur de ligne et un compteur de colonne. Les trois registres sont utilisés pour calculer l'adresse du pixel à afficher. L'idée est que quand la ligne de démarcation est atteinte, les trois registres sont réinitialisés, ils sont tous trois mis à zéro. Ce qui fait re-démarrer l'affichage à partir de l'adresse zéro. Et cela implique que la moitié basse de l'écran commence à l'adresse 0. La moitié haute doit donc être placée plus loin en mémoire, assez loin pour laisser assez de place à la moitié basse.

La ligne de démarcation est censée être mémorisée dans un registre de splitscreen, qui indique à quelle ligne il faut commuter de framebuffer. Mais les cartes EGA et VGA n'ont pas de registre dédié pour. A la place, les différents bits de ce registre sont dispersés dans plusieurs registres séparés. Les cartes EGA utilisaient une valeur de 9 bits dispersée dans deux registres : les bits 7 à 0 sont dans le Line Compare register (CRTC register 18H), le 9ème bit est dans le registre Overflow register (CRTC register 7). Les cartes VGA rajoutent un 10ème bit, qui est dans le registre Maximum Scan Line register (CRTC register 9). Toute modification de ces registres a un effet immédiat, contrairement à une modification de la plupart des autres registres du CRTC.

Le support du splitscreen était présent pour toutes les résolutions officielles, mais quelques cartes VGA le supportaient aussi pour les résolutions non-officielles. Il fonctionne aussi en mode texte ! En mode texte, le passage d'une moitié d'écran à l'autre a le bon ton d'ajouter un saut de ligne automatique, ce qui fait que le texte du second écran reprend au bon endroit, au tout début d'une ligne. Pour cela, le compteur de ligne est réinitialisé à zéro.

Le PC-98 de NEC

[modifier | modifier le wikicode]

Les ordinateurs PC-98 sont une gamme d'ordinateurs japonais qui a grandement évolué dans le temps. Ils s'inspiraient grandement des PC IBM et étaient centrés autour d'un processeur x86. Le standard IBM PC était globalement respecté sur les PC-98 et le PC_98 tournait sur MS-DOS, mais il y avait cependant de nombreuses différences avec les PC x86. La carte d'affichage était totalement différente, le bus ISA était remplacé par un C-bus, le BIOS était différent, les adresses des périphériques étaient différentes, etc.

Les PC-98 ont été déclinés en plusieurs versions, chacune ayant un processeur de plus en plus puissant, plus de mémoire RAM, plus de mémoire vidéo, etc. La première génération utilisait un processeur x86 8086 d'Intel, c'était celle du PC-9801. La seconde génération a démarré avec le PC-9801VM. Leurs capacités graphiques étaient différentes, comme on va le voir.

Le support des caractères japonais

[modifier | modifier le wikicode]

Le support des caractères japonais demande beaucoup de contraintes. Afficher des Kanjis demande d'utiliser une mémoire de caractère très grosse, capable de mémoriser plusieurs centaines ou milliers de caractères, dont des Kanjis, hiragana et katakana. Concrètement, la mémoire de caractère des PC-98 mémorisait près de 700 caractères, qui suivaient les standards JIS X 0201 et JIS X 0208.

De plus, afficher des kanjis demande d'utiliser des hautes résolutions, au moins 640 par 400, pour que les kanjis soient lisibles. La mémoire vidéo devait être compatible avec de tells résolution. Sur les PC_98, la mémoire vidéo faisait entre 12 et 256 kilo-octets, ce qui était beaucoup pour l'époque, mais était nécessaire pour supporter les hautes résolutions.

Charset contenant des caractères Japonais, en basse résolution. Différent de celui du PC_98.

Le double rendu texte-graphique

[modifier | modifier le wikicode]
PC-9801VM memory map

Le framebuffer graphique était de type planaire et intégrait 4 bitplanes, comme le VGA. A partir du PC-9801VM ajoutait de quoi lire/écrire en parallèle dans plusieurs bitplanes, grâce à l'ajout d'un circuit nommé Graphics Charger. C'est aussi à partir de ce modèle que la mémoire de caractère et une palette indicée reconfigurable ont été ajoutés. Le PC-9801VX améliora le Graphic Charger pour ajouter un blitter.

En général, une carte d'affichage fonctionne soit en mode texte, soit en mode graphique, pas les deux. Les ordinateurs de la gamme PC-98 permettaient de fusionner les deux. Pour cela, ils étaient structurés autour de deux VDC NEC μPD7220 : un qui fonctionnait en mode texte, un autre en mode graphique. Les deux VDC lisaient dans des mémoires vidéos séparées. Le VDC fonctionnant en mode graphique était relié à une mémoire vidéo dédiée, le VDC en mode texte était quant à lui relié à une text RAM qui servait de tampon de texte. La text RAM faisait entre 8 et 12 kilo-octets, suivant les modèles, alors que la mémoire vidéo faisait 96, 192 ou 256 kilo-octets. Le framebuffer était d'abord rendu en mode graphique, et le second VDC pouvait superposer du texte au-dessus.

Rendu graphique sur les PC-98


Le rendu d'une scène 3D : concepts de base

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

Les bases du rendu 3D

[modifier | modifier le wikicode]

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

Les objets 3D et leur géométrie

[modifier | modifier le wikicode]
Illustration d'un dauphin, représenté avec des triangles.

Dans la quasi-totalité des jeux vidéo actuels, les objets et la scène 3D sont modélisés par un assemblage de triangles collés les uns aux autres, ce qui porte le nom de maillage, (mesh en anglais). Il a été tenté dans le passé d'utiliser des quadrilatères (rendu dit en quad) ou d'autres polygones, mais les contraintes techniques ont fait que ces solutions n'ont pas été retenues.

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

Les modèles 3D sont définis par leurs sommets, aussi appelés vertices dans le domaine du rendu 3D. Chaque sommet possède trois coordonnées, qui indiquent sa position dans la scène 3D : abscisse, ordonnée, profondeur. Un triangle est donc composé de 9 coordonnées, 3 par sommet.

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

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

Les API 3D supportent des primitives assez diverses. Elles gèrent au minimum les points, les lignes et les triangles. Elles gèrent éventuellement les triangle-strip et triangle-fan. Aujourd'hui, OpenGL et DirectX ne gèrent plus le rendu avec des quads est aujourd’hui tombé en désuétude, mais il est parfois supporté par les cartes graphiques actuelles, bien que ce soit souvent par émulation (un quad est rendu avec deux triangles). Pour information, voici les primitives gérées par les premières versions d'OpenGL :

Primitives supportées par OpenGL.

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

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

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

[modifier | modifier le wikicode]

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

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

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

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

Texture Mapping

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

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

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

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

[modifier | modifier le wikicode]
Même géométrie, plusieurs rendus différents.

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

La raison principale est que le lancer de rayons demande beaucoup de puissance de calcul. Une autre raison est que créer des cartes accélératrices pour le lancer de rayons n'est pas simple. Il a existé des cartes accélératrices permettant d'accélérer le rendu en lancer de rayons, mais elles sont restées confidentielles. Les cartes graphiques modernes incorporent quelques circuits pour accélérer le lancer de rayons, mais ils restent d'un usage marginal et servent de compléments au rendu par rastérization. Un chapitre entier sera dédié aux cartes accélératrices de lancer de rayons et nous verrons pourquoi le lancer de rayons est difficile à implémenter avec des performances convenables, ce qui explique que les jeux vidéo utilisent la rasterization.

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

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

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

Mais quoiqu'il en soit, le rendu d'une image est décomposé en une succession d'étapes, chacune ayant un rôle bien précis. Le traitement de la géométrie est lui-même composé d'une succession de sous-étapes, la rasterisation est elle-même découpée en plusieurs sous-étapes, et ainsi de suite. Le nombre d'étapes pour une carte graphique moderne dépasse la dizaine. La rastérisation calcule un rendu 3D avec une suite d'étapes consécutives qui doivent s'enchainer dans un ordre bien précis. L'ensemble de ces étapes est appelé le pipeline graphique,qui sera détaillé dans ce qui suit.

Le calcul de la géométrie

[modifier | modifier le wikicode]

Le calcul de la géométrie regroupe plusieurs manipulations différentes. La principale demande juste de placer les modèles 3D dans la scène, de placer les objets dans le monde. Puis, il faut centrer la scène 3D sur la caméra. Les deux changements ont pour point commun de demander des changements de repères. Par changement de repères, on veut dire que l'on passe d'un système de coordonnées à un autre. En tout, il existe trois changements de repères distincts qui sont regroupés dans l'étape de transformation : un premier qui place chaque objet 3D dans la scène 3D, un autre qui centre la scène du point de vue de la caméra, et un autre qui corrige la perspective.

Les trois étapes de transformation

[modifier | modifier le wikicode]

La première étape place les objets 3D dans la scène 3D. Un modèle 3D est représentée par un ensemble de sommets, qui sont reliés pour former sa surface. Les données du modèle 3D indiquent, pour chaque sommet, sa position par rapport au centre de l'objet qui a les coordonnées (0, 0, 0). La première étape place l'objet 3D à une position dans la scène 3D, déterminée par le moteur physique, qui a des coordonnées (X, Y, Z). Une fois placé dans la scène 3D, le centre de l'objet passe donc des coordonnées (0, 0, 0) aux coordonnées (X, Y, Z) et tous les sommets de l'objet doivent être mis à jour.

De plus, l'objet a une certaine orientation : il faut aussi le faire tourner. Enfin, l'objet peut aussi subir une mise à l'échelle : on peut le gonfler ou le faire rapetisser, du moment que cela ne modifie pas sa forme, mais simplement sa taille. En clair, le modèle 3D subit une translation, une rotation et une mise à l'échelle, les trois impliquant une modification des coordonnées des sommets..

Transformations géométriques possibles pour chaque triangle.

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

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

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

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

[modifier | modifier le wikicode]

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

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

Le traitement de la géométrie implique, sans surprise, des calculs de géométrie dans l'espace. Et cela implique des opérations mathématiques aux noms barbares : produits scalaires, produits vectoriels, et autres calculs impliquant des vecteurs et/ou des matrices. Et les calculs vectoriels/matriciels impliquent beaucoup d'additions, de soustractions, de multiplications, de division, mais aussi des opérations plus complexes : calculs trigonométriques, racines carrées, inverse d'une racine carrée, etc.

Au final, un simple processeur peut faire ce genre de calculs, si on lui fournit le programme adéquat, l'implémentation est assez aisée. Mais on peut aussi implémenter le tout avec un circuit spécialisé, non-programmable. Les deux solutions sont possibles, tant que le circuit dispose d'assez de puissance de calcul. Les cartes graphiques anciennes contenaient un ou plusieurs circuits de multiplication de matrices spécialisés dans l'étape de transformation. Chacun de ces circuits prend un sommet et renvoie le sommet transformé. Ils sont composés d'un gros paquet de multiplieurs et d'additionneurs flottants. Pour plus d'efficacité, les cartes graphiques comportent plusieurs de ces circuits, afin de pouvoir traiter plusieurs sommets en même temps.

L'élimination des surfaces cachées

[modifier | modifier le wikicode]

Un point important du rendu 3D est que ce que certaines portions de la scène 3D ne sont pas visibles depuis la caméra. Et idéalement, les portions de la scène 3D qui ne sont pas visibles à l'écran ne doivent pas être calculées. A quoi bon calculer des choses qui ne seront pas affichées ? Ce serait gâcher de la puissance de calcul. Et pour cela, de nombreuses optimisations visent à éliminer les calculs inutiles. Elles sont regroupées sous les termes de clipping ou de culling. La différence entre culling et clipping n'est pas fixée et la terminologie n'est pas claire. Dans ce qui va suivre, nous n'utiliserons que le terme culling.

Les cartes graphiques modernes embarquent diverses 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 différentes formes de culling/clipping

[modifier | modifier le wikicode]

La première forme de culling est le view frustum culling, dont le nom indique qu'il s'agit de l'élimination de tout ce qui est situé en-dehors du view frustum. Elle fait que ce qui est en-dehors du champ de vision de la caméra n'est pas affiché à l'écran n'est pas calculé ou rendu, dans une certaine mesure. De même, certains objets qui sont trop loin ne sont tout simplement pas calculés et remplacé par du brouillard, voire pas remplacé du tout.

Le view frustum culling est assez trivial : il suffit d'éliminer ce qui n'est pas dans le view frustum, quelques calculs de coordonnées assez simples le permette assez facilement. Quelques subtilités surviennent quand un triangle est partiellement dans le view frustrum, ce qui arrive parfois si le triangle est sur un bord de l'écran. Mais rien d'insurmontable.

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

Les autres formes de culling visent à éliminer ce qui est dans le view frustum, mais qui n'est pas visible depuis la caméra. Pensez à des objets cachés par un autre objet plus proche, par exemple. Ou encore, pensez aux faces à l'arrière d'un objet opaque qui sont cachées par l'avant. Ces deux cas correspondent à deux types de culling. L'élimination des objets masqués par d'autres est appelé l'occlusion culling. L'élimination des parties arrières d'un objet est appelé le back-face culling. Dans les deux cas, nous parlerons d'élimination des surfaces cachées.

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

Le lancer de rayons n'a pas besoin d'éliminer les surfaces cachées, il ne calcule que les surfaces visibles. Par contre, la rastérisation demande d'éliminer les surfaces cachées. Sans cela, le rendu est incorrect dans le pire des cas, ou alors le rendu calcule des surfaces invisibles pour rien. Il existe de nombreux algorithmes logiciels pour implémenter l'élimination des surfaces cachées, mais la carte graphique peut aussi s'en charger.

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

L'algorithme du peintre

[modifier | modifier le wikicode]

Pour éliminer les surfaces cachées, la solution la plus simple consiste simplement à rendre les triangles du plus lointain au plus proche. L'idée est que si deux triangles se recouvrent totalement ou partiellement, on doit dessiner celui qui est derrière, puis celui qui est devant. Le dessin du second va recouvrir le premier. Quelque chose qui devrait vous rappeler le rendu 2D, où les sprites sont rendus du plus lointain au plus proche. Il ne s'agit ni plus ni moins que de l'algorithme du peintre.

Polygons cross
Painters problem

Un problème est que la solution ne marche pas avec certaines configurations particulières, dans le cas où des polygones un peu complexes se chevauchent plusieurs fois. Il se présente rarement dans un rendu 3D normal, mais c'est quand même un cas qu'il faut gérer. Le problème est suffisant pour que cette solution ne soit plus utilisée dans le rendu 3D normal.

Un autre problème est que l'algorithme demande de trier les triangles d'une scène 3D selon leur profondeur, du plus profond au moins profond. Et les cartes graphiques n'aiment pas ça, que ce soit les anciennes cartes graphiques comme les modernes. Il s'agit généralement d'une tâche qui est réalisée par le processeur, le CPU, qui est plus efficace que le GPU pour trier des trucs. Aussi, l'algorithme du peintre était utilisé sur d'anciennes cartes graphiques, qui ne géraient pas la géométrie mais seulement les textures et quelques effets de post-processing. Avec ces GPU, les jeux vidéo calculaient la géométrie et la triait sur le CPU, puis effectuaient le reste de la rastérisation sur le GPU.

Les anciens jeux en 2.5D comme DOOM ou les DOOM-like, utilisaient une amélioration de l'algorithme du peintre. L'amélioration variait suivant le moteur de jeu utilisé, et donnait soit une technique dite de portal rendering, soit un système de Binary Space Partionning, assez complexes et difficiles à expliquer. Mais il ne s'agissait pas de jeux en 3D, les maps de ces jeux avaient des contraintes qui rendaient cette technique utilisable. Ils n'avaient pas de polygones qui se chevauchent, notamment.

Le tampon de profondeur

[modifier | modifier le wikicode]

Une autre solution utilise ce qu'on appelle un tampon de profondeur, aussi appelé un z-buffer. Il s'agit d'un tableau, stocké en mémoire vidéo, qui mémorise la coordonnée z de l'objet le plus proche pour chaque pixel. Par défaut, ce tampon de profondeur est initialisé avec la valeur de profondeur maximale. Au fur et à mesure que les triangles sont rastérisés, le tampon de profondeur est mis à jour, conservant ainsi la trace de l'objet le plus proche de la caméra.

Si jamais un triangle a une coordonnée z plus grande que celle du tampon de profondeur, cela veut dire qu'il est situé derrière un objet déjà rendu : il est éliminé et ne poursuit pas sa route au-delà de l'unité de rastérisation. Le tampon de profondeur n'a pas à être mis à jour. Dans le cas contraire, l'objet est plus près de la caméra et sa coordonnée z remplace l'ancienne valeur z dans le tampon de profondeur.

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

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

La rastérisation

[modifier | modifier le wikicode]

L'étape de rastérisation est difficile à expliquer, surtout que son rôle exact dépend de la technique de rendu utilisée. Pour simplifier, elle projette un rendu en 3D sur un écran en 2D. Une autre explication tout aussi vague est qu'elle s'occupe la traduction des triangles en un affichage pixelisé à l'écran. Elle détermine à quoi ressemble la scène visible sur l'écran. C'est par exemple lors de cette étape que sont appliquées certaines techniques de culling, qui éliminent les portions non-visibles de l'image, ainsi qu'une correction de la perspective et diverses opérations d'interpolation dont nous parlerons dans plusieurs chapitres.

L'étape de rastérisation effectue aussi beaucoup de traitements différents, mais l'idée structurante est que rastérisation et placage de textures sont deux opérations très liées entre elles. Il existe deux manières principales pour lier les textures à la géométrie : la méthode directe et la méthode inverse (UV Mapping). Et les deux font que la rastérisation se fait de manière très différente. Précisons cependant que les rendus les plus simples n'utilisent pas de textures du tout. Ils se contentent de colorier les triangles, voire d'un simple rendu en fil de fer basé sur du tracé de lignes. Dans la suite de cette section, nous allons voir les quatre types de rendu principaux : le rendu en fils de fer, le rendu colorié, et deux rendus utilisant des textures.

Le rendu en fil de fer

[modifier | modifier le wikicode]
Rendu en fil de fer d'un objet 3D.

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

Maze war
Maze representation using wireframes 2022-01-10

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

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

Evans & Sutherland LDS-1 (1)

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

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

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

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

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

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

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

Le rendu à primitives colorées

[modifier | modifier le wikicode]
Exemple de rendu pouvant être obtenu avec des sommets colorés.

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

Maillage coloré.

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

Niveau matériel, quelques bornes d'arcade ont utilisé ce rendu. La toute première borne d'arcade utilisant le rendu à maillage coloré est celle du jeu I Robot, d'Atari, sorti en 1983. Par la suite, dès 1988, les cartes d'arcades Namco System 21 et les bornes d'arcades Sega Model 1 utilisaient ce genre de rendu. On peut s'en rendre compte en regardant les graphismes des jeux tournant sur ces bornes d'arcade. Des jeux comme Virtua Racing, Virtua Fighter ou Virtua Formula sont assez parlants à ce niveau. Leurs graphismes sont assez anguleux et on voit qu'ils sont basés sur des triangles uniformément colorés.

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

Pour ceux qui veulent en savoir plus sur la toute première borne d'arcade en rendu à maillage colorée, la borne I Robot d'Atari, voici une vidéo youtube à ce sujet :

Le placage de textures direct

[modifier | modifier le wikicode]

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

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

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

Exemple caricatural de placage de texture sur un quad.

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

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

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

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

L'avantage de cette technique est qu'on parcourt les textures dans un ordre bien précis. Par exemple, on peut parcourir la texture ligne par ligne, l'exploiter par blocs de 4*4 pixels, etc. Et accéder à une texture de manière prédictible se marie bien avec l'usage de mémoires caches, ce qui est un avantage en matière de performances. Mais un même pixel du framebuffer est écrit plusieurs fois quand plusieurs quads se superposent, alors que le rendu inverse gère la situation avec une seule écriture (sauf si usage de la transparence). De plus, la gestion de la transparence était compliquée et les jeux devaient ruser en utilisation des solutions logicielles assez complexes.

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

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

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

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

Sega ST-V Dynamite Deka PCB 20100324

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

Le placage de textures inverse

[modifier | modifier le wikicode]

Le rendu précédent, le rendu direct, permet d'appliquer des textures directement dans le framebuffer. Mais comme dit plus haut, il existe une seconde technique pour plaquer des textures, appelé le placage de texture inverse, aussi appelé l'UV Mapping. Elle associe une texture complète pour un modèle 3D,contrairement au placage de tecture direct qui associe une texture par quad/triangle. L'idée est que l'on attribue un texel à chaque sommet. Plus précisémment, chaque sommet est associé à des coordonnées de texture, qui précisent quelle texture appliquer, mais aussi où se situe le texel à appliquer dans la texture. Par exemple, la coordonnée de texture peut dire : je veux le pixel qui est à ligne 5, colonne 27 dans cette texture. La correspondance entre texture et géométrie est réalisée lorsque les créateurs de jeu vidéo conçoivent le modèle de l'objet.

Exemple de placage de texture.

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

UV Mapping

Avec le placage de texture inverse, la rastérisation se fait grosso-modo en trois étapes : la rastérisation proprement dite, le placage de textures, et les opérations finales qui écrivent un pixel dans le framebuffer. Au niveau du matériel, ainsi que dans la plupart des API 3D, les trois étapes sont réalisées par des circuits séparés.

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

Lors de la rasterisation, chaque triangle se voit attribuer un ou plusieurs pixels à l'écran. Pour bien comprendre, imaginez une ligne droite qui part de caméra et qui passe par un pixel sur le plan de l'écran. Cette ligne intersecte 0, 1 ou plusieurs objets dans la scène 3D. Les triangles situés ces intersections entre cette ligne et les objets rencontrés seront associés au pixel correspondant. L'étape de rastérisation prend en entrée un triangle et renvoie la coordonnée x,y du pixel associé.

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

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

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

[modifier | modifier le wikicode]

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

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

L'éclairage par pixel (per-pixel lighting), est calculé pixel par pixel. En clair, l’éclairage est finalisé après l'étape de rastérisation, il ne se fait pas qu'au niveau de la géométrie. Même des algorithmes d'éclairage par pixel très simples demandent une intervention des pixels shaders. Autant dire que cette distinction a été importante dans l'évolution du matériel graphique et l'introduction des shaders. Mais pour comprendre cela, il faut voir comment fonctionnent les algorithmes d'éclairage et voir comment le hardware peut aider à les implémenter.

En général, l'éclairage par pixel a une qualité d'éclairage supérieure aux techniques d'éclairage par sommet, mais il est aussi plus gourmand. L'éclairage par pixel est utilisé dans presque tous les jeux vidéo depuis DOOM 3 (l'un des premiers jeux à utiliser ce genre d'éclairage), en raison de sa meilleure qualité, les ordinateurs actuels étant assez performants. Mais cela n'aurait pas été possible si le matériel n'avait pas évolué de manière à incorporer des algorithmes d'éclairage matériel assez puissants, avant de basculer sur un éclairage programmable.

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

[modifier | modifier le wikicode]

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

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

L'éclairage d'une scène 3D provient de sources de lumières, comme des lampes, des torches, etc. Celles-ci sont souvent modélisées comme de simples points, qui ont une couleur bien précise (la couleur de la lumière émise) et émettent une intensité lumineuse codée par un entier. La lumière provenant de ces sources de lumière est appelée la lumière directionnelle.

Mais en plus de ces sources de lumière, il faut ajouter une lumière ambiante, qui sert à simuler l’éclairage ambiant (d’où le terme lumière ambiante). Par exemple, elle correspond à la lumière du cycle jour/nuit pour les environnements extérieurs. On peut simuler un cycle jour-nuit simplement en modifiant la lumière ambiante : nulle en pleine nuit noire, élevée en plein jour. En rendu 3D, la lumière ambiante est définie comme une lumière égale en tout point de la scène 3D, et sert notamment à simuler l’éclairage ambiant (d’où le terme lumière ambiante).

Lumière ambiante.
Lumière directionnelle.

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

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

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

Couleurs utilisées dans l'algorithme de Phong.

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

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

[modifier | modifier le wikicode]

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

Le premier de ces paramètres est l'intensité de la source de lumière, à quel point elle émet de la lumière. Là encore, cette information est encodée par un simple nombre, un coefficient, spécifié par l'artiste lors de la création du niveau/scène 3D. La couleur de la source de lumière est une version améliorée de l'intensité de la source lumineuse, dans le sens où on a une intensité pour chaque composante RGB.

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

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

Normale de la surface.

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

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

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

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

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

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

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

[modifier | modifier le wikicode]

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

L'illumination ambiante d'un point de surface s'obtient à partir de la lumière ambiante, mais elle n'est pas strictement égale à celle-ci, c'est légèrement plus compliqué que ça. Deux objets de la même couleur, illuminés par la même lumière ambiante, ne donneront pas la même couleur. Un objet totalement rouge illuminé par une lumière ambiante donnera une couleur ambiante rouge, un objet vert donnera une lumière verte. Et sans même parler des couleurs, certains objets sont plus sombres à certains endroits et plus clairs à d'autres, ce qui fait que leurs différentes parties ne vont pas réagir de la même manière à la lumière ambiante.

La couleur de chaque point de surface de l'objet lui-même est mémorisée dans le modèle 3D, ce qui fait que chaque point de surface se voit attribuer, en plus de ces trois coordonnées, une couleur ambiante qui indique comment celui-ci réagit à la lumière ambiante. La couleur ambiante finale d'un point de surface se calcule en multipliant la couleur ambiante de base par l'intensité de la lumière ambiante. Les deux sont des constantes pré-calculées par les concepteurs du jeu vidéo ou du rendu 3D.

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

L'illumination spéculaire et diffuse sont calculées autrement, à partir des vecteurs indiqués dans le schéma ci-contre. Tous les vecteurs sont définis à partir d'un point de la surface géométrique, d'un sommet, qui est éclairé. Les quatre vecteurs sont les suivants :

  • la normale N est un vecteur perpendiculaire à la surface, au sommet ;
  • le vecteur L qui connecte le sommet avec la source de lumière ;
  • le vecteur V qui connecte la caméra au sommet ;
  • le vecteur R qui se calcule à partir du vecteur L et N, qui représente le trajet d'un rayon lumineux réfléchit par la surface au sommet, calculé avec les lois de Snell-Decartes.

Afin de simplifier les explications, il faut préciser deux choses. premièrement, le vecteur L a pour longueur l'intensité de la lumière émise par la source de lumière. Il s'agit d'un vecteur pointant vers la source lumineuse, c'est un choix arbitraire mais pas sans raison. Quant au vecteur pour la normale, sa longueur est le coefficient de réflexion diffuse , qui indique à quel point la surface diffuse de la lumière, l'intensité de la source lumineuse. Il faut dire que ce coefficient est définit pour chaque point d'une surface,

Le calcul implique une opération mathématique appelée le produit scalaire, qui s'explique assez simplement. Elle prend deux vecteurs, que l'on notera A et B. Un produit scalaire prend la longueur des deux vecteurs et l'angle entre les deux vecteurs noté . Le produit scalaire se calcule comme suit :

L'illumination diffuse est calculée en faisant le produit scalaire entre deux vecteurs : la normale de la surface et le vecteur 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. On rappelle que la longueur des deux vecteurs est repsectivement l'intensité de la lumière et le coefficient de réflexion. L'angle en question est noté dans l'équation suivante :

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

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

Les algorithmes d'éclairage basiques : par triangle, par sommet et par pixel

[modifier | modifier le wikicode]

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

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

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

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

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

Interpolation des normales dans l'éclairage de Phong.

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

Flat shading
Gouraud Shading
Phong Shading
Flat shading
Gouraud Shading
Phong Shading
Flat shading
Gouraud Shading
Phong Shading
Flat shading
Gouraud Shading
Phong Shading

L'éclairage de Gouraud et l'éclairage plat sont des cas particulier d'éclairage par sommet (vertex lighting), où l'éclairage est calculé sur la géométrie d'une scène 3D. L'éclairage par pixel demande lui d'agir après l'étape de rastérisation, avant ou après l'unité de textures. Les trois algorithmes peuvent être implémentés soit dans la carte graphique, soit en logiciel. Nous verrons comment les cartes graphiques peuvent implémenter ces algorithmes, dans les deux prochains chapitres.

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

[modifier | modifier le wikicode]

Les techniques dites de bump-mapping visent à ajouter du relief et des détails sur des surface planes en jouant sur l'éclairage. Les reliefs et autres détails ne sont pas présents dans la géométrie, mais sont ajoutés lors de l'étape d'éclairage. Ils sont mémorisés dans une texture appelée la bump-map, qui est appliquée au-dessus que la texture normale.

Bump mapping

La technique du normal mapping est assez simple à expliquer, sans compter que plusieurs cartes graphiques l'ont implémentée directement dans leurs circuits. Plus haut, nous avions vu que l'éclairage de Phong demande de calculer les lumières pour chaque pixel. Le normal-mapping consiste à précalculer les normales d'une surface dans une texture, appelée la normal-map. Lors des calculs d'éclairage, la carte graphique lit les normales adéquates directement depuis cette texture, puis fait un calculs d'éclairage de type Phong avec. Avec cette technique, l'éclairage n'est pas géré par pixel, mais par texel, ce qui fait qu'il a une qualité de rendu un peu inférieure à un vrai éclairage de Phong, mais bien supérieure à un éclairage par sommet.

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


Le rendu graphique a beaucoup évolué avec le temps. Lestrict minimum pour rendre une image texturée est de gérer la géométrie, la rastérisation, les textures, et un z-buffer. Mais avec le temps, des fonctionnalités de plus en plus complexes sont apparues. Et la plus importante est celle des shaders. Il s'agit de programmes informatiques que l'API fait exécuter par la carte graphique. Ils permettent de programmer des effets des effets graphiques, qui sont ensuite exécutés par la carte 3D.

L'utilisation des shaders pour les algorithmes d'éclairage

[modifier | modifier le wikicode]

Les shaders servaient initialement à coder des algorithmes d'éclairage. Tout les algorithmes vus plus haut peuvent être programmés avec un shader adéquat, que ce soit du vertex lighting, de l'éclairage par pixel, l'éclairage de type Gouraud, Phong et bien d'autres. Il existe plusieurs types de shaders, mais les deux principaux sont les vertex shaders et les pixel shaders. Pour simplifier grandement, les pixels shaders s'occupent de l'éclairage par pixel, alors que les vertex shaders s'occupent de l'éclairage par sommet. Un vertex shader permet d'implémenter un éclairage de type plat ou de type Gouraud, alors qu'il faut un pixel shader pour implémenter un éclairage de type Phong. Les techniques de bump-mapping et de normal-mapping peuvent aussi s'implémenter avec des pixel shaders.

L'avantage est que cela simplifie grandement l'implémentation des algorithmes d'éclairage. Sans shaders, les algorithmes d'éclairage doivent être intégrés dans la carte graphique pour fonctionner, avec un circuit distinct pour chaque algorithme. Si la carte graphique ne gère pas un algorithme d'éclairage, il est parfois possible de l'émuler avec des contournements logiciels, mais au prix de performances souvent désastreuses. Avec des shaders, il est possible de programmer l'algorithme d'éclairage de notre choix, pour l'exécuter sur la carte graphique. Et le tout avec des performances plus que convenables.

Les autres utilisations des shaders

[modifier | modifier le wikicode]

Cependant, l'usage des shaders dépasse le cadre des algorithmes d'éclairage. Les shaders modernes prennent en charge des fonctionnalités qui n'étaient autrefois pas programmables.

Par exemple, les calculs géométriques sont pris en charge par les vertex shaders. Je parle bien des trois étapes de transformation vues plus haut, qui effectuent des calculs de transformation de coordonnées avec des matrices. La raison à cela est que les calculs de transformation ressemblent beaucoup aux calculs d'éclairage par sommet. Ils impliquent tous deux des calculs vectoriels, comme des produits scalaires et des produits vectoriels, qui agissent sur des sommets/triangles. Si la carte graphique incorpore un processeur de shader capable de faire de tels calculs, alors il peut servir pour les deux.

Les anciennes cartes graphiques faisaient les calculs géométriques dans un circuit fixe, non-programmable, appelé le circuit de Transform & Lightning. Il s'occupait de tous les calculs géométriques, éclairage par sommet inclus (d'où le L de T&L). La première carte graphique à avoir intégré un circuit de T&L était la Geforce 256. A la même époque, les consoles de jeu n'avaient pas de circuit de T&L et les calculs géométriques étaient réalisés sur le CPU. La toute première carte graphique à avoir intégré des shaders était la Geforce 3, sur laquelle les vertex shaders sont apparus pour remplacer l'unité de T&L.Par contre, les pixel shaders sont apparus après. Mais nous détaillerons cela dans un chapitre dédié sur l'historique des GPUs.


Les cartes graphiques : architecture de base

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

L'architecture d'une carte graphique 3D

[modifier | modifier le wikicode]

Une carte accélératrice 3D est un carte d'affichage à laquelle on aurait rajouté des circuits de rendu 3D. Elle incorpore donc tous les circuits présents sur une carte d'affichage : un VDC, une interface avec le bus, une mémoire vidéo, des circuits d’interfaçage avec l'écran, un contrôleur DMA, etc. Le VDC s'occupe de l'affichage et éventuellement du rendu 2D, mais ne s'occupe pas du traitement de la 3D. Du moins, c'est le cas sur les cartes à placage de texture inverse. Le placage de texture direct utilise au contraire un VDC avec accélération 2D très performant, comme nous l'avons vu au chapitre précédent. Mais nous mettons ce cas particulier de côté.

La carte accélératrice 3D reçoit des commandes graphiques, qui proviennent du pilote de la carte graphique, exécuté sur le processeur. les commandes en question sont très variées, avec des commandes de rendu 3D, de rendu 2D, de décodage/encodage vidéo, des transferts DMA, et bien d'autres. Mais nous allons nous concentrer sur les commandes de rendu 3D, qui demandent à la carte accélératrice 3D de faire une opération de rendu 3D. Pour cela, elles précisent quel tampon de sommet utiliser, quelles textures utiliser, quels shaders sont nécessaires, etc.

La carte accélératrice 3D traite ces commandes grâce à deux circuits : des circuits de rendu 3D, et un chef d'orchestre qui dirige ces circuits de rendu pour qu'ils exécutent la commande demandée. Le chef d'orchestre s'appelle le processeur de commandes, et il sera vu en détail dans quelques chapitres. Pour le moment, nous allons juste dire qu'il s'occupe de la logistique, de la répartition du travail. Pour les commandes de rendu 3D, il commande les différentes étapes du pipeline graphique et s'assure que les étapes s’exécutent dans le bon ordre.

Architecture globale d'une carte 3D

Les circuits de rendu 3D regroupent des circuits hétérogènes, aux fonctions fort différentes. Dans le cas le plus simple, il y a un circuit pour chaque étape du pipeline graphique. De tels circuits sont appelés des unités de traitement graphique. On trouve ainsi une unité pour le placage de textures, une unité de traitement de la géométrie, une unité de rasterization, une unité d'enregistrement des pixels en mémoire appelée ROP, etc. Les anciennes cartes graphiques fonctionnaient ainsi, mais on verra que les cartes graphiques modernes font un petit peu différemment.

Les circuits de traitement des pixels

[modifier | modifier le wikicode]

Pour simplifier les explications, nous allons séparer la carte graphique en quatre circuits :

  • Une unité géométrique qui fait tous les calculs géométriques ;
  • Un rastériseur qui fait le lien entre triangles et pixel ;
  • Une unité de texture qui lit les textures et les plaque sur les modèles 3D ;
  • Un ROP (Raster Operation Pipeline), qui gère grossièrement le tampon de profondeur (z-buffer).

Nous allons voir les trois dernières en détail, car ce sont des unités dédiées au traitement des pixels. Et pour une autre raison que nous expliquerons plus bas.

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

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

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

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

Les circuits d'élimination des pixels cachés

[modifier | modifier le wikicode]

L'élimination des surfaces cachées élimine les triangles invisibles à l'écran, car cachés par un objet opaque. En théorie, elle est prise en charge à la toute fin du pipeline, dans les ROPs, car cela permet de gérer la transparence. Dès que de la transparence est présente, on ne peut pas éliminer les triangles invisibles avant le placage de textures. En effet, on ne sait pas si une texture transparente sera plaquée sur le triangle ou non. La seule option est alors de gérer la transparence et l'élimination des surfaces cachées dans les ROP.

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

Mais la plupart des cartes graphiques ajoutent des circuits d'élimination des surfaces cachées juste après la rastérisation, pour gérer le cas où on sait d'avance que les textures ne sont pas transparentes. Cela permet d'éliminer à l'avance les triangles dont on sait qu'ils ne seront pas rendus. Les deux possibilités coexistent sur les cartes graphiques modernes. Une carte graphique moderne peut éliminer les surfaces cachées avant et après la rastérisation, grâce à des techniques d'early-z dont nous parlerons plus tard, dans un chapitre dédié sur la rastérisation.

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

Les cartes graphiques en mode immédiat et à tuile

[modifier | modifier le wikicode]

Il existe deux types de cartes graphiques : celles en mode immédiat, et celles avec un rendu en tiles. Les cartes graphiques des ordinateurs de bureau ou portables sont toutes en mode immédiat, alors que celles des appareils mobiles, smartphones et autres équipements embarqués ont un rendu en tiles. Les raisons à cela sont multiples, mais pour résumer : le rendu en tiles est moins performant, mais plus facile à implémenter en matériel et consomme moins d'électricité. Mais surtout, le rendu en tile est avantagé pour le rendu en 2D, comparé aux architectures en mode immédiat, ce qui se marie bien aux besoins des smartphones et autres objets connectés.

Pour simplifier les explications, nous allons regrouper les circuits de traitement des pixels dans un seul gros circuits appelé le rastériseur, par abus de langage. La carte graphique est donc composée de deux circuits : l'unité géométrique et le mal-nommé rastériseur. Les cartes graphiques ajoutent des mémoires caches pour la géométrie et les textures, afin de rendre leur accès plus rapide.

Carte graphique, généralités

Les cartes graphiques en mode immédiat implémentent le pipeline graphique d'une manière assez évidente. L'unité géométrique envoie des triangles au rastériseur, qui lui-même envoie les pixels à l'unité de texture, qui elle-même envoie le pixel texturé au ROP. Elles effectuent le rendu 3D triangle par tringle, pixel par pixel. Un point important est que pendant que le pixel N est dans les ROP, les pixels N+1 est dans l'unité de texture, le pixel N+2 est dans le rastériseur et le triangle suivant est dans l'unité géométrique. En clair, on n'attend pas qu'un triangle soit affiché pour en démarrer un autre.

Un problème est qu'un triangle dans une scène 3D correspond souvent à plusieurs pixels, ce qui fait que la rastérisation prend plus de temps de calcul que la géométrie. En conséquence, il arrive fréquemment que le rastériseur soit occupé, alors que l'unité de géométrie veut lui envoyer des données. Pour éviter tout problème, on insère une petite mémoire entre l'unité géométrique et le rastériseur, qui porte le nom de tampon de primitives. Elle permet d'accumuler les sommets calculés quand le rastériseur est occupé.

Carte graphique en rendu immédiat

Le rendu en tiles fonctionne d'une manière bien différente. Il 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. Pour cela, la géométrie est intégralement rendue avant de faire la rastérisation, afin de regrouper les triangles par tile. Une fois le regroupement fait, les tiles sont envoyées au rastériseur une par une, ce qui fait que la rastérisation se fait tile par tile. Le ROP incorpore une petite mémoire SRAM qui mémorise tout ce qui est nécessaire pour rendre une tile. Pas besoin d’accéder à un gigantesque z-buffer pour toute l'image, juste d'un minuscule z-buffer pour la tile en cours de traitement, qui tient totalement dans la SRAM.

Carte graphique en rendu par tiles

La performance d'une carte graphique est limitée par la quantité d'accès mémoire par seconde. Autant dire que les économiser est primordial. Et les cartes en mode immédiat et par tile ne sont pas égales de ce point de vue. En mode immédiat, le tampon de primitives évite de passer par la mémoire vidéo, mais le z-buffer et le framebuffer sont très gourmand en accès mémoire. Avec les architectures à tile, c'est l'inverse : la géométrie est enregistrée en mémoire vidéo, mais le tampon de profondeur n'utilise pas la RAM vidéo.

Au final, les deux architectures sont optimisées pour deux types de rendus différents. Les cartes à rendu en tile brillent quand la géométrie n'est pas trop compliquée, et que la résolution est grande ou que l'antialising est activé. 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 dans et après la rastérisation. Les GPU en mode immédiat disposent d'optimisations pour l’antialiasing, mais les ROP font tout de même beaucoup d'accès mémoire quand il est activé. Avec le rendu en tiles, l'antialising utilise la SRAM et est donc plus rapide.

Les circuits d'éclairage

[modifier | modifier le wikicode]
Implémentation de l'éclairage sur les cartes graphiques

Les explications précédentes décrivent une carte graphique très simple, qui ne gère pas les techniques d'éclairage. Mais elles ont disparues depuis plusieurs décennies, toutes les cartes graphiques gèrent l'éclairage en matériel depuis les années 2000. Et ces GPU des années 2000 géraient différemment l'éclairage par pixel et l'éclairage par sommet. Pour rappel, l'éclairage par sommet attribue une couleur et une luminosité à chaque sommet. L'éclairage par pixel est plus fin, car il attribue une luminosité pour chaque pixel de l'écran. Les deux étaient gérés autrefois dans des circuits distincts, comme illustré ci-contre.

Les circuits d'éclairage par sommet

[modifier | modifier le wikicode]

L'éclairage par sommet est grossièrement calculé dans l'unité géométrique, le circuit de calculs géométriques. L’unité de traitement géométrique peut se mettre en œuvre de deux manières. La première utilise un circuit non-programmable, appelé le circuit de Transform & Lightning, qui effectue les calculs d'éclairage par sommet (d'où le L de T&L), en plus des calculs de transformation (le T de T&L). La première carte graphique à avoir intégré un circuit de T&L était la Geforce 256. Une seconde solution utilise un processeur dédié, qui exécute tous les calculs géométriques. Pour cela, il faut fournir un programme qui émule le pipeline géométrique, appelé un vertex shader, dont nous reparlerons d'ici quelques chapitres.

Intuitivement, on se dit que l'unité géométrique calcule une luminosité pour chaque triangle/sommet, comprise entre 0 (très sombre) et 1 (très brillant). Mais en réalité, l'unité de traitement géométrique calcule une couleur RGB pour chaque sommet/triangle, cette couleur de sommet indiquant quelle est sa luminosité. L'avantage est que cela simplifie la combinaison avec les textures et permet d'avoir des lumières colorées.

L'unité de traitement géométrique calcul donc une couleur de sommet, qui est envoyée à l'unité de rastérisation. L'unité de rastérisation calcule la couleur du pixel à partir des trois couleurs de sommet. Pour cela, il y a deux méthodes principales, qui correspondent à l'éclairage plat et l'éclairage de Gouraud, qu'on a vu dans le chapitre précédent. La première méthode attribue la même couleur à chaque pixel d'un triangle, typiquement la moyenne des trois couleurs de sommet. La seconde méthode, celle de l'éclairage de Gouraud, calcule une couleur différente pour chaque pixel du triangle. Le calcul en question est une interpolation, à savoir une sorte de moyenne pondérée.

L'éclairage de Gouraud demande donc d'ajouter un circuit d'interpolation pour les couleurs des sommets. Il fait normalement partie du circuit de rastérisation, comme on le verra plus tard dans le chapitre dédié. Pour donner un exemple, la console de jeu Playstation 1 gérait l'éclairage de Gouraud directement en matériel, mais seulement partiellement. Elle n'avait pas de circuit de T&L, ni de vertex shaders, mais intégrait un circuit pour interpoler les couleurs de chaque sommet.

Enfin, il faut prendre en compte les textures. Pour cela, le pixel texturé est multiplié par la luminosité/couleur calculée par l'unité géométrique. Il y a donc un circuit de combinaison situé après l'unité de texture qui effectue la combinaison/multiplication. Le circuit de combinaison est parfois configurable, à savoir qu'on peut remplacer la multiplication par une addition ou d'autres opérations. Un tel circuit de combinaison s'appelle alors un combiner, dans la vieille nomenclature graphique de l'époque des années 90-2000.

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

Les circuits d'éclairage par pixel

[modifier | modifier le wikicode]

L'éclairage par pixel est implémenté d'une manière totalement différente. Une implémentation naïve ajoute un circuit d'éclairage par pixel dédié, après l'unité de texture. Le circuit d’éclairage par pixel n'utilise pas la couleur de sommet, mais d'autres informations nécessaires pour calculer la luminosité d'un pixel.

Il a existé quelques rares cartes graphiques capables de faire de l'éclairage de Phong en matériel. Un exemple est celui de la Geforce 3, dont l'unité géométrique implémentait des instructions dédiées pour l'algorithme de Phong. L'unité géométrique de la Geforce 3 était programmable, et elle avait une instruction Phong, qui envoyait les normales au rastériseur. Les normales étaient alors interpolées par l'unité de rastérisation, puis utilisées par une unité d'éclairage par pixel dédié, fixe, non-programmable.

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

La technique précédente doit être adaptée pour implémenter le bump-mapping et le normal-mapping, qui mémorisent des informations d'éclairage dans une texture en mémoire vidéo. La texture contient des informations de relief pour le bump-mapping, des normales précalculées pour le normal-mapping. Pour cela, l'unité d'éclairage par pixel doit être reliée à l'unité de texture, mais l'implémentation matérielle n'est pas aisée.

Un exemple de carte graphique capable de faire cela est celle de la Nintendo DS, la PICA200. Créée par une startup japonaise, elle incorporait un circuit de T&L, un éclairage de Phong, du cel shading, des techniques de normal-mapping, de Shadow Mapping, de light-mapping, du cubemapping, de nombreux effets de post-traitement (bloom, effet de flou cinétique, motion blur, rendu HDR, et autres).

De nos jours, les circuits d'éclairage par pixel ont été remplacés par un processeur de pixel shader. Les processeurs de shaders sont des processeurs très simples, qui exécutent des algorithmes d'éclairage par pixel appelés des pixel shaders. L'avantage est que les programmeurs peuvent coder l'algorithme d'éclairage de leur choix et l'exécuter sur le GPU. Pas besoin d'avoir une unité dédiée par algorithme d'éclairage, on a un processeur de shader à tout faire.

Les processeurs de shaders récupèrent les pixels émis par le rastériseur, exécutent un pixel shader dessus, puis envoient le résultat à la suite du pipeline (aux ROPs). L'unité de texture est inclue dans le processeur de shader, ce qui permet au processeur de shader de lire des textures en mémoire vidéo. Le processeur de shader peut faire ce qu'il veut avec les texels lus, cela va bien au-delà d'opérations de combinaison avec une couleur de sommet. Notez que cela permet de grandement faciliter l'implémentation du bump-mapping et du normal-mapping.

Sur les anciens GPUs, l'unité de texture était le seul moyen pour un processeur de shader d'accéder à la mémoire vidéo, ce qui faisait que les pixels shaders pouvaient lire des textures, rien de plus. Mais de nos jours, les processeurs de shaders sont directement connectés à la mémoire vidéo et peuvent lire ou écrire dedans sans passer par l'unité de texture, ce qui peut servir pour divers algorithmes complexes.

Eclairage avec des pixels shaders


Les cartes accélératrices 3D

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

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

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

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

[modifier | modifier le wikicode]
Sega ST-V Dynamite Deka PCB 20100324

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

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

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

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

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

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

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

[modifier | modifier le wikicode]

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

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

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

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

[modifier | modifier le wikicode]

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

La Nintendo 64 : un GPU avancé

[modifier | modifier le wikicode]

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

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

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

La Playstation 1

[modifier | modifier le wikicode]

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

La 3DO et la Sega Saturn

[modifier | modifier le wikicode]

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

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

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

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

[modifier | modifier le wikicode]

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

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

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

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

[modifier | modifier le wikicode]

L'histoire de la 3D sur PC commence avec la sortie du jeu Quake, d'IdSoftware. Celui-ci pouvait fonctionner en rendu logiciel, mais le programmeur responsable du moteur 3D (le fameux John Carmack) ajouta une version OpenGL du jeu. Le fait que le jeu était programmé sur une station de travail compatible avec OpenGL faisait que ce choix n'était si stupide, même si aucune carte accélératrice de l'époque ne supportait OpenGL. C'était là un choix qui se révéla visionnaire. En théorie, le rendu par OpenGL aurait dû se faire intégralement en logiciel, sauf sur quelques rares stations de travail adaptées. Mais les premières cartes graphiques étaient déjà dans les starting blocks.

La toute première carte 3D pour PC est la Rendition Vérité V1000, sortie en Septembre 1995, soit quelques mois avant l'arrivée de la Nintendo 64. La Rendition Vérité V1000 était purement programmable, contrairement aux autres cartes graphiques de l'époque. Elle contenait un processeur MIPS cadencé à 25 MHz, 4 mébioctets de RAM, une ROM pour le BIOS, et un RAMDAC, rien de plus. C'était un vrai ordinateur complètement programmable de bout en bout. Les programmeurs ne pouvaient cependant pas utiliser cette programmabilité avec des shaders, mais elle permettait à Rendition d'implémenter n'importe quelle API 3D, que ce soit OpenGL, DirectX ou même sa son API propriétaire.

La Rendition Vérité avait de bonnes performances pour ce qui est de la géométrie, mais pas pour le reste. Autant les calculs géométriques sont assez rapides quand on les exécute sur un CPU, autant réaliser la rastérisation et le placage de texture en logiciel n'est pas efficace, pareil pour les opérations de fin de pipeline comme l'antialiasing. Le manque d'unités fixes très rapides pour la rastérisation, le placage de texture ou les opérations de fin de pipeline était clairement un gros défaut. Les autres cartes graphiques avaient implémenté l'exact inverse : de bonnes performances pour le placage de textures et la rastérization, mais les calculs géométriques étaient réalisés par le CPU. Au final, la carte graphique qui s'en sortait le mieux était la Nintendo 64 qui avait un CPU dédié pour les calculs géométriques et des circuits fixes pour le reste...

Les autres cartes graphiques étaient totalement non-programmables et ne contenant que des circuits fixes, regroupe les Voodoo de 3dfx, les Riva TNT de NVIDIA, les Rage/3D d'ATI, la Virge/3D de S3, et la Matrox Mystique. Elles contenaient des circuits pour gérer les textures, mais aussi une étape d'enregistrement des pixels en mémoire. Elle était gérait le z-buffer en mémoire vidéo, mais aussi quelques effets graphiques comme les effets de brouillard. L'unité d'enregistrement des pixels en mémoire s'appelle le ROP pour Raster Operation Pipeline.

Carte 3D sans rasterization matérielle.

Les cartes suivantes ajoutèrent une gestion des étapes de rasterization directement en matériel. Les cartes ATI rage 2, les Invention de chez Rendition, et d'autres cartes graphiques supportaient la rasterisation en hardware.

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

Pour exploiter les unités de texture et le circuit de rastérisation, OpenGL et Direct 3D étaient partiellement implémentées en logiciel, car les cartes graphiques ne supportaient pas toutes les fonctionnalités de l'API. C'était l'époque du miniGL, des implémentations partielles d'OpenGL, fournies par les fabricants de cartes 3D, implémentées dans les pilotes de périphériques de ces dernières. Les fonctionnalités d'OpenGL implémentées dans ces pilotes étaient presque toutes exécutées en matériel, par la carte graphique. Avec l'évolution du matériel, les pilotes de périphériques devinrent de plus en plus complets, au point de devenir des implémentations totales d'OpenGL.

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

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

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

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

[modifier | modifier le wikicode]

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

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

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

Multitexturing

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

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

Multitexturing avec combiners

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

Texture combiners Open GL

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

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

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

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

Le Transform & Lighting matériel de Direct X 7.0

[modifier | modifier le wikicode]
Carte 3D avec gestion de la géométrie.

La première carte graphique pour PC capable de gérer la géométrie en hardware fût la Geforce 256, la toute première Geforce. Son unité de gestion de la géométrie n'est autre que la bien connue unité T&L (Transform And Lighting). Elle implémentait des algorithmes d'éclairage de la scène 3D assez simples, comme un éclairage de Gouraud, qui étaient directement câblés dans ses circuits. Mais contrairement à la Nintendo 64 et aux bornes d'arcade, elle implémentait le tout, non pas avec un processeur classique, mais avec des circuits fixes.

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

Un autre problème est qu'il a beaucoup d'algorithmes d'éclairages différents, aux résultats visuels différents, bien au-delà des algorithmes d'éclairage plat, de Gouraud et de Phong. Et les unités de T&L étaient souvent en retard sur les algorithmes logiciels. Les programmeurs avaient le choix entre programmer les algorithmes d’éclairage qu'ils voulaient et les exécuter en logiciel, ou utiliser ceux de l'unité de T&L. Ils choisissaient souvent la première option. Par exemple, Quake 3 Arena et Unreal Tournament n'utilisaient pas les capacités d'éclairage géométrique et préféraient utiliser leurs calculs d'éclairage logiciel fait maison.

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

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

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

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

L'arrivée des shaders avec Direct X 8.0

[modifier | modifier le wikicode]
Architecture de la Geforce 3

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

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

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

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

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

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

Les shaders de Direct X 9.0 : de vrais pixel shaders

[modifier | modifier le wikicode]

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

De plus, les shaders ne sont plus écrit en assembleur comme c'était le cas avant. Ils sont dorénavant écrits dans un langage de haut-niveau, le HLSL pour les shaders Direct X et le GLSL pour les shaders Open Gl. Les shaders sont ensuite traduit (compilés) en instructions machines compréhensibles par la carte graphique. Au début, ces langages et la carte graphique supportaient uniquement des opérations simples. Mais au fil du temps, les spécifications de ces langages sont devenues de plus en plus riches à chaque version de Direct X ou d'Open Gl, et le matériel en a fait autant.

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

L'après Direct X 9.0

[modifier | modifier le wikicode]

Avant Direct X 10, les processeurs de shaders ne géraient pas exactement les mêmes opérations pour les processeurs de vertex shader et de pixel shader. Les processeurs de vertex shader et de pixel shaderétaient séparés. Depuis DirectX 10, ce n'est plus le cas : le jeu d'instructions a été unifié entre les vertex shaders et les pixels shaders, ce qui fait qu'il n'y a plus de distinction entre processeurs de vertex shaders et de pixels shaders, chaque processeur pouvant traiter indifféremment l'un ou l'autre.

Architecture de la GeForce 6800.

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

Les cartes graphiques d'aujourd'hui

[modifier | modifier le wikicode]

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

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

Les GPU modernes sont un mélange de processeurs et de circuits fixes

[modifier | modifier le wikicode]

Une carte graphique contient donc un mélange de circuits fixes et de processeurs de shaders, qui peut sembler contradictoire. Pourquoi ne pas tout rendre programmable ? Ou au contraire, utiliser seulement des circuits fixes ? La réponse rapide est qu'il s'agit d'un compromis entre flexibilité et performance qui permet d'avoir le meilleur des deux mondes. Mais ce compromis a fortement évolué dans le temps, comme on va le voir plus bas.

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

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

Les unités de texture sont intégrées aux processeurs de shaders

[modifier | modifier le wikicode]

Les unités de textures sont à part des autres circuits fixes, dans le sens où ce sont les seuls à être implémentés dans les processeurs de shaders. Sur les anciennes cartes 3D, les unités de textures étaient des circuits séparés des autres. Mais avec l'arrivée des processeurs de shaders, elles ont été intégrée dans les processeurs de shaders eux-mêmes. Pour comprendre pourquoi, il faut faire un rappel sur ce qu'il y a dans un processeur. Un processeur contient globalement quatre circuits :

  • une unité de calcul qui fait des calculs ;
  • des registres pour stocker les opérandes et résultats des calculs ;
  • une unité de communication avec la mémoire ;
  • et un séquenceur, un circuit de contrôle qui commande les autres.

L'unité de communication avec la mémoire sert à lire ou écrire des données, à les transférer de la RAM vers les registres, ou l'inverse. Lire une donnée demande d'envoyer son adresse à la RAM, qui répond en envoyant la donnée lue. Elle est donc toute indiquée pour lire une texture : lire une texture n'est qu'un cas particulier de lecture de données. Les texels à lire sont à une adresse précise, la RAM répond à la lecture avec le texel demandé. Il est donc possible d'utiliser l'unité de communication avec la mémoire comme si c'était une unité de texture.

Cependant, les textures ne sont pas utilisées comme telles de nos jours. Le rendu 3D moderne utilise des techniques dites de filtrage de texture, qui permettent d'améliorer la qualité du rendu des textures. Sans ce filtrage de texture, les textures appliquées naïvement donnent un résultat assez pixelisé et assez moche, pour des raisons assez techniques. Le filtrage élimine ces artefacts, en utilisant une forme d'antialiasing interne aux textures, le fameux filtrage de texture.

Le filtrage de texture peut être réalisé en logiciel ou en matériel. Techniquement, il est possible de le faire dans un shader : le shader calcule les adresses des texels à lire, lit plusieurs texels, et effectue ensuite le filtrage. En soi, rien d'impossible. Mais le filtrage de texture est toujours effectué directement en matériel. Les processeurs de shaders incorporent des circuits de filtrage de texture, dans l'unité de texture.

Pour simplifier l'implémentation, les processeurs de shader modernes disposent d'une unité d'accès mémoire séparée de l'unité de texture. L'unité d'accès mémoire normale s'occupe des accès mémoire hors-textures, alors que l'unité mémoire s'occupe de lire les textures. L'unité de texture contient de quoi faire du filtrage de texture, mais aussi faire des calculs d'adresse spécialisées, intrinsèquement liés au format des textures, qu'on détaillera dans le chapitre sur les textures. En comparaison, les unités d'accès mémoire effectuent des calculs d'adresse plus basiques. Un dernier avantage est que l'unité de texture est reliée au cache de texture, alors que l'unité d'accès mémoire est relié au cache L1/L2.

La raison est que le filtrage de texture est une opération très simple à implémenter en hardware, qui demande assez peu de circuits. Le filtrage bilinéaire ou trilinéaire demande juste des circuits d'interpolation et quelques registres, ce qui est trivial. Et la seconde raison est qu'il n'y a pas 36 façons de filtrer des textures : une carte graphique peut implémenter les algorithmes principaux existants en assez peu de circuits.

Il faut noter que les ROPs peuvent aussi être intégré dans les processeurs de shader, mais c'est assez rare. Les cartes graphiques pour PC ont des ROPs séparés des processeurs de shaders. Par contre, quelques cartes graphiques destinées les smartphones et autres appareils mobiles font exception. Sur celles-ci, les unités de textures et les ROPs sont intégrés dans les processeurs de shaders, aux côtés de l'unité d'accès mémoire LOAD/STORE. Il s'agit de cartes graphiques en rendu à tuiles.


Les processeurs de shaders

Les shaders sont des programmes informatiques exécutés par la carte graphique, et plus précisément par des processeurs de shaders. Un point très important à comprendre est que chaque triangle ou pixel d'une scène 3D peut être traité indépendamment des autres. Le tout se résume comme suit :

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

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

La conséquence est que les cartes graphiques sont des architectures massivement parallèles, à savoir qu'elles sont capables d'effectuer un grand nombre de calculs indépendants en même temps. De plus, le parallélisme utilisé est du parallélisme de données, à savoir qu'on exécute le même programme sur des données différentes, chaque donnée étant traitée en parallèle des autres. Les cartes graphiques récentes incorporent toutes les techniques de parallélisme de donnée au niveau matériel, et nous allons toutes les détailler dans ce chapitre. S'il fallait résumer, elles ont plusieurs processeurs/cœurs, chaque cœur est capable d’exécuter des instructions SIMD (ils ne font que cela, à vrai dire), les cœurs sont fortement multithreadés, et j'en passe.

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

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

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

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

Les portions bleu, jaune et verte du schéma précédent méritent chacune un chapitre séparé. La hiérarchie mémoire en vert fera l'objet d'un chapitre ultérieur. Quant au répartiteur en jaune, il sera détaillé en profondeur dans le prochain chapitre. Dans ce chapitre, nous allons voir comment fonctionnent les processeurs de shaders, la partie bleue. Nous allons voir que ceux-ci ne sont pas très différents des processeurs que l'on trouve dans les ordinateurs normaux, du moins dans les grandes lignes. Ce sont des processeurs séquentiels, qui exécutent des instructions les unes après les autres. Ils ont des instructions machines, des modes d'adressage, un assembleur, des registres et tout ce qui fait qu'un processeur est un processeur. Néanmoins, il y a une différence de taille : ce sont des processeurs adaptés pour effectuer un grand nombre de calculs en parallèle.

Les registres des processeurs de shaders

[modifier | modifier le wikicode]

Un processeur de shaders contient beaucoup de registres, sans quoi il ne pourrait pas faire son travail efficacement. Les plus intuitifs sont les registres généraux, aussi appelés registres temporaires, qui servent à mémoriser des résultats temporaires. Les registres temporaires sont les registres du processeur proprement dit, ceux qu'il peut manipuler à loisir. Tout processeur digne de ce nom en possède. Mais un processeur de shader dispose aussi de registres spécialisés, qu'on ne trouve que sur les processeurs de shaders, qui servent à l'interfacer avec le reste du pipeline graphique.

Architecture carte graphique vertex avec texture

Les registres d'interface avec le pipeline graphique

[modifier | modifier le wikicode]

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

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

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

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

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

Les registres spécialisés internes

[modifier | modifier le wikicode]

D'autres registres spécialisés ne font pas l'interface avec le reste du GPU. Ils servent à stocker des constantes ou des données importantes, qui n'ont pas vraiment leur place dans les registres généraux.

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

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

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

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

Les processeurs de shaders modernes : les processeurs SIMD

[modifier | modifier le wikicode]

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

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

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

Les instructions SIMD

[modifier | modifier le wikicode]

Les instructions SIMD manipulent plusieurs nombres en même temps. Elles manipulent plus précisément des vecteurs, des ensembles de plusieurs nombres entiers ou nombres flottants placés les uns à côté des autres, le tout ayant une taille fixe, qui sont stockés dans des registres spécialisés. En général, tous les vecteurs ont une taille fixe, peu importe leur contenu. Cela implique que suivant la taille des données à manipuler, on pourra en placer plus ou moins dans un vecteur. Par exemple, un vecteur de 128 bits pourra contenir 4 entiers de 32 bits, 4 flottants 32 bits, ou 8 entiers de 16 bits.

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

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

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

Une instruction SIMD traite chaque donnée du vecteur indépendamment des autres. Par exemple, une instruction d'addition vectorielle va additionner ensemble les données qui sont à la même place dans deux vecteurs, et placer le résultat dans un autre vecteur, à la même place.

Instructions SIMD

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

Les instruction scalaires entières, typiques des CPU

[modifier | modifier le wikicode]

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

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

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

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

Il est rare que les CPU soient capables de faire des opérations flottantes complexes, comme des calculs trigonométriques, des exponentielles, des logarithmes, des racines carrées ou racines carrées inverse, etc. De tels calculs sont rares dans les programmes exécutables, alors que les calculs arithmétiques simples y sont légion. Mais le rendu 3D demande pas mal de calculs trigonométriques, de produits scalaires ou d'autres opérations. Par exemple, dans les chapitres précédents, nous avions abordé les calculs d'éclairage et avions vu qu'ils font beaucoup de calculs vectoriels avec des vecteurs comme la normale d'un sommet. Et ces calculs demandent de calculer des produits scalaires et vectoriels, qui eux-mêmes demandent des calculs trigonométriques comme le cosinus ou le sinus.

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

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

Les instructions en co-issue

[modifier | modifier le wikicode]

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

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

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

Pour simplifier, cette technique permettait d’exécuter deux opérations arithmétiques en même temps, en parallèle : une opération vectorielle appliquée aux couleurs R, G, et B, et une opération scalaire appliquée à la couleur de transparence. Si cela semble intéressant sur le papier, cela complexifie fortement le processeur de shader, ainsi que la traduction à la volée des shaders en instructions machine.

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

[modifier | modifier le wikicode]

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

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

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

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

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

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

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

La prédication et le SIMT

[modifier | modifier le wikicode]

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

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

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

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

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

Vector mask register

La prédication avec une pile SIMT

[modifier | modifier le wikicode]

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

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

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

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

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

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

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

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

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

    Autres instructions
}

Instructions après le IF...

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

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

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

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

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

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

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

Les compteurs d'activité

[modifier | modifier le wikicode]

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

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

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

masque 1 3 1 1 0

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

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

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

Les processeurs de shaders anciens : des processeurs VLIW

[modifier | modifier le wikicode]

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

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

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

Les processeurs VLIW : généralités

[modifier | modifier le wikicode]

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

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

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

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

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

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

Les processeurs VLIW pour les shaders proprement dit

[modifier | modifier le wikicode]

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

Un exemple est celui du processeur de pixel shader de la Geforce 6800. Il disposait de plusieurs unités de calcul utilisables en parallèle. La première est une unité de calcul capable de réaliser une multiplication et une addition SIMD, portant sur des vecteurs de 4 éléments. La seconde effectue des fonctions arithmétiques spéciales, comme les logarithmes, exponentielles ou les calculs trigonométriques, les produits scalaires et autres. Enfin, il y a une unité d'accès aux textures, ce qui veut dire que le vertex shader a la possibilité de lire des textures en mémoire vidéo, ce qui facilite l'implémentation de certaines techniques de rendu. On remarque aussi la présence d'un cache de texture intégré dans le processeur de vertex shader.

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

GeForce 6800 Vertex processor.

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

Les processeurs VLIW sur les cartes graphiques AMD

[modifier | modifier le wikicode]

Les cartes graphiques AMD et ATI d'architectures R300, de la série des Radeon 9700, étaient des processeurs VLIW. L'intérêt du VLIW était de faciliter les calculs de produits vectoriels combinés avec de l'éclairage. Elles pouvaient faire plusieurs types d'opérations : des opérations SIMD sur des vecteurs de 4 flottants, des opérations SIMD sur des vecteurs de 4 entiers, des opérations scalaires sur des entiers, et des opérations scalaires sur des flottants. Il n'y avait que deux opérations simultanées possibles : une vectorielle et une scalaire. Les contraintes de combinaisons des instructions sont assez complexes.

  • L'opération vectorielle pouvait aussi bien manipuler des vecteurs de flottants que d'entiers. Elle gérait les opérations de base, à savoir : comparaisons, additions, soustractions, opérations logiques et bit à bit, décalages. Elle gérait aussi des instructions CMOV, mais aussi et surtout 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, soit les classiques comparaisons, additions, soustractions, opérations logiques et bit à bit, décalages. Ajoutons à cela une opération CMOV.

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. L'opération scalaire est maintenant uniquement flottante, la possibilité de faire une opération entière a été retirée. La première opération est soit une opération transcendantale, soit une opération vectorielle sur trois flottants. L'origine de ce changement, peu intuitif, sera expliqué dans le chapitre sur la microarchitecture des processeurs de shaders. Pour résumer, le processeur peut faire au choix :

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

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

Un cas particulier : les register combiners

[modifier | modifier le wikicode]

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

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

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

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

Les registres modifiables sont les suivants :

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

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

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

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

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

Implementation de l'unité alpha des registers combiners

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

Implementation de l'unité RGB des registers combiners

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

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

L'abandon des architectures VLIW

[modifier | modifier le wikicode]

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

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

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

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


La microarchitecture des processeurs de shaders

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

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

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

[modifier | modifier le wikicode]

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

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

Les unités de calcul SIMD

[modifier | modifier le wikicode]
Une unité de calcul SIMD.

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

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

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

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

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

[modifier | modifier le wikicode]

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

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

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

Les unités de calcul scalaires

[modifier | modifier le wikicode]

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

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

Les processeurs de shaders incorporent aussi une unité de calcul flottante scalaire, utilisée pour faire des calculs sur des flottants isolés. L'unité de calcul gère généralement des calculs simples, comme les additions, soustractions, multiplications et divisions. Il s'agit typiquement d'une unité de calcul spécialisée dans l’opération Multiply-And-Add (une multiplication suivie d'une addition, opération très courante en 3D, notamment dans le calcul de produits scalaires), qui ne gère pas la division.

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

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

L'unité de texture/lecture/écriture

[modifier | modifier le wikicode]

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

La gestion des dépendances de données

[modifier | modifier le wikicode]

Un processeur de shaders 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. 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.

Pour cela, une possibilité est d'utiliser des instructions à co-issue. Le problème est que ces instructions sont surtout utiles pour exécuter des instructions scalaires en parallèle d'instruction SIMD, mais guère plus. Aussi, nous allons mettre la co-issue de côté. Dans ce qui suit, nous allons partir du principe que le processeur peut démarrer une nouvelle instruction par cycle d'horloge. Et cela permet malgré tout d’exécuter plusieurs instructions en même temps dans des unités de calcul séparées. La raison est que la plupart des instructions prend plusieurs cycles d'horloge à s’exécuter. Ainsi, pendant qu'une instruction en est à son second ou troisième cycle dans une ALU, il est possible de démarrer une nouvelle instruction dans une ALU inoccupée, s'il y en a une. La seule contrainte est que les deux instructions soient indépendantes.

Les processeurs de shaders en sont capables, tout comme les CPU. Mais les CPU utilisent au mieux cette possibilité. Ils intègrent des circuits d'exécution dans le désordre, de renommage de registre, et bien d'autres. Mais leur implémentation demande un budget en transistors conséquent, que les GPU ne peuvent pas se permettre. A la place, ils utilisent une technique appelée le multithreading matériel, une technique qui 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 basique du multithreading matériel.

L'idée est 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. Par exemple, 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. Pour un GPU, les programmes en question sont des instances de shader qui travaillent sur des données différentes. Ces instances de shader portent les noms de warp dans la terminologie NVIDIA. Un processeur de shader commute donc régulièrement d'un warp à l'autre, suivant les besoins. Dans ce qui va suivre, nous allons voir dans quelles situations un processeur de shader change de thread/warp en cours d'exécution. Suivant le GPU, les situations ne sont pas les mêmes.

Les lectures non-bloquantes avec multithreading matériel

[modifier | modifier le wikicode]

Les processeurs de shader sont connectés à une mémoire vidéo très lente, avec un temps d'accès élevé, qui se rattrape avec un débit binaire important. La conséquence est qu'un accès à une texture, c'est long : si celle-ci est lue depuis la mémoire vidéo, le temps d'attente est d'une bonne centaine de cycles d'horloges. Pour limiter la casse, les unités de texture incorporent un cache de texture, mais cela ne suffit pas toujours à alimenter les processeurs de shaders en données. Et cs derniers ne peuvent pas recourir à des techniques avancées communes sur les CPU, comme l’exécution dans le désordre : le cout en circuit serait trop important. Fort heureusement, les processeurs de shaders disposent d'optimisations pour remplir ces temps d'attente avec des calculs indépendants de la texture lue.

Les anciennes cartes graphiques d'avant les années 2000 géraient une forme particulière de lectures non-bloquantes. L'idée est simple : pendant qu'on effectue une lecture dans l'unité de texture, on peut faire des calculs en parallèle dans les ALU de calcul. 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 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. Pour cela, le processeur de shader incorpore un circuit situé juste après le décodeur d'instruction, appelé l'unité d'émission. Elle compare les registres utilisés par l'instruction avec le registre de destination. Si il y a correspondance, l'instruction 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.

Depuis l'époque de Direct X 9, les GPU combinent les lectures non-bloquantes avec le multithreading matériel. L'idée est que si un thread effectue un accès mémoire, le thread est mis en pause pendant l'accès mémoire et laisse la place à un autre. Le thread est réveillé une fois que l'accès mémoire est terminé. L'avantage est que si un thread est mis en pause par une lecture, 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. Faire ainsi marche assez bien si on a beaucoup de threads dont peu d'entre eux font des lectures/écritures.

L'avantage est que si un thread est mis en pause par une lecture, 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. Faire ainsi marche assez bien si on a beaucoup de threads dont peu d'entre eux font des lectures/écritures.

L'implémentation de cette technique est assez simple au premier abord. L'unité de chargement a plusieurs program counter, un par thread. Un circuit dédié sait quels threads sont mis en pause, et en envoie un aux unités de calculs. La technique impose cependant que les registres soient 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 de shader peut exécuter entre 16 et 32 threads/warps, ce qui multiplie le nombre de registres par 16/32. Et il y a la même chose avec d'autres structures matérielles, comme les files de lecture/écriture de l'unité d'accès mémoire.

Aperçu de l'architecture d'un processeur multithreadé

Le scoreboard des GPU des années 2000-2010

[modifier | modifier le wikicode]

Les lectures non-bloquantes permettent de gérer le cas des instructions d'accès mémoire. Mais il y a d'autres instructions qui prennent plus d'un cycle d'horloge pour s'exécuter. Les instructions de calcul basiques d'un GPU prennent 2 à 3 cycles d'horloge dans le meilleur des cas, parfois plus d'une dizaine. De telles instructions sont autant d'opportunités pour exécuter plusieurs instructions en même temps. Pendant qu'une instruction multicycle occupe une ALU, les autres ALU peuvent accepter d'autres instructions.

Les processeurs de shaders SIMD peuvent démarrer une nouvelle instruction scalaire/SIMD par cycle, si les conditions sont réunies. Le problème est alors le suivant : deux instructions démarrées l'une après l'autre doivent occuper des ALU différentes, mais en plus elles doivent manipuler des données différentes.

Prenons un exemple simple : une instruction calcule un résultat, qui est utilisé comme opérande par une seconde instruction. La seconde ne doit pas démarrer tant que la première n'a pas enregistré son résultat dans les registres. Il s'agit d'une dépendance dite RAW (Read After Write) typique, que la carte graphique doit gérer automatiquement. Il existe aussi d'autres dépendances liées au fait que deux instructions utilisent les mêmes registres, mais laissons-les de côté pour le moment. Il faut dire qu'avec 4096 registres par shader, elles sont plus rares. Les dépendances en question sont regroupées sous le terme de dépendances de données.

S'il y a une dépendance de données entre deux instructions, on ne peut pas les exécuter en parallèle dans des unités de calcul séparées. Sur les cartes graphiques des années 2000-2010, un circuit dédié appelé le scoreboard détectait ces dépendances. Pour cela, il vérifie si deux instructions utilisent les mêmes registres.

Précisons que le grand nombre de registres et de threads fait qu'un scoreboard classique, tel que décrit dans les cours d'architecture des ordinateurs, devient rapidement impraticable. 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.

A chaque cycle, le scoreboard reçoit une instruction provenant du décodeur et vérifie si elle peut être exécutée. Il vérifie les dépendances de données, mais aussi d'autres formes de dépendances. Notamment, il vérifie qu'il y a une unité de calcul libre pour exécuter l'instruction.S'il n'y a pas de dépendance, elle est envoyée à une unité de calcul inoccupée, s'il y en a une. Mais s'il y a une dépendance de donnée, le processeur de shader met en pause le thread/warp et bascule sur un autre thread. Le thread est mis en pause tant que la dépendance n'est pas résolue. A chaque cycle, le processeur détecte les dépendances et choisit une instruction parmi celles qui peuvent s'exécuter, peu importe leur thread.

L'implémentation sépare 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 ou plusieurs files d'instruction. Le cas le plus simple à comprendre utilise une file d'instruction par thread. Les instructions attendent leur tour dans la file d'instruction. L'unité d'émission vérifie à chaque cycle quelles instructions sont prêtes à s'exécuter, dans chaque file d'instruction. L'unité d'émission choisit un thread et envoie l'instruction la plus ancienne dans la file d'instruction aux unités de calcul. Les instructions qui ont besoin d'un résultat pas encore calculé ou lu depuis la mémoire attendent juste.

FGMT sur un processeur de shaders

Mais pour cela, il faut que ces derniers aient des instructions en attente dans la file d'instruction. 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. Pour éviter ce problème, le processeur profite du fait que le chargement des instructions depuis la mémoire et leur exécution se font en même temps : le processeur charge des instructions pendant qu'il en exécute d'autres. Plus haut, nous avions dit que l'unité de chargement mémorise plusieurs program counter, un par thread, et choisit à chaque cycle l'un d'entre eux pour charger une instruction. Le choix en question est synchronisé avec l'émission des instructions. Si un thread émet une instruction, ce même thread charge une instruction au même moment. Sauf si la file d'instruction du thread est déjà pleine, auquel cas un autre thread est choisit.

L'encodage explicite des dépendances sur les GPU post-2010

[modifier | modifier le wikicode]

Depuis environ 2010, les GPU n'utilisent plus de scoreboard proprement dit. A la place, les GPU modernes déportent la détection des dépendances de données à la compilation. 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. La technique porte le nom d'anticipation de dépendances explicite (Explicit-Dependence lookahead). Un exemple historique assez ancien est le processeur Tera MTA (MultiThreaded Architecture), qui utilisait cette technique.

Les GPU NVIDIA modernes utilisent plusieurs bits par instruction pour gérer les dépendances de données. Un premire mécanisme est utilisé pour bloquer l'émission d'une nouvelle instruction pendant x cycles. Il utilise un stall counter, qui mémorise le nombre de cycles d'attente. Une instruction peut initialiser le stall counter avec une valeur de base, qui indique combien de cycles attendre. Le stall counter est décrémenté à chaque cycle d'horloge et une nouvelle instruction s'exécute seulement quand le compteur atteint 0. Il est utilisé quand une instruction productrice prenant X cycles est suivie par une instruction consommatrice. Le stall counter est alors initialisé à la valeur X.

La méthode précédente ne vaut cependant que pour les instructions dont le compilateur peut prédire la durée. En clair, les accès mémoire et certaines instructions spéciales ne sont pas prises en charge. Pour les gérer, les GPU modernes utilisent un autre mécanisme, basé sur des compteurs de dépendances. Il y en a plusieurs par thread, entre 5 et 10 selon le GPU. Une instruction productrice réserve un de ces compteur, l'incrémente quand elle démarre son exécution/est émise, le décrémente quand la dépendance est résolue. Les instructions consommatrices doivent attendre que le compteur tombe à zéro pour s'exécuter. Elles précisent quels compteurs regarder avec un masque de compteurs de dépendance, encodé directement dans l'instruction elle-même. Le masque est composé de bits à 0/1, un par compteur.

Précisément, chaque instruction productrice se réserve deux compteurs, pour gérer les trois types de dépendances de données (RAW, WAR et WAW). Le premier compteur est décrémenté quand le résultat est écrit dans les registres, ce qui gère naturellement les dépendances RAW et WAW. Le second est décrémenté quand l'instruction a lu ses opérandes, ce qui gère les dépendances WAR. Les autres instructions regarde l'un ou l'autre des compteurs selon leur situation.

Il arrive même que le switch de thread soit géré par des bits intégrés dans l'instruction. Par exemple, sur les GPU NVIDIA modernes, chaque instruction contient un bit yield qui indique qu'il faut changer de thread une fois l'instruction émise. En clair, il indique que l'instruction risque de durer longtemps, a des dépendances avec la mémoire ou autre chose qui fait qu'il est préférable de changer de thread.

Le banc de registres d'un processeur de shader

[modifier | modifier le wikicode]

Les GPU disposent d'un grand nombre de registres. Les normes de DirectX et Open GL imposent que les shaders modernes gèrent au moins 4096 registres généraux par instance de shader, avec des registres spécialisés en plus. En soi, 4096 est énorme ! Mais au-delà de ces normes, le FGMT implique de dupliquer des registres par le nombre de threads hardware simultanés. Avec le FGMT, 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.

Maintenant, faisons quelques calculs d'épiciers. Un processeur de shader peut exécuter entre 16 et 32 threads/warps, ce qui multiplie le nombre de registres par 16/32. En multipliant par les 4096 registres nécessaires, cela fait 128 kilooctets de mémoire rien que pour les registres. Et c'est pour un seul cœur ! Si on multiplie par le nombre de cœurs, on trouve que les cartes graphiques modernes ayant plusieurs processeurs de shaders ont facilement entre 32 768 et 65 536 registres de 32 bits, ce qui est énorme ! Il y a plus de mémoire gaspillée dans les registres que dans le cache L1 ou L2 !

Et ce grand nombre de registres par cœur pose quelques problèmes. Les registres sont regroupés dans une petite mémoire SRAM, adressable, appelée le banc de registre. Et comme toutes les mémoires, plus ce banc de registres est grand, plus il est lent. En conséquence, lire un opérande dans les registres prend beaucoup de temps. Du moins, c'est le cas sans optimisations. Et les GPU implémentent de nombreuses parades pour limiter le nombre de registres réellement présents dans leur silicium.

L'allocation dynamique/statique des registres par thread

[modifier | modifier le wikicode]

Les GPU modernes n'implémentent pas le nombre maximal de registres demandés. Par exemple, si je prends les GPU AMD de type RDNA 4, il peuvent gérer 16 threads hardware simultanés, chacun ayant accès à 256 registres, registres faisant 128 octets chacun. On s'attend à avoir un banc de registre de 16 * 256 * 128 octets, soit 512 Kilo-octets. En somme, un banc de registre de la taille du cache L1. Sauf que les GPUs en question intègrent moins de registres que prévu ! Ils ont précisément une taille de 192 kilo-octets, soit 96 registres pour chacun des 16 threads.

En effet, 256 registres est un nombre maximal, que la plupart des shaders n'utilise pas totalement. La plupart des shaders utilise entre 64 et 128 registres, rarement moins, rarement plus. Aussi, le GPU partitionne le banc de registres à la demande entre les threads, en leur donnant seulement une partie des registres. Le partitionnement peut être pseudo-statique, à savoir que le banc de registre est découpé en parts égales pour chaque thread, ou dynamique avec un nombre de registre variant d'un thread à l'autre, selon les besoins.

Prenons l'exemple d'un partitionnement pseudo-statique, avec l'exemple des GPU AMD RDNA 1 et 2. Leur banc de registre fait 1024 registres, de 128 octets chacun, soit 128 KB au total. Le GPU gère 16 threads simultanés maximum. Avec un seul thread d'exécuté, le thread unique peut utiliser les 1024 registres du banc de registre pour lui tout seul. Avec deux threads, chacun aura droit à 512 registres, soit la moitié du cas précédent. Avec 16 thread simultanés, chaque thread a accès à 64 registres, pas plus. Et ainsi de suite : le nombre de registre par thread est égal à la taille du banc de registre divisée par le nombre de threads.

A l'heure où j'écris ces lignes, courant 2025, les GPU Intel se contentent d'un partitionnement statique très limité. Avant les GPU d'architecture Battlemage, il n'y avait pas de partitionnement du banc de registre, tous les threads avaient 128 registre à leur disposition. Les GPU Battlemage et ses successeurs ont introduit un partitionnement limité, avec deux modes : un mode sans partitionnement où tous les threads ont accès à 128 registres, un mode avec partitionnement qui divise le nombre de thread par deux et leur donne chacun 256 registres. Pas de possibilité de diviser plus le nombre de threads.

Les GPU AMD et NVIDIA sont eux plus compétents niveau partitionnement statique. Par exemple, les GPU RDNA 4 supportent les partitionnements suivants :

  • 16 threads avec 96 registres chacun ;
  • 12 threads avec 120 registres chacun ;
  • 10 threads avec 144 registres chacun ;
  • 9 threads avec 168 registres chacun ;
  • 8 threads avec 192 registres chacun ;
  • 7 threads avec 216 registres chacun ;
  • 6 threads avec 240 registres chacun ;
  • 5 threads avec 256 registres chacun.

Le partitionnement pseudo-statique est simple à implémenter, il ne demande pas beaucoup de circuits pour fonctionner. Il est rapide et a de bonnes performances pour le rendu graphique en rastérisation. La raison est qu'en rastérisation, les différents threads sont souvent des copies/instances d'un même shader qui travaillent sur des données différentes. Leur donner le même nombre de registres colle bien avec cet état de fait.

Cependant, si les différents threads sont des shaders différents, les choses ne sont pas optimales. Un shader utilisera plus de registres que l'autre, leur donner le même nombre de registres n'est pas optimal. Par exemple, imaginons que l'on a deux shaders, nommés shaders 1 et 2, aux besoins différents, l'un étant gourmand en registres et l'autre très économe. Dans ce cas, il faudrait partitionner le banc de registre pour donner plus de registre au premier et moins au second. Il s'agit là d'un partitionnement dynamique.

Le partitionnement dynamique est plus optimal pour gérer des shaders déséquilibrés niveau registres, mais a une implémentation matérielle plus complexe. Il a été introduit assez tard, car il a fallu attendre que le rayctracing se démocratise. En effet, le partitionnement dynamique du banc de registre est surtout utile pour le raytracing. Exécuter simultanément des shaders déséquilibrés en registres est peu fréquent avec la rastérisation, beaucoup plus courant avec le raytracing.

NVIDIA et AMD ont des implémentations différentes du partitionnement dynamique. Les GPU AMD RDNA 4 allouent un nombre minimal de registre à chaque thread, mais ils peuvent demander d'avoir accès à plus de registres si besoin. Quand un thread a besoin de plus de registres, il exécute une instruction dédiée, qui sert à demander plus de registres, ou au contraire à en libérer s'ils sont inutilisés. La demande d'allocation de nouveaux registres se fait par blocs de 16 à 32 registres, suivant comment est configuré le processeur. Précisons que l'instruction d'allocation n'est disponible que pour les compute shaders et pas les shaders graphiques.

L'instruction d'allocation de registre précédent peut échouer dans certains cas. Si assez de registres sont disponibles, à savoir inutilisés par d'autres threads, l'instruction réussit. Dans le cas contraire, elle échoue et le shader est mis en pause avant de retenter cette demande plus tard. Le résultat de l'instruction, échec ou réussite, est mémorisé dans le registre d'état. La technique a pour défaut que certaines situations peuvent mener à un blocage complet du processeur, où chaque thread ne peut plus poursuivre son exécution, faute de registres disponibles. Des méthodes pour éviter cette situation sont implémentés sur ces GPU, mais la documentation n'est pas très explicite.

Sur les GPU NVIDIA, il y a aussi une instruction d'allocation de registre, mais elle fonctionne différemment. Elle permet d'échanger des registres entre threads. Une première différence est que tous les threads commencent avec une allocation égale des registres. Les threads démarrent tous avec le même nombre de registres. Un thread peut libérer des registres, qui sont alors alloués à un autre thread, le thread en question pouvant être choisit par le thread qui libère les registres.

Le banc de registre est multiport de type externe

[modifier | modifier le wikicode]

Le banc de registre doit permettre de lire deux vecteurs SIMD par opération, soit deux lectures simultanées. Pour cela, le banc de registre contient deux ports de lecture, chacun permettant de lire un opérande dans le banc de registre. Mais plus le nombre de ports augmente, plus la consommation énergétique du banc de registre augmente, sans compter que celui-ci devient plus lent.

Les processeurs peuvent utilisent des bancs de registres ayant réellement deux ports par banc de registre. Un port de lecture est implémenté avec un composant appelé un multiplexeur, connecté à tous les registres.

Mémoire multiport faite avec des MUX-DEMUX

Les GPU ne peuvent pas se permettre un tel luxe. Leur banc de registre doit alimenter plusieurs unités de calcul en même temps, en parallèle. Le nombre de ports serait plus proche de 4 à 10 ports de lectures. La solution précédente aurait un budget en transistor et un budget thermique trop important. À la place, ils utilisent une autre méthode : ils simulent un banc de registre à plusieurs ports avec un ou plusieurs bancs de registres à un port. On parle alors de multiport externe.

Mémoire multiport à multiportage externe.

Il existe plusieurs méthodes de multiport externe. Mais celle utilisée sur les GPU simule un banc de registre multiport à partir de plusieurs bancs de registres à un port. Le banc de registre est en réalité formé de plusieurs banques, de plusieurs bancs de registre séparés, chacun mono-port. L'idée est que si l'on accède à deux banques en même temps, on peut lire deux opérandes, une par port/banc de registre. Par contre, si les deux opérandes à lire sont la même banque, il y a un conflit d'accès aux banques.

Mémoire à multiports par banques.

L'Operand Collector et les caches de register reuse

[modifier | modifier le wikicode]

Sans conflit d'accès à une banque, les deux opérandes sont disponibles immédiatement. Par contre, en cas de conflit d'accès aux banques, les deux opérandes sont lus l'une après l'autre. En clair, le premier opérande doit être mis en attente quelque part pendant que la seconde est en cours de lecture. Et le problème survient souvent, surtout avec les opérations FMA qui utilisent trois opérandes, encore plus avec les rares opérations qui demandent 4 à 5 opérandes.

Pour gérer les conflits d'accès aux banques, les GPU utilise un circuit dédié, appelé le collecteur d'opérandes (operand collector). Le rôle du collecteur d'opérandes est d'accumuler les opérandes en attente, son nom est assez transparent. Il accumule les opérandes en attente, puis les envoie aux unités de calcul quand elles sont toutes prêtes, toutes lues depuis le banc de registres. Les opérandes sont mis en attente dans des entrées, qui contiennent la donnée, l'identifiant du thread/warp pour ne pas confondre des opérandes entre threads, et deux bits d'occupation. Les deux bits d'occupation indiquent si l'entrée est vide, réservée pour un opérande en cours de lecture ou occupée par un opérande.

Le collecteur d'opérande est souvent accompagné de registres temporaires, qui mémorisent le résultat d'une instruction précédente. Il y a un registre temporaire par ALU, le résultat fournit par une ALU est mémorisé dans le registre temporaire associé. Une instruction peut lire une opérande dans un registre temporaire, ce qui permet de lire le résultat d'une instruction précédente sans passer par le banc de registres. Le collecteur d'opérande est alors configuré pour récupérer les opérandes adéquats dans les registres temporaires adéquats.

Pour faire une comparaison avec les processeurs modernes, ces registres sont une forme de data forwarding, de contournement, mais qui est rendue explicite pour le logiciel.

Une optimisation des GPU récent vise à réduire les accès aux bancs de registres en utilisant une mémoire cache spécialisée. Il s'agit de l'operand reuse cache, aussi appelés register reuse cache. L'idée est que quand un opérande est lu depuis le banc de registres, elle peut être stockée dans ce cache pour des utilisations ultérieures. Les architectures Volta, Pascal et Maxwell disposent de 4 caches de ce type, chacun stockant 8 données/opérandes/résultats

Il s'agit en réalité de pseudo-caches, car ils sont partiellement commandés par le logiciel. Une instruction précise qu'un opérande doit être stocké dans un register reuse cache, pour une utilisation ultérieure. Pour cela, elle incorpore quelques bits pour préciser qu'elle doit être placée dans le cache. Les bits font en quelque sorte partie du mode d'adressage. Si l'instruction immédiatement suivante lit ce registre dans le même slot d'opérande lira l'opérande dans le cache. La technique est donc assez limitée, mais elle a des résultats pas négligeables.

Les register reuse cache et le collecteur opérandes sont sans doute fusionnés en un seul circuit, plutôt que d'utiliser deux circuits séparés. La raison est que les deux doivent mémoriser des opérandes les mettre en attente pour une utilisation ultérieure, et sont placés juste après le banc de registre. NVIDIA a publié deux brevets à propos de ces deux techniques, mais rien n'indique que c'est exactement cette technique qui utilisée dans les cartes modernes. Il faut dire que le nombre de banques a changé suivant les cartes graphiques.

Les processeurs de shaders VLIW

[modifier | modifier le wikicode]

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

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

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

Les architectures VLIW pures, sans unité SIMD

[modifier | modifier le wikicode]

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

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

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

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

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

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

[modifier | modifier le wikicode]

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

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

Un autre exemple est le processeur de vertex shader de la Geforce 6800, illustré ci-dessous. On voit que le processeur contient une unité d'accès mémoire/textures, avec deux unités de calcul. La première est une unité de calcul scalaire flottante, qui gère les opérations flottantes transcendantales. Elle travaille sur des opérandes de 32 bits. À côté, on trouve une unité de calcul SIMD, qui gère des vecteurs de 4 nombres flottants 32 bits. Elle permet de faire des additions, des multiplications, des opérations MAD, des produits vectoriels, et quelques autres opérations comme le calcul du maximum/minimum de deux nombres. Il n'y a aucune gestion d'instructions flottantes scalaires ou d'opérations entières, la carte graphique est trop ancienne pour cela.

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

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

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

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

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

Processeur de pixel shader de la Geforce 6800


Les caches d'un processeur de shader

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. Un GPU contient évidemment une mémoire vidéo de grande taille, séparée des processeurs de shader, mais pas que. Les processeurs de shaders intègrent aussi des mémoires plus petites, appelées des mémoires caches. Les processeurs intégrent tous des caches et les processeurs de shaders ne font pas exception. Cependant, les caches d'un GPU sont quelque peu particuliers et sont organisés différemment. La hiérarchie mémoire des GPUs est assez particulière, et nous allons voir en quoi dans ce qui suit.

Les caches spécialisés d'un GPU

[modifier | modifier le wikicode]

Un point important est que les GPU sont dédiés au rendu 3D, et cette spécialisation se voit dans leurs mémoires caches. Les premières cartes graphiques avaient des caches spécialisés, avec des caches pour les textures, des caches de sommets, des caches pour le tampon de profondeur, etc. Ils n'avaient pas de caches généralistes, qui servent à stocker n'importe quel type de données. Les caches spécialisés étaient intégrés aux circuits fixes. Par exemple, le cache pour les textures est placé dans l'unité de texture, le cache de sommet dans l'input assembler, le cache du z-buffer dans les ROPs.

Le cache de textures

[modifier | modifier le wikicode]

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

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

Notons que ce cache interagit avec les techniques de compression de texture. Les textures sont en effet des images, qui sont donc compressées. Et elles restent compressées en mémoire vidéo, car les textures décompressées prennent beaucoup plus de place, entre 5 à 8 fois plus. Les textures sont décompressées lors des lectures : le processeur de shaders charge quelques octets, les décompresse, et utilise les données décompressées ensuite. Le cache s'introduit quelque part avant ou après la décompression.

On peut décompresser les textures avant de les placer dans le cache, ou laisser les textures compressées dans le cache. Tout est une question de compromis. Décompresser les textures dans le cache fait que la lecture dans le cache est plus rapide, car elle n'implique pas de décompression, mais le cache contient moins de données. À 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 de constante

[modifier | modifier le wikicode]

Un shader a besoin de certaines "constantes" pour faire son travail. Les constantes en question sont d'accès peu fréquent, qui se limite souvent à un accès au début de chaque instance de shader, guère plus. Au premier abord, dédier un cache à de telles constantes ne parait pas très utile, vu qu'elles ne semblent pas réutilisées. Mais c'est oublier un point important : toutes les instances du shader manipulent ces constantes, et il y en a souvent plusieurs qui s'exéceutn à tour de rôle, si ce n'est en même temps ! Pour profiter du partage des constantes entre instances d'un shader, les GPU incorporent des caches de constantes. Ainsi, quand un shader lit une donnée, elle est chargée dans le cache de constante, ce qui fait que les autres instances liront ces constantes depuis le cache et non depuis la VRAM.

Les caches de constante sont séparés des autres caches de données, car ce sont des données peu fréquemment utilisées, qui sont censées ê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é, les constantes restent dans le cache. 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.

Les caches généralistes

[modifier | modifier le wikicode]

Les GPUs récents contiennent des caches généralistes, qui ne sont spécialisés dans le rendu graphique. Leur existence se justifie par le fait que les GPU sont de plus en plus utilisés pour du calcul généraliste (scientifique, notamment), à savoir qu'ils exécutent des compute shaders qui manipulent des données arbitraires. Et de tels compute shaders sont parfois utilisés pour du rendu 3D, pour exécuter des algorithmes d'élimination des pixels cachés, ou des algorithmes de rendu assez complexes.

Les caches généralistes des GPU modernes ressemblent à ceux des CPU, avec une hiérarchie de caches. Pour rappel, les processeurs multicœurs modernes ont souvent trois à quatre niveaux de caches, appelés les caches L1, L2, L3 et éventuellement L4. Les GPU ont une organisation similaire, sauf que le nombre de cœurs est beaucoup plus grand que sur un processeur moderne.

  • Pour le premier niveau, on a deux caches L1 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 qui peut stocker indifféremment données et instruction et qui est partagé entre plusieurs cœurs/processeurs.
  • Le cache L3 est un cache partagé entre tous les cœurs/processeurs.
Partage des caches sur un processeur multicoeurs

Les caches d'instruction

[modifier | modifier le wikicode]

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. Les shaders sont donc des programmes assez légers, qui ont peu d'instructions. Les caches d'instructions des GPU sont généralement assez petits, 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.

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.

Les caches de données

[modifier | modifier le wikicode]

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. Il y a cependant des exceptions, qui expliquent que les cartes graphiques incorporent un cache de texture et un cache de sommet (pour le tampon de sommet).

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. Au lieu d'incorporer un cache de sommets et un cache de textures, autant utiliser un seul cache qui sert alternativement de cache de vertex et de cache de texture, afin d'économiser des circuits.

La mémoire partagée : un local store

[modifier | modifier le wikicode]

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

Scratch-Pad-Memory (SPM).

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

Local stores d'un GPU.

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

L'implémentation des local store

[modifier | modifier le wikicode]

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

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

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

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

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

Phased cache

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

Hydride cache - local store

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

La cohérence des caches sur un GPU

[modifier | modifier le wikicode]

Pour terminer ce chapitre, nous allons parler de la cohérence des caches. La cohérence des caches est un problème qui se manifeste à plusieurs niveaux, quand on parle d'un GPU : entre CPU et GPU, ou entre processeurs de shaders. Nous allons voir les deux cas l'un après l'autre.

La cohérence des caches pour les transferts DMA

[modifier | modifier le wikicode]

Supposons que le CPU ait transféré les données dans la mémoire vidéo, avec un transfert DMA. Une situation bien précise pose problème : quand un transfert DMA écrase des données devenues inutiles, pour les remplacer par des données utiles. C'est très fréquent, les pilotes graphiques libèrent souvent de la mémoire vidéo pour la réallouer immédiatement après, afin de ne pas gaspiller de VRAM. Sans intervention du GPU, le remplacement des données aura été fait en mémoire vidéo, pas dans les caches du GPU. Et tout accès ultérieur au cache renverra la donnée écrasée.

Cohérence des caches avec DMA.

Pour éviter cela, le GPU invalide ses caches en cas de transfert DMA. Par invalider, on veut dire que le cache est réinitialisé, mis à zéro, il est rendu vierge de toute donnée. Ainsi, tout accès mémoire ultérieur se fera en mémoire RAM sans passer par le cache. Les données lues depuis la RAM seront ensuite copiées dans le cache, mais ce seront les données valides écrites après le transfert DMA. Le contenu du cache est alors reconstitué au fur et à mesure des accès mémoire. L'invalidation est automatique sur les anciens GPU, elle est réalisée par le processeur de commande. Sur les GPU modernes, elle est réalisée par le programmeur, comme on va le voir dans la section immédiatement suivante.

La cohérence des caches entre CPU et GPU

[modifier | modifier le wikicode]

Dans ce qui suit, on suppose qu'il n'y a qu'une seule mémoire RAM, qui est partagée entre CPU et GPU, et sert à la fois de RAM et de mémoire vidéo. Il s'agit de ce que l'on appelle la mémoire unifiée. Elle est utilisée dans de nombreuses consoles de jeu, mais aussi avec les GPU intégrés, qui sont dans le processeur. La mémoire unifiée n'implique pas de transfert DMA entre CPU et GPU, vu qu'il n'y a qu'une seule RAM. Par contre, un problème différent du précédent peut survenir.

Le processeur n'écrit pas directement en mémoire RAM, mais dans son cache. Les écritures sont propagées en RAM avec un certain retard, quand les données sont évincées du cache pour faire de la place à de nouvelles données. Et même quand elles sont propagées en RAM, les écritures ne sont pas propagées dans les caches du GPU. Pour corriger cela, lorsque le processeur envoie des données au GPU, il force le GPU à invalider ses caches. Il envoie une commande dédiée pour, qui précède les commandes liées au rendu 2D/3D/autres.

Cohérence des caches entre CPU et GPU avec mémoire unifiée

Au niveau du processeur, le processeur doit écrire les données dans la mémoire RAM, avant de faire le transfert DMA. Mais la présence de caches pose problème : les écritures peuvent être interceptées par le cache et ne pas être propagées en RAM. Pour éviter cela, les processeurs modernes marquent des blocs de mémoire comme "non-cacheables", à savoir que toute lecture/écriture dedans se fait sans passer par le cache. C'est une fonctionnalité très importante pour communiquer avec les périphériques. Pour les GPU dédiés/soudés, cela a un lien avec la mémoire vidéo mappée en mémoire. Plus haut, nous avions dit que la mémoire vidéo est visible dans l'espace d'adressage du processeur, à savoir qu'un bloc de mémoire est détourné pour adresser non pas la RAM, mais la mémoire vidéo. Et bien ce bloc de mémoire entier est marqué comme étant non-cacheable.

Cohérence des caches entre CPU et GPU avec mémoire unifiée, mécanismes

La situation peut être optimisée sur les GPU intégrés. Si le GPU est conçu pour, il n'y a pas besoin de marquer les données comme non-cacheables. Le cas le plus simple est celui où le CPU et le GPU partagent leur cache L3/L4. Dans ce cas, il n'y a qu'un seul cache L3/L4 qui ne contient qu'une seule copie valide, écrite par le CPU et lue par le GPU. Il faut juste garantir que la donnée soit lue par le GPU depuis le L3, mais c'est là une question d'inclusivité du cache, qui ne nous concerne pas ici. Si le CPU et le GPU ne partagent pas de cache, il suffit que le GPU puisse lire les caches du CPU. C'est la méthode utilisée sur l'APU Trinity d'AMD.

Cohérence des caches entre CPU et GPU intégré

La cohérence des caches entre processeurs de shaders

[modifier | modifier le wikicode]

Pour terminer, il faut voir la cohérence des caches entre processeurs de shaders. Une carte graphique moderne est, pour simplifier, un gros processeur multicœurs auquel on aurait rajouté des ROPs, les circuits de la rastérisation et les unités de textures. Il n'est donc pas étonnant que les problèmes rencontrés sur les processeurs multicœurs soient aussi présents sur les GPU, la cohérence des caches ne fait pas exception.

Pour simplifier les explications, nous allons partir du principe que chaque processeur de shaders a son propre cache de données. Prenons deux processeur de shaders qui ont chacun une copie d'une donnée dans leur cache. Si un processeur de shaders 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.

Cohérence des caches

La réalité est cependant plus complexe, dans le sens où il n'y a souvent pas un cache par processeur de shaders, mais une hiérarchie de cache assez complexe, avec un cache L1 par processeur de shaders, un cache L2 partagé entre plusieurs processeur de shaders, des caches partagés entre tous les processeur de shaders, etc. Certains GPU partagent leur cache L1 d’instructions entre plusieurs processeur de shaders, d'autres non. Mais le principe reste valide, tant qu'un cache n'est pas partagé entre tous les processeurs de shaders : un cache peut contenir une donnée invalide, à savoir qu'elle a été modifiée dans le cache d'un autre processeur de shaders.

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. Mais autant ces techniques sont faisables sur des CPU avec un nombre limité de cœurs, autant elles sont impraticables avec un GPU contenant une centaine de cœurs. Heureusement, la cohérence des caches est un problème bien moins important sur les GPU que sur les CPU. En effet, le rendu 3D implique un parallélisme de données : 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.

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és. 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 insère une instruction qui invalide le cache, avant 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 et donc qu'il s'agit d'une donnée correcte.

Elle est utilisée pour supporter les techniques de render-to-texture, pour dessiner l'image finale dans une texture (pour y appliquer un filtre de post-traitement, par exemple). Les opérations de render-to-texture étant assez rares, il vaut mieux ne pas rendre les caches de texture accessibles en écriture. L'invalidation du cache au besoin est alors parfaitement adapté. Les autres caches du GPU sont gérés avec le même principe. Pour les caches généralistes, certains GPU modernes commencent à implémenter des méthodes plus élaborées de cohérence des caches.


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

Pour rappel, 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é, tous les processeurs modernes intègrent une carte graphique, 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.

Les cartes graphiques dédiées ont de la mémoire vidéo intégrée à la carte graphique, sauf pour quelques exceptions dont on parlera plus tard. Par contre, les IGP n'ont pas de mémoire vidéo dédiée, vu qu'on ne peut pas intégrer beaucoup de mémoire dans un processeur. Et cela permet de classer les cartes graphiques en deux types :

  • Les cartes graphiques à 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.
  • Les cartes graphiques à mémoire unifiée, où la mémoire RAM est partagée entre le processeur et la carte graphique. Le terme "unifiée" sous-entend que l'on a unifié la mémoire vidéo et la mémoire système (la RAM).
Répartition de la mémoire entre RAM système et carte graphique

La distinction "mémoire dédiée versus unifiée" correspond de très près à la distinction "GPU dédié versus IGP". Dans la grosse majorité des cas, les cartes vidéos dédiées ont une mémoire dédiée, alors que les IGP utilisent la mémoire unifiée. Mais il existe de rares exceptions où une carte vidéo dédiée utilise la mémoire unifiée. Par 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 ! Les performances étaient ridicules, mais les cartes étaient peu chères du fait de l’absence de VRAM, ce qui explique que l'Intel 740 a eu un petit succès sur les ordinateurs d'entrée de gamme.

Une autre exception est celle des GPU soudés sur la carte mère, utilisées sur certaines consoles de jeu, ainsi que certains PC portables puissants destinés aux gamers. Pour ces dernières, il est possible d'utiliser aussi bien de la mémoire dédiée que de la mémoire unifiée. Si la plupart des consoles récents utilisent de la VRAM dédiées, d'anciennes consoles de jeu avec un GPU soudé utilisaient une mémoire unifiée, comme la Nintendo 64, pour ne citer qu'elle.

Mémoire unifiée Mémoire dédiée
GPU dédié Quelques rares GPU d'entrée de gamme Tous les GPU, sauf de rares exceptions
GPU intégré Systématique
GPU soudé Anciennes consoles de jeu Ordinateurs portables modernes

La mémoire vidéo dédiée

[modifier | modifier le wikicode]

La mémoire 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.

Dans ce qui suit, la mémoire vidéo des cartes graphiques dédiées sera appelée la VRAM.

Les mémoires GDDR et autres VRAM

[modifier | modifier le wikicode]

La VRAM ressemble aux barrettes de RAM qu'on trouve dans nos PC, à quelques différences près. Le point le plus important est qu'elle 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 carte graphique, sur son PCB vert. La conséquence est que l'on ne peut pas upgrader la VRAM d'une carte vidéo. Le fait que la VRAM est soudée simplifie la conception de la carte graphique, mais a aussi des avantages au niveau électrique et donc en termes de performance.

Les cartes graphiques des années 90-2000, utilisaient la même mémoire que celle des barrettes de RAM, à savoir des DRAM de type FPM, EDO, ou SDRAM (Synchronous DRAM). De nos jours, les barrettes de RAM utilisent de la mémoire dite DDR (Double Data Rate), alors que les VRAM sont des RAM dites GDDR (Graphic Double Data Rate). Les deux se ressemblent beaucoup, mais il y a de petites différences techniques qui sont trop complexes pour être expliquées ici.

Quelques cartes graphiques ont utilisé de la mémoire non-DDR, comme les GPU Laguna de Cirrus Logic, qui utilisaient de la RDRAM de feu Rambus, une mémoire RAM qui a utilisée comme RAM système sur certains PC et sur la Nintendo 64. Citons aussi les cartes graphiques Matrox Millennium et ATI 3D Rage Pro, qui utilisaient de la Window DRAM (WRAM). Idem avec

Niveau performance, la GDDR se distingue de la DDR simple par une bande passante élevée, proche de la centaine de gigaoctets par secondes sur les GPU modernes. Mais il y a une contrepartie : un temps d'accès très long, de plusieurs centaines de cycles d'horloge. Concrètement, 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 GPU. 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.

Les GPU récents sont des monstres de bande passante et ce n'est pas qu'à cause de la GDDR. Une puce mémoire de DDR/GDDR peut lire/écrire 64 bits par cycle d'horloge. Mais les GPU connectent la GDDR de manière à additionner la bande passante de plusieurs puces. Cela implique que chaque puce de RAM a sa propre connexion au GPU, ce qui permet d’accéder à plusieurs puces en parallèle, en même temps. Les GPU actuels sont capables de lire/écrire 192, 256, 384, voire 512 bits par cycle d'horloge. Les techniques de dual channel permettent de faire la même chose avec la RAM système, mais n’atteignent que 128 bits par cycle - 192/256 bits avec des techniques de triple/quad channel. En clair, le bus mémoire de la GDDR permet de lire/écrire plus de données par cycle d'horloge qu'une RAM système, de 2 à 8 fois plus.

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

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, multithreading 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. À une exception près : l'usage de caches et de local stores.

Les transferts Direct Memory Access

[modifier | modifier le wikicode]

Pour échanger des données entre la RAM et la mémoire vidéo, les GPU utilisent la technologie Direct Memory Access, aussi appelée DMA. Elle permet à un périphérique de lire/écrire un bloc de mémoire RAM, sans intervention du processeur, par l'intermédiaire du bus PCI Express.

Pour cela, le GPU intègre un circuit dédié à la gestion des transferts DMA, appelé le contrôleur DMA, qui lit des données en RAM système pour les copier en RAM vidéo (ou inversement, mais c'est plus rare). Précisément, le contrôleur DMA copie un bloc de mémoire, de données consécutives en mémoire, par exemple un bloc de 128 mégaoctets, un bloc de 64 kiloctets, ou autre. Le processeur configure le contrôleur DMA en lui indiquant l'adresse de départ du bloc de mémoire, sa taille, et quelques informations annexes. Le contrôleur DMA lit alors les données une par une, et les écrit dans la mémoire vidéo.

La mémoire vidéo est mappée dans l'espace d'adressage du CPU

[modifier | modifier le wikicode]

Les GPU doivent souvent échanger des données avec le processeur. Des données doivent être copiées de la mémoire RAM vers la VRAM. De plus, le CPU peut aussi adresser directement la VRAM. Pour cela, une partie de l'espace d'adressage peut être détourné pour communiquer avec les périphériques, grâce à la technique des entrées-sorties mappées en mémoire. Et c'est ce qui est fait pour le GPU : une partie des adresses est détournée vers le GPU. Typiquement un bloc d'adresse de la même taille que la mémoire vidéo est détournée : elles n'adressent plus de la RAM, mais la directement la mémoire vidéo de la carte graphique. En clair, le processeur voit la mémoire vidéo et peut lire ou écrire dedans directement.

Espace d'adressage classique avec entrées-sorties mappées en mémoire

Intuitivement, on se dit que toute la mémoire vidéo est visible par le CPU, mais le bus PCI, AGP ou PCI Express ont leur mot à dire. Le bus PCI permettait au CPU d'adresser une fenêtre de 256 mégaoctets de VRAM maximum, en raison d’une sombre histoire de configuration des Base Address Registers (BARs). Les registres BAR étaient utilisés pour gérer les transferts DMA, mais aussi pour l'adressage direct.

Le PCI Express était aussi dans ce cas avant 2008. La gestion de la mémoire vidéo était alors difficile, mais on pouvait adresser plus de 256 mégaoctets, en déplaçant la fenêtre de 256 mégaoctets dans la mémoire vidéo. 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.

La mémoire unifiée

[modifier | modifier le wikicode]

Avec la mémoire unifiée, une partie de la RAM système est détournée pour servir de mémoire vidéo. La quantité d'adresses détournées 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. Les GPU modernes sont plus souples et fournissent deux réglages : une quantité de RAM vidéo minimale 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.

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

Les GPU intégrés

[modifier | modifier le wikicode]

Les GPU intégrés ou soudés passent par le bus AGP ou PCI Express pour lire des données en RAM système. Mais leur accès à la RAM passe par les mêmes voies que le CPU. Les GPU et le processeur sont reliés à la RAM par un bus dédié appelé le bus mémoire. Il existe même sans GPU intégré : tous les processeurs sont reliés à la RAM par un tel bus. Si le CPU intègre un IGPU, il greffe l'IGPU sur ce bus mémoire existant.

Un défaut de cette approche est que le débit du bus système est partagé entre le GPU et le processeur. En clair, si le bus mémoire peut transférer 20 gigas de données par secondes, il faudra partager ces 20 gigas/seconde entre CPU et GPU. Par exemple, le CPU aura droit à 15 gigas par secondes, le GPU seulement 5 gigas. Divers circuits d'arbitrage s'occupent de répartir équitablement le bus système, selon les besoins, mais ça reste un compromis imparfait.

Connexion du bus mémoire au CPU et à un GPU soudé sur la carte mère.

N'allez cependant pas croire que les GPU intégrés n'ont que des désavantages. Ils disposent d'un avantage bien spécifique : la zero-overhead copy, terme barbare mais qui cache une réalité très simple. Avec une mémoire dédiée, le processeur doit copier des données de la RAM système dans la RAM vidéo. Les copies en question se font souvent avant de démarrer le rendu 3D, par exemple lors du chargement d'un niveau dans un jeu vidéo. Elles peuvent aussi se faire lors du rendu, bien que ce soit plus rare. Et ces copies ont un cout en performance. Par contre, avec la mémoire unifié, pas besoin de faire de copie d'une mémoire à l'autre. Le processeur envoie juste une commande du GPU, qui indique l'adresse des données en RAM, leur position dans la RAM.

Le processeur et le GPU intègrent des mémoires caches, afin de réduire l'usage du bus mémoire. Et l'intégration des caches avec un GPU intégré est assez intéressante. Le CPU et l'IGPU peuvent partager certains caches. Par exemple, les processeurs Skylake et Sandy Bridge d'Intel, le CPU et GPU avaient leurs propres caches L2 et L1, mais ils partageaient le cache L3. En général, le partage ne touche que le cache de dernier niveau, le cache le plus gros et le plus proche de la mémoire, à savoir le cache L3 ou L4.

Sur les processeurs modernes, le CPU et le GPU ont leur propre cache L3/L4, afin avoir des caches plus spécialisés. Les caches L3/L4 sont plus gros pour le processeur que pour le GPU, ils vont à des fréquences différentes, sont alimentés par des tensions différentes, etc. De plus, le cache du CPU est optimisé pour un temps d'accès faible, alors que celui du GPU est optimisé pour un fort débit mémoire. Utiliser des caches séparés entraine des problèmes de cohérence des caches, qui ont été vus dans le chapitre sur les caches des processeurs de shaders.

Caches sur un iGPU

Les GPU dédiés avec mémoire unifiée

[modifier | modifier le wikicode]

Il existe des cartes dédiées qui utilisent pourtant la mémoire unifiée, par exemple l'Intel 740. Pour lire en mémoire RAM, elles doivent passer par l'intermédiaire du bus AGP, PCI ou PCI-Express. Et ce bus est très lent, bien plus que ne le serait une mémoire vidéo normale. Aussi, les performances sont exécrables. J'insiste sur le fait que l'on parle des cartes graphiques dédiées, mais pas des cartes graphiques soudées des consoles de jeu.

D'ailleurs, de telles cartes dédiées incorporent un framebuffer directement dans la carte graphique. Il n'y a pas le choix, le VDC de la carte graphique doit accéder à une mémoire suffisamment rapide pour alimenter l'écran. Ils ne peuvent pas prendre le risque d'aller lire la RAM, dont le temps de latence est élevé, et qui peut potentiellement être réservée par le processeur pendant l’affichage d'une image à l'écran.

La mémoire virtuelle des GPUs

[modifier | modifier le wikicode]

Pour commencer, parlons d'un point important, à savoir l'espace d'adressage du GPU. L'espace d'adressage d'un processeur est l'ensemble des adresses utilisables par le processeur. Par exemple, un processeur 16 bits peut adresser 2^16 = 65536 adresses, l'ensemble de ces adresses forme son espace d'adressage. L'espace d'adressage n'est pas toujours égal à la mémoire réellement installée. S'il n'y a pas assez de RAM installée, des adresses seront inoccupées.

L'espace d'adressage du CPU est quelque peu particulier. Depuis les années 80-90, les logiciels n'adressent pas la mémoire RAM directement. Tous les processeurs gèrent un système de mémoire virtuelle, qui donne l'illusion aux programmes d'avoir accès à toute la mémoire adressable, tout l'espace d'adressage. Par exemple, sur les systèmes 32 bits, chaque programme a accès à tout l'espace d'adressage de 4 gigaoctets, et il ne voit pas les autres programmes dedans. C'est une illusion qui a de nombreux avantages : les programmes sont isolés les uns des autres, un programme ne peut pas lire dans la mémoire d'un autre, les programmes n'ont pas à se soucier de la quantité de RAM installée dans l'ordinateur, etc.

Par contre, cela implique que les adresses manipulées par les programmes sont des adresses fictives, qui doivent être traduites en adresses physiques. Les données situées à une adresse dans l'espace d'adressage ne sont pas à la même adresse en RAM. On fait ainsi la distinction entre adresse physique et logique : les adresses logiques sont les fausses adresses, les adresses physiques sont les vraies adresses en RAM.

Rien de tout cela ne devrait vous surprendre si vous avez déjà lu un cours d'architecture des ordinateurs. Maintenant, passons à quelque chose auquel vous n'avez peut-être pas pensé : qu'en est-il du GPU ? La réponse est que non seulement cela dépend de si le GPU est dédié ou intégré, mais que cela dépend aussi de si le GPU est ancien ou non. Les anciens GPU ne géraient pas la mémoire virtuelle, les nouveaux ont leur propre système de mémoire virtuelle séparé du CPU !

L'abstraction mémoire des GPU

[modifier | modifier le wikicode]

Cependant, cela pose un léger problème : la communication avec les programmes et les API graphiques est perturbée. Le pilote de périphérique reçoit des adresses virtuelles de la part des applications, mais il doit gérer la mémoire vidéo en utilisant des adresses physiques. Et cela posait de nombreux problèmes pour les pilotes de GPU.

Le cas le plus facile à expliquer est celui des GPU dédiés. Le pilote de GPU alimente la mémoire vidéo avec des transferts DMA et le contrôleur DMA doit être configuré avec des adresses physiques pour faire son travail. Or, les API 3D leur donnaient des adresses virtuelles, pour localiser les textures, mesh, shaders compilés et autres. Et c'est à eux et au système d'exploitation de traduire ces adresses en adresses physiques. Des cas similaires apparaissent sur des GPU intégrés.

Sous Windows, du temps de l'ancien Windows Display Driver Model (WDDM), c'était le système d’exploitation qui gérait cela. Le WDDM déportait une partie de la gestion du GPU dans le noyau de l'OS, dont la gestion de la mémoire vidéo, vu que seul lui pouvait faire la traduction d'adresse (il a accès au tables des pages). Le pilote de GPU envoyait les commandes matérielles au noyau de l'OS, qui les patchait, en remplaçant les adresses virtuelles par des adresses physiques. Le pilote envoyait alors les commandes traduites au GPU.

Avec le WDDM 2.0, les GPU supportent maintenant la mémoire virtuelle. Cependant, il faut noter que les GPU utilisent une mémoire virtuelle séparée de celle du CPU. Le processeur et le GPU ont deux espaces d'adressage différents. Concrètement, une adresse virtuelle n'adresse pas la même adresse physique selon qu'elle est utilisée par le GPU. De même, la même adresse physique correspondra à des adresses virtuelles différentes entre CPU et GPU. Aussi, on parle de mémoire virtuelle GPU pour la distinguer de la mémoire virtuelle du processeur.

Séparation de la mémoire virtuelle du CPU et du GPU.

Même si la mémoire virtuelle du GPU n'est pas celle du CPU, cela simplifie grandement la gestion de la mémoire vidéo. Le pilote de GPU peut travailler directement avec des adresses virtuelles du GPU, et gérer la mémoire vidéo de lui-même, dans avoir à déléguer une partie du travail au noyau de l'OS. Plus besoin de traduire les adresses en adresses physiques, c'est le GPU qui s'en charge de lui-même ! D'ailleurs, cet avantage fait que la mémoire virtuelle GPU est aussi utilisée avec des GPU intégré, qui n'ont pas besoin de faire de copies entre RAM système et RAM vidéo.

Séparation de la mémoire virtuelle CPU et GPU, avec un GPU intégré.

Pour gérer la mémoire virtuelle, les GPU intègrent un circuit appelé la Graphics address remapping table, abrévié en GART. La GART est techniquement une une Memory Management Unit (MMU), à savoir un circuit spécialisé qui s'occupe de traduire les adresses virtuelles en adresses physiques, elle prend en charge la mémoire virtuelle. La dite MMU étant intégrée dans un périphérique d'entrée-sortie (IO), ici la carte graphique, elle est appelée une IO-MMU (Input Output-MMU). Toutes les cartes graphiques utilisant les bus AGP ou PCI-Express intégrent une GART/IO-MMU.

La GART est configurée par le pilote de GPU, afin de gérer la mémoire virtuelle au mieux. Et cela explique que la mémoire virtuelle du GPU et du CPU soient séparées. Le pilote de GPU coopére avec le système d'exploitation pour configurer la GART, mais cette coopération ne peut pas être parfaite. On ne peut pas permettre au pilote de GPU d'avoir accès à la table des pages ou d'autres structures impliquées dans la traduction d'adresse : le risque de sécurité est trop grand. Avoir deux mémoires virtuelles séparées aide grandement le travail du pilote de GPU.

Cependant, quelques technologies permette au CPU et au GPU d'utiliser le même espace d'adressage, de fusionner la mémoire virtuelle du CPU et du GPU. Par exemple, la technologie Heterogeneous System Architecture permet cela, entre autres fonctionnalités. Elles permettent de synchroniser la MMU du processeur et l'IO-MMU du GPU.

HSA-enabled virtual memory with distinct graphics card
HSA-enabled integrated graphics

La mémoire virtuelle des GPU dédiés

[modifier | modifier le wikicode]

Pour rappel, la mémoire virtuelle permet à un CPU d'utiliser plus de RAM qu'il n'y en a d'installée 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.

La mémoire virtuelle des GPUs dédiés fait la même chose, sauf que le surplus d'adresses n'est pas stockés sur le disque dur dans un fichier pagefile, mais est dans la 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. Cela permet au GPU d'adresser plus de RAM qu'en a la mémoire vidéo. Par exemple, si la carte vidéo a 2 giga-octets de RAM, la carte graphique peut être capable d'en adresser 8 : 2 gigas en RAM vidéo, et 6 autres gigas en RAM système. Le GPU peut lire directement des données en RAM, sans forcément les copier en mémoire vidéo.

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

La technologie s'est démocratisée avec le bus AGP, dont la fonctionnalité dite d'AGP texturing permettait de lire ou écrire directement dans la mémoire RAM, sans passer par le processeur. D'ailleurs, la carte graphique Intel i740 n'avait pas de mémoire vidéo et se débrouillait uniquement avec la mémoire système. C'est l'AGP qui a introduit le GART, la fameuse IO-MMU mentionnée 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 PCI-Express d'entrée de gamme pouvaient accéder à certaines portions de la mémoire RAM grâce à des technologies adaptées, comme le TurboCache de NVIDIA ou l'HyperMemory d'AMD. Mais la technologie s'est aujourd'hui étendue. De nos jours, toutes les cartes vidéos modernes utilisent la RAM système en plus de la mémoire vidéo, mais seulement en dernier recours, soit quand la mémoire vidéo est quasiment pleine, soit pour faciliter les échanges de données avec le processeur. C'est typiquement le pilote de la carte graphique qui décide ce qui va dans la mémoire vidéo et la mémoire système, et il fait au mieux de manière à avoir les performances optimales.

Les GPU utilisent la pagination

[modifier | modifier le wikicode]

Le GPU utilise la technique dite de la pagination, à savoir que la RAM système et la RAM du GPU sont découpées en pages de taille fixe, généralement 4 kilo-octets. Un GPU dédié échange des pages entre RAM système et mémoire vidéo. Lors d'un transfert DMA, la page en RAM système est copiée en mémoire vidéo. S'il n'y a pas assez de place en mémoire vidéo, le GPU rapatrie une page de mémoire vidéo vers la RAM système. La page rapatriée en RAM système est choisie par un algorithme spécialisé. Un GPU intégré n'a pas besoin de copie, il lit directement la page voulue en RAM système.

La traduction des adresses virtuelles en adresses physique se fait au niveau de la page. Une adresse est coupée en deux parts : un numéro de page, et la position de la donnée dans la page. La position dans la page ne change pas lors de la traduction d'adresse, mais le numéro de page est lui traduit. Le numéro de page virtuel est remplacé par un numéro de page physique lors de la traduction.

Pour remplacer le numéro de page virtuel en numéro physique, il faut utiliser une table de translation, appelée la table des pages, qui associe un numéro de page logique à un numéro de page physique. Le système d'exploitation dispose de sa table des pages, qui n'est pas accesible au GPU. Par contre, le GPU dispose d'une sorte de mini-table des pages, qui contient les associations page virtuelle-physique utiles pour traiter les commandes GPU, et rien d'autre. En clair, une sorte de sous-ensemble de la table des pages de l'OS, mais spécifique au GPU. La mini-table des pages est gérée par le pilote de périphérique, qui remplit la mini-table des pages. La mini-table des pages est mémorisée dans une mémoire intégrée au GPU, et précisément dans la MMU.

Le contrôleur DMA et l'IO-MMU des GPU dédiés

[modifier | modifier le wikicode]

Sur les GPU dédiés, le contrôleur DMA est souvent fusionné avec l'IO-MMU. En effet, il s'occupe de la copie des pages entre mémoire vidéo et RAM système. Le contrôleur DMA reçoit des adresses provenant du pilote de périphérique, qui sont des adresses virtuelles. Mais vu qu'il accède à la RAM système, il doit faire la traduction entre adresses virtuelles qu'il reçoit et adresses physiques qu'il émet, ce qui implique qu'il sert d'IO-MMU.

Même si la mémoire virtuelle GPU a été intégrée dans Windows assez tard, du temps de l'AGP et du PCI-Express, la technologie existait vraisemblablement sur certaines cartes graphiques au format PCI, même la documentation est assez rare. Seule certitude : la carte graphique NV1 de NVIDIA, leur toute première carte graphique, disposait d'un contrôleur DMA avec une IO-MMU intégrée. Le fonctionnement de cette IOMMU est décrite dans le brevet "US5758182A : DMA controller translates virtual I/O device address received directly from application program command to physical i/o device address of I/O device on device bus", des inventeurs David S. H. Rosenthal et Curtis Priem.

Microarchitecture du GPU NV1 de NVIDIA


Le rendu d'une scène 3D : l'API graphique

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

La description des API 3D les plus communes

[modifier | modifier le wikicode]

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

Les draw calls

[modifier | modifier le wikicode]

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

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

Le fait de rendre une image objet par objet permet de nombreuses optimisations. Par exemple, il peut utiliser une première passe pour dessiner les objets opaques, puis une seconde pour les objets transparents. L'avantage est que l'élimination des surfaces cachées fonctionne mieux. Comme autre exemple, le moteur de jeu peut trier les objets selon leur profondeur, afin de les rendre du plus proche au plus lointain. Pour les objets opaques, cela permet d'éliminer les surfaces cachées à la perfection: aucun triangle/pixel caché par un autre ne sera rendu. Mais trier les objets selon leur profondeur prend alors du temps CPU, qu'il faut comparer à ce qui est gagné sur le CPU.

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

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

Les render target

[modifier | modifier le wikicode]

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

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

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

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

Il faut noter que les API modernes permettent à un shader d'écrire dans plusieurs render-target. On parle alors de Multiple Render Targets, abrévié en MRT. Le MRT accélère fortement les techniques de rendu différé, qui enregistrent plusieurs images séparées, qui sont combinées par un pixel shader pour obtenir l'image finale.

L'intérêt initial était d'accélérer le calcul de l'éclairage par pixel. Sans rendu différé, avec les anciennes API graphiques, il fallait utiliser un draw call par objet et par source de lumière. Un objet éclairé par N sources de lumière demandait N draw call pour être éclairé. Avec le rendu différé, pas besoin. De plus, on garantit que le calcul de l'éclairage n'est pas réalisé sur des pixels invisibles, à savoir des calculer l'éclairage pour des triangles cachés par un objet opaque. Le désavantage est que la transparence n'est pas prise en charge, de même que l’antialiasing de type MSAA.

Le rendu différé demande deux passes de rendu. La première passe calcule tout, sauf le pixel shader, il n'y a pas de calculs d'éclairage par pixel. Elle enregistre son résultat dans plusieurs textures :un avec la couleur non-éclairée de chaque pixel, un autre pour la profondeur de chaque pixel (le tampon de profondeur), une texture contenant les normales de la surface pour chaque pixel, et éventuellement d'autres informations (couleur spéculaire, autres). Les textures sont ensuite utilisées par un pixel shader pour calculer l'image finale avec éclairage.

Il faut alors supporter des pseudo-framebuffer pour chaque "texture", appelés des G-buffer, pour gérer de telles techniques. De plus, le MRT optimise le rendu. Pas besoin de faire un draw call par G-buffer, chacun recalculant la géométrie. Avec le MRT, les différents G-buffer sont calculés en une seule passe, la géométrie n'est calculée qu'une seule fois.

G-buffer pour la couleur.
G-buffer pour la profondeur.
G-buffer pour les normales.
Image finale

Les render states et les Pipeline State Object

[modifier | modifier le wikicode]

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

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

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

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

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

Les commandes graphiques

[modifier | modifier le wikicode]

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

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

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

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

Les optimisations liées aux draw calls

[modifier | modifier le wikicode]

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

Une première optimisation regroupe les objets avec le même render state ensemble. Sans cette optimisation, le moteur graphique met à jour le render state à chaque fois qu'il rend un objet. Avec cette optimisation, il met à jour le render state plus rarement. Par contre, le moteur graphique dépense du temps et de la puissance de calcul pour faire le tri. Il y a donc un compromis pas évident, qui ne vaudrait pas souvent le coup. Cependant, cette optimisation débloque d'autres optimisations très importantes, qui permettent de réduire le nombre de draw calls.

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

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

Diverses optimisations permettent de faciliter le batching. L'idée est de rendre les différents render state plus similaires que la normale. Une optimisation de ce type est l'usage d'atlas de textures. Un atlas de texture regroupe plusieurs textures en une seule texture. Deux objets avec les mêmes shaders et les mêmes options de configuration, peuvent ainsi partager le même render state quand ils adressent le même atlas de texture et non exactement les mêmes textures.

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

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

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

Le pipeline graphique

[modifier | modifier le wikicode]

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

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

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

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

Pipeline d'OpenGL 1.0

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

D3D Pipeline
Pipeline de D3D 11

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

[modifier | modifier le wikicode]

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

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

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

Les API imposent des contraintes sur le matériel

[modifier | modifier le wikicode]

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

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

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

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

Le pilote de carte graphique

[modifier | modifier le wikicode]

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

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

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

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

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

[modifier | modifier le wikicode]

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

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

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

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

La compilation des shaders

[modifier | modifier le wikicode]

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

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

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

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


Le processeur de commandes

Pour rappel, les API 3D envoient des commandes graphiques standardisées au pilote de la carte graphique. Le pilote de périphérique transforme alors ces commandes graphiques, que le GPU ne peut pas comprendre, en commandes matérielles que le GPU connait. Et ce sont ces commandes matérielles qui sont exécutées par le GPU.

Dans les grandes lignes, on peut classer ces commandes matérielles en quelques types principaux : celles pour le rendu 3D, celles pour le rendu 2D, celles pour le décodage/encodage vidéo, celles pour le GPGPU, et les transferts DMA. Pour ce qui est du rendu 3D, les commandes matérielles ressemblent aux commandes graphiques de l'API, mais elles restent cependant différentes. Leur encodage est différent, elles ne font pas exactement la même chose, etc. Pour donner un exemple de commandes matérielles, 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

La gestion des commandes matérielle est le rôle du processeur de commande. Il s'occupe d'exécuter les commandes, en répartissant le travail sur les processeurs de shaders, en configurant les circuits fixes, en configurant les contrôleurs DMA, et bien d'autres choses.

L'architecture interne du processeur de commande

[modifier | modifier le wikicode]

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. Certains GPU utilisent parfois plusieurs microcontrôleurs séparés, avec souvent une séparation du processeur de commande en deux, voyons pourquoi.

Le processeur de commande a deux rôles : récupérer les commandes depuis la mémoire RAM, les exécuter. Pour rappel, les commandes matérielles sont accumulées dans une file de commande, en mémoire RAM ou en mémoire vidéo. Le processeur de commande lit la commande la plus ancienne dans la file de commande, puis l’exécute. Les deux fonctions sont souvent prises en charge par deux processeurs séparés. Le premier récupére les commandes dans la file de commandes et les envoie au second. Le second exécute les commandes, configure les circuits fixes du GPU, envoie les shaders sur les processeurs de shaders, et autres.

Il y a souvent une mémoire FIFO entre les deux, afin d'assouplir les transferts de données entre les deux processeurs de commande. L'intérêt est que le premier processeur de commande peut précharger à l'avance des commandes. Au lieu d'envoyer le commandes une par une au second processeur de commande, elle lui envoie à l'avance des commandes prêtes et les accumule dans la FIFO. Le second processeur de commande est alors constamment alimenté, les situations où il attend le premier processeur sont réduites.

Le second processeur de commande a plusieurs fonctionnalités : configurer les contrôleurs DMA intégrés au GPU, répartir les shaders sur les processeurs de shaders, gérer les commandes de changement d'état et de synchronisation (barrières et sémaphores), configurer les circuits fixes comme l’input assembler ou les ROP. Notamment, il répartit les shaders sur les processeurs de shaders, en faisant en sorte qu'ils soient utilisés le plus possible. Pour simplifier, il lance les shaders et gére le DMA.

Sur les anciens GPU, le processeur de commande cadencait le flot des données dans le pipeline graphique. 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 et les étages précédents sont potentiellement bloqués. 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.

La gestion du render state

[modifier | modifier le wikicode]

S'il y a une différence entre les commandes matérielles et les commandes graphiques, il en est de même pour le render state. Le GPU a bien besoin de mémoriser des informations de configuration, quelles textures utiliser, quels shaders exécuter, etc. Mais autant l'API regroupe le tout dans un render state unique, autant ce n'est pas forcément le cas sur le GPU.

La manière la plus simple mémorise le render state tel quel, dans divers registres de configuration, dispersés dans le GPU, souvent dans les unités non-programmables. Les unités de texture mémorisent les textures à rendre, ainsi que les options de filtrage de texture. Les ROP mémorisent les options de résolution, d'antialiasing et celles pour le tampon de profondeur. Et ainsi de suite. Tout changement dans le render state correspondra à une commande matérielle qui modifiera le registre adéquat. Changer de shader ou de texture demande de modifier un registre qui pointe vers la texture ou le shader, cela revient à modifier un pointeur mémorisé dans un registre.

Mais les GPU modernes ne font pas cela à la lettre. Des informations ne sont pas mémorisées dans des registres statiques et sont transmises dans le pipeline, d'étage en étage. Par exemple, les unité de textures sont totalement bindless : le shader leur envoie l'adresse de la texture à utiliser à chaque lecture, elles n'ont pas besoin de la mémoriser dans un registre. En clair, changer de texture changera le render state, mais n'impliquera pas de commande de changement d'état.

L'exécution parallèle des commandes

[modifier | modifier le wikicode]

Un processeur de commande basique exécute les commandes l'une après l'autre. Mais un processeur de commande évolué peut parfois exécuter plusieurs commandes en même temps. Nous verrons dans le chapitre suivant comment une telle sorcellerie est possible pour les commandes de rendu 3D. Mais n'oublions pas les autres commandes qui n'ont rien à voir avec le rendu 3D : commandes de rendu 2D, de calcul GPGPU, de décodage vidéo, ou des transferts DMA.

Il est possible d'exécuter des commandes de type différent, sous certaines conditions. Par exemple, exécuter un transfert DMA pendant que le GPU fait un rendu 2D ou des calculs GPGPU. Mais il faut pour cela que les deux commandes n'utilisent pas les mêmes circuits. Le rendu 2D et 3D accèdent tous deux au framebuffer, ce qui fait qu'on ne peut pas lancer une commande 2D et une commande 3D en même temps, du moins sans les optimisations qu'on verra dans le prochain chapitre. Il en est de même avec le GPGPU, le rendu 2D et le rendu 3D, qui utilisent tous les trois les processeurs de shaders.

Par contre, les processeurs de shader peuvent traiter une commande 3D, alors qu'une commande de transfert DMA est en cours dans le contrôleur DMA. Les deux sont traités dans des circuits séparés, si on omet le fait que les deux adressent la VRAM, mais les deux peuvent se partager la bande passante mémoire naturellement, sans intervention du processeur de commande. La seule contrainte est que le transfert DMA et la commande 3D n'utilisent pas les mêmes zones de mémoire. En clair, il ne faut pas faire de transfert DMA dans un tampon de sommet si celui-ci est en cours d'utilisation. Mais le pilote de périphérique peut détecter ce genre de cas et le processeur de commande peut bloquer l'exécution de la seconde commande si besoin.

La synchronisation avec le processeur

[modifier | modifier le wikicode]

Il arrive que le processeur doive savoir où en est le traitement des commandes, ce qui est très utile pour la gestion de la mémoire vidéo. Par exemple, 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, une fonctionnalité de communication entre CPU et périphérique, qui sont gérées par le pilote du GPU. 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".

L'exemple du processeur de commande du NV1

[modifier | modifier le wikicode]

Pour donner un exemple, prenons la première carte graphique de NVIDIA, le NV1. Il s'agissait d'une carte multimédia qui incorporait non seulement une carte 2D/3D, mais aussi une carte son un contrôleur de disque et de quoi communiquer avec des manettes.

Il utilisait une mémoire FIFO dédiée aux commandes matérielles. Le processeur pouvait ainsi envoyer plusieurs commandes au GPU, en une seule transaction. Il y avait donc une copie de la file de commandes vers cette FIFO. Le GPU exécutait alors les commandes une par une. Le processeur pouvait consulter la FIFO pour savoir s'il restait des entrées libres et combien. Le processeur savait ainsi combien d'écritures il pouvait envoyer en une fois au GPU.

Le fonctionnement de la FIFO du NV1 est décrit dans le brevet US5805930A : System for FIFO informing the availability of stages to store commands which include data and virtual address sent directly from application programs.

En plus de cette FIFO de commandes, il incorporait un contrôleur DMA pour échanger des données entre RAM système et les autres circuits. Lorsque le processeur voulait copier des données en mémoire vidéo, il envoyait une commande de copie, qui était stockée dans la FIFO de commande, puis était exécutée par le processeur de commande. Le processeur de commande envoyait alors les ordres adéquats au contrôleur DMA, qui faisait la copie des données.

Microarchitecture du GPU NV1 de NVIDIA

Le NVIDIA NV1 avait diverses optimisations pour supporter plusieurs applications à la fois. L'une d'entre elle est le support de plusieurs tampons de commande. La carte graphique gérait en tout 128 tampons de commandes, chacun contenant 32 commandes consécutives. Le tout permettait à 128 logiciels différents d'avoir chacun son propre tampon de commande. L'implémentation du NV1 utilisait en réalité une FIFO unique, dans une mémoire RAM unique, qui était segmentée en 128 sous-FIFOs. Mais il est techniquement possible d'utiliser plusieurs FIFOs séparées connectées à un multiplexeur en sortie et un démultiplexeur en entrée.

Le NV1 utilisait des adresses de 23 bits, ce qui fait 8 méga-octets de RAM. Les 8 méga-octets étaient découpées en 128 blocs de mémoire consécutifs, chacun étant associé à une application et faisant 64 kilo-octets. Les adresses de 23 bits étaient donc découpées en une portion de 7 bit pour identifier le logiciel qui envoie la commande, et une portion de 16 bits pour identifier la position des données dans le bloc de RAM. Une entrée dans la FIFO du NV1 faisait 48 bits, contenant une donnée de 32 bits et les 16 bits de l'adresse.


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

Un GPU plusieurs processeurs de shaders, chacun traitant plusieurs sommets/pixels à la fois. La répartition du travail sur plusieurs processeurs de shaders est un vrai défi sur les cartes graphiques actuelles. La répartition du travail sur plusieurs processeurs de shaders est le fait du processeur de commande, un circuit de la carte graphique. Ce n'est pas son seul rôle, mais c'est clairement une fonctionnalité très importante que prend en charge le processeur de commande.

La répartition du travail pour le GPGPU

[modifier | modifier le wikicode]

Avant de voir ce qu'il en est pour le rendu 3D, nous allons faire un détour par les fonctionnalités dites de GPGPU. Outre le rendu 3D, les cartes graphiques modernes sont utilisées pour accélérer des calculs scientifiques, tout ce qui implique des réseaux de neurones, de l'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é.. L'usage d'une carte graphique pour autre chose que le rendu 3D porte le nom de GPGPU (General Processing GPU). En soi, le GPGPU est assez logique : les processeurs de shaders, bien que conçus avec le rendu 3D en tête, n'en restent pas moins des processeurs multicœurs SIMD/VLIW assez puissants.

Si nous voyons le GPGPU avant le rendu 3D, c'est pour une raison simple : la répartition du travail sur les processeurs de shaders est alors nettement plus simple. En GPGPU, les shaders font des calculs génériques, à savoir qu'ils ne travaillent pas sur des pixels ou des vertices. Ils n'ont donc pas à communiquer avec le rastériseur ou l'input assembler, ils ne lisent même pas de textures dans la RAM. Les processeurs de shaders communiquent seulement avec la mémoire vidéo, mais pas avec le moindre circuit fixe. La répartition du travail en GPGPU est donc beaucoup plus simple qu'en mode graphique, le processeur de commande a nettement moins de travail.

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

[modifier | modifier le wikicode]

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, le bleu et le rouge correspondant à des circuits de calcul différents. La hiérarchie mémoire est indiquée en vert. Le tout est alimenté par un processeur de commande, en jaune, ici appelé le Thread Execution Control Unit.

Ce schéma illustre l'architecture d'un GPU en utilisant la terminologie NVIDIA. Comme on le voit, la carte graphique contient plusieurs cœurs de processeur distincts, ayant chacun plusieurs unités de calcul appelées malencontreusement "processeurs de threads". Ces cœurs sont alimentés en instructions par le processeur de commandes, ici appelé Thread Execution Control Unit, qui répartit les différents shaders sur chaque cœur. Enfin, on voit que chaque cœur a accès à une mémoire locale dédiée, en plus d'une mémoire vidéo partagée entre tous les cœurs.

En GPGPU, un shader s'exécute sur des regroupements de données bien connus des programmeurs : des tableaux. Pour rappel, un tableau est un ensemble d'entiers ou de flottants qui sont consécutifs en mémoire RAM. Les tableaux peuvent être de simples tableaux, des matrices, peu importe. Le processeur envoie à la carte graphique un shader à exécuter et les 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.

Le processus de répartition du travail est globalement le suivant. Le processeur de commande reçoit une commande de calcul GPGPU, 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 découpe les tableaux en vecteurs de taille fixe, qu'un processeur de shader 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.

Cependant, il faut tenir compte que les processeurs de shader sont multithréadés. Ils peuvent gérer plusieurs threads, qui sont exécutés selon les besoins. Si un thread est bloqué par un accès mémoire, un autre thread prend la relève. Un processeur de shader permet d'exécuter "en même temps" entre 8 et 64 threads. La conséquence que l'on peut envoyer plusieurs morceaux de tableau sur un processeur de shader. Chaque morceau de tableau est combiné avec un shader pour former un thread, et l'on envoie 8 à 64 de ces threads sur un même processeur de shader.

Pour résumer, on découpe le travail en morceaux de taille identique, qu'on envoie à chaque processeur de shader. 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.

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 scalaire, 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, mais ce n'est certainement pas ce qui se passe.

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

[modifier | modifier le wikicode]

Pour les GPU NVIDIA, le processus de découpage n'est pas très bien connu. Mais on peut en avoir une idée en regardant l'interface logicielle utilisée pour le GPGPU. Chez NVIDIA, celle-ci s'appelle CUDA et ses versions donnent une idée de comment le découpage s'effectue.

Premièrement, les shaders sont appelés des kernels. Les tableaux de taille variable sont appelés des grids. Les données individuelles sont appelées, de manière extrêmement trompeuse, des threads. Aussi, pour éviter toute confusion, je vais renommer les threads 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, 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 sa position dans le tableau, qui est calculé par le processeur de commande. Le calcul de l'indice peut se faire de différentes manières, suivant que le tableau soit un tableau unidimensionnel (une suite de nombre) ou bidimensionnel (une matrice). CUDA gère les deux cas et les dernières cartes graphiques gèrent aussi des tableaux à trois dimensions. Le calcul de l'adresse d'un scalaire se fait en prenant l'adresse de départ du bloc de scalaire, et la combinant avec les indices.

Découpage des tableaux avec CUDA.

Les processeurs de shader sont appelés des streaming multiprocessor, terme encore une fois trompeur. Une fois lancé sur un processeur de shader, le shader lit un bloc de scalaire et l'utilise pour faire ses calculs. Il y reste définitivement : il ne peut pas migrer sur un autre processeur en cours d'exécution. Un processeur de shader peut exécuter plusieurs instances de shaders travaillant sur des bloc de scalaires différents. Dans le meilleur des cas, il peut traiter en parallèle environ 8 à 16 bloc de scalaires différents en même temps.

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

Le bloc de scalaires est découpé en vecteurs d'environ 32 entiers/flottants, appelés des warps dans la terminologie NVIDIA/CUDA, des wavefronts dans la terminologie AMD. Avec 512/1024 éléments par bloc de scalaire, découpé en warps de 32, cela donne 16 à 32 warps suivant la version de CUDA utilisée. Le découpage en warps est encore une fois le fait du processeur de commande.

Le terme warp est aussi utilisé pour décrire l'instance du shader qui fait des calculs avec un warp. 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. Les 16 à 32 warps sont exécutés en même temps sur le processeur de shader, via multithreading matériel, à savoir que le processeur les exécute à tour de rôle. Un warp exécute des instructions SIMD sur des vecteurs de 32 éléments, donc de taille fixe.

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 répartition avec une file de threads

[modifier | modifier le wikicode]

Pour résumer, les tableaux sont découpés en morceaux et chaque morceau est combiné avec un shader pour former un thread. reste à répartir ces threads sur les processeurs de shaders. La répartition la plus simple est la suivante : le premier morceau va dans le premier processeur de shader, le second morceau va dans le second processeur de shader, etc. En clair, un simple algorithme du tourniquet. Si elle été utilisée sur les anciens GPU, ce n'est pas celle qui est utilisée aujourd'hui. A la place, la répartition demande une coopération entre le processeur de commande et les processeurs de shaders.

Les GPU modernes incorporent une file de threads, entre le processeur de commande et les processeurs de shaders, qui sert de file d'attente. Le processeur de commande découpe les tableaux en threads, qui sont ajoutés dans cette file d'attente. Les processeurs de shader récupèrent ensuite les threads disponibles dans cette file d'attente. Le processeur de commande remplit la file de thread tant que celle-ci n'est pas pleine. Et la file de thread se vide quand un processeur de shader est libre, qu'il a finit de calculer le thread précédent. La répartition est alors dynamique et exploite au mieux les processeurs de shader.

Prenons l'exemple d'un GPU avec une file de thread capable de mémoriser 32 threads, et 16 processeurs de shader. Soumettons un tableau de 24 éléments. Le processeur de commande remplit la file de thread avec 16 threads. Les processeurs de shader lisent alors chacun un thread, ce qui fait que la file d'attente se vide de 16 threads, il n'en reste que 8. Une fois qu'un shader a finit son travail, il lit un thread dans la file d'attente, ce qui fait qu'elle se vide un thread après l'autre.

L'exécution simultanée des commandes

[modifier | modifier le wikicode]

Maintenant, regardons ce qui se passe quand on envoie deux commandes successives. Prenons deux commandes de 24 threads chacune, avec 16 processeurs de shaders. Pour simplifier, les processeurs de shader vont d'abord exécuter les 16 threads de la première commande, et on va supposer qu'ils vont tous se terminer quasiment en même temps. Le processeur de commande remplit alors la file de commande : en plus des 8 threads restants, il rajoute 8 threads provenant de la commande suivante. Les processeurs de shader exécutent alors les 16 commandes dans la file : la moitié vient de la première commande, l'autre vient de la seconde commande.

En clair, la file de thread permet une forme de "pipeline", un terme connu de ceux qui ont déjà lu un cours d'architecture des ordinateurs. L'idée est que l'on peut lancer une nouvelle commande alors que la précédente n'est pas terminée. Il s'agit de l'idée générale, mais les détails peuvent être assez surprenants. Par exemple, si on veut exécuter pleins de commandes très petites, elles peuvent s'exécuter en parallèle dans des processeurs de shader séparés. Par exemple, avec 32 processeurs de shader, vous pouvez exécuter 16 commandes de un thread chacune. Ou encore, une commande lancée récemment peut se terminer avant une commande plus ancienne. Bref : la file de thread permet de lancer plusieurs commandes l'une après l'autre, mais elles peuvent s’exécuter en même temps et se terminer dans le désordre.

Tout fonctionne à la perfection tant que les commandes de calcul sont indépendantes. Mais il arrive qu'une commande prenne en entrée le résultat d'une commande précédente. Dans ce cas, lancer les deux commandes en même temps peut poser problème. La seconde commande peut alors tenter de lire un résultat qui n'a pas encore été calculé. Et il ne s'agit là que d'un cas particulier, mais de nombreuses dépendances assez complexes entre commandes existent. De telles dépendances imposent que la première soit intégralement terminée avant que la seconde démarre. On parle alors de partial pipeline flush.

Pour gérer cela, le processeur de commande supporte des commandes de synchronisation. Les plus simples, les commandes FLUSH empêchent le démarrage d'une commande tant que les précédentes sont en cours. Elles empêchent le processeur de commande d'ajouter des threads dans la file de thread, du moins tant que celle-ci n'est pas entièrement vide, et aussi tant que les processeurs de shader sont occupés. Ces commandes de synchronisation sont aussi appelées des barrières GPU. le terme indique bien qu'elle séparent le flux de commandes en deux.

Les barrières GPU ont un cout en performance, car elles empêchent d'exécuter plusieurs commandes en même temps. Mais elles sont nécessaires. Par exemple, reprenons l'exemple de deux commandes de 24 threads chacune, séparées par une barrière GPU, toujours avec 16 processeurs de shader. Le GPU va d'abord exécuter les 16 premiers threads. Puis, il va exécuter les 8 threads restants de la première commande, mais pas plus. La barrière GPU l'empeche d'exécuter les threads de la commande suivante. C'est seulement ensuite qu'il lancera les 16 threads de la seconde commande, puis les 8 restants. Cela a pris plus de temps que d'exécuter les deux commandes en même temps.

D'autres barrières GPU sont plus précises. Elles empêchent le démarrage d'une nouvelle commande tant que la commande précédente n'a pas atteint un certain stade de son exécution. Elles permettent de gagner en performance en démarrant la commande suivante au plus tôt. De telles commandes sont des commandes du style "attend que le registre de statut numéro N contienne la valeur adéquate avant de démarrer la commande suivante". Une alternative réserve une adresse mémoire, dans laquelle la commande précédente écrit une valeur prédéterminée pour dire qu'elle a finit.

La répartition du travail pour le rendu graphique

[modifier | modifier le wikicode]

Après avoir vu le cas du GPGPU, nous allons voir le cas du rendu 3D. La différence est que les commandes GPGPU sont remplacées par des commandes 3D, qui demande d'afficher un objet. Du point de vue de l'API 3D, elles correspondent grossièrement soit à un draw call, soit à un changement de render state. Aussi, nous ferrons parfois la confusion entre les deux, bien que ce soit techniquement une grosse simplification, plus proche de l'erreur ou de la confusion que de l'abus de langage.

Les commandes graphiques

[modifier | modifier le wikicode]

Les commandes graphiques sont différentes des commandes de calcul, mais elles fonctionnent sur le même principe. La différence est qu'une commande graphique demande d'afficher un objet 3D, les draw call affichant une image objet par objet. Une commande graphique contient bien un tableau de données : un tableau de triangle qui correspond à l'objet à afficher. Cependant, elle contient aussi une liste de texture et une liste de shaders. Le tableau de triangles est découpé en threads qui sont envoyés aux processeurs de shaders.

Dans les GPU rudimentaires, le processeur de commande exécute les commandes 3D dans l'ordre, une par une. Il attend qu'une commande soit terminée avant de démarrer la suivante. Il s'agit d'une technique simple, conservatrice, mais pas forcément très performante. Elle était surtout utilisée sur les GPU non-programmables, avant l'invention des shaders. Sur les GPU modernes, les choses sont très différentes.

Plus haut, on a vu que le GPU peut exécuter plusieurs commandes de calcul consécutives en même temps. Et il se trouve que c'est la même chose avec les commandes de rendu 3D/2D. Par exemple, si une commande simple n'utilise que 3 processeurs de shaders sur 8, la commande suivante peut être lancée pour occuper les 5 processeurs de shader restants. 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é.

Concrétement, du point de vue de l'API graphique, le GPU peut exécuter plusieurs draw call en même temps. Pire que ca, un draw call lancé après un autre peut finir avant ! Les draw call sont donc traités dans un désordre tout relatif, mais loin de ressembler à un ordre strict. Et la conséquence, c'est que les problèmes de dépendances vus plus haut reviennent.

Par exemple, imaginez qu'un jeu écrive une shadowmap dans une texture, puis l'utilise dans un algorithme d'éclairage. Une première commande calcule et écrit la shadowmap, une seconde commande exécute l'algorithme d'éclairage. Il est interdit de démarrer la seconde commande tant que la première commande n'a pas calculé son résultat, la shadowmap n'est pas encore prête. Et une barrière GPU est alors nécessaire. Elle est ajoutée par le driver, ou par le programmeur s'il utilise une API 3D récente (DirectX 12, Vulkan).

Un autre exemple survient quand deux draw calls consécutifs utilisent des render state différents. Dans ce cas, le GPU voit deux commandes de rendu, avec une commande de changement d'état entre les deux. La commande de changement d'état fait alors deux choses : le changement d'état, mais aussi une barrière GPU. Concrètement, le processeur de commande ne démarre le second draw call que quand le changement d'état est terminé.

La répartition entre pixel shaders et vertex shaders

[modifier | modifier le wikicode]

Avant de poursuivre, faisons une première remarque. Les premiers GPU avaient des processeurs séparés pour les vertex shaders et les pixel shaders. Pour donner un exemple, la Geforce 6800 avait 16 processeurs pour les pixel shaderset 6 processeurs pour les vertex shaders. La raison est que DirectX 9.0 et OpenGL avaient des jeux d'instruction différents pour les vertex et pixel shaders. Par exemple, les pixels shaders doivent accéder aux textures, pas les vertex shaders. Les GPU de l'époque ont beaucoup plus de processeurs de pixel shaders que de processeurs de vertex shaders, en raison du phénomène d'amplification mentionné plus haut.

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

Un détail important est qu'un triangle est affiché sur un ou 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. La conséquence est qu'il y a plus de travail à faire sur les pixels que sur les sommets. C'est un phénomène d'amplification, qui explique qu'il y a plus de processeurs pour les pixel shaders que pour les vertex shaders.

Un problème de ces GPU est que la répartition entre puissance entre vertex et pixel shaders est fixe. Mais 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. La répartition idéale est variable d'un jeu vidéo à l'autre, voire d'un niveau de JV à l'autre, et ces GPU en étaient loin.

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

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.
Carte 3D avec pixels et vertex shaders unfifiés.

La répartition du travail n'est pas exactement la même dans les deux cas, mais les grandes lignes sont les mêmes. Dans le cas le plus simple, le processeur de commande regarde s'il y a des processeurs de shaders libres. Si c'est le cas, il leur envoie un nouveau thread pour calculer de nouveaux triangles. Il y a bien des problématiques liées à la présence de linput assembler, mais pas plus. Plus complexe, il peut y avoir une file de threads, que le processeur de commande remplit au mieux.

Les GPU implémentent un parallélisme de type Fork/join

[modifier | modifier le wikicode]

La répartition des sommets sur les processeurs de shaders n'est pas un problème. Le tableau de triangles est découpé en threads qui sont envoyés aux processeurs de shaders (remplacer par les unités de T&L sur les anciens GPU). Il faut noter que le découpage du tableau de triangle en threads est le fait de l'input assembler, pas du processeur de commande. Mais tout ce qui se passe après est plus complexe. Notamment, le rastériseur et les ROPs sont des points de convergence où convergent plusieurs sommets/pixels.

Parallélisme dans une carte 3D

Pour gérer cette convergence, les cartes graphiques mettent en attente les sommets/pixels dans des mémoire FIFO, situées un peu partout dans le pipeline. Elles ont le même rôle que la file de thread des commandes de calcul, sauf qu'elles mémorisent des blocs de pixels/triangles.

La FIFO la plus proche d'une file de thread est celle située en sortie de l'input assembler, ce qui colle bien avec le fait que c'est lui qui découpe le tableau de triangle en threads. Il y aussi une FIFO en entrée et en sortie du rastériseur. Celle en entrée accumule des sommets et les regroupe en triangles (c'est la phase d'assemblage de primitive qu'on détaillera dans quelques chapitres). Celle en sortie contient des pixels à destination des pixels shaders. Enfin, il y a une FIFO en entrée des ROP qui accumule des pixels à enregistrer en mémoire.

Les processeurs de shaders lisent dans ces mémoires FIFO pour récupérer du travail à faire. La présence des mémoires FIFO désynchronise les é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, la première étape n'a pas à attendre que la seconde soit disponible pour lui envoyer des données. Sauf si la mémoire tampon est pleine, car elle ne peut plus accepter de nouveaux sommet/pixels, évidemment.

Intuitivement, on penserait que les FIFO sont utilisées de la même manière que la file de thread, à savoir que tout processeur de shader libre peut récupérer des pixels dedans. Par exemple, un processeur de pixel shader peut lire dans la FIFO en sortie du rastériseur, un processeur de vertex shader peut lire dans celle en sortie de l'input assembler, etc. Avec des shaders unifié, un processeur de shader peut lire les deux FIFOs précédentes. Une telle distribution dynamique permet 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.

Mais l'implémentation est cependant assez compliquée, pour diverses raisons. Par exemple, il faut prendre en compte le cas où plusieurs processeurs de shaders sont libre : les deux processeurs ne doivent pas récupérer le même thread, mais deux threads différents. Pour cela, il est possible d'utiliser une mémoire FIFO multiport. Une autre solution couple la mémoire FIFO à 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. Et tout cela demande beaucoup de circuits...

Dispatch des shaders sur plusieurs processeurs de shaders

Cependant, d'autres solutions permettent de fortement simplifier le design du GPU. La distribution statique envoie chaque sommet/pixel 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 position à 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.

Les GPU modernes mélangent distribution statique et dynamique. La raison est qu'ils disposent de plusieurs rastériseurs et de plusieurs ROP, pour ces questions de performance. Un GPU moderne est organisé en plusieurs Graphic Processing Units (terminologie NVIDIA), que nous abrévierons en GPC. Ils regroupent chacun : plusieurs processeurs de shaders, un rastériseur et un ROP. Les processeurs de shaders envoient leurs résultats au ROP et au rasteriseur dans le GPC, pas à ceux dans un autre GPC, ce qui est de la distribution statique. Par contre, le rastériseur utilise la distribution dynamique pour distribuer le travail sur les processeurs de shaders.

Graphic card architecture with unified shaders

L'exécution de commandes différentes : graphiques et GPGPU

[modifier | modifier le wikicode]

Les GPU modernes disposent de plusieurs processeurs de commandes, et donc de plusieurs files de commande. Et les chiffres peuvent monter assez haut. Par exemple, les GPU AMD utilisent un processeur de commande graphique, accompagné de plusieurs processeurs de commande dédiés 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. Et chacun gérait plusieurs files de commandes séparées ! Par exemple, les GPU AMD d'architecture GCN ont 8 processeurs de commande GPGPU pour 64 files de commandes GPGPU, auxquels il faut ajouter le processeur pour les commandes graphiques en plus ! Les GPU NVIDIA d'architecture Pascal disposent eux de 32 files de commande matérielles, mais dont 31 sont réservées aux GPGPU.

GCN command processing

Mais quelle est l'intérêt ? Pour le dire vite : remplir des processeurs de shader inutilisés. Il arrive que des processeurs de shaders soient inutilisés, soit parce que la commande en cours d'exécution n'en a pas besoin. Il est théoriquement possible de les remplir avec des threads provenant de la commande suivante. Cependant, ce n'est pas toujours possible. Par exemple, il se peut que la commande suivante soit une barrière GPU, qui bloque l'avancée des commandes suivantes. Ou encore, que la file de commande soit vide, car les commandes suivantes ne sont pas encore arrivées.

Une solution serait alors de chercher des commandes ailleurs, et de préférence des commandes capables de remplir des processeurs de shader. Mais où les trouver ?

L'exécution simultanée de plusieurs applications sur le GPU

[modifier | modifier le wikicode]

La solution la plus simple est assez évidente, quand on se rappelle du chapitre sur les API 3D, et notamment de la section de fin sur le partage du GPU entre plusieurs applications. Un GPU est aujourd'hui sollicité par plusieurs logiciels en même temps : il est possible de lancer un jeu vidéo en fenêtré, pendant que OBS ou un logiciel de Streaming capture l'écran, avec Firefox et Discord et un programme de cloud computing de type Folding@Home qui tournent en arrière-plan. Et toutes ces applications sont accélérées par le GPU.

Des situations de ce genre, où on doit partager le GPU entre plusieurs applications, sont assez courantes. Et c'est soit le pilote qui s'en charge, soit le GPU lui-même.

  • L'arbitrage logiciel est la méthode la plus simple : tout est fait en logiciel. Concrètement, le système d'exploitation et/ou le pilote de la carte graphique se chargent du partage du GPU. Dans les deux cas, tout est fait en logiciel.
  • L'arbitrage matériel délègue ce partage au GPU. Du moins en partie, car le système d'exploitation détermine quelle application a la priorité. L'avantage est que cela décharge le processeur d'une tâche assez lourde en calcul, pour la déporter sur le GPU. Sans compter que les mécanismes matériels d'arbitrage sont plus efficaces.

Sur Windows, avant l'arrivée du modèle de driver dit Windows Display Driver Model, il n'y avait pas d'arbitrage entre les applications. Il y avait une file de commande unique et les commandes étaient exécutées en mode "premier entré, premier sorti". Et ce n'était pas un problème car les seules applications qui utilisaient le GPU étaient des jeux vidéos fonctionnant en mode plein écran. Avec le modèle de driver Windows Display Driver Model (WDDM), l'arbitrage logiciel est apparu. Depuis 2020, avec l'arrivée de la Windows 10 May 2020 update, Windows supporte l'arbitrage matériel.

L'arbitrage matériel est implémenté grâce à la présence de plusieurs processeurs de commandes. L'idée est d'attribuer des priorités à chaque processeurs de commande. Et plus un processeur de commande est prioritaire, plus le GPU lui réserve de processeurs de shaders. Prenons l'exemple avec deux processeurs de commande, et donc deux files de thread, et 16 processeurs de shaders. Si la première file de thread a la priorité, le GPU lui réserve 14 processeurs de shaders sur 16, contre seulement 2 pour l'autre file.

Un point important est qu'en général, une seule application effectue un rendu 3D, typiquement un jeu vidéo en plein écran ou en fenêtré. Les autres applications utilisent plutôt le GPGPU ou des commandes de rendu 2D, qui utilisent uniquement les processeurs de shaders. En conséquence, pas besoin d'avoir plusieurs processeurs de commande généralistes. L'idéal est de n'avoir qu'un seul processeur de commandes graphiques, accompagné de pleins de processeurs de commandes dédiés au GPGPU. Cela permet d'exécuter des commandes graphiques et GPGPU en parallèle.

Les priorités étaient autrefois statiques, à savoir que certaines processeurs de commande avaient toujours la priorité sur tous les autres. En théorie, le processeur de commande graphique devrait avoir la priorité sur les autres. Mais il y a des exceptions. Par exemple, sur les cartes graphiques avant l'architecture AMD RDNA 1, c'était l'inverse : les tâches de GPGPU avaient la priorité sur le rendu graphique. Maintenant, les GPU récents ont un système de priorité plus complexe, dynamique, qui choisit les priorité en fonction des besoins.

L'usage de plusieurs processeurs de commande pour le rendu 3D

[modifier | modifier le wikicode]

Utiliser plusieurs applications est une première solution pour utiliser plusieurs files de commande. Mais avec un peu d'huile de coude, il est possible d'extraire plusieurs files de commande par application. le rendu 3D permet ce genre de choses, car de nombreux draw calls sont indépendants. Pour comprendre en quoi traiter plusieurs commandes peut être utile, je vais reprendre l'exemple décris sur cet article de blog, les liens sur l'ensemble des posts sur le sujet sont à la fin de ce chapitre.

En rendu 3D, il est fréquent que des draw call consécutifs soient dépendants, qu'ils doivent s'exécuter l'un après l'autre, sans recouvrement possible. Un exemple est celui des filtres de post-traitement comme le Bloom ou la profondeur de champ. Ces deux filtres se font en plusieurs étapes, qui se traduisent en plusieurs draw call consécutifs. Les draw calls d'un filtre sont dépendants, à savoir que chaque étape lit le résultat de l'étape précédente. Ils sont donc séparés par des barrières GPU, ce qui ruine rapidement les performances. De plus, leurs draw calls n'utilisent pas tous les processeurs de shaders.

Un filtre de bloom basique laisse des processeurs de shaders libre, qui pourraient être utilisés pour exécuter un autre filtre de post-traitement en parallèle, comme un filtre de profondeur de champ. Le problème est que la file de commande est séquentielle par nature. Une solution serait pour le moteur graphique de mélanger les draw call pour le filtre de bloom et ceux du filtre de profondeur de champ, mais ce serait assez compliqué pour les programmeurs. Une autre solution délègue le problème au matériel et à l'API.

L'idée est d'avoir deux files de thread : une pour le filtre de Bloom, une autre pour le filtre de profondeur de champ. Les processeurs de shaders peuvent prendre des commandes dans les deux files. S'il y a assez de processeurs de shaders de libre, ils peuvent piocher des commandes dans la seconde file de commande. Les deux files accumulent des commandes, séparées par des barrières GPU. Mais les barrières GPU se limitent à l'intérieur d'une file de commande, elles n'ont pas d'impact sur l'autre file de commande.

Mais qui dit deux files de commande dit : deux processeurs de commande et files de commandes ! Les deux processeurs de commande alimentent les deux files de thread, en piochant chacun dans une file de commande dédiée. Et la même logique vaut pour N processeurs de commandes : tant qu'il y a autant de files de thread et de files de commande, la logique fonctionne à l'identique. La seule contrainte est d'alimenter plusieurs files de commande, ce qui est le job du pilote du GPU. De plus, il faut ajouter des commandes de synchronisation entre files de commandes, qui permettent de synchroniser les deux files de commande. La plus simple force à attendre que les deux files de commandes soient vidées avant de démarrer une nouvelle commande.

Le support dans les API graphiques modernes

[modifier | modifier le wikicode]

Avant l'apparition des API modernes Vulkan et DirectX 12, l'usage de plusieurs files de commande était peu fréquent. En effet, les applications ne voyaient pas de file de commande, les API graphiques avaient juste des draw calls et les commandes graphiques étaient envoyées au pilote une par une. C'est ce dernier qui remplissait la file de commande avec des commandes matérielles. En pratique, le pilote pouvait utiliser une file de commande par application, guère plus. Il n'y avait aucun moyen d'utiliser plusieurs files de commande par application.

Pire que ça : un moteur graphique ne pouvait pas utiliser plusieurs cœurs. Les API graphiques de l'époque étaient séquentielles par nature, elles exécutaient les draw calls l'un après l'autre, et il ne pouvait y avoir qu'une seule instance par application. DirectX 11 a bien tenté d'ajouter des mécanismes pour multi-threader les moteurs graphiques, mais ils étaient difficiles à utiliser. Les jeux vidéo de l'époque étaient capables d'utiliser plusieurs cœurs, mais le moteur graphique n'en utilisait qu'un seul. Typiquement, le rendu du son était réalisé sur un cœur CPU, le reste sur un autre. Parfois, on séparait le moteur physique et le moteur graphique sur deux cœurs séparés, mais pas plus.

Pour aider les programmeurs, les API modernes Vulkan et DirectX 12, gèrent nativement plusieurs files de commandes, qui sont exposées au programmeur. Ce qui était autrefois le domaine du pilote du GPU a été déporté dans les API graphiques. Les commandes remplacent les draw calls mais fonctionnent plus ou moins de la même manière. Le programmeur a accès à une fonction pour créer une file de commande, une autre pour ajouter une commande dedans, et une pour envoyer la file de commande au pilote de GPU. Le programmeur peut remplir ces files de commande comme il le souhaite, tant que les commandes dans des files séparées sont indépendantes.

Il faut noter que ce sont des files de commandes graphiques, pas des files de commande matérielles. En clair, les files de commandes sont envoyées au pilote du GPU, qui les traduit en commandes matérielles. Et le GPU gère ses propres files de commandes matérielles. Il peut envoyer ses files de commandes séparément dans des processeurs de commande séparés, mais il peut décider d'accumuler toutes les commandes dans une seule file de commande matérielle si le GPU n'a qu'un seul processeur de commande, ou n'utiliser qu'une seule file de commande par application.

En théorie, ce système permet de réduire l'usage du processeur. Au lieu d'appeler le pilote de GPU à chaque draw call, il peut accumuler plein de draw call dans une file de commande et envoyer le tout en une seule fois au pilote. Il est donc possible de lancer un grand nombre de draw calls sans trop surcharger le CPU. Du moins en théorie, car le problème des draw call très petits que le GPU exécute trop vite reste présent. Mieux que ça, ce système permet de couper le moteur graphique en plusieurs threads, afin de l'exécuter sur plusieurs cœurs. Par exemple, deux threads peuvent créer deux files de commandes différentes qui sont exécutées en parallèle (si elles ne sont pas sérialisées par le pilote de GPU).

Tout complexifie la tâche du programmeur, vu qu'il doit faire le travail autrefois pris en charge par le pilote de GPU. L'intérêt est que cela permet au programmeur d'optimiser. Il peut utiliser plusieurs processeurs de commande par application, peut exécuter des commandes en parallèle pour remplir tous les processeurs de shaders, etc. Et surtout, il peut insérer des barrières GPU seulement quand elles sont nécessaires. Avec DirectX 11, le pilote de GPU avait tendance à être prudent. Il insérait des barrières GPU très souvent, et n'avait pas moyen de savoir lesquelles étaient réellement nécessaires et celles qui étaient juste inutiles. Il n'avait pas accès aux algorithmes des programmeurs, pour optimiser le tout. Les programmeurs ont la possibilité d'être plus efficaces, du moins s'ils sont assez compétents et qu'on leur laisse le temps.

Il faut impérativement que les files puissent exécuter des commandes séparément sans que cela pose problème. Il y a bien des barrières GPU inter-files, mais laissons cela de côté. Toujours est-il que les files de commandes en question accumulent des commandes graphiques, pas des commandes matérielles. Mais le pilote de la carte graphique reçoit ces files de commande graphique et les accumule dans ses propres files de commandes matérielles.

Avec DirectX 12, trois types différents de files de commande sont nativement supportés : GRAPHIC, COMPUTE et COPY. COPY correspond aux transferts DMA ou à des copies de données en VRAM, COMPUTE mémorise des commandes GPGPU, GRAPHIC est une file de commande de rendu 2D/3D. Du moins, c'est l'explication simplifiée. Car en réalité, les commandes GPGPU sont capables de faire tout ce que COPY permet, et les commandes GRAPHIC supportent tout ce que les commandes COMPUTE supportent. Les relations entre les trois sont inclusives : COPY est inclus dans COMPUTE, qui est lui-même inclus dans GRAPHIC.

Les applications envoient des files de commande COPY, COMPUTE et GRAPHIC au pilote de GPU, qui décide quoi en faire. Si le GPU le supporte, il envoie les commandes GRAPHIC au processeur de commandé généraliste, les commandes COMPUTE à ceux dédiés au GPGPU, les commandes COPY au contrôleur DMA. Mais il peut aussi envoyer transformer des commandes COMPUTE en commandes GRAPHICS ou des commandes CPY en COMPUTE ou GRAPHIC, si le besoin s'en fait sentir. Si le GPU n'a qu'un seul processeur de commande, les commandes sont simplement remises en série et envoyées au seul processeur de commande. C'est le cas sur les GPU Intel intégrés : ils ont un simple processeur de commande qui fait tout. Ils ne gèrent pas les transferts DMA, car ils sont reliés à la RAM système (mémoire unifiée).

Vulkan utilise un système différent. Vulkan permet de demander à la carte graphique combien elle a de processeurs de commande et quelles commandes ils supportent. Si un GPU n'a qu'un seul processeur de commande, Vulkan n'en verra qu'un et le code devra être adapté pour. En clair, la gestion des processeurs de commande est totalement délégué au programmeur. Il n'y a pas d'abstraction comme avec DirectX 12, mais une gestion fine du matériel.

Sources extérieures

[modifier | modifier le wikicode]

Pour compléter la lecture de ce chapitre, vous pouvez lire les 6 articles de blog suivants :


Le pipeline géométrique d'avant DirectX 10

Le pipeline graphique a beaucoup changé dans le temps et est devenu de plus en plus programmable. Et cela s'est beaucoup ressentit dans la partie du pipeline graphique dédiée au traitement de la géométrie. De circuits fixes de T&L, il a intégré des vertex shaders, puis des geometry shaders, hull shaders, domain shaders, primitive shaders, mesh shaders et bien d'autres. L'évolution s'est faite en plusieurs grandes périodes, qu'on peut distinguer très nettement.

La toute première période est celle où les circuits géométriques n'étaient pas programmables du tout. C’était l'époque ancienne des circuits de Transform & Lightning, introduit sur la Geforce 256 de NVIDIA. Le pipeline géométrique se résumait alors aux quatre étapes suivantes :

  • 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 ?
  • La phase d'assemblage des primitives regroupe les sommets en triangles.

Les deux étapes du milieu étaient le fait d'un circuit de T&L (Transform & Lightning) unique, qui n'était pas programmable. Il était accompagné de deux circuits fixes pour les deux étapes restantes. L''input assembler charge les vertices depuis la mémoire vidéo, l'assembleur de primitive regroupe les sommets en triangles avant la rastérisation.

Dans les tableaux qui vont suivre, les circuits non-programmables sont indiqués en rouge.
Avant l'arrivée des shaders
Input assembly Transform & Lighting Primitive assembly

La première période a été très courte : à peine deux générations de cartes graphiques. Rapidement, dès la Geforce 3, les circuits de T&L ont été remplacés par des processeurs de shaders programmables, avec l'introduction des vertex shaders. Ils s'occupaient donc des phases de transformation et d'éclairage, comme les circuits de T&L qu'ils remplaçaient. Les processeurs de shaders étaient toujours accompagnés de l''input assembler et de l'assembleur de primitive.

Après la Geforce 3, avant DirectX 10
Input assembly Vertex shader Primitive assembly

S'en est suivi une longue période où le traitement de la géométrie se résumait aux vertex shaders. Durant cette période, le pipeline graphique ne manipulait que des sommets, les triangles n'existaient qu'en sortie du pipeline géométrique. L'introduction des techniques de tesselation, qui agissent directement sur des primitives, a changé la donne. Nous détaillerons ces techniques de tesselation dans le prochain chapitre. Mais leur introduction ne s'est pas faite sans heurts.

Initialement, les concepteurs de cartes graphiques ont tenté d'ajouter des nouveaux shaders pour gérer la tesselation. Sont d'abord apparus les geometry shaders, puis le hull/domain shaders. Il s'agit là de la troisième période, qui visait à améliorer le pipeline graphique précédent. Mais le résultat était assez désastreux. Les programmeurs avaient beaucoup de mal à utiliser la tesselation ou les nouveaux shaders, ce qui fait que ces technologies ont été peu utilisées.

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

Depuis les années 2018-2020, le pipeline géométrique a fortement évolué et a été revu de fond en comble. L'arrivée des primitive shaders d'AMD et des mesh shaders de NVIDIA a modifié en profondeur le pipeline géométrique, qui est devenu plus simple, plus puissant et plus flexible. De nombreuses étapes ont disparues ou sont devenus programmables. A vrai dire, les étapes d'assemblage de primitives et d'input assembly ont disparues.

Dans ce chapitre, nous allons voir l'ancien pipeline géométrique, celui de la première et de la seconde période. Nous allons nous concentrer sur l'input assembler et l'assemblage de primitives. Nous avons déjà vu les étapes de transformation et l'éclairage dans le chapitre sur les bases du rendu 3D, nous n'allons pas revenir dessus dans le détail. Toujours est-il que l'ancien pipeline géométrique ne traitait que des sommets, les triangles n'existaient qu'une fois l'assemblage de primitive effectué. Le pipeline géométrique après Direct X 10 fonctionne sur un principe totalement différent, mais ce sera le sujet du prochain chapitre.

L'input assembler

[modifier | modifier le wikicode]

L'input assembler charge les sommets de la mémoire vidéo dans les unités de traitement des sommets (circuit de T&L ou processeurs devertex shader). C'est une unité d'accès mémoire un peu particulière, 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 de 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). Ces informations sont mémorisées dans des registres, qui sont configurés par le pilote de périphérique.

Input assembler

Les objets géométriques et 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.

Cube en 3D

Le point crucial est qu'un sommet est très souvent partagé par plusieurs triangles. Par exemple, prenez le cube de l'image ci-contre. Le sommet rouge du cube appartient aux 3 faces grise, jaune et bleue, et sera présent en trois exemplaires dans le tampon de sommets : un pour la face bleue, un pour la jaune, et un pour la grise. Pour éviter ce gâchis, les concepteurs d'API et de cartes graphiques ont inventé des représentations pour les maillages, qui visent à limiter la consommation de mémoire ou faciliter la traversée du tampon de sommet.

Les techniques anciennes : Triangle strip et Triangle fan

[modifier | modifier le wikicode]

Pour gérer le partage des sommets entre triangles, la représentation la plus simple est appelée le maillage sommet-sommet (Vertex-Vertex Meshes). L'idée est que chaque sommet précise, en plus de ses trois coordonnées, quels sont les autres sommets auxquels il est relié. Les sommets sont regroupés dans un tableau et les autres sommets sont identifiés par leur position dans le tableau, leur indice.

Vertex-Vertex Meshes (VV)

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.

Mais il existe des méthodes pour que les informations sur les arêtes soient codées de manière explicite. 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 des triangle fans et celle des triangle strips.

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

Triangle strip

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

Triangle fan

Le tampon d'indices

[modifier | modifier le wikicode]

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

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

On pourrait remplacer les indices par des pointeurs, ce qui donnerait un cas particulier d'une structure de données connue sous le nom de vecteur de Liffe. Mais ce n'est pas très pratique et n'est pas utilisé dans le domaine du rendu 3D.
Représentation face-sommet.

Avec un tampon d'indices, un sommet peut être chargé plusieurs fois depuis la mémoire vidéo. Pour exploiter cette propriété, les cartes graphiques intercalent une mémoire cache pour mémoriser les sommets déjà chargés : le cache de sommets. Chaque sommet est stocké dans ce cache avec son indice en guise de Tag. 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é, ...).

Cache de sommets.

Notons que ce cache a disparu 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 T&L et les vertex shaders

[modifier | modifier le wikicode]

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

Un changement de coordonnée s'effectue assez simplement en multipliant le vecteur (X, Y, Z) des coordonnées d'un sommet par une matrice adéquate. Il existe des matrices pour la translation, la mise à l'échelle, d'autres pour la rotation, une autre pour la transformation de la caméra, une autre pour l'étape de projection, etc. Un petit problème est que les matrices qui le permettent sont des matrices avec 4 lignes et 4 colonnes, et que la multiplication demande que le nombre de coordonnées du vecteur soit égal au nombre de colonnes. Pour résoudre ce petit problème, on ajoute une 4éme coordonnée aux sommets, la coordonnée homogène, qui ne sert à rien, et est souvent mise à 1, par défaut. Mais oublions de détail.

L'étape de transformation est souvent fusionnée avec l'étape d'éclairage par sommet, les deux demandant de faire des calculs assez similaires. Sur PC, la geforce 256 a été la première à intégrer une unité non-programmable pour gérer transformation et éclairage, appelée unité de T&L (Transform & Lighting). L'unité de T&L incorporait un ou plusieurs circuits de multiplication de matrices spécialisés pour l'étape de transformation. Ils sont composés d'un gros paquet de multiplieurs et d'additionneurs flottants. Elles prennent en entrée les données provenant de l'input assembler, ainsi que les matrices nécessaires, et fournissent en sortie les coordonnées des sommets transformés.

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

L'assemblage de primitives

[modifier | modifier le wikicode]

En sortie de l'étage précédent, on n'a que des sommets éclairés et colorisés, pas des triangles. Et les sommets ne sont pas dans l'ordre : deux sommets qui en sortent à la suite ne sont pas forcément dans le même triangle. Pour recréer des triangles, on doit lire les sommets dans l'ordre adéquat, par paquets de trois pour obtenir des triangles. C'est le rôle de l'étape d'assemblage de primitives (primitive assembly), qui regroupe les sommets appartenant au même triangle, à la même primitive. L'assemblage des primitives est réalisée par un circuit fixe, non-programmable, qui utilise le tampon d'indice pour regrouper les sommets en primitives.

L'étape d'assemblage de primitives est suivie par un tampon de primitives, dans lequel les triangles sont accumulés 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 triangles. La première solution fait un meilleur usage de la mémoire du tampon de primitive, l'autre est plus simple et plus rapide, plus simple d'utilisation pour le rastériseur.

Carte graphique en rendu immédiat


Le pipeline géométrique après DirectX 10

Dans le chapitre précédent, nous avons étudié la manière dont les anciennes cartes graphiques traitaient la géométrie. Elles traitaient uniquement des sommets, via des vertex shaders. Mais depuis DirectX 10, le pipeline graphique a intégré des techniques pour gérer nativement des triangles et faire des traitements dessus. L'intérêt est que cela permet de faciliter l'implémentation de techniques de tesselation, sans compter que certaines optimisations deviennent plus simples à effectuer. Dans ce chapitre, nous allons étudier le pipeline graphique de DirectX 10, DirectX 11 et DirectX 12.

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

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

Un exemple d'utilisation des primitives : la tessellation

[modifier | modifier le wikicode]

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

Tessellation.

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

L'historique de la tesselation sur les cartes 3D

[modifier | modifier le wikicode]

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

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

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

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

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

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

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

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

DirectX 10 : les geometry shaders

[modifier | modifier le wikicode]

DirectX 10 et OpenGl 3.2 ont introduit les geometry shaders, juste avant l'étape d'assemblage des primitives, afin de gérer une forme limitée de tesselation. Les geometry shaders peuvent ajouter, supprimer ou altérer des primitives dans une scène 3D. Ils prennent entrée une primitive et fournissent en sortie zéro, une ou plusieurs primitives. Ils sont surtout utilisés pour la gestion des cubemaps, le shadow volume extrusion, la génération de particules, et quelques autres effets graphiques. Ils pourraient en théorie être utilisés pour faire de la tesselation, mais leurs limitations font que ce n'est pas pratique. Rappelons que les geometry shaders sont optionnels et que beaucoup de jeux vidéos ou de moteurs de rendu 3D n'en utilisent pas.

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

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

[modifier | modifier le wikicode]

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

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

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

Implémentation matérielle des geometry shaders

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

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

La fonctionnalité de stream output

[modifier | modifier le wikicode]

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

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

Stream output

DirectX 12 : les mesh shaders

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

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

Mais Direct X 12 a simplifié le tout, sous l'impulsion de technologies introduites par AMD et de NVIDIA. AMD a introduit les primitive shaders, NVIDIA a introduit les mesh shaders'' ont été introduit par NVIDIA. Les derniers ont été gardés pour DirectX 12, simplifiant grandement le pipeline.

Les primitive/mesh shaders

[modifier | modifier le wikicode]

Les deux solutions de AMD et NVIDIA partent du même principe : elles fusionnent certaines étapes du pipeline. Les primitive/mesh shaders font disparaitre les étapes d'input assembly et d'assemblage de primitives, qui sont maintenant gérées par les primitive/mesh shaders. Les primitive/mesh shaders lisent directement le tampon d'indice et lisent les sommets depuis la VRAM, sans passer par une étape non-programmable. Ils assemblent les primitives eux-mêmes et les envoient directement au rastériseur. Le tout permet des optimisations très intéressantes, comme un culling précoce.

Les mesh shaders sont des shaders généralistes, semblables aux compute shaders. Pour rappel, un compute shader peut lire des données en RAM, exécuter des traitements dessus, et enregistrer les résultats en RAM. Il peut lire ou écrire à des adresses arbitraires, sans limitations. Il n'est pas limité à lire des données consécutives, peut sauter d'une donnée à une autre donnée distante en RAM. Les mesh shaders sont des variantes des compute shaders, qui n'écrivent pas leur résultat en RAM, mais envoient celui-ci au rastériseur. Plus précisément, ils écrivent leur résultat dans le tampon de primitives.

Les mesh shaders peuvent contourner l'étape dinput assembly et la remplacer par leur propre code. Pour rappel, l'étape dinput assembly était non-programmable et gérait des tampons de vertices et d'indices très normés. Les sommets étaient lus soit un par un, soit par paquets de N sommets consécutifs, ce qui était assez rigide. Il n'y avait pas d'accès arbitraire en mémoire RAM comme peuvent le faire les compute shaders. Par contre, un mesh shader peut accéder aux sommets de la manière qu'il souhaite, ce qui permet d'émuler un input assembler normal et plus encore.

Une autre différence avec les vertex shaders est qu'ils ne traitent pas forcément des sommets, mais peuvent aussi envoyer des primitives au rastériseur directement. En clair, ils n'ont pas besoin d'une étape de primitive assembly, qu'ils peuvent émuler directement dans le shader lui-même. Le culling est lui aussi réalisé par le primitive shader, pas par une unité fixe. Et cela permet de contourner un problème fondamental des vertex shaders : il fallait que les primitives soient assemblées pour qu'on puisse déterminer si elles sont ou non invisibles. A l'opposé, les primitive/mesh shaders assemblent les primitives de manière précoce dans le primitive/mesh shader, ce qui permet d'éliminer les primitives invisibles le plus tôt possible. Pour cela, les opérations permettant de déterminer si une primitive est visible sont exécutés en priorité, les autres opérations sont retardées et effectuées le plus tard possible. Ainsi, les calculs pour colorier ou orienter un sommet ne sont pas exécutés si le sommet est invisible.

Il y a des différences entre primitive et mesh shaders. Les primitive shaders permettent de lire un sommet à la fois, alors que les mesh shaders permettent de lire des batchs de plusieurs primitives d'un coup. Ces batchs de plusieurs primitives sont appelés des meshlets. La différence n'est pas fondamentale : le hardware des cartes AMD, qui gère des primitive shaders, peut regrouper dynamiquement plusieurs instances de primitive shaders en un seul mesh shader, via les technique de SIMT (une instance de primitive shader effectue des opérations scalaires, qui peuvent être regroupées en une seule instance SIMD en traitant plusieurs sommets en parallèle). La seule différence est que les mesh shaders exposent ce comportement au niveau du jeu d'instruction des shaders, les programmeurs en ont conscience.

Le pipeline géométrique avec les primitive/mesh shaders

[modifier | modifier le wikicode]

Avec les primitive shaders, l'implémentation exacte dépend de si la tesselation est activée ou non. Si la tesselation n'est pas activée, le vertex shader et le geométry shader sont fusionnés en un seul primitive shader.

Comparaison entre les pipelines géométriques de DirectX 11 et 12, sans tesselation
DirectX 11 Input assembly Vertex shader Geometry shader Primitive assembly
DirectX 12 Primitive shader (AMD)

Avec la tesselation activée, les geometry shaders et les domain shaders en un seul shader. De même, les vertex shaders et les hull shaders sont fusionnés en un seul shader, nommé l'amplification shader. Ainsi, le pipeline graphique est grandement simplifié, avec seulement deux shaders et un étage fixe, au lieu de quatre shaders différents.

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



Le rasterizeur

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

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

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

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

Le clipping-culling

[modifier | modifier le wikicode]

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

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

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

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

La rastérisation

[modifier | modifier le wikicode]
Exemple de rastérisation, où les formes géométriques sont une ligne droite, un arc de cercle et un polygone.

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

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

La rastérisation de type scan-line

[modifier | modifier le wikicode]
Rendu en Scan-line.

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

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

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

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

[modifier | modifier le wikicode]

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

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

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

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

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

L'étape de Triangle traversal

[modifier | modifier le wikicode]

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

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

Smallest rectangle traversal

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

Tiled traversal

La coordonnée de profondeur

[modifier | modifier le wikicode]

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

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

[modifier | modifier le wikicode]

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

Mais ces techniques peuvent causer un rendu anormal quand la coordonnée de profondeur ou de transparence d'un pixel est modifiée après l'étape de rastérisation, typiquement dans les pixels shaders. Il est rare que les shaders bidouillent la profondeur ou la transparence d'un pixel, mais cela peut arriver. C'est pour cela que l’élimination des fragments invisibles est traditionnellement réalisé à la toute fin du pipeline graphique, dans les ROPs, juste avant d’enregistrer les pixels dans le framebuffer.

Pour éliminer tout problème, on doit recourir à des solutions qui activent ou désactivent l'élimination précoce des pixels suivant les besoins. La plus simple est de laisser le choix aux drivers de la carte graphique. Ils analysent les shaders et décident si le test de profondeur précoce peut être effectué ou non. De nos jours, les APIs graphiques comme DirectX et OpenGl permettent de marquer certains shaders comme étant compatibles ou incompatibles avec l'élimination précoce. C'est possible depuis DirectX 11.

Plus d'information dans cet article de blog : To Early-Z, or Not To Early-Z.

La duplication des circuits de gestion de profondeur

[modifier | modifier le wikicode]

Il existe plusieurs techniques d'élimination précoce', qui sont présentes depuis belle lurette dans les cartes graphiques modernes. La plus simple effectue le même calcul que dans les ROP. Les circuits de gestion de la profondeur sont ainsi dupliqués : un exemplaire est dans le ROP, l'autre en sortie de l'étape de rastérisation. L'implémentation peut utiliser soit deux unités de profondeur, soit une seule unité partagée. La geforce 6800 a apparemment opté pour la seconde solution.

GeForce 6800

Rappelons que la carte graphique change régulièrement de shader à exécuter. Et il arrive qu'on passe d'un shader compatible avec l'élimination précoce à un shader incompatible ou inversement. Passer d'un shader qui est compatible avec l'élimination précoce à un qui ne l'est n'est pas un problème. Il suffit de désactiver l'unité d'élimination précoce lors du changement de shader. Mais dans le cas inverse, quelques problèmes de synchronisation peuvent apparaitre.

Il faut activer l'élimination précoce quand les pixels du nouveau shader sortent du circuit de rastérisation, ce qui n'est pas exactement le même temps que le changement de shader. En effet, le shader précédent a encore des pixels qui traversent le pipeline et qui sont en cours de calcul dans les pixels shaders ou dans les ROP. Le processeur de commande doit donc faire attendre les processeurs de shader et quelques autres circuits. Typiquement, il faut attendre que la commande précédente se termine, avant d'en relancer une autre avec le nouveau shader.

L'interpolation des coordonnées de texture

[modifier | modifier le wikicode]

Une fois l'étape de triangle setup terminée, on sait donc quels sont les pixels situés à l'intérieur d'un triangle donné. Il faut alors texturer le triangle. Pour les pixels situés exactement sur les sommets, on peut reprendre la coordonnée de texture et la profondeur du sommet associé. Mais pour les autres pixels, nous sommes obligés d'extrapoler les coordonnées et la profondeur à partir des données situées aux sommets. C'est le rôle de l'étape d'interpolation, qui calcule les informations des pixels qui ne sont pas pile-poil sur un sommet. Par exemple, si j'ai un sommet vert, un sommet rouge, et un sommet bleu, le triangle résultant doit être colorié comme indiqué dans le schéma de droite.

Les coordonnées barycentriques

[modifier | modifier le wikicode]

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

Coordonnées barycentriques.

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

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

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

La gestion de la perspective

[modifier | modifier le wikicode]

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

Lors de la rastérisation, chaque fragment se voit attribuer un triangle, et les coordonnées de texture qui vont avec. Si un pixel est situé pile sur un sommet, les coordonnées de texture de ce sommet est attribuée au pixel. Si ce n'est pas le cas, les coordonnées de texture sont interpolées à partir des coordonnées des trois sommets du triangle rastérisé.

Il existe plusieurs moyens de faire cette interpolation, mais le plus simple est l'interpolation affine, identique à celle effectuée pour les autres valeurs interpolées. Concrètement, on fait une moyenne pondérée des coordonnées de texture u et v des trois sommets pour obtenir les coordonnées de textures finales, sans prendre en compte la coordonnée de profondeur. Par contre, en faisant cela, la perspective n'est pas correctement rendue, comme illustré ci-dessous. L'interpolation affine était utilisée sur la console Playstation 1 de Sony, d'où des textures un peu bizarres sur cette console.

Correction de perspective.

D'autres consoles utilisaient l'interpolation affine, mais s'en sortaient mieux car elles utilisaient non pas des triangles, mais des quads (des rectangles). Avec des primitives rectangulaires, le résultat a l'air visuellement, meilleur, car l'interpolation donne un bon résultat pour ce qui va à l'horizontal, seule les objets à la verticale de la caméra donnant une perspective légèrement déformée. Tout cela est bien illustré ci-dessous. Cependant, l'interpolation est alors plus lourde en calculs, car elle demande d'interpoler quatre sommets au lieu de trois. Le cout en calculs n'est pas négligeable.

Affine texture mapping tri vs quad

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



Les unités de texture

Texture mapping

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

Le placage de textures inverse

[modifier | modifier le wikicode]

Pour rappel, plaquer une texture sur un objet consiste à attribuer un texel à chaque sommet, ce qui est fait lorsque les créateurs de jeu vidéo conçoivent le modèle de l'objet. Chaque sommet est associé à des coordonnées de texture, qui précisent quelle texture appliquer, mais aussi où se situe le texel à appliquer dans la texture. Par exemple, la coordonnée de texture peut dire : je veux le pixel qui est à ligne 5, colonne 27 dans cette texture.

Dans les faits, on n'utilise pas de coordonnées entières de ce type. Les coordonnées de texture sont deux nombres flottants compris entre 0 et 1. La coordonnée 0,0 correspond au texel en bas à gauche, celui de coordonnée 1,1 est tout en haut à droite. Les deux coordonnées de texture sont notées u,v avec DirectX, ou encore s,t dans le cas général : u est la coordonnée horizontale, v la verticale. Le nom donnée à cette technique de description des coordonnées de texture s'appelle l'UV Mapping.

UV Mapping

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

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

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

La normalisation des coordonnées

[modifier | modifier le wikicode]

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

Clamp tile

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 adresse mémoire

[modifier | modifier le wikicode]

Une fois la normalisation effectuée, les coordonnées de texture sont utilisées pour lire le texel voulu. Pour cela, les coordonnées de texte sont transformées en adresse mémoire, adresse qui pointe sur le texel ayant ces cordonnées. Pour cela, la première étape est de transformer les coordonnées flottantes u,v en coordonnées entières x,y qui pointent sur un texel. Pour cela, il suffit de multiplier les coordonnées flottantes 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 conservée pour le filtrage de textures, mais passons cela sous silence pour le moment.

La seconde étape prend les coordonnées entières x,y et calcule l'adresse mémoire du texel. L'adresse dépend de la position de la texture en mémoire, précisément de son début, son premier texel, mais aussi de la position du texel 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 textures naïves

[modifier | modifier le wikicode]

Les programmeurs qui lisent ce cours s'attendent certainement à ce que la texture soit stockée en mémoire ligne 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 par colonne, ce qui donne le column major order.

Row et column major order.

Maintenant, supposons que la texture commence à l'adresse , qui est l'adresse du premier texel. La texture a une résolution de 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 du pixel se calcule comme suit :

La taille d'un pixel en mémoire est notée T. La taille d'une ligne en mémoire est de , par définition, vu qu'elle fait texels. On a donc :

La formule se réécrit comme suit :

Le calcul d'adresse est donc assez simple. Malheureusement, les textures ne sont pas stockées de cette manière en mémoire vidéo. En effet, elle se marie mal avec les opérations de filtrage de texture que nous allons voir dans ce qui suit. Le filtrage d'un texel dépend de ses voisins du dessus et du dessous. 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 même, les textures sont 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. Vu qu'on ne connait pas à l'avance l'angle que fera la diagonale de parcours, on doit ruser.

Les textures tilées

[modifier | modifier le wikicode]

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

Texture tilée

La formule de calcul d'adresse vue plus haut doit être adaptée pour tenir compte des tiles. Pour cela, il faut remplacer la taille d'un texel par la taille d'une tile, et que la largeur de la texture soit exprimée 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 shaders.

Vu que les tiles sont carrées avec une largeur qui est une puissance de deux, la multiplication par la taille d'une tile en mémoire se simplifie : on passe d'une multiplication entière à des décalages de bits. Même chose pour le calcul de l'adresse de la tile à partir des coordonnées x,y : ils impliquent des divisions par une puissance de deux, qui deviennent de simples décalages.

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

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

Les textures basées sur des z-order curves

[modifier | modifier le wikicode]

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

Construction d'une Z-order curve.

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

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

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

Les techniques de rendu à textures multiples

[modifier | modifier le wikicode]

Nous venons de voir comment une texture est plaquée sur un objet 3D, ou une surface comme un sol. Pour résumer, le calcul de l'adresse d'un texel prend la position du texel par rapport au début de la texture, et ajoute l'adresse du début de la texture. L'adresse mémoire de la texture est 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.

Néanmoins, il s'agit là du cas le plus simple. Certaines techniques de rendu demandent de choisir la texture à plaquer parmi un ensemble de plusieurs textures. Les techniques en question sont assez variées et n'ont pas grand chose en commun. Les plus connues sont le mip-mapping, le cube-mapping et les textures virtuelles. Le mip-mapping sert à filtrer les textures, chose qu'on expliquera plus tard, le cube-mapping sert à simuler des réflexions sur un objet en plaquant une texture de l'environnement dessus, les textures virtuelles sont une optimisation pour les textures des terrains de grande taille. Mais malgré leurs différences, elles demandent de choisir quelle texture plaquer entre plusieurs textures de base. En clair, l'adresse de base de la texture varie selon la situation. Voyons-les dans le détail.

Le mip-mapping

[modifier | modifier le wikicode]

Le mip-mapping a pour but de légèrement améliorer les graphismes des objets lointains, tout en rendant les calculs de texture plus rapides. Formellement, le mip-mapping est une technique de filtrage de texture, mais nous l'abordons maintenant car elle est surtout liée au calcul d'adresse. Les unités de texture ont des circuits de filtrage de texture 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 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. Un pixel de l'écran est associé à plusieurs texels. Idéalement, la carte graphiques devrait lire tous ces texels et en faire une sorte de moyenne pondérée, pour calculer la couleur finale du pixel. Mais dans les faits, ce serait très gourmand et compliqué à implémenter en hardware. Une solution serait de ne garder que quelque texels, mais cela a tendance à créer des artefacts visuels (les textures affichées ont tendance à pixeliser). Le mip-mapping permet de réduire ces deux problèmes en même temps en précalculant cette moyenne pondérée pour des distances prédéfinies.

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 pixel. Chaque exemplaire correspond à un niveau de détail, aussi appelé Level Of Detail (abrévié en LOD). 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.

Exemples de mip-maps.

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

Exemple de mipmapping.

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/4, le suivant à l'adresse L + L/4 + L/16, et ainsi de suite. Le calcul d'adresse demande juste connaître le niveau de détails souhaité et 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.

É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 qu'en prenant une texture dexu fois plus petite, elle prend 4 fois moins de mémoire : 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.

Le cube-mapping

[modifier | modifier le wikicode]
Exemple de reflets environnementaux.

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

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

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

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

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

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

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

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

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

[modifier | modifier le wikicode]

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

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

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

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

Unité de texture simple

L'implémentation du mip-mapping

[modifier | modifier le wikicode]

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

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

, , ,

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

Unité de texture avec mipmapping.

Malheureusement, le calcul exact utilisé pour le choix de la mip-map dépend du GPU considéré et peu de chose est connu quant à ces algorithmes. Il est possible d'inférer le comportement à partir d'observations, mais guère plus. Pour ceux qui veulent en savoir plus, je conseille la lecture de cet article de blog :

La gestion des accès mémoire

[modifier | modifier le wikicode]

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

Une solution alternative consiste à mettre en attente les demandes de lectures de texture pendant que la mémoire est occupée. La manière la plus simple d'implémenter des accès mémoire multiples est de les mettre en attente dans une petite mémoire FIFO. Cela implique que les accès mémoire s’exécutent dans l'ordre demandé par le shader et/ou l'unité de rastérisation, il n'y a pas de réorganisation des accès mémoire ou d’exécution dans le désordre des accès mémoire.

Accès mémoire simultanés.

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

L'intégration du cache de textures

[modifier | modifier le wikicode]

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

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

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

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

Phased cache

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

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

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

Unité de texture avec un cache de texture

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

Le filtrage de textures

[modifier | modifier le wikicode]

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

Position du pixel par rapport aux texels.

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

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

Unité de texture.

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

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

Le filtrage au plus proche

[modifier | modifier le wikicode]

La méthode de filtrage la plus simple consiste à colorier avec le texel le plus proche. Cela revient tout simplement à ne pas tenir compte de la partie fractionnaire des coordonnées x,y, ce qui est très simple à implémenter en matériel. C'est ce que l'on appelle le filtrage au plus proche, aussi appelé nearest filtering.

Autant être franc, le résultat est assez pixelisé et peu agréable à l’œil. Par contre, le résultat est très rapide à calculer, vu qu'il ne demande aucun calcul à proprement parler. Elle ne fait pas appel à la parti fractionnaire des coordonnées entières de texture, ni aux dérivées de ces coordonnées. On peut combiner cette technique avec le mip-mapping, ce qui donne un résultat bien meilleur, bien que loin d'être satisfaisant. Au passage, toutes les techniques de filtrage de texture peuvent se combiner avec du mip-mapping, certaines ne pouvant pas faire sans.

Filtrage de texture au plus proche.

Le filtrage linéaire

[modifier | modifier le wikicode]

Le filtrage le plus simple est le filtrage linéaire. Il 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, avec une texture carrée de 512 texels de côté. Le mip-mapping crée plusieurs textures : une de 256 texels de côté, une de 128 texels, une de 64, etc. Maintenant, la texture est sur un objet à une certaine distance de l'écran, vu de face. Le résultat est qu'elle correspond à l'écran à un carré de 300 pixels de côté (pas d'erreur : pixels, pas texels). Dans ce cas, la texture se trouve entre deux mip-maps : celle de 512 pixels de côté, celle de 256. Laquelle choisir ? Le filtrage au plus proche prend la texture de 512 pixels de côté. Le filtrage linéaire lui, fait autrement.

Vu que la texture est entre deux mip-maps, l'idée est de prendre le texel au plus proche dans chaque texture et de faire une sorte de moyenne appelée l'interpolation linéaire. 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. Le résultat est que les transitions entre deux niveaux de détails sont plus lisses, moins abruptes.

Interpolation linéaire.

Le filtrage bilinéaire

[modifier | modifier le wikicode]

Le filtrage bilinéaire effectue une sorte de moyenne pondérée des quatre texels les plus proches du pixel à afficher. Pour cela, rappelez-vous ce qui a été dit plus haut : les coordonnées x,y d'un pixel ont une partie entière et une partie fractionnaire. Le filtrage au plus proche élimine les parties fractionnaires, ce qui donne une coordonnée x,y. Avec le filtrage bilinéaire, on prend les texels de coordonnées (x,y) ; (x+1,y) ; (x,y+1) ; (x+1,y+1), le pixel étant entre ces 4 texels.

Mais le filtrage ne fait pas qu'une simple moyenne, il prend en compte les parties fractionnaires pour faire la moyenne. En effet, le pixel n'est pas au milieu du carré de texel, il est quelque part mais est souvent plus proche d'un texel que des autres. Et il faut donc pondérer la moyenne par les distances aux 4 texels. Pour cela, la moyenne est calculée à partir d'interpolations linéaires. Avec 4 pixels, nous allons devoir calculer la couleur de deux points intermédiaires. La couleur de ces deux points se calcule par interpolation linéaire, et il suffit d'utiliser une troisième interpolation linéaire pour obtenir le résultat.

Filtrage bilinéaire de texture.

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

Unité de filtrage bilinéaire.

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, comme on l'a vu plus haut. 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.

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

Le filtrage trilinéaire

[modifier | modifier le wikicode]

Avec le filtrage bilinéaire, des discontinuités apparaissent sur certaines surfaces. Par exemple, pensez à une texture de sol : elle 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. De telles transitions sont lissées grâce au filtrage linéaire, il n'y a plus qu'à le combiner avec le filtrage bilinéaire. Rien d’incompatible : le premier filtre l'intérieur d'une mip-map, le second combine deux mip-maps.

Le filtrage trilinéaire prend les deux mip-maps les plus proches, fait un filtrage bilinéaire avec chacune, puis fait une « une moyenne » pondérée entre les deux résultats. 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. Mais ce circuit nécessite de charger 8 texels simultanément. Qui plus est, ces 8 texels ne sont pas consécutifs en mémoire, car ils sont dans deux niveaux de détails/mip-maps différents.

Unité de filtrage trilinéaire parallèle.

Vu qu'on lit des texels dans deux mip-maps, les texels sont lus en deux fois : 4 texels provenant de la première mip-map, suivis par les 4 texels de l'autre mip-map. Les 4 premiers texels doivent donc être mis en attente dans des registres, en attendant que les 4 autres arrivent. Une amélioration du circuit précédent gère cela en ajoutant des registres. Il lit les 4 premiers texels, les filtre avec une interpolation bilinéaire, et mémorise le résultat dans un registre. Puis, il lit les 4 autres texels, les filtre, et met le résultat dans un second registre. A ce moment là, un circuit d'interpolation linéaire finit le travail. On économise donc un circuit d'interpolation bilinéaire, sans que les performances soient trop impactées.

Unité de filtrage trilineaire série.

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

Le filtrage anisotrope

[modifier | modifier le wikicode]

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

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

Exemple de filtrage anisotrope.

La compression de textures

[modifier | modifier le wikicode]

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

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

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

La palette indicée et la technique de Vector quantization

[modifier | modifier le wikicode]

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

Les algorithmes de Block Truncation coding

[modifier | modifier le wikicode]

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

Block Truncation coding.

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

Le format de compression S3TC / DXTC

[modifier | modifier le wikicode]

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

Color Cell Compression.
Dxt1 et color cell compression.

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

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

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

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

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

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

Circuit de décompression du DXTC.

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

[modifier | modifier le wikicode]

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

Dxt 2 et 3.

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

Dxt 4 et 5.

Le format de compression PVRTC

[modifier | modifier le wikicode]

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

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

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

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

Annexe : les textures virtuelles

[modifier | modifier le wikicode]

Les textures virtuelles sont une optimisation des textures normales, qui visent à accélérer le rendu de terrains de grande taille. Imaginez par exemple un monde assez ouvert, comme un environnement en forêt ou en montagne, avec une grande distance de visibilité. Avec de tels terrains, le "sol" est recouvert par une texture de sol unique qui recouvre tout le terrain. Elle ne se répète pas, est de très grande taille, et peut parfois recouvrir toute la map ! Mais il n'y a pas assez de mémoire vidéo pour mémoriser la texture toute entière. La seule solution est la suivante : une partie de la texture est placée en mémoire vidéo, le reste est soit placé en mémoire RAM ou sur le disque dur.

Pour cela, le moteur de jeu utilise une optimisation ingénieuse, basée sur une observation assez basique : une bonne partie de la texture est visible, mais le reste est caché par des arbres, des habitations ou d'autres obstacles. Une optimisation possible de ne garder en mémoire vidéo que les portions visibles de la texture, pas les portions cachées. Une autre optimisation mélange textures virtuelles et mip-mapping. L'idée est que pour les portions lointaines d'une texture, la texture utilisée est une mip-map de basse résolution. L'idée est alors de ne charger que la mip-map adéquate, pas les autres niveaux de détail. En clair, la texture de base n'est pas chargée en mémoire vidéo, mais la mip-map basse résolution l'est.

Une texture à deux niveaux

[modifier | modifier le wikicode]

L'implémentation des textures virtuelles découpe les méga-textures en tiles, en morceaux rectangulaires de taille modeste. En clair, le terrain est découpé en morceau rectangulaires/carrés. Seules les tiles nécessaires sont chargées en mémoire vidéo, pas les autres. Par exemple, les tiles non-visibles ne sont pas placées en mémoire vidéo, seules les tiles visibles le sont. De même, il y a une tile par niveau de mip-map : seul la tile correspondant le niveau adéquat est en mémoire vidéo, les autres niveaux de détail ne sont pas chargés. On peut faire une analogie avec la mémoire virtuelle, où les données sont découpées en pages, qui sont chargées en mémoire RAM à la demande, suivant les besoins, les données pouvant être swappées sur le disque dur si elles sont peu utilisées. Sauf qu'ici, il s'agit de textures qui sont découpées en pages chargées à la demande en mémoire vidéo, depuis la RAM système.

Une texture virtuelle est en réalité un système à deux niveaux : une liste de tiles et les tiles elles-mêmes. La liste de tiles est appelée un atlas de texture, c'est un peu l'équivalent de la tilemap pour le rendu 2D. Rendre une texture demande de calculer quelle tile contient le texel à afficher, consulter la tile en question, puis récupérer le texel adéquat dans cette tile. La tile est donc une texture, mais la texture à charger est choisie parmi un ensemble, qui est ici l'atlas de texture.

L'implémentation : logicielle versus matérielle

[modifier | modifier le wikicode]

Les textures virtuelles ont été utilisées pour la première fois par les jeux Rage 1 et 2 d'IdSoftware, et quelques jeux ultérieurs comme DOOM 2016. IdSoftware les appelait des mega-textures. L'optimisation permettait des gains en performance assez impressionnants. Le jeu Rage 1 utilisait une texture carrée unique de 128k pixels de côté pour rendre le terrain. En théorie, une telle texture devrait prendre 64 giga-octets, mais le jeu tournait correctement avec 512 méga-octets de RAM, poussivement avec seulement 256 méga-octets de RAM.

De nos jours, les textures virtuelles sont supportées par beaucoup de jeux vidéos, les moteurs les plus courants gèrent de telles textures de manière logicielles. Mais quelques GPU récents supportent les textures virtuelles. Sur les GPU récents, l'atlas de texture est géré nativement par le matériel. Le GPU choisit quelle tile, quelle texture choisir pour rendre le texel adéquat. Pour cela, le GPU calcule quelle tile charger, consulte l'atlas de texture, et lit la texture de tile adéquate.

Mais l'implémentation sur les GPU récents a de nombreuses limitations. La limitation la plus importante est que la taille des textures virtuelles ne peut pas dépasser la taille d'une texture normale, soit 32768 pixels de côté pour une texture carrée environ sur les GPU de 2020. De plus, le chargement d'une tile est très lent. En clair, dès qu'on veut changer de niveau de mip-map pour une tile, ou dès qu'une tile devient visible, le chargement de la tile peut facilement prendre plusieurs centaines de millisecondes. Le filtrage de texture est très complexe avec des textures virtuelles, ce qui fait que le filtrage de texture virtuelle est souvent soumis à des limitations que les textures normales n'ont pas, notamment pour le filtrage anisotropique.


Les Render Output Target

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

Tout demande de calculer un pseudo-pixel pour chaque point d'intersection et de combiner leurs couleurs pour obtenir le résultat final. Les pseudo-pixels en question sont des fragments. Chaque fragment possède une position à l'écran, une coordonnée de profondeur, une couleur, ainsi que quelques autres informations potentiellement utiles. Les fragments attribués à un même pixel, qui sont à la même position sur l'écran, sont donc combinés pour obtenir la couleur finale de ce pixel. Pour résumer, la profondeur des fragments doit être gérée, de même que la transparence, etc.

Et c'est justement le rôle de l'étage du pipeline que nous allons voir maintenant. Ces opérations sont réalisées dans un circuit qu'on nomme le Raster Operations Pipeline (ROP), aussi appelé Render Output Target, situé à la toute fin du pipeline graphique. Dans ce qui suit, nous utiliserons l'abréviation ROP pour simplifier les explications. Le ROP effectue quelques traitements sur les fragments, avant d'enregistrer l'image finale dans la mémoire vidéo.

Les fonctions des ROP

[modifier | modifier le wikicode]

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

La gestion de la profondeur (tests de visibilité)

[modifier | modifier le wikicode]

Le premier rôle du ROP est de trier les fragments du plus proche au plus éloigné, pour gérer les situations où un triangle en cache un autre (quand un objet en cache un autre, par exemple). Prenons un mur rouge opaque qui cache un mur bleu. Dans ce cas, un pixel de l'écran sera associé à deux fragments : un pour le mur rouge, et un pour le bleu. Vu que le mur de devant est opaque, seul le fragment de ce mur doit être choisi : celui du mur qui est devant. Et il s'agit là d'un exemple simple, mais il est fréquent qu'un objet soit caché par plusieurs objets. En moyenne, un objet est caché par 3 à 4 objets dans un rendu 3d de jeu vidéo.

Pour cela, chaque fragment a une coordonnée de profondeur, appelée la coordonnée z, qui indique la distance de ce fragment à la caméra. La coordonnée z est un nombre qui est d'autant plus petit que l'objet est près de l'écran. La profondeur est calculée à la rastérisation, ce qui fait que les ROP n'ont pas à la calculer, juste à trier les fragments en fonction de leur profondeur.

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

Pour savoir quels fragments sont à éliminer (car cachés par d'autres), la carte graphique utilise ce qu'on appelle un tampon de profondeur. Il s'agit d'un tableau, stocké en mémoire vidéo, qui mémorise la coordonnée z de l'objet le plus proche pour chaque pixel.

Par défaut, ce tampon de profondeur est initialisé avec la valeur de profondeur maximale. Au fur et à mesure que les objets seront calculés, le tampon de profondeur est mis à jour, conservant ainsi la trace de l'objet le plus proche de la caméra. Si jamais un fragment a une coordonnée z plus grande que celle du tampon de profondeur, cela veut dire qu'il est situé derrière un objet déjà rendu et le tampon de profondeur n'a pas à être mis à jour. Dans le cas contraire, le fragment est plus près de la caméra et sa coordonnée z remplace l'ancienne valeur z dans le tampon de profondeur.

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

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

Z-fighting

La gestion de la transparence : test alpha et alpha blending

[modifier | modifier le wikicode]

En premier lieu, les ROPs s'occupent de la gestion de la transparence. La transparence/opacité d'un pixel/texel est codée par un nombre, la composante alpha, qui est ajouté aux trois couleurs RGB. Plus la composante alpha est élevée, plus le pixel est opaque. En clair, tout fragment contient une quatrième couleur en plus des couleurs RGB, qui indique si le fragment est plus ou moins transparent. La gestion de la transparence par les ROP est le fait de plusieurs fonctionnalités distinctes, les deux principales étant le test alpha et l'alpha blending.

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

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

Maintenant, le test alpha ne permet pas de gérer des situations où on voit quelque chose à travers un objet transparent. Si un fragment transparent est placé devant un autre fragment, la couleur du pixel sera un mélange de la couleur du fragment transparent, et de la couleur du (ou des) fragments placé·s derrière. Le calcul à effectuer est très simple, et se limite en une simple moyenne pondérée par la transparence de la couleur des deux pixels. On parle alors d'alpha blending.

Application de textures.

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

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

Le tampon de stencil

[modifier | modifier le wikicode]

Le stencil est une fonctionnalité des API graphiques et des cartes graphiques depuis déjà très longtemps. Il sert pour générer des effets graphiques très variés, qu'il serait vain de lister 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 la coordonnée z est remplacée par une valeur arbitraire, dont le programmeur peut faire ce qu'il veut. La valeur est de plus une valeur entière, pas flottante. 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 à 0, inverser ses bits, remplacer par l'octet de stencil du fragment, etc. Les opérations possibles sont bien plus nombreuses qu'avec le tampon de profondeur, qui se contente de remplacer la coordonnée z par celle du fragment. C'est toujours possible, on peut remplacer l'octet de stencil dans le tampon de stencil par celui du fragment s'il passe le test. Mais pour les techniques de rendu plus complexes, c'est une autre opération qui est utilisée, comme incrémenter l'octet dans le tampon de stencil.

Les effets de brouillard

[modifier | modifier le wikicode]

Les effets de brouillard sont des effets graphiques assez intéressants. Ils sont nécessaires dans certains jeux vidéo pour l'ambiance (pensez à des jeux d'horreur comme Silent Hill), mais ils ont surtout été utilisés pour économiser des calculs. L'idée est de ne pas calculer les graphismes au-delà d'une certaine distance, sans que cela se voie.

L'idée est d'avoir un view frustum limité : le plan limite au-delà duquel on ne voit pas les objets est assez proche de la caméra. Mais si le plan limite est trop proche, cela donnera une cassure inesthétique dans le rendu. Pour masquer cette cassure, les programmeurs ajoutaient un effet de brouillard. Les objets au-delà du plan limite étaient totalement dans le brouillard, puis ce brouillard se réduisait progressivement en se rapprochant de la caméra, avant de s'annuler à partir d'une certaine distance.

Pour calculer le brouillard, on mélange la couleur finale du pixel avec une couleur de brouillard, la couleur de brouillard étant pondérée par la profondeur. Au-delà d'une certaine distance, l'objet est intégralement dans le brouillard : le brouillard domine totalement la couleur du pixel. En dessous d'une certaine distance, le brouillard est à zéro. Entre les deux, la couleur du brouillard et de l'objet devront toutes les deux être prises en compte dans les calculs. La formule de calcul exacte varie beaucoup, elle est souvent linéaire ou exponentielle.

Notons que ce calcul implique à la fois de l'alpha blending mais aussi la coordonnée de profondeur, ce qui en fait que son implémentation dans les ROPs est l'idéal. Aussi, les premières cartes graphiques calculaient le brouillard dans les ROP, en fonction de la coordonnée de profondeur du fragment. De nos jours, il est calculé par les pixel shaders et les ROP n'incorporent plus de technique de brouillard spécialisée. Vu que les pixels shaders peuvent s'en charger, cela fait moins de circuits dans les ROPs pour un cout en performance mineur. Et ce d'autant plus que les effets de brouillard sont devenus assez rares de nos jours. Autant les émuler dans les pixels shaders que d'utiliser des circuits pour une fonction devenue anecdotique.

Les autres fonctions des ROPs

[modifier | modifier le wikicode]

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

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

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

L'architecture matérielle d'un ROP

[modifier | modifier le wikicode]

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

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

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

Un ROP est typiquement organisé comme illustré ci-dessous et ci-contre. Il récupère les fragments calculés par les pixels shaders et/ou les unités de texture, via un circuit d'interconnexion spécialisé. Chaque ROP est connecté à toutes les unités de shader, même si la connexion n'est pas forcément directe. Toute unité de shader peut envoyer des pixels à n'importe quel ROP. Les circuits d'interconnexion sont généralement des réseaux d'interconnexion de type crossbar, comme illustré ci-contre (le premier rectangle rouge).

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

Les circuits de gestion de la profondeur et de la couleur gèrent diverses techniques de compression pour économiser de la mémoire et de la bande passante mémoire. Ajoutons à cela que ces deux unités contiennent des caches spécialisés, qui permettent de réduire fortement les accès mémoires, très fréquents dans cette étape du pipeline graphique.

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

Le circuit de gestion de la profondeur

[modifier | modifier le wikicode]

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

La z-compression

[modifier | modifier le wikicode]

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

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

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

AMD HyperZ

Le cache de profondeur

[modifier | modifier le wikicode]

Une autre solution complémentaire ajoute une ou plusieurs mémoires caches dans le ROP, dans le circuit de profondeur. Ce cache de profondeur stocke des portions du tampon de profondeur qui ont été lues ou écrite récemment. Comme cela, pas besoin de les recharger plusieurs fois : on charge un bloc une fois pour toutes, et on le conserve pour gérer les fragments qui suivent.

Sur certaines cartes graphiques, les données dans le cache de profondeur sont stockées sous forme compressées dans le cache de profondeur, là encore pour augmenter la taille effective du cache. D'autres cartes graphiques ont un cache qui stocke des données décompressées dans le cache de profondeur. Tout est question de compromis entre accès rapide au cache et augmentation de la taille du cache.

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


Le support matériel du lancer de rayons

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

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

Le lancer de rayons

[modifier | modifier le wikicode]

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

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

[modifier | modifier le wikicode]

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

Raycasting, rayon simple.

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

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

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

Simple raycasting with fisheye correction

Le raytracing proprement dit

[modifier | modifier le wikicode]

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

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

Principe du lancer de rayons.

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

Le raytracing récursif

[modifier | modifier le wikicode]
Image rendue avec le lancer de rayons récursif.

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

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

Lancer de rayon récursif.

Les optimisations du lancer de rayons

[modifier | modifier le wikicode]

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

Les volumes englobants

[modifier | modifier le wikicode]
Objet englobant : la statue est englobée dans un pavé.

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

L'usage seul de volumes englobant est une optimisation très performante. Au lieu de tester l'intersection avec chaque triangle, on teste l'intersection avec chaque objet, puis l'intersection avec chaque triangle quand on intersecte chaque volume englobant. On divise le nombre de tests par un facteur quasi-constant, mais de très grande valeur. Il faut noter que les calculs d'intersection sont légèrement différents entre un triangle et un volume englobant. Il faut dire que les volumes englobant sont généralement des pavées, ils utilisent des rectangles, etc. Les différences sont cependant minimales.

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

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

Hiérarchie de volumes englobants.

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

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

La cohérence des rayons

[modifier | modifier le wikicode]

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

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

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

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

[modifier | modifier le wikicode]
Volume délimité par la caméra (view frustum).

L'avantage principal du lancer de rayons est la détermination des surfaces visibles. La rastérisation a tendance à calculer inutilement des portions non-rendues de la scène 3D, malgré l'usage de techniques de culling ou de clipping aussi puissantes qu'imparfaites. De nombreux fragments/pixels sont éliminés à la toute fin du pipeline, grâce au z-buffer, après avoir été calculés et texturés. Le lancer de rayons ne calcule pas les portions invisibles de l'image par construction : pas besoin de culling, de clipping, ni même de z-buffer. D'ailleurs, l'absence de z-buffer réduit grandement le nombre d'accès mémoire.

Les réflexions et la réfraction sont gérées naturellement par le lancer de rayon récursif, de même que l'éclairage et les ombres, alors qu'elles demandent des ruses de sioux pour obtenir un résultat correct avec la rastérisation. Avec la rastérisation, cela demande d'utiliser des techniques de rendu au-dessus de la rastérisation de base, comme des ombres volumétriques, des lightmaps, des shadowmaps ou autres. L'absence de shadowmaps, qui demande de faire du render-to-texture, élimine certaines écritures mémoires et permet aux caches de texture d'être en lecture seule (leurs circuits sont donc assez simples et performants, les problèmes de cohérence des caches disparaissent).

Mais tous ces avantages sont compensés par le fait que le lancer de rayons est plus lent sur un paquet d'autres points. Déjà, les calculs d'intersection sont très lourds, ils demandent beaucoup de calculs et d'opérations arithmétiques, plus que pour l'équivalent en rastérisation. Et ils se parallélisent mal. Le moteur de jeu doit d'abord lancer les rayons primaires, puis les rayons secondaires qui en découlent, puis les rayons de la troisième passe, etc. Au final, cela se parallélise assez mal, car il y a un ordre de traitement des rayons (primaires, puis secondaires, puis...), alors que la rastérisation traite chaque triangle/pixel/source de lumière de manière indépendante.

Ensuite, le lancer de rayon n'est pas économe niveau accès mémoire. Ce qu'on économise avec l’absence de tampon de profondeur et l'absence de shadowmaps, on le perd au niveau de la BVH et des accès aux textures. La BVH est notamment un énorme problème. Les BVH étant ce que les programmeurs connaissent sous le nom d'arbre (binaire ou k-tree), elles dispersent les données en mémoire, alors que les GPU et CPU modernes préfèrent des données consécutives en RAM. Leur hiérarchie mémoire est beaucoup plus efficace pour accéder à des données proches en mémoire qu'à des données dispersées et il n'y a pas grand chose à faire pour changer la donne. La traversée d'une BVH se fait avec des accès en mémoire vidéo complétement désorganisés, là où le rendu 3D a des accès plus linéaires qui permettent d'utiliser des mémoires caches.

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

[modifier | modifier le wikicode]

En théorie, il est possible d'utiliser des shaders pour effectuer du lancer de rayons, mais la technique n'est pas très performante. Il vaut mieux utiliser du hardware dédié. Heureusement, cela ne demande pas beaucoup de changements à un GPU usuel. Le lancer de rayons se différencie peu de la rastérisationla différence principale étant que l'étape de rastérisation est remplacée par une étape de lancer de rayons. Les deux types de rendu ont besoin de calculer la géométrie, d'appliquer des textures et de faire des calculs d'éclairage. Sachant cela, les GPU actuels ont juste besoin d'ajouter des circuits dédiés au lancer de rayons, à savoir des unités de génération des rayons et des unités pour les calculs d'intersection.

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

[modifier | modifier le wikicode]

Toute la subtilité du lancer de rayons est de générer les rayons et de déterminer quels triangles ils intersectent. La seule difficulté est de gérer la transparence, mais aussi et surtout la traversée de la BVH. En soit, générer les rayons et déterminer leurs intersections n'est pas compliqué. Il existe des algorithmes basés sur du calcul vectoriel pour déterminer si intersection il y a et quelles sont ses coordonnées. C'est toute la partie de parcours de la BVH qui est plus compliquée à implémenter : faire du pointer chasing en hardware n'est pas facile.

Et cela se ressent quand on étudie comment les GPU récents gèrent le lancer de rayons. Ils utilisent des shaders dédiés, qui communiquent avec une unité de lancer de rayon dédiée à la traversée de la BVH. Le terme anglais est Ray-tracing Unit, ce qui fait que nous utiliseront l'abréviation RTU pour la désigner. Les shaders spécialisés sont des shaders de lancer de rayon et il y en a deux types.

  • Les shaders de génération de rayon s'occupent de générer les rayons
  • Les shaders de Hit/miss s'occupent de faire tous les calculs une fois qu'une intersection est détectée.

Le processus de rendu en lancer de rayon sur ces GPU est le suivant. Les shaders de génération de rayon génèrent les rayons lancés, qu'ils envoient à la RTU. La RTU parcours la BVH et effectue les calculs d'intersection. Si la RTU détecte une intersection, elle lance l'exécution d'un shader de Hit/miss sur les processeurs de shaders. Ce dernier finit le travail, mais il peut aussi commander la génération de rayons secondaires ou d'ombrage. Suivant la nature de la surface (opaque, transparente, réfléchissante, mate, autre), ils décident s'il faut ou non émettre un rayon secondaire, et commandent les shaders de génération de rayon si besoin.

Implémentation hardware du raytracing

La RTU : la traversée des structures d'accélérations et des BVH

[modifier | modifier le wikicode]

Lorsqu'un processeur de shader fait appel à la RTU, il lui envoie un rayon encodé d'une manière ou d'une autre, potentiellement différente d'un GPU à l'autre. Toujours est-il que les informations sur ce rayon sont mémorisées dans des registres à l'intérieur de la RTU. Ce n'est que quand le rayon quitte la RTU que ces registres sont réinitialisés pour laisser la place à un autre rayon. Il y a donc un ou plusieurs registres de rayon intégrés à la RTU.

La RTU contient beaucoup d'unités de calculs d'intersections, des circuits de calcul qui font les calculs d'intersection. Il est possible de tester un grand nombre d'intersections de triangles en parallèles, chacun dans une unité de calcul séparée. L'algorithme de lancer de rayons se parallélise donc, au moins un minimum, et la RTU en profite. En soi, les circuits de détection des intersections sont très simples et se résument à un paquet de circuits de calcul (addition, multiplications, division, autres), connectés les uns aux autres. Il y a assez peu à dire dessus. Mais les autres circuits sont très intéressants à étudier.

Il y a deux types d'intersections à calculer : les intersections avec les volumes englobants, les intersections avec les triangles. Volumes englobants et triangles ne sont pas encodés de la même manière en mémoire vidéo, ce qui fait que les calculs à faire ne sont pas exactement les mêmes. Et c'est normal : il y a une différence entre un pavé pour le volume englobant et trois sommets/vecteurs pour un triangle. La RTU des GPU Intel, et vraisemblablement celle des autres GPU, utilise des circuits de calcul différents pour les deux. Elle incorpore plus de circuits pour les intersections avec les volumes englobants, que de circuits pour les intersections avec un triangle. Il faut dire que lors de la traversée d'une BVH, il y a une intersection avec un triangle par rayon, mais plusieurs pour les volumes englobants.

L'intérieur de la RTU contient aussi de quoi séquencer les accès mémoire nécessaires pour parcourir la BVH. Traverser un BVH demande de tester l'intersection avec un volume englobant, puis de décider s'il faut passer au suivant, et rebelotte. Une fois que la RTU tombe sur un triangle, elle l'envoie aux unités de calcul d'intersection dédiées aux triangles.

Les RTU intègrent des caches de BVH, qui mémorisent des portions de la BVH au cas où celles-ci seraient retraversées plusieurs fois de suite par des rayons consécutifs. La taille de ce cache est de l'ordre du kilo-octet ou plus. Pour donner des exemples, les GPU Intel d'architecture Battlemage ont un cache de BVH de 16 Kilo-octets, soit le double comparé aux GPU antérieurs. Le cache de BVH est très fortement lié à la RTU et n'est pas accesible par l'unité de texture, les processeurs de shader ou autres.

Raytracing Unit

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

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

[modifier | modifier le wikicode]

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

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

Un historique rapide des cartes graphiques dédiées au lancer de rayon

[modifier | modifier le wikicode]

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

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

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

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

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

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

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


L'antialiasing

Effet d'escalier sur les lignes.

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

Le supersampling

[modifier | modifier le wikicode]

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

Le rendu de l'image se fait à une résolution 2, 4, 8, 16 fois plus grande. La résolution n'apparait qu'après le rastériseur, et impacte tout le reste du pipeline à sa suite : pixel shaders, unités de textures et ROP. Le rastériseur produit 2, 4, 8, 16 fois plus de pixels, les unités de textures vont 2, 4, 8, 16 fois plus de travail, idem pour les pixels shaders. Par contre, la réduction de l'image s'effectue dans les ROP.

Pour effectuer la réduction de l'image, le ROP découpe l'image en rectangles de 2, 4, 8, 16 pixels, et « mélange » les pixels pour obtenir une couleur uniforme. Ce « mélange » est généralement une simple moyenne pondérée, mais on peut aussi utiliser des calculs plus compliqués comme une série d'interpolations linéaires similaire à ce qu'on fait pour filtrer des textures.

Pour simplifier les explications, nous allons appeler "sous-pixels" les pixels de l'image rendue dans le pipeline, et pixels les pixels de l'image finale écrite dans le framebuffer. On parle aussi de samples au lieu de sous-pixels.

Supersampling
Supersampling

La position des sous-pixels

[modifier | modifier le wikicode]

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

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

Le multisampling (MSAA)

[modifier | modifier le wikicode]

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

Le calcul de la couleur finale du pixel se fait dans le ROP. Pour cela, le ROP a besoin d'une information quant à la position des sous-pixels. Le pixel final est associé à un triangle précis par la rastérisation. Cependant, cela ne signifie pas que tous les sous-pixels sont associés à ce triangle. En effet, les sous-pixels ne sont pas à la même place que le pixel dans la résolution inférieure. La position des sous-pixels est une chose dont nous parlerons plus en détail ci-dessous.

Toujours est-il que l'étape de rastérisation précise si chaque sous-pixel est associé au triangle du pixel. Il se peut que tous les sous-pixels soient sur le triangle, ce qui est signe qu'on est pile sur l'objet, que les sous-pixels sont tous l'intérieur du triangle. Par contre, si certains sous-pixels sont en dehors, c'est signe que l'on est au bord d'un objet. Par exemple, le sous-pixel le plus à gauche sort du triangle, alors que les autres sont dessus. L'unité de rastérisation calcule un masque de couverture, qui précise, pour chaque pixel, quels sont les sous-pixels qui sont ou non dans le triangle. Si un pixel est composé de N sous-pixels, alors ces N sous-pixels sont numérotés de 0 à N-1 en passant dans l'ordre des aiguilles d'une montre. Le masque est un nombre dont chaque bit est associé à un sous-pixel. Le bit associé est à 1 si le sous-pixel est dans le triangle, 0 sinon.

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

Les avantages et inconvénients comparé au SSAA

[modifier | modifier le wikicode]

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

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

L'antialiasing sur les textures transparentes

[modifier | modifier le wikicode]

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

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

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

Les optimisations du multisampling

[modifier | modifier le wikicode]

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

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

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

Le Coverage Sampled Anti-Aliasing (CSAA) et de Enhanced Quality Anti-Aliasing (EQAA)

[modifier | modifier le wikicode]

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

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

[modifier | modifier le wikicode]

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

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

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

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

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

La compression du framebuffer temporaire

[modifier | modifier le wikicode]

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

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

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

L'antialiasing temporel

[modifier | modifier le wikicode]

L'antialiasing temporel (TAA pour temporal Anti-Aliasing) est une technique répartit l'antialiasing sur plusieurs frames, sur plusieurs images. L'idée de l'antialiasing temporel est que chaque image est mélangée avec les images rendues avant elle pour donner un effet d'antialiasing. Mais il ne s'agit pas d'un mélange bête et méchant où chaque image est la moyenne des précédentes. La carte graphique rend chaque image à la même résolution que l'écran, mais chaque image a une position légèrement différente de la précédente, ce qui fait que le mélange de plusieurs images consécutives permet d'affiner la qualité d'image.

L'antialiasing temporel subdivise chaque pixel en sous-pixel, sauf qu'au lieu de traiter tous les sous-pixels à chaque image comme le font le super-sampling et le MSAA, elle ne traite qu'un sous-pixel par pixel à chaque image. Concrètement, si on prend un antialiasing 4x, où chaque pixel est subdivisé en 4 sous-pixels, le premier sous-pixel sera calculé par la première image, le second sous-pixel par la seconde image, etc. Il reste ensuite à appliquer l'opération de mélange sur les 4 images rendues auparavant. Naïvement, on pourrait croire que le filtre de mélange des sous-pixels est effectué toutes les 4 images, mais on peut le faire à chaque rendu d'image en prenant les 4 images précédemment calculées.

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

L'avantage du TAA est qu'il est relativement léger en calculs. Le cout est surtout lié au filtre de reconstruction de l'image finale, qui est assez léger en calculs. Cette forme d'antialiasing améliore la qualité de toute l'image, contrairement au MSSA, mais comme le SSAA. Niveau inconvénients, si le TAA marche très bien pour des scènes statiques, il se débrouille assez mal sur les scènes où la caméra bouge vite. Des mouvements trop rapides font que l'image a un flou de mouvement très important, sans compter que les objets en mouvement laissent une sorte de trainée de mouvement visible derrière eux. Notons cependant que le TAA marche d'autant mieux en qualité que le nombre d'images par secondes est élevé.

L'antialiasing par post-processing

[modifier | modifier le wikicode]

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

Les avantages et inconvénients

[modifier | modifier le wikicode]

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

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

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

[modifier | modifier le wikicode]

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

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


Le multi-GPU

Les techniques dites de multi-GPU, tels le SLI et le Crossfire, permettent de mettre plusieurs cartes graphiques dans un PC pour gagner en performances. Le multi-GPU a eu son heure de gloire durant les années 2000. Dès 1998, il était possible de mettre dans un même PC deux cartes graphiques Voodoo 2, de marque 3dfx. Autre exemple : en 2006, le fabricant de cartes graphiques S3 avait introduit cette technologie pour ses cartes graphiques Chrome. Mais le multi-GPU est tombé en désuétude après 2010, du moins pour le grand public.

Le multi-GPU était destiné aux jeux vidéo, même si les applications de réalité virtuelle, l'imagerie médicale haute précision ou les applications de conception par ordinateur pouvaient en tirer profit. C'est ce genre de choses qui se cachent derrière les films d'animation ou les effets spéciaux créés par ordinateur : Pixar ou Disney ont vraiment besoin de rendre des images très complexes, avec beaucoup d'effets, ce qui demande la coopération de plusieurs cartes graphiques.

La répartition des calculs sur les GPU

[modifier | modifier le wikicode]

Tout le problème des solutions multi-GPU est de répartir les calculs sur plusieurs cartes graphiques, ce qui est loin d'être chose facile. Il existe diverses techniques, chacune avec ses avantages et ses inconvénients, que nous allons aborder de suite. Mais elles peuvent être classées en deux types. Le Split Frame Rendering répartit le calcul d'une image sur plusieurs GPU. L'Alternate Frame Rendering calcule une image sur un GPU, les GPU calculent chacun des images différentes.

Le Split Frame Rendering

[modifier | modifier le wikicode]

Le Split Frame Rendering (SFR) découpe l'image en morceaux, qui sont répartis sur des cartes graphiques différentes. Le SFR et l'AFR n'utilisent pas les GPU de la même manière. Le SFR demande d'utiliser au moins deux GPU, soit deux GPU sur une même carte imprimée, soit deux cartes graphiques séparées. Les GPU ont une organisation entre maitre-esclave : un GPU est un maitre, les autres sont des esclaves. Tous les GPU font des calculs de rendu pour un morceau de l'image final, mais seul le GPU maitre récupère les résultats calculés par les GPU esclaves et combine le tout pour donner l'imager finale. Pour faire la combinaison, le GPU contient des circuits de composition d'image dédié et c'est lui qui a le framebuffer final.

Historiquement, la première technique multi-GPU inventée est apparue sur les cartes graphiques Voodoo 2 et s'appelait le Scan Line Interleave, ou SLI. Elle fonctionnait avec seulement deux GPU maximum. Le premier GPU rendait les lignes paires et l'autre les lignes impaires. Il faut noter qu'outre des performances améliorées, utiliser le SLI permettait de doubler la résolution, faisant passer d'une résolution maximale de 800 par 600 maximum pour une voodoo 2, à 1024 par 768. En théorie, on peut adapter la technique à un nombre arbitraire de GPU, en faisant calculer par chaque GPU une ligne sur 3, 4, 5, etc.

Scanline interleave

Il est aussi possible de simplement couper l'image en deux : la partie haute de l'image ira sur un GPU, et la partie basse sur l'autre. Cette technique peut être adaptée avec plusieurs GPU, en découpant l'image en autant de parties qu'il y a de GPU. Intuitivement, on se dit que l'écran est coupé en deux portions égales. Mais en faisant cela, des complications peuvent survenir dans certains jeux où le bas de l'image est plus chargé que le haut, les FPS notamment. Dans ces jeux, le haut représente le ciel ou un plafond assez vide de géométrie, toute la géométrie et les textures sont dans le bas de l'image. Ainsi, le rendu de la partie haute sera plus rapide que celui du bas, une des cartes 3D finira par attendre l'autre.

Mieux répartir les calculs devient alors nécessaire. Pour cela, on peut choisir un découpage statique adapté, dans lequel la partie haute envoyée au premier GPU est plus grande que la partie basse. Cela peut aussi être fait dynamiquement : le découpage de l'image est alors choisi à l’exécution, et la balance entre partie haute et basse s'adapte aux circonstances. Pour cela, le driver dispose d'algorithmes plus ou moins complexes capables de déterminer assez précisément comment découper l'image au mieux. Mais il va de soit que ces algorithmes ne sont pas parfaits.

Screen spliting

La technique du Checker Board découpe l'image en carrés de plusieurs pixels, de taille identique. Le premier GPU calcule les carrés pairs, le second GPU calcule les carrés impairs. Les carrés ont une taille fixe, de 16 ou 32 pixels de largeur, identique pour tous les carrés d'une image. L'avantage est que la technique équilibre bien la charge de travail entre les deux GPU : les deux GPU calculent une portion égale de l'écran, autant en haut qu'en bas.

L'Alternate Frame Rendering

[modifier | modifier le wikicode]

L'alternate Frame Rendering (AFR) consiste à répartir des images complètes sur les différents GPUs. Dans sa forme la plus simple, un GPU calcule une image, et l'autre GPU calcule la suivante en parallèle. Les problèmes liés à la répartition des calculs entre cartes graphiques disparaissent alors. L'AFR a été inventé par ATI, sur ses cartes graphiques Rage Fury, afin de faire concurrence à la Geforce 256.

Un des défauts de cette approche est le micro-stuttering. Dans des situations où le processeur est peu puissant, les temps entre deux images peuvent se mettre à varier très fortement, et d'une manière beaucoup moins imprévisible. Le nombre d'images par seconde se met à varier rapidement sur de petites périodes de temps. Alors certes, on ne parle que de quelques millisecondes, mais cela se voit à l’œil nu. Cela cause une impression de micro-saccades, que notre cerveau peut percevoir consciemment, même si le temps entre deux images est très faible. Suivant les joueurs, des différences de 10 à 20 millisecondes peuvent rendre une partie de jeu injouable.

Pour diminuer l'ampleur de ce phénomène, les cartes graphiques récentes incorporent des circuits pour limiter la casse. Ceux-ci se basent sur un principe simple : pour égaliser le temps entre deux images, et éviter les variations, le mieux est d’empêcher des images de s'afficher trop tôt. Si une image a été calculée en très peu de temps, on retarde son affichage durant un moment. Le temps d'attente idéal est alors calculé en fonction de la moyenne du framerate mesuré précédemment.

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

L'implémentation matérielle du multi-GPU

[modifier | modifier le wikicode]

Le multi-GPU peut se présenter sous plusieurs formes. Il est possible d'utiliser des GPU différents, des GPU identiques, de placer plusieurs GPU sur un même circuit imprimé, et j'en passe. Voyons ces méthodes en revue.

La plus simple des méthodes consiste à placer plusieurs GPU sur une même carte graphique. La technique a été utilisée dès les premières cartes accélératrices 2D. Par exemple, la Voodoo 5500 était une carte avec deux GPU sur son circuit imprimé et elle est sortie en Juin 2000. 3dfx a même envisagé des prototypes avec 4 GPU, portant le nom de Voodoo 5 6000, mais ils ne sont pas sortis dans le commerce. On parle alors de carte double GPU (dual GPU).

3dfx Voodoo 5500

Il est aussi possible d'utiliser plusieurs cartes graphiques séparées, connectées à la carte mère via PCI-Express. Pour échanger des informations, les premières implémentations demandaient de connecter les deux cartes avec un connecteur spécialisé. ATI et NVIDIA faisaient ainsi sur les premières implémentations de leurs technologies Crossfire et SLI. Le connecteur n'était pas standardisé, dans le sens où ATI et NVIDIA avaient chacun leur connecteur dédié, incompatibles entre eux.

Connecteur CrossFireX pour le multi-GPU ATI/AMD.

Par la suite, le connecteur SLI/CrossFire a rapidement été abandonné, pour laisser la place à des échanges passant par le PCI-Express. Le PCI Express permet en effet à deux périphériques de communiquer entre eux sans passer par l'intermédiaire du processeur, de la RAM, ou autre. En configurant des échanges DMA adéquats, plusieurs cartes graphiques dédiées peuvent communiquer entre elles via PCI-Express. ATI/AMD et NVIDIA utilisaient pour cela des technologies propriétaires, comme l'AMD DirectGMA.

AMD DirectGMA.

Utiliser un connecteur dédié épargnait la bande passante PCI-Express, dans le sens où le connecteur fournissait de la bande passante en plus, utilisée uniquement pour la communication entre GPU. Les transferts PCI Express normaux n'entraient pas en compétition avec ceux du multi-GPU. Mais le gain était surtout pertinent sur les premières versions du PCI-Express dont le débit était limité. Sur les versions ultérieures du PCI-Express, le débit a augmenté suffisamment pour pouvoir gérer à la fois les transferts GPU normaux et les échanges multi-GPU sans trop de casse.

L'ATI Crossfire

[modifier | modifier le wikicode]

La technologie multi-GPU d'ATI/AMD était appelée le Crossfire. Elle a été développée durant les années 2000 et a progressivement évolué dans le temps.

La toute première version n'était pas compatible avec toutes les cartes graphiques vendues par ATI. ATI vendait ses cartes graphiques en deux éditions : une édition Crossfire et une édition normale. Elle demandait d'utiliser une carte maitre avec une carte esclave. La carte esclave était une carte graphique normale ou Crossfire, mais la carte maitre devait obligatoirement être une carte Crossfire. La carte maitre était celle branchée sur l'écran. En tant que carte Crossfire, elle incorporait des circuits de composition d'image, afin de combiner les portions d'image calculée par elle-même avec celles calculées par la carte esclave.

Cependant, les circuits de composition d'image étaient un peu faibles. Par exemple, la Radeon XT 8500 ne supportait au maximum que des résolutions de 600×1200 - 60 Hz, or 1920×1440 - 52 Hz. Vu que le taux de rafraichissement était faible à de telles résolutions, surtout pour des écrans CRT qui ont tendance à faire mal aux yeux, le Crossfire était surtout utile pour les résolutions plus basses. Dommage pour du multi-GPU, censé aider pour les hautes résolutions.

Par la suite, les cartes Crossfire ont disparues et toutes les cartes graphiques ATI étaient compatibles Crossfire. La communication entre les GPU se faisait via le bus PCI Express. La technologie devint alors bien plus pratique, les cartes Crossfire étant rares et peu disponibles. Et ce malgré une petite perte en performance liée aux transferts via le PCI Express. Par la suite, l'introduction du CrossfireX ajouta le support d'un connecteur entre cartes graphiques, afin de passer outre le bus PCI Express. Cependant, cela ne dura que pour les générations des AMD HD 2000 à 7000. ATI/AMD a abandonné l'usage d'un connecteur CrossFire avec ses cartes utilisant le PCI-Express 3.0.

Illustration du multi-GPU où deux cartes graphiques communiquent via un lien indépendant du bus PCI-Express. On voit que le débit du lien entre les deux cartes graphique est ajouté au débit du bus PCI-Express.
GFDL GFDL Vous avez la permission de copier, distribuer et/ou modifier ce document selon les termes de la licence de documentation libre GNU, version 1.2 ou plus récente publiée par la Free Software Foundation ; sans sections inaltérables, sans texte de première page de couverture et sans texte de dernière page de couverture.