M2 Image

TP4 - compute shaders



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


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

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.

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

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

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


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 est probablement assez lente. tous les threads vont faire 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 : frustum culling

relisez l'exercice 4 de la partie 2 du tp précédent, "ou sont les cubes ??"

que faut-il faire pour réaliser le test de visibilité d'une région avec un compute shader ?

indications : l'idée est de construire un buffer contenant les paramètres des draws des régions à dessiner et d'utiliser glMultiDrawIndirect() / glMultiDrawIndirectCountARB() pour tout dessiner d'un coup. relisez la doc

exercice 1 : pour commencer, on va laisser le driver faire un peu de travail à notre place...
vérifiez que vous arrivez à dessiner toutes les régions de la scène avec MultiDrawIndirect.

indications : construisez un std::vector avec les parametres des régions et créez un buffer avec.

Pour ne pas dessiner une région, on peut tout simplement mettre un 0 dans le paramètre vertex_count (pour MultiDrawArraysIndirect) ou index_count (pour MultiDrawElementsIndirect). vérifiez que ca fonctionne...
écrivez le compute shader qui teste l'inclusion de la boite englobante d'une région dans le frustum et qui met à 0 le paramètre vertex_count/index_count pour ne pas dessiner une région non visible.

indication : votre shader va ressembler à celui de l'exercice 1 / partie 1. chaque thread va tester une boite et selon le résultat écrire un 0, ou pas, dans les paramètres de draw associés.

après l'exécution du compute shader, il n'y a plus qu'à attendre (cf glMemoryBarrier()) et l'application peut (enfin) utiliser MultiDrawIndirect pour dessiner les régions visibles.

exercice 2 : un peu de nettoyage...
modifiez le shader précédent pour écrire, dans un buffer résultat, les paramètres de draw des régions visibles (et uniquement les régions visibles). le shader ressemblera à l'exercice 6 de la partie 1.

utilisez le compteur du nombre total des paramètres de draw à dessiner avec MultiDrawIndirectCount(), relisez la doc, si nécessaire.
comparez les performances à la version précédente. c'est mieux ?

exercice 3 : un gros nettoyage !
modifiez votre shader pour limiter les opérations atomiques en mémoire graphique. cf exercice 7 / partie 1.

comparez les performances aux versions précédentes. c'est mieux ?

bonus : vérifiez que votre shader fonctionne correctement, ie que suffisamment de sous-groupes sont exécutables simultanément. cf AMD_DEBUG="cs noir nonir" pour les radeons sous linux avec mesa, ou INTEL_DEBUG="cs" pour les igpu intels sous mesa, cf la doc de mesa

comment réduire le nombre de registres utilisés ?
comment modifier le test de visibilité pour stocker/manipuler moins de résultats intermediaires par thread ?
combien de régions faut-il tester pour "remplir" les processeurs du gpu ?
comment utiliser plus de threads pour tester les n régions ? est-ce a priori interressant ?

rappel : on peut découper la boucle principale de plusieurs manières...

Partie 3 : occlusion culling

relisez la partie 5 du tp précédent.

que faut-il faire pour réaliser le test de visibilité avec des compute shaders ?

indications : même principe que dans la partie précédente, l'idée est de construire un buffer et de dessiner avec glMultiDrawIndirect() ou une des variantes.


Dans un premier temps, concentrez vous sur la méthode elle meme et faites les tests dans l'application. Lorsque les différents éléments du test fonctionnent dans l'application, il faudra écrire un compute shader...


exercice 1 : première étape, construire le rectangle englobant dans l'image de la projection de la boite englobante d'un objet.
le plus direct est de transformer les sommets de la boite englobante dans le repère image et de limiter le rectangle englobant aux dimensions du repere image : [0 .. largeur] x [0 .. hauteur] x [0 .. 1]





