Les cartes graphiques/Les écritures en VRAM hors ROPs
Le chapitre précédent nous a expliqué comment les ROPs mettaient à jour le framebuffer et le tampon de profondeur. Cependant, les ROPs ne sont pas les seuls à écrire en mémoire vidéo. Les compute shaders, vertex shaders et pixel shaders peuvent écrire des données en mémoire vidéo, dans des tableaux dédiés. Et n'allez pas croire que c'est un petit sujet. La majeure partie des jeux vidéos actuels utilisent des techniques de rendu différé qui ne fonctionneraient pas sans cela ! Et ne parlons pas des compute shaders, qui ne serviraient à rien sans cela. Dans ce chapitre, nous allons détailler comment se font ces écritures en mémoire.
- Notons que nous mettons de côté les architectures sort-middle et les GPU à rendu en tile dans ce chapitre.
Les fonctionnalités de Multiple Render Targets
[modifier | modifier le wikicode]Il faut noter que les API modernes permettent à un pixel shader d'écrire dans plusieurs render-target. On parle alors de Multiple Render Targets, abrévié en MRT. L'implémentation classique est que le pixel shader peut écrire son résultat dans une ou plusieurs textures. Précisons qu'il s'agit bien du pixel shader qui écrit dans une texture. Suivant le GPU, l'écriture dans les render target peut ou non passer par les ROPs. Sur les anciennes versions de DirectX, un seul render target passait par les ROPs, mais pas les autres. De nos jours, tous les render target passent par les ROPs. Il est même possible de configurer le mélange alpha différemment suivant le render target.

