M2 - Images

TP2 - affichage efficace openGL


24 millions de cubes ! extrait d'une scène minecraft, cf http://westeroscraft.com/




L'objectif de ce tp est de comprendre les différentes solutions permettant d'afficher une scène importante en utilisant openGL.


Partie 1 : prise en main

Installez la mise à jour de gKit, et compilez la documentation (avec doxygen), elle est disponible dans doc/html/index.html

Vous aurez également besoin des données : winterfell

et éventuellement du programme de conversion des fichiers minecraft : mNBT


gk::App, et gk::GLResource :

La classe gk::App permet de construire une application openGL, elle crée une fenetre et un contexte de rendu openGL. Elle permet également de gérer facilement les évènements claviers, souris, joysticks, etc.

Pour construire une application, il faut dériver la classe gk::App et redéfinir les méthodes suivantes :


Tous les objets openGL (gk::GLBuffer, gk::GLProgram, etc.) dérivent d'une classe de base gk::GLResource et se manipulent donc de la même manière. 

Les objets openGL / les GLResources n'appartiennent pas à l'application ! Ils appartiennent au contexte openGL. L'application ne peut donc que les referencer (garder un pointeur sur un objet GLResource). Les objets GLResources sont construits en deux étapes : new/delete permettent de créer/détruire l'objet c++ qui permet de manipuler un objet openGL, et l'objet openGL lui-même est crée/détruit par create()/release().

exemple :

    // creation d'un buffer
    #include "Vec.h"
    #include "GL/GLBuffer.h"

    std::vector<gk::Vec3> positions;
    GLBuffer *buffer0= new gk::GLBuffer;
    buffer0->create(GL_ARRAY_BUFFER, positions);

    // ou de maniere plus compacte :
    gk::GLBuffer *buffer= (new GLBuffer())->create(GL_ARRAY_BUFFER, positions);


Les objets openGL sont actifs après leur création. Dans l'exemple précédent, le nouveau buffer est sélectionné sur GL_ARRAY_BUFFER.
Pour rendre cette construction en 2 temps plus simple à manipuler des fonctions utilitaires sont disponibles : par exemple, gk::createBuffer().

Les objets openGL fournis par gKit sont documentés dans le module openGL. gKit fournit les fonctions de création / initialisation qui correspondent aux utilisations les plus courantes. Une fois l'objet crée, il faut le manipuler "normalement" en utilisant l'api openGL.

exemple :

    // creation et utilisation d'un shader program
    #include "Transform.h"
    #include "GL/GLProgram.h"

    #include "ProgramManager.h"

    // creation d'un shader program, et compilation de 2 shaders dont le source se trouve dans le fichier tp2.glsl
    GLProgram *program= gk::createProgram("tp2.glsl");
    if(program == gk::GLProgram::null())
        return "erreur de chargement / compilation du shader"

    // utilisation du programme pour dessiner
    glUseProgram(program->name);

    // modifier la valeur d'un uniform du shader program : par exemple la matrice "model view projection"
    gk::Transform model;
    gk::Transform view;
    gk::Transform perspective;
    gk::Transform mvp= perspective * view * model;
    program->uniform("mvpMatrix")= mvp.matrix();

    // dessiner quelquechose
    { ... }

Voici l'application minimale utilisant gKit (cf tutorial1.cpp dans l'archive) :


#include "App.h"

#include "ProgramManager.h"


class TP : public gk::App
{
    gk::GLProgram *m_program;
   
public:
    // creation du contexte openGL et d'une fenetre
    TP( )
        :
        gk::App()
    {
        // specifie le type de contexte openGL a creer :
        gk::AppSettings settings;
        settings.setGLVersion(3,3);     // version 3.3
        settings.setGLCoreProfile();      // core profile
        settings.setGLDebugContext();     // version debug pour obtenir les messages d'erreur en cas de probleme
       
        // cree le contexte et une fenetre
        if(createWindow(512, 512, settings) < 0)
            closeWindow();
    }
   
    ~TP( ) {}
   
