Pour rappel, nos fragments ne sont pas tout à fait des pixels. Il s'agit de données qui vont permettre, une fois combinées, d'obtenir la couleur finale d'un pixel. Ceux-ci contiennent diverses informations :
- position à l'écran
- profondeur
- couleur
- valeur de stencil
- transparence alpha
Une fois que nos fragments se sont vus appliquer une texture, il faut les enregistrer dans la mémoire, afin de les afficher. On pourrait croire qu'il s'agit là d'une opération très simple, mais ce n'est pas le cas. Il reste encore un paquet d’opérations à effectuer sur nos pixels : la profondeur des fragments doit être gérée, de même que la transparence, etc.
Ces opérations sont les opérations suivantes :
- la gestion des profondeurs des fragments ;
- les mélanges de couleurs et la gestion de la transparence ;
- l'antialiasing ;
- et enfin la gestion du stencil buffer.
Elles sont réalisées dans un circuit qu'on nomme le Render Output Target. Celui-ci est le tout dernier circuit, celui qui enregistre l'image finale dans la mémoire vidéo. Ce chapitre va aborder ce circuit dans les grandes lignes. Dans ce chapitre, nous allons voir celui-ci.
Test de visibilité
Pour commencer, il va falloir trier les fragments par leur profondeur, 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 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. De tous les fragments, un seul doit être choisit : celui du mur qui est devant. Reste à faire ce choix.
Pour cela, la profondeur d'un fragment dans le champ de vision est calculée à la rasterization. Cette profondeur est appelée la coordonnée z. Par convention, plus la coordonnée z est petite, plus l'objet est prêt de l'écran. Cette coordonnée z est un nombre, codé sur plusieurs bits.
Petite précision : il est assez rare qu'un objet soit caché seulement par un seul objet. En moyenne, un objet est caché par 3 à 4 objets dans un rendu 3d de jeu vidéo.
Z-buffer
Pour savoir quels fragments sont à éliminer (car cachés par d'autres), notre carte graphique va utiliser ce qu'on appelle un depth-buffer. Il s'agit simplement d'un tableau, stocké en mémoire vidéo, qui va mémoriser la coordonnée z de l'objet le plus proche déjà rendu pour chaque pixel.
Par défaut, ce depth-buffer est initialisé à une valeur de profondeur suffisamment grande. Par défaut, ce depth-buffer est remplit avec la valeur de profondeur maximale. Au fur et à mesure que les objets seront calculés, la coordonnée z stockée dans ce depth-buffer sera mise à jour, conservant ainsi la trace de l'objet le plus proche de la caméra.
Si jamais un pixel à calculer a une coordonnée z plus grande que celle du depth-buffer, cela veut dire qu'il est situé derrière un objet déjà rendu, et il n'a pas à être calculé. Dans le cas contraire, le fragment reçu est plus près de la caméra, et il est rendu : sa coordonnée z va remplacer l'ancienne valeur z dans le depth-buffer.
Si deux objets sont suffisamment proches, le depth-buffer n'aura pas la précision suffisante pour discriminer les deux objets : pour lui, les deux objets seront à la même place. Conséquence : il faut bien choisir un des deux objets. Si l'objet choisi est le mauvais, des artefacts visuels apparaissent. Voici ce que cela donne :
On peut préciser qu'il existe des variantes du depth-buffer, qui utilisent un codage de la coordonnée de profondeur assez différent. On peut notamment citer :
- l'Irregular Z-buffer ;
- le W-buffer.
Ils se distinguent du depth-buffer 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éras, et plus faible pour les fragments éloignés. Mais il s'agit-là de détails assez mathématique que je me permets de passer sous silence.
Circuit de gestion de la profondeur
La profondeur est gérée par un circuit spécialisé. Celui-ci va devoir :
- récupérer les coordonnées du fragment reçu à l'écran ;
- lire en mémoire la coordonnée z correspondante dans le depth-buffer ;
- comparer celle-ci avec la coordonnée z du fragment reçu ;
- et décider s'il faut mettre à jour le frame-buffer et le depth-buffer.
Comme vous le voyez, ce circuit va devoir effectuer des lectures et des écritures en mémoire vidéo. Or, la mémoire est déjà mise à rude épreuve avec les lectures de vertices et de textures. Diverses techniques existent pour limiter l'utilisation de la mémoire, en diminuant :
- la quantité de mémoire vidéo utilisée ;
- le nombre de lectures et écritures dans celle-ci.
Z Compression
Une première solution consiste à compresser le depth-buffer. Cette compression est une compression sans-perte. Évidemment, les données devront être compressées avant d'être stockée ou lue dans le depth-buffer.
Pour donner un exemple, nous allons prendre la z-compression des cartes graphiques ATI radeon 9800. Cette technique de compression découpait des morceaux de 8 * 8 fragments, et les encodait avec un algorithme nommé DDPCM : Differential differential pulse code modulation.
Ce découpage du depth-buffer en morceaux carrés est souvent utilisé dans la majorité des circuits de compression et de décompression de la profondeur. Toutefois, il arrive que certains de ces blocs ne soient pas compressés : tout dépend si la compression permet de gagner de la place ou pas . On trouve un bit au tout début de ce bloc qui indique s'il est compressé ou non. C'est le circuit de compression qui décidé s'il faut compresser un bloc ou non.
Fast Z Clear
Entre deux images, le depth-buffer doit être remis à zéro. La technique la moins performante consiste à réécrire tout son contenu avec la valeur maximale.
Pour éviter cela, chaque bloc contient un bit : si ce bit est positionné à 1, alors le ROP va faire comme si le bloc avait été remis à zéro. Ainsi, au lieu de réécrire tout le bloc, il suffit simplement de récrire un bit par bloc. Le gain en nombre d'accès mémoire peut se révéler assez impressionnant.
Z-cache
Une dernière optimisation possible consiste à ajouter une mémoire cache qui stocke les derniers blocs de coordonnées z lues ou écrites depuis la mémoire. Comme cela, pas besoin de les recharger plusieurs fois : on charge un bloc une fois pour toute, et on le conserve pour gérer les fragments qui suivent.
Transparence
En plus de la profondeur, il faut aussi gérer la transparence. Cette transparence est gérée comme une sorte de couleur ajoutée aux composantes RGB : elle est codée par un nombre, le canal alpha, qui indique su un pixel est plus ou moins transparent.
Et là, c'est le drame : que se passe-il si un fragment transparent est placé devant un autre fragment ? Je vous le donne en mille : la couleur du pixel calculée avec l'aide du depth-buffer ne sera pas la bonne, vu que le pixel transparent ne cache pas totalement l'autre.
Alpha Blending
Mais comment calculer la couleur finale du pixel à partir de fragments contenant de la transparence ? Sur le principe, la couleur 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.
Première précisions : A est la couleur de l'élément qui est devant, B est la couleur de l'élément qui est derrière, a et b sont les valeurs alpha respectives de A et B. Le calcul de la couleur finale s'effectue pour chaque composante R, G, B et alpha.
La valeur de transparence finale se calcule avec cette formule : $a_0 = a + b * ( 1 - a )$
Les composantes R, G, et B se calculent comme suit : $C = \frac{( A * a ) + ( B * b * (1 - a)}{a_0} $
Color buffer
Les pixels étant 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 color buffer, une portion de la mémoire vidéo.
Calculer la couleur finale s'effectue simplement : à chaque fragment envoyé dans le ROP, celui-ci va :
- lire l'ancienne couleur, contenue dans le color buffer ;
- calculer la couleur finale en fonction de la couleur du fragment qui est devant, et de celle lue depuis le color buffer ;
- et enregistrer le résultat.
Alpha Test
Certaines vielles cartes graphiques possédaient une "optimisation" assez intéressante : l'alpha test. Cette technique consistait à ne pas enregistrer en mémoire les fragments dont la couleur alpha était inférieure à un certain seuil. De nos jours, cette technologie est devenue obsolète.
Effets de brouillard
Le ROP peut aussi ajouter des effets de brouillard dans notre scène 3D. Ce brouillard sera simplement modélisé par une couleur, la couleur de brouillard, qui est mélangée avec la couleur du pixel calculée par un simple calcul de moyenne. La carte graphique stocke une couleur de brouillard de base, sur laquelle elle effectuera un calcul pour déterminer la couleur de brouillard à appliquer au pixel.
En dessous d'une certaine distance fogstart, la couleur de brouillard est nulle : il n'y a pas de brouillard. Au-delà d'une certaine distance fogend, l'objet est intégralement dans le brouillard : seul le brouillard est visible. Entre les deux, la couleur du brouillard et de l'objet devront toutes les deux être prises en compte dans les calculs.
Le calcul de la couleur de brouillard dans l'intervalle mentionné plus haut peut s'effectuer de diverses façons :
- par un calcul linéaire : $\frac{fogend - z} {fogendd-fogstart}$
- avec une exponentielle : $\frac{1} {e^(z * fogdensity)}$
Les premières cartes graphiques calculaient une couleur de brouillard pour chaque vertice, dans les unités de vertices. Sur les cartes plus récentes la couleur de brouillard définitivement était calculée dans les ROP, en fonction de la coordonnée de profondeur du fragment.
Color ROP
Ces opérations de test et de blending sont effectuées par un circuit spécialisé qui travaille en parallèle du Depth-ROP : le Color ROP. Il va ainsi mélanger et tester nos couleurs pendant que le Depth-ROP effectue ses comparaisons entre coordonnées z.
Et comme toujours, les lectures et écritures de couleurs peuvent saturer la mémoire vidéo. On peut diminuer la charge de la mémoire vidéo en ajoutant une mémoire cache, ou en compressant les couleurs. Cela a une tête de déjà-vu…
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.
Anti-aliasing
Le ROP prend en charge l'anti-aliasing, 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. Le filtre d'anti-aliasing rajoute une sorte de dégradé pour adoucir les bords des lignes.
Types d'anti-aliasing
Il existe un grand nombre de techniques d'anti-aliasing différentes. Toutes ont des avantages et des inconvénients en terme de performances ou de qualité d'image. Dans ce qui va suivre, nous allons voir ces différentes techniques.
SSAA - Super Sampling Anti Aliasing
Première technique : calculer l'image à une résolution supérieure, et la réduire avant de l'afficher. Par exemple, si je veux rendre une image en 1280*1024, la carte graphique va calculer une image en 2560 * 2048, avant de la réduire. On appelle cet anti-aliasing le Super sampling anti-aliasing, ou SSAA.
Si vous regardez les options de vos pilotes de carte graphique, vous verrez qu'il existe plusieurs réglages pour l'antia-alising : 2X, 4X, 8X, etc. Cette option signifie que l'image calculé par la carte graphique contiendra respectivement 2, 4, ou 8 fois plus de pixels que l'image originale.
Pour effectuer la réduction de l'image, notre ROP va découper l'image en blocs de 4, 8, 16 pixels, et va effectuer un "mélange" des couleurs de tout le bloc. Ce "mélange" est en réalité une série d'interpolations linéaires, comme montré dans le chapitre sur le filtrage des textures, mais avec des couleurs de fragments.
Niveau avantage, cette technique filtre toute l'image, y compris l'intérieur des textures. Niveau désavantage, le SSAA va augmenter la résolution des images à traiter, ce qui signifie augmentation de la consommation de la mémoire vidéo (frame-buffer, color buffer, z-buffer, etc) et du temps de calcul (on calcule 4 fois plus de pixels).
MSAA - MultiSampling Anti-Aliasing
Pour réduire la consommation de mémoire induite par le SSAA, il est possible d'améliorer celui-ci pour faire en sorte qu'il ne filtre pas toute l'image, mais seulement les bords des objets. Après tout, les bords des objets sont censés être les seuls endroits où l'effet d'escalier se fait sentir, alors pourquoi filtrer le reste ? Cette optimisation a donné naissance au Multi-Sampling Anti-Aliasing, abrévié en MSAA.
Avec le MSAA, l'image à afficher est rendue dans une résolution supérieure, qui contiennent 4, 9, 16, 25 fois plus de fragments. Ces fragments sont regroupés en blocs de 4, 9, 16, etc : chaque bloc correspond à un pixel à afficher. Nous allons appeler les fragments d'un même bloc des sous-pixels.
Contrairement au SSAA, les textures ne s'appliquent pas aux sous-pixels, mais à un bloc complet : tous les sous-pixels d'un bloc ont la même couleur. Avec le SSAA, chaque sous-pixel se verrait appliquer un morceau de texture indépendamment des autres, ce qui peut leur donner des couleurs différentes.
Le MSAA va jouer sur l'enregistrement de cette couleur dans le color-buffer. La couleur finale dépend de la position du sous-pixel : est-il dans le triangle qui lui a donné naissance (à l'étape de rasterization), ou en dehors du triangle ? Si le sous-pixel est complétement dans le triangle, sa couleur sera inchangée. Si le sous-pixel est en-dehors du triangle, sa couleur est mise à zéro. Pour obtenir la couleur finale du pixel à afficher, le ROP va m"élanger les couleurs des sous-pixels du bloc (série d'interpolation linéaire, comme dit plus haut).
Niveau avantages, le MSAA n'utilise qu'un seul filtrage de texture par pixel, alors que le SSAA effectue un filtrage par sous-pixel. Niveau désavantage, il faut remarquer que le MSAA ne filtre pas l'intérieur des textures, ce qui pose problème avec les textures transparentes. Pour résoudre ce problème, les fabricants de cartes graphiques ont crées diverses techniques pour appliquer l'anti-aliasing à l'intérieur des textures alpha.
FAA : Fragment Anti-Aliasing
Comme on l'a vu, le MSAA utilise une plus grande quantité de mémoire vidéo. Le Fragment Anti-Aliasing, ou FAA, cherche à diminuer la quantité de mémoire vidéo utilisée par le MSAA. Il fonctionne sur le même principe que le MSAA, à un détail prêt : il ne stocke pas les couleurs pour chaque sous-pixel, mais utilise à la place un masque.
Dans le color-buffer, le MSAA stocke une couleur par sous-pixels, couleur qui peut prendre deux valeurs : soit la couleur calculée lors du filtrage de texture, soit la couleur noire (par défaut). A la place, le FAA stockera une couleur, et un petit groupe de quelques bits. Chacun de ces bits sera associé à un des sous-pixels du bloc, et indiquera sa couleur :
- 0 si le sous-pixel a la couleur noire (par défaut) ;
- et 1 si la couleur est à lire depuis le color-buffer.
Le ROP utilisera ce masque pour déterminer la couleur du sous-pixel correspondant.
Avec le FAA, la quantité de mémoire vidéo utilisée est fortement réduite, et la quantité de donnée à lire et écrire pour effectuer l'anti-aliasing diminue aussi fortement. Mais le FAA a un défaut : il se comporte assez mal sur certains objets géométriques, donnait naissance à des artefacts visuels.
Position des samples
Un point important concernant la qualité de l'anti-aliasing concerne la position des sous-pixels sur l'écran. Comme vous l'avez vu dans le chapitre sur la rasterization, notre écran peut être vu comme une sorte de carré, dans lequel on peut repérer des points. Chaque point peut être repéré par deux nombres flottants.
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 un phrase : où placer nos sous-pixels pour obtenir une meilleure qualité d'image possible.
Simple Grid
La solution la plus simple consiste à placer nos sous-pixels à l'endroit qu'il occuperaient si l'image était réellement rendue avec la résolution simulée par l'anti-aliasing.
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.
Rotated Grid
Pour mieux gérer les bords penchés, on peut positionner nos sous-pixels comme ce ci :
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 de $arctan(1/2)$ (26,6 degrés), et d'un facteur de rétrécissement de $√5/2$.
Quincux
Le Quincux utilise cette grille :
Avec cette grille, les sous-pixels sont partagés avec les pixels voisins. Une fois qu'on a calculé un sous-pixel dans un bloc, le résultat est réutilisable directement dans les calculs de couleur de l'autre bloc. Ainsi, on a besoin de calculer que deux sous-pixels pour un seul pixel : ceux indiqués en rouge sur le schéma au-dessus.
FLIPSQUAD
L'algorithme FLISQUAD réutilise l'astuce du Quincux, mais en utilisant une grille tournée, comme dans l'algorithme Rotated Grid.
Aléatoire
D'autres techniques placent les sous-pixels à des positions aléatoires dans le pixel. Et contrairement à ce qu'on pourrait croire, la qualité est souvent au rendez-vous.
En plus du Z-buffer et du color buffer, certaines cartes graphiques permettent le support matériel d'un troisième buffer, présent en mémoire vidéo : le stencil buffer. Celui-ci n'a pas vraiment d'utilité bien définie, et peut servir à beaucoup de choses diverses et variées. Dans la majorité des cas, celui-ci sert pour effectuer des calculs d'éclairage. D'ordinaire, ce buffer mémorise des entiers, un par pixel.