L'éclairage différé
[modifier | modifier le wikicode]Le MRT accélère fortement les techniques d'éclairage différé, qui séparent les calculs d'éclairage par pixel dans une passe séparée du reste. Ils fonctionnent en deux passes, au minimum. La première calcule toutes les informations nécessaires pour les calculs d'éclairage par pixel. Elle calcule les normales, la couleur diffuse, de quoi calculer les réflexions spéculaires, et d'autres informations du genre. Le tout est mémorisé dans plusieurs "textures" séparées, qui sont lues par un pixel shader pour obtenir l'image finale. On parle aussi de rendu différé au lieu d'éclairage différé, mais c'est un détail.
La première passe calcule la couleur de chaque pixel, la profondeur de chaque pixel, et la normale de la surface pour chaque pixel. Dans l'implémentation la plus simple, ces informations sont écrites dans trois "textures" : une qui contient la couleur diffuse, une autre pour la profondeur, et une autre pour les normales. Il faut alors supporter des pseudo-framebuffer pour chaque "texture". Ils sont appelés des G-buffer, ce qui est l'abréviation de tampon géométrique.
L'intérêt de l'éclairage différé est d'exécuter le pixel shader une fois par source de lumière et par pixel. Il y a donc un draw call par source de lumière, qui lit les g-buffer. La conséquence est que le rendu différé utilise généralement un shader par type de sources de lumières, qu'elles soient ponctuelles, directionnelles, etc. Pour comparer, sans éclairage différé, l'éclairage est réalisé autrement.
L'implémentation la plus simple fait les calculs d'éclairage une fois par source et lumière et par objet, avec un draw call par objet et par source de lumière. On parle alors de rendu direct multi-passe. La géométrie d'un objet est donc recalculée plusieurs fois, une fois par source de lumière. Par contre, le pixel shader est généralement assez simple, car on peut utiliser un shader différent pour les lumières ponctuelles, directionnelles, etc. Mieux que ça : on a tendance à avoir un shader par material, vu qu'on a généralement un seul material par objet, les exceptions étant rares. Le cout en termes de performances est alors le suivant : des pixels shaders très spécialisés et optimisés, mais des calculs géométriques redondants.
Une implémentation plus optimisée utilise un seul draw call par objet. Le draw call reçoit une liste des sources de lumière. Le pixel shader lit les sources de lumière une par une dans cette liste, et calcule l'éclairage une source de lumière à la fois, jusqu'à obtenir la couleur finale du pixel. La différence est qu'on a un draw call par objet, et non un par objet et source de lumière. On parle alors de rendu direct simple passe.
Les calculs géométriques redondants sont éliminés, mais le pixel shader perd sa spécialisation. Au lieu d'utiliser plusieurs pixel shaders spécialisés, on utilise un über shader qui supporte tous les types de sources de lumières et tous les materials. La conséquence est qu'un über shader tend à être moins bien optimisé, car il utilise plus de registres. Cela est cependant compensé par le fait que le pixel shader fait peu d'accès mémoire, comparé au rendu différé. Il a beau utiliser plus de registres, ce n'est pas tellement un problème vu qu'il y a moins d'accès mémoire à masquer.
Le rendu différé a donc de nombreux avantages et désavantages. Un avantage est que le rendu différé se marie bien avec les effets graphiques de type screen space, comme certaines formes d'occlusion ambiante. Ces effets ont besoin de connaitre la profondeur de chaque pixel, qui est fournie sur un plateau dans un g-buffer. Par contre, la transparence n'est pas prise en charge, de même que l'antialiasing de type MSAA. Il y a aussi des conséquences en termes de performances, car lire les g-buffer n'est pas gratuit. Le cout en bande passante mémoire, est totalement absent dans le rendu direct, qui se contente de lire les textures des objets. Au final, le rendu différé fait un compromis différent du rendu direct : il échange du temps de calcul contre de la bande passante mémoire et contre la VRAM occupée par les g-buffer.
Un autre désavantage est que tous les objets doivent faire leurs calculs d'éclairage avec le peu d'informations présentes dans le g-buffer. Ils doivent donc se débrouiller avec une couleur diffuse, spéculaire, les normales et quelques informations comme l'émissivité. En clair, plusieurs materials partagent des BRDFs très similaires, voire identique. Le rendu direct n'a absolument pas ce problème et peut utiliser un material différent par objet, voire plusieurs materials différents sur un même objet. Ils seront simplement rendus dans des draw call séparés.
Une prépasse z implicite
[modifier | modifier le wikicode]Les avantages et désavantages de l'éclairage différé sont très nombreux. L'un d'entre eux est que l'on n'a pas d'overdraw : seuls les pixels visibles sont éclairés. Ce n'est pas forcément le cas avec le rendu direct (forward rendenring), où des pixels sont éclairés par un pixel shader et enregistrés dans le framebuffer, avant d'être écrasé par la suite. On dit qu'il y a un certain overdraw, d'autant plus important que les pixels invisibles éclairés sont nombreux. Les techniques d'élimination des pixels cachés limitent la casse, mais elles ne fonctionnent à la perfection que si l'on trie les triangles du plus proche au plus lointain, ce qui n'est jamais fait en pratique.
Il est cependant possible d'éliminer tout overdraw avec le rendu direct, en utilisant une pré-passe z (abordé dans le chapitre sur le ROP). D'ailleurs, le rendu différé effectue une pré-passe z implicitement. En réalité, l'éclairage différé est une sorte de pré-passe z sous stéroïde, qui ne fait pas que calculer le tampon de profondeur final, mais calcule aussi les normales et d'autres informations fournies par la rastérisation. De plus, le rendu différé ne recalcule pas la géométrie dans la seconde passe, alors que le rendu direct avec pré-passe z va le faire.
Par contre, cette pré-passe z implicite est à l'origine de plusieurs défauts de cette technique. Déjà, l'usage d'une prépasse z n'est pas compatible avec l'usage de la transparence. Le rendu différé ne fonctionne que pour les objets opaques. La seule solution est de traiter les objets opaques à part des objets transparents. Les objets opaques sont rendus dans une première passe utilisant le rendu différé, alors que les objets transparents sont rendus dans une seconde passe qui utilise le rendu direct.La prépasse z désactive aussi l'antialiasing, même si je ne peux pas expliquer pourquoi ici.
L'implémentation sur les GPU modernes
[modifier | modifier le wikicode]Sans MRT, le rendu différé est très compliqué à implémenter. Chaque g-buffer demande une passe de rendu, chacune re-calculant la géométrie. Par contre, avec MRT, les différents g-buffer sont tous calculés en une seule passe, sans recalculer la géométrie plusieurs fois.
Il est possible d'ajouter d'autres textures pour d'autres informations, comme des textures pour les paramètres de la lumière spéculaire (couleur spéculaire, illumination). Par contre, on est rapidement limité en termes de nombres de textures. En effet, les GPU modernes supportent entre 4 à 8 textures maximum en sortie pour le MRT. Un pixel shader a donc accès à 4 g-buffer, rarement plus. Pour une compatibilité maximale, les programmeurs se limitent à 4 textures. De plus, utiliser plus de textures aurait un impact en matière de consommation mémoire, et de bande passante.
Pour limiter la casse, il est possible de regrouper des informations séparées dans un seul g-buffer, pour gagner de la place. Par exemple, la couleur diffuse est une couleur RGB codée sur 24 bits, là où une texture utilise souvent des pixels RGBA codés sur 32 bits. Il est alors d'utiliser l'octet pour la composante A pour stocker une autre information, comme une information de métallicité (utilisée pour calculer la couleur spéculaire).
Un autre défaut du MRT est que son usage désactive de nombreuses optimisations intégrées dans les ROPs, comme le z-fast pass. De telles optimisations demandent en effet que les ROPs n'écrivent que dans un seul render target : le tampon de profondeur. LA vitesse des ROPs est alors doublée, sans compter que la bande passante mémoire est mieux utilisée. Mais dès qu'on active le MRT, ces optimisations sont désactivées. En comparaison, elles sont activées lors d'une prépasse z normale.
Enfin, le MRT désactive automatiquement l'antialiasing matériel. Précisément, c'est l'antialiasing de type multisampling (MSAA) qui est désactivé, même s'il est possible d'utiliser du supersampling ou un antialiasing par post-traitement. En conséquence, l'usage de l'éclairage différé désactive totalement le MSAA.
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.

