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>
Three.js version. Tidewater is tested against r184. Newer versions usually work, but WebGPU / TSL are still evolving — pin a version you've verified.

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";

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({ camera, sun, size: 240 });
ocean.applyMode(SEA_MODES.reef);
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

Tidewater ships two ocean implementations behind the same uniform shape and setter API. They are interchangeable: switch by changing the constructor.

ModulePathWhen to use
OceanFFTtidewater/OceanFFT.jsDefault. Three FFT cascades, real spectrum, full physics. Costs more GPU.
OceanNodetidewater/OceanNode.jsGerstner waves (analytical). Cheaper, used on Low quality tier and as the WebGL fallback for the swell layer.

OceanFFT

The constructor accepts an options object:

new OceanFFT({
  camera,                  // THREE.Camera — required, used for the reflector
  sun,                     // THREE.Vector3 — normalised sun direction (world)
  size:    240,            // world-space side of the rendered patch (m)
  cascades: 3,             // 1, 2, or 3 — pulled from quality tier by default
  N:        64,            // FFT resolution per cascade (power of two)
  segments: 256,           // mesh tessellation across the patch
  normalMap,               // THREE.Texture — tileable broad normals map
  detailNormalMap,         // THREE.Texture — tileable capillary normals map
  foamMap,                 // THREE.Texture — tileable whitewater breakup map
});

Once instantiated, the most useful public methods are:

MethodEffect
ocean.update(t)Step the spectrum and the foam Jacobian. Call once per animation frame with elapsed seconds.
ocean.applyMode(mode)Swap to a sea-mode preset (see Sea modes).
ocean.sampleHeight(x, z)CPU-mirror surface height at (x, z) world coords. Frame-perfect — no GPU readback latency.
ocean.setWaveProfile(profile)Override the analytical Gerstner swell band table layered on top of the FFT.
ocean.setSeaParams(fft)Push wind speed / direction / cascade amplitudes into the Phillips spectrum and rebuild h₀(k).
ocean.setReflector(camera)Re-bind the planar reflector to a new camera.
ocean.dispose()Free render targets, materials, and geometry.

OceanNode (Gerstner)

The Gerstner ocean is the WebGL2 fallback path and the Low-tier choice. It shares the same shading set (reflector, refraction, depth absorption, normal cascade, Fresnel, foam, SSS, Snell window, sun glint, wake field) — the only difference is the vertex displacement source. applyMode, sampleHeight, and setWaveProfile all 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] },
]);

For the FFT path the same call is a no-op for displacement (the FFT owns that) but still updates the swell-layer mix uniform if you want a touch of Gerstner on top.

Sea modes

A sea mode is a single object describing the entire ocean look: waves, foam, sky, fog, water tint, ambient audio, time-of-day baseline, and which scene props show up. The bundled SEA_MODES object exports all 13.

The 13 presets

Ten ocean sea-states plus three water-body presets:

KeyCharacterWave height (typ.)
reefBright tropical cove, kelp + coral0.5 m
tropicalOpen warm sea, light chop1.2 m
offshoreWind-driven blue-water2.6 m
swellHuge rolling open-ocean swellsup to 16 m
sunsetWarm low sun, amber sky1.6 m
tranquilGlassy mirror-flat lavender twilight0.2 m
moonlitDark blue, sparse highlights1.0 m
foggySoft pastels, low visibility0.8 m
arcticPale sky, ice floes, choppy1.4 m
hurricaneStorm sea, spray particles, copper sky9 m
lakeCalm freshwater + shoreline foam0.1 m
riverDirectional flow, drifting foam streaks0.15 m
poolBounded clear water, no sky reflections0.05 m

Mode object shape

{
  key:        "reef",
  label:      "Reef cruise",
  waves:      [ /* Gerstner band table */ ],
  heightScale: 1,
  ripple:      1,
  lengthScale: 1,
  fft: {
    windSpeed:      5,
    windDirection: [1, 0.3],
    cascadeAmplitudes: [1, 0.6, 0.3],
  },
  water:    { surface: 0x2c7d76, deep: 0x0c3138 },
  airFog:   { color: 0x6f97a5, density: 0.0010 },
  seaFog:   { color: 0x14555f, density: 0.005 },
  floor:    { tint:  0x4ac0d8, caustics: 1.15 },
  sky:      { turbidity: 2.2, rayleigh: 1.6, mie: 0.005, cloudCoverage: 0.3 },
  sss:      { color: 0x9cf2e0, distortion: 0.18, power: 3 },
  time:     0.5,             // canonical hour for this mode
  audio:    { wind: 0.2, surf: 0.4 },
  props:    ["reefLife", "dock", "yacht"],
}

