Génération aléatoire de terrains

Heightmap et 3D

Cet article est une introduction à la génération aléatoire de terrains via une heightmap. Dans celui-ci, nous verrons la notion de heightmap et l'algorithme Diamond Square pour générer notre terrain.

Les commentaires et les suggestions d'améliorations sont les bienvenus, alors, après votre lecture, n'hésitez pas. Commentez.

Article lu   fois.

L'auteur

Profil Pro

Liens sociaux

Viadeo Twitter Facebook Share on Google+   

I. Introduction

Dans un jeu vidéo, générer aléatoirement des terrains est une manière de renouveler l'expérience du joueur à chaque partie. Cela peut également servir à simplifier la tâche au concepteur de niveaux, surtout si les niveaux sont nombreux, en fournissant une base de travail qui pourra être retouchée et enrichie pendant la phase de développement d'un jeu.

C'est un domaine très actif avec un champ d'études et d'expérimentations qui ne cesse de s'élargir, au point qu'à l'heure actuelle il est possible de générer procéduralement des planètes entières avec un haut niveau de détails (cf. Outerra, cette vidéo).

Le premier objectif, beaucoup plus modeste, que se fixera cette présentation sera de générer aléatoirement le relief d'un terrain rectangulaire de dimensions finies, puis de le restituer en image bitmap 2D et en scène 3D. Avant d'arriver à ce résultat, plusieurs notions seront abordées, et c'est la raison pour laquelle je laisserai dans un premier temps de côté tout ce qui est accessoire comme les rivières, les lacs ou encore la végétation qui pourront être envisagés dans une présentation ultérieure.

Le langage utilisé pour illustrer le propos sera TypeScript, un sur-ensemble de JavaScript avec typage et héritage. Une présentation de ce langage est disponible ici.

II. Heightmap

II-A. Principes

La représentation du relief d'un terrain passe souvent par une carte des altitudes, plus communément appelée par son terme anglo-saxon, heightmap. C'est ce terme anglo-saxon qui sera utilisé dans cette présentation, car très répandu dans le jargon. L'idée d'une heightmap est d'associer une valeur correspondant à l'altitude à chaque point d'une carte en 2D. Ce modèle simple est très pratique, mais possède quelques limitations que nous ignorerons ici. Par exemple une heightmap ne permet pas de modéliser des grottes ou des tunnels. Pour que les heightmaps soient facilement manipulables, les altitudes d'une heightmap sont souvent codées en utilisant la couleur (une intensité de gris).

Image non disponible
Exemple de heightmap

Sur la heightmap ci-dessus, les zones les plus claires (blanches) correspondent aux hautes altitudes, tandis que les zones les plus sombres (noires) correspondent aux basses altitudes.

Le codage d'une heightmap peut donc être une image bitmap où l'altitude d'un point est stockée en triple exemplaire chaque composante RGB du pixel du point correspondant. Cela implique qu'en pratique une heightmap ainsi codée a des valeurs d'altitude comprises entre 0 et 255, sur 8 bits. Par exemple, si en un point <x, y> tel que x et y sont respectivement l'abscisse et l'ordonnée, l'altitude vaut 73, alors le pixel correspondant en <x, y> aura ses composantes RGB qui vaudront <73, 73, 73> et une valeur alpha (transparence) constante de 255.

Il existe des codages de heightmaps en 16, 24 et même 32 bits, qui autorisent donc une plage de valeurs pour l'altitude nettement plus importante, mais qui ne sont pas vraiment standardisés.

À noter que contrairement à la convention usuelle dans le domaine de la 3D, on utilisera dans un premier temps le symbole y comme une coordonnée du plan horizontal et non comme une coordonnée de la hauteur. Ceci afin de se conformer aux notations des représentations graphiques dans le domaine de la 2D, ce qui est le cas d'une image bitmap classique. Ce n'est que lorsque nous aborderons la représentation effective en 3D que le symbole y désignera effectivement la coordonnée de la hauteur.

II-B. Affichage en 2D

Avant d'aborder les différents algorithmes possibles pour générer aléatoirement un terrain, voyons déjà comment afficher une heightmap sur un navigateur en HTML5.

Pour l'affichage, nous ne considérerons que le cas d'une heightmap sur 8 bits et nous utiliserons le repère habituel en informatique, à savoir l'origine de l'abscisse et de l'ordonnée en haut à gauche.