    int init( )
    {
        // cree un shader program
        m_program= gk::createProgram("tp1sphere.glsl");
        if(m_program == gk::GLProgram::null())
            return -1;
       
        // tout c'est bien passe.
        return 0;
    }
   
    int quit( )
    {
        // rien a faire les ressources opengl sont detruites automatiquement par gk::GLManager.
        return 0;
    }
   
    int draw( )
    {
        if(key(SDLK_ESCAPE))
            // fermer l'application si l'utilisateur appuie sur ESCAPE
            closeWindow();
       
        if(key('r'))
        {
            // reinitialise l'etat de la touche, evite de recharger les shaders a la prochaine image
            // (en general, on maintient la touche enfoncee plus de 16ms...)
            key('r')= 0;

            // recharge et recompile les shaders
            gk::reloadPrograms();
        }
       
        // redimensionne l'image en fonction de la fenetre de l'application
        glViewport(0, 0, windowHeight(), windowHeight());
        // efface l'image
        glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
       
        // selectionne le shader program a utiliser
        glUseProgram(m_program->name);
       
        // dessine quelquechose
        { ... }

        // un peu de nettoyage
        glUseProgram(0);

        // enregistre l'image dessinee par opengl
        if(key('s'))
        {
            key('s')= 0;           
            gk::writeFramebuffer("screenshot.png");
        }
       
        // visualise l'image que l'on vient de dessiner.
        present();
       
        return 1;
    }
};


int main( int argc, char **argv )
{
    TP app;
    app.run();
   
    return 0;
}


Grid World, Map, Region et Block :

Les scènes extraites de Minecraft sont représentées par une multi-grille : une hiérarchie de grilles de résolutions de plus en plus fines. Seules les cellules occuppées d'une grille sont définies à une résolution plus fine.

L'élément de base, le voxel, est le cube unitaire,
    un block regroupe 16x16x16 cubes,
    une region regroupe 16x16x16 blocks,
    une map groupe 16x1x16 regions,
    et world groupe 16x1x16 maps.

Ce qui permet de représenter un monde de 65536x256x65536 cubes...
L'utilisation d'une hiérarchie de grilles permet de ne stocker que les zones de la scène qui sont définies. La scène d'exemple n'occuppe "que" 35Mo en ne stockant que des informations sur 24M de cubes.

Quelle serait la taille occuppée par le monde sans cette représentation hiérarchique ? Un ordinateur dispose-t-il de suffisament de mémoire pour le manipuler sous cette forme ? d'un disque suffisant ?

Pourquoi la hiérarchie occuppe-t-elle 35Mo alors qu'il n'y a que 24M cubes à représenter ? Peut on facilement corriger ce probleme ?


La hiérarchie de grilles utilisée ici est un hachage spatial : une grille est représentée par un ensemble de cellules, un tableau d'indices désignant les cellules contenant des données plus détaillées.
Par exemple, la classe World est composée d'un index spatial et d'un ensemble d'objets Map. Un objet Map contient un index spatial et un ensemble d'objets Region, etc.

Grid.h et Grid.cpp fournissent les classes permettant de charger et de manipuler cette représentation : cf World, Map, Region, Block qui dérivent de Grid.

Une application minimaliste utilisant une version modifiée de la librairie mNBT permet de convertir les scènes du format minecraft vers le format utilisé par la classe World pour charger les données. (mNBT ne relit que le format anvil, extension .mca, pas le format region, extension .mcr)

