A wet line painted at a fixed height looks like a stain. If it tracks the surface, the rock gets darker on the way down, which is the wrong memory. The stone has to stay dark after the crest has already gone.
This is one HTML file on Three.js 0.186.1, WebGPU and TSL. There are no textures in it. The sea and the rocks are noise, and the bank uses the same rock material. I use AI to write a lot of the node graph. How long the rock stays dark after a wave, and where the foam is allowed to sit, comes from 25+ years of JavaScript, game development, modding and level design.
The waves
The height is the afl_ext sum, the public-domain Shadertoy (MdXyzX). Thirteen octaves of exp(sin(x) - 1), each one travelling in a direction taken from a running angle, with a little derivative drag so the peaks lean over. Those directions never line up again, so the sea does not come back around. I had a fixed Gerstner set in this scene first, and it always came back around.
Every entry in ROCKS adds a small radial lap on top, including three virtual ones that have no mesh. They sit along the beach so the swash has a clock to breathe with. The array is read while the graph is built. A rock you push in later does not exist until the materials are rebuilt.
The field is written twice. oceanH is the shader. jsOceanH is the CPU copy, and the bubbles, the underwater check and the waterline band all read that one. Change the shader and leave the copy, and the bubbles float off the surface.
Wet stone
At each fragment the rock material samples the wave height now, then again at twelve earlier times, out to 44 seconds. Each sample is a candidate waterline, a little above the wave for infiltration, plus a bit of grain. The weight is exp(-age * 0.035), about a 20 second half-life, and the shader keeps the maximum. The early samples sit close together, at 0, 0.3, 0.8 and 1.6 seconds. A two-second gap at that end let a crest register late, and the rock darkened while the water was already falling.
Wet albedo drops to 13% of the dry colour. The highlight goes through a normal with two bands of high-frequency noise on it, so the film sparkles in the grain. Dry stone has no specular. Under the surface there is a caustic product of two scrolling noise fields, and a teal tint. The shore is the same material. The bank is another mesh, and the wet band, the caustics and the contact foam come with it.
Where the water meets the rock
The hard line is the water's own silhouette. Drawing something through the surface cannot soften those pixels. Opacity fades across EDGE_BLUR, 0.45 world units, and that fade is what blurs the edge. Refraction still shows the rock wherever the water is opaque.
The refraction tap reads the opaque scene through screen UVs bent by the normal. A sample that would land on geometry standing above the water is thrown out, and the tap falls back to the straight UV. Near the contact, and as the column gets deeper, the tap reads a higher mip, so the bottom goes soft. Absorption is per channel, and red goes first. Antialiasing on the renderer stays off. That tap is a copy of the framebuffer, and WebGPU will not copy a multisampled texture.
Foam is four masks added up. A surge ring rides the lap clock, a noise-warped band hugs each foam radius, crest foam keys off the wave height, and a swash runs up the beach and lingers on the way back. The amount goes through domain-warped noise so the lace does not tile, then two Worley scales, and a thin sheet breaks into bubbles. The bubbles you can orbit around are separate, hemispheres in one InstancedMesh, sat on the steep parts of the waterline and lifted by jsOceanH.
Under it
The water is double sided. From below, a Snell window sits overhead and the grazing angle goes dark teal, with the foam still faintly visible. When the camera drops under jsOceanH, the fog switches to a short teal range. A CSS strip blurs the waterline when the camera is within 0.55 of the surface. The strip is horizontal, so a steep diagonal waterline still gets a horizontal blur. I left that.
The camera is OrbitControls. Drag right and the scene turns with your hand. The polar limit sits just past straight down, which is what lets you go under. Scroll zooms, and right-drag pans.
The frame is that file. The badge in the corner should say WebGPU. The hint fades once you drag. The first frame is slow, because the rock shader samples the whole wave field twelve times.
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.
- Open it on its own
water.html, the project-
SKILL.md
If you retune the waves, change oceanH and jsOceanH in the same edit. If the contact line goes hard again, look at the opacity fade before you touch the foam.