M2 Image

TP5 - compute shaders et rayons



Partie 1 : préparation - compute shaders, storage buffers / textures


pour démarrer en douceur, on va se concentrer sur les nouveautés techniques : les "shader storage buffers"... ce sont des buffers comme les autres (ils sont crées avec glBufferData()) mais un shader peut écrire une valeur dans ces buffers (d'ou leur nom...)

relisez la doc, sur les storage buffers, si ce n'est pas déjà fait.

si vous avez du mal à structurer le programme de départ : regardez tutos/tuto_compute_vertex.cpp et son shader tutos/vertex_compute.glsl

exercice 1 : écrivez un compute shader débile qui ajoute 10 à chaque case d'un tableau passé en paramètre.

void ajoute( std::vector<int>& entree )
{
    for(unsigned i= 0; i < entree.size(); i++)
        entree[i]= entree[i] + 10;
}

comme d'habitude, il faut se poser quelques questions avant de démarrer :
quelles sont les paramètres du compute shader ?
comment déclarer les différents paramètres du shader ?
quelle taille de groupe de threads utiliser ?
comment paralléliser l'algo précédent avec un compute shader ?
quelle tache associer au thread d'indice ID ?

rappel : n'oubliez pas de déclarer les buffers avec la décoration std430... ce sera plus simple pour écrire l'application
rappel : le constructeur de la classe App permet de préciser la version d'openGL, en plus des dimensions de la fenetre, cf App(1024,640, 4,3)

une dernière question : à partir de quelle taille de tableau un compute shader peut s'exécuter efficacement ?


exercice 2 : que doit faire l'application pour exécuter le shader précédent ?
indication : comme toutes les fonctions, un shader doit lire ses entrées et écrire ses résultats quelquepart...


exercice 3 : comment vérifier que ca marche ?

on peut relire le contenu d'un buffer avec glGetBufferSubData(); et écrire une fonction de vérification dans l'application.

attention : il faut attendre que l'exécution du shader soit terminée avant de relire le contenu du buffer dans l'application, relisez la section "attendre les résultats" dans la doc sur les compute shaders.

indications : pour relire et vérifier le résultat, les valeurs modifiées du tableau, vous pouvez écrire quelque chose comme ca :
// pour relire size elements...
std::vector<int> tmp(size, 0);

// attendre que les donnees soient ecrites en memoire apres l'execution des compute shaders
glMemoryBarrier(
GL_BUFFER_UPDATE_BARRIER_BIT);

// relire le contenu du buffer
glBindBuffer(GL_SHADER_STORAGE_BUFFER, buffer);
glGetBufferSubData(GL_SHADER_STORAGE_BUFFER, 0, sizeof(int) * size, tmp.data());

// afficher
for(int i= 0; i < size; i++)
    printf("%d ", tmp[i]);
printf("\n");

exercice 4 : on va faire un truc très compliqué, modifiez le shader pour que 10 soit un paramètre fixé par l'application...



pour continuer, on va également regarder l'autre nouveauté technique, les "storage textures"... ce sont des textures (ou des images) comme les autres (création avec glTexImage2D() ou glTexStorage()), mais un shader peut écrire directement dedans, sans passer par un framebuffer object...

relisez la doc, sur les storage textures, si ce n'est pas deja fait...


exercice 5 :
écrivez un compute shader très compliqué qui divise par 2 les compsantes rgb de chaque pixel d'une image passée en paramètre.
void divise( Image& image )
{
    for(unsigned i= 0; i < image.size(); i++)
        image(i)= image(i) / 2;
}

ou
void divise( Image& image )
{
    for(int y= 0; y < image.height(); y++)
    for(int x= 0; x < image.width(); x++)
        image(x, y)= image(x, y) / 2;
}

comme d'habitude, il faut se poser quelques questions avant de démarrer :
quelles sont les paramètres du compute shader ?
comment déclarer les différents paramètres du shader ?
quelle taille de groupe de threads utiliser ?
comment paralléliser l'algo précédent avec un compute shader ?
quelle tache associer au thread d'indice ID ?

mêmes questions que pour le shader précédent...

indication : pour créer une texture dans l'application, vous pouvez utiliser les utiltaires make_xxx_texture() de texture.h

indication : pour relire le contenu d'une texture dans l'application, on peut utiliser glGetTexImage();
Image read_texture( const GLuint tex )
{
    // selectionne la texture pour la manipuler
    glBindTexture(GL_TEXTURE_2D, tex);

    // recupere les dimensions de l'image
    int width= 0;
    glGetTexLevelParameteriv(GL_TEXTURE_2D, 0,  GL_TEXTURE_WIDTH, &width);
    int height= 0;
    glGetTexLevelParameteriv(GL_TEXTURE_2D, 0,  GL_TEXTURE_HEIGHT, &height);
   
    Image image(width, height);
    glGetTexImage(GL_TEXTURE_2D, /* mipmap */ 0, GL_RGBA, GL_FLOAT, image.data());
   
    return image;
}

exercice 6 : et si on veut afficher l'image modifiée ?

le compute shader vient de modifier une texture, comment la dessiner ? il y a au moins 2 manières, soit avec un fragment shader, comme d'habitude,
soit en utilisant glBlitFramebuffer(), mais il faut créer et configurer un framebuffer avec la texture que l'on veut afficher...

Partie 2 : et les rayons ?



écrivez un compute shader qui génère un rayon par pixel d'une image et qui calcule l'intersection de ce rayon avec chaque triangle d'un objet. pour démarrer simplement, on va se contenter d'une cornell box avec 32 triangles.

comment générer le rayon de chaque pixel de l'image ?
comment calculer une intersection rayon / triangle ?
quelles sont les paramètres du compute shader ? pour générer le rayon ? pour calculer l'intersection avec un triangle ?

comment déclarer les différents paramètres du shader ?
comment écrire l'application pour créer et initialiser les buffers, images, etc. nécéssaires au fonctionnement du shader ?
quelle taille de groupe de threads utiliser ?
comment paralléliser l'algo précédent avec un compute shader ?
quelle tache associer au thread d'indice ID ?

si vous utilisez un buffer pour stocker un tableau de triangles, comment le remplir correctement dans l'application ? ie comment aligner les structures de données dans le buffer ?


indications : vous pouvez récupérer le code d'intersection et de génération de rayons dans tutos/M2/raytrace.glsl, et le code pour initialiser le buffer / tableau de triangles dans tutos/M2/tuto_raytrace_fragment.cpp

indications :
et vraiment si c'est trop compliqué, la correction est dispo, cf tutos/M2/raytrace_compute.glsl et tutos/M2/tuto_raytrace_compute.cpp


Partie 3 : et avec beaucoup de triangles ?

il va falloir utiliser un arbre, un BVH, par exemple, le transférer dans un buffer et écrire la fonction de parcours. mais, petit problème technique, on ne peut pas écrire de fonctions récursives dans un shader. il va falloir cogiter un peu...

rappel : construction de BVH et parcours : cf les principes et les astuces de construction.



il y a 2 manières classiques de régler ce problème (ie parcourir l'arbre sans utiliser de fonction récursive) :

est ce qu'une solution est meilleure que l'autre ? manipuler une pile explicite permet de faire un parcours ordonné (ie de choisir dans quel sens visiter les fils en fonction du rayon), a priori plus rapide, par contre, lire et écrire constamment la pile génère un gros traffic mémoire. utiliser la couture du BVH élimine ce problème, mais le parcours ne sera pas ordonné...


exercice 1 : écrivez la fonction de parcours avec pile explicite.
testez votre fonction de parcours sur cpu, sans utiliser de shader, histoire de vérifier que l'algo est correct.

indications : gagnez du temps, n'utilisez pas std::stack, écrivez tout directement, la stl n'existe pas en glsl...

dans un 1er temps, utilisez le parcours simple, pas le parcours ordonné... ce sera plus simple pour démarrer.

indications : un BVH est representé par un tableau de triangles et un tableau de noeuds. il n'y a pas de pointeur non plus dans les shaders, mais il est direct d'utiliser l'indice des fils d'un noeud au lieu de leur adresse / pointeur. les différents tutos de construction de BVH utilisent deja cette solution.

indications : vous pouvez utiliser une construction simple, comme celle des BLAS de tuto_bvh2.cpp, par exemple. le parcours récursif simple peut s'écrire de cette manière :
// intersection avec un rayon, entre 0 et htmax
     Hit intersect( const Ray& ray, const float htmax ) const
     {
         Hit hit;
         hit.t= htmax;
         Vector invd= Vector(1 / ray.d.x, 1 / ray.d.y, 1 / ray.d.z);
         intersect(root, ray, invd, hit);
         return hit;
     }

// intersection et parcours simple
     void intersect( const int index, const Ray& ray, const Vector& invd, Hit& hit ) const
     {
         const Node& node= nodes[index];
         if(node.bounds.intersect(ray, invd, hit.t))
         {
             if(node.leaf())
             {
                 // parcours tous les triangles references par la feuille

                 for(int i= node.leaf_begin(); i < node.leaf_end(); i++)
                     if(Hit h= primitives[i].intersect(ray, hit.t))
                         hit= h;
             }
             else // if(node.internal())
             {
                 // visite les fils du noeud interne

                 intersect(node.internal_left(), ray, invd, hit);
                 intersect(node.internal_right(), ray, invd, hit);

                 // todo parcours ordonne...

             }
         }
     }
cf la structure Node pour récupérer les informations sur un noeud interne ou une feuille.

indications : vous pouvez aussi utiliser le code de départ à compléter : tuto_compute_bvh.cpp et compute_bvh.glsl, si vous le souhaitez. le bvh est construit avec des structures Node et Triangle compatibles avec l'organisation mémoire des shader storage buffers, ce qui pourrait vous gagner un peu de temps...


exercice 2 : comment limiter les manipulations de la pile ? ie éviter d'empiler les 2 fils à parcourir à chaque étape ?
modifiez votre fonction de parcours sur cpu.


exemple : nombre d'opérations sur la pile, ie stack.push(index), index= stack.pop(). sur la scène de test robot_studio...



en blanc, 400 opérations sur la pile :

   
à gauche, les 2 fils sont empilés, à droite uniquement le 2ieme fils à parcourir...

quel serait l'impact sur le volume de données lu / écrit dans la pile par le shader ? si la pile est en mémoire globale ? en mémoire locale (tableau local à la fonction de parcours), ou en mémoire partagée ?
indication : on réserve du stockage pour la pile, pour chaque thread, et à chaque itération, on dépile un indice, on charge un noeud et on empile les indices des 2 fils, ou ...


exercice 3 : utilisez un tableau local à la fonction de parcours

vous pouvez écrire le shader...

indication : pensez bien à utiliser une pile assez grande pour parcourir l'arbre... le code de départ calcule et affiche la hauteur du bvh construit.


exercice 4 : utilisez la mémoire partagée des processeurs graphiques pour stocker la pile. quelle taille peut on donner à la pile ? quelles sont les conséquences pour le nombre de groupes de threads ordonnancables sur chaque processeur ? quel sera l'impact sur le temps d'execution ? quelle est la limite de taille du BVH ?

vous pouvez écrire le shader...

indication : attention aux accès à la mémoire locale, ie les threads consécutifs doivent accéder à des cases consécutives, sinon c'est plus lent...
en gros, faut-il déclarer les piles des threads du groupe comme ça : int stack[Nthreads][N]; ou int stack[N][Nthreads]; ?

est ce que cette solution (pile en memoire partagee) est plus rapide que la version precedente (pile locale) ?


exercice 5 : et avec un parcours ordonné, qu'est ce qui change ?


exercice 6 : et avec une meilleure organisation mémoire, qu'est ce qui change ? comment tester les englobants des fils d'un noeud sans faire d'indirection ?

rappel : accès mémoire indirect... un noeud connait les indices de ses fils, charge les englobants de ses fils pour calculer les intersections et décider du prochain noued à visiter. on pourrait écrire le test comme ça :
    const Node& node= nodes[index];                        // 1er acces memoire, chargement du noeud, + indices des fils, node.left et node.right
    bool left= nodes[node.left].bounds.intersect(...);     // 2ieme acces memoire, chargement de l'englobant du fils gauche
    bool right= nodes[node.right].bounds.intersect(...);   // 3ieme acces memoire, chargement de l'englobant du fils droit
pourquoi est-il important d'éliminer cette indirection ?
indication : comment s'exécute un compute shader ? que fait le processeur d'un gpu pendant les acces memoire ?

exercice 7 : et si le bvh est trop gros pour stocker toute la pile en mémoire partagée ? ou dans un tableau local ? comment faire ? comment le détecter ?



proposez une modification de la gestion de la pile.
modifiez votre fonction de parcours sur cpu pour vérifier que tout est correct avant de modifier le shader...

indication : il n'est pas nécessaire de conserver la totalite de la pile en mémoire locale ou partagée, on peut très bien en stocker une partie en memoire globale...

indication : une pile de 64 entrees est suffisante dans la plupart des cas. vous pouvez tester avec la scene simplifiée sponza.gltf. le bvh de base à une hauteur de 37.
la scene complète (avec tous les details), ainsi que plusieurs éléments de décors est dispo. utilisez blender pour la convertir en gltf / glb, si nécessaire.

si vous trouvez cette solution moche, il y a plein d'alternatives ! y compris construire une table de hachage... cf les articles suivants :

exercice 8 : mais c'est toujours trop lent ! que peut on faire de mieux ?