The environment: the rain
In the previous chapter, the sun decided the lighting of the scene. Now I want to add rain, and for that I took my cue from Peter Adams’ article Cheap, Beautiful Rain in Three.js.
The idea is simple: you build a few thousand small segments once and for all, spread inside a
box above the island, and you never touch that geometry again. Each drop falls on its own in
the vertex shader, a mod() bringing it back up when it finishes its run, and two uniforms are
then enough to drive everything: one for the number of visible drops, one for the tilt. That
last point is what I add to the original article: sensitivity to the wind.
One construction, everything in the shader
The rain is a single LineSegments: thousands of small streaks drawn in one call. I build it
once, at the maximum density the scene will ever display, and I never touch it again:
// Rain.js
const volume = this.terrain.params.size ** 2 * this.params.rainHeight;
const count = Math.round(volume * this.maxDensity); // ~2458 streaks for size 32, height 20
maxDensity is 0.12, that is the density of the heaviest weather preset, the storm. It is the
ceiling the geometry has to be able to absorb: every other kind of weather will only show a
fraction of it.
The number of streaks is frozen at construction. Everything that gives the shower life (the fall, the tilt, the intensity) then happens in the vertex shader, driven by uniforms. It is a choice that costs a bit of memory upfront (you allocate the drops of the worst storm, even in dry weather) but that makes everything else free: changing the wind or making the rain fall requires no rebuild, just a new uniform value.
This decision comes straight from a trap I had run into before: if you regenerate the drop positions every time the intensity changes, you randomly remove new points each time, and the shower flickers. By freezing the geometry, each drop keeps its place forever, and the intensity becomes a simple matter of “which one do I show.”
One drop, two vertices
A rain streak is a segment, so two vertices: a head and a tail. Both start from exactly the same point at construction:
const i6 = i * 6; // 6 components per segment: 2 vertices x 3 coordinates
// Head AND tail start from the SAME point; the tail is offset in the 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;
Why not place the tail directly above the head at construction? Because the length and orientation of the streak depend on the wind, and the wind changes. If I froze the tail here, I would have to rebuild on every gust. By starting from the same point, I let the shader pull the tail apart according to the wind of the moment.
One attribute distinguishes the two vertices, and a few others carry the characteristics specific to each drop. Three of them are drawn at random at 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 is the drop’s own falling speed, between 8 and 14 units per second, so that they don’t
all descend at the same pace.
offset is a phase shift, not to be confused with the starting position (already drawn at
random inside the box, just above). It tells where the drop is in its fall cycle at startup:
without it, all the drops would begin their run at the same instant. We’ll see in the next
section that it is enough to add it to the time to get that result.
rand, finally, has nothing to do with the fall: it is a number between 0 and 1, drawn once
and never touched again, that will serve as the threshold for the density mask at the end of
the chapter. What matters here is that it is frozen for good.
These values describe a whole drop, but the attributes are per vertex: they must therefore be
written twice, once for the head and once for the tail. v = i * 2 gives the head’s index,
v + 1 the tail’s:
const v = i * 2; // head vertex index; the tail is right after, at v + 1
alphas[v] = 1.0;
alphas[v + 1] = 0.0; // opaque head -> transparent tail
isTail[v] = 0.0;
isTail[v + 1] = 1.0; // 0 = head, 1 = tail
speeds[v] = speed;
speeds[v + 1] = speed; // IDENTICAL head+tail
offsets[v] = offset;
offsets[v + 1] = offset; // IDENTICAL head+tail
rands[v] = rand;
rands[v + 1] = rand; // IDENTICAL head+tail
The speed, the phase shift and the random draw are identical on the head and the tail. That is what keeps the streak rigid. If the head and the tail fell at different speeds, the segment would stretch or compress as it fell. By giving them the same values, the two move as one block, and the streak keeps its length.
aAlpha, on the other hand, is deliberately different: 1 at the head, 0 at the tail. That is
what gives the little trail fading toward the top, like a drop streaking down.
The recycled fall: a mod() and nothing else
Here is the heart of the effect, and it is surprisingly short. Each drop falls, then teleports
back to the top to start over, endlessly. It all fits in a mod:
// rain/vertex.glsl
float displacement = mod(uTime * aSpeed + aOffset, uFallDistance);
uTime * aSpeed makes the fall distance grow with time, at the drop’s own speed. The
mod(..., uFallDistance) is the trick: as soon as the drop has covered the whole height of the
column, the remainder of the division goes back to zero and the drop reappears at the top. It
is a sawtooth, a cycle that climbs and drops back endlessly, without a single if or any array
management on the JavaScript side.
Two parameters break the synchronization, without which all the drops would fall at the same pace and at the same instant, which would give the effect away immediately:
aSpeedgives each drop a speed drawn between 8 and 14 units per second. The drops don’t all descend at the same pace.aOffsetshifts each drop’s phase in the cycle. At time zero, they are not all at the top: some have already gone halfway.
What’s left is to know in which direction to apply this displacement. It doesn’t simply point down: the wind determines it, and that is the subject of the next section.
The wind tilts the rain
This is where chapter 4 comes back into play. The Wind class holds a Vector2 for its
direction, mutated in place, and the rain hooks into it by reference exactly the way the grass
did:
// Rain.js, setMesh()
uWindStrength: { value: this.wind.params.strength },
uWindDir: { value: this.wind.direction }, // same Vector2 as Wind, shared by reference
As with the grass, the direction propagates on its own: Wind mutates its vector, the rain
reads the same object, no line to write. The strength, on the other hand, is a number, so a
frozen copy: sharing it by reference is impossible, you have to be notified when it changes.
That is exactly what Wind emits, and the rain subscribes to it just like the grass:
// Rain.js
setSubscriptions();
this.wind.on("change", () => {
this.uniforms.uWindStrength.value = this.wind.params.strength;
});
From that strength, the shader computes a tilt. The physical idea is simple: a drop has a vertical falling speed and the wind adds a horizontal speed; the angle of the streak is that of the sum of the two.
// tilt = atan(horizontal speed / falling speed)
float tilt = atan(uWindStrength * uWindFactor);
float horiz = uStreakLength * sin(tilt); // horizontal part of the streak
float vert = uStreakLength * cos(tilt); // vertical part of the streak
With no wind, tilt is zero: the rain falls straight. The stronger the wind, the more the
streak lies down. uWindFactor (6 in my settings) has no physical pretension, it is a
sensitivity factor I tuned by eye so that the tilt is legible without being cartoonish.
This tilt serves two purposes. First, to offset the tail, in the direction opposite the wind so that the trail streams backward:
// aIsTail is 0 on the head (still) and 1 on the tail (offset).
vec3 pos = position + aIsTail * vec3(-horiz * uWindDir.x, vert, -horiz * uWindDir.y);
Then, to orient the falling direction left pending above, so that the drops don’t fall only downward but also in the direction of the wind:
// Falling direction: down (-cos) + toward the wind (+dir * sin). Already unit-length.
vec3 fallDir = vec3(sin(tilt) * uWindDir.x, -cos(tilt), sin(tilt) * uWindDir.y);
vec4 modelPosition = modelMatrix * vec4(pos, 1.0);
modelPosition.xyz += fallDir * displacement;
Tilted streak and oblique fall stay consistent, since they derive from the same tilt. And
because all of this lives in the shader, turning the wind in the debug panel tilts the shower
live, without a single re-draw.
Density: a single uniform to dose everything
That leaves the third constraint, the most interesting one: making the rain start and stop
smoothly. The geometry already contains all the drops of the maximum storm. So it is enough to
choose how many of them are shown, and that is the role of a single uniform, uDensityFrac, a
fraction between 0 and 1.
Each drop carries a stable random threshold, aRand, drawn once at construction. The shader
keeps the drop only if its threshold falls below the current fraction:
// Density mask: visible if aRand < uDensityFrac, otherwise alpha 0.
float visible = step(aRand, uDensityFrac);
vAlpha = aAlpha * visible;
That’s the whole mechanism. At uDensityFrac = 0, no drop passes the test, it isn’t raining.
At 0.5, half the drops (those whose threshold is under 0.5) are visible. At 1, all of them.
And because each drop has a fixed threshold, increasing the fraction only reveals new ones
without ever moving the old ones: the rain thickens gradually instead of flickering. That is
exactly the problem the one-time construction set out to solve, and it is solved in a single
line of GLSL.
Varying that fraction over time gives the shower its fade. When the weather system changes its target intensity, I don’t jump to the new value, I interpolate it over a fixed duration with an 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; // all masked -> switch it off
The smoothstep, slow at the start and at the end, makes the appearance and the disappearance
equally gradual. The last line cuts the rendering outright when no drop is visible anymore, so
as not to draw a fully transparent LineSegments.
Lighting: the rain doesn’t glow at night
One last connection, and it loops back to the previous chapter. The rain reads the sun’s
direction and derives from it the same dayFactor as the grass:
// rain/fragment.glsl
// dayFactor: 1 when the sun is up, 0 when it drops below the horizon.
float dayFactor = smoothstep(-0.15, 0.05, uSunDirection.y);
float light = max(dayFactor, uAmbientLight);
gl_FragColor = vec4(uColor, vAlpha * light * uOpacity);
The streaks have no color of their own to light, but their opacity does depend on the time of
day. In full sun they catch the light and show clearly; at night, dayFactor drops and only
the ambient term remains, so the shower becomes barely visible, which is consistent with a
scene going dark. The final opacity combines three factors: vAlpha (the head-to-tail trail),
light (the day) and uOpacity, a constant global opacity that keeps the streaks from being
too harsh.
What it looks like
The rain in this scene is a single LineSegments built once and driven entirely by uniforms.
The fall is a mod in the vertex shader, desynchronized by a speed and a phase specific to each
drop. The tilt derives from the wind’s strength, whose direction arrives for free through the
shared reference from chapter 4. The intensity is a density mask that we vary smoothly, without
ever rebuilding or randomly removing drops. And the lighting reuses the sun’s dayFactor so
that the shower fades away at night.
Here is the complete scene, with all the sliders from the article gathered together. The shower
is that single LineSegments built once: pull on the intensity and you only reveal or hide
drops that are already there, push the wind and the streak tilts live, advance the hour and the
rain fades with the sun. None of these settings rebuilds the geometry.
It all comes back to the same discipline as the wind and the sun: a piece of data you build or mutate in a single place, and shaders that read it. Here, it lets the rain fall, tilt and stop without ever touching the geometry, which sets up what comes next: wiring all of this into a real weather system able to chain fair weather and storm.