M2 Image

TP2 - affichage d'une scène détaillée

"exterior" (1.2Go)  extrait de la scene Bistro :
version allégée de la scène "bistro-small" (200Mo textures réduites, même géométrie)



le reste de la scène et les fichiers originaux sont dispo également.

les indications pour charger les textures sont à la fin du tp de préparation.


Partie 1 : ne pas dessiner ce que la camera ne peut pas voir

La première idée pour gagner du temps est de ne pas dessiner les objets / groupes de triangles en dehors de la région de la scène observée par la camera, comme c'est un peu long comme nom, on utilise souvent le mot frustum (def) pour désigner cette partie de la scène.

exercice 1 : comment déterminer qu'un point est dans le frustum ? ou à l'extérieur ? repassez dans le cours pipeline 3d / openGL, si nécessaire.

exercice 2 : et pour une boite alignée sur les axes ?

Ce test serait très simple si l'on voulait déterminer que 2 boites alignées sur les axes se touchent (ou pas). Il suffit de vérifier que tous les sommets d'une boite se trouvent tous à droite de l'autre boite, ou à gauche, ou en bas, etc. (6 tests en tout)
Qu'est ce qui change si l'on veut tester qu'une boite touche (ou pas) le frustum ? Le frustum est un morceau de pyramide dans le repère de la scène, pas une boite alignée sur les axes... le test précédent n'est pas utilisable, par contre : c'est bien un cube aligné sur les axes dans le repère projectif...

une solution simple est donc de faire les tests dans le "bon" repère. Selon le cas, c'est un objet ou l'autre, qui est une boite alignée sur les axes :
  1. comparer la boite au frustum dans le repère projectif : le frustum est un cube aligné sur les axes, si tous les sommets (transformés) de la boite sont tous du même coté du frustum, les 2 objets sont séparés et ne se touchent pas,
  2. comparer le frustum dans le repère de la scène à la boite alignée sur les axes (du repère de la scène), si tous les sommets (transformés) du frustum sont tous du même coté de la boite, les 2 objets sont séparés et ne se touchent pas...

et oui, si le premier test ne permet pas de "séparer" la boite et le frustum, il faut faire le 2ieme test.

remarque : dans le repère projectif homogène, qu'est ce qui se passe avec w, le poids homogène des points projettés ?

question : quelles sont les coordonnées des sommets du frustum dans le repère projectif ? et dans le repère de la scène ? faut-il utiliser des transformations particulières ?


Partie 2 : ou sont les cubes ??

Il ne reste plus qu'à tester les objets de la scène avant de les dessiner. Mesh::bounds() calcule les points extrèmes de la boite englobante alignée sur les axes d'un objet.

exercice 1 : connaissant pmin et pmax, les points extrèmes d'une boite alignée sur les axes, comment calculer les coordonnées des 8 sommets de la boite ?

indications : pmin.x et pmax.x représentent les bornes de l'intervalle sur l'axe x, idem pour y et z.
autrement dit, 4 sommets se trouvent à x= pmin.x et 4 autres à x= pmax.x, idem pour y et z.

exercice 2 : vérifiez que votre test de visibité fonctionne correctement sur une scène de test, comme la grille de cubes du tp précédent.

exercice 3 : sauf que dans la scène "bistro", il n'y a qu'un seul objet... comment faire pour découper cette scène en plusieurs blocs ?

il y a bien sur plusieurs solutions, proposez-en 1 ou 2 et vérifiez avec les autres binomes, quelle est la meilleure solution ?

exercice 4 : réalisez le découpage du mesh en boites. testez-les avant affichage des triangles associés.

est-il plus rapide d'afficher toute la scène sans faire de tests de visibilité ? et en changeant la taille des boites ? que constatez-vous ?

indication : vous pouvez trier les triangles d'un objet avec Mesh::groups( const std::vector<unsigned>& properties ), en associant un entier à chaque triangle (par exemple un indice de cellule dans une grille...). groups() renvoie les triangles tries selon ce critere, par cellule de la grille... cf MeshGroup et tuto9_groups.cpp pour afficher les différents groupes.


bonus : peut-on utiliser un draw avec un indice de matière comme dans le tp précédent, partie 2 / exercice 2 ? un seul draw ne dessinera que les triangles d'un cube, regardez comment fonctionne le multi draw, cf la doc en ligne.

indication : il faudra créer un texture2DArray avec toutes les textures. ie on ne peut pas créer un tableau de sampler2D... et oui, ce n'est pas très cohérent. cf les annexes pour créer le tableau de textures.


Partie 3 : ou sont les ombres ?

lorsque l'on dessine un triangle, on a pas d'information sur les autres triangles qui pourraient faire de l'ombre...

ah ? comment on fait alors ?
facile, mais il va falloir dessiner 2 fois la scène,

