Afficher une heightmap avec OpenGL

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.

- Titre : Afficher une heightmap avec OpenGL

- Auteur : Aurélien Vallée

- Parution : 2 juin 2008

- Licence : 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'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- 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érents 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.

À 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 sûr, 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és à 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 limiterons à un chargeur réalisé grâce à la bibliothèque SFML, qui nous permettra de gérer les formats graphiques les plus classiques : 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 désavantage 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

(1) Pour illustrer le fonctionnement de notre application, commençons par créer un diagramme de classes simple.

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 galerie, 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'exécutable. 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 bibliothèque 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'entête ci-dessous, un loader est quelque chose 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ée 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 )
{
// L'image servant d’où récupérer les pixels
sf::Image image;

// Si le fichier n'existe pas ou est invalide, on retourne sans rien faire,
// sinon on peut continuer avec l'image chargée
if ( !image.LoadFromFile(filename) )
return false;

// Assignations
setWidth ( image.GetWidth () );
setHeight( image.GetHeight() );

// Le tableau renvoyé; par GetPixelsPtr() contient les 4 composantes RGBA
// sous forme de char
static const int elemSize = 4;

// La taille totale du tableau renvoyée par GetPixelsPtr()
const int size = image.GetWidth() * image.GetHeight() * 4;

// Création de notre propre tableau qui ne contiendra qu'une composante (noir et blanc)
pixels_  = new unsigned char[size/elemSize];

// On stocke un pointeur vers le tableau renvoyé par GetPixelsPtr() pour y accéder plus
// simplement
const unsigned char* const px = image.GetPixelsPtr();

// On copie les pixels
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 renvoie 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 pris 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 grâce à la conversion suivante :
tableau_multidimensionnel[x][y] == tableau_monodimensionnel[y*height+x] Le schéma ci-dessous clarifiera les choses$:

Conversion indices monodimensionnels<-> indices multidimensionnels

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:
// Constructeurs / Desctructeurs
Map( void );
~Map( void );

// Méthodes publiques
bool loadTexture( const std::string& filename );
bool loadDetails( const std::string& filename );
void compile    ( void                        );
void render     ( sf::RenderWindow& w         );

// Accesseurs
const std::string& getName        ( void ) const;
const std::string& getDescription ( void ) const;
unsigned int       getPrecision   ( void ) const;

// Mutateurs
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:
// Méthodes privées
void renderTerrain ( sf::RenderWindow& w );
void renderInfos   ( sf::RenderWindow& w );

private:
// Attributs privés
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. À 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 le fichier 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 là où se situe la vue, et permettra en cliquant 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 standard 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. Ça a le mérite de la simplicité (plutôt que de remapper les appels vers l'objet composé).

VII-B - Les différents modes de rendu

Il existe deux 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 communications 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 que 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’ambiguïté. En revanche dans les cas 3) et 4), il y a deux façons possibles de découper le quadrangle

Lorsque vous essayez de fournir à 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 (puisque finalement, pour la carte graphique, tout sera triangle). Il va donc nous falloir créer nous-mêmes 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 « complétion » 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 3e 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éfinir 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 simples é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 appelle 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 grâce aux instructions suivantes :

glFrontFace( GL_CW  ); //         ClockWise: Aiguilles d'une montre
glFrontFace( GL_CCW ); // Counter ClockWise: Trigonométrique

L'intérêt de spécifier à OpenGL quelles sont les faces avant et les faces arrière 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'appelle le backface culling (élimination des faces arrière).

glEnable(GL_CULL_FACE);

Et vous pouvez ensuite spécifier à OpenGL quelles faces éliminer (front ou back) grâce à l'instruction :

glCullFace(GL_BACK);

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 paraître surprenant, mais étant donné la taille de la carte par rapport à sa texture principale, le smooth est indispensable pour un rendu correct.

À gauche, avec interpolation. À 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 )
{
// Génération d'un identifiant pour notre display lsit
list_ = glGenLists( 1 );

// On demande à OpenGL de compiler ce qui va suivre dans
// une display list identifiée par list_, que l'on vient de
// générer.
glNewList( list_, GL_COMPILE );
{
// Nous informons OpenGL que ce qui va suivre est une
// énumération de triangles.
glBegin( GL_TRIANGLES );
{
//  Pour chaque ligne, avec un pas dépendant de la précision souhaitée
for ( unsigned int x=0; x<(getHeight()-getPrecision()); x+=getPrecision())
{
// Pour chaque colonne, avec un pas dépendant de la précision souhaitée
for ( unsigned int z=0; z<(getWidth()-getPrecision()); z+=getPrecision())
{
// Définition des coordonnées des points
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()) );

// Premier triangle
vertex3.sendWithText();
vertex2.sendWithText();
vertex1.sendWithText();

// Deuxi&#232;me triangle
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 contente 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 dû à 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 );           // Affichage du terrain
renderInfos  ( w );           // Affichage des informations
camera_.renderPosition( w );  // Affichage de la position sur la minimap
}

template <class MapLoader>
void Map<MapLoader>::renderInfos( sf::RenderWindow& w )
{
w.Draw        ( name_            );  // Affichage de l'ombre du nom
name_.Move    ( -2.f, -2.f       );  // Décalage pour le texte en blanc
name_.SetColor( sf::Color::White );  // Le prochain affichage sera blanc
w.Draw        ( name_            );  // Rendu en blanc
name_.SetColor( sf::Color::Black );  // Retour à la couleur de l'ombre
name_.Move    (  2.f,  2.f       );  // Retour à la place initiale

w.Draw               ( description_     ); // Idem qu'au-dessus
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_ ); // Affichage de la minimap
}

template <class MapLoader>
void Map<MapLoader>::renderTerrain( sf::RenderWindow& )
{
glClear          ( GL_DEPTH_BUFFER_BIT ); // Réinitialisation z-buffer
camera_.focus    (                     ); // gluLookAt
glScalef         ( 1.f, 0.15f, 1.f     ); // Diminution du rapport de hauteur
glEnable         ( GL_TEXTURE_2D       ); // Activation du texturing
texture_main.Bind(                     ); // Sélection de la texture principale
glCallList       ( list_               ); // Appel de la display 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 bibliothèque, il en sera très content.

(3)SFML définit une classe appelée sf::String permettant d'afficher des chaines de caractères à 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 images des sprites. 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 sprites peuvent donc partager la même image, mais en afficher des parties différentes, à des tailles différentes, à des positions différentes…