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)