A tile size that divides the ocean comes back around. From above you can see the seam. Three sizes that share no factors do not line up, so the swell, the chop and the ripples never print the same patch twice.
This is one HTML file on Three.js 0.186.1, WebGPU and TSL, plus one rock photograph. I use AI to draft the butterfly stages and the spectrum kernels. How long the foam is allowed to sit, and how slowly the rock dries, comes from 25+ years of JavaScript, game development, modding and level design.
Three seas, one surface
Each cascade is a Phillips-style spectrum. Wind speed is 14, gravity is 9.81, and the largest wave follows from those two. The tiles are 503, 47 and 23 metres. The first two run at 256, the ripples at 128, because short wavelengths do not need the big grid and each halving cuts that cascade by about four. The ripple amplitude is tuned for 128. The inverse transform divides by N squared, so the old 256 number is the wrong one.
Every frame builds the height spectrum, packs the sideways shift into the same complex field, and runs the FFT. A second pass carries the other sideways component. The butterfly ping-pongs and, because both sizes have an even log, it lands back in the first buffer. A heightScale of 40 turns that into metres, and a chop of 1.3 leans the crests over.
Choppy water moves sideways, so the surface above a point is not the height stored at that point. surfaceHeightAt walks it back in four steps. Two steps left the spray under the crest.
Foam that stays
Folding is the Jacobian of that sideways shift. Where it drops below 0.62 the cell births foam, and the cell remembers it. Decay is 0.5 a second, so a crest leaves a raft instead of a flash. The ripple cascade is left out of that sum. It folds everywhere, and if you let it vote the whole sea goes white.
The lace is a bubble mat, dark inside and bright on the rim. It only runs where the foam is already above a hundredth. Below that it is empty water, and the lookup is the expensive part of the shader.
The rock
Wetness is a second grid, 256 cells on the swell tile. Each step keeps the higher of the water now and the old mark, minus 0.14 metres a second. The stone stays dark after the crest has gone, then dries. Wet colour is about a quarter of the dry colour, a little warmer. The splash band never really dries.
The face is tex2.jpg, projected from three axes, because an icosphere's own UVs pinch at the poles. Foam on the rock is pushed down the surface, in Y only. Push it in X or Z and it slides off to the side, which water does not do. It stays grey. Paper white on stone reads as paint.
Spray is 80,000 particles, all of them born inside 19 metres of the rock. Dead ones respawn at once, so the count is the density. Turning the spawn chance up does almost nothing. The 20,000 bubbles sit in the same disc, and a bubble dies when the foam under it dies.
The corner should say WebGPU. Drag right and the sea turns with your hand. The first frames are slow. Each cascade runs two transforms before anything is drawn.
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 the sea tiles again, one of the three lengths has started to divide the plane. If the spray sinks, the surface lookup has lost a step.