L'environnement : la pluie
Dans le chapitre précédent, le soleil décidait de la lumière de la scène. Je veux maintenant rajouter de la pluie, pour cela je me suis inspiré de l’article de Peter Adams Cheap, Beautiful Rain in Three.js.
Le principe est simple : on construit une bonne fois pour toutes quelques milliers de petits
segments répartis dans une boîte au-dessus de l’île, et on ne touche plus jamais à cette
géométrie. Chaque goutte tombe toute seule dans le vertex shader, un mod() la ramenant en haut
quand elle a fini sa course, et deux uniforms suffisent ensuite à tout piloter : un pour la
quantité de gouttes visibles, un pour l’inclinaison. C’est ce dernier point que j’ajoute à
l’article d’origine : la sensibilité au vent.
Une seule construction, tout dans le shader
La pluie est une seule LineSegments : des milliers de petits traits dessinés en un appel.
Je la construis une fois, à la densité maximale que la scène pourra jamais afficher, et je n’y touche plus jamais :
// Rain.js
const volume = this.terrain.params.size ** 2 * this.params.rainHeight;
const count = Math.round(volume * this.maxDensity); // ~2458 traits pour size 32, hauteur 20
maxDensity vaut 0.12, c’est-à-dire la densité du preset météo le plus lourd, la tempête.
C’est le plafond que la géométrie doit pouvoir absorber : tous les autres temps n’en montreront
qu’une fraction.
Le nombre de traits est figé dès la construction. Tout ce qui donne vie à l’averse (la chute, l’inclinaison, l’intensité) se passe ensuite dans le vertex shader, piloté par des uniforms. C’est un choix qui coûte un peu de mémoire au départ (on alloue les gouttes de la pire tempête, même par temps sec) mais qui rend tout le reste gratuit : changer le vent ou faire tomber la pluie ne redemande aucune reconstruction, juste une nouvelle valeur d’uniform.
Cette décision découle directement d’un piège que j’avais rencontré avant : si on régénère les positions des gouttes à chaque changement d’intensité, on retire au hasard de nouveaux points à chaque fois, et l’averse clignote. En figeant la géométrie, chaque goutte garde sa place pour toujours, et l’intensité devient une simple question de « laquelle je montre ».
Une goutte, deux sommets
Un trait de pluie, c’est un segment, donc deux sommets : une tête et une queue. Les deux partent exactement du même point à la construction :
const i6 = i * 6; // 6 composantes par segment : 2 sommets x 3 coordonnées
// Tête ET queue partent du MÊME point ; la queue est décalée dans le vertex shader.
positions[i6] = startX;
positions[i6 + 1] = startY;
positions[i6 + 2] = startZ;
positions[i6 + 3] = startX;
positions[i6 + 4] = startY;
positions[i6 + 5] = startZ;
Pourquoi ne pas placer la queue directement au-dessus de la tête dès la construction ? Parce que la longueur et l’orientation du trait dépendent du vent, et que le vent change. Si je figeais la queue ici, il faudrait reconstruire à chaque rafale. En partant du même point, je laisse le shader écarter la queue selon le vent du moment.
Un attribut distingue les deux sommets, et quelques autres portent les caractéristiques propres à chaque goutte. Trois d’entre elles sont tirées au hasard à la construction :
const speed = THREE.MathUtils.randFloat(
this.params.speedMin,
this.params.speedMax,
);
const offset = THREE.MathUtils.randFloat(0, this.params.rainHeight);
const rand = Math.random();
speed est la vitesse de chute propre à la goutte, entre 8 et 14 unités par seconde, pour que
toutes ne descendent pas à la même allure.
offset est un décalage de phase, à ne pas confondre avec la position de départ (déjà tirée
au hasard dans la boîte, juste au-dessus). Il indique où la goutte en est dans son cycle de
chute au démarrage : sans lui, toutes les gouttes entameraient leur course au même instant. On
verra dans la section suivante qu’il suffit de l’ajouter au temps pour obtenir ce résultat.
rand, enfin, n’a rien à voir avec la chute : c’est un nombre entre 0 et 1, tiré une fois et
plus jamais retouché, qui servira de seuil au masque de densité en fin de chapitre. Ce qui
compte ici, c’est qu’il soit figé pour de bon.
Ces valeurs décrivent une goutte entière, mais les attributs sont par sommet : il faut donc
les écrire deux fois, une pour la tête et une pour la queue. v = i * 2 donne l’index de la
tête, v + 1 celui de la queue :
const v = i * 2; // index du sommet tête ; la queue est juste après, en v + 1
alphas[v] = 1.0;
alphas[v + 1] = 0.0; // tête opaque -> queue transparente
isTail[v] = 0.0;
isTail[v + 1] = 1.0; // 0 = tête, 1 = queue
speeds[v] = speed;
speeds[v + 1] = speed; // IDENTIQUE tête+queue
offsets[v] = offset;
offsets[v + 1] = offset; // IDENTIQUE tête+queue
rands[v] = rand;
rands[v + 1] = rand; // IDENTIQUE tête+queue
La vitesse, le décalage de phase et le tirage aléatoire sont identiques sur la tête et la queue. C’est ce qui garde le trait rigide. Si la tête et la queue tombaient à des vitesses différentes, le segment s’étirerait ou se comprimerait en chutant. En leur donnant les mêmes valeurs, les deux bougent d’un bloc, et le trait garde sa longueur.
L’aAlpha, lui, est volontairement différent : 1 à la tête, 0 à la queue. C’est ce qui donne
la petite traînée qui s’estompe vers le haut, comme une goutte qui file.
La chute recyclée : un mod() et rien d’autre
Voici le cœur de l’effet, et c’est étonnamment court. Chaque goutte tombe, puis se
téléporte en haut pour recommencer, indéfiniment. Tout tient dans un mod :
// rain/vertex.glsl
float displacement = mod(uTime * aSpeed + aOffset, uFallDistance);
uTime * aSpeed fait grandir la distance de chute avec le temps, à la vitesse propre de la
goutte. Le mod(..., uFallDistance) est l’astuce : dès que la goutte a parcouru toute la
hauteur de la colonne, le reste de la division repasse à zéro et la goutte réapparaît en haut.
C’est une dent de scie, un cycle qui monte et retombe sans fin, sans le moindre if ni gestion
de tableau côté JavaScript.
Deux paramètres brisent la synchronisation, sans quoi toutes les gouttes tomberaient au même rythme et au même instant, ce qui trahirait immédiatement l’effet :
aSpeeddonne à chaque goutte une vitesse tirée entre 8 et 14 unités par seconde. Les gouttes ne descendent pas toutes à la même allure.aOffsetdécale la phase de chaque goutte dans le cycle. À l’instant zéro, elles ne sont pas toutes en haut : certaines ont déjà fait la moitié du chemin.
Reste à savoir dans quelle direction appliquer ce déplacement. Elle ne pointe pas simplement vers le bas : c’est le vent qui la détermine, et c’est l’objet de la section suivante.
Le vent incline la pluie
C’est ici que le chapitre 4 ressert. La classe Wind possède un Vector2 de direction, muté
en place, et la pluie s’y branche par référence exactement comme l’herbe le faisait :
// Rain.js, setMesh()
uWindStrength: { value: this.wind.params.strength },
uWindDir: { value: this.wind.direction }, // même Vector2 que Wind, partagé par référence
Comme pour l’herbe, la direction se propage toute seule : Wind mute son vecteur, la pluie
lit le même objet, aucune ligne à écrire. La force, elle, est un nombre, donc une copie figée :
la partager par référence est impossible, il faut être prévenu quand elle change. C’est
précisément ce que Wind émet, et la pluie s’y abonne comme l’herbe :
// Rain.js
setSubscriptions();
this.wind.on("change", () => {
this.uniforms.uWindStrength.value = this.wind.params.strength;
});
À partir de cette force, le shader calcule une inclinaison. L’idée physique est simple : une goutte a une vitesse de chute verticale et le vent lui ajoute une vitesse horizontale ; l’angle du trait est celui de la somme des deux.
// tilt = atan(vitesse horizontale / vitesse de chute)
float tilt = atan(uWindStrength * uWindFactor);
float horiz = uStreakLength * sin(tilt); // part horizontale du trait
float vert = uStreakLength * cos(tilt); // part verticale du trait
Sans vent, tilt vaut zéro : la pluie tombe droite. Plus le vent forcit, plus le trait se
couche. Le uWindFactor (6 dans mes réglages) n’a aucune prétention physique, c’est un
facteur de sensibilité que j’ai réglé à l’œil pour que l’inclinaison soit lisible sans être
caricaturale.
Cette inclinaison sert à deux choses. D’abord à décaler la queue, dans le sens inverse du vent pour que la traînée parte en arrière :
// aIsTail vaut 0 sur la tête (immobile) et 1 sur la queue (décalée).
vec3 pos = position + aIsTail * vec3(-horiz * uWindDir.x, vert, -horiz * uWindDir.y);
Ensuite à orienter la direction de chute laissée en suspens plus haut, pour que les gouttes ne tombent pas seulement vers le bas mais aussi dans le sens du vent :
// Direction de chute : bas (-cos) + vers le vent (+dir * sin). Déjà unitaire.
vec3 fallDir = vec3(sin(tilt) * uWindDir.x, -cos(tilt), sin(tilt) * uWindDir.y);
vec4 modelPosition = modelMatrix * vec4(pos, 1.0);
modelPosition.xyz += fallDir * displacement;
Trait penché et chute oblique restent cohérents, puisqu’ils dérivent du même tilt. Et comme tout ça vit dans le
shader, faire tourner le vent dans le panneau de debug incline l’averse en direct, sans le
moindre re-tirage.
La densité : un seul uniform pour tout doser
Reste la troisième contrainte, la plus intéressante : faire commencer et cesser la pluie en
douceur. La géométrie contient déjà toutes les gouttes de la tempête maximale. Il suffit donc
de choisir combien on en montre, et c’est le rôle d’un seul uniform, uDensityFrac, une
fraction entre 0 et 1.
Chaque goutte porte un seuil aléatoire stable, aRand, tiré une fois à la construction. Le
shader ne garde la goutte que si son seuil passe sous la fraction courante :
// Masque de densité : visible si aRand < uDensityFrac, sinon alpha 0.
float visible = step(aRand, uDensityFrac);
vAlpha = aAlpha * visible;
C’est tout le mécanisme. À uDensityFrac = 0, aucune goutte ne passe le test, il ne pleut
pas. À 0.5, la moitié des gouttes (celles dont le seuil est sous 0.5) sont visibles. À 1,
toutes. Et comme chaque goutte a un seuil fixe, augmenter la fraction ne fait qu’en révéler de
nouvelles sans jamais déplacer les anciennes : la pluie s’épaissit progressivement au lieu de
clignoter. C’est exactement le problème que la construction unique voulait résoudre, et il se
résout en une ligne de GLSL.
Faire varier cette fraction dans le temps donne le fondu de l’averse. Quand le système météo change d’intensité cible, je ne saute pas à la nouvelle valeur, je l’interpole sur une durée fixe avec un easing :
// Rain.js, update()
if (this._densT < 1) {
this._densT = Math.min(
1,
this._densT + this.time.delta / this.densityDuration,
);
const e = this._densT * this._densT * (3 - 2 * this._densT); // smoothstep
this.uniforms.uDensityFrac.value =
this._densFrom + (this._densTo - this._densFrom) * e;
}
this.lines.visible = this.uniforms.uDensityFrac.value > 1e-4; // tout masqué -> on éteint
Le smoothstep, lent au début et à la fin, rend l’apparition et la disparition également
progressives. La dernière ligne coupe carrément le rendu quand plus aucune goutte n’est
visible, pour ne pas dessiner une LineSegments entièrement transparente.
L’éclairage : la pluie ne brille pas la nuit
Dernier branchement, et il boucle avec le chapitre précédent. La pluie lit la direction du
soleil et en tire le même dayFactor que l’herbe :
// rain/fragment.glsl
// dayFactor : 1 quand le soleil est levé, 0 quand il passe sous l'horizon.
float dayFactor = smoothstep(-0.15, 0.05, uSunDirection.y);
float light = max(dayFactor, uAmbientLight);
gl_FragColor = vec4(uColor, vAlpha * light * uOpacity);
Les traits n’ont pas de couleur propre à éclairer, mais leur opacité, elle, dépend du jour. En
plein soleil ils accrochent la lumière et se voient bien ; la nuit, dayFactor tombe et il ne
reste que l’ambiant, donc l’averse devient à peine visible, ce qui est cohérent avec une scène
qui s’assombrit. L’opacité finale combine trois facteurs : vAlpha (la traînée tête vers
queue), light (le jour) et uOpacity, une opacité globale constante qui empêche les traits
d’être trop francs.
Ce que ça donne
La pluie de cette scène, c’est une seule LineSegments construite une fois et pilotée
entièrement par uniforms. La chute est un mod dans le vertex shader, désynchronisé par une
vitesse et une phase propres à chaque goutte. L’inclinaison dérive de la force du vent, dont la
direction arrive gratuitement par la référence partagée du chapitre 4. L’intensité est un
masque de densité que l’on fait varier en douceur, sans jamais reconstruire ni retirer les
gouttes au hasard. Et l’éclairage reprend le dayFactor du soleil pour que l’averse s’efface
la nuit.
Voici la scène complète, avec tous les curseurs de l’article réunis. L’averse est cette
LineSegments unique construite une fois : tirez sur l’intensité et vous ne faites que révéler
ou masquer des gouttes déjà là, poussez le vent et le trait s’incline en direct, avancez
l’heure et la pluie s’efface avec le soleil. Aucun de ces réglages ne reconstruit la géométrie.
Tout revient à la même discipline que le vent et le soleil : une donnée qu’on construit ou qu’on mute à un seul endroit, et des shaders qui la lisent. Ici, ça permet de faire tomber la pluie, de l’incliner et de la faire cesser sans jamais toucher à la géométrie, ce qui prépare la suite : brancher tout ça sur un vrai système de météo capable d’enchaîner le beau temps et la tempête.