L'environnement : le vent
Dans le chapitre précédent, j’ai posé des dizaines de milliers de brins d’herbe sur le terrain. Mais une fois placés, ils sont inertes : couleur unie, raides, plantés. Pour que la scène respire, il faut au minimum une chose : que l’herbe ondule. C’est le rôle du vent.
On va suivre la donnée de bout en bout, depuis la classe Wind en JavaScript jusqu’au
sommet déplacé dans le vertex shader.
Une source de vérité, des consommateurs
Le vent est une classe Wind à part entière. C’est elle qui possède la donnée, la met à
jour, et l’expose. L’herbe n’est que consommatrice : elle reçoit une référence vers le vent
dans son constructeur.
export default class Grass extends WorldComponent {
constructor(terrain, wind, sun, groundShadow) {
super();
this.wind = wind; // la source du balancement
// ...
}
}
La donnée centrale du vent tient en deux valeurs : une direction dans le plan horizontal (un
Vector2 dans le plan XZ) et une force (un simple nombre).
// Wind.js
this.params = {
angle: 0, // direction dans le plan XZ (radians)
strength: 0.15, // amplitude du balancement
};
// Muté en place par updateWind(), jamais réassigné.
this.direction = new THREE.Vector2(
Math.cos(this.params.angle),
Math.sin(this.params.angle),
);
L’herbe branche ces deux valeurs dans les uniforms de son matériau :
this.uniforms = {
uWindStrength: { value: this.wind.params.strength },
uWindDirection: { value: this.wind.direction }, // même objet que Wind.direction
uBladeHeight: { value: this.params.BLADE_H }, // servira à corriger la normale
// ...
};
Direction contre force : référence contre copie
C’est ici que se joue tout le mécanisme de mise à jour, et il dépend entièrement du type de la donnée.
this.wind.direction est un objet (Vector2). En JavaScript, un objet est copié par
référence : uWindDirection.value et Wind.direction pointent vers le même Vector2 en
mémoire. Quand le vent tourne, Wind ne recrée pas l’objet, il le mute en place :
// Wind.js
updateWind() {
this.direction.set(Math.cos(this.params.angle), Math.sin(this.params.angle)); // mute .x/.y
this.trigger('change');
}
Comme l’uniform pointe sur ce même objet, la nouvelle direction est visible côté shader immédiatement, sans une seule ligne à écrire dans l’herbe.
La force, elle, est un nombre. Les primitives sont copiées par valeur : au moment où on écrit
value: this.wind.params.strength, on copie 0.15 une bonne fois, et la copie n’est plus
liée à la source. Si la force change ensuite, l’uniform ne le saurait jamais. Il faut donc la
recopier explicitement. Comme la force ne bouge que rarement, j’utilise le pattern Observer :
le vent notifie, l’herbe recopie.
// Grass.js, setSubscriptions()
this.wind.on("change", () => {
this.uniforms.uWindStrength.value = this.wind.params.strength; // recopie manuelle
});
Le balancement, dans le vertex shader
Toute la vie de l’herbe se passe maintenant sur le GPU. Chaque brin est fait de 5 sommets (base, milieu, pointe) et le vent doit les déplacer différemment : la base reste plantée dans le sol, la pointe bouge le plus. Pour ça, il faut savoir « à quelle hauteur » se trouve chaque sommet le long du brin.
Cette information est cuite dans la géométrie, sous forme d’attribut de couleur, au moment de construire le brin :
// Grass.js, masque de courbure : un poids par sommet
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)
]);
geometry.setAttribute("color", new THREE.BufferAttribute(windBladePower, 3));
color.x vaut donc 0 à la base, 1 à la pointe. Astuce : le même attribut servira aussi de
dégradé vertical pour la couleur dans le fragment shader, une donnée, deux usages.
Dans le vertex shader, le déplacement se calcule en trois temps :
// 1. Le masque : combien CE sommet a le droit de bouger (0 base -> 1 pointe)
float windMask = color.x;
// 2. L'oscillation : une onde qui dépend du temps ET de la position monde du brin
float wave = sin(uTime * 1.5 + modelPosition.x * 0.5 + modelPosition.z * 0.5);
// 3. Le décalage : orienté par la direction globale, dosé par masque × force
vec2 windOffset = uWindDirection * (wave * windMask * uWindStrength);
modelPosition.x += windOffset.x;
modelPosition.z += windOffset.y; // .y du Vector2 -> axe Z du monde
Trois idées à retenir :
windMaskgarantit que la base ne bouge jamais (× 0) et que la pointe bouge à fond (× 1). Le brin plie, il ne glisse pas.waveoscille dans le temps (uTime), mais son argument dépend aussi de la position monde du brin (modelPosition.x,.z). Résultat : deux brins voisins sont légèrement déphasés, et l’onde se propage à travers le champ au lieu de faire bouger tout le monde en même temps. C’est ce qui donne l’impression d’une brise qui balaie la prairie.uWindDirectionoriente le décalage. En multipliant par leVector2, le vent penche l’herbe dans n’importe quelle direction et comme cet uniform est partagé par référence, la faire tourner dans le debug fait virer tous les brins en direct.
uTime est la seule chose que Grass.update() pousse pour le vent, chaque frame :
update() {
this.uniforms.uTime.value = this.time.elapsed; // anime l'onde
// ...
}
Correction de la normale
Déplacer le sommet suffit à voir l’herbe onduler, mais pas à l’éclairer correctement. En courbant le brin, on l’a cisaillé : sa surface n’est plus verticale, donc sa normale ne devrait plus pointer comme si de rien n’était. Si on laisse la normale d’origine, tous les brins reçoivent la lumière comme s’ils étaient droits, et l’ondulation paraît plate, en carton.
Le vent décale X proportionnellement à windMask, c’est-à-dire proportionnellement à la
hauteur. Le décalage croît donc linéairement le long du brin : c’est une pente constante :
float scaleY = length(instanceMatrix[1].xyz); // échelle Y de CETTE instance
float worldHeight = scaleY * uBladeHeight; // hauteur réelle du brin dans le monde
float windSlope = wave * uWindStrength / worldHeight; // d(décalage) / d(hauteur)
C’est ici que uBladeHeight entre en jeu et pourquoi l’herbe le passe en uniform. La
pente, c’est « de combien X se décale par unité de hauteur ». Il faut donc diviser le décalage
par la vraie hauteur du brin dans le monde, échelle d’instance comprise (chaque brin a été
posé avec un scale.y aléatoire pour la variété).
Cette pente sert à basculer la normale, dans la direction du vent :
// Cas simple (vent sur X) : n.y -= pente * n.x
// Généralisé à une direction quelconque :
modelNormal.y -= windSlope * (uWindDirection.x * modelNormal.x + uWindDirection.y * modelNormal.z);
modelNormal = normalize(modelNormal);
La normale s’incline exactement comme la surface du brin. Du coup, quand un brin se penche face à la lumière, il s’éclaircit ; quand il se détourne, il s’assombrit et l’ondulation prend du volume au lieu de rester un simple glissement de sommets.
Ce que ça donne
Le vent, dans cette scène, c’est une donnée mutée à un seul endroit et lue partout. La classe
Wind possède un Vector2 et un nombre ; l’herbe branche les deux dans ses uniforms et
laisse le partage par référence faire le travail pour la direction, un simple abonnement pour
la force. Le reste est du shader : un masque de courbure cuit dans la géométrie, une onde
déphasée dans l’espace, et une normale corrigée pour que la lumière suive le mouvement.
Une fois ce branchement posé, recevoir la bonne source dans le constructeur, respecter le contrat « je mute, je ne réassigne jamais » le rendre réutilisable pour d’autres éléments de la scène ne coûte presque rien.