On supposera que la heightmap est un tableau à deux dimensions et dont chaque valeur est un nombre entier compris entre 0 et 255 correspondant à l'altitude en ce point. La première coordonnée du tableau sera l'abscisse x (la composante horizontale de l'écran) et la seconde coordonnée sera l'ordonnée y (la composante verticale de l'écran). Ainsi, si le tableau est nommé map, map[5][2] doit renvoyer l'altitude du point de coordonnées (5 ; 2).

Pour afficher la heightmap, il s'agit globalement de parcourir l'ensemble des cases du tableau et d'affecter à chaque pixel, et à chaque composante RGB la valeur adéquate.

Routine d'affichage d'une heightmap
Sélectionnez
function drawHeightmap(map: number[][], container: HTMLElement) {
  var dimX = map.length;
  var dimY = map[0].length;
  
  var canvas = document.createElement("canvas");
  canvas.width = dimX;
  canvas.height = dimY;
  var ctx = canvas.getContext("2d");
  var img = ctx.createImageData(canvas.width, canvas.height);
  var imgData = img.data; // optimisation
  var i = 0;              // indice imgData

  for (var y = 0; y < dimY; y++) {
    for (var x = 0; x < dimX; x++) {
      var height = Math.round(map[x][y]);

      imgData[i++] = height;
      imgData[i++] = height;
      imgData[i++] = height;
      imgData[i++] = 255; // alpha opaque
    } // for x
  } // for y

  ctx.putImageData(img, 0, 0);

  container.innerHTML = "";
  container.appendChild(canvas);
} // drawHeightmap

La fonction drawHeightmap prend deux paramètres : map et container. Le paramètre map qui comme son nom l'indique est la carte, doit évidemment être défini avant l'appel à la fonction. C'est d'ailleurs ce que nous verrons prochainement avec un algorithme générant aléatoirement un ensemble de valeurs cohérentes sous la forme d'un tableau de nombres à deux dimensions. Pour tester dans un premier temps l'affichage, nous pouvons affecter une valeur aléatoire comprise entre 0 (noir) et 255 (blanc).

Initialisation d'une heightmap avec des valeurs aléatoires
Sélectionnez
var myMap = Array(128);
for (var x = 0; x < 128; x++) {
  myMap[x] = Array(128);
  for (var y = 0; y < 128; y++) {
    myMap[x][y] = Math.round(255 * Math.random());
  } // for y
} // for x

Le paramètre container doit quant à lui contenir la référence à un objet du DOM existant dans la page HTML, comme une balise <div> déjà existante, qui servira de conteneur à l'image de la heightmap. Pour obtenir cette référence à une balise <div> ayant un identifiant, il suffit de faire appel à la méthode document.getElementById. Par exemple, si l'identifiant est view tel qu'on ait <div id="view">, on pourrait initialiser une variable myContainer ainsi :

Initialisation du conteneur d'affichage
Sélectionnez
var myContainer = document.getElementById("view");

À l'aide de myMap et de myContainer, nous pouvons ainsi appeler la fonction drawHeightmap une fois la page HTML chargée :

Appel à la routine d'affichage
Sélectionnez
drawHeightmap(myMap, myContainer);

En reprenant les exemples précédents, ceci doit afficher un carré rempli d'un brouillard de points à l'endroit de la balise <div id="view"> définie dans le corps du fichier HTML.

Image non disponible
Heightmap générée de façon totalement aléatoire

Vous pouvez voir le code de cet exemple en action ici.

Bien évidemment, bien que valable du point de vue du format, une telle heightmap totalement aléatoire n'est pas exploitable dans la pratique, le relief généré ne pouvant pas être vraisemblable. Pour cela nous allons examiner un des algorithmes permettant de générer des reliefs cohérents.

III. Algorithme Diamond Square

III-A. Principes

L'algorithme Diamond Square est un algorithme relativement répandu dans la génération procédurale de terrain, car il est simple à comprendre et plutôt rapide à l'exécution.

Tout d'abord, cet algorithme ne fonctionne que sur des grilles carrées de longueur kitxmlcodeinlinelatexdvp2^{n}+1finkitxmlcodeinlinelatexdvp. Il se déroule principalement en deux étapes qui s'alternent. L'une est appelée l'étape du carré (square) et l'autre est appelée l'étape du losange (diamond). L'idée centrale est qu'à chacune de ces étapes, on calcule la hauteur du centre en fonction de la moyenne des quatre extrémités, soit du carré, soit du losange selon l'étape dans laquelle se trouve l'algorithme.

La hauteur du centre se base sur la moyenne des extrémités, mais est modifiée d'un décalage aléatoire, positif ou négatif, afin de donner un aspect irrégulier au terrain final.

Plutôt que de longues et interminables explications, l'animation ci-dessous permet d'illustrer simplement et intuitivement le déroulement de l'algorithme.

Image non disponible
Animation de l'algorithme Diamond Square (Paul Boxley)

Pour ceux qui souhaitent rentrer dans le détail du fonctionnement de l'algorithme, il existe un très bon article en français rédigé par Hiko Seijuro.

III-B. Implémentation

Il existe deux grandes variantes de l'algorithme Diamond Square. La plus répandue étant la version récursive que l'on trouve facilement sur Internet. Dans cet article, nous présenterons la version itérative trouvée dans une discussion sur le site d'échange Stack Overflow, moins connue, mais pourtant légèrement plus rapide.

Il s'agit donc de définir une fonction prenant en paramètre principal la taille de la heightmap (pour être plus précis, la longueur d'un côté) et d'autres paramètres auxiliaires comme celui indiquant si la carte peut être répétable (wrap) et celui influençant la dureté du relief (roughness).

L'algorithme commence par des initialisations, et en particulier l'initialisation de la hauteur des quatre coins qui peuvent être fixes ou bien aléatoires comme c'est le cas dans cette implémentation.

Puis, on entre dans une grande boucle qui permet de balayer les différentes cases de la carte en utilisant le principe du déplacement du point central (midpoint displacement) sur une surface de plus en plus réduite à mesure de l'avancement de l'itération principale.

C'est dans cette itération principale que sont réalisées les deux étapes caractéristiques de l'algorithme Diamond Square, l'étape du carré et l'étape du losange, où la subtilité réside essentiellement dans le mode de calcul des coordonnées des coins qui permettent in fine d'attribuer une hauteur semi-aléatoire pour le centre.

Voici l'intégralité de l'algorithme Diamond Square en TypeScript :

Algorithme Diamond Square
CacherSélectionnez

Il est bien évidemment possible de faire appel à d'autres algorithmes que le Diamond Square (par exemple Perlin Noise, Value Noise, DLA, Particle Deposition, etc.) sachant que l'essentiel étant que l'algorithme doit produire en bout de course un résultat compatible avec notre implémentation générale, en l'occurrence ici un tableau de nombres entiers en deux dimensions.

Le résultat de la méthode Diamond Square peut donner une heightmap telle que celle-ci :

Image non disponible
Heightmap générée par l'algorithme Diamond Square

Vous pouvez voir ici le code du Diamond Square en action reprenant l'implémentation générale.

IV. Rendu en 3D

À partir d'une heightmap, il n'est pas forcément évident de se rendre compte concrètement du résultat. Pour cela, une possibilité serait d'enregistrer l'image (via un clic droit sur l'image dans votre navigateur par exemple) et de l'importer dans un outil spécialisé pour un rendu en 3D. Les outils pouvant importer une heightmap sont nombreux. Citons par exemple Terragen ou Geogen parmi les outils spécialisés disponibles gratuitement (sous certaines conditions), ou encore des moteurs de jeu comme Unity ou Unreal Engine même si ces derniers utilisent des formats bruts (raw) en 16 bits.

Il est cependant tout à fait possible de générer une petite scène interactive en 3D directement dans son navigateur grâce à des bibliothèques JavaScript se basant sur WebGL. Il existe différentes bibliothèques simplifiant l'emploi de WebGL comme babylon.js. Ici, nous utiliserons three.js qui bénéficie d'une bonne popularité.

Le rendu de la heightmap avec three.js suit les étapes classiques de n'importe quel rendu 3D. La scène est constituée d'une caméra et du terrain sous la forme d'un maillage de points dans l'espace et sur lequel nous appliquons une texture monochrome afin de rendre l'exemple le plus simple possible. Et nous associons le contrôleur (les entrées clavier) à la caméra afin de simuler un déplacement à la première personne. On notera que le contrôleur est défini dans une classe à part pour des besoins de clarté.

Rendu 3D d'une heightmap
CacherSélectionnez

Malgré sa longueur, ce script est relativement simple et linéaire sur un mode plutôt déclaratif. Une démonstration en ligne de ce script est disponible ici. Dans cette démonstration, la navigation horizontale se fait via les touches W-A-S-D pour les claviers QWERTY (ou Z-Q-S-D pour les claviers AZERTY), la navigation verticale via les touches R et F, et l'orientation de la caméra via les flèches du clavier.

Image non disponible
Rendu en 3D de l'algorithme Diamond Square

Ce rendu 3D nous permet de nous rendre compte d'un défaut important et bien connu de l'algorithme Diamond Square. Il a tendance à générer des pics disgracieux au sommet des montagnes. Pour y remédier, nous pouvons adoucir le relief.

V. Lissage

Il existe d'innombrables algorithmes, ou filtres dans le jargon du traitement de l'image, pour rendre une image plus lisse ou smooth en anglais. Dans cette présentation nous nous limiterons simplement à décrire l'un des plus simples d'entre eux : l'algorithme dit Box Blur.

Sans rentrer dans la théorie, le principe de cet algorithme est de remplacer la hauteur d'un point de notre heightmap par la moyenne des hauteurs de son voisinage, en incluant la hauteur du point concerné, et d'appliquer ce principe à tous les points de la heightmap.

Il faut voir le Box Blur comme une moyenne mobile en deux dimensions.

Avec le Box Blur, l'utilisateur peut jouer principalement sur deux paramètres. Le premier peut consister à faire varier la notion de voisinage. Cela peut être uniquement les points immédiatement adjacents au point concerné, ou bien incluant des points plus éloignés, sachant qu'en termes d'implémentation le voisinage sera très souvent modélisé par des matrices carrées. Plus le voisinage sera étendu, et plus le lissage sera fort à cause du poids relatif du centre qui tendra à diminuer.

Le second paramètre que peut faire varier l'utilisateur de cet algorithme est la notion de moyenne. Cela peut être une simple moyenne arithmétique ou bien une moyenne pondérée, avec des poids distincts selon les cases de la matrice de voisinage. On pourrait vouloir par exemple que le point concerné, au centre, ait un poids supérieur au poids de ses voisins dans le calcul de la moyenne, ce qui aura pour effet de rendre le lissage moins uniforme. Pour cette présentation, nous nous limiterons à une simple moyenne arithmétique où chaque point de la matrice de voisinage a le même poids.

Voici ce que pourrait donner une implémentation en TypeScript de l'algorithme Box Blur :

Algorithme Box Blur
Sélectionnez
function boxBlur(map:number[][], radius: number): number[][] {
  var dimX = map.length;
  var dimY = map[0].length;
  var result: number[][] = [];
  var line: number[];
  var val: number;

  for (var i = 0; i < dimX; i++) {
    line = [];
    for (var j = 0; j < dimY; j++) {
      val = 0;
      for (var iy = j - radius; iy < j + radius + 1; iy++) {
        for (var ix = i - radius; ix < i + radius + 1; ix++) {
          var x = Math.min(dimX - 1, Math.max(0, ix));
          var y = Math.min(dimY - 1, Math.max(0, iy));
          val += map[x][y];
        } // for ix
      } // for iy
      line.push(val / ((radius + radius + 1) * (radius + radius + 1)));
    } // for j
    result.push(line);
  } // for i

  return result;
} // boxBlur

Si, juste après la génération de la heightmap via la méthode du Diamond Square vu plus haut, nous appelons cette fonction de lissage, voici ce que nous pourrions obtenir comme rendu 3D :

Image non disponible
Rendu 3D d'une heightmap via Diamond Square sans lissage
Image non disponible
Rendu 3D d'une heightmap via Diamond Square avec lissage

On notera ici l'apparition de « textures ». Il s'agit simplement d'une petite amélioration parmi celles disponibles dans cette démonstration en ligne.

L'intérêt majeur de l'algorithme Box Blur est qu'on peut l'utiliser pour approximer un filtre de Gauss (ou Gaussian Blur) un autre algorithme de lissage plus sophistiqué, mais plus coûteux en calculs. Il faut pour cela réaliser plusieurs passages du Box Blur pour simuler un filtre de Gauss, mais cela reste néanmoins plus rapide.

VI. Conclusion

À travers cette présentation des heightmaps, nous avons abordé l'affichage d'une image dans le navigateur, nous avons vu un algorithme, le Diamond Square, permettant de générer des hauteurs donnant un aspect plus réaliste que de simples valeurs aléatoires, même si cet algorithme comporte quelques défauts qui sont décelables via un simple rendu en 3D que nous avons également abordé. Et pour corriger ces défauts, nous avons utilisé un algorithme simple de lissage qui est le Box Blur.

VI-A. Remerciements

Je remercie LittleWhite pour la relecture technique ainsi que Claude Leloup pour la relecture orthographique de cet article.

Les commentaires et les suggestions d'améliorations sont les bienvenus, alors, après votre lecture, n'hésitez pas. Commentez.

Vous avez aimé ce tutoriel ? Alors partagez-le en cliquant sur les boutons suivants : Viadeo Twitter Facebook Share on Google+   

  

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 œuvre intellectuelle protégée par les droits d'auteur. Copyright © 2014 yahiko. 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.