Switching modes

One call rewires the whole scene. Tidewater's helper rebakes the IBL, retunes fog, swaps prop visibility, repaints foam textures and adjusts SSS:

import { applySeaMode } from "tidewater/SeaModes.js";
applySeaMode("hurricane");

If you've assembled the scene yourself (e.g. using just OceanFFT without the bundled scene container), you can apply individual aspects of a mode directly:

ocean.applyMode(SEA_MODES.hurricane);
tidewaterSky.applyMode(SEA_MODES.hurricane);
underwaterFog.setFromMode(SEA_MODES.hurricane);

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. Each preset's "canonical" hour is its mode.time value; the dial scrubs around that baseline.

import { TimeOfDay } from "tidewater/TimeOfDay.js";

const tod = new TimeOfDay({ ocean, sky: tidewaterSky, sun, post });
tod.captureBaseline(SEA_MODES.reef);   // remember Reef's noon look
tod.setTime(0.78);                       // scrub to dusk; everything retunes

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:

Single-point

For buoys, debris, lifebuoys, anything where pitch and roll don't matter:

import { Buoyancy } from "tidewater/Buoyancy.js";

const floater = new Buoyancy(ocean, mesh);
// in your animate loop:
floater.update();

Multi-point

For ships, boats, anything with a hull: provide local-space sample points along the keel. The hull lifts on each sample independently and Tidewater solves for translation + tilt:

const hull = new Buoyancy(ocean, mesh, {
  points: [
    [ 2.2, 0,  0.6],   // bow port
    [ 2.2, 0, -0.6],   // bow starboard
    [-2.2, 0,  0.6],   // stern port
    [-2.2, 0, -0.6],   // stern starboard
  ],
  damping: 0.92,
});

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.

import { Ripples } from "tidewater/Ripples.js";

const ripples = new Ripples({
  grid: 128,
  size: 240,
  decay: 0.985,
});
ocean.attachRipples(ripples);

canvas.addEventListener("click", (ev) => {
  const hit = projectScreenToOcean(ev, camera, /*y=*/ 0);
  ripples.stamp(hit.x, hit.z, /* impulse */ 2.5);
});

The boat's hull integration already calls stamp() at each keel sample when the keel crosses a crest, so the boat 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 propcreateCausticProjector() walks any Object3D and projects the same Worley caustic field onto each material's emissiveNode. 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.

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.

TierFFT cascadesFFT NMax DPRCloudsSSR
Low1321offoff
Med2641.25onoff
High3641.5onon
Ultra31282onon
import { pickQuality } from "tidewater/Quality.js";
const quality = pickQuality();
renderer.setPixelRatio(Math.min(devicePixelRatio, quality.maxPixelRatio));

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>
AttributeTypeDefaultEffect
modepreset keyreefWhich sea-mode preset to load. Any of the 13 listed above.
timeHH:MMpreset defaultOverride the time-of-day dial.
qualitytier nameautoForce low / med / high / ultra.
controlsnone / orbitnoneAdd OrbitControls so the visitor can orbit the camera.
autoplaybooltruePause / resume rendering. Helpful for off-screen embeds.
audioboolfalseEnable the procedural WebAudio ambience (needs a user gesture).

TypeScript

Tidewater ships hand-authored declaration files under types/. Reference them via a triple-slash directive or by adding them to your tsconfig.json:

// triple-slash (inline)
/// <reference path="./node_modules/tidewater/types/index.d.ts" />

// or via tsconfig
{
  "compilerOptions": {
    "paths": {
      "tidewater/*": ["./tidewater/types/*.d.ts"]
    }
  }
}

Each public module exports the same names from JS and from the declaration file — no separate type-only entry point.

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

Two causes: (a) the boat is reading the GPU surface but using a stale CPU mirror, or (b) you're sampling the wrong cascade root spectrum after a setSeaParams. Call GPUFFTCascade.adoptH0From(spectrum) after every setSeaParams so the CPU and GPU share the same h₀(k).

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.