M2 Image

TP5 - Frustum culling + Occlusion culling


L'idée du tp est de réaliser les tests de visibilité des boites englobantes des régions de la scène comme dans le tp précédent, mais avec un compute shader et d'afficher les régions visibles avec un seul draw, cf MultiDrawIndirect. les parties suivantes décomposent une solution.


Partie 1 : préparation : MultiDrawIndirect

relisez 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. sans tenir compte des matières, pour démarrer, ie utilisez la même matière de base / le même shader.

indications : construisez un std::vector avec les paramètres de dessin 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 glMultiDrawArraysIndirect) ou index_count (pour glMultiDrawElementsIndirect). vérifiez que ca fonctionne...


exercice 2 : é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 de la région pour ne pas dessiner les triangles d'une région non visible.

indication : votre shader va ressembler à celui de l'exercice 1 du tp de préparation. 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 3 : et avec les matières et les textures ?
quel est le poblème ? si on souhaite dessiner les régions avec un seul draw (normal ou multi...), cela suppose qu'un seul fragment shader est capable de dessiner tous les objets, quelque soient leurs matières / textures. une solution consiste à créer un tableau de matières, mais il faut connaitre l'indice de la matière du triangle dans le fragment shader. il y a 2 solutions simples : soit utiliser un attribut de sommet qui stocke l'indice de la matière du triangle, soit utiliser un tableau d'indice de matières et utiliser gl_PrimitiveID pour l'indexer.

dernier détail technique, il faut également que toutes les textures référencées par les matières soient disponibles en même temps. une solution simple consiste
à créer un texture2DArray pour les stocker, mais cela suppose que toutes les textures ont la même résolution. c'est le cas pour la scène bistro-small par exemple.
les indications pour créer le tableau de textures sont dans les annexes du tp 2.


Partie 2 : plus rapide

exercice 4 : 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 du tp de préparation.

comparez les performances à la version précédente. c'est mieux ?

mais combien de régions faut-il dessiner maintenant ? l'application doit fournir le nombre de draws lors de l'appel MultiDrawIndirect.
mais la valeur est dans un buffer... il suffit de relire cette valeur avec glGetBufferSubData() avant de dessiner.

cette solution fonctionne correctement mais elle introduit une synchronisation entre le gpu et l'application qui doit attendre la fin de l'exécution du compute shader, relire la valeur du compteur et enfin dessiner les n régions visibles. il existe une solution plus rapide, sans synchronisation : utilisez MultiDrawIndirectCount(), relisez la doc, si nécessaire. cette version de MultiDrawIndirect utilise un buffer pour lire le nombre de draws à réaliser !

comparez les performances à la version précédente. c'est mieux ?



exercice 5 :
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 ? ou cette optimisation n'est pas nécessaire dans ce cas ?


exercice 6 :
et avec les matières et les textures ?
c'est le bon moment pour faire cette partie, si ce n'est pas déjà fait.


Partie 3 : cache-cache et occlusion culling...

L'idée de l'occlusion culling est de vérifier qu'un objet n'est pas caché derrière un autre avant de le dessiner. Pour faire ce test, il faut construire le rectangle dans l'image qui englobe la projection de l'objet, puis comparer (pour chaque pixel du rectangle) la profondeur de l'objet aux profondeurs des objets deja dessinés (ie le zbuffer). Si la profondeur de l'objet est supérieure aux profondeurs stockées dans le zbuffer, l'objet est caché par de la géométrie deja dessinée, et ce n'est pas la peine de le dessiner.



plusieurs cas peuvent se produire, l'objet peut etre partiellement devant ou derrière la géométrie deja dessinée :

Ce test est relativement simple à faire, mais il y a un problème : il faut dessiner des objets pour obtenir un zbuffer avant de faire les tests... Sinon, tous ces tests ne servent à rien.
L'autre idée utilisée par l'occlusion culling est de conserver la visibilité de chaque objet pour aider les tests de la prochaine image... il suffira de commencer par dessiner les objets visibles dans l'image précédente pour remplir le zbuffer, puis de tester les autres objets avant de les dessiner (ou pas), et de mettre à jour l'info caché / visible, pour aider le dessin de l'image suivante.

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 7 : 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]

   

   

attention ! lorsque l'englobant de l'objet passe derriere la camera, la projection va produire des résultats surprenants, il faudrait découper l'englobant de l'objet et ne conserver que la partie devant la camera.

indication : une autre solution, plus simple, considère qu'un objet dont l'englobant est partiellement derrière la camera sera visible sur toute l'image... ie le rectangle englobant de sa projection dans l'image sera l'image complète.
bien sur, si l'englobant est entièrement derrière la camera, l'objet n'est pas dans le frustum : il n'est pas visible et il n'y a pas besoin de faire ce calcul de rectangle englobant. ie on ne dessine un objet que si une partie de son englobant est dans le frustum.


exercice 8 : 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 9 : 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 de la fenetre 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());

remarque : si le zbuffer est dans une texture, relisez la texture dans l'application avec glGetTexImage().


exercice 10 : 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 11 : 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 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 12 : 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 MultiDrawIndirectCount().

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)


Partie 4 : bravo !

vous venez de construire votre premier "gpu-driven rendering pipeline" !
il ne manque plus que les mesh shaders et vous savez faire aussi bien qu'eux ! (ou peut etre pas tout à fait ?)