Afficher une heightmap avec OpenGL
Date de publication : 02 juin 2008
Par
Aurélien Vallée
Dans cet article nous allons traiter d'un domaine phare de la programmation
graphique, le rendu de terrain.
Pour ce faire nous allons utiliser une méthode classique, celle dite des
cartes de hauteur (ou heightmaps). Cette méthode est particulièrement usitée
dans les jeux, et propose une manière simple et intuitive d'approcher le
problème.
I. Principe des cartes de hauteur
II. Le choix du format
III. Limites des cartes de hauteur
IV. Modélisation générale de l'application
V. Configuration externe
VI. Chargement de la carte
VII. Rendu de la carte de hauteur
VII-A. Aperçu de notre classe de carte
VII-B. Les différentes modes de rendu
VII-C. Choix des primitives appropriées
VII-D. Compilation de la display list
VII-E. Affichage de la display list
I. Principe des cartes de hauteur
Une carte de hauteur est en fait un simple fichier image, souvent en noir et blanc, qui représente le
relief d'une région. Un exemple étant plus parlant qu'un long discours, contemplez la heightmap suivante
:

Un exemple de carte de hauteur
Sur cette carte, on peut distinguer des zones hautes (en blanc) et des zones basses (en noir). On peut
donc reconnaître une vallée, avec un lac, entourée de montagnes.
A partir de cet exemple, vous comprendrez qu'il serait pratique de disposer d'un tableau d'entiers à
deux dimensions (largeur, longueur) qui contiendrait la hauteur (donc la couleur) de chaque point
de notre carte. Il nous suffirait donc de matérialiser cette image sous forme d'un tableau.
Bien sur, notre carte ne se contentera pas d'un relief en noir et blanc. Nous allons plaquer par dessus
une grande texture ( en l'étirant au besoin ):

Texture principale de notre carte
Enfin, dernière touche d'esthétisme, nous allons encore replaquer par dessus la texture principale, une
texture de détail.
Cette dernière est chargée de donner un peu de relief à la carte, avec un petit jeu d'ombrages.
Voici quelques exemples de textures de détails :

Exemple de plaquage de différentes textures de détails
II. Le choix du format
Pour représenter notre carte de hauteur, nous aimerions pouvoir utiliser n'importe quel format de fichier
graphique. Par la suite, nous pourrions en revanche être amené à utiliser un format particulier créé
spécialement pour notre application (par exemple pour pouvoir réunir plusieurs cartes de hauteur
dans un seul fichier).
Nous allons donc profiter d'un idiome assez en vogue dans le monde du C++, qui a le mérite de ne pas
couter cher à implémenter: les polices (ou policies). Toute la logique de chargement de la
carte de hauteur sera donc encapsulée dans un chargeur spécifique, dérivant d'une classe commune
Loader.
Dans notre cas, nous nous limiteront à un chargeur réalisé grace à la librairie SFML, qui nous permettra
de gérer les formats graphiques les plus cassiques : PNG, BMP, JPG...
 |
Le principe derrière les politiques est assez simple. Il consiste à définir une classe par de multiples
autres, chacune étant spécialisée pour une certaine opération.
Le mode d'assemblage de ces fragments peut se faire par composition ou par héritage. Nous avons ici
choisi l'héritage, car il a le mérite de ne pas être intrusif pour le code de la classe générale.
|
III. Limites des cartes de hauteur
Les cartes de hauteur sont plus particulièrement adaptées aux rendus de terrain extérieurs. En effet,
le but étant de modéliser des variations de relief du terrain, les cartes intérieures n'auraient
que peu d'intérêt à utiliser ce type de représentation. Un autre desavantage des cartes de hauteur
est qu'il n'est possible d'attribuer qu'une seule hauteur par point de la carte, autrement dit, il
est impossible de modéliser des grottes, et toutes autres falaises avec une pente plus que verticale
:

Problème du recouvrement
IV. Modélisation générale de l'application
 |
Beaucoup font l'erreur de penser qu'un diagramme UML doit être le plus complet (et complexe) possible.
Il ne s'agit pas ici d'épater la gallerie, mais de fournir une interprétation intuitive des différentes
entités de l'application. Dans l'idéal, un diagramme devrait être lisible et compréhensible par n'importe
qui, pour peu qu'il connaisse le minimum vital de conventions utilisées.
|

Diagramme général de l'application
Comme nous le voyons, la classe principale est l'Application, qui se chargera d'afficher la fenêtre avec
le mode vidéo choisi, de charger les différents objets ( textes, carte, ... ), et de gérer la boucle
principale de rendu et de gestion d'événements.
Vient ensuite la classe Map, qui comme vous le devinez sera chargée de stocker tout le nécessaire pour
pouvoir s'afficher correctement ( points, textures, caméra... ).
Voilà pour ce petit tour d'horizon de l'application. Maintenant, il convient de s'intéresser au déroulement
général de l'application. C'est ce que nous allons voir avec le
diagramme de séquence suivant.

Diagramme de séquence général de l'application
Ce dernier diagramme étant largement commenté, il est inutile d'y revenir, nous allons donc pouvoir (enfin)
rentrer dans le vif du sujet avec la programmation effective de l'application.
V. Configuration externe
Pour plus de souplesse, nous utiliserons un petit fichier de configuration XML dans le même répertoire
que l'executable. Celui ci nous permettra de paramétrer le mode vidéo, et de fournir les différents
fichiers de la carte de hauteur, de manière à ce que vous puissiez tester sans avoir à tout recompiler.
Voici à quoi ressemble ce fichier:
<?xml version="1.0" encoding="iso-8859-1"?>
<configuration>
<videomode>
<width>1024</width&>
<height>768</height>
<bpp>32</bpp>
</videomode>
<map>
<name>Example map</name>
<description>OpenGL heightmap article by NewbiZ.</description>
<heightmap path="map_height.png" precision="1" />
<texture path="map_texture.png" />
<details path="map_details.png" />
</map>
</configuration>
|
Pour le chargement de ce fichier, nous utiliserons une petite librairie XML très pratique et légère réalisée
(2)
par Frank Vanden Berghen .
VI. Chargement de la carte
Comme nous l'avons dit plus haut, le chargement de la carte se fait via une classe de politique appelée
SFMLLoader. Comme le montre l'en-tête ci-dessous, un loader est quelquechose de très simple:
#ifndef SFMLLOADER_HH
#define SFMLLOADER_HH
#include <string>
#include "loader.hh"
class SFMLLoader : public Loader
{
public:
virtual bool loadHeightmap( const std::string& filename );
};
#endif // SFMLLOADER_HH
|
Vous l'imaginez, l'essentiel du travail de récupération des données est fait dans la classe parente Loader.
Tout ce que vous avez à faire dans un Loader, c'est de remplir le tableau de pixels, et de définir
la hauteur et la largeur de la carte.
Commençons par faire un petit récapitulatif de comment va se passer le chargement avant de nous lancer
dans le code.
Penchons nous donc sur la notion de précision de notre carte, que les plus attentifs auront décelé
dans le fichier de configuration XML.
Un fichier image est composé d'énormément de pixels, et il n'est pas toujours approprié de vouloir assigner
à chacun de ces pixels une hauteur sur notre carte. On peut volontairement réduire cette précision
en "sautant" plusieurs points lors du chargement.

Les points en rouge sur l'image sont ceux qui feront partie de la géométrie de la carte de hauteur
Nous sommes maintenant parés pour charger le terrain de notre heightmap.
bool SFMLLoader::loadHeightmap( const std::string& filename )
{
sf::Image image;
if ( !image.LoadFromFile(filename) )
return false;
setWidth ( image.GetWidth () );
setHeight( image.GetHeight() );
static const int elemSize = 4;
const int size = image.GetWidth() * image.GetHeight() * 4;
pixels_ = new unsigned char[size/elemSize];
const unsigned char* const px = image.GetPixelsPtr();
for ( int i=0; i<size/elemSize; ++i )
pixels_[i] = px[i*elemSize];
return true;
}
|
Rien d'incroyable ici, la SFML a fait tout le travail pour nous. Nous avons fait charger une image par
la SFML, puis avons utilisé la méthode GetPixelsStr() qui renvoit un tableau de unsigned char sous
la forme
RGBA. Il ne restait plus qu'à stocker ces informations dans
notre tableau pixels_.
Notez qu'ici nous avons prit la composante Rouge, mais nous aurions en fait pu prendre n'importe
laquelle. Dans une image en noir et blanc, toutes les composantes ont la même valeur.
Comme vous l'aurez remarqué, les points sont stockés linéairement dans pixels_. Ne vous inquiétez pas,
il nous sera facile d'y accéder plus tard grace à la convertion suivante :
tableau_multidimensionnel[x][y] == tableau_monodimensionnel[y*height+x] Le schéma ci-dessous clarifiera
les choses:

Conversion indice monodimensionnel <-> indices multidimensionnel
VII. Rendu de la carte de hauteur
VII-A. Aperçu de notre classe de carte
Un morceau de code valant plus que de longs discours, voici le fichier d'entête de notre classe de carte:
#ifndef MAP_HH
#define MAP_HH
#include <string>
#include "SFML/Window.hpp"
#include "SFML/Graphics.hpp"
#include "camera/camera.hh"
template <class MapLoader>
class Map : public MapLoader
{
public:
Map( void );
~Map( void );
bool loadTexture( const std::string& filename );
bool loadDetails( const std::string& filename );
void compile ( void );
void render ( sf::RenderWindow& w );
const std::string& getName ( void ) const;
const std::string& getDescription ( void ) const;
unsigned int getPrecision ( void ) const;
void setName ( const std::string& name );
void setDescription ( const std::string& description );
void setPrecision ( unsigned int precision );
void setPosition ( unsigned int x, unsigned int y );
private:
void renderTerrain ( sf::RenderWindow& w );
void renderInfos ( sf::RenderWindow& w );
private:
sf::String name_;
sf::String description_;
sf::Image texture_main;
sf::Image texture_details;
Camera camera_;
sf::Sprite minimap_;
GLuint list_;
unsigned int precision_;
};
#include "map.inl"
#endif // MAP_HH
|
Les méthodes publiques ont un nom assez explicite pour se passer de description, nous allons donc nous
concentrer sur la description des méthodes privées et des attributs.
renderTerrain et renderInfos sont chargées respectivement d'afficher la carte, et les informations (nom,
description et minimap).
|
Attribut
|
Type
|
Description
|
|
name_
|
sf::String
|
C'est le nom de la carte. Il est modifiable dans le fichier XML de configuration, et sera affiché en
haut à gauche de la carte. A noter que nous le stockons ici directement sous forme de sf::String
(3)
, donc en tant qu'objet affichable, et non en tant que std::string. La même remarque vaut pour la
description.
|
|
description_
|
sf::String
|
C'est un court texte de description de la carte. Il est aussi modifiable dans lefichier XML de configuration,
et sera affiché juste en dessous du nom de la carte.
|
|
texture_main
|
sf::Image
|
Image
(4)
représentant la texture principale à plaquer sur notre carte.
|
|
texture_details
|
sf::Image
|
Image représentant la texture de détails à replaquer par dessus notre texture principale.
|
|
camera_
|
Camera
|
Contient la caméra active de notre carte. La classe caméra est ici minimaliste et ne permet pas la rotation
de la vue, le but étant de ne pas obscurcir l'article par des considérations mathématiques scabreuses.
|
|
minimap_
|
sf::Sprite
|
Ce sprite (objet graphique affichable représentant tout ou partie d'une image) est en fait l'équivalent
redimensionné de la texture principale. Il sera affiché en bas à droite de l'écran et permettra
d'avoir rapidement une idée de la oùse situe la vue, et permettra en clickant dessus de se déplacer.
|
|
list_
|
GLuint
|
OpenGL définit toute une batterie de types préfixés par "GL" qui ont l'avantage d'assurer une taille
standarde sur toutes les plateformes. Nous utilisons ici un unsigned int (GLuint) pour stocker
l'identifiant de la display list contenant notre carte de hauteur.
|
|
precision_
|
unsigned int
|
Comme expliqué plus haut, la précision représente le nombre de pixels qui seront considérés lors de l'affichage
de la carte.
|
Comme expliqué en début d'article, nous avons fait le choix de
l'héritage pour notre politique de chargement de la carte. Ca a
le mérite de la simplicité (plutot que de remapper les appels vers
l'objet composé).
VII-B. Les différentes modes de rendu
Il existe 2 modes majeurs de rendu avec OpenGL: Le mode immédiat,
et les display lists.
Le mode immédiat est le plus simple, il consiste à
faire des appels directs à OpenGL pour lui envoyer (par exemple)
les primitives à dessiner, les changements d'état, des modifications
de matrice... Le problème de ce mode est qu'il
nécessite des communication constantes entre le client et le serveur
(i.e. le programme client et le serveur graphique).
A contrario, les display lists sont un ensemble d'instructions
OpenGL qu'il est possible de compiler. Le résultat pouvant
(selon la distribution d'OpenGL) être stocké directement en
mémoire graphique, ou tout du moins, être sauvegardé sous une
forme optimisée. De manière générale, tant que vous avez plus
de quelques primitives à rendre, considérez l'utilisation de
display lists.
La création de display lists est très simple, et relativement
transparente par rapport au mode immédiat. Tout ce qu'il convient
de faire, est de demander un nouvel identifiant de liste à OpenGL
(fonction glGenLists), puis de lui indiquer que les instructions
qui vont suivre devront être compilées dans une certaine
liste (entourer les instructions de glNewList et glEndList).
VII-C. Choix des primitives appropriées
Nous devons définir à l'avance quel type de primitives nous
allons utiliser pour rendre notre carte (quadrangles ou triangles?)
ainsi que le mode de spécification de ces primitives (énumération ou
strips?).
Intuitivement on pourrait être tenté de vouloir utiliser des
quadrangles pour dessiner notre carte. Ce serait malheureusement
une assez mauvaise idée. Imaginez la situation suivante:

Le problème de l'interprétation des quadrangles lors de vertices non coplanaires. Dans les cas 1) et 2), les vertices sont coplanaires, il n'y a donc pas d'ambiguité. En revanche dans les cas 3) et 4), il y a deux façons possibles de découper le quadrangle
Lorsque vous essayez de founir à OpenGL des points pour créer
un quadrangle, et que ces points ne se trouvent pas sur le même plan,
OpenGL n'a aucun moyen d'inférer comment redécouper ce quadrangle
en triangles (puisqu'au final, pour la carte graphique, tout sera
triangle). Il va donc nous falloir créer nous même nos triangles.
Pour ce qui est du choix de la diagonale délimitant nos triangles,
nous choisirons arbitrairement [(x,z)(x+1,z+1)].

Choix de la diagonale délimitant nos triangles
Reste une question: énumération des triangles, ou strips ?

Différence entre strips et énumération
Les flèches grises représentent la "completion" qu'opère OpenGL
dans le cas de points à considérer comme des énumérations (en haut),
ou des strips (en bas).
Dans le cas d'une énumération, chaque triangle est composé par trois
points, OpenGL se chargeant de lier le 3ème avec le premier.
Pour les strips, OpenGL considère à chaque fois que le point qui lui
est passé (au delà du deuxième) est le dernier d'un nouveau triangle.
Ainsi il se chargera de lier chaque point passé avec l'avant dernier
passé.
Les strips présentent le gros avantage de diminuer considérablement
le nombre de points nécessaires pour exprimer une géométrie.
En revanche, ils deviennent très laborieux à utiliser lorsqu'il
faut définier des coordonnées de texture pour ces points (ce sera
notre cas) ou lorsqu'il faut définir des surfaces "par bandes" (ce
sera aussi notre cas). Nous opterons donc pour de simple énumérations
de triangles.
Ne vous impatientez pas, avant d'arriver au code, il va nous falloir
discuter d'un dernier point de détail: L'ordre d'affichage des vertices.
Il est évident qu'un triangle possède deux faces distinctes.
La question se pose alors de leur différenciation.
OpenGL n'a aucun moyen de savoir ce qui constitue la face avant (front)
et la face arrière (back) d'un triangle, si ce n'est l'ordre dans lequel
vous lui fournissez les vertices. Typiquement, on apelles ces deux ordres
clockwise (sens des aiguilles d'une montre) ou counter-clockwise (sens contraire
des aiguilles d'une montre, ou trigonométrique).

Ordre de déclaration des vertices pour la détermination des faces
Le sens par défaut est le sens trigonométrique. Vous pouvez changer
ce comportement grace aux instructions suivantes:
glFrontFace( GL_CW );
glFrontFace( GL_CCW );
|
L'intéret de spécifier à OpenGL quelles sont les faces avant et les
faces arrières est qu'il nous sera ensuite possible de lui demander
de n'afficher que certaines de ces faces, pour économiser un peu de
temps de rendu. Cette méthode s'apelle le backface culling (élimination
des faces arrière).
Et vous pouvez ensuite spécifier à OpenGL quelles faces éliminer (front ou back)
grace à l'instruction:
Dernièrement, il ne nous faudra pas oublier d'activer l'interpolation
des textures pour l'affichage. Si vous venez du "monde 2D", ceci
peut paraitre surprenant, mais étant donné la taille de la carte
par rapport à sa texture principale, le smooth est indispensable
pour un rendu correct.

A gauche, avec interpolation. A droite, sans interpolation.
VII-D. Compilation de la display list
Nous pouvons (enfin) nous pencher sur le code qui va être compilé
dans notre display list. Trève de bavardages, le voici :
template <class MapLoader>
void Map<MapLoader>::compile( void )
{
list_ = glGenLists( 1 );
glNewList( list_, GL_COMPILE );
{
glBegin( GL_TRIANGLES );
{
for ( unsigned int x=0; x<(getHeight()-getPrecision()); x+=getPrecision())
{
for ( unsigned int z=0; z<(getWidth()-getPrecision()); z+=getPrecision())
{
Point3D<GLfloat> vertex1( x,
getPixel( x, z ),
z,
x/getWidth (),
1.f-(z/getHeight()) );
Point3D<GLfloat> vertex2( getPrecision()+x,
getPixel( getPrecision()+x, z ),
z,
(x+getPrecision())/getWidth(),
1.f-(z/getHeight()) );
Point3D<GLfloat> vertex3( getPrecision()+x,
getPixel( getPrecision()+x, getPrecision()+z ),
getPrecision()+z,
(x+getPrecision())/getWidth(),
1.f-((z+getPrecision())/getHeight()) );
Point3D<GLfloat> vertex4( x,
getPixel( x, getPrecision()+z ),
getPrecision()+z,
(x)/(float)getWidth (),
1.f-((z+getPrecision())/getHeight()) );
vertex3.sendWithText();
vertex2.sendWithText();
vertex1.sendWithText();
vertex4.sendWithText();
vertex3.sendWithText();
vertex1.sendWithText();
}
}
}
glEnd();
}
glEndList();
}
|
Une petite explication s'impose.
La classe Point3D est très simple. Trois attributs publicsx, y et z,
ainsi qu'un constructeur prenant en paramètre les coordonnées
du point, ainsi que d'optionnelles coordonnées de texture.
La méthode sendWithText (i.e. "Envoyer avec coordonnées de texture" )
se content de générer l'instruction glTexCoord2f et glVertex3f
avec les paramètres appropriés.
Voici le code de cette classe:
#ifndef POINT3D_HH
#define POINT3D_HH
template <class T>
class Point3D
{
public:
Point3D ( T mx=0, T my=0, T mz=0, float mtx=0, float mty=0 ):
x ( mx ),
y ( my ),
z ( mz ),
tx( mtx ),
ty( mty )
{
}
~Point3D ( void )
{
}
void send( void ) const
{
glVertex3f( static_cast<float>(x),
static_cast<float>(y),
static_cast<float>(z) );
}
void sendWithText( void ) const
{
glTexCoord2f( tx, ty );
glVertex3f( static_cast<float>(x),
static_cast<float>(y),
static_cast<float>(z) );
}
public:
T x;
T y;
T z;
float tx;
float ty;
};
#endif // POINT3D_HH
|
Notez bien pour les coordonnées de texture que l'on est
obligé d'exprimer leur hauteur comme l'inverse de ce qu'attend
OpenGL. Ceci est du à la différence d'interprétation des coordonnées
de texture entre OpenGL (origine en bas à gauche) et SFML (en
haut à gauche).
VII-E. Affichage de la display list
Cette partie est peut-être la plus simple de toutes. Maintenant
que la display list a été compilée, tout ce qu'il reste à faire,
c'est de l'appeler avec glCallList.
template <class MapLoader>
void Map<MapLoader>::render( sf::RenderWindow& w )
{
renderTerrain( w );
renderInfos ( w );
camera_.renderPosition( w );
}
template <class MapLoader>
void Map<MapLoader>::renderInfos( sf::RenderWindow& w )
{
w.Draw ( name_ );
name_.Move ( -2.f, -2.f );
name_.SetColor( sf::Color::White );
w.Draw ( name_ );
name_.SetColor( sf::Color::Black );
name_.Move ( 2.f, 2.f );
w.Draw ( description_ );
description_.Move ( -1.f, -1.f );
description_.SetColor( sf::Color::White );
w.Draw ( description_ );
description_.SetColor( sf::Color::Black );
description_.Move ( 1.f, 1.f );
w.Draw( minimap_ );
}
template <class MapLoader>
void Map<MapLoader>::renderTerrain( sf::RenderWindow& )
{
glClear ( GL_DEPTH_BUFFER_BIT );
camera_.focus ( );
glScalef ( 1.f, 0.15f, 1.f );
glEnable ( GL_TEXTURE_2D );
texture_main.Bind( );
glCallList ( list_ );
}
|
Avant d'attaquer le multitexturing pour gérer la texture
de détails, une petite pause avec le résultat actuel :

Rendu de la carte de hauteur sans texture de détails
| (1) |
Les différents diagrammes que vous trouverez dans cet article ont été réalisés avec le logiciel Poseidon For UML, Community Edition. Pour votre usage personnel, je
vous conseille l'excellent ArgoUML qui, tout en gardant toutes les fonctionnalités de Poseidon, a l'avantage de la gratuité.
|
| (2) |
Vous pouvez retrouver Frank Vanden Berghen sur son site http://www.applied-mathematics.net/. N'hésitez pas
à le lui signaler si vous utilisez sa librairie, il en sera très content.
|
| (3) |
SFML définit une classe appelée sf::String permettant d'afficher des chaines de caractère
à partir d'un texte et d'une police au format True Type Font. Nous parlons bien ici d'objets
graphiques, ce ne sont en aucun cas des conteneurs pour un texte "normal".
|
| (4) |
SFML distingue les Image des Sprite. Les premières sont la représentation
informatique d'un fichier graphique. Grossièrement, on pourrait l'associer à
un tableau de pixels. Les sprites sont des objets graphiques permettant
l'affichage de tout ou partie d'une Image. Plusieurs Sprite peuvent donc
partager la même image, mais en afficher des parties différentes, à des
tailles différentes, à des positions différentes...
|


Copyright © 2008 Aurélien Vallée. Aucune reproduction, même partielle, ne peut être faite
de ce site et de l'ensemble de son contenu : textes, documents, images, etc
sans l'autorisation expresse de l'auteur.
Sinon vous encourez selon la loi jusqu'à 3 ans de prison et jusqu'à 300 000 E
de dommages et intérêts.
Cette page est déposée à la
SACD.