← All notes

Three.js

Gerstner ocean with crest and shore foam in TSL

White painted on the peaks looks like snow. Foam has to sit on the back of a crest and then slip down the face, and on a beach it has to run up with the surge and drain back down, or the sea looks varnished.

This is one HTML file on Three.js 0.186.1, WebGPU and TSL. Sixteen Gerstner waves over a seabed I bake once, plus a planar reflection, so the colour has a real thickness to travel through. I use AI to write a lot of the TSL. Where the foam is allowed to sit, and how long the sand stays dark after the water leaves, comes from 25+ years of JavaScript, game development, modding and level design.

The waves

The surface is sixteen Gerstner components, with wavelengths spaced geometrically from a 140m swell down to 5.5m chop. Amplitude falls off with wavelength, a stand-in for the Phillips roll-off, and the short waves spread wider around the wind than the long ones. That spread is what stops the sum looking like a few tidy sine waves marching in step.

Each component shoves the surface sideways as well as up, and I keep the analytic tangent and binormal while I sum, so the normal matches the displaced mesh. Screen-space derivatives light that surface as if it were still flat, which is why the analytic one is the one I keep. The same sum produces a fold, choppiness times amplitude times wavenumber times sin, and crest foam keys off that. For this spectrum the fold only reaches about 0.36, so I divide by that total and the breaking threshold means something. Steeper seas still break more, because amplitude and choppiness are still inside the term.

Wavenumber, phase speed and phase are compiled into the graph. Changing the wind bearing writes new direction uniforms. Changing how many components there are means rebuilding the material, which is why the count stays at sixteen.

The mesh is a plane warped so the vertices bunch up near the camera, a fine grid out to about 170 units and then a long run to 1500. Displacement fades with distance, and the horizon stays a line.

How thick the water is

I used to read thickness from the depth buffer, which meant a copy every frame. WebGPU will not copy a multisampled texture, so antialiasing on the renderer stays off and FXAA runs in the pipeline. The seabed does not move, and bedHeight() is already a closed form, so I bake it once into a half-float texture and sample that. Thickness is the water surface minus that height, the actual column of water. A grazing view travels further through it, so the path length is the column divided by the view's Y, clamped so it doesn't explode when you look along the surface.

Absorption is Beer-Lambert, and red goes first:

const trans = exp(thick.mul(u.absorb).negate().mul(vec3(1.0, 0.42, 0.28)));

Reflection is a planar mirror. An oblique-clipped camera hides the water while it renders, so the sea doesn't reflect itself. Refraction reads viewportSharedTexture, and the distortion fades out across the shallows, because a displaced tap near the shore can land on a rock that is standing in front of the surface. Schlick Fresnel mixes the reflection and the refracted bed. Ripples are fractal noise with time on the third axis, so they churn in place, and they bend the analytic normal in tangent space. That detail fades out with distance as well. Bloom is only there so the sun glint can spill. Pull it to zero and the sea is the same sea.

Foam

Crest foam belongs to the wave that threw it. I sample the noise in a frame drifting along the dominant wave, a little slower than the phase speed, so the pattern locks to the crest and then slips backwards down the face. Phase is measured from the peak, and an exponential skew parks the foam on the back of the crest, which is where a spilling breaker leaves it. The face that is still climbing stays clean.

A smooth ramp of that coverage looks airbrushed, so I threshold it against a Worley field. The noise returns squared distance to the nearest cell, and inverting it gives blobs, which read as rafts of bubbles. As coverage falls, the sheet breaks into clumps. Shore foam uses the same dissolve, with coverage from how thin the water column is, and the pattern is carried along the seabed gradient: up the beach while the surge runs in, back down while it drains. A sheet that sits still and pulses is the look I kept trying to get rid of.

The wet sand doesn't store a history. For the leading wave, a point at height y is under water whenever sin of the phase is greater than y over the amplitude, and that window has closed-form edges, so the time since the water last left is just a fraction of the phase. It stays fully wet while submerged and decays from there. That value darkens the albedo and drops the roughness, and a clearcoat sheen is what actually reads as wet. A dark patch on its own looks like a stain.

Zero coverage has to sit entirely above the pattern. Cell centres reach exactly 1, so if the threshold band still overlaps them you get specks over the whole ocean with no wave underneath. That was a long afternoon.

const span = float(1).add(soft.mul(2));
const centre = oneMinus(cover).mul(span).sub(soft);
return pattern.smoothstep(centre.sub(soft), centre.add(soft));

The buoy is the same field evaluated on the CPU, which is why it sits in the water.

Crest foam slips down the face. Shore foam runs up and drains back.

The frame is that file. Drag to orbit, and stay above the surface, because the camera is clamped there. The panel starts closed. Click Sea state, or press H. Coastal breeze is the default. Storm swell is the one that shows the fold, and glassy calm is the one that shows whether the ripples are doing any work.

WebGPU only, on Three.js 0.186.1, with no WebGL fallback. If the browser has no WebGPU, the page stops. Open it through a server, because file:// will not load the modules. php -S localhost:8000 or npx serve in the folder is enough.

If you take this into a level, the planar reflection is the cost to watch. I turn bloom down before I touch the wave count. Match the shallow colour to the sand, or the waterline reads as a sticker.