In the 2D post the CPU simulated the water step by step, every cell asking its neighbours. For a surface you look across all the way to the horizon in 3D, that gets expensive fast. So nothing is simulated here. The height at every point is a formula that only depends on position and time: six waves, summed up, evaluated in the vertex shader.
Drag to orbit the camera, the wheel zooms. The chips set the wind to calm, breeze or storm, and the waves grow smoothly into the new strength. Wireframe shows the grid underneath, foam switches the foam off. The orange buoy sits on the water and leans with the slope. The CPU computes its position, the GPU draws the water, and both have the same formula in their heads.
Why Gerstner instead of sine
A sum of sine waves gives hills that are as round on top as at the bottom. Real water has sharp crests and flat troughs. A Gerstner wave therefore pushes every point of the surface sideways in the direction of travel as well. Each point keeps its rest position and runs on an ellipse, at a steepness of Q = 1 it is a circle. In the demo Q is between 0.72 and 1. Under a crest the points move together, in a trough they are pulled apart.
The grid has 201 by 201 vertices, so 80,000 triangles. It is uploaded once and never touched again, the vertex data only holds the rest position (x, z). The rest happens every frame in the shader:
float theta = k * dot(d, aRest) - uWaveB[i].x; float s = sin(theta); float c = cos(theta); p += vec3(q * a * d.x * c, a * s, q * a * d.y * c);
d is the direction of travel, k the wave number (2π over the wavelength), a the amplitude, q the steepness. Six waves from 22 down to 3 metres, from different directions.
Long waves run faster
In deep water the frequency depends on the wave number: ω = √(g·k), and the phase speed is c = ω/k. The 22 metre wave takes 3.75 seconds per period and runs at 5.9 m/s, the 3 metre wave manages 2.2 m/s. The test has a hand-computed case for it: at k = 1/g, ω is exactly 1.
inline float dispersion(float k) { return std::sqrt(kGravity * std::max(k, 0.0F)); }
inline double timePhase(const Wave& w, double t) {
double p = std::fmod(static_cast<double>(omega(w)) * t, kTwoPi);
if (p < 0.0) p += kTwoPi;
return p;
}
The second function is a trap. The CPU collects time from the frame times, like in Update Loop, with a clamp at 0.25 seconds against stalls, and pause adds nothing. What goes to the shader is ω·t, reduced to one turn and computed in double. A float at ω·t around 100,000 (with ω = 3 that is a good nine hours) only has 0.008 radians of resolution, and the waves would jitter.
Normals from the derivatives
For the normal you could take differences between neighbouring points. The derivatives with respect to x and z are as simple as the formula itself though, and fall out of the same loop iteration:
float ka = k * a; float sharp = q * ka; tx += vec3(-sharp * d.x * d.x * s, ka * d.x * c, -sharp * d.x * d.y * s); tz += vec3(-sharp * d.x * d.y * s, ka * d.y * c, -sharp * d.y * d.y * s); // after the loop vec3 n = cross(tz, tx); vJacobian = n.y;
The y of the unnormalised cross product is the Jacobian determinant of the horizontal shift. On flat water it is 1, under a crest it drops to 1 − Q·k·A, in a trough it rises to 1 + Q·k·A. Where the surface is squeezed together is where the foam belongs: 1.0 - smoothstep(0.30, 0.80, vJacobian), plus noise in rest coordinates so the foam travels with the water. The test compares the analytic normal with central differences at 900 random points, the deviation stays below 0.004.
The colour comes from Fresnel after Schlick: look at the water at a grazing angle and you see the sky mirrored, look steeply down and you see the teal below.
The steepness limit
Pick Q too large and the surface folds over itself. The criterion is S = Q·k·A: as long as the sum over all waves is at most 1, the Jacobian determinant does not go negative. The test shows it on a single wave. At S = 0.9 the shifted x position runs strictly upwards, at S = 1.5 it runs backwards in places, and the normal points down at the crest.
The limit is generous. With six waves from different directions J only drops to 0.26 in the storm (S = 0.96) in my test scan. At double the steepness (S = 1.91) the surface really does fold, at 1.53 J was still at 0.002. The sum is shown in the top left of the demo.
A buoy that floats along
The buoy is moored at a fixed world point, but the formula works in rest coordinates, and the points travel sideways, in the storm by up to 1.7 metres. A plain displace(x, z) therefore returns the height at the wrong place. You have to invert the shift, and Newton's method on the 2×2 map is enough for that. Its matrix is already there, it is the tangents from above:
for (int i = 0; i < iterations; ++i) {
const Frame f = frame(waves, r.x, r.z, t);
const float ex = f.position.x - worldX;
const float ez = f.position.z - worldZ;
if (std::fabs(ex) < 1e-6F && std::fabs(ez) < 1e-6F) break;
if (std::fabs(f.jacobian) < 1e-3F) break;
r.x -= (f.tangentZ.z * ex - f.tangentZ.x * ez) / f.jacobian;
r.z -= (f.tangentX.x * ez - f.tangentX.z * ex) / f.jacobian;
}
At the point it finds, the same function gives position and normal, and the normal turns the buoy. The test checks at 300 points and three wind strengths that the shifted position ends up at most 2 millimetres from the mooring spot. A ship would be handled the same way, sampled at several points.
Vertex versus fragment
The shader computes sin and cos six times per vertex, with 40,401 vertices. In the fragment shader that would be a good twelve times as many evaluations at 960×540 pixels. In return the normals are as fine as the grid, 0.5 metres. The 3 metre wave has six vertices per wavelength, which is the lower limit.
More pitfalls
Waves hide waves, so the scene needs a depth buffer. The window helper of the series asks for none, so here the demo creates its own window with 24 bits of depth and 4x MSAA. The camera keeps at least 3 metres of height because the storm crests reach 2.6 metres. The uniform arrays in the shader have a fixed size, eight waves is the maximum.
The code is in the repo under water-waves-3d. All demos of the series are collected in the project Game Dev Snippets.