Three.js From Zero · Article s0-10

Raging Sea

Raging Sea is Article s0-10 of Three.js From Zero, a MasterAllArts free interactive lesson for artists learning creative 3D on the web.

← Three.js From ZeroS0-10 · Quick Wins

Season 0 · Article 10 · Quick Wins

A vertically-displaced plane shader: layered Gerstner waves, foam at the crests, color gradient from trough to peak, sun reflection at the horizon. The "shader tutorial classic" — completes Season 0.

— fps

Gerstner waves in one paragraph

A sine wave moves a point up and down. A Gerstner wave moves it up, down, AND forward — the peaks lean in the direction of wave travel. Stack multiple Gerstner waves at different directions, amplitudes, and wavelengths, and you get ocean. The Disney short Moana shipped on this exact technique.

Step 1 — High-density plane

const sea = new THREE.Mesh(
  new THREE.PlaneGeometry(10, 10, 256, 256),    // 256² = 66k vertices
  seaMaterial,
);
sea.rotation.x = -Math.PI / 2;
scene.add(sea);

You need every vertex you can afford for smooth wave silhouettes. 256² is the sweet spot for desktop; drop to 128² on mobile. The waves happen entirely in the vertex shader — none of this is CPU work.

Step 2 — One wave function

vec3 gerstner(vec2 pos, vec2 dir, float amp, float wavelength, float speed, float time) {
  float k = 6.28318 / wavelength;       // wave number
  float c = speed * k;                   // speed → phase rate
  float f = k * (dot(dir, pos)) - c * time;
  return vec3(
    dir.x * amp * sin(f),     // X displacement (lean)
    amp * cos(f),             // Y displacement (height)
    dir.y * amp * sin(f)      // Z displacement (lean, the other axis)
  );
}

Five parameters per wave: position to evaluate, direction (a 2D unit vector), amplitude (height), wavelength, speed. The output is a displacement vector you add to the vertex position.

Step 3 — Stack three waves

const vertexShader = /*glsl*/`
  varying float vHeight;
  uniform float uTime;
  // ... gerstner function from above ...
  void main() {
    vec2 p = position.xz;
    vec3 d = vec3(0.0);
    d += gerstner(p, normalize(vec2(1.0, 0.6)),   0.18, 2.5,  1.2, uTime);
    d += gerstner(p, normalize(vec2(-0.5, 1.0)),  0.10, 1.4,  1.6, uTime);
    d += gerstner(p, normalize(vec2(0.3, -1.0)),  0.06, 0.7,  2.0, uTime);

    vec3 newPos = position + d;
    vHeight = newPos.y;
    gl_Position = projectionMatrix * modelViewMatrix * vec4(newPos, 1.0);
  }
`;

Three waves at decreasing amplitudes and wavelengths is the magic formula for "ocean that looks real but doesn't kill the GPU." The amplitudes form a power series (0.18, 0.10, 0.06) so you don't get a stack of equally-loud waves cancelling each other.

Pitfall. When you sum Gerstner waves, the result is not normalized to a real water surface. For perfect physical accuracy you need to compute the per-wave Jacobian and use that to derive normals. For a tutorial demo, fake the normal in the fragment shader using a derivative — covered below.

Step 4 — Color gradient

const fragmentShader = /*glsl*/`
  varying float vHeight;
  void main() {
    vec3 trough = vec3(0.0, 0.15, 0.3);
    vec3 peak = vec3(0.6, 0.85, 1.0);
    float t = smoothstep(-0.3, 0.3, vHeight);
    vec3 col = mix(trough, peak, t);

    // Foam at high crests
    float foam = smoothstep(0.20, 0.32, vHeight);
    col = mix(col, vec3(1.0), foam);

    gl_FragColor = vec4(col, 1.0);
  }
`;

The trough/peak gradient sells the depth illusion. The foam threshold gives you the iconic whitecap rim on top of every wave. Tighten the foam threshold (e.g., 0.25 → 0.32) for sparser foam, widen for stormy seas.

Step 5 — Sun reflection

Without a real environment cube, you can fake the sun's specular highlight directly:

// In the fragment shader, after computing color:
vec3 sunDir = normalize(vec3(0.5, 0.6, 0.4));
float spec = pow(max(dot(normal, sunDir), 0.0), 32.0);
col += vec3(1.0, 0.85, 0.4) * spec * 0.8;

Where normal comes from? Either pass it from the vertex shader (cleaner, requires re-computing post-displacement) or use dFdx/dFdy in the fragment shader to derive it from the depth gradient. The latter is one line:

vec3 normal = normalize(cross(dFdx(vWorldPos), dFdy(vWorldPos)));

Common first-time pitfalls

"Waves look frozen — no motion." Forgot to set seaMaterial.uniforms.uTime.value = t * 0.001 in the loop. Three.js does NOT update uniforms automatically.
"Surface is flat — no displacement." Vertices are only displaced if the plane has segments. 1×1 plane is two triangles, four corners — no waves. Check that you have PlaneGeometry(10, 10, 256, 256).
"Waves all go the same direction." Wave directions need to be different normalized 2D vectors. vec2(1.0, 0.0), vec2(0.7, 0.7), vec2(-0.5, 1.0) — different headings, otherwise they pile up into one big wave.
"Foam covers the whole surface." Foam threshold too low. smoothstep(0.20, 0.32, vHeight) is the starting point; if vHeight peaks at 0.3, foam should start at ~0.25.

Exercises

  1. Add a boat. A glTF model or just a box. Each frame, compute the wave height at the boat's XZ position (run the Gerstner function in JS too — same code) and set boat.position.y to it. Boat bobs in the waves.
  2. Storm controls. Two sliders: wind strength scales all wave amplitudes; wind direction shifts all wave directions. Calm sea, choppy sea, hurricane on demand.
  3. Reflection probe. Add a real HDRCubeTextureLoader environment map. Mirror it on the water surface using the normal you computed. Now you have photo-real water (see S4-08).