Shader Lab
25.Gerstner waves

Gerstner waves

Arc 5 · 3D Geometry · Lesson 25 of 35
coordinate signal color
0.85

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

"tighter waves" steepness up "softer, lapping / calmer sea" steepness down "wave breaking / cresting" steepness past 1.0 "flat pond ripples" steepness to 0, low amplitude "stormy / choppy sea" high steepness + more waves at varied angles
Meta-phrase you gain here: "Gerstner, not sine" — the one-phrase brief for anything that should look like real water. Before this lesson you'd say "make waves"; after, you can say "Gerstner at steepness 1.0 with five waves" and a shader developer knows exactly what to build.
Combines with: L24 (the underlying vertex-displacement mechanism), L26 (when you need water to look deep without moving more geometry), tessellation in production (gives Gerstner the mesh density it needs for fine detail). Later: PBR water (Arc 4) + SSR + foam mask are the full recipe.
the vertex + fragment shaders running above
// 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);
}