SCENE.LOG
scenenode[03]
2026-06-13 · 7 min

The environment: wind

stylized nature shaders three.js

In the previous chapter, I scattered tens of thousands of grass blades over the terrain. But once placed, they’re lifeless: a flat color, stiff, rooted in place. For the scene to breathe, it needs at least one thing: the grass has to sway. That’s the job of the wind.

We’ll follow the data end to end, from the Wind class in JavaScript all the way to the vertex displaced in the vertex shader.

One source of truth, many consumers

The wind is a full-fledged Wind class. It owns the data, updates it, and exposes it. The grass is only a consumer: it receives a reference to the wind in its constructor.

export default class Grass extends WorldComponent {
  constructor(terrain, wind, sun, groundShadow) {
    super();
    this.wind = wind; // the source of the sway
    // ...
  }
}

The wind’s core data comes down to two values: a direction in the horizontal plane (a Vector2 in the XZ plane) and a strength (a plain number).

// Wind.js
this.params = {
  angle: 0, // direction in the XZ plane (radians)
  strength: 0.15, // sway amplitude
};
// Mutated in place by updateWind(), never reassigned.
this.direction = new THREE.Vector2(
  Math.cos(this.params.angle),
  Math.sin(this.params.angle),
);

The grass wires both values into its material’s uniforms:

this.uniforms = {
  uWindStrength: { value: this.wind.params.strength },
  uWindDirection: { value: this.wind.direction }, // same object as Wind.direction
  uBladeHeight: { value: this.params.BLADE_H }, // will be used to correct the normal
  // ...
};

Direction vs strength: reference vs copy

This is where the whole update mechanism plays out, and it depends entirely on the data’s type.

this.wind.direction is an object (Vector2). In JavaScript, an object is copied by reference: uWindDirection.value and Wind.direction point to the same Vector2 in memory. When the wind turns, Wind doesn’t recreate the object, it mutates it in place:

// Wind.js
updateWind() {
  this.direction.set(Math.cos(this.params.angle), Math.sin(this.params.angle)); // mutates .x/.y
  this.trigger('change');
}

Since the uniform points to that same object, the new direction is visible on the shader side immediately, without a single line to write in the grass.

The strength, on the other hand, is a number. Primitives are copied by value: the moment we write value: this.wind.params.strength, we copy 0.15 once and for all, and the copy is no longer tied to the source. If the strength changes afterwards, the uniform would never know. So it has to be copied over explicitly. Since the strength changes only rarely, I use the Observer pattern: the wind notifies, the grass recopies.

// Grass.js, setSubscriptions()
this.wind.on("change", () => {
  this.uniforms.uWindStrength.value = this.wind.params.strength; // manual recopy
});

The sway, in the vertex shader

All of the grass’s life now happens on the GPU. Each blade is made of 5 vertices (base, middle, tip) and the wind has to move them differently: the base stays rooted in the ground, the tip moves the most. For that, we need to know “at what height” each vertex sits along the blade.

That information is baked into the geometry, as a color attribute, when the blade is built:

// Grass.js, curvature mask: one weight per vertex
const windBladePower = new Float32Array([
  0,
  0,
  0,
  0,
  0,
  0, // base   -> 0 (fixed)
  0.5,
  0.5,
  0.5,
  0.5,
  0.5,
  0.5, // middle -> 0.5
  1,
  1,
  1, // tip    -> 1 (moves the most)
]);
geometry.setAttribute("color", new THREE.BufferAttribute(windBladePower, 3));

So color.x is 0 at the base, 1 at the tip. Neat trick: the same attribute also doubles as a vertical gradient for the color in the fragment shader, one piece of data, two uses.

In the vertex shader, the displacement is computed in three steps:

// 1. The mask: how much THIS vertex is allowed to move (0 base -> 1 tip)
float windMask = color.x;

// 2. The oscillation: a wave that depends on time AND on the blade's world position
float wave = sin(uTime * 1.5 + modelPosition.x * 0.5 + modelPosition.z * 0.5);

// 3. The offset: oriented by the global direction, scaled by mask x strength
vec2 windOffset = uWindDirection * (wave * windMask * uWindStrength);
modelPosition.x += windOffset.x;
modelPosition.z += windOffset.y; // .y of the Vector2 -> world Z axis

Three ideas to keep in mind:

  • windMask guarantees the base never moves (x 0) and the tip moves fully (x 1). The blade bends, it doesn’t slide.
  • wave oscillates over time (uTime), but its argument also depends on the blade’s world position (modelPosition.x, .z). The result: two neighboring blades are slightly out of phase, and the wave travels across the field instead of moving everyone at once. That’s what gives the impression of a breeze sweeping the meadow.
  • uWindDirection orients the offset. By multiplying by the Vector2, the wind tilts the grass in any direction, and since this uniform is shared by reference, turning it in the debug panel makes every blade lean live.

uTime is the only thing Grass.update() pushes for the wind, every frame:

update() {
  this.uniforms.uTime.value = this.time.elapsed; // animates the wave
  // ...
}

Normal correction

Displacing the vertex is enough to see the grass sway, but not to light it correctly. By bending the blade, we sheared it: its surface is no longer vertical, so its normal shouldn’t keep pointing as if nothing happened. If we leave the original normal, every blade catches the light as if it were straight, and the sway looks flat, like cardboard.

The wind offsets X in proportion to windMask, that is, in proportion to height. The offset therefore grows linearly along the blade: it’s a constant slope.

float scaleY      = length(instanceMatrix[1].xyz); // Y scale of THIS instance
float worldHeight = scaleY * uBladeHeight;          // the blade's real height in the world
float windSlope   = wave * uWindStrength / worldHeight; // d(offset) / d(height)

This is where uBladeHeight comes into play, and why the grass passes it as a uniform. The slope is “how much X shifts per unit of height”. So we have to divide the offset by the blade’s true height in the world, instance scale included (each blade was placed with a random scale.y for variety).

That slope is used to tilt the normal, in the wind’s direction:

// Simple case (wind along X): n.y -= slope * n.x
// Generalized to an arbitrary direction:
modelNormal.y -= windSlope * (uWindDirection.x * modelNormal.x + uWindDirection.y * modelNormal.z);
modelNormal = normalize(modelNormal);

The normal tilts exactly like the blade’s surface. As a result, when a blade leans toward the light, it brightens; when it turns away, it darkens, and the sway gains volume instead of staying a mere slide of vertices.

The result

The wind, in this scene, is data mutated in a single place and read everywhere. The Wind class owns a Vector2 and a number; the grass wires both into its uniforms and lets copy-by-reference do the work for the direction, with a simple subscription for the strength. The rest is shader work: a curvature mask baked into the geometry, a wave phase-shifted in space, and a corrected normal so the light follows the motion.

Once this wiring is in place, receiving the right source in the constructor and honoring the “I mutate, I never reassign” contract, making it reusable for other elements of the scene costs almost nothing.

← back to scene