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.
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.
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.
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
seaMaterial.uniforms.uTime.value = t * 0.001 in the loop. Three.js does NOT update uniforms automatically.PlaneGeometry(10, 10, 256, 256).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.smoothstep(0.20, 0.32, vHeight) is the starting point; if vHeight peaks at 0.3, foam should start at ~0.25.Exercises
- 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.
- Storm controls. Two sliders: wind strength scales all wave amplitudes; wind direction shifts all wave directions. Calm sea, choppy sea, hurricane on demand.
- Reflection probe. Add a real
HDRCubeTextureLoaderenvironment map. Mirror it on the water surface using the normal you computed. Now you have photo-real water (see S4-08).
SEASON 1 →
You've finished the Quick Wins season. S1-01 — Foundations starts the formal curriculum. The fundamentals that make everything you just built make sense.