M2 Image

TP2 > challenge accepted !






récupérez le fichier scène au format gltf + textures : splash3.zip
le fichier blender correspondant, si vous souhaitez faire des retouches : splash3.blend

cette version a été fortement simplifiée pour etre affichable en temps réel sur la plupart des machines. la végétation est beaucoup plus dense dans la version originale (dispo sur blender.org)


15/12/2022 : version texturée des iles... cf splash3_bake.zip
la conversion n'est pas exacte, il a fallut faire pas mal de modifications, mais ce sera mieux que tout blanc ou marron...


Partie 1 : prise en main

avant de vous lancer dans l'affichage de la scène, il est quand même recommandé de passer un peu de temps :

exercice 1 : dessiner plusieurs copies du meme objet

utilisez glDrawInstanced pour afficher plusieurs fois data/robot.obj à des endroits différents. vous devez être à l'aise avec la version qui stocke les transformations dans un buffer. cf la doc, section "draw instanced, dessiner plusieurs copies", ie configurez un attribut d'instance.

dans un premier temps, utilisez uniquement une translation, ie un vec3.


exercice 2 : et avec une matrice ?

il va falloir trouver un moyen d'utiliser une matrice pour placer et orienter chaque copie de l'objet. comment faire ? il y a au moins 2 solutions, soit utiliser des attributs d'instance, soit utiliser un tableau uniform...

mais les uniforms ne peuvent pas occuper plus de 32Ko au total, ce qui permet de stocker au mieux 512 matrices 4x4 (1 matrice == 64octets). en pratique, il faut se limiter plutot à 128 ou 256 matrices, pour laisser de la place aux autres uniforms des shaders.

au final le vertex shader va ressembler à ça :
in vec3 position;

uniform mat4 projection;
uniform mat4 view;

uniform mat4 models[128];

void main( )
{
    // recuperer la transform de l'instance
    mat4 model= models[gl_InstanceID];
   
    // comme d'habitude...
    gl_Position= projection * view * model * vec4(position, 1);
}
// remarque : on peut bien sur composer view et projection dans une seule matrice...


remarque : vous pouvez utiliser program_uniform( /* program */ , /* uniform */ , std::vector<Transform>& transforms ) pour initialiser ce tableau. le vecteur doit faire la meme taille que le tableau d'uniforms dans le shader. ce n'est pas nécessaire pour openGL mais la vérification permet de trouver certaines erreurs d'indexation. pour eviter l'affichage des messages dans la console, il suffit d'utiliser glUniformMatrix4fv() directement.

Partie 2 : chargement...

mettez gkit à jour, avec git pull

les fichiers src/gKit/gltf.h et src/gKit/gltf.cpp permettent de charger une scene au format gltf. la scene est décrite par une structure toute simple, cf GLTFScene, qui contient des tableaux de maillages, de transformations pour dessiner les maillages, etc.

pour les curieux : le format gltf permet de stocker pas mal de choses, cf glTF Tutorial.

exemple de parcours des noeuds, des maillages et de leur groupes de primitives (triangles) :

#include "gltf.h"

GLTFScene scene= read_gltf_scene( "..." );

// parcours les noeuds de la scene
for(unsigned node_id= 0; node_id < scene.nodes.size(); node_id++)
{
const GLTFNode& node= scene.nodes[node_id];

// recupere le mesh du noeud
const GLTFMesh& mesh= scene.meshes[node.mesh_index];
// recupere la transform du noeud
Transform m= node.model;

// parcours les primitives du mesh
for(unsigned primitive_id= 0; primitive_id < mesh.primitives.size(); primitive_id++)
{
const GLTFPrimitives& primitives= mesh.primitives[primitive_id];

// matiere des primitives / groupe de triangles
const GLTFMaterial& material= scene.materials[primitives.material_index];

{ ... }
}
}

exercice 1 : chargez la scene d'exemple et affichez quelques propriétes : nombre de maillages, taille totale des attributs de sommets des maillages, nombre de fois ou chaque maillage est dessine...

