M2 images


TP4 - compute shaders et visibilité


Partie 1 : préparation

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 dans ces buffers (d'ou leur nom...)

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

exercice 1 : écrivez un compute shader débile qui ajoute 10 à chaque case d'un tableau.

void ajoute( std::vector<int>& entree )
{
    for(unsigned i= 0; i < entree.size(); i++)
        entree[i]= entree[i] + 10;
}
à partir de quelle taille de tableau un compute shader peut s'exécuter efficacement ?
quelle taille de groupe de threads utiliser ?

rappel : n'oubliez pas de déclarer les buffers avec la décoration std430... ce sera plus simple pour écrire l'application


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

indications : votre programme de test va ressembler à :
init() :
    charger le compute shader et créer le program, cf program= read_program( ... ) avec #ifdef COMPUTE_SHADER / #endif
    creer un buffer pour les donnees, cf glBufferData(GL_SHADER_STORAGE_BUFFER, ...);

render( ) :
    glUseProgram(program);
    glBindBufferBase(GL_SHADER_STORAGE_BUFFER, 0, buffer);

    glDispatchCompute( ... );

    // attendre les resultats
    glMemoryBarrier( ... );

    // relire et verifier...
    ...


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 : votre programme va ressembler à ça :

        glUseProgram(program);
        { ... }

        glDispatchCompute(n, 1, 1);
       
        // attendre que les resultats soient disponibles
        glMemoryBarrier(GL_BUFFER_UPDATE_BARRIER_BIT);
       
        // relire le resultat
        std::vector<int> tmp(n);
        glBindBuffer(GL_SHADER_STORAGE_BUFFER, buffer_sortie);
        glGetBufferSubData(GL_SHADER_STORAGE_BUFFER, 0, sizeof(int) * tmp.size(), tmp.data());

        // afficher le resultat
        for(unsigned i= 0; i < tmp.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...
rappel : paramètres uniform des shaders...


exercice 5 : modifiez le shader et l'application pour que les résultats soient écrits dans un tableau / buffer résultat, différent du tableau / buffer en entrée.
void ajoute( const int v, const std::vector<int>& entree, std::vector<int>& sortie )
{
    sortie.resize(entree.size());
    for(unsigned i= 0; i < entree.size(); i++)
        sortie[i]= entree[i] + v;
}
rappel : utilisez les décorations readonly, writeonly pour déclarer les buffers en lecture seule, ou en écriture seule...
indication : oui, il faudra créer un buffer pour stocker les résultats du shader.


exercice 6 : écrivez un autre shader qui filtre ses entrées, ie il n'écrit que les nombres plus grands qu'une valeur v (par exemple) dans un tableau résultat.
qu'est ce qui change ? le shader ne connait pas à l'avance la place du résultat dans le tableau / buffer de sortie.
void filtre( const int v, const std::vector<int>& entree, std::vector<int>& sortie )
{
    sortie.clear();
    for(unsigned i= 0; i < entree.size(); i++)
        if(entree[i] > v)
            sortie.push_back(entree[i]);
}
rappel : operations atomiques... cf la doc de glsl sur les buffers, et celle sur les compute shaders


pour les rapides :
exercice 7 : limiter le nombre d'opérations atomiques simultanées sur le même compteur...
la solution de l'exercice précédent fonctionne mais : tous les threads vont exécuter atomicAdd(n, 1) simultanement sur la variable n... et ca va être assez lent.

indication : on peut aussi utiliser des compteurs en mémoire partagée, (ie declarés avec la décoration shared), mais la mémoire partagée n'est visible / partagée que par les threads d'un même groupe, pas par tous les groupes... par contre, si on pouvait ne faire qu'un seul atomicAdd(n, ...) par groupe, on accelererait pas mal le shader...


Partie 2 : tests de visibilité

exercice 8 :
reprennez le tp du cours précédent qui teste la visibilité d'un bloc / d'une région avant de la dessiner.
c'est dommage de faire calculer tout ça sur un seul thread du processeur, non ?

écrivez le test de visibilité d'une boite englobante alignée sur les axes dans un compute shader.
bien sur le shader testera en parallèle toutes les régions de la scène. ie il faudra un tableau / buffer en entrée et un tableau (storage) buffer en sortie.

le résultat 0 ou 1, visible ou pas, sera écrit dans un buffer / tableau d'entiers.
récupérez ce (storage) buffer dans l'application (cf glGetBufferSubData()) et vérifiez que vous obtenez bien les mêmes résultats que le test du tp précédent.


Partie 3 : dessiner ce qui est visible...

on peut bien sur récupérer le résultat du test de visibilité et dessiner ensuite les blocs / régions visibles, mais c'est quand même dommage de créer une synchronisation entre l'application et le gpu. de plus, le gpu doit maintenant attendre (longtemps) de recevoir les draws émis par l'application.

il serait quand même beaucoup plus efficace de pouvoir dessiner les régions directement, sans re-passer par l'application...

(re-)lisez la doc de multidraw indirect, cette fonctionnalité va permettre de faire exactement ça ! (après un peu de préparation bien sur...)

l'idée est de construire un buffer contenant les paramètres du draw de chaque région à dessiner et d'utiliser glMultiDrawIndirect() pour tout dessiner d'un coup. un compute shader prend en entrée les paramètres des draws des régions ainsi que leurs englobants, teste la visibilite de l'englobant et copie dans un autre buffer les paramètres de draw des régions visibles.


exercice 9 :
pour démarrer en douceur, on va tricher un peu : au lieu de ne copier que les paramètres des régions visibles, on peut simplement mettre à zero le nombre d'instances à dessiner lors de la copie.


exercice 10 :

maintenant que la première solution fonctionne, modifiez le compute shader pour ne copier que les paramètres des régions visibles. 
c'est malin, comment on dessine maintenant ? l'application ne connait pas le nombre de draws à réaliser ! utilisez glMultiDrawIndirectCountARB() qui est faite exactement pour ça !


exercice 11 :

il serait sans doute plus rapide d'éviter de faire autant d'opérations atomiques... décomposez l'addition en utilisant un compteur en mémoire partagée.



bravo !
vous venez d'écrire votre premier "gpu driven pipeline" ! ie c'est un shader qui décide quoi dessiner sans intervention de l'application...

la version moderne de ce test s'écrit avec un mesh shader, ce qui évite d'écrire les paramètres des draws dans un buffer et qui va encore plus vite (plus besoin de synchronisation...). vous pouvez regarder les perfs avec ou sans dans Alan Wake 2 : ici chez TechPowerUp, par exemple.

malheureusement, l'extension openGL permettant d'écrire des mesh shaders n'est pas encore disponible dans les drivers standards. ce sera le cas l'année prochaine normalement (enfin !!). l'extension propriétaire nvidia pour les geforce est disponible par contre.