Génération aléatoire de terrains

Heightmap et 3D

yahiko

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.

- Titre : Génération aléatoire de terrains

- Auteur : yahiko

- Parution : 2 octobre 2014

- Public visé :

- Licence : 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.

Sommaire

- I. Introduction

- II. Heightmap

- II-A. Principes

- II-B. Affichage en 2D

- III. Algorithme Diamond Square

- III-A. Principes

- III-B. Implémentation

- IV. Rendu en 3D

- V. Lissage

- VI. Conclusion

- VI-A. Remerciements

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).

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

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

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

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

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.

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

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

function heightmapDiamondSquare(MAP_SIZE: number, // taille de la grille à générer, de valeur (2^n)+1
wrap: boolean,    // true pour les cartes répétables, false sinon
roughness: number // intensité de l'élévation supplémentaire aléatoire
): number[][] {

var map = [];
var nw = (wrap ? 0 : 1); // indicateur non répétable

// Crée un tableau 2D vide, size × size
for (var i = 0; i < MAP_SIZE; i++) {
map[i] = [];
}

// Initialise les coins de la carte
map[0][0] = Math.round(Math.random() * 255); // haut gauche
map[0][MAP_SIZE - 1] = Math.round(Math.random()* 255); // bas gauche
map[MAP_SIZE - 1][0] = Math.round(Math.random() * 255); // haut droite
map[MAP_SIZE - 1][MAP_SIZE - 1] = Math.round(Math.random() * 255); // bas droite

var h = 128; // la plage (-h -> +h) pour le décalage moyen

// sideLength est la longueur d'un côté d'un carré
// ou la longueur de la diagonale d'un losange
for (var sideLength = MAP_SIZE - 1;
// sideLength doit être >= 2 afin de toujours avoir
// une nouvelle valeur (si c'est 1, on écrase les valeurs existantes
// de la précédente itération)
sideLength >= 2;
// à chaque itération, on considère les carrés ou les losanges
// les plus petits, et on diminue la variation du décalage
sideLength /= 2, h /= 2.0) {
// la moitié de la longueur d'un carré
// ou la distance du centre d'un losange à un coin
// (juste pour rendre les calculs ci-dessous un peu plus clairs)
var halfSide = sideLength / 2;

// génère les nouvelles valeurs du carré
for (var x = 0; x < MAP_SIZE - 1; x += sideLength) {
for (var y = 0; y < MAP_SIZE - 1; y += sideLength) {
// x, y est en haut à gauche du carré
// calcule la moyenne des coins existants
var avg = map[x][y] + //top left
map[x + sideLength][y] + // top right
map[x][y + sideLength] + // lower left
map[x + sideLength][y + sideLength]; // lower right
avg /= 4.0;

// le centre c'est la moyenne plus un décalage aléatoire
map[x + halfSide][y + halfSide] = normalize(avg + offset(h, roughness));
} // for y
} // for x

// génère les valeurs du losange
// puisque les losanges sont en quinconce on se déplace en x
// uniquement de halfSide.
// NOTE : si la carte ne doit pas se répéter, alors x < MAP_SIZE
// pour générer les valeurs du bord
for (var x = 0; x < MAP_SIZE - 1 + nw; x += halfSide) {
// et y est x décalé de halfSide, mais translaté
// de la pleine longueur du côté
// NOTE : si la carte ne doit pas se répéter, alors y < MAP_SIZE
// pour générer les valeurs du bord
for (var y = (x + halfSide) % sideLength; y < MAP_SIZE - 1 + nw; y += sideLength) {
// x, y est le centre du losange
// à noter que nous devons utiliser le modulo et l'ajout de MAP_SIZE pour la soustraction
// de façon à parcourir cycliquement le tableau pour trouver les coins
var avg =
map[(x - halfSide + MAP_SIZE - 1) % (MAP_SIZE - 1)][y] + // gauche du centre
map[(x + halfSide) % (MAP_SIZE - 1)][y] + // droite du centre
map[x][(y + halfSide) % (MAP_SIZE - 1)] + // bas du centre
map[x][(y - halfSide + MAP_SIZE - 1) % (MAP_SIZE - 1)]; // haut du centre

avg /= 4.0;

// nouvelle valeur = moyenne + décalage aléatoire
avg = normalize(avg + offset(h, roughness));
// met à jour la valeur pour le centre du losange
map[x][y] = avg;

// duplique les valeurs sur les bords si carte répétable
if (wrap) {
if (x == 0) map[MAP_SIZE - 1][y] = avg;
if (y == 0) map[x][MAP_SIZE - 1] = avg;
}
} // for y
} // for x
} // for sideLength

return map;
} // heightmapDiamondSquare

