The pack
water3d
function water3d(opts: Water3dOptions = {}): Pack<{ water: Water3dApi }>
The water-3d pack: Gerstner seas and ribbon streams drawn in their own pass against render-3d's depth,
with the same wave sum on the CPU so buoyancy matches what is on screen. Requires a 3D world and render-3d,
on WebGPU or WebGL2. Throws on an unknown preset or tint, or a wave table longer than WATER_MAX_WAVES.
Helpers
bakeRippleField
function bakeRippleField(size = 512): Uint8Array
Bake the ripple field: size × size rgba8 texels over one tile, row major. RG the gradient, B the field.
gerstnerXZ
function gerstnerXZ( waves: readonly WaveDef[], x: number, z: number, t: number, ampScale: number, steepScale: number, shoal: number, ): [number, number]
The HORIZONTAL Gerstner displacement at a material point. The vertex shader slides each grid vertex sideways
by q · amp · cos so crests gather and troughs spread, with q = steep / (k · amp · 6) — the amplitude
cancels. heightAt inverts this, so a boat sits on the water that is VISIBLY at (x, z) rather than on the
height of water that has advected somewhere else; at high swell that difference is a hull's worth of air.
gerstnerY
function gerstnerY(waves: readonly WaveDef[], x: number, z: number, t: number): number
The height of the summed waves at a MATERIAL point and a time, in world units about the still level: the
CPU twin of the vertex shader's vertical sum, before swell, tide and shoaling. The vertex stage also slides
each point sideways (gerstnerXZ), so the water visible at (x, z) is the material point that LANDS
there; Water3d.heightAt inverts that slide first and samples this at the result, which matters at high
swell, where the two differ by a hull's length.
ribbonVerts
function ribbonVerts(pts: ReadonlyArray<readonly [number, number, number]>, width: number, step = 2): Float32Array
A water STRIP along a course. Each vertex is position (3), uv (2 — u is arc distance, v runs 0..1 across) and slope (1 — 0 flat, 1 vertical, taken from the tangent).
The right vector is horizontal, cross(tangent, up), so the strip lies FLAT on the land; on a near-vertical
drop it falls back to a parallel-transported binormal, so a waterfall face keeps its width. Slope is a
per-vertex fact, which is the whole trick: a waterfall is just a stream whose course falls off a cliff, and
the shader's foam ramp keys straight off it.
RIPPLE_TILE
const RIPPLE_TILE: 16
Noise units per texture tile. The lattices wrap at TILE · 2^octave, which is what makes it seamless.
rippleFbm
function rippleFbm(px: number, py: number): number
The tileable fbm the texture bakes. Periodic on both axes with period RIPPLE_TILE.
steepNorm
function steepNorm(waves: readonly WaveDef[], steepScale: number): number
The GERSTNER SELF-INTERSECTION GUARD. When the summed crest steepness climbs too high the horizontal advection makes crest vertices cross past each other and the surface folds — inverted, flat, jagged shards riding the crests. Both the GPU packing and the CPU twin scale every wave's steepness by this factor, so crests SATURATE instead of folding. Folding needs the sum near 6; 4.8 guards a pathological custom wave table while never touching a shipped preset, which maxes out around 4.5.
WATER_MAX_WAVES
const WATER_MAX_WAVES: 8
The most Gerstner components one body carries: the GPU packs eight. A longer table is refused.
WATER_MAX_WELLS
const WATER_MAX_WELLS: 4
The most wells one body keeps; a fifth retires the oldest. Foam wakes are stamped for at most this many wells across ALL bodies a frame.
WATER_PRESETS
const WATER_PRESETS: Record<string, WaterPreset>
The named waters. A sea and a pond are the same water at different numbers.
WATER_TINTS
const WATER_TINTS: Record<string, WaterTint>
The named sea colours setTint and the tint option accept.
Classes
Water3d
class Water3d
One body of water. Every field marked live may be set any frame.
readonly kind: 'grid' | 'ribbon'—'grid'for a sheet of waves,'ribbon'for a stream along a course.readonly preset: WaterPreset— The preset it was made from.level: number— The still surface height. Live.x: number— The x centre of the body. Live.z: number— The z centre of the body. Live.readonly w: number— The extent on x.readonly d: number— The extent on z.get waves(): WaveDef[]— The Gerstner wave table, at mostWATER_MAX_WAVEScomponents. Live: assigning a new table takes effect next frame, and one that is too long or not finite throws. Replace the wave table; throws on more thanWATER_MAX_WAVEScomponents or a non-finite field.set waves(v: WaveDef[])— The Gerstner wave table, at mostWATER_MAX_WAVEScomponents. Live: assigning a new table takes effect next frame, and one that is too long or not finite throws. Replace the wave table; throws on more thanWATER_MAX_WAVEScomponents or a non-finite field.shallow: string— The shallow-water colour, css. Live.deep: string— The deep-water colour, css. Live.foam: string— The shore foam colour, css. Live.alpha: number— Surface opacity, 0..1. Live.ripple: number— Detail ripple-normal strength, 0..1. Live.absorb: number— How fast depth turns the colour todeep. Live.foamWidth: number— Shore foam band width. Live.flow: number— Ribbon flow speed, world units a second. Live.swell: number— Sea state, 0..1: 0 glass, 1 a storm. Live.tide: number— Peak-to-peak waterline movement from the tide; 0 is off. Live.tidePeriod: number— Seconds for a full tide cycle. Live.readonly width: number— Ribbon width.readonly path: Array<[number, number, number]> | undefined— The course, set only on ribbons.readonly ground: ((x: number, z: number) => number) | undefined— The ground height function, baked once.readonly resolution: number— The requested grid quads per side, or 0 to pick by size;gridResis the grid actually drawn.dead: boolean— True oncekillhas been called.visible: boolean— Whether it is drawn. Live.timeNow: numberdebug: number— Debug views, live: 0 off, 1 the shoal factor (red damped, green full waves), 2 flat after the underside branch, 3 the base optical colour, 4 after env and glint, 5 after foam. The staged returns isolate which stage paints an artifact.wells: WaterWell[]— The live dry boxes.readonly floaters: Floater[]— The live floaters.ribbon?: Float32Arrayconstructor(o: Water3dOptions = {})well(opts: WaterWellOptions = {}): WaterWell— Add a DRY BOX — a boat hull — so waves never rise through the deck. Returns the live handle; a fifth retires the oldest.setTint(name: string): void— Swap the sea colour live: the palette and the optical depth move together. Throws on an unknown name.waveScale(): { amp: number; steep: number; speed: number }gridRes(): numberlevelNow(): numberheightAt(x: number, z: number): number— The surface height at a point RIGHT NOW — the exact wave the GPU draws, swell and tide included, and, when the water has aground, the same SHOALING damp the shader applies, so a hull sampled here never clips the rendered surface anywhere, beach shallows included. For a boat, sample it at bow, stern, port and starboard: that gives height, pitch and roll together. Ribbons return the still level — a stream is shallow decoration, not a swim volume.normalAt(x: number, z: number): { x: number; y: number; z: number }— The surface normal there — what a boat tilts to.submerged(x: number, y: number, z: number): boolean— Is this point under the surface?hullPose(at: { x: number; z: number; rot: number }, spec: FloatSpec = {}): HullPose— The height and attitude a hull at(x, z)headingrottakes right now: the surface sampled at its bow, stern, port and starboard (lengthandwidthapart), averaged for height and differenced for pitch and roll, withoffset,trimandheeladded. Instantaneous:floatsmooths it over time.float(target: FloatTarget, spec: FloatSpec = {}): Floater— FLOAT an object on this water: every simulation step itsy,pitchandrollsettle ontohullPoseat its ownx,zandrot, which the game keeps driving. Returns the live floater; edit itsspecany frame. It stops when killed, when this water is killed or when the target dies.stepFloaters(dt: number): voidkill(): void— Remove the water from the world.
WaterWell
class WaterWell
A DRY BOX on a grid water: inside the oriented box the surface is pressed to floor, so waves never rise
through a watertight hull, and the dip around it reads as displacement. Every field is live — move it with
the boat each frame. Four per water, and heightAt ignores them, so a hull rides the undisturbed wave.
x: number— The x centre of the box. Live.z: number— The z centre of the box. Live.yaw: number— The yaw of the box, radians. Live.w: number— The full x extent. Live.d: number— The full z extent. Live.floor: number— The absolute y the surface is pressed to. Live.feather: number— The feather band outside the box. Live.wake: number— Foam wake strength 0..1. Live.dead: boolean— True oncekillhas been called.constructor(o: WaterWellOptions = {})kill(): void— Remove the well from its water.
Constants
FOAM_WGSL
const FOAM_WGSL: "\nstruct FU {\n shift : vec4<f32>, // uv shift xy, decay factor, texel size in uv\n stamps : array<vec4<f32>, 8>, // 4 stamps, 2 each: (u, v, yaw, intensity), (halfU, halfV, 0, 0)\n};\n@group(0) @binding(0) var<uniform> fu : FU;\n@group(0) @binding(1) var src : texture_2d<f32>;\n@group(0)... …
The FOAM STAMP buffer: a small r8 texture following the camera over 360 world units. Each frame it decays and diffuses the previous frame, shifted by the camera's texel delta, into a temporary target, stamps every well's footprint with an intensity that rides its speed, and copies back. The water's fragment stage samples it as foam — curved, time-fading wakes and waterline collars for anything with a well, for one tiny pass and one tap.
WATER_WGSL
const WATER_WGSL: "\nstruct Cam {\n viewProj : mat4x4<f32>,\n eye : vec4<f32>,\n sunDir : vec4<f32>, // xyz = direction the light travels, w = intensity\n sunCol : vec4<f32>,\n skyCol : vec4<f32>, // w = ambient strength\n groundCol: vec4<f32>,\n fogCol : vec4<f32>,\n params : vec4<f32>, // shininess, spe... …
The water shader, as one WGSL module: two vertex stages, one fragment stage.
Types
Floater
interface Floater
A live floater: the object float keeps on the waves each step.
readonly target: FloatTarget— The object being floated.readonly spec: FloatSpec— Its footprint and tuning; edit any field live (trimfrom speed, a largeroffsetin a storm).kill(): void— Stop floating it; the object keeps its last pose.readonly dead: boolean— True once killed, bykill, by its water being killed, or by its target dying.
FloatSpec
interface FloatSpec
A hull's footprint on the water, for Water3d.hullPose and Water3d.float. Every field is live on a floater.
length?: number— Bow to stern, world units: where the fore and aft samples sit. Default 8.width?: number— Beam, world units: where the port and starboard samples sit. Default 4.offset?: number— How far the object's origin rides above the mean of the four samples, world units: the deck height above the waterline. Default 0.smoothing?: number— Seconds for the height and tilt to settle onto the wave (a time constant; a hull has inertia and raw samples jitter on chop). 0 snaps. Default 0.125.trim?: number— Extra pitch added to the wave's, radians, nose-down positive: a planing hull's squat, set live from speed. Default 0.heel?: number— Extra roll added to the wave's, radians: a heel into a turn. Default 0.
FloatTarget
interface FloatTarget
What Water3d.float moves: a world-3d handle, or anything with its pose fields. rot is the yaw.
x: number— World x, read.y: number— World y, written.z: number— World z, read.rot: number— Yaw, radians, read: 0 faces +z, increasing turns right.pitch: number— Pitch, radians, written.roll: number— Roll, radians, written.readonly alive?: boolean— False once a roster handle is killed; the floater then stops.
HullPose
interface HullPose
The height and attitude a hull takes on the water: what Water3d.hullPose returns.
y: number— World y for the object: the mean of the bow, stern, port and starboard surface heights, plusoffset.pitch: number— Nose-down-positive pitch, radians, as world-3d's handles take it: negative when the bow is higher than the stern.roll: number— Roll, radians, as world-3d's handles take it: positive when the port (left) side is higher.
Water3dApi
interface Water3dApi
game.water. The named fields address the MAIN body — the one build makes — and add gives you more.
build(opts?: Water3dOptions): Water3d— Build or rebuild the main body.add(opts?: Water3dOptions): Water3d— Add another body: a second lake, a stream, a waterfall.readonly list: readonly Water3d[]— Every live body.readonly main: Water3d | null— The main body, if one has been built.heightAt(x: number, z: number): number— The main body's surface height at a world point, right now — what a boat floats on.normalAt(x: number, z: number): { x: number; y: number; z: number }— Its surface normal there — what a boat tilts to.submerged(x: number, y: number, z: number): boolean— Is this point under the main body's surface?well(opts?: WaterWellOptions): WaterWell— Add a dry hull box to the main body, building a default sea first if there is none.setTint(name: string): void— Swap the main body's sea colour; throws on an unknown name.hullPose(at: { x: number; z: number; rot: number }, spec?: FloatSpec): HullPose— The height and attitude a hull takes on the main body right now (seeWater3d.hullPose).float(target: FloatTarget, spec?: FloatSpec): Floater— Float an object on the main body, building a default sea first if there is none (seeWater3d.float).level: number— The main body's still surface height.waves: WaveDef[]— Its wave table, at mostWATER_MAX_WAVEScomponents.swell: number— Its sea state, 0..1.tide: number— Its tide, peak to peak in world units.visible: boolean— Whether the water is drawn at all.debug: number— The main body's debug view, 0..5.
Water3dOptions
interface Water3dOptions
Configures one body of water. Anything left unset comes from the preset.
preset?: string—'sea'(the default),'ocean','lake','pool'or'stream'. Apathmakes it a ribbon regardless.level?: number— The still surface height. Ribbons carry their height in the course. Default 0.x?: number— The x centre of the body. Default 0.z?: number— The z centre of the body. Default 0.w?: number— The extent on x. Default 2400.d?: number— The extent on z. Default 2400.size?: number— Both extents at once, for the common square body.wanddwin if they are also given.waves?: WaveDef[]— The wave table, if the preset's will not do.shallow?: string— The shallow-water colour, css.deep?: string— The deep-water colour, css.foam?: string— The shore foam colour, css.alpha?: number— Surface opacity, 0..1.ripple?: number— Detail ripple-normal strength, 0..1.absorb?: number— How fast depth turns the colour todeep— higher makes shallower water read deep.foamWidth?: number— Shore foam band width, world units.path?: Array<[number, number, number]>— RIBBON: the course, as world points running downhill.width?: number— RIBBON: strip width. Default 3.flow?: number— RIBBON: flow speed in world units a second. Default 4.swell?: number— SEA STATE, 0..1. Default 0.5, which is the preset as authored: it scales wave heights and crest sharpness together, so 0 is glass and 1 is ferocious. Live.tide?: number— TIDE: how far the waterline breathes, peak to peak, in world units. Default 0. The level slides sinusoidally overtidePeriod; on a shallow beach even a unit or two reads as the sea drawing back and running in.tidePeriod?: number— Seconds for a full tide cycle. Default 45.tint?: string— A SEA COLOUR:'caribbean','azure','temperate','emerald'or'northsea'.ground?: (x: number, z: number) => number— The GROUND function — pass the terrain'sheightAtwhen the water meets land. It is baked to a height texture once, which gives the wave shader EXACT, camera-independent depth: waves shoal against the real beach, the sheet parks below dry sand with no z-fighting and no view-dependent popping, and the waterline is stable however the camera moves. Without it, depth is estimated from the frame's depth buffer, which is fine on open water and unstable in a shallow swash zone.resolution?: number— Grid quads per side. There are two shared grids, 288 and 96: a value of 288 or more draws the fine one and anything lower the coarse one. Default: fine for a body wider than 900 units, coarse otherwise.
WaterPreset
interface WaterPreset
One named water: its wave table, its colours and its optics.
waves: WaveDef[]— The Gerstner wave table.shallow: string— The shallow-water colour, css.deep: string— The deep-water colour, css.foam: string— The shore foam colour, css.alpha: number— Surface opacity, 0..1.ripple: number— Detail ripple-normal strength, 0..1.absorb: number— How fast depth turns the colour todeep.foamWidth: number— Shore foam band width, world units.
WaterTint
interface WaterTint
One sea colour: the palette, the absorption rate and the opacity.
shallow: string— The shallow-water colour, css.deep: string— The deep-water colour, css.absorb: number— How fast depth turns the colour todeep.alpha: number— Surface opacity, 0..1.
WaterWellOptions
interface WaterWellOptions
Configures a WaterWell — the dry box that presses the surface down around a hull.
x?: number— The x centre of the box. Default 0.z?: number— The z centre of the box. Default 0.yaw?: number— The box's yaw in radians — match the hull's. Default 0.w?: number— Its full x extent: the hull footprint plus a margin. Default 4.d?: number— Its full z extent. Default 8.floor?: number— The absolute y the surface is pressed to inside the box. Set it just below the hull's bottom.feather?: number— The feather band outside the box before the surface returns. Default 1.5.wake?: number— Foam WAKE strength 0..1 trailing behind it; 0 is none. Default 0.7.
WaveDef
interface WaveDef
One Gerstner component of a water surface. Six of them make a sea; one makes a swell.
dir: { x: number; z: number }— The direction it travels across the xz plane. It does not have to be normalised.amp: number— Amplitude in world units — a crest reacheslevel + amp.len: number— Wavelength in world units.speed: number— Phase speed in world units a second.steep: number— 0 is a pure sine, 1 is a sharp trochoidal crest.


