Générer de l'herbe avec InstancedMesh et MeshSurfaceSampler
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) :
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.