Why a sine wave does not look like the sea
The obvious way to animate an ocean is to displace a flat mesh vertically by a sum of sines. It works, and it looks like jelly. Real swell has sharp crests and broad, flat troughs; a sine wave has crests and troughs of exactly the same shape. The reason is physical: in a deep-water wave the water itself does not travel with the wave, it moves in circles. A parcel of water rises, moves forward under the crest, sinks, and moves backward under the trough, returning almost exactly to where it started. Only the shape propagates.
That circular orbit is what a Gerstner wave encodes, and it is why the crests get pinched. It is not a graphics hack: the Gerstner (or trochoidal) wave was published by František Josef Gerstner in 1802 and is one of the few exact non-linear solutions of the equations for an inviscid, incompressible fluid of infinite depth.
The trochoid
A Gerstner wave displaces a vertex horizontally as well as vertically. In a vertex shader, for a flat grid point p, one wave of direction D (a unit vector in the horizontal plane), amplitude A, wavenumber k and angular frequency ω:
float phase = k * dot(D, p.xz) - omega * time; vec3 disp; disp.xz = -D * A * sin(phase); // horizontal — this is what sharpens crests disp.y = A * cos(phase); // vertical position = p + disp;
Take away the horizontal term and you are back to a sine surface. Keep it and each vertex traces a circle of radius A, gathering vertices towards the crest and spreading them out in the trough. The result is a trochoid — the curve traced by a point on a rolling circle — and it is exactly the profile you see in a photograph of ocean swell.
The parameters are tied together by the dispersion relation for deep water, which says long waves travel faster than short ones:
k = 2π / L // L = wavelength omega = sqrt(g * k) // g = 9.81 m/s² (deep water) c = omega / k = sqrt(g / k) // phase speed
Choosing ω freely instead of deriving it from k is the single most common mistake in a hand-rolled ocean shader: the waves then all travel at the same speed, the spectrum never separates, and the surface looks like a mechanically driven wave pool rather than a sea.
Steepness, and the wave that eats itself
Amplitude alone is not the right control. The meaningful quantity is steepness, S = A · k. At S = 0 the surface is flat; as S grows the crests sharpen. At S = 1 the crest becomes a perfect cusp with a 0° angle, and beyond S = 1 the surface self-intersects — the mesh folds through itself and you get an unmistakable pinched, twisted artefact along the crest lines. Gerstner waves have no notion of breaking; they just turn inside out.
So authoring is done in steepness, not amplitude, and the total is budgeted across the whole wave train:
A_i = S_i / k_i // steepness → amplitude, per wave
Σ (A_i * k_i) < 1 // keep the SUM below 1 across all waves
// (in practice budget ~0.6–0.8 for safety)
For reference, real ocean waves break at a steepness far below the mathematical limit — the classical Stokes limit corresponds to a crest angle of 120°, not a cusp — so a physically plausible sea never approaches S = 1 anyway. A convincing ocean is typically four to eight wave trains: one or two long, low swells carrying most of the energy, and several short, steeper waves with directions scattered around the wind direction, each with its own wavelength, steepness, phase and speed derived from the dispersion relation.
Normals, foam and Fresnel
Because the vertex is displaced in three axes, you cannot take the normal from a height field. You can differentiate the displacement analytically — the derivative of a sum of sines is a sum of cosines, so the tangent and binormal come out in closed form in the same shader, at no extra sampling cost:
// per wave, accumulate into tangent T and binormal B
T += vec3(-D.x*D.x * (S * sin(phase)), D.x * (S * cos(phase)),
-D.x*D.y * (S * sin(phase)));
B += vec3(-D.x*D.y * (S * sin(phase)), D.y * (S * cos(phase)),
-D.y*D.y * (S * sin(phase)));
normal = normalize(cross(B, T));
Foam is placed where the surface is being compressed — where the horizontal displacement squeezes vertices together, i.e. where the Jacobian of the displacement map drops towards zero. That happens exactly at the sharp crests, which is where whitecaps appear in reality, so a foam mask driven by the Jacobian (or, more cheaply, by the vertical component of the displacement combined with steepness) lands in the right place for free.
Finally, water is a Fresnel surface. Looking straight down you see mostly refraction — the colour of the water body and whatever is below it. Looking towards the horizon at a grazing angle you see almost pure reflection of the sky. Schlick's approximation captures the transition in one line, with an index of refraction of ~1.33 giving F₀ ≈ 0.02 for water:
float F = F0 + (1.0 - F0) * pow(1.0 - max(dot(N, V), 0.0), 5.0); color = mix(refractedColor, reflectedSky, F);
Without Fresnel, an ocean looks like a sheet of painted plastic no matter how good the geometry is. With it, the same mesh reads as water immediately.
Gerstner vs the FFT ocean
The alternative is the FFT ocean (the Tessendorf method, used in film and in most AAA water). Instead of summing a handful of explicit wave trains, it fills a spectrum in the frequency domain — usually a Phillips or JONSWAP spectrum fitted to a wind speed and direction — and takes an inverse FFT to get a height field, plus a second transform for the horizontal choppiness displacement, which is precisely the Gerstner term reintroduced.
Gerstner (sum of N waves) FFT ocean (Tessendorf) ───────────────────────── ──────────────────────────────── O(N) per vertex O(M² log M) per frame, per grid 4–8 waves in practice thousands of frequencies at once directly art-directable controlled through a wind spectrum no CPU/compute pass needed needs an FFT pass (compute/CPU) tiles trivially / infinite tiles with a visible period cheap on mobile heavier, but far richer detail
The trade-off is clear. Gerstner is a pure vertex-shader effect: no precomputation, no texture round-trip, trivially cheap, and every wave is a named parameter an artist can tune. Its ceiling is low — with more than a dozen waves the cost grows linearly and the surface still lacks the fine, chaotic high-frequency detail that a full spectrum gives. FFT captures the whole spectrum at once but needs a compute pass and a tiling texture, and it is harder to art-direct because you steer it through wind rather than through individual waves.
The ocean simulation on this site is Gerstner: a handful of trochoidal wave trains summed in the vertex shader with the deep-water dispersion relation, steepness budgeted below the self-intersection limit, analytic normals, a Jacobian-driven foam mask and a Schlick–Fresnel blend between the sky reflection and the water colour. It runs entirely on the GPU in a single pass, which is exactly why it holds 60 fps on a phone.
Frequently asked questions
What is the difference between a Gerstner wave and a sine wave?
A sine wave only displaces the surface vertically, so crests and troughs have the same shape. A Gerstner wave also displaces it horizontally, making each vertex travel in a circle — the physically correct orbital motion of deep water. That gathers vertices at the crest and spreads them in the trough, giving the sharp crests and flat troughs of real swell.
Why does my ocean mesh fold through itself?
The total steepness is above 1. Steepness is amplitude times wavenumber (S = A·k), and at S = 1 the crest becomes a cusp; beyond it the surface self-intersects. Sum A·k over all your wave trains and keep the total below roughly 0.8.
Should I use Gerstner waves or an FFT ocean?
Gerstner for a cheap, art-directable, single-pass vertex shader with a handful of waves — ideal for mobile and for stylised water. An FFT (Tessendorf) ocean for photoreal water with a full wind-driven spectrum, at the cost of a compute pass, a tiling texture and less direct control.
Try it live
Everything above runs in your browser — open Interactive Ocean and change the parameters while it is running. Nothing is installed, nothing is uploaded, the whole model lives in one tab.
▶ Open Interactive Ocean simulation