M2 images


TP4 - compute shaders et rendu différé


L'objectif du TP est de mettre en place les éléments qui permettent de faire du rendu différé, repassez dans "rendu différé / direct ?" si tout ça n'est pas très clair. Il y a plusieurs étapes, un peu techniques, avant de démarrer : créer et configurer un framebuffer et ses textures pour stocker les informations nécessaires au rendu, ie le G-Buffer et bien sur compiler un compute shader et configurer ses entrées et surtout (c'est la grosse nouveauté) ses sorties.


Partie 1 - préparation framebuffer et textures

pour dessiner dans une ou plusieurs textures, c'est comme pour les shadow maps, mais en plus complet, il faudra stocker plus d'informations : position, normale, paramètres des matières, etc.

par exemple, si l'on souhaite construire un G-Buffer avec position, normale et couleur diffuse, il faut créer 3 textures de 3 canaux. le plus simple est de créer des textures avec 3 canaux floats, mais utiliser des formats plus compacts (mais moins précis) n'est pas difficile.

vous pouvez utiliser les utilitaires de texture.h pour créer simplement les textures nécessaires, cf make_depth_texture() ou make_vec3_texture(), etc. elles sont faites exactement pour ça !

la configuration se fait en plusieurs étapes : on commence par la création des textures pour stocker les données, puis la configuration du framebuffer. il faut associer les textures aux sorties du framebuffer avec glFramebufferTexture() et associer les sorties du fragment shader aux sorties du framebuffer avec glDrawBuffers()

les explications détaillées sont dans la doc en ligne : "framebuffer object"


au final, votre code ressemblera à :

// creation des textures pour stocker le GBuffer
    GLuint zbuffer= make_depth_texture( /* unit */ 0, /* width */ 256, /* height */ 256 );

    GLuint position= make_vec3_texture( /* unit */ 0, /* width */ 256, /* height */ 256 );
    GLuint normal= make_vec3_texture( /* unit */ 0, /* width */ 256, /* height */ 256 );
      ...

// configuration du framebuffer
    GLuint framebuffer= 0;

    glGenFramebuffers(1, &framebuffer);
    glBindFramebuffer(GL_DRAW_FRAMEBUFFER, framebuffer);

    glFramebufferTexture(GL_DRAW_FRAMEBUFFER, /* attachment */ GL_DEPTH_ATTACHMENT, zbuffer, /* mipmap */ 0);    // associe une texture a la sortie zbuffer du frambuffer;
    glFramebufferTexture(GL_DRAW_FRAMEBUFFER, /* attachment */ GL_COLOR0_ATTACHMENT, position, /* mipmap */ 0);  // associe une texture a la sortie 0
    glFramebufferTexture(GL_DRAW_FRAMEBUFFER, /* attachment */ GL_COLOR1_ATTACHMENT, normal, /* mipmap */ 0);    // associe une texture a la sortie 1
      ...

// configuration des sorties du fragment shader
//                      binding 0             binding 1            etc
    GLenum buffers[]= { GL_COLOR_ATTACHMENT0,
GL_COLOR_ATTACHMENT1 };
    glDrawBuffers(2, buffers);

// verification de la configuration du framebuffer
    if(glCheckFramebufferStatus(GL_DRAW_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE)
        return "error";

// nettoyage...
    glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0);


pour dessiner dans le framebuffer, il ne reste plus qu'à le sélectionner (avant de dessiner la scène), mais attention aux paramètres implicites du draw() !
(par exemple les dimensions de l'image... cf glViewport())

exercice :
écrivez une application de test qui charge une scène, qui la dessine et qui permet de construire un G-Buffer avec position, normale et couleur.

question :
comment déclarer les sorties du fragment shaders pour constuire le G-Buffer ?

debug :
pour vérifier que les textures sont bien "remplies" par un glDraw() quand vous dessinez les objets de la scène, il faut afficher les textures. on peut bien sur écrire un fragment shader qui copie la texture ou on peut utiliser glBlitFramebuffer pour faire la copie, cf doc en ligne

init( ) :
GLuint read_framebuffer;
glGenFramebuffers(1, &read_framebuffer);

glBindFramebuffer(GL_READ_FRAMEBUFFER, read_framebuffer);
glFramebufferTexture(GL_READ_FRAMEBUFFER, /* attachment */ GL_COLOR_ATTACHMENT0, /* texture a copier */ , /* mipmap */ 0);
glReadBuffer(GL_COLOR_ATTACHMENT0);

render( ) :
// copie vers le framebuffer par defaut / la fenetre
glBindFramebuffer(GL_READ_FRAMEBUFFER, read_framebuffer);
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0);

glBlitFramebuffer(/* source */ 0,0, 1024,1024,  /* destination */ 0,0, 1024,1024, GL_COLOR_BUFFER_BIT, GL_NEAREST);


Partie 2 - préparation compute shader, buffers et images

les entrées des compute shaders sont classiques : des textures et des buffers. mais bien sur les compute shaders peuvent aussi écrire leurs résultats quelque part : dans des texture images ou des storage buffers (oui, storage textures était visiblement trop simple comme nom...)

repassez dans la doc en ligne si nécessaire pour voir comment déclarer les buffers et les textures en entrée et en sortie.

exercice :
modifiez l'application pour exécuter un compute shader sur tous les pixels d'un framebuffer. quelle sera la sortie du compute shader ?

question :
comment observer le résultat du compute shader ?


Partie 3 - rendu différé

bravo ! vous savez manipuler tous les ingrédients techniques. il ne reste plus qu'à modifier le compute shader pour calculer la couleur de chaque pixel du G-Buffer.

question :
est ce que tout ça est plus rapide que la 1ere version directe de votre code ?
dans quels cas le G-Buffer + compute est plus rapide ? et dans quel cas est-il plus lent ?

exercice : petit G-Buffer.
au lieu de stocker la position dans une texture, il est tout a fait possible de recalculer la position, on connait le zbuffer. dans quel cas sera-t-il interessant de limiter le volume de données écrit par chaque fragment ?
rappel : chaque fragment remplit le G-Buffer, si plusieurs triangles se projettent sur le même pixel, on écrit plusieurs fois les informations d'un pixel.


bonus : G-Buffer compact.
quelles informations doit on vraiment conserver dans le G-Buffer ? peut-on les compresser pour réduire la représentation d'un pixel ? et si l'objet et les paramètres de la matière sont texturés ?
comment mesurer l'impact de la compression sur l'exécution du rendu différé ?

pour les curieux : un peu de lecture, par exemple comment "rainbow six | siege" GDC2016 fait son rendu et quel format de G-Buffer il utilise, + comment il interpole ou calcule ses pixels...
 

Partie 4 - Deferred Adaptive Compute Shading

si le rendu est toujours trop lent, que peut-on faire de plus ? une idée est de limiter le nombre de source de lumière qui peut influencer chaque fragment, cf les bonus, une autre solution consiste à ne pas calculer tous les pixels du G-Buffer : en choisissant d'interpoler les pixels voisins à la place.

on commence par calculer 1 pixel sur 16 dans l'image, ensuite, il faut décider d'interpoler ou de calculer chaque pixel d'un bloc de 5x5 pixels. il y a 2 étapes : d'abord calculer ou interpoler le pixel au centre du bloc, puis calculer / interpoler les pixels au milieu de chaque arête du bloc (en utilisant les voisins horizontaux et verticaux) :

pour calculer / interpoler les pixels du bloc, on doit quand même accéder aux blocs voisins :


et on recommence pour les 4 petits blocs 3x3 de chaque bloc pour compléter l'image.

pour décider d'interpoler ou pas, on peut calculer la variance des 4 couleurs et la comparer à un seuil. par exemple si a, b, c et d sont des couleurs :

Color s= ( a+b+c+d ) / 4;
Color ss= ( a*a+b*b+c*c+d*d ) / 4;
float v= gray(ss - s*s);

avec la conversion en luma :

float gray( const Color& color ) { return float(0.21) * color.r + float(0.71) * color.g + float(0.08) * color.b; }

l'interpolation est une simple moyenne dans ce cas (et oui, on vient juste de la calculer pour évaluer la variance, cf la variable s)


et ça marche ?
voila la variance calculée pour chaque pixel :


et voila les résultats avec un seuil de 1/100 (à gauche) et de 1/1000 (à droite) :

   

(zoomez sur les images pour voir les défauts de reconstruction, l'image vient de Alan Wake 2)


pas mal non, pour 1% et 7% des pixels de l'image calculés (sans compter la premiere passe) ? (même s'il reste des défauts)
en fonction du seuil, on peut vraiment ne calculer que quelques pixels ou presque tous.

les détails complets sont dans  "Deferred Adaptive Compute Shading" HPG 2018 et aussi dans sa suite publiée en 2020.


bien sur, toutes les scènes / images ne présentent pas la même structure / densité de contours marqués. le réglage du seuil pour obtenir une reconstruction de bonne qualité, sans calculer tous les pixels, peut etre assez délicat. voici la variance de 2 images bien différentes tirées de Black Myth Wukong :

   

(il y a nettement plus de pixels sur des contours marqués dans l'image de gauche...)

comment programmer efficacement cette technique ? quel est le problème fondamental ?

question :
combien de threads s'exécutent simultanément si tous les pixels d'un groupe sont calculés ? si tous les pixels d'un groupe sont interpolés ?
et dans le cas normal (ie des pixels sont interpolés et d'autres sont calculés...) ?

remarque : une fonctionnalité similaire existe sur les cartes récentes, le Variable Rate Shading qui permet de contrôler le nombre de fragment shaders exécuté par pixel. mais seul nvidia et les constructeurs de gpu embarqués fournissent la version openGL ou openGL ES.

remarque :
avant de vous lancer dans le compute shader, vous pouvez écrire une application cpu qui charge une image quelconque et qui applique le principe d'interpolation sur les pixels de l'image. j'ai utilisé cette solution sur les captures d'Alan Wake 2.

remarque : combien de fois va-t-on relire la couleur d'un pixel ? ou comment sont les accès mémoire de cette technique ? cohérents ? ou pas ? nombreux ?


question :

si tout ça parait trop compliqué, que pensez-vous du checkerboard rendering proposé par ubisoft dans  "rainbow six | siege" ?
quelles sont les différences principales ? quel pourcentage des pixels sont calculés ?

question :
que pensez vous de la solution utilisée dans "call of duty: modern warfare" ?
quelles sont les différences principales ? quel pourcentage des pixels sont calculés ? est-ce efficace ?
quelle technique permet de rendre les acces mémoire cohérents ?


exercice : premier essai
écrivez un compute shader qui réalise une passe de calcul : ie qui teste 4 pixels voisins et décide d'interpoler ou de calculer la couleur.
comment enchainer l'exécution des passes de calcul ? combien faut-il de passes de calcul ?

indication : la position des voisins change à chaque passe de calcul, comment paramétrer le shader ? (et n'écrire qu'un seul shader)


question :

peut-on écrire un seul shader qui enchaine toutes les passes ? quel serait l'interet de cette solution ?

exercice : restructuration des calculs / des accès mémoire
proposez une solution plus cohérente.
peut-on utiliser la mémoire partagée ? quel serait l'interet ?

exercice : et si on changeait un peu la méthode ?
un inconvénient de la méthode vient du test "interpole / calcule" réalisé pour chaque pixel. l'exécution est donc plutot incohérente et il serait plus simple et plus efficace de décider lors de la toute première passe quels pixels calculer. les passes suivantes ne feraient donc qu'interpoler et leur exécution serait cohérente.

problème : comment décider à priori de calculer certains pixel ? quels critères peut-on utiliser ? quel sera l'impact sur l'image finale ?


Bonus - Tiled Shading / Clustered Shading 

cf la presentation "Clustered Deferred and Forward Shading" ou l'article


Bonus - Temporal Anti Aliasing

une autre solution consiste à ne pas faire tous les calculs à chaque image lorsqu'ils sont trop longs et que l'application affiche moins de 60 images par seconde. l'idée est de répartir les calculs sur 4 ou 8 images pour permettre d'afficher 60 images par seconde. les calculs répartis sur plusieurs images ne seront complets que 10 ou 20 fois par seconde, ce qui suffisant pour pas mal d'effets !  c'est très simple à faire si rien ne bouge : chaque image calcule une partie des résultats et les accumule aux résultats précédents pixel par pixel. mais en général, les objets et/ou la camera bougent, et pour accumuler les résultats sur les bons pixels, il faut faire un peu de gynmastique... mais en connaissant les positions des objets à l'image actuelle et l'image précédente, on peut y arriver, c'est ce que propose le TAA, ie l'intégration temporelle.

cf les présentations "An excursion in Temporal Supersampling" GDC 2016 et "Temporal Reprojection Anti-Aliasing" PlayDead INSIDE

plus récent, une comparaison de plusieurs techniques : "A Survey of Temporal Antialiasing Techniques" L.Yang 2020

les techniques de base : moyenne exponentielle, reprojection temporelle, etc. sont introduites dans "Accelerating Real-Time Shading with Reverse Reprojection Caching" HPG 2007