mais on "garde" les triangles visibles par la source de lumière, on sait qu'ils sont éclairés, c'est le résultat de la 1ere étape. pour chaque pixel de l'image dessinée pour le point de vue de la source on connait la distance entre la source de lumière et le triangle visible.

ensuite, lorsque l'on dessine la scène pour le point de vue de la camera, on transforme le point visible pour la camera dans le repère / le point de vue de la source. il ne reste plus qu'à comparer la distance entre le point transformé et la source avec la distance de la 1ere étape, qui correspond au triangle le plus proche de la source, ie celui qui est éclairé. si le point est à la bonne distance / sur le triangle éclairé, il est lui aussi éclairé, sinon sa distance est plus grande et il est à l'ombre.

exercice 1 : dessinez la scène pour le point de vue de la source de lumière.

on commence par construire les transformations qui permettent d'observer la scène depuis la position de la source de lumière. il faut, comme d'habitude, définir les transformations model, view, projection et image.

le plus direct est d'utiliser une projection ortographique, cf Ortho() qui "projette" un cube [left .. right] x [bottom .. top] x [znear .. zfar] du repère camera vers le repère projectif.
des exemples complets sont dans la doc en ligne, cf "decals et projection de textures"


exercice 2 : création du framebuffer et des textures

étape suivante, il faut conserver les triangles visibles par la source de lumière, ils seront éclairés. ces triangles sont aussi les plus proches de la source de lumière (par construction), on veut conserver au minimum le zbuffer, ie la distance entre la source de lumière et le triangle visible / éclairé.

la création / configuration d'un framebuffer est assez directe, mais il y a bien sur quelques détails techniques à régler, cf "rendu multi-passes". vous pouvez utiliser les utilitaires de texture.h pour créer simplement les textures nécessaires, cf make_depth_texture() ou make_vec3_texture(), etc. elles sont faites exactement pour ça !

