Three.js From Zero · Article s4-08
S4-08 Ocean & Water
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.
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:
- Sample a Phillips spectrum in frequency domain (h(k, t)). This is "how much energy is at each wavelength traveling in each direction."
- Update its phase per frame:
h(k, t) = h(k, 0) · exp(i · ω(k) · t) - 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:
- Wave crests: sample a noise texture, modulated by (wave amplitude > threshold). Spray.
- Shorelines: where
sceneDepth - waterDepth < smallValue. Soft intersection mask. - 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 case | Technique |
|---|---|
| Lake, still pond | Three.js Water + normal scroll |
| River, stream | Scrolling UVs + flowmap texture |
| Ocean surface | Gerstner 4-8 waves (this demo) OR FFT 256² |
| AAA open world | FFT 512² with foam Jacobian mask |
| Film | Houdini 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.