Gerstner waves
Purpose
A pure vertical sine wave produces a bedsheet, not an ocean — real ocean waves also pull vertices horizontally toward the crest, and that horizontal pull is the entire difference between "water" and "fabric."
Key insight
If you just move vertices up and down with sin, you get a soft, symmetric ripple — peak and trough look like mirror images. Real ocean waves don't look like that. Their peaks are sharp and their troughs are broad. Gerstner noticed in 1802 why: particles at the surface don't only rise — they also shuffle toward the crest as the wave passes.
On screen, this means vertices near a peak pile up under it, pinching the crest into a sharp ridge while the valleys broaden. The math is the same sine wave, just applied to two axes: vertical for height, and horizontal in the wave's travel direction for the pull. Sum four or more such waves at different angles and speeds, and you have the look every ocean shader from Pixar to Sea of Thieves uses.
This is the three-layer model recursing on itself: a signal-like sum of sines drives the coordinate change of each vertex's position. Signals can live inside the coordinate stage.
Break it
Pull steepness to 0. You get pure vertical sine waves. Look at it for five seconds — this is a kids' drawing of water, or a picnic blanket being shaken. Teaches by contrast: the horizontal pull is 100% of what makes this read as water, not fabric. Nothing about the vertical motion alone sells it. Now push back to 1.0. The difference is the whole insight of Gerstner.
Push steepness past 1.2. Crests start to overshoot themselves — vertices loop back over the peak and the mesh self-intersects. That self-intersection is a breaking wave in nature, but in a vertex shader it's a visual glitch. The slider lets you feel the knife-edge between "ocean" and "broken."
Direct Claude
// VERTEX STAGE — pre-fragment coordinate layer.
// Four Gerstner waves summed, each pulling vertices both UP and TOWARD the crest.
uniform float u_time;
uniform float u_steep;
varying float vHeight;
varying vec3 vNormal;
// One Gerstner wave. direction = horizontal travel; steep = crest-pull ratio.
// Returns the (x, y, z) offset for the vertex's rest position.
vec3 gerstner(vec2 pos, vec2 direction, float wavelength, float steep, float speed, float t) {
float k = 6.2831853 / wavelength; // spatial frequency
vec2 d = normalize(direction);
float phase = k * dot(d, pos) - speed * t;
float amp = steep / k; // height scales with steepness / k
float c = cos(phase), s = sin(phase);
return vec3(d.x * amp * c, amp * s, d.y * amp * c);
}
void main() {
vec2 p = position.xz;
vec3 offset = vec3(0.0);
// Four stacked waves at different angles, wavelengths, speeds.
// Total steepness divided across them so sum doesn't blow up.
float s = u_steep * 0.35;
offset += gerstner(p, vec2( 1.0, 0.2), 2.2, s, 1.4, u_time);
offset += gerstner(p, vec2( 0.7, -0.7), 1.6, s, 1.8, u_time);
offset += gerstner(p, vec2(-0.3, 1.0), 1.1, s, 2.2, u_time);
offset += gerstner(p, vec2(-0.9, -0.3), 0.7, s, 2.8, u_time);
vec3 displaced = position + offset;
vHeight = offset.y;
vNormal = vec3(0.0, 1.0, 0.0); // stale normal — flat shading, note the limitation
gl_Position = projectionMatrix * modelViewMatrix * vec4(displaced, 1.0);
}
// FRAGMENT STAGE — simple deep/shallow water tint by height.
// Stale flat normal → no real specular. That's a note, not a bug.
varying float vHeight;
varying vec3 vNormal;
void main() {
vec3 light = normalize(vec3(0.3, 0.9, 0.5));
float shade = max(0.0, dot(normalize(vNormal), light));
vec3 deep = vec3(0.06, 0.18, 0.30);
vec3 shallow = vec3(0.35, 0.68, 0.78);
vec3 crest = vec3(0.92, 0.96, 1.00);
float t = clamp(vHeight * 1.4 + 0.5, 0.0, 1.0);
vec3 col = mix(deep, shallow, t);
col = mix(col, crest, smoothstep(0.55, 0.85, t));
col *= 0.55 + 0.55 * shade;
gl_FragColor = vec4(col, 1.0);
}