exercice 2 : déterminez la visibilité de l'objet en comparant la profondeur stockée dans le zbuffer et la profondeur de la boite englobante de l'objet pour chaque pixel. selon le cas, l'objet est entièrement derrière le zubffer, complètement devant, etc.




exercice 3 : testez l'inclusion des régions dans le frustum, dessinez-les, et récupérez le zbuffer. Testez les englobants image de chaque région par rapport au zbuffer. combien sont visibles ?

rappel : pour relire le zbuffer depuis l'application, utilisez glReadPixels(), (ou regardez comment fonctionne screenshot()...)

    std::vector<float> zbuffer(width*height);
    glReadBuffer(GL_BACK);
    glReadPixels(0, 0, width, height, GL_DEPTH_COMPONENT, GL_FLOAT, zbuffer.data());


exercice 4 :
c'est un peu lent non ?
tester tous les pixels de l'englobant image de chaque région est long. comment faire plus rapide, et en temps constant ?

indications :
l'idée est d'utiliser un zbuffer hiérarchique et de faire le test dans la "bonne" résolution du zbuffer en fonction des dimensions de l'englobant image. par exemple, on peut choisir la resolution du zbuffer pour ne tester que 4 pixels... ou 16.

exemple : pour une région dont l'englobant image est toute l'image, on peut choisir le zbuffer de resolution 1x1, 2x2 ou 4x4 et ne faire que 1, 4, ou 16 tests.

pour des englobants plus petits, qui n'occuppent pas toute l'image, il est aussi possible de choisir une resolution du zbuffer qui permette de ne tester que quelques pixels.

choisir cette resolution est un compromis, existe-t-il des cas ou il serait souhaitable de faire un test plus précis ?
quel est le probleme avec les englobants qui occuppent toute l'image ?
quel est le problème avec les englobants qui sont partiellement derrière la camera, le plan near ?

pour chaque pixel d'un mipmap du zbuffer, on peut construire l'intervalle de profondeur des pixels du zbuffer pleine resolution. est-il nécessaire de conserver les 2 bornes de l'intervalle ?

construisez le zbuffer hiérarchique. et vérifiez que le test d'occultation hiérarchique fonctionne correctement.


exercice 5 :
maintenant que le test d'occultation est en place, il ne reste plus qu'à l'utiliser pour dessiner les régions de la scène.

Mais pour que ce soit interressant, il faut un zbuffer pour tester la visibilite des régions avant de les dessiner... L'idée présentée dans le tp précédent est de dessiner les régions considérées visibles lors de l'image précédente pour construire / initialiser le zbuffer, puis de tester les régions pour lesquelles on a pas encore établi la visibilité. et bien sur de conserver cette information pour initialiser l'image suivante...


exercice 6 : c'est quand meme lent de relire le zbuffer dans l'application... comment faire le test avec un compute shader ?
l'idée est toujours la meme, le compute shader fera le test de visibilite et remplira un tableau de parametres pour MultiDrawIndirect().

indications : commencez par construire le zbuffer hiérarchique avec un compute shader...
utilisez imageLoad() / imageStore() pour lire et écrire les zbuffers. relisez la doc sur les storage images / textures.

les opérations atomiques min, max, addition, etc existent aussi sur les images (en plus des buffers) elles sont documentées dans la section glsl de la doc opengl, ou sur le wiki opengl.org

comment paralléliser ce calcul ? combien de dispatch() faudra-t-il faire ? 1 par resolution ?
peut-on grouper le calcul de plusieurs niveaux du zbuffer hierarchique dans un seul compute shader ? comment ? pourquoi vouloir faire ca ?

commencez par la version directe, 1 dispatch() + synchronisation par resolution du zbuffer.

pour les curieux : oui, on peut gagner un peu de temps sur le test d'occultation en utilisant textureGather() au lieu de texture(), qui renvoie 4 valeurs de 4 pixels différents...

pour les tres curieux : construire un mipmap de maniere très efficace est décrit sur gpuopen. les commentaires sur la technique sont dispo (video)