Génération de textures de terrainDate de publication : 08/06/2006 , Date de mise à jour : 25/08/2006
Par
Jean Christophe Beyler (Autres articles)
La génération de terrain est souvent un sujet qui intéresse et passionne. Ce tutoriel présente comment afficher un terrain en utilisant OpenGL et comment lui créer une texture au début de l'exécution du programme. En utilisant trois images de bases (l'herbe, de la roche et de la neige) et une image de niveau monochrome, on peut donc générer une texture qui sera parfaite pour le terrain.
1. Introduction 2. Présentation 3. Génération des textures 3.1. Calculer la hauteur h 3.2. Utiliser la valeur pour calculer la couleur du pixel 3.3. Calcul de la couleur 3.4. Un peu d'aléatoire 3.5. Taille de la texture 4. Le rendu 4.1. Un tableau pour les points 4.2. Dessiner des triangles ou des quads 4.3. Charger la texture avec OpenGL 4.4. L'application de la texture 5. Des ombres 5.1. Avant de continuer 5.2. Une première version 5.3. Les limitations de la 1ère méthode 5.4. Une 2ème solution : L dot N 5.5. Dernière méthode : le calcul des ombres 6. A propos du code source 7. Conclusion 8. Remerciements 9. Téléchargements 10. Poursuite de la génération de Terrain 1. Introduction
Lorsqu'on affiche un terrain en 3 dimensions, il faut arriver à colorer ou utiliser une texture pour le rendu des triangles. Pour un rendu plus réel, la texture doit être appliquée correctement. Le problème réside dans la génération de cette texture, c'est donc ce que nous allons voir ici.
Cet article ne présentera pas en détail chaque aspect du code et suppose donc une connaissance du C et de la bibliothéque SDL. Il montrera par contre les idées générales sur la génération de textures à partir d'une image de niveaux et d'images de base.
2. Présentation
Lorsqu'on veut afficher un terrain en 3 dimensions, on utilise généralement une image qui permet d'avoir la hauteur de chaque point du terrain. Cette image, généralement monochrome, ressemble à ceci :
![]() Image de niveaux
Bien sûr, la gestion des images de niveaux est une convention entre le programme et l'image de niveaux. Généralement, une couleur sombre représente une faible altitude dans le terrain et une couleur clair représente un niveau élevé.
Voici ce que donne l'affichage du terrain en 3 dimension et en mode filaire :
![]() Affichage de terrain 3D en filaire
Mais comment décider comment colorier les différentes zones du terrain? Une première solution serait d'utiliser des couleurs dépendant des hauteurs des sommets du terrain. C'est bien sûr possible mais, généralement, ne donne pas de très bons résultats.
Donc l'idée est de générer une texture qui sera fait parfaitement par rapport au terrain qui sera affiché. Ce que nous voulons faire est d'utiliser trois textures différentes pour afficher correctement le terrain. Voici les trois images que nous allons utiliser :
![]() 3 images de base
Donc on va calculer un mélange de ces trois images pour arriver à générer une texture qui sera à l'image de niveaux. On obtient donc ceci :
![]() Texture générée
Et lorsqu'on applique cette texture au rendu 3D, cela donne :
![]() Texture appliquée
La prochaine section présentera comment on crée cette texture.
3. Génération des textures
Pour générer un terrain comme celui-ci, l'algorithme que nous allons utiliser est relativement simple :
Il faut donc savoir calculer la hauteur h du pixel associé et calculer la couleur du pixel.
3.1. Calculer la hauteur h
Pour calculer la hauteur du pixel associé, on utilise l'image de niveaux. Si nous sommes dans le meilleur des mondes, la taille de la texture du terrain est la même que l'image de niveaux. C'est donc facile d'avoir la hauteur du pixel associé.
Comme le montre ce code, on peut facilement passer de l'échelle 0-255 de l'image de niveaux à l'intervalle [a,b].
Avant de montrer l'algorithme général pour cette transformation, remarquons qu'en utilisant uniquement une couleur d'une image bitmap de niveaux, nous avons seulement 256 niveaux. En utilisant, les trois couleurs à la fois, nous pouvons gérer plus d'un million de niveaux différents. Mais dans le cas général, un seul octet suffit pour faire un joli terrain.
Nous avons donc la solution pour récupérer la hauteur du pixel associé et la transformer à n'importe quelle échelle.
3.2. Utiliser la valeur pour calculer la couleur du pixel
Pour avoir la couleur du pixel, nous utilisons directement la hauteur du pixel. Dépendant de la hauteur, nous allons utiliser un mélange entre les différentes images de niveaux.
L'image suivante montre le dégradé des images de base :
![]() Dégradé
En effet, si la hauteur est assez basse, on utilisera uniquement la couleur de l'image qui représente de l'herbe. Ensuite, si la hauteur est très haute alors on utilise la couleur de l'image de neige.
Bien sûr, cela veut dire que nous pouvons faire des mélanges entre les trois images. Voici la fonction que j'utilise pour donner la couleur du pixel :
La fonction va donc remplir un tableau à 3 éléments qui donnera la participation de la couleur de chacune des trois images de base. Si la valeur est à zéro, on n'utilise pas cet image. Si la valeur est à 1.0, on utilise entièrement cette composante.
Je pense que vous pouvez comprendre facilement ce code. Les valeurs que j'utilise ont été trouvées empiriquement. Jouez avec les valeurs pour trouver de meilleurs résultats si vous le voulez.
Bien que pas obligatoire, je tente de garder la somme des trois valeurs soit égale à 1. Si on dépasse cette limite, on risque d'avoir des couleurs saturées.
3.3. Calcul de la couleur
Supposons pour l'instant que nous sommes dans le cas où la texture générée est la même taille que les trois images de base. Il est donc facile de savoir le pixel associé aux images de base.
Une fois que nous avons la participation des couleurs des trois images, nous pouvons calculer la couleur du pixel. L'algorithme général est :
Ce qui se traduit par :
On a bien une composition des trois images de base. La fonction Terrain_GetPixelColor sera montrée plus bas. Acceptez qu'elle prend en paramètre un couple (tmpi, tmpj) pour rendre une couleur (0 pour bleu, 1 pour vert et 2 pour rouge) de l'image passé en premier paramètre. Le dernier paramètre permet de spécifier si on veut le canal rouge, vert ou bleu.
3.4. Un peu d'aléatoire
Pour ajouter un peu d'aléatoire dans le calcul de la participation des images de base. On peut ajouter ou enlever un peu à la hauteur du pixel avant la distribution de la participation des couleurs. En effet, en ajoutant au début de la fonction Terrain_RemplitPerc :
On permet d'ajouter un peu de différence dans le choix de la couleur indépendamment de la hauteur du pixel. En faisant un modulo 30, suivi d'une soustraction de 15, on centre le nombre add sur un intervalle [haut-15, haut+15[ pour ajouter un peu de différence de coloriation des pixels avoisinants. En choisissant des nombres plus petits, la différence se fera moins sentir (il est vrai que 30 est un peu beaucoup).
Voici une comparaison :
![]() A gauche : sans aléatoire, à droite : avec l'aléatoire
On ne voit pas beaucoup de différences entre les deux images de loin. Mais nous voyons des petites différences dans la
partie enneigée.
C'est bien sûr une question de goût et de ce que vous cherchez dans votre application. Une image plus proche permet de voir l'effet de l'aléatoire.
![]() A gauche : sans aléatoire, à droite : avec l'aléatoire 3.5. Taille de la texture
Bien sûr, il n'est pas obligatoire que la texture soit de la même taille que les images de base. Pour résoudre le problème, on utilise en fait le modulo pour ne plus avoir de problèmes de débordement. Cette solution n'est pas parfaite mais elle permet d'avoir un meilleur résultat.
Pourquoi avoir une taille plus grande? Cela permet d'avoir plus de détail dans le terrain. C'est encore un compromis entre le temps de calcul, la consommation mémoire et les détails qui seront visibles lors du rendu.
Voici une comparaison entre une texture de taille 1024*1024 et une texture de taille 4096*4096. On voit parfaitement bien la différence de détail.
![]() A gauche : texture de taille 4096*4096, à droite : texture de taille 1024*1024 4. Le rendu
Ce n'est pas tout de pouvoir générer la texture pour le terrain mais il faut pouvoir afficher ce terrain. Cette section expliquera donc cela.
4.1. Un tableau pour les points
Pour optimiser le rendu du programme, on mettra tous les points dans un tableau à 2 dimensions qu'on parcourera lors de l'affichage.
Comme le code le montre, nous débutons la boucle par un calcul de rapport entre les coordonnées (i,j) et le nombre de points sur chaque côté (défini par MAX_POINTS). Nous avons donc une valeur dans l'intervalle [0,1[ pour chaque point.
Ensuite, nous multiplions par la hauteur et la largeur de l'image de niveaux. Ce qui nous donnera le pixel correspondant de l'image.
Enfin, nous pouvons utilisons la fonction Terrain_GetHauteur pour calculer la hauteur du point. Cette fonction en appelle une autre nommée Terrain_GetPixelColor Voici le code de ces petites fonctions :
Remarquons d'abord le changement de repére. En effet, les coordonnées (i,j) sont les coordonnées de la texture que nous voulons générer. Par contre, notre image de niveau n'a pas forcément la même taille. Donc nous passons par l'intervalle [0,1] pour avoir le point correspondant à l'image.
Dans ce code, j'utilise les surfaces de la bibliothéque SDL. Ces structures contiennent les couleurs des pixels (que nous pouvons modifier pendant l'exécution) sous forme de tableau de type void*. Vu que les pixels sont rangés sont la forme de triplets (rouge,vert,bleu) et en ligne. L'accés au pixel (i,j) se fait logiquement avec le calcul i*(largeur de l'image) + j.
Par contre, vu que nous avons un type void* le facteur 3 que vous voyez nous permet de passer d'un pixel à l'autre (vu qu'il y a trois couleurs par pixel, si on avait une image RGBA, on utiliserait un facteur 4).
4.2. Dessiner des triangles ou des quads
Le terrain est défini par un ensemble de points qui forment un maillage en forme de cases carrées. Pour le dessiner, on peut soit dessiner directement les cases avec des GL_QUADS ou alors dessiner deux triangles avec GL_TRIANGLES.
En fait, si nous voulions faire mieux, il faudrait utiliser GL_TRIANGLE_STRIP ou encore des listes précalculées. Mais cela sort du cadre de ce tutoriel et le programme tourne sur mon ordinateur avec 230 images par seconde. Donc ce n'est pas nécessaire d'optimiser le rendu pour l'instant.
Pour décider quelle technique utilisée, il suffit de décider ce dont vous avez besoin. Ici, nous utiliserons les triangles. Voici comment desssiner les triangles de chaque case sans les textures :
Expliquons un peu ce code puisqu'il est le centre de l'affichage du terrain. Premièrement, nous utilisons deux couples (i,j) et (posi,posj).
Nous savons que le couple (i,j) parcourt les différents points du terrain. Les dimensions du terrain sont données par la macro MAX_POINTS. Mais nous voulons que les dimensions du terrain soient les mêmes à l'affichage.
Arbitrairement, j'ai décidé que le terrain serait un carré de largeur 40. On va donc associer le couple (posi,posj) avec le couple (i,j). Le point (-20,-20) sera associé avec (0,0) (d'où l'initialisation des variables posi et posj) et le point (20,20) sera associé avec (MAX_POINTS-1, MAX_POINTS-1).
Donc pour garder l'association, nous définissons la distance entre les points par une variable ajout qui est définie comme ceci :
4.3. Charger la texture avec OpenGL
Pour passer la texture qu'on vient de créer à OpenGL, on utilise le code suivant :
C'est du code classique mais je pense que c'est intéressant de le voir une fois. On commence par générer un identifiant de texture. On utilise ensuite la fonction glBindTexture pour dire qu'on va travailler sur cette texture et que c'est une texture 2D.
Ensuite, les fonctions glTexParameteri permettent de dire ce qu'on va faire avec la texture. Ici, nous demandons d'utiliser des textures Mipmaps, ce qui permet d'avoir un bon rendu de loin et de près.
Enfin, on appelle la fonction gluBuild2DMipmaps qui permet de créer les textures Mipmaps.
Une autre solution est d'utiliser à la place un filtrage de base, c'est plus rapide mais l'effet est nettement moins bon. On remplacerait
les trois dernières instructions par ceci :
La différence entre les deux méthodes se trouve dans la façon dont OpenGL va filtrer la texture avant de l'afficher. Comme toujours, appliquer un filtre a un coût non négligeable.
Dépendant de l'ordinateur sur lequel vous tournez un programme comme celui-ci, le niveau de détail peut avoir un impact significatif sur les performances du programme. Etudiez soigneusement vos besoins en terme de performances et en terme de détail pour décider quel filtre choisir.
4.4. L'application de la texture
Il ne reste plus qu'à appliquer la texture au terrain. Pour le faire, nous allons associer un point de la texture à un point du terrain. Simplement, le point (0,0) du terrain sera le point (0,0) de la texture et le point (MAX_POINTS-1, MAX_POINTS-1) du terrain est (1,1) de la texture.
Par contre, il faut faire attention à l'échange des coordonnées. En effet, il est usuel de considérer la première coordonnée du terrain comme étant le numéro de ligne. C'est l'opposé pour les textures, la première coordonnée est la coordonnée des colonnes.
Enfin, nous présentons une image utilisant un bleu transparent pour faire un effet d'eau pour rendre le rendu plus réaliste.
![]() Image intermédiaire avec eau 5. Des ombres
En espérant que vous êtes d'accord avec moi, réussir à afficher le terrain comme nous l'avons fait est déjà une bonne chose. Mais il manque une chose primordiale : des ombres pour bien montrer le relief. Cette partie va montrer trois techniques pour le calcul de l'éclairage.
L'ajout d'ombres dans la génération de la texture ajoute un effet de réalisme important au rendu. Regardez l'image suivante qui comprare trois rendus différents. Vous pouvez comparer cette image avec celle de la partie précédente qui n'avait pas d'ombres.
A gauche, la version la plus basique mais qui permet d'avoir un résultat quasi-identique au deuxième (celui du milieu) qui utilise un produit scalaire. Ces deux techniques sont acceptables mais elles ne font qu'un calcul localisé. Donc si nous avions une montagne, on n'aurait pas l'effet d'ombre que la montagne aurait sur la vallée d'à côté. C'est le dernier algorithme (donc l'image de droite) qui permet en plus de faire ressortir un plus grand réalisme en ce qui concerne les ombres.
![]() Comparaison de calculs d'ombre 5.1. Avant de continuer
Avant de commencer l'explication des ombres, je vais prendre deux ou trois paragraphes pour présenter la structure globale qui contiendra toutes les informations pour la génération et la visualisation du terrain. A la place d'avoir une dizaine de variables globales, il nous reste une grande structure avec toutes les informations nécessaires.
Sans trop rentrer dans les détails :
Cette structure sera déclaré en global dans le fichier Main.c. Voici sa déclaration et ses valeurs par défaut :
5.2. Une première version
La technique la plus facile dans le calcul d'ombre est d'utiliser un vecteur lumineux et de l'utiliser comme base de tous les algorithmes qui vont suivre.
Ensuite, ce qu'on fait c'est que pour chaque pixel on va calculer le coefficient de luminosité. Ce sera un nombre flottant entre 0 et 1 et on multipliera ce nombre par la couleur sans calcul d'ombre pour obtenir la couleur finale :
Tout ce qui reste à faire est le calcul de ce coefficient. Mais ce n'est pas compliqué! En effet, nous avons fixé la direction du rayon du soleil dans une structure et nous définirons ce vecteur comme étant (param.l_veci, param.l_vecj) (donc le rayon du soleil posséde une direction (param.l_veci, param.l_vecj)), donc si, pour un pixel (i,j), le pixel (i-param.l_veci,j-param.l_vecj) est plus élevé alors la luminosité du pixel (i,j) sera amoindrie (En effet, le pixel (i-param.l_veci,j-param.l_vecj) cachera un peu le pixel (i,j)).
En utilisant cette idée, on peut définir donc une première fonction Terrain_calcLightMap_Simple qui sera
définie par :
Cette fonction commence bien sûr par une série de vérifications sur les valeurs des coefficients pour ne pas provoquer un débordement mémoire.
Comme on le remarque, on utilise la différence entre les deux hauteurs pour calculer le taux d'ombre. Si jamais cette différence est grande, alors res sera proche de 0, si jamais la différence est faible, le résultat sera proche de 1.
La valeur param.l_adouc permet de moduler la quantité d'ombre créée. Si ce nombre est petit, les zones seront très ombragées, sinon elles seront peu ombragées.
5.3. Les limitations de la 1ère méthode
La première méthode a un inconvénient : il est trop localisé, ce qui provoque des imperfections dans le calcul des ombres.
![]() Défauts de la première méthode
Pour remédier à ce problème, à la place d'avoir un calcul si localisé, pour le calcul de l'ombre du pixel (i,j), on va utiliser la moyenne du coefficient d'ombre de chaque pixel avoisinant aussi. Ceci permettra d'éviter ce genre de défaut.
Puisque cette idée de faire une moyenne va revenir par la suite. Nous allons donc faire une fonction qui applique ce calcul de moyenne. Il faudra donc faire deux passes.
En effet, pour faire correctement les choses, on stocke dans un tableau assez grand (qu'on appellera lightmap) les coefficents d'ombre de chaque pixel. Ensuite, à l'aide d'un deuxième tableau temporaire, nous procéderons au calcul de chaque coefficient.
Pour ceux qui ne sont pas convaincus que deux passes sont nécessaires :
Voici donc le gros de la fonction qui fait le calcul des moyennes :
Et voici le résultat :
![]() Après l'application du filtre
Remarquez que c'est le dernier paramètre que nous utilisons pour définir la grandeur du filtre.
Par contre, plus c'est grand, plus il y aura de calculs pour terminer la génération.
De plus, d'un point de vue rendu, cela n'est pas forcément une bonne idée donc nous avons mis
un filtre 4*4 (donc le dernier paramètre vaut 4).
5.4. Une 2ème solution : L dot N
L'avantage de la méthode précédente est la facilité d'utilisation de cette méthode. Mais elle demeure une approximation de la vraie solution qui est d'utiliser un produit scalaire entre la normal du point et le vecteur lumière.
En effet, le signe de ce produit scalaire nous dira si le pixel devrait être ombragé. Si le signe est positif, alors la normale et le vecteur lumière sont dans les mêmes directions. La lumière ne peut donc pas illuminer ce point.
Sinon, le produit est négatif, auquel cas, nous prendrons l'opposé de la valeur du produit scalaire comme coefficient de luminosité. Nous pouvons donc définir une nouvelle fonction
Terrain_calcLightMap_LDOTN :
Cette fonction vérifie si nous n'allons pas provoquer un débordement mémoire. Ensuite, il calcule une normale. Comment calculer la normal d'un point?
On utilise en fait les positions des points voisins. En effet, en utilisant les points (i-1,j-1), (i+1,j-1) et (i+1,j+1) on définit un triangle. Donc on pourra calculer un vecteur normal à partir de ces trois points.
En regardant le signe de z, on sait si le vecteur regarde vers le haut ou vers le bas. Vu que c'est par rapport au vecteur lumière, nous allons vérifier que ce vecteur regarde vers le haut.
Vous remarquez qu'on utilise toujours des vecteurs normaux. C'est conseillé de le faire et d'utiliser des facteurs d'ajustement par la suite.
Comme c'est fait ici :
On voit bien que si tmp est négatif alors le pixel sera illuminé. Vu que nous voulons une valeur entre 0 et 1, nous multiplions la valeur par un ajustement. Cet ajustement dépendra du terrain et du vecteur lumière.
Comme pour la première méthode, il sera plus sage d'appliquer un filtre comme pour la partie précédente sur le calcul d'ombre.
Voici une image illustrant cette solution :
![]() Deuxième méthode utilisant un filtre 5.5. Dernière méthode : le calcul des ombres
Cette dernière technique n'est qu'une extension de la dernière. En effet, nous allons utiliser le produit scalaire lorsqu'un calcul de coefficient sera nécessaire. La problématique de cette solution est donc de savoir quand est-ce qu'un tel calcul est nécessaire.
Nous partons toujours de la même hypothése : nous avons un vecteur lumière connu. Pour se simplifier la vie, mais ce n'est pas obligatoire, nous avons décidé que le soleil, étant tellement loin, posséde le même vecteur rayon lumineux pour tous les points du terrain.
Remarquons ici que je vais utiliser dans le vocabulaire le vecteur lumière, cela veut dire le vecteur se dirigeant vers la lumière donc c'est le vecteur opposé à celui donné dans la structure globale.
Finalement, cette solution, bien que gourmande en ressources (vu le nombre de calculs à faire) n'est pas très difficile à mettre en place. Voici son implémentation :
Jusque là, rien de bien particulier, nous avons le point de départ avec son hauteur. Remarquez que nous avons de nouveau fait un changement de repére entre la position dans la texture à générer et l'image de niveaux. Ensuite vient le calcul de savoir s'il y a une intersection ou non.
Rien de bien particulier. Si le niveau du terrain est au-dessus du vecteur allant vers la lumière alors il y a une intersection. Sinon on peut calculer le coefficient de luminosité utilisant le produit scalaire de la deuxième méthode.
Voici une image montrant ce calcul d'ombre :
![]() Dernière méthode 6. A propos du code source
Voici quelques remarques sur le code source présenté :
Il a été porté à mon attention que ce bout de code n'est pas tout à fait portable. En effet, les touches présentées ici fonctionne avec certains claviers et sous certains systèmes d'exploitation. Avec un petit peu de chance, la prochaine version de SDL corrigera ce détail.
Il est donc possible que la touche 'w' ne fonctionne pas pour vous mais que c'est la touche 'z' qui se chargera pour l'eau.
Pour le rendre portable, il faudrait passer par les codes unicode. Mais cela sort du cadre de ce tutoriel.
La génération de ces textures peut prendre un certain temps, il faudra être patient. En principe, lorsque la création est faite, on pourrait créer un fichier image (bmp, png, ...) et l'utiliser directement. C'est donc un procédé de prétraitement.
Enfin, la texture d'herbe utilisée ici a été créée à l'aide de l'outil graphique Gimp et la texture de roches a été trouvée sur
http://www.games-creator.com/. La texture de l'eau a été trouvée sur http://www.noctua-graphics.de/english/fraset_e.htm.
L'idée de l'algorithme pour le lancé de rayon pour le calcul des ombres a été tiré de cet article : Fast Computation of Terrain Shadow Maps par Mircea Marghidanu .
7. Conclusion
J'ai tenté de montrer comment générer une texture en utilisant des images de base et une image de niveaux. On peut calculer des coefficients de luminosité et même les ombres des objets.
L'avantage de cette solution permet d'avoir une seule texture et un rendu beaucoup plus rapide que de faire les mélanges entre les images de base à chaque rendu. Ensuite, des extensions existent encore pour faire encore mieux. Le choix des extensions dépendra de ce que vous cherchez dans votre générateur de terrain.
8. Remerciements
J'aimerais remercier Laurent Gomila pour son aide dans la
rédaction de cet article et Miles pour son soutien et sa relecture!
9. Téléchargements
Voici le code source : TexTerrainGen.zip (1.21 Mo)
Le pdf : TexTerrainGen.pdf (720 Ko)
10. Poursuite de la génération de Terrain
khayyam90 a fait un très bon article sur la génération de terrain utilisant l'algorithme de Perlin. Nous avons mis ensemble un code source utilisant le même visualisateur mais utilisant son algorithme. Je vous conseille de lire cet article, il vous permettra de générer aléatoirement des terrains avec cet algorithme.
|
Les sources présentées sur cette page sont libres de droits, et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une oeuvre intellectuelle protégée par les droits d'auteurs. Copyright © 2006 Jean Christophe Beyler. 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.