Vous pouvez récupérer une scène déja convertie (cf l'archive westeros au début du sujet), prête à être utilisée, utiliser un éditeur comme MCedit pour construire la vôtre, ou convertir une scène existante.


Manipulation des objets Grid :

Les classes World, Map, Region et Block définissent quelques opérations permettant de récupérer les données stockées :

World world;
if(world.loadMap("repertoire/westeros/winterfell") < 0)    // pour charger la scene fournie en exemple.
    return "erreur de chargement"

Gridpoint p;

Map *map= world.map(p);           // renvoie l'objet Map contenant les coordonnées du point, ou NULL, s'il n'y a pas de données à ces coordonnées.
Region *region= world.region(p);  // idem, renvoie l'objet Region contenant le point, s'il existe
Block *block= world.block(p);     // idem, renvoie l'objet Block contenant le point, s'il existe

et pour obtenir le type de voxel :

int voxel= block->voxel(p);       // renvoie le type du voxel ou -1 s'il n'existe pas.

Gridpoint p;
Map *map= world.map(p);   
if(map != NULL)
{
    Gridindex index= map->grid_index(p);    // la methode grid_index() est definie dans World, Map, Region et Block
   
    // index represente la cellule de la grille à laquelle appartient p, dans ce cas, un objet Map
}

Gridindex index;
Gridpoint p= world.grid_point(index);

for(int x= 0; x < world.size.x; x++)
for(int y= 0; y < world.size.y; y++)

for(int z= 0; z < world.size.z; z++)

{
    Gridpoint p( world.grid_point(x, y, z) );
    Map *map= world.map(p);
    if(map == NULL)
        continue;

    // faire quelquechose avec l'objet Map
    { ... }
}


Ecrivez l'itération sur l'ensemble des voxels associés au monde. Il y a 2 solutions : une directe, à éviter (pourquoi ?) et une autre hiérarchique, qui itère les objets Map, Region et Block.

solution directe (ne pas utiliser !! pourquoi ?) :

for(int x= world.bbox.pMin.x; x < world.bbox.pMax.x; x++)
for(int y=
world.bbox.pMin.y; y < world.bbox.pMax.y; y++)
for(int z=
world.bbox.pMin.z; z < world.bbox.pMax.z; z++)
{
    Gridpoint p( x, y, z );
    Block *block= world.block(p);
    if(block == NULL)
        continue;

    int voxel= block->voxel(p);

    // faire quelquechose avec le voxel
    { ... }
}



Partie 2 : affichage

Exercice 1 : mon premier cube...

Ecrivez un programme qui charge l'objet décrit par 'cube.obj' (présent dans  l'archive) et affichez le.

indications :


Exercice 2 : un block

Utilisez l'itération sur les blocks pour trouver et afficher un seul block. Dessinez un objet cube par voxel de type différent de 0.

indication : faites le chargement et la recherche du block dans la fonction init( ) et l'affichage dans draw( ).

Eliminez les voxels non visibles : on considère qu'un voxel est non visible, s'il est entouré de 6 voisins.

Exercice 3 : un block rapide

Générez un maillage indexé représentant les faces visibles d'un block. Cette solution est-elle plus rapide que la précédente ?

indication : on considère qu'une face est potentiellement visible, si elle n'a pas de voisin : le voxel adjacent est vide (type == 0).


Exercice 4 : déplacement de l'observateur / camera

Comment déplacer l'observateur dans la direction regardée ?
Comment ré-orienter l'observateur pour regarder vers la gauche, la droite, en haut, en bas ? (et contioner à se déplacer dans la direction observée).

indications :
utilisez les fonctions de SDL permettant de connaitre le déplacement de la souris. cf http://wiki.libsdl.org/CategoryMouse, et plus particulièrement SDL_GetRelativeMouseState( ).
Vous aurez sans doute aussi besoin de gk::RotateX(), gk::RotateY() ainsi que de gk::LookAt( ).


Partie 3 : affichage efficace

Exercice 1 : n'afficher que ce qui est a priori visible.

Pour pouvoir afficher la totalité du monde, il est nécessaire de limiter la quantité de blocks que l'on affiche.

Comment vérifier que la boite englobante d'un block est visible par la camera ? (cf. tp1, rasterization en bloc).

Ecrivez ce test et modifiez votre programme pour l'utiliser : seul les blocks visibles sont affichés.


pour les curieux :

utilisez un test exact en 2 étapes. La première étape teste l'existence d'un plan du volume visible "rejettant" tous les sommets de la boite englobante. Ce test est approché, il considère que certains block sont visibles alors que c'est faux. Ajoutez un deuxième test qui vérifie que les sommets du volume visible sont rejettés par un plan de la boite englobante.
Dans quel cas le deuxième test est-il nécessaire ?


Exercice 2 : n'afficher que la surface ?

