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 :
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) :
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 :
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 :
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 classesDictionnaire des développeurs 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.
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équenceDictionnaire des développeurs suivant :
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.
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 RGBADictionnaire des développeurs. 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$:
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 :
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)].
Reste une question : énumération des triangles, ou strips ?
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).
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.
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è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.15
f, 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 :