SCENE.LOG
scenenode[05]
2026-06-18 · 8 min

L'environnement : la pluie

nature stylisée shaders three.js

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 :

  • aSpeed donne à chaque goutte une vitesse tirée entre 8 et 14 unités par seconde. Les gouttes ne descendent pas toutes à la même allure.
  • aOffset dé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.

← retour à la scène