Few things in a game carry the same immediate visual weight as water. A well-simulated ocean can make a pirate adventure feel genuinely threatening; a shimmering lake gives an open world its sense of place. Water is also a perennial technical benchmark — studios boast about their wave systems the same way they once boasted about bump-mapping or HDR. Water rendering combines classical physics, signal processing, and GPU techniques in ways worth understanding.
The Physics of Waves
An ocean wave is energy propagating through a medium, not water itself travelling across the sea. Wind transfers energy to the surface, generating patterns of crests and troughs that can travel thousands of kilometres before dissipating on a beach. Simulating that energy transfer rigorously is computationally impractical in real time, so game developers instead approximate the statistical appearance of ocean waves — and the results are remarkably convincing.
The simplest mathematical model is a sinusoidal wave. A single wave travelling along the x-axis looks like:
$$y(x, t) = A \sin\!\left(kx - \omega t + \phi\right)$$
where $A$ is amplitude, $k = 2\pi/\lambda$ is the wave number ($\lambda$ being wavelength), $\omega = 2\pi f$ is angular frequency, and $\phi$ is a phase offset. Summing many such waves in different directions and at different scales produces richer-looking water:
$$h(\vec{x}, t) = \sum_{i=1}^{N} A_i \sin\!\left(\vec{k_i} \cdot \vec{x} - \omega_i t + \phi_i\right)$$
This sum-of-sines approach is fast, easy to implement in a vertex shader, and gives decent results for calm inland water. Its weakness is that real waves are not sinusoidal — they are sharper at the crests and flatter in the troughs, and they displace water particles horizontally as well as vertically. To model this correctly we need Gerstner waves.
Gerstner Waves: The Game Industry Standard
Gerstner waves (also called trochoidal waves) were described by the mathematician Franz Josef von Gerstner in 1802 and have been the workhorse of real-time ocean rendering ever since. The key insight is that water particles in a surface wave travel in circular orbits, not purely up and down. As a crest passes, particles are pushed forward; in the trough they are pulled back. This horizontal motion is what creates the characteristic peaked crests of real ocean waves.
For a single wave $i$ with normalised direction $\hat{D_i}$, steepness $Q_i \in [0,1]$, amplitude $A_i$, and angular frequency $\omega_i$, the Gerstner displacement of a surface point $\vec{p_0} = (x_0, z_0)$ is:
$$\vec{P}(\vec{p_0},t) = \begin{pmatrix} x_0 + \displaystyle\sum_i Q_i A_i\, D_x^i \cos(\omega_i \hat{D_i}\cdot\vec{p_0} + \varphi_i t) \\[6pt] \displaystyle\sum_i A_i \sin(\omega_i \hat{D_i}\cdot\vec{p_0} + \varphi_i t) \\[6pt] z_0 + \displaystyle\sum_i Q_i A_i\, D_z^i \cos(\omega_i \hat{D_i}\cdot\vec{p_0} + \varphi_i t) \end{pmatrix}$$
The steepness $Q_i$ controls how much the horizontal displacement sharpens the crests. At $Q_i = 0$ you get pure sinusoidal motion; as $Q_i$ approaches 1 the crests sharpen dramatically. If $Q_i$ exceeds 1 the wave folds over itself, producing rendering artifacts — so in practice values around 0.2–0.5 are common. Summing four to eight Gerstner waves at different frequencies, amplitudes, and directions gives a convincing open-ocean look at a cost of a handful of trigonometric operations per vertex.
A GLSL vertex shader implementation typically looks like this:
vec3 GerstnerWave(vec4 wave, vec3 p, inout vec3 tangent, inout vec3 binormal) {
float steepness = wave.z;
float wavelength = wave.w;
float k = 2.0 * PI / wavelength;
float c = sqrt(9.81 / k); // deep-water phase speed
vec2 d = normalize(wave.xy); // wave direction
float f = k * (dot(d, p.xz) - c * uTime);
float a = steepness / k;
// accumulate surface frame for normal calculation
tangent += vec3(
-d.x * d.x * (steepness * sin(f)),
d.x * (steepness * cos(f)),
-d.x * d.y * (steepness * sin(f))
);
binormal += vec3(
-d.x * d.y * (steepness * sin(f)),
d.y * (steepness * cos(f)),
-d.y * d.y * (steepness * sin(f))
);
return vec3(
d.x * (a * cos(f)),
a * sin(f),
d.y * (a * cos(f))
);
}
Notice that tangent and binormal vectors are accumulated alongside the displacement. This lets you reconstruct a physically correct surface normal — essential for the specular highlight that makes water look wet and shiny.
The Dispersion Relation
The phase speed $c = \sqrt{g/k}$ appearing in the shader above is not arbitrary — it comes from the deep-water dispersion relation:
$$\omega^2 = gk$$
This relationship is fundamental: longer waves travel faster than shorter ones. That is why ocean swells generated by distant storms ($\lambda$ of hundreds of metres) arrive at a beach long before the shorter local chop does. Embedding the dispersion relation into your wave model — even the simple sum-of-sines version — makes the animation look dramatically more natural at essentially zero extra cost.
FFT Ocean Simulation
For the most realistic open-ocean look, the industry turns to the Phillips Spectrum combined with an inverse Fast Fourier Transform, first described for graphics by Jerry Tessendorf in his 2001 SIGGRAPH course notes, Simulating Ocean Water. This is the approach behind the oceans in Sea of Thieves, Assassin's Creed IV: Black Flag, and every major nautical game of the last fifteen years.
The key idea is to work in the frequency domain:
- Build an $N \times N$ grid of complex Fourier coefficients, each representing a wave component at a particular spatial frequency and direction.
- Weight those coefficients by the Phillips Spectrum, a statistical model of real ocean energy distribution parameterised by wind speed and direction.
- Each frame, time-evolve the coefficients using the dispersion relation — this is a cheap complex-number rotation, not a re-solve.
- Run an inverse 2D FFT to convert back to a height field in world space.
The Phillips Spectrum assigns energy to each wave vector $\vec{k}$ as:
$$P(\vec{k}) = A\, \frac{e^{-1/(kL)^2}}{k^4}\, \left|\hat{k} \cdot \hat{w}\right|^2$$
where $L = V^2/g$ is the largest possible wave for wind speed $V$, and $\hat{w}$ is the normalised wind direction. The dot-product term means waves aligned with the wind carry the most energy. The time evolution of a single coefficient is a rotation in the complex plane:
// Animate one frequency component h0 at wave vector k
vec2 hTilde(vec2 k, vec2 h0, vec2 h0Conj, float t) {
float omega = sqrt(9.81 * length(k)); // dispersion
float cosT = cos(omega * t);
float sinT = sin(omega * t);
// Euler rotation: h0 * e^(i*omega*t) + conj(h0) * e^(-i*omega*t)
return vec2(
h0.x * cosT - h0.y * sinT + h0Conj.x * cosT - h0Conj.y * (-sinT),
h0.x * sinT + h0.y * cosT + h0Conj.x * (-sinT) + h0Conj.y * cosT
);
}
After the iFFT the result is a heightmap that can be applied to a tessellated mesh. Typical grid sizes are 256×256 or 512×512, computed each frame by a compute shader or FFT texture pass at $O(N^2 \log N)$ cost — fast enough on modern GPUs at 60 fps.
Shallow Water Equations for Interactive Water
Gerstner and FFT methods describe a surface that looks like an ocean but doesn't react to objects in the world. For interactive water — flooding, splashes, boats displacing the surface — you need a simulation that propagates disturbances in real time. The Shallow Water Equations (SWE) are the standard tool:
$$\frac{\partial h}{\partial t} + \nabla \cdot (h\,\vec{u}) = 0$$
$$\frac{\partial \vec{u}}{\partial t} + (\vec{u} \cdot \nabla)\vec{u} = -g\,\nabla h$$
The first equation is mass conservation (water neither appears nor disappears). The second is momentum conservation — pressure gradients (steep slopes) accelerate flow. Discretised on a 2D grid with finite differences, this becomes straightforward to code:
function updateShallowWater(grid, dt) {
const g = 9.81;
const dx = grid.cellSize;
for (let y = 1; y < grid.rows - 1; y++) {
for (let x = 1; x < grid.cols - 1; x++) {
const h = grid.h[y][x];
// height gradients drive velocity changes
const dhdx = (grid.h[y][x+1] - grid.h[y][x-1]) / (2 * dx);
const dhdy = (grid.h[y+1][x] - grid.h[y-1][x]) / (2 * dx);
grid.u[y][x] -= g * dhdx * dt;
grid.v[y][x] -= g * dhdy * dt;
// velocity divergence changes water height
const dudx = (grid.u[y][x+1] - grid.u[y][x-1]) / (2 * dx);
const dvdy = (grid.v[y+1][x] - grid.v[y-1][x]) / (2 * dx);
grid.h_new[y][x] = h - h * (dudx + dvdy) * dt;
}
}
[grid.h, grid.h_new] = [grid.h_new, grid.h];
}
SWE works beautifully for rivers, ponds, and contained bodies of water where the surface gradient is relatively small. For deep explosions, submarine wakes, or fluid pouring, more expensive 3D methods like Smoothed Particle Hydrodynamics (SPH) or the Lattice-Boltzmann method are used — typically at low resolution with visual upscaling tricks to hide the coarseness.
Normals, Foam, and Subsurface Lighting
Surface Normals
Correct normals are what separate water that looks like a moving bump map from water that looks genuinely wet. For Gerstner waves they can be derived analytically from the tangent and binormal vectors computed during displacement. For FFT and SWE approaches, a normal map is generated each frame from the height-field gradient using central differences — the same operation that turns a grayscale heightmap into a tangent-space normal map.
Foam Generation
Ocean foam appears wherever waves break or steep slopes form. The Jacobian of the displacement map identifies these regions mathematically: when the Jacobian of the surface deformation becomes negative, the surface is folding over itself — a breaking wave:
$$J = \left(1 + \lambda\,\partial_x D_x\right)\left(1 + \lambda\,\partial_z D_z\right) - \left(\lambda\,\partial_x D_z\right)\left(\lambda\,\partial_z D_x\right)$$
Negative or near-zero $J$ values are written into a foam accumulation texture. The texture decays each frame, so foam fades after waves pass. This creates the streaky foam trails visible in high-quality ocean rendering.
Fresnel Reflectance
Water appears mirror-like at grazing angles and transparent when viewed from directly above. This is the Fresnel effect, and Schlick's approximation keeps it GPU-cheap:
$$F(\theta) = F_0 + (1 - F_0)(1 - \cos\theta)^5$$
where $F_0 \approx 0.02$ for water (about 2% reflectance at normal incidence) and $\theta$ is the angle between the view direction and the surface normal. At $\theta = 90°$ (grazing), $F \approx 1$ — almost everything reflects. Blending between a reflection texture (or screen-space reflection) and the refracted underwater colour using $F(\theta)$ produces convincing results with a single lerp.
Real-World Game Examples
- Sea of Thieves (2018, Rare): Widely regarded as the benchmark for real-time ocean rendering. Uses an FFT-based simulation with a layered normal map system for fine detail, dynamic foam driven by the Jacobian, and a full weather system that modulates wave height, choppiness, and colour in real time.
- Assassin's Creed IV: Black Flag (2013, Ubisoft): Built on a Gerstner wave system combined with screen-space reflections and heavy use of projected grid tessellation to concentrate geometry detail near the camera and horizon.
- Uncharted 4: A Thief's End (2016, Naughty Dog): Uses an artistic rather than physically-based approach — multiple layers of tiled normal maps scrolled at different speeds and angles, blended with screen-space reflections. Proves that art direction can compensate for physical accuracy.
- Valheim (2021, Iron Gate): Achieves stylised but atmospheric water using a relatively simple Gerstner-based mesh animation with art-directed colour and fog. Shows that small teams can get strong results with fundamental techniques.
- Minecraft (Java Edition): Classic example of SWE-style fluid simulation (heavily simplified) — water flows into adjacent blocks, fills cavities, and reacts to dams and channels, making it genuinely interactive despite being visually simple.
Performance Hierarchy
Every project must balance fidelity against frame budget. From cheapest to most expensive:
- Scrolling normal maps only: No vertex displacement, just a shiny surface with animated normals. Appropriate for mobile, background water, and very distant surfaces.
- Sum-of-sines / 4–8 Gerstner waves in a vertex shader: A few trig ops per vertex, easily runs in a vertex shader. The sweet spot for most mid-range and indie titles.
- FFT simulation (compute shader): Requires DX11-class hardware. Produces the most convincing large-scale ocean and is used in AAA titles. Typically combined with close-range Gerstner detail waves.
- SWE or SPH simulation: For interactive water. Often run at 64×64 or 128×128 resolution and visually upscaled. Adds meaningful gameplay interactivity at significant compute cost.
Modern AAA games layer several of these: FFT or Gerstner for the primary surface, tiled normal maps for sub-metre ripple detail, foam accumulation textures, and a separate particle system for splashes and spray.
Engine Starting Points
You don't have to build everything from scratch. Both major commercial engines ship capable water systems:
- Unity HDRP Water Surface: A Gerstner-based system with full artist parameter exposure — wave amplitude, steepness, speed, and per-region masking. Includes built-in caustics projection and foam.
- Unreal Engine 5 Water Plugin: Supports rivers, lakes, and oceans via spline-defined water bodies with Gerstner simulation, physics-based buoyancy for actors, and full Lumen/Nanite integration.
- Godot 4 ShaderMaterial: Gives you full control over vertex and fragment shaders. The community maintains several open-source Gerstner and SWE implementations in the asset library.
Understanding the mathematics — Gerstner displacement, the dispersion relation, Fresnel blending — means you can tune these systems intelligently and extend them when they fall short. Whether you are building an epic naval combat game or just want a convincing koi pond in your platformer, the foundations laid here will serve you well.
Comments