Le sous sol est également modélisé, et est donc affiché. Si la camera est au dessus du sol, il est inutile d'afficher les parties du sous sol qui seront de toute manière cachées.

Proposez un test permettant d'éliminer les blocks du sous sol qui ne peuvent pas être visibles depuis la surface.
indication : pensez aux puits...


pour les curieux :
complétez le test pour qu'il permette de faire le contraire : ne pas afficher la surface lorsque la camera est dans le sous-sol.

pour les très curieux :
consultez la documentation de glBeginConditionalRender() et la section 2.15 de la spécification openGL 3.3 et proposez une solution exploitant les requêtes de visibilité (occlusion query) et le rendu conditionnel.
Quels gains observez vous ?
Cette solution est-elle plus interressante que la solution "manuelle" consistant à récupérer les résultats des requètes avant de dessiner un block ?
Quels sont les avantages et inconvénients de chaque solution ? Proposez une solution permettant de cacher la latence des requetes.
Faites l'exercice 3 avant de tester en profondeur cette fonctionnalité.


Exercice 3 : aider le materiel !

Toutes les solutions actuelles limitent le nombre de cubes à afficher. Mais selon l'ordre dans lesquels sont dessinés ces cubes, la carte graphique mettra plus ou moins de temps. Pourquoi ?

Proposez une solution simple permettant de dessiner les cubes dans l'ordre : en s'éloignant de la camera.

indication :
calculez les coordonnées du block dans lequel se trouve la camera et utilisez la distance "block/Manhattan" : length(v)= |v.x| + |v.y| + |v.z|, ce qui permettra de dessiner les blocks en anneaux autour de la camera.

Comparez avec la solution inverse : en s'approchant de la camera.
Cette solution est-elle plus efficace que la version précédente ?


Exercice 4 : aider l'api à bien utiliser le matériel !

Il existe de nombreuses manières d'écrire la boucle de dessin des blocks. Certaines sont plus efficaces que d'autres.
De manière générale, il faut limiter le nombre d'appels openGL.

Les données permettant de dessiner les cubes et les blocks sont, a priori, stockées dans plusieurs buffers. Ce qui implique que vous devez changer de buffer pour chaque block, et cette opération n'est gratuite ni pour le driver, ni pour la carte graphique. L'association des attributs de sommets aux variables du vertex shader, configurée par un objet Vertex Array est également couteuse à modifier.

Modifiez vos shaders et votre programme pour n'utiliser qu'un seul (gros) vertex buffer et un seul vertex array.
La boucle de dessin ne doit plus contenir que les appels glDrawXXX() et aucun glBindXXX() / glVertexArrayXXX().

Quel gain constatez vous ?


Partie 3 : un peu de diversité

Modifiez la couleur ou la texture d'un voxel en fonction de son type.

Les couleurs "officielles" sont décrites sur cette page : http://minecraft.gamepedia.com/Map_item_format


pour les curieux : construisez les textures cubes représentant les 6 faces de chaque type de voxel. Les textures sont disponibles sur westeroscraft.com, dans le répertoire /assets/minecraft/textures/blocks de l'archive.

Les noms de textures de base correspondant aux 128 premiers types de voxels sont décrits dans le fichier block_name.cpp inclut dans l'archive du tp. par exemple, le type 1 est "stone", et le type 2 correspond a "grass".

Il reste à charger les images (utilisez gk::ImageIO::read()) et à déterminer quelle texture utiliser sur chaque face du voxel : les noms d'images sont suffixées par "_top", "_bottom", "_side" selon le cas.

exemple :
"stone" est utilisée pour les 6 faces du voxel.
par contre "grass" ne correspond pas à une image, faut utiliser "grass_top" sur la face du dessus et "grass_side" sur les faces latérales du voxel.

Creez les textures cubes avec gk::createTextureCube().

Il faut également organiser toutes les textures pour y acceder simplement dans le fragment shader. La solution la plus simple est de toutes les ranger dans un texture cube array.


Partie 4 : niveaux de détails

Déterminez à partir de quelle distance un voxel appartenant à un block visible se projette sur +/- 1 pixel.
N'affichez que les blocks dont tous les voxels sont plus gros qu'un pixel.