// Renvoie un décalage aléatoire proportionnel à la hauteur
function offset(height, roughness) {
// On calcule la valeur aléatoire dans une plage de 2h
// et ensuite on soustrait h de façon à ce que la valeur finale
// soit dans l'intervalle [-h, +h]
return (Math.random() * 2 - 1) * height * roughness;
} // offset

// Normalise la valeur pour s'assurer qu'elle reste dans les limites
function normalize(value) {
return Math.round(Math.max(Math.min(value, 255), 0));
} // normalize

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 :

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

// Dessine la carte en 3D avec WebGL
function render3DHeightmap(map: number[][], container: HTMLElement) {
// Initialisation
var dimX = map.length;
var dimZ = map[0].length;
var halfDimX = Math.round(dimX / 2);
var halfDimZ = Math.round(dimZ / 2);
var clock = new THREE.Clock();
var scene = new THREE.Scene();

// Caméra
var camera = new THREE.PerspectiveCamera(60, container.clientWidth / container.clientHeight, 1, 20000);
camera.position.y = map[halfDimX][halfDimZ] * 10 + 500;

// Terrain brut
var geometry = new THREE.PlaneGeometry(7500, 7500, dimX - 1, dimZ - 1);
geometry.applyMatrix(new THREE.Matrix4().makeRotationX(- Math.PI / 2));
for (var x = 0; x < dimX; x++) {
for (var z = 0; z < dimZ; z++) {
geometry.vertices[x + z * dimX].y = map[x][z] * 10;
} // for z
} // for x

// Texture
var texture = new THREE.Texture(generateTexture(map, dimX, dimZ),
new THREE.UVMapping(), THREE.ClampToEdgeWrapping, THREE.ClampToEdgeWrapping);
texture.needsUpdate = true;

var mesh = new THREE.Mesh(geometry, new THREE.MeshBasicMaterial({ map: texture }));
scene.add(mesh);

// Renderer
var renderer = new THREE.WebGLRenderer();
renderer.setClearColor(0xbfd1e5);
renderer.setSize(container.clientWidth, container.clientHeight);

// Contrôleur
var controls = new FirstPersonControls(camera);
controls.movementSpeed = 500;
controls.lookSpeed = 0.1;

container.innerHTML = "";
container.appendChild(renderer.domElement);

animate();

function generateTexture(map: number[][], width: number, height: number): HTMLCanvasElement {
var sun = new THREE.Vector3(1, 1, 1);
sun.normalize();

var canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;

var context = canvas.getContext("2d");
context.fillStyle = "#000";
context.fillRect(0, 0, width, height);

var image = context.getImageData(0, 0, canvas.width, canvas.height);
var imageData = image.data;

var shade;
var vector3 = new THREE.Vector3(0, 0, 0);

for (var x = 0, dimX = map.length; x < dimX; x++) {
for (var z = 0, dimZ = map[0].length; z < dimZ; z++) {
vector3.x = map[x - 2][z] - map[x + 2][z];
vector3.y = 2;
vector3.z = map[x][z - 2] - map[x][z + 2];
vector3.normalize();

shade = vector3.dot(sun);

imageData[i] = (96 + shade * 128) * (0.5 + map[x][z] * 0.007);
imageData[i + 1] = (32 + shade * 96) * (0.5 + map[x][z] * 0.007);
imageData[i + 2] = (shade * 96) * (0.5 + map[x][z] * 0.007);
} // for z
} // for x

context.putImageData(image, 0, 0);

// Agrandissement 4x
var canvasScaled = document.createElement("canvas");
canvasScaled.width = width * 4;
canvasScaled.height = height * 4;

context = canvasScaled.getContext("2d");
context.scale(4, 4);
context.drawImage(canvas, 0, 0);

image = context.getImageData(0, 0, canvasScaled.width, canvasScaled.height);
imageData = image.data;

for (var i = 0, l = imageData.length; i < l; i += 4) {
var v = ~~(Math.random() * 5);

imageData[i] += v;
imageData[i + 1] += v;
imageData[i + 2] += v;
} // for i

context.putImageData(image, 0, 0);

return canvasScaled;
} // generateTexture

function animate() {
requestAnimationFrame(animate);
render();
} // animate

function render() {
controls.update(clock.getDelta());
renderer.render(scene, camera);
} // render
} // draw3D