voici le résultat en image : en rouge les objets qui doivent etre dessiné plusieurs fois / instanciés



et tous les objet dessinés à leur place :



quel est le nombre max d'instances utilisé dans la scène ? combien d'objets ne sont dessinés qu'une seule fois, combien sont dessinés plus de 128 fois ? et pour les autres ? quel est le nombre moyen d'instances ?

indication : utilisez GLTFScene::instances() pour analyser tout ca...

voila le résultat en image : en gris les maillages dessinés 1 fois, en rouge ceux qui sont dessinés plus de 128 fois et en jaune ceux qui sont dessinés entre 2 et 128 fois...



et sans surprises, c'est la végétation qui utilise le plus d'instances...

remarque : oui, cette version affiche les 36M de triangles de la scène avec glDrawElementsInstanced() et un tableau uniform de 128 matrices dans le vertex shader, comme suggéré dans la partie 1 / exercice 2...
le temps cpu est plus raisonnable aussi : 0.5ms (au lieu de 12ms). il faut 306 draws au total pour dessiner les instances des 199 maillages, au lieu de 15080 pour la solution naive (1 draw par instance).


exercice 2 : dessinez la scène...

commencez par créer les buffers et les vao et faites un glDrawElements() pour chaque maillage.


exercice 3 : dessinez rapidement la scène...

utilisez GLTFScene::instances() pour récupérer les transformations de chaque maillage et utilisez glDrawElementsInstanced() pour dessiner ses instances.


bonus (pour les curieux) : aider openGL / bien utiliser openGL

quelle est la meilleure organisation des données de la scène pour tout dessiner efficacement ?
L'idée est de faire le moins de modifications / de changements de shaders, de textures, de buffers / vao et d'uniforms, c'est à dire de construire les plus grands groupes de triangles possibles, moins il y a de groupes avec des propriétés différentes, moins on aura besoin de draw() pour tout dessiner.

