Tidewater handbook
Tidewater is a Three.js ocean asset that ships as plain ES modules. Drop it into any HTML page with an import map; no build step is required. This page documents the public API. For the source, license terms and pricing, see the product page.
Quick start
Install via CDN
Tidewater is distributed as a versioned .zip after purchase.
Unpack it next to your index.html and reference it via an import
map. You'll also load Three.js and a couple of its sub-packages from a CDN —
the easiest setup is jsdelivr, but any CDN that serves the same files works
identically.
<!-- 1. import map: tells the browser where to resolve module names from --> <script type="importmap">{ "imports": { "three": "https://cdn.jsdelivr.net/npm/three@0.184.0/build/three.module.js", "three/webgpu": "https://cdn.jsdelivr.net/npm/three@0.184.0/build/three.webgpu.js", "three/tsl": "https://cdn.jsdelivr.net/npm/three@0.184.0/build/three.tsl.js", "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.184.0/examples/jsm/", "tidewater/": "./tidewater/src/" } }</script> <!-- 2. your scene module --> <script type="module" src="./main.js"></script>
Your first ocean
The minimal scene: a renderer, a camera, a sun direction, and an ocean.
For the renderer, use the createRenderer() factory — it picks
WebGPU when available and falls back to WebGL2 transparently.
import * as THREE from "three"; import { createRenderer } from "tidewater/renderer.js"; import { OceanFFT } from "tidewater/OceanFFT.js"; import { SEA_MODES } from "tidewater/SeaModes.js"; // createRenderer() resolves to { renderer, backend } — the backend string // ("webgpu" | "webgl") is what the quality picker wants later. const { renderer } = await createRenderer(); document.body.appendChild(renderer.domElement); const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 0.1, 5000); camera.position.set(0, 4, 30); const sun = new THREE.Vector3(0.7, 0.6, 0.2).normalize(); const ocean = new OceanFFT({ sunDirection: sun, size: 240 }); // A preset is data, not a method call: push each block at the setter // that owns it. const mode = SEA_MODES.reef; ocean.setSeaParams(mode.fft); ocean.setWaterColor(mode.water); ocean.setDeepColor(mode.deep); scene.add(ocean); renderer.setAnimationLoop((t) => { ocean.update(t * 0.001); renderer.render(scene, camera); });
That's the minimum viable ocean. You'll usually want a sky and a sea floor, and you'll want to apply per-mode parameters when switching presets. All of that is in the next sections.
Ocean
The surface is OceanFFT: cascaded Tessendorf FFT waves evaluated on the GPU, drawn on a camera-centred radial LOD mesh and shaded per pixel. OceanNode (analytical Gerstner) is kept as an escape hatch with the same setter API; the demo reaches it with ?ocean=gerstner.
| Module | Path | When to use |
|---|---|---|
OceanFFT | tidewater/OceanFFT.js | Default. Three GPU FFT cascades (256 / 64 / 16 m), CPU buoyancy mirror, per-pixel normals, PMREM sky reflection, persistent foam, depth-aware masking, deterministic clock. Works on WebGPU and WebGL2. |
OceanNode | tidewater/OceanNode.js | Gerstner waves (analytical) on a flat plane. Cheaper, no GPU passes; last-resort fallback when float render targets are unavailable. |
OceanFFT
Everything is optional; the defaults give a 12 km radial sea. Pass the renderer to run the FFT on the GPU (recommended — the CPU path is a diagnostic).
import { OceanFFT } from "tidewater/OceanFFT.js"; import { pickQuality, tierFftCascades } from "tidewater/Quality.js"; const { tier } = pickQuality({ backend }); const ocean = new OceanFFT({ useGPU: true, renderer, // GPU FFT — fragment passes, WebGPU + WebGL2 cascades: tierFftCascades(tier), // grid 64…256 per tier, one shared atlas rings: tier.oceanRings, // radial LOD mesh: rings × sectors sectors: tier.oceanSectors, size: 12000, // radius to the horizon (m) cpuSize: 64, // buoyancy mirror grid (any tier, same sea) sunDirection: sun, // THREE.Vector3, normalised envMap: scene.environment, // PMREM for the per-pixel sky reflection normalMap, detailNormalMap, foamMap, // optional tileable textures wakeMap, rippleMap, maskTexture, // optional: WakeField / Ripples / WaterMask seed: 1, // phase seed — same seed, same sea, every client deterministic: false, stepSize: 1/60, // fixed-step clock for multiplayer foamField: true, // persistent whitecaps (or an options object) }); scene.add(ocean);
Per-frame calls
| Call | Effect |
|---|---|
ocean.advance(dt) | Step the cascades (and the foam field) by a wall-clock delta. In deterministic mode dt is consumed in whole stepSize ticks. Prefer this over update(t). |
ocean.setViewer(camera.position) | Re-centre the radial mesh and the foam window on the eye. The wave field is world-locked; only the tessellation follows you. |
ocean.setEnvironment(pmrem) | Hand over a fresh PMREM after every IBL bake (the sky reflection samples it by the per-pixel normal). |
ocean.sampleHeight(x, z) / sampleNormal(…) | CPU-mirror surface at world (x, z). Frame-perfect — no GPU readback. Buoyancy reads this. |
ocean.setSunDirection(v) | Sun for specular, SSS and foam shading. |
ocean.dispose() | Free render targets, materials, geometry and the CPU spectra. |
Keep the sky off the planar reflector if you want the per-pixel sky reflection to show: park the dome on its own layer, enable it on the main camera, and disable it on the reflector's virtual camera (ocean._mirror.reflector.getVirtualCamera(camera).layers.disable(layer)). Where the mirror painted nothing, the surface mirrors the PMREM by its own normal; scene objects still come from the mirror.
Spectrum & cascades
setSeaParams() pushes a preset's fft block into every cascade and rebuilds h₀(k) on both the GPU and the CPU mirror. Each cascade's amplitude is its significant wave height in metres — the band's energy is normalised to it, so wind speed shapes the spectrum (peak, directional spread) without changing how big the waves are.
ocean.setSeaParams({
windSpeed: 16, windDirection: [0.92, 0.32],
cascadeAmplitudes: [4.4, 2.2, 0.38], // Hs per cascade: swell / chop / fizz (m)
jonswapGamma: 1.0, // 1 = Phillips; 2–3.3 peakier JONSWAP crests
spectralSharpness: 1.0, // directional spread exponent
standingWaveRatio: 0.0, // 0 travelling … 1 standing (lakes, pools)
seed: 4242, // optional re-roll
});
Phases are hashed by wavenumber, not drawn from a stream, so a 64² mirror and a 256² GPU grid — or two clients on different quality tiers — compute the same waves for every wavenumber they share. Surface shading knobs: setSunRoughness (GGX lobe at the camera; it widens with distance automatically), setExtinction(r, g, b) (per-channel Beer–Lambert on the refracted scene), setGustStrength (wind-gust slicks), setIor, the SSS uniforms, and the foam/colour setters shared with OceanNode.
OceanNode (Gerstner)
The Gerstner ocean shares the shading set (reflector, refraction, depth absorption, normal cascade, Fresnel, foam, SSS, Snell window, sun glint, wake field, water mask) — the difference is the vertex displacement source and a flat plane instead of the radial mesh. sampleHeight and setWaveProfile work identically.
Wave params
The Gerstner band table is exposed through setWaveProfile and
lives on each preset under mode.waves. Each band is one Gerstner
wave with steepness, wavelength, amplitude, and direction.
ocean.setWaveProfile([
{ steepness: 0.4, wavelength: 26, amplitude: 0.55, direction: [1, 0, 0.3] },
{ steepness: 0.3, wavelength: 11, amplitude: 0.30, direction: [0.7, 0, 1] },
{ steepness: 0.2, wavelength: 4, amplitude: 0.10, direction: [-0.4, 0, 1] },
]);
On the FFT path the call is a no-op — the spectrum owns the shape; use setSeaParams.
Water masking
The sea is infinite and has no idea a hull or a pool is carved out of it. WaterMask lets you register closed proxy volumes and clips the surface against them in depth: each frame the volumes are rendered into a screen-sized target holding their nearest front-face and farthest back-face depth per pixel, and the water discards only where its own depth lies inside that interval. A wave in front of the hull rolls past the silhouette intact; water behind it is untouched; a camera standing inside the hull gets an interval that starts at the eye.
import { createWaterMask } from "tidewater/WaterMask.js"; const mask = createWaterMask(renderer, scene); // before the ocean const ocean = new OceanFFT({ …, maskTexture: mask.texture }); // A box inside the hull, parented so it moves with the boat. mask.createBoxProxy({ size: new Vector3(3.4, 2.6, 9.5), position: new Vector3(0, 0.9, 0.3), parent: boat }); // Or register your own closed mesh (it moves to the private mask layer). mask.add(hullInteriorMesh); // Pools / tanks: keep water only INSIDE the volumes. mask.invert = true; ocean.setMaskInvert(true); mask.softness = 0.4; ocean.setMaskSoftness(0.4); // metres of alpha feather // per frame, before the main render: mask.update(camera);
Through the façade: water.masking.add / addBox / setInvert / setSoftness / setEnabled. Proxies must be closed volumes (boxes, simplified hulls, extruded outlines) — a flat quad has no interval.
Foam
Whitecaps come from two signals. The instantaneous crest line is the summed Tessendorf fold (1 − J) of the cascades, gated so only genuinely breaking crests light up. The persistent raft is FoamField: a world-locked GPU field (1024² over 700 m around the viewer) that is seeded where the surface folds, accumulates while a crest keeps breaking over a spot, drifts downwind and decays with a lifetime constant — so foam lingers in streaks and patches behind the break instead of flickering with it. Both are dissolved through the foam texture and lit by the sun. Shore foam (analytic depth), contact foam (depth-buffer edge around hulls and pilings) and the boat wake field layer on top.
ocean.foamField.lifetime = 2.6; // seconds (e-folding) ocean.foamField.birthRate = 2.5; // coverage per second under a breaking crest ocean.foamField.setGate(0.28, 0.7); // fold thresholds that seed foam ocean.setCrestFoamIntensity(1.2); // preset knob, also water.foam.setCrest()
Multiplayer sync
The surface is a closed-form function of an integer tick, so peers only have to agree on the tick — no wave data crosses the wire. Turn on the fixed-step clock, share the seed, and stream ticks. getSyncState() is a ~300-byte JSON blob (spectrum + seed + clock) a joining client applies once.
// host const ocean = new OceanFFT({ seed: 4242, deterministic: true, … }); send({ state: ocean.getSyncState() }); // once, on join setInterval(() => send({ tick: ocean.tick }), 250); // then just the tick // client import { NetClock } from "tidewater/NetClock.js"; ocean.applySyncState(state); const clock = new NetClock({ stepSize: ocean.stepSize }); onPacket(({ tick }) => clock.receive(tick, rttMs)); // per frame if (clock.shouldSnap(ocean.tick)) ocean.syncToTick(Math.floor(clock.targetTick)); else ocean.advance(clock.adjust(dt, ocean.tick)); // slewed dt, no pop
NetClock estimates transit latency from the packets you feed it, extrapolates the host's current tick, and stretches or squeezes the local dt by a few percent until the error is gone; it only snaps on joins and long stalls. Determinism holds across quality tiers (wavenumber-keyed phases), and the buoyancy mirror is the same field on every client. The bundled bow-spray emitter takes a seed too. See it live — two independent clients with simulated latency and jitter.
Debug views
ocean.setDebugView(n) (or water.waves.setDebugView(n)) replaces the surface colour with one term of the shading so you can see what each is contributing: 1 sky env, 2 planar mirror, 3 mirror coverage, 4 refraction + absorption, 5 Fresnel, 6 normal, 7 foam, 8 sun specular, 9 scattering, 10 fold, 11 wind gust, 12 raw refraction. 0 restores the final image.
Sea modes
A sea mode is a single object describing the entire ocean look: waves, foam, sky, fog, water tint, lighting, ambient audio mix, and which scene props show up. The bundled SEA_MODES object exports all 13.
The 14 presets
Ten ocean sea-states plus four water-body presets:
| Key | Character | Wave height (typ.) |
|---|---|---|
reef | Bright tropical cove, kelp + coral | 0.5 m |
tropical | Open warm sea, light chop | 1.2 m |
offshore | Wind-driven blue-water | 2.6 m |
swell | Huge rolling open-ocean swells | up to 16 m |
sunset | Warm low sun, amber sky | 1.6 m |
tranquil | Glassy mirror-flat lavender twilight | 0.2 m |
moonlit | Dark blue, sparse highlights | 1.0 m |
foggy | Soft pastels, low visibility | 0.8 m |
arctic | Pale sky, ice floes, choppy | 1.4 m |
hurricane | Storm sea, spray particles, copper sky | 9 m |
lake | Calm freshwater + shoreline foam | 0.1 m |
river | Directional flow, drifting foam streaks | 0.15 m |
pool | Bounded clear water, no sky reflections | 0.05 m |
beach | Surf beach — shoaling swell, breaking shore train, wet sand (1.3) | 1.2 m, breaking |
Mode object shape
Presets are plain data — SEA_MODES.reef in full, minus the wave
table:
{
label: "Reef Cruise",
foam: "reef", // which procedural foam texture set to paint
waves: [ /* Gerstner band table — [dirX, dirZ, amplitude, wavelength] */ ],
water: 0x2c7d76, // surface tint (a hex number, not a block)
deep: 0x0c3138, // colour the depth absorption walks toward
foamColor: 0xf2f7f7,
heightScale: 0.16,
ripple: 0.22,
lengthScale: 1.0,
rays: 0.24, // god-ray strength
boatRide: 0.55,
sun: { elevation: 24, azimuth: 150 },
surface: { choppiness: 0.38, foam: 1.0, shore: 1.0, crest: 1.0,
wakeFoam: 1.0, wakeStrength: 0.45 },
fft: { windSpeed: 9, windDirection: [1.0, 0.15],
cascadeAmplitudes: [2.6, 1.4, 0.32],
jonswapGamma: 1.0, spectralSharpness: 1.0, standingWaveRatio: 0.08 },
sky: { turbidity: 3.2, rayleigh: 1.6,
mieCoefficient: 0.005, mieDirectionalG: 0.82,
cloudCoverage: 0.32, cloudDensity: 0.5, cloudElevation: 0.55,
cloudScale: 0.00022, cloudSpeed: 0.00010 },
airFog: { color: 0xaecbd8, density: 0.00026 },
seaFog: { color: 0x14555f, density: 0.004 },
floor: { sandA: 0xbfae88, sandB: 0x7d6f4f, tint: 0x1c5560, caustics: 1.15 },
sss: { color: 0x9cf3d8, distortion: 0.35, power: 3.6, scale: 1.6, ambient: 0.35 },
wake: { decayRate: 0.16, opacity: 0.85, sizeScale: 1.0, stampScale: 1.0 },
lighting: { exposure: 1.05, ambient: 0.45, key: 1.4, hemi: 0.55,
underwaterExposure: 0.95, underwaterKey: 0.45, underwaterAmbient: 0.3 },
boat: { hull: 0xf3f2ea, cabin: 0xe4e0d4, trim: 0xb8432f,
deck: 0xffffff, glass: 0x86bfcb, dark: 0x252a2c },
props: { ice: false, spray: false, reefLife: true },
}
Other presets add optional blocks on top: audio
({ volume, rumble, surf, hiss, surfRate }), rain
(0–1), flow ({ x, y } for the river), and
scene ("lake" / "river" /
"pool", which swaps the enclosing world). There is no
key field — the key is the object's name in
SEA_MODES — and no canonical time; time of day is
the caller's dial (see below).
Switching modes
SeaModes.js exports data and nothing else —
SEA_MODES and SEA_MODE_ORDER. Each subsystem
takes the block it owns, so a switch is a handful of setters:
import { SEA_MODES } from "tidewater/SeaModes.js"; const mode = SEA_MODES.hurricane; ocean.setSeaParams(mode.fft); // reshape the spectrum, rebuild h₀(k) ocean.setWaterColor(mode.water); ocean.setDeepColor(mode.deep); ocean.setFoamColor(mode.foamColor); ocean.setHeightScale(mode.heightScale); ocean.setChoppiness(mode.surface.choppiness); ocean.setFoamIntensity(mode.surface.foam); ocean.setCrestFoamIntensity(mode.surface.crest); tidewaterSky.applyMode(mode); // atmosphere + clouds, then rebake scene.environment = tidewaterSky.bakeNow(); ocean.setEnvironment(scene.environment); underwaterFog.setFromMode(mode);
The WaterSystem façade wraps the same writes behind
loadPreset(). An object is applied as a partial override of
whatever is live — handy for authoring:
water.loadPreset({ water: 0x1a6f8a, surface: { choppiness: 1.7, foam: 1.2 } });
Passing a preset name (water.loadPreset("hurricane"))
routes through the host's full mode-switch hook — the demo supplies one,
which is what also swaps props, foam textures and the boat palette.
Time of day
The TimeOfDay module owns a single dial in [0, 1]: 0 and 1 are midnight, 0.25 is sunrise, 0.5 is noon, 0.75 is sunset. captureBaseline(mode) takes the preset's look as the noon reference; every other hour is derived as an overlay on it, so Tranquil-at-dawn still reads as Tranquil.
It drives your scene rather than owning it: the constructor takes the handles it should write to, plus the two callbacks that push the result into the frame. This is the demo's wiring, verbatim in shape:
import { TimeOfDay } from "tidewater/TimeOfDay.js"; const tod = new TimeOfDay({ tidewaterSky, // TidewaterSky — sun + atmosphere uniforms sun, // THREE.Vector3 shared with the ocean sunParams, // { elevation, azimuth } — written in place activeEnvironment, // the fog/exposure object your frame eases toward updateSun, // () => recompute `sun` from sunParams applyEnvironment, // (below) => push activeEnvironment at the scene getBelow: () => camera.position.y < 0, keyLight, // THREE.DirectionalLight — colour + intensity }); tod.captureBaseline(SEA_MODES.reef); // remember Reef's noon look tod.setTime(0.78); // scrub to dusk; everything retunes
Re-capture the baseline on every mode switch. src/main.js is
the worked example — the callbacks there are the demo's existing
environment-easing pipe, which is the point: time of day never bypasses it.
What gets retuned, automatically: sun elevation + azimuth, Preetham sky atmosphere, IBL bake, ambient air fog, sea fog, the god-ray direction, the SSS power lobe, and the water surface tint.
Buoyancy
Float anything on the ocean. Tidewater reads the CPU-mirror surface height — there's no GPU readback latency, so the rendered surface and the queried surface agree perfectly. Two modes:
Buoyancy is a manager, not a per-object wrapper: you build
one for the ocean, attach as many objects as you like, and step them all
from a single call.
import { Buoyancy } from "tidewater/Buoyancy.js"; const buoyancy = new Buoyancy(ocean); // in your animate loop — t and dt in seconds: buoyancy.update(t, dt);
Floaters
The default kind. For buoys, debris, lifebuoys — anything that rides the surface and takes its tilt from the local slope:
const buoy = buoyancy.attach(mesh, { ride: 0.3 }); // metres above the surface
Dynamic hulls
For ships and boats: kind: "dynamic" runs the multi-point
solver — it probes the surface fore, aft, port and starboard, and derives
pitch, roll, bank and hull slam from the difference:
const hull = buoyancy.attach(boat, { kind: "dynamic", length: 7.4, width: 2.6, // probe spacing (m) maxPitch: 0.28, maxRoll: 0.22, }); buoyancy.detach(hull); // or detach(boat) — either works buoyancy.clear(); // drop everything buoyancy.size; // how many are attached
attach() hands back the handle so you can push input or
read velocity later; the demo's boat is one of these.
Ripples
The Ripples module is a 128² Müller height-field that rides on top of the FFT. Click the canvas, drop an impulse, watch a ring of waves propagate and decay.
The field is a texture the ocean samples, so it is wired at construction — build the ripples first, then hand the ocean its texture, origin and extent:
import { Ripples } from "tidewater/Ripples.js"; const ripples = new Ripples({ size: 128, // grid resolution (N×N buffers) worldSize: 240, // metres the buffer spans damping: 0.985, // per-step decay — ripples last a few seconds }); const ocean = new OceanFFT({ /* … */ rippleMap: ripples.texture, rippleOrigin: ripples.origin, rippleSize: ripples.worldSize, }); canvas.addEventListener("click", (ev) => { const hit = projectScreenToOcean(ev, camera, /*y=*/ 0); ripples.splash(hit.x, hit.z, /* strength m */ 0.8, /* radius m */ 4); }); // per frame, before the render: ripples.update(dt, boat.position); // second arg re-centres the buffer ocean.setRippleField(ripples.texture, ripples.origin, ripples.worldSize); // realign the sampling window
The buffer is world-locked: pan or zoom and the rings stay where they
were stamped. Passing a target to update() shifts the
contents cell-aligned as that target moves, so the field follows the
player without the ripples sliding with it. The boat's hull integration
calls splash() at each keel sample when the keel crosses a
crest, so it seeds its own micro-wake automatically.
Underwater
When the camera drops below y = 0, Tidewater swaps several systems:
- Underwater fog — directional Beer–Lambert (
UnderwaterFog.js). Looking horizontal hazes fast, looking up keeps the Snell window bright. - Caustics on every prop —
createCausticProjector()walks any Object3D and projects the same Worley caustic field onto each material'semissiveNode. Idempotent. - Screen-space god rays from the surface — anchor switches from world-sun to the (camera.x, surface, camera.z) projection. Reads dimmer Snell-window glow instead of the bright sky.
- Marine snow — per-particle twinkle, vertex-colour modulated.
If you're using just the ocean piece (no scene container), call post.setUnderwater(below, waterLevel) and toggle scene.fogNode = underwaterFog.node on dive.
Interaction
Anything that touches the water can leave a mark: a wake footprint where it moves, ripples where it bobs, a splash where it lands. WaterInteraction (src/Interaction.js) ties the three together behind one call; on the façade it is water.interaction.
Anything touches water
import { WaterInteraction } from "tidewater/Interaction.js"; const interaction = new WaterInteraction({ ocean, ripples, wakeField, spray, waterLevel: 0 }); // Any Object3D. Defaults: wake + ripple + splash, radius 1 m. const h = interaction.add(crate, { radius: 0.8, depth: 0.4, strength: 1 }); interaction.add(swimmer, { radius: 0.5, wake: true, ripple: true, splash: false }); // per frame, after you've moved your objects: interaction.update(dt, ocean.simTime); interaction.remove(h); // or interaction.remove(crate)
| Option | Default | Effect |
|---|---|---|
radius | 1 | Footprint radius (m) of the wake stamp and the ripple. |
depth | 0.5 | How far below the surface the footprint reaches before the wake fades. |
offset | [0,0,0] | Local offset of the contact point from the object's origin. |
wake / ripple / splash | all on | Which effects this object produces. |
strength | 1 | Wake stamp weight; rippleStrength scales the ripple separately. |
splashThreshold | 2.5 m/s | Vertical entry speed that triggers the splash ring + spray burst. |
wakeMinSpeed | 0.6 m/s | Below this the object stamps no wake (a moored buoy just bobs). |
Bodies attached through Buoyancy get this for free — a floater or hull stamps ripples and wake as it moves, and splashes when it is dropped in (drift and entry speed come from the body itself).
Wake generators & splashes
Under the hood the wake canvas (WakeField) now takes generators: registered objects it samples each frame, so one boat can carry a bow and a stern generator and a school of jet-skis carries twelve. The spray emitter gained burst() for one-shot splashes.
// A generator per contact point — bow V + prop wash on one hull. const bow = wakeField.addGenerator(hull, { radius: 1.2, depth: 0.6, offset: [0, 0, 5.5], strength: 0.9 }); const stern = wakeField.addGenerator(hull, { radius: 1.6, depth: 0.6, offset: [0, 0, -5.8], strength: 1.2 }); wakeField.update(dt, focus); // focus = camera / boat XZ the canvas follows wakeField.removeGenerator(bow); // One-shot splash: ripple ring + spray burst at a world position. ripples.splash(x, z, 1.0, 1.2); // strength, radius spray.burst({ x, y: 0, z }, { count: 120, speed: 6, up: 7, spread: 1.0, life: 1 }); // …or through the façade: water.spray.burst(pos, { count: 120 }); water.wake.addGenerator(obj, { radius: 1 });
Coast & shallow water
Deep-water FFT waves do not know about a beach. ShallowWater (src/ShallowWater.js) gives the surface a depth field: the cascades fade out as the bed rises (the dispersion relation says the short waves go first), an analytical shore train shoals, steepens, breaks and washes up, and the foam / wet-sand bands follow. The demo's Beach, Lake and River scenes all run on it; the CPU height mirror applies the same attenuation so buoyancy agrees.
Depth field
import { ShallowWater } from "tidewater/ShallowWater.js"; const shallow = new ShallowWater({ size: 384, worldSize: 900, waterLevel: 0 }); // Depth in metres below the waterline at world (x, z); ≤ 0 is land. shallow.setDepthFunction((x, z) => 0.018 * (z + 150) + 0.4); // …or bake it from your terrain meshes (one ray per cell, call once per scene): shallow.setDepthFromObjects([terrain, rocks], { fallbackDepth: 60 }); shallow.bake(); // ~40 ms at 384²: depth, shore distance, wave phase const ocean = new OceanFFT({ ..., shallowWater: shallow }); // bind at construction // Queries (the boat can't drive up the beach): shallow.sampleDepth(x, z); // m, ≤ 0 on land shallow.sampleShoreDistance(x, z); // m to the waterline shallow.disable(); // back to deep water everywhere
The field is one half-float texture (depth, shore distance, phase distance, land mask) re-baked in place — change the function, call bake() again, nothing else rebuilds. setRegion(x, z, worldSize) moves it.
Shore train
The breaking waves are a separate, steerable train: a swell of period seconds and deep-water amplitude that travels along the phase distance toward the shore, grows by Green's law as the depth drops, sharpens (steepness), breaks once H/h passes breakRatio (0.78 — the textbook number) and spills a foam lip, a bore and a swash. trainReach is how many wavelengths out from the break zone the train is visible before the FFT swell takes over.
shallow.setWave({ period: 7, amplitude: 1.15, steepness: 0.85, trainReach: 3.2 });
// Sea modes carry a `shallow` block with these; beach / lake / river set them on switch.
Debug views 19–21 (ocean.setDebugView(19)) show the depth field, the shore distance and the breakness so you can see what the train is doing. The same math is on the CPU: shallow.shoreHeight(x, z, t) and ocean.sampleHeight() include the train, so a hull rides up the face of a breaker.
Drop-in boats
attachHull(model, opts) (src/Hull.js) turns any loaded glTF or procedural mesh into a Tidewater vessel in one call: it measures the hull (not the mast — only the lower part of the model counts toward beam and length), turns its long axis to +Z, seats the keel one draft below the waterline inside a pivot group, gives it a buoyancy body, a hull-interior water mask, bow + stern wake generators and a bow-spray emitter. Every piece is optional and only built when its system is passed in.
import { attachHull } from "tidewater/Hull.js"; const trawler = attachHull(gltf.scene, { ocean, buoyancy, waterMask, wakeField, scene, length: 26, // scale the model to this length (m); omit to keep its scale draftFrac: 0.1, // keel depth as a fraction of length kind: "dynamic", // drivable five-probe hull; "floater" = anchored single point maxFwdSpeed: 9, throttleAccel: 6, drag: 0.9, springK: 40, damping: 9, sprayOptions: { count: 400, size: 0.5 }, }); scene.add(trawler.object); trawler.object.position.set(65, 0, 90); // drive it trawler.setInput({ throttle: 0.55, steer: -0.2 }); // per frame, after buoyancy.update(): trawler.update(dt, waterLevel, seaState); trawler.getMotionState(); // { x, y, z, heading, speed, slam, halfLen, halfWid } — same shape as the demo runabout trawler.dispose();
The demo's yacht is a floater hull, its trawler a dynamic one on a waypoint patrol (src/scenes/Harbour.js is the worked example). maskScale shrinks the interior mask box (defaults are conservative — a box that pokes outside the skin cuts a hole in the water beside the hull); forward: "x" etc. overrides the axis guess.
Occluded caustics
Caustics are projected straight down from the surface, so by default a hull still has dancing light painted on the sand beneath it. createSunOcclusion (src/SunOcclusion.js) renders registered occluders from the sun into a small light-space depth map once a frame; the seabed, the caustic projector and the underwater light shafts compare against it (2×2 PCF) and go dark under boats, docks and sea stacks. It is a shadow map whose only consumers are the caustic terms — it never touches the scene's lighting.
import { createSunOcclusion } from "tidewater/SunOcclusion.js"; import { setCausticOcclusion } from "tidewater/Caustics.js"; const occlusion = createSunOcclusion(renderer, scene, { size: 512, extent: 200, tilt: 0.3 }); setCausticOcclusion(occlusion); // before the seabed / projector materials compile occlusion.add(boat); occlusion.add(dock); // anything that should shadow the sea floor // per frame, before the main render (sun = direction TO the sun): occlusion.update(sun, camera.position);
extent is the square (m) the map covers around the focus; tilt leans the light toward the real sun (1 = true sun direction, 0 = straight down — the caustic projection is itself near-vertical, so 0.3 keeps a low sun's shadow under the hull rather than a hundred metres away).
envMapIntensity on WebGPU. With scene.environment alone three uses scene.environmentIntensity for every material and ignores a material's own envMapIntensity. Pass envMap to createCausticProjector({ envMap }) (or bind the texture to the material yourself) and per-material intensities work again — the demo's sand uses 0.05 so wet sand doesn't mirror the sky.
Lights on the water
The surface glints under up to six point / spot lights plus a moon. Hand it three.js Light objects or plain descriptors; the demo's harbour drives four buoy lamps and two dock posts at night, and the Hurricane mode flashes lightning through slot 5.
// three.js lights (position / colour / intensity / distance / decay are read each frame) ocean.setLights([lampA, lampB, spot]); // …or descriptors ocean.setLight(0, { position: { x: 26, y: 2.1, z: 34 }, color: 0xff6a4a, intensity: 40, range: 70 }); ocean.setLight(1, { position: p, direction: dir, angle: 0.5, penumbra: 0.3, color: 0xffc878, intensity: 90, range: 110 }); ocean.clearLights(); // Moon glint: direction TO the moon, colour, intensity (0 = off). Fades in with night. ocean.setMoon(moonDir, 0xa9bfe8, 0.55 * night); // façade: water.lighting.setLights / setLight / clearLights / setMoon
Glints are computed per pixel from the shaded normal (a choppy sea scatters a lamp into a long shimmering path; a calm one keeps a tight reflection), and they respect the sampled-texture budget — descriptors are uniforms, not textures.
Flow maps
A river is not one direction: fast mid-channel, slow at the banks, bending round rocks, reversing in eddies. FlowMap (src/FlowMap.js) stores a world-locked current field (m/s in XZ) the surface advects its fine ripple normals and foam along with the classic two-phase flow-map trick (two samples half a cycle apart, cross-faded, so nothing stretches), and the buoyancy manager reads the same field to drift floaters downstream.
import { FlowMap } from "tidewater/FlowMap.js"; const flow = new FlowMap({ size: 256, worldSize: 1200, period: 3, strength: 1 }); flow.setFlowFunction((x, z) => [0, -2.5 * channelProfile(x)]); // m/s flow.bake(); const ocean = new OceanFFT({ ..., flowMap: flow }); buoyancy.setFlow(flow, 1); // drift factor; 0 detaches flow.sampleFlow(x, z); // [vx, vz] for your own physics flow.disable(); // back to the uniform flowDir scroll
On a device stuck at WebGPU's base 16 sampled textures per stage (and on WebGL2, where 16 is the norm) the surface cannot bind the depth field and the flow map on top of everything else; it keeps the depth field and logs that the flow map fell back to the uniform scroll.
Multiple water bodies
Several OceanFFT instances share a scene: the sea as the usual camera-following radial mesh, a lake or pool as a bounded geometry: "plane" mesh placed at its own height with its own spectrum, colours and bed. Each body decides which side of its surface the eye is on from its mesh height, so setViewer is all they need. See examples/multi-body.html.
const lake = new OceanFFT({ geometry: "plane", size: 216, segments: 180, cascades: [{ worldSize: 64, amplitude: 0.35, minWavelength: 8, maxWavelength: 64, size: 128 }, { worldSize: 16, amplitude: 0.12, minWavelength: null, maxWavelength: 16, size: 128 }], windSpeed: 7, seed: 5, waterColor: 0x2f7a5e, deepColor: 0x103d33, useGPU: true, renderer, envMap, reflector: false, // PMREM sky only — one planar mirror in the scene is plenty foamField: false, }); lake.position.y = 6; lake.uniforms.seabedY.value = 2; lake.layers.set(7); // keep it out of the sea's reflector… camera.layers.enable(7); // per frame for (const b of [sea, lake]) { b.advance(dt); b.setViewer(camera.position); } sea.setReflectionLayers(camera, [3, 7]); // …a second surface must never render inside a reflector
Why the layer dance: a water material captures the framebuffer for its refraction, and that capture must not run inside another body's mirror pass. The sea's reflector renders everything the camera sees except the listed layers (the sky dome's layer 3 is the other usual suspect — the surface reflects the PMREM sky itself).
Preset files
A look tuned in the demo GUI can be saved as a file and shipped with your page. water.exportPreset() writes a JSON document — the sea mode it started from plus every façade dial, the spectrum parameters, the wake field and the time of day; water.importPreset(doc | jsonText) takes it back, applying the base mode first and the dials on top. The demo's Presets folder has export (download), copy and import… buttons.
const doc = water.exportPreset({ name: "evening-reef" }); // { format: "tidewater-preset", version: 1, base: "reef", time: 0.72, // settings: { water, deep, heightScale, surface: {…}, sss: {…} }, fft: {…}, wake: {…} } const res = await fetch("./presets/evening-reef.json"); water.importPreset(await res.text()); // false if it isn't a preset document
Quality tiers
The Quality.js module auto-detects the right tier and you mostly don't need to touch it. Manual override via ?quality=low|med|high|ultra on the URL.
| Tier | GPU FFT N | CPU mirror N | Radial mesh (rings × sectors) | Max DPR | Clouds | SSR |
|---|---|---|---|---|---|---|
| Low | 64 | 32 | 140 × 160 | 0.6 | off | off |
| Med | 128 | 64 | 180 × 208 | 0.85 | on | on |
| High | 256 | 64 | 220 × 256 | 1.0 | on | on |
| Ultra | 256 | 64 | 260 × 320 | 1.5 | on | on |
All tiers run three cascades; the wave physics does not change with quality — only grid resolution, mesh density and post features do.
import { pickQuality } from "tidewater/Quality.js"; // { tier, name, reason } — the budgets live on `tier`; `name` and `reason` // are for your HUD ("auto (webgpu, 3.7MP)"). const { tier, name, reason } = pickQuality({ backend }); renderer.setPixelRatio(Math.min(devicePixelRatio, tier.maxPixelRatio));
Pass force: "ultra" to skip detection
entirely — that outranks the URL override, and is how
<water-canvas quality="…"> is wired.
Dynamic resolution
The water is fill-bound — surface, reflector, framebuffer capture, mask and post all scale with pixels — so rather than pick a tier that suits every laptop your page opens on, let DynamicResolution (src/DynamicResolution.js) hold the frame rate: it watches the presented frame interval and moves the pixel ratio between 50 % and the tier's ceiling, stepping down on dropped frames and creeping back up under a remembered ceiling. Target 60 fps by default (a 120 Hz laptop should not render the sea at half resolution just to fill every vsync); "display" targets the refresh rate.
import { createDynamicResolution } from "tidewater/DynamicResolution.js"; const drs = createDynamicResolution({ maxPixelRatio: Math.min(devicePixelRatio, tier.maxPixelRatio), minScale: 0.5, targetFps: 60, apply: (pr) => { renderer.setPixelRatio(pr); renderer.setSize(w, h); post.setSize(w, h); }, }); // per frame, after render: drs.update(performance.now()); drs.enabled = false; // restores scale 1 drs.stats; // { frameMs, targetMs, refreshMs, scale, pixelRatio, ceiling } // façade: water.performance.setDynamicResolution(on) / setTargetFps(fps) / renderScale / stats // element: <water-canvas dynamic-resolution="off"> or dynamic-resolution="30"
Compute FFT
On WebGPU the cascades run as compute kernels (src/GPUFFTCompute.js): three dispatches per cascade — the spectrum evolution fused into a row FFT that lives entirely in workgroup memory, a column FFT, and a pack into the atlas — instead of the 4·log₂N + 4 render passes the fragment chain needs. Same layout, same numbers to float precision, so the surface, the foam field and the CPU buoyancy mirror are unchanged; the demo at 1400×900 High went from 22.6 to 16.1 ms a frame, the JS side from 8.9 to 4.4 ms. WebGL2 (and grids above 256) keep the fragment chain. ocean.fftMode tells you which is running; new OceanFFT({ fftMode: "fragment" }) forces the pass chain.
Web component
For non-Three.js sites — marketing pages, blog posts, portfolio embeds — drop in the <water-canvas> custom element. No JavaScript wiring required.
<script type="module" src="./tidewater/src/WaterCanvas.js"></script> <water-canvas mode="reef" time="14:00" controls="orbit"> </water-canvas>
| Attribute | Type | Default | Effect |
|---|---|---|---|
mode | preset key | reef | Which sea-mode preset to load. Any of the 14 listed above. |
time | HH:MM | 12:00 | Sun position. Presets carry no canonical hour — this is the whole dial. |
quality | tier name | auto | Force low / med / high / ultra. Read once at start-up: the tier sizes the mesh and the FFT grid, so changing it later does nothing. |
controls | none / orbit | none | Add OrbitControls so the visitor can orbit the camera. |
autoplay | bool | true | Pause / resume rendering. Helpful for off-screen embeds. |
seed | integer | 1 | Phase seed — the same seed renders the same sea everywhere. |
deterministic | flag | off | Fixed-step clock. Pair with el.tick, el.syncToTick(n), el.getSyncState() / applySyncState() and el.netClock = new NetClock(…). |
step | seconds | 1/60 | Tick length in deterministic mode. |
dynamic-resolution | off / fps | on (60 fps) | Frame-rate-driven render scaling between 50 % and the tier's pixel ratio. off disables; a number sets the target fps. el.dynamicResolution is the controller. |
mode, time, controls and
autoplay are live — set them any time and the element follows.
The element runs the same FFT surface as the full demo (GPU cascades, radial mesh, PMREM sky reflection, persistent foam) and exposes it as el.ocean. It fires a tick event every frame with detail.tick. One event object is reused across frames, so the loop allocates nothing — read detail.tick inside your handler rather than stashing the object.
React Three Fiber
r3f/index.js wraps the kit for React Three Fiber v9. It is a plain module with no build step (React.createElement, no JSX) and it ships with declarations; examples/r3f.html runs it straight off esm.sh. <Tidewater> owns the sky dome, the sun (the mode's own sun, or a 0..1 time phase), the PMREM environment, fog/haze and dynamic resolution; <Ocean> builds an OceanFFT against R3F's renderer, applies the mode, advances and centres it every frame and prepares its reflector. Props are live.
import { Canvas } from "@react-three/fiber"; import { Tidewater, Ocean, useTidewater, createTidewaterRenderer } from "./tidewater/r3f/index.js"; function Crate() { const ref = useRef(); const { ocean } = useTidewater(); useFrame(() => { if (ocean) ref.current.position.y = ocean.sampleHeight(4, 6, ocean.simTime); }); return <mesh ref={ref}><boxGeometry /><meshStandardMaterial /></mesh>; } <Canvas gl={createTidewaterRenderer} camera={{ position: [0, 9, -38], fov: 55 }}> <Tidewater mode="sunset"> <Ocean /> <Ocean geometry="plane" size={200} position={[300, 6, 0]} reflector={false} /> <Crate /> </Tidewater> </Canvas>
The renderer has to be Tidewater's (three/webgpu's WebGPURenderer, WebGL2 fallback included) — pass createTidewaterRenderer to the Canvas gl prop; R3F v9 accepts the async factory. The wrapper resets R3F's ACES default to the neutral curve the kit is balanced for. Import-map note: R3F imports react/jsx-runtime, so map it (see the example's import map, which uses external=react,react-dom,three on esm.sh to keep one copy of each).
Wave-tuning guide
Every sea in the kit is the same machine with different numbers. This is the order to turn the knobs in, and what each one actually does.
- Pick the sea state first: the spectrum.
windSpeed(m/s) sets the energy and the peak wavelength — doubling it roughly quadruples wave height and pushes the dominant swell longer.windDirectionis the mean travel direction.cascadeAmplitudes(one per cascade, long → short) shape the balance between swell and chop.jonswapGamma(1 = Phillips, 3.3 = classic JONSWAP) sharpens the spectral peak: high gamma gives a cleaner, more regular swell; low gamma a confused, mixed sea.spectralSharpnessnarrows the directional spread — high values make waves march in ranks, low values make them cross.seedonly changes which ocean you get, not what kind. Set them throughocean.setSeaParams({...})or the mode'sfftblock; they rebuild the spectrum once, not per frame. - Then the shape: heightScale, lengthScale, choppiness. These are live multipliers.
heightScalescales displacement uniformly (1 = physical);lengthScalestretches the wavelengths (long-period ground swell vs. short wind sea) without touching the spectrum;choppinessis the horizontal Gerstner shove — 0.2 is a glassy heave, 1.2 starts to pinch crests into the sharp peaks that fold foam, above 1.6 crests self-intersect on purpose (storms). - Then the skin: ripple and foam.
rippleis the sub-metre normal detail (capillary wind ripple), the main cue for wind strength on a calm day.foamIntensityis the overall whitecap weight;crestFoamIntensitygates it on the fold (Jacobian) so it appears only where crests break;shoreFoamIntensityis the surf/swash band;wakeFoamIntensitythe boat's trail. The foam field has alifetimeandbirthRate— long lifetimes leave streaks downwind of every breaker. - Then the light: colours, sss, ior.
wateris the colour of light scattered back out of the first few metres (what you see looking down at noon),deepthe colour at depth (what a grazing look across the surface fades to).sssis the green-yellow glow through a back-lit crest — raisescalefor tropical water, drop it to near zero for a cold grey sea.ior1.333 is water; touch it only for stylised looks. - Last, the atmosphere.
airFogcolour/density set the horizon haze the surface fades into; the sky's turbidity/rayleigh (the mode'sskyblock) set what it reflects. If the water looks wrong at the horizon, the fix is almost always here, not in the water.
Recipes
| Look | Start from | Then |
|---|---|---|
| Glassy dawn lake | tranquil | windSpeed 3–5, choppiness 0.2, ripple 0.4, crest foam 0, sss scale 0.2, shallow water for a shoreline. |
| Long clean ground swell | swell | jonswapGamma 3.3, spectralSharpness 1.4, lengthScale 1.3, heightScale 1.1, choppiness 0.7, foam low. |
| Short steep wind chop | offshore | windSpeed 14, lengthScale 0.6, choppiness 1.3, ripple 1.0, crest foam 1.2, jonswapGamma 1. |
| Storm | hurricane | windSpeed 30+, choppiness 1.6–1.8, crest foam 1.4, foam lifetime long, airFog density up, lightning on; drop the sky exposure. |
| Tropical shallows | tropical | water bright turquoise, deep teal, sss scale 0.9, caustics 1.2, ShallowWater with a sand bed 2–6 m, shore train amplitude 0.6. |
| Night harbour | moonlit | time 0.95, setMoon on, lamp descriptors on the buoys/dock, SSR on, ripple 0.6 so the lamps streak. |
| River | river | FlowMap with a channel profile, shallow depth field from the banks, choppiness 0.5, foam streaks via flow, floaters with buoyancy.setFlow. |
Glossary
- Cascade
- One FFT tile of the surface. Three cascades at 256 m / 64 m / 16 m tile the spectrum by wavelength so the sea repeats at no visible scale; each has its own amplitude.
- Spectrum (Phillips / JONSWAP)
- How wave energy is distributed over wavelength and direction for a given wind. The FFT turns it into a height field;
jonswapGammablends between the two classic shapes. - Dispersion
- Long waves travel faster than short ones (ω² = g·k in deep water; ω² = g·k·tanh(k·h) over a bed of depth h). It is why a swell outruns its storm and why short waves die first on a beach.
- Choppiness (Gerstner shove)
- The horizontal displacement toward the crests that turns sinusoids into trochoids — sharp peaks, flat troughs.
- Fold / Jacobian
- Where the horizontal displacement makes the surface overlap itself the Jacobian of the map goes negative: that is a breaking crest, and where crest foam is born.
- Shoaling (Green's law)
- A wave entering shallower water slows, shortens and grows (height ∝ h−1/4) until it breaks.
- Breaking ratio H/h
- A wave breaks once its height passes ~0.78 of the local depth — the shore train's trigger.
- Swash
- The sheet of water that runs up the sand after a wave breaks; the wet band it leaves is the run-up.
- Snell window
- From below, the whole sky is squeezed into a 97° cone overhead; outside it the underside of the surface is a mirror (total internal reflection).
- SSS
- Subsurface scattering: light entering a thin back-lit crest and glowing out the front.
- IBL / PMREM
- Image-based lighting from a pre-filtered sky cube; the surface reflects it per pixel by its normal.
- Planar reflector
- A second render of the scene from the mirrored camera, for the boats and rocks the PMREM cannot know about.
- Flow map
- A texture of current velocities; textures advected along it in two half-cycle phases never visibly stretch.
- Depth field
- ShallowWater's texture of bed depth, shore distance and wave phase distance that the surface and the CPU mirror both read.
- Caustics
- The focused light pattern on the sea floor; projected from the live wave atlas and shadowed by the sun-occlusion map.
TypeScript
Tidewater ships hand-authored declaration files under
types/. types/index.d.ts re-exports all of them,
so the simplest wiring is a type-only import from the barrel — no
tsconfig changes at all:
import type { OceanFFT, SeaMode, QualityTier } from "./tidewater/types"; let ocean: OceanFFT; const mode: SeaMode = SEA_MODES.reef;
The declarations are grouped by subject, not one-per-source-file
(Ocean.d.ts covers both OceanFFT.js and
OceanNode.js, Renderer.d.ts covers
renderer.js), so a blanket
"paths": { "tidewater/*": [...] } mapping will not resolve. If
you import through the import map's tidewater/ specifier and
want TypeScript to follow it, declare the modules once in a shim you keep
alongside your source:
// tidewater-modules.d.ts declare module "tidewater/OceanFFT.js" { export * from "./tidewater/types/Ocean"; } declare module "tidewater/SeaModes.js" { export * from "./tidewater/types/SeaModes"; } declare module "tidewater/renderer.js" { export * from "./tidewater/types/Renderer"; } // …one line per module you import
Every public module exports the same names from the JS and from its
declaration file — there is no separate type-only entry point. 1.3 adds
Interaction, ShallowWater, Hull,
SunOcclusion, FlowMap and
DynamicResolution declarations, the WaterLightDescriptor
and TidewaterPreset shapes on WaterSystem, and
r3f/index.d.ts for the React wrapper.
Troubleshooting
WebGPU bind-group error after a dive
If you see 'mipLevelCount' of undefined when the camera
crosses the water plane, you're probably mutating a material that's shared
across multiple meshes (the GLTFLoader caches materials by default).
Clone the material before adding caustics or any other emissiveNode
contribution. caustics.paint() does this for you — if you
wrote a custom equivalent, mirror the per-mesh clone.
Boat sits above the wave
The CPU mirror and the GPU cascades are generated from one wavenumber-keyed
phase hash, so they agree by construction. If a floating body drifts off the
surface, it is almost always reading sampleHeight with a stale
heightScale, or sampling before the first advance()
of the frame — advance the ocean first, then run buoyancy.
Sky reflection is dark, but the dome looks fine
The per-pixel sky reflection samples the PMREM you pass to
setEnvironment(). Pass the PMREM texture (what
PMREMGenerator.fromScene() returns, or scene.environment),
and make sure the dome is visible to the bake — a sky mesh parked on a private
layer must be enabled for the PMREM cube camera. TidewaterSky
handles both.
Writing your own GPU passes against the cascade atlas
Two r184 facts bite every data pass: NodeMaterial.colorNode
clamps its output to ≥ 0 and forces alpha = 1 for opaque materials — use
fragmentNode with NoBlending; and a render-target
texture sampled at v returns what was written at 1 − v
(three stores them top-down on both backends), so flip v when you read one by
explicit coordinate. ocean.atlasInfo carries flipV
for this reason.
Sky looks flat / muted
SkyMesh's internal Lin.mul(0.04) pulls the sky slightly
below the historical photographic cubemap brightness. Bump the renderer's
exposure (renderer.toneMappingExposure = 1.1) or set a
per-mode sky intensity multiplier.
Module cache + dev server
Python's http.server sends no cache headers, and Chrome
heuristically caches ES modules. A ?cb=N on the host document
doesn't bust the module cache. Use a no-store server or change the port
to bypass the per-origin cache.