Les instructions atomiques des compute shaders
[modifier | modifier le wikicode]Dans le chapitre précédent, nous avions parlé des ROPs, et notamment de pourquoi on ne peut pas les émuler dans un shader. Le problème est que plusieurs instances de shaders peuvent lire une même donnée, faire un traitement, et la remplacer par leur résultat. Et l'ordre entre ces instances de shaders n'est pas garanti. Le même problème peut survenir dans un compute shader, à savoir un shader utilisé pour du GPGPU.
Un compute shader peut accéder à un ou plusieurs tableaux en mémoire, voire des structures de données plus complexes. Mais restons sur des tableaux. Les tableaux en question sont appelés des Unordered Access Views, leur nom indiquant qu'aucune garantie n'est donnée sur l'ordre des écritures. Il n'y a pas de tri entre instances de shaders pour que les shaders écrivent leurs données dans leur ordre de lancement. À la place, les écritures dans un compute shader sont immédiates. Les shaders peuvent lire et écrire dans la mémoire vidéo dans le désordre. Le problème est qu'il y a des données qui ne doivent pas être traitées par plusieurs instances d'un compute shader à la fois.
Heureusement, les compute shaders disposent d'un mécanisme pour éliminer ce problème. La solution en question est similaire à ce qu'on trouve sur les architectures multicœurs, qui sont confrontées au même problème. Il y a des données qui ne doivent pas être traitées par plusieurs threads à la fois. La solution est d'ajouter au processeur des instructions atomiques, qui lisent, modifient et écrivent une donnée, sans qu'aucune instruction ne puisse les déranger. De telles instructions empêchent tout accès mémoire à la donnée manipulée, tant qu'elles ne sont pas terminées. Le terme atomique veut dire qu'elles donnent l'impression de s'exécuter en une seule fois, elles ne donnent pas l'impression d'associer une lecture, une opération et une écriture séparées.
Les GPU modernes ont des instructions atomiques, utilisables uniquement dans les pixel shaders. Par contre, leur implémentation matérielle est différente. Les CPU multicœurs rendent leurs instructions atomiques par divers mécanismes. Le plus simple bloque totalement le bus mémoire, pour empêcher aux autres cœurs d'accéder à la mémoire RAM. Le cout en performance est alors énorme. Une solution alternative utilise les mécanismes de cohérence des caches. L'idée est de bloquer certaines lignes de cache, afin qu'aucun autre cœur ne puisse lire ou écrire la ligne de cache réservée. Mais les GPU ne peuvent pas faire cela.
Les méthodes précédentes fonctionnent quand on a un bus mémoire partagé entre 2-4 cœurs, mais ont un cout en performance trop important au-delà. Aussi, les GPUs font autrement. Ils exécutent les instructions atomiques en dehors du processeur de shader ! À la place, chaque cache est associé à une unité atomique (atomic unit), qui exécute les opérations atomiques directement dans le cache, en dehors des processeurs de shader. Pour exécuter une instruction atomique, les processeurs de shaders envoient l'instruction aux unités atomiques, qui les exécutent directement dans le cache. Les unités atomiques peuvent recevoir plusieurs instructions atomiques en même temps, mais elles forcent l'exécution des instructions atomiques à se faire une par une.