Pour commencer, on va regarder ce qu'il se passe lorsque l'on dessine tout de manière directe. On suppose que l'on a crée un buffer et un vao par primitives / groupe de triangles de chaque maillage (cf GLTFPrimitives et GLTFMesh).
for( node : nodes )
{
    mesh=  node.mesh
   
    // chaque groupe de triangles est associe a une matiere differente / shader
    for( triangles : mesh.primitives )
    {
        bind_vao(triangles)
       
        material= triangles.material
        bind_program(material)
       
        // transformations
        uniform(node.model)
        uniform(view)
        uniform(projection)
       
        // parametres de la matiere
        uniform(material.color)
        uniform(material.metallic)
        uniform(material.roughness)
       
        // textures
        bind_texture(material.color_texture)
        bind_texture(material.metallic_roughness_texture)
       
        draw(triangles)
    }
}
il est assez simple de compter combien de fois on change de shader, de texture, etc. dans l'exemple précédent, c'est facile, tout change à chaque itération. c'est a dire 30300 fois... et c'est lent (pour l'application et le driver openGL qui prépare les commandes à exécuter sur la carte graphique).

le code en annexe permet de compter le nombre de changements nécessaire sans avoir à tout re-écrire.
si on ne sélectionne un shader, une texture, etc que si nécessaire, (uniquement s'il y a une différence par rapport aux paramètres du draw() précédent...) voici les resultats que l'on obtient :

246 material / 30008 color texture / 26 metallic+roughness texture / 15080 node / 30124 primitive

ie, il est necessaire de changer 246 fois de shader, 30000 fois de texture couleur, etc.

l'idée est de changer l'ordre d'affichage pour diminuer le nombre de modifications de chaque propriété. quels sont les changements les plus couteux ? changer de shader et de textures. donc si on trie les groupes de triangles par matière et par texture, on devrait pouvoir réduire les changements nécessaires...

on commence par construire l'ensemble des draws et leur paramètres :
struct state
{
    shader
    primitives
    material
    mesh
    model
}

// construire les parametres
state states[]
for( node : nodes )
{
    mesh= node.mesh
    model= node.model
   
    for( primitives : mesh.primitives )
    {
        material= primitives.material
        shader= material.shader
        states.insert( {shader, primitives, material, mesh, model} )
    }
}
le code d'affichage boucle sur un ensemble de groupes de triangles et avant de changer la valeur d'un paramètre (ce qui n'est pas gratuit) on vérifie qu'il est différent du précédent :
// trier les parametres, ou pas...
{ ... }

// dessiner dans l'ordre
for( state : states )
{
    // shader
    if(state.shader != last shader)

    {
        use_program(state.shader)
        // camera
        unifom(view)
        uniform(projection)
    }
     
    // parametres de la matiere
    if(state.material != last material)

    {
        // parametres de la matiere
        uniform(state.material.color)
        uniform(state.material.metallic)
        uniform(state.material.roughness)       
    }
   
    // textures
    if(state.material.color_texture != last color_texture)

        bind_texture(state.material.color_texture)
    if(state.material.metallic_roughness_texture != last metallic_roughness_texture)
        bind_texture(state.material.metallic_roughness_texture);
   
    // buffers+vao
    if(state.primitives.vao != last vao)

        bind_vao(state.primitives.vao)
       
    draw(state.primitives)
}
mais, on peut faire mieux : pourquoi faut-il changer un paramètre ? le shader par exemple ? il y a 90 matières différentes dans la scène, mais voici leurs paramètres principaux :

  shader 0: transparent no  / color texture no  / metallic+roughness texture no
  shader 1: transparent no  / color texture yes / metallic+roughness texture no
  shader 2: transparent no  / color texture yes / metallic+roughness texture yes
  shader 3: transparent yes / color texture no  / metallic+roughness texture no

on a plutot besoin de 4 shaders différents pour représenter les matières : selon que la matiere est transparente ou pas, et qu'elle utilise une texture ou pas (pour modifier un paramètre à la surface des triangles du groupe). est-il réellement nécessaire d'écrire les 3 versions du shader pour les matières opaques ? sachant que la différence principale se résume à la déclaration et l'utilisation d'une texture pour récupérer la valeur d'un paramètre pour un fragment ?

on peut simplifier tout ça en utilisant des textures uniformes, dont tous les pixels (texels) ont une valeur par défaut, 0 ou 1 selon le paramètre. donc au lieu de 3 shaders pour les matières opaques, on peut écrire un seul shader qui utilise toujours des textures pour chaque paramètre.

mais il reste un détail : pour lire les textures dans les shaders, il faut des coordonnées de texture, ie il faut savoir quel texel de la texture est associé à un fragment. malheureusement, quelques groupes de triangles n'ont pas de coordonnées de textures... c'est embettant non ? ou peut on créer des coordonnées de textures par défaut pour ces triangles ? sachant que si les paramètres ne sont pas dans une texture, ils sont constants pour tous les triangles.

de manière générale, on vient de réduire le nombre de configurations différentes nécessaires pour dessiner les objets de la scène. il ne reste plus qu'à trouver l'ordre qui minimise les changements de paramètres entre 2 configurations / draws() successifs...

après quelques tests, on arrive à trier les paramètres pour tout dessiner avec le nombre de changements suivant :

2 shaders / 18 color texture / 4 metallic+roughness texture / 30042 node / 428 primitive

et on a toujours pas utilisé l'instanciation / glDrawElementsInstanced() ...


en résumé : on a identifié les groupes de paramètres, les "configurations", nécessaires à l'affichage direct de la scène. on a réduit ce nombre de configurations et on a ensuite cherché à limiter le nombre de transitions entre des configurations successives pour tout afficher. de manière générale, on vient de construire des groupes de primitives que l'on peut dessiner ensemble, avec un seul draw. une autre manière de regarder ce problème : qu'est ce qui limite le nombre de primitives que l'on peut dessiner ensemble, sans changer de configuration / de paramètres ?

que peut-on faire de plus ? quelles sont les propriétés qui forcent encore à dessiner des primitives séparemment ? le changement de buffers/vao et le changement de textures (on a plus qu'un seul shader pour toutes les matières opaques).

Partie 3 : c'est un peu moche non ?

relisez la doc sur les modèles de matières empiriques, cf Lambert et Blinn, écrivez les shaders et affichez la scène.

remarque : si vous trouvez la scène d'exemple trop complexe à manipuler, vous pouvez utiliser data/robot.gltf  (ou robot_gltf.zip) par exemple.

les différents modèles de matières utilisent des paramètres différents. par exemple, les modèles empiriques utilisent directement une couleur pour le comportement diffus et une autre pour le comportement spéculaire. par contre, les paramètres des matières GLTF utilisent la paramétrisation métal / couleur / rugosité (cf la doc de GLTFMaterial et les explications dans modèle à micro-facettes / section c'est quoi le modèle ? ) il suffit de convertir les valeurs pour estimer les paramètres d'un modèle empirique :
diffuse_color= (1 - metal) * color
specular_color = (1 - metal) * Color(0.04) + metal * color
alpha= 2 / (roughness^4) - 2
(la forme des reflets n'est pas exacte, c'est une approximation... les modèles à micro-facettes plus complexes ne seraient pas utilisés sinon...)

au final, on obtient un modèle empirique :
f_r(p, n, o, l)= diffuse_color / pi + specular_color * (alpha+8)/ 8pi * (cos theta(n, h))^alpha

avec
theta(u, v) : angle entre les vecteurs u et v, et cos theta(u, v), cosinus de l'angle entre u et v, cf dot(u, v) si u et v sont de longueur 1...

exercice 1 :
écrivez le shader qui utilise ce modèle pour afficher un objet (ou la scène).
remarque : si vous avez du mal à démarrer, relisez la doc avec les explications sur les shaders et les brdfs.

indication : vous pouvez charger un objet et modifier les paramètres de ses matières dans blender... cf les paramètres de Principled BRDF et exporter le résultat en glTF pour le recharger dans votre application.


exercice  2 :
et avec les textures ?
relisez la doc sur l'utilisation des textures si necessaire.




vous pouvez charger les images référencées par les matières de la scène avec : read_gltf_images(), cette fonction respecte l'indexation utilisée par la description des matières.
il ne reste plus qu'à créer les textures openGL avec make_texture().

remarque : il faudra sans doute recalculer les paramètres du modèle empirique dans le fragment shader, en fonction du contenu des textures...


pour les curieux : au lieu de sommer la partie diffuse et la partie spéculaire, on peut également utiliser le terme de Fresnel pour pondérer les 2 :
F= specular_color + (Color(1) - specular_color) * (1 - cos theta(o, h))^5    // utilise l'approximation des coeffs de Fresnel...

f_r(p, n, o, l)= F * diffuse_color / pi + (1 - F) * specular_color * (alpha+8)/ 8pi * (cos theta(n, h))^alpha


pour les très curieux : utilisez le vrai modèle à micro facettes, cf GGX+Smith dans modèle à micro-facettes...

Partie 4 : c'est trop éclairé !

ou sont les ombres ?

Partie x : c'est un peu lent non ?

comment gagner du temps sur l'affichage ? utiliser glDrawElementsInstanced() permet de réduire le temps cpu, mais comment accélérer le dessin par le gpu ?

intuition : est-il vraiment nécessaire de dessiner les 36M de triangles ? lesquels pourait-on supprimer sans modifier l'image ? et si on accepte une difference dans l'image produite, lesquels pourrait-on supprimer ?

indication : peut on vérifier qu'un objet / maillage est inclut dans le frustum de la camera avant de le dessiner ?
indication : peut on vérifier qu'un objet / maillage est visible dans l'image avant de le dessiner ? ie il n'est pas caché par un autre objet plus proche de la camera.

indication : pourrait on dessiner le fond marin plus rapidement, ou une approximation ? (sans dessiner tous les triangles...)
indication : et pour les arbres ?

intuition : 36M de triangles et 2M de pixels... se pourrait-il, par hasard, que certains triangles soit suffisemment petits pour passer entre les centres des pixels ? ie ils ne contribuent pas à l'image...

intuition : est il vraiment nécessaire de dessiner des arbres qui occuppent moins de 10 pixels dans l'image ?

indication : cf fonction bake dans cycles/blender...

Partie x : mais on ne voit pas le fond ? transparence...

...


Annexe : estimation des changements d'états


            struct draw_state
            {
                int shader_type;
                int material_index;
               
                int transparent;
                int color_texture;
                int metallic_roughness_texture;
               
                int node_index;
                int mesh_index;
                int primitive_index;
                int first;
                int n;
            };
           
            std::vector<draw_state> states;
           
            // construire la liste d'etats
            for(unsigned node_id= 0; node_id < m_scene.nodes.size(); node_id++)
            {
                const GLTFNode& node= m_scene.nodes[node_id];
                const GLTFMesh& mesh= m_scene.meshes[node.mesh_index];
               
                for(unsigned primitive_id= 0; primitive_id < mesh.primitives.size(); primitive_id++)
                {
                    const GLTFPrimitives& primitives= mesh.primitives[primitive_id];
                   
                    int material_id= primitives.material_index;
                    const GLTFMaterial& material= m_scene.materials[material_id];
                   
                    draw_state state= { };
                   
                    state.shader_type= -1;
                    state.material_index= material_id;
                    state.transparent= material.transmission > 0;
                    state.color_texture= material.color_texture;
                    state.metallic_roughness_texture= material.metallic_roughness_texture;
                    state.node_index= node_id;
                    state.mesh_index= node.mesh_index;
                    state.primitive_index= primitive_id;
                   
                    states.push_back(state);
                }
            }
           
            printf("%d states\n", int(states.size()));
           
            // tri par etat
            std::sort(states.begin(), states.end(),
                []( const draw_state& a, const draw_state& b )
                {
                    if(a.material_index != b.material_index)
                        return a.material_index < b.material_index;
                   
                    // todo comparaison lexicographique
                    { ... }
                   
                    return false;
                }
            );
           
            // analyse des changements d'etats en fonction de l'ordre...
            {
                int materials_count= 0;
                int color_texture_count= 0;
                int metallic_roughness_texture_count= 0;
                int nodes_count= 0;         // transforms...
                int primitives_count= 0;    // vao + buffers...
               
                int last_material= -1;
                int last_color_texture= -1;
                int last_metallic_roughness_texture= -1;
                int last_node_index= -1;
                int last_primitive_index= -1;
               
                for(unsigned i= 0; i < states.size(); i++)
                {
                    const draw_state& state= states[i];
                   
                    if(state.material_index != last_material)
                    {
                        last_material= state.material_index;
                        materials_count++;
                    }
                    if(state.color_texture != last_color_texture)
                    {
                        last_color_texture= state.color_texture;
                        color_texture_count++;
                    }
                    if(state.metallic_roughness_texture != last_metallic_roughness_texture)
                    {
                        last_metallic_roughness_texture= state.metallic_roughness_texture;
                        metallic_roughness_texture_count++;
                    }
                    if(state.node_index != last_node_index)
                    {
                        last_node_index= state.node_index;
                        nodes_count++;
                    }
                    if(state.primitive_index != last_primitive_index)
                    {
                        last_primitive_index= state.primitive_index;
                        primitives_count++;
                    }
                }
               
                printf("%d material / %d color texture / %d metallic+roughness texture / %d node / %d primitive\n",
                    materials_count, color_texture_count, metallic_roughness_texture_count, nodes_count, primitives_count);
            }
        }