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.
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
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;
}
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.
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.
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
{ ... }
}
Ecrivez un programme qui charge l'objet décrit par 'cube.obj'
(présent dans l'archive) et affichez le.
indications :
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.
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).
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( ).
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 ?
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é.
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 ?
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 ?
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.
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.