SCENE.LOG
scenenode[02]
2026-06-09 · 5 min

Générer de l'herbe avec InstancedMesh et MeshSurfaceSampler

nature stylisée geometry three.js

Maintenant que j’ai mon terrain vallonné, je veux le recouvrir d’herbe. L’objectif : des milliers de brins posés à la surface du relief, qui suivent les collines et les creux, avec un peu de variété pour que ça ne fasse pas trop artificiel. Pour cela, j’ai décidé d’utiliser la même technique que dans cet article : https://smythdesign.com/blog/stylized-grass-webgl/ Il s’agit d’afficher un grand nombre d’instances de la même géométrie grâce à InstancedMesh et de les positionner sur le terrain grâce à un MeshSurfaceSampler.

La géométrie d’un brin

Un brin, c’est une petite forme effilée que je construis à la main avec une BufferGeometry : 5 sommets qui se rétrécissent vers le haut. Il faudra aussi dans le futur animer le brin d’herbe en fonction du vent et pour cela il faut que le haut du brin subisse plus la force du vent que le bas du brin. Pour ce faire, je vais utiliser un buffer attribute (on utilisera l’attribut couleur ici) pour envoyer les valeurs 0 si c’est un vertex de la base, 0.5 si c’est un vertex du milieu et 1 pour un vertex du sommet. Ainsi cet attribut nous servira plus tard comme facteur pour le déplacement lié au vent.

// Masque de courbure (base=0 -> pointe=1), constant quelle que soit la taille du brin.
const windBladePower = new Float32Array([
  0,0,0,
  0,0,0, // base    -> 0 (fixe)
  0.5,0.5,0.5,
  0.5,0.5,0.5, // milieu  -> 0.5
  1,1,1, // pointe  -> 1 (bouge le plus)
]);

buildBladeGeometry() {
    const w = this.params.BLADE_W;
    const h = this.params.BLADE_H;

    const geometry = new THREE.BufferGeometry();

    const vertices = new Float32Array([
      -w / 2,0,0, // 0 base gauche
      w / 2,0,0, // 1 base droite
      -w / 4,h / 2,0, // 2 milieu gauche
      w / 4,h / 2,0, // 3 milieu droit
      0,h,0, // 4 pointe
    ]);

    geometry.setAttribute("position", new THREE.BufferAttribute(vertices, 3));
    geometry.setIndex([0, 1, 2, 1, 3, 2, 2, 3, 4]); // on arrange nos vertices
    geometry.computeVertexNormals();
    geometry.setAttribute(
      "color",
      new THREE.BufferAttribute(windBladePower, 3),
    );

    return geometry;
  }

Le setIndex sert à dire au GPU comment relier les sommets en triangles (une carte graphique ne sait dessiner que des triangles). Chaque groupe de 3 chiffres est un triangle désigné par les numéros des sommets : ça me permet de réutiliser les sommets partagés plutôt que de répéter leurs coordonnées.

Material

Pour l’instant, on va juste afficher de l’herbe sans interactions avec d’autres éléments du décor. On va quand même utiliser un ShaderMaterial afin de préparer la suite.

setMaterial() {
    this.uniforms = {
      uBaseColor: { value: new THREE.Color("#3f706a") }, // vert foncé à la base
      uTipColor: { value: new THREE.Color("#a6d6cc") }, // vert clair à la pointe
    };

    this.material = new THREE.ShaderMaterial({
      vertexShader: grassVertexShader,
      fragmentShader: grassFragmentShader,
      uniforms: this.uniforms,
      side: THREE.DoubleSide,
    });
  }

Le choix des couleurs a été fait grâce à des tweaks et en choisissant le rendu qui me satisfaisait le plus.

Le sampler : où poser les brins

Pour que les brins collent au relief, je ne peux pas tirer des positions au hasard sur un plan plat : elles doivent se trouver sur la surface du terrain déformé. C’est exactement le rôle du MeshSurfaceSampler.

import { MeshSurfaceSampler } from "three/addons/math/MeshSurfaceSampler.js";

const sampler = new MeshSurfaceSampler(this.terrain.mesh).build();

Le .build() fige un instantané de la géométrie du terrain. Ensuite, chaque appel à sampler.sample(...) me rend un point aléatoire posé sur cette surface, réparti uniformément (le sampler pondère par l’aire des triangles). Comme il échantillonne le terrain après déformation, les points suivent naturellement les collines et les vallées.

L’InstancedMesh : afficher les brins en masse