au final, votre code ressemblera à :

    GLuint shadow_map= make_depth_texture( /* unit */ 0, /* width */ 256, /* height */ 256 );

    GLuint framebuffer= 0;
    glGenFramebuffers(1, &framebuffer);
    glBindFramebuffer(GL_DRAW_FRAMEBUFFER, framebuffer);

    glFramebufferTexture(GL_DRAW_FRAMEBUFFER, /* attachment */ GL_DEPTH_ATTACHMENT, shadow_map, /* mipmap */ 0);

    // verification de la configuration du framebuffer
    if(glCheckFramebufferStatus(GL_DRAW_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE)
        return "error";

    // nettoyage...
    glBindFramebuffer(GL_DRAW_FRAMEBUFFER, 0);

pour dessiner dans le framebuffer, il ne reste plus qu'à le sélectionner (avant de dessiner la scene), mais attention aux paramètres implicites du draw() !
(par exemple les dimensions de l'image... cf glViewport())


exercice 3 : et maintenant les ombres !!

et voila, tout est pret !! il ne reste plus qu'à dessiner la scène depuis le point de vue de la camera et à écrire le test qui vérifie qu'un point (vu par la camera) est plus près ou plus loin que le triangle éclairé par la source de lumière. les détails de la projection et des transformations sont dans "decals et projection de textures" / section "finir le calcul : projetter le décal"

attention : dans le shader, la fonction texture(shadow_map, uv) renvoie un vec4, la distance stockée dans la texture se trouve dans la composante .x ou .r (et pas .z qui sera toujours 0...)


exercice 4 : et maintenant ? des ombres propres !!

et voila ça marche !


ou pas, c'est quand même moche, non ?

argh, mais pourquoi ??

le zbuffer stocke une distance par pixel :


mais cette profondeur est constante : tous les points du triangle qui se projettent sur le même pixel ont la même distance, et le triangle n'est plus vraiment une surface, mais une approximation :

et lorsque l'on dessine la scène depuis un autre point de vue, on utilise la distance d'un autre point du triangle, et ces distances ne sont pas tout à fait identiques... donc il va falloir modifier le test...




il y a plusieurs solutions : on peut ajouter une tolérance lors de la comparaison (comme pour le lancer de rayon, cf doc en ligne), on peut arrondir la position du point pour correspondre au point stocké, et recalculer sa distance avant de faire la comparaison, on peut aussi stocker (ou recalculer) la normale du triangle et interpoler la distance, etc. mais le test reste quand même fragile.

pour les curieux : une solution assez robuste est présentée dans  "Adaptive Depth Bias for Shadow Maps", H. Dou, Y. Yan, E. Kerzner, Z.Dai, C.Wyman, 2014

l'idée est relativement simple : la comparaison directe du z et du zbuffer d'un point p est fausse, puisque le zbuffer ne stocke pas la profondeur du point p, mais la profondeur du point visible pour le centre du pixel du zbuffer / shadow map. en supposant que le point du zbuffer se trouve sur le meme triangle que p, il est possible de constuire le point du triangle qui se projette sur le centre du pixel du zbuffer / shadow map, ce qui permet de faire la comparaison, cf la section 3 de l'article, et le schema :





on connait le point p (visible depuis la camera, en noir), et il faut calculer le point sur le triangle qui se projette sur le centre du texel, en rouge. c'est un calcul d'intersection entre le plan du triangle et le rayon entre la source de lumiere et le centre du pixel du zbuffer sur lequel se projette p. cf principes du lancer de rayon / intersection rayon / plan pour les détails du calcul. au final, le plus "compliqué" est d'obtenir les coordonnées des points et vecteurs dans le même repère pour faire les calculs d'intersection.

et ça marche ?

   




les solutions récentes utilisent une autre technique : il faut se rendre compte que le problème n'apparait que sur les surfaces éclairées lorsque les distances sont proches, pas sur les surfaces bien séparées / différentes. il n'y a pas de problème sur l'ombre du sol (les surfaces sont éloignées), ils sont principalement visibles sur la tête du robot... unreal et unity par exemple, proposent des "screen space contact shadows" : c'est un lancer de rayon local qui est réalisé sur le zbuffer de la caméra lorsque les surfaces sont proches (sans intersecter les triangles, uniquement en comparant la profondeur d'un point sur le rayon à la profondeur du zbuffer, ce type de lancer de rayon s'appelle du ray marching : on teste plusieurs points le long du rayon pour trouver l'intersection), sinon la comparaison est faite directement.

les détails sont la : Bend Studio, 2023

 
un autre problème apparait lorsque la résolution de l'image camera est très différente de celle de la shadow map... par exemple, pour un objet loin de la camera et proche de la source... il faudrait tester tous les pixels de la shadow map qui se projettent dans un pixel de l'image, et c'est très lent. malheureusement, on ne peut pas moyenner les profondeurs de la shadow map (ie construire les mipmaps) et faire un seul test sur la profondeur moyenne, ie le test n'est pas linéaire. mais bien sur, on peut construire une version linéaire de ce test, par exemple "Variance Shadow Maps" W. Donnelly, A. Lauritzen. il y a d'autres constructions possibles : cf Gpu Gems 3, chapitre 8 par exemple, et bien sur la version brutale, cf la méthode PCF décrite également dans le chapitre.

Crytek à une présentation assez technique de l'ensemble des astuces qu'ils ont utilisé dans leurs différents jeux : playing with real time shadows, Crytek 2013


Partie Bonus :

la scène bistro éclairée par un soleil avec les ombres associées ? uniquement les blocs visibles ? et les vraies matières ?

avec des ombres propres ? cf la présentation de Bend Studio, juste au dessus ? ou l'autre solution, cf l'article "Adaptive Depth Bias..." ?


Annexe :

construire un texture 2d array à partir de plusieurs images de même dimensions :

GLuint make_texture_array( const int unit, const std::vector<ImageData>& images, const GLenum texel_format= GL_RGBA )
{
    assert(images.size());
    assert(images[0].pixels.size());
   

    // verifie que toutes les images sont au meme format
    int w= images[0].width;
    int h= images[0].height;
    int d= int(images.size());
   

    for(unsigned i= 1; i < images.size(); i++)
    {
        if(images[i].pixels.size() == 0)
            continue;     // pas de pixels, image pas chargee ?
       
        if(images[i].width != w)
            return 0;   //  pas la meme largeur
        if(images[i].height != h)
            return 0;   // pas la meme hauteur
    }
   
    // alloue le tableau de textures
    GLuint texture= 0;
    glGenTextures(1, &texture);
    glActiveTexture(GL_TEXTURE0 + unit);
    glBindTexture(GL_TEXTURE_2D_ARRAY, texture);
   
    glTexImage3D(GL_TEXTURE_2D_ARRAY, /* mipmap */ 0,
        texel_format, w, h, d, /* border */ 0,
        GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
   
    // transfere les textures
    for(unsigned i= 0; i < images.size(); i++)
    {
        if(images[i].pixels.size() == 0)
            continue;
       
        // recupere les parametres de conversion...
        GLenum format== GL_RGB;
        if(images[i].channels == 4)
            format= GL_RGBA;
       
        glTexSubImage3D(GL_TEXTURE_2D_ARRAY, /* mipmap */ 0,
            /* x offset */ 0, /* y offset */ 0, /* z offset == index */ i,
            w, h, 1,
            format, GL_UNSIGNED_BYTE, images[i].pixels.data());
    }
   
    // mipmaps
    glGenerateMipmap(GL_TEXTURE_2D_ARRAY);
   
    printf("texture array: %dx%dx%d %dMo\n", w, h, d, 4*w*h*d / 1024 / 1024);
    return texture;
}