Three.js From Zero · Article s4-08

S4-08 Ocean & Water

Season 4 · Article 08

Ocean & Water — Gerstner waves, FFT, and the foam on top

An ocean isn't a wiggling plane. It's a sum of waves with directional bias, sharpened crests, depth-dependent foam, and a tinted refraction underneath. We build each piece.

1. Why water is hard

Three lies that make "water" convincing:

  • The surface is not flat — it's a superposition of waves traveling in many directions at many wavelengths.
  • The lighting is wrong in obvious ways if you use Lambert. You need Fresnel (shallow angles → more reflection), absorption (depth tints the color), and caustics (focused light patterns on the floor).
  • The foam is where waves steepen past breaking, at shorelines, or where the surface compresses (negative Jacobian).

2. Gerstner waves — the workhorse

A sine wave moves the surface up and down. Gerstner (aka trochoidal) also moves it sideways to sharpen crests — peaks become pointed, troughs flatten. That's the shape real waves take.

// For each wave i with direction D_i, wavelength L_i, amplitude A_i, speed S_i
float k = 2π / L_i;
float f = k * dot(D_i, position.xz) - S_i * time;

position.xz += Q_i * A_i * D_i * cos(f);   // lateral pinch (Q = steepness)
position.y  +=      A_i *       sin(f);    // vertical rise

Sum 4–8 waves with varying directions and wavelengths. You get ocean. Compute the normal analytically from the derivatives — gives crisp, shader-cheap shading.

Steepness Q: Q = steepness / (k · A · numWaves). Tune just under 1 to keep waves non-self-intersecting.

3. Live demo — a small ocean

8 Gerstner waves over a plane. Toggle sun direction and wave chop to feel the shape.

4. FFT ocean — the AAA answer

Gerstner summing 8 waves is great. Real oceans have thousands of frequencies. FFT ocean (Tessendorf 2001) does it right:

  1. Sample a Phillips spectrum in frequency domain (h(k, t)). This is "how much energy is at each wavelength traveling in each direction."
  2. Update its phase per frame: h(k, t) = h(k, 0) · exp(i · ω(k) · t)
  3. Inverse FFT → height field in real space.

Games run a 256×256 or 512×512 FFT each frame on the GPU. Infinite detail, all frequencies coupled, no visible repeat.

// Phillips spectrum
float phillips(vec2 k, vec2 windDir, float windSpeed) {
  float L = windSpeed * windSpeed / G;
  float k2 = dot(k, k);
  float kdotw = dot(normalize(k), windDir);
  return A * exp(-1.0/(k2 * L*L)) / (k2*k2) * pow(kdotw, 2.0);
}

5. Fresnel — where reflection takes over

Look straight down into a pool — you see the bottom. Look at a lake across the surface — you see sky. That's Fresnel: reflection rises sharply at grazing angles.

float F = schlick(NdotV, 0.02);       // water F0 = 0.02
vec3 reflectCol = envMap(reflect(-V, N));
vec3 refractCol = waterColor * depthAbsorption;
color = mix(refractCol, reflectCol, F);

6. Depth-based water color

Light traveling through water is absorbed unequally — red goes first, blue lingers. Fetch the scene depth behind each water pixel, compute underwater distance, exponential-decay the direct-light color.

float depth = linearizeDepth(texture(uDepth, screenUv).r);
float dist = depth - fragViewZ;
vec3 absorbed = exp(-absorption * dist);     // absorption = vec3(0.3, 0.1, 0.05)
vec3 underwater = floorColor * absorbed;

7. Foam

Three places foam happens:

  1. Wave crests: sample a noise texture, modulated by (wave amplitude > threshold). Spray.
  2. Shorelines: where sceneDepth - waterDepth < smallValue. Soft intersection mask.
  3. Jacobian negative: areas where waves compress below flat-surface area. Authoritative for FFT oceans, where crests steepen and "break."

8. Three.js ships Water

Both a Water (from examples/jsm/objects/Water.js) and Water2 exist. They use a flat mirror-like surface with sampled normals + sun specular. Great for lakes. Not surf-quality, but three lines of code.

import { Water } from 'three/examples/jsm/objects/Water.js';

const water = new Water(new THREE.PlaneGeometry(1000, 1000), {
  textureWidth: 512,
  textureHeight: 512,
  waterNormals: loader.load('waternormals.jpg'),
  sunDirection: new THREE.Vector3(),
  sunColor: 0xffffff,
  waterColor: 0x001e0f,
  distortionScale: 3.7,
});
water.rotation.x = -Math.PI / 2;
scene.add(water);

9. Shoreline and caustics

Caustics are the focused light patterns on the pool floor. Cheap trick: sample a scrolling voronoi texture, tint by sun direction, project onto the floor.

Proper: render the scene from the sun's POV, compute where refracted rays converge, splat brightness there. That's what Yuksel's work does. Real-time-ish.

10. What ships where

Use caseTechnique
Lake, still pondThree.js Water + normal scroll
River, streamScrolling UVs + flowmap texture
Ocean surfaceGerstner 4-8 waves (this demo) OR FFT 256²
AAA open worldFFT 512² with foam Jacobian mask
FilmHoudini FLIP + extremely tall FFT cascade

11. Takeaways

  • Gerstner = sharp-crested sine. 4-8 of them gives you a believable ocean in 100 lines.
  • Fresnel mixes refraction (look down) with reflection (look across).
  • Depth-based absorption turns water the right color without tricks.
  • Foam at crests + shorelines makes it look wet, not just wavy.
  • FFT is the AAA upgrade when you need real spectrum and infinite detail.