InstancedMesh ne crée pas des milliers de meshes : il crée un seul objet qui dessine la même géométrie de brin un grand nombre de fois, en un seul appel GPU. Chaque exemplaire (une instance) a juste sa propre matrice de transformation (position, rotation, échelle).

this.instancedMesh = new THREE.InstancedMesh(
  this.bladeGeometry, // la forme à dupliquer
  this.material,
  this.params.count, // le nombre d'instances
);

Reste à remplir ces matrices. Pour chaque brin, je demande une position au sampler, puis j’utilise un Object3D “fantôme” (jamais affiché) comme calculatrice de matrice : je lui règle une position/rotation/échelle, il me calcule la matrice 4×4 correspondante que l’InstancedMesh sait consommer.

const dummy = new THREE.Object3D();
const samplePos = new THREE.Vector3();

for (let i = 0; i < this.params.count; i++) {
  sampler.sample(samplePos); // Le sampler définit une position

  //on passe par un objet intermédiaire pour calculer une matrice de transformation
  dummy.position.copy(samplePos);
  dummy.rotation.y = Math.random() * Math.PI * 2; // orientation aléatoire
  const s = 0.8 + Math.random() * 0.6; // taille aléatoire (variété)
  dummy.scale.set(s, s, s);

  dummy.updateMatrix();
  this.instancedMesh.setMatrixAt(i, dummy.matrix); // range la matrice de transformation pour le brin i
}

this.instancedMesh.instanceMatrix.needsUpdate = true;
this.scene.add(this.instancedMesh);

La rotation et l’échelle aléatoires sont importantes : sans elles, tous les brins seraient identiques et alignés, ce qui trahit immédiatement l’aspect “généré”. Un peu de hasard suffit à donner une touffe crédible.

Voici le résultat :

N’hésitez pas à jouer avec les tweaks pour changer le nombre de brins et leurs dimensions.

Contrôler la densité avec un masque

Pour décider où l’herbe pousse, j’utilise une image en niveaux de gris comme masque de densité : je la peins une fois, et je la lis à chaque position tirée par le sampler. L’idée est de convertir la position (x, z) du brin en coordonnées (u, v) dans l’image, puis de lire le niveau de gris à ce pixel :

  • noir → pas d’herbe ici, je rejette le point ;
  • gris → zone clairsemée, je ne garde qu’une partie des brins ;
  • blanc → herbe pleine.
sampleZone(pos) {
  if (!this.maskData) return "full"; // pas de masque => herbe partout

  // position monde -> coordonnées (u, v) (0 a 1)dans l'image du masque
  const size = this.terrain.params.size;
  const u = (pos.x + size / 2) / size;
  const v = (pos.z + size / 2) / size;

  // on converti en pixel en utilisant la taille du mask
  const px = Math.floor(u * this.maskW);
  const py = Math.floor(v * this.maskH);
  const lum = this.maskData[(py * this.maskW + px) * 4] / 255; // niveau de gris 0..1

  if (lum < 0.25) return "none";  // noir
  if (lum < 0.75) return "short"; // gris
  return "full";                  // blanc
}

Il suffit ensuite de consulter le masque juste après avoir tiré une position, et de décider si on garde le brin ou non :

sampler.sample(samplePos);
const zone = this.sampleZone(samplePos);

if (zone === "none") continue;   // pixel noir -> on rejette
if (zone === "short" && Math.random() > this.grayDensity) continue; // gris -> moins dense

Comme certains points sont désormais rejetés, je ne remplis plus forcément toutes les instances prévues. Je compte donc les brins réellement placés et j’ajuste le rendu à ce nombre, pour ne pas dessiner d’instances vides :

this.instancedMesh.count = placed; // ne dessine que les brins effectivement posés

Avec le masque, je peux maintenant “peindre” la répartition de l’herbe : dessiner un chemin qui reste dégagé, créer des clairières, densifier certaines zones… le tout sans toucher au code, juste en modifiant l’image.

Voici un exemple de masque que j’utilise (blanc = herbe pleine, gris = clairsemé, noir = sol nu) :

Masque de densité de l'herbe (niveaux de gris)

Et le rendu, avec l’herbe filtrée par ce masque :

Pour l’instant les brins sont statiques et d’une couleur unie. Dans le prochain chapitre, je m’attaque au shader : un dégradé de couleur de la base vers la pointe, puis l’animation du vent pour faire onduler tout ça.

← retour à la scène