class FirstPersonControls {
object: THREE.Object3D;
target: THREE.Vector3;

domElement: HTMLElement;

movementSpeed: number;
lookSpeed: number;

heightCoef: number;
heightMin: number;
heightMax: number;

verticalMin: number;
verticalMax: number;

lookX: number;
lookY: number;

lat: number;
lon: number;
phi: number;
theta: number;

moveForward: boolean;
moveBackward: boolean;
moveLeft: boolean;
moveRight: boolean;
moveUp: boolean;
moveDown: boolean;
freeze: boolean;

constructor(object: THREE.Object3D, domElement: HTMLElement) {
this.object = object;
this.target = new THREE.Vector3(0, 0, 0);

this.domElement = (domElement !== undefined) ? domElement : document;

this.movementSpeed = 1.0;
this.lookSpeed = 0.005;

this.heightCoef = 1.0;
this.heightMin = 0.0;
this.heightMax = 1.0;

this.verticalMin = 0;
this.verticalMax = Math.PI;

this.lookX = 0;
this.lookY = 0;

this.lat = 0;
this.lon = 0;
this.phi = 0;
this.theta = 0;

this.moveForward = false;
this.moveBackward = false;
this.moveLeft = false;
this.moveRight = false;
this.moveUp = false;
this.moveDown = false;
this.freeze = false;

if (this.domElement !== document) {
this.domElement.setAttribute("tabindex", "-1");
}

this.domElement.addEventListener("contextmenu", function (event) { event.preventDefault(); }, false);

window.addEventListener("keydown", bind(this, this.onKeyDown), false);
window.addEventListener("keyup", bind(this, this.onKeyUp), false);

function bind(scope, fn) {
return function () {
fn.apply(scope, arguments);
};
};
} // constructor

onKeyDown(event) {
switch (event.keyCode) {
case 90: /*Z*/
case 87: /*W*/ this.moveForward = true; break;

case 81: /*Q*/
case 65: /*A*/ this.moveLeft = true; break;

case 83: /*S*/ this.moveBackward = true; break;

case 68: /*D*/ this.moveRight = true; break;

case 38: /*up*/ this.lookY = 300; break;

case 40: /*down*/ this.lookY = -300; break;

case 37: /*left*/ this.lookX = -300; break;

case 39: /*right*/ this.lookX = 300; break;

case 82: /*R*/ this.moveUp = true; break;

case 70: /*F*/ this.moveDown = true; break;

case 80: /*P*/ this.freeze = !this.freeze; break;
}
} // onKeyDown

onKeyUp(event) {
switch (event.keyCode) {
case 90: /*Z*/
case 87: /*W*/ this.moveForward = false; break;

case 81: /*Q*/
case 65: /*A*/ this.moveLeft = false; break;

case 83: /*S*/ this.moveBackward = false; break;

case 68: /*D*/ this.moveRight = false; break;

case 38: /*up*/ this.lookY = 0; break;

case 40: /*down*/ this.lookY = 0; break;

case 37: /*left*/ this.lookX = 0; break;

case 39: /*right*/ this.lookX = 0; break;

case 82: /*R*/ this.moveUp = false; break;

case 70: /*F*/ this.moveDown = false; break;
}
} // onKeyUp

update(delta) {
if (this.freeze) {
return;
}

var actualMoveSpeed = delta * this.movementSpeed;

if (this.moveForward) this.object.translateZ(-(actualMoveSpeed));
if (this.moveBackward) this.object.translateZ(actualMoveSpeed);

if (this.moveLeft) this.object.translateX(-actualMoveSpeed);
if (this.moveRight) this.object.translateX(actualMoveSpeed);

if (this.moveUp) this.object.translateY(actualMoveSpeed);
if (this.moveDown) this.object.translateY(-actualMoveSpeed);

var actualLookSpeed = delta * this.lookSpeed;

this.lon += this.lookX * actualLookSpeed;
this.lat -= this.lookY * actualLookSpeed;

this.lat = Math.max(-85, Math.min(85, this.lat));
this.phi = THREE.Math.degToRad(90 - this.lat);

this.theta = THREE.Math.degToRad(this.lon);

var targetPosition = this.target,
position = this.object.position;

targetPosition.x = position.x + 100 * Math.sin(this.phi) * Math.cos(this.theta);
targetPosition.y = position.y + 100 * Math.cos(this.phi);
targetPosition.z = position.z + 100 * Math.sin(this.phi) * Math.sin(this.theta);

this.object.lookAt(targetPosition);
} // update
} // FirstPersonControls

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.

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

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 :

Rendu 3D d'une heightmap via Diamond Square sans lissage

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.