threejs-water
v0.1.0
Published
Oceans, lakes and rivers for three.js: WebGPU water surfaces, river carving and drainage-basin hydrology
Maintainers
Readme
threejs-water
Oceans, lakes and rivers for three.js (WebGPU + TSL), from an ant on the beach to orbit. Works with a
threejs-biomes world (flat or planet), and
on its own for a plain flat sea. Includes optional React Three Fiber components.
npm i threejs-water threejs-biomes threeOcean: one draw call at every altitude. A camera-centred grid of concentric rings (dense under the camera, sparse at the horizon, scaled with the camera's height) built in the local tangent plane and bent onto the planet in the vertex shader, clamped at the horizon. The waves are a GPU FFT ocean (Tessendorf) in TSL compute: a JONSWAP wind sea with directional spreading, sampled into 2–4 cascades of different tile sizes, with choppy displacement, optional Gerstner swell on top, and float64 tile offsets (stable millions of metres from the origin). Slopes and slope² are mip-mapped and anisotropically filtered, so the variance the filtering removes becomes GGX roughness (LEAN mapping): the far sea turns into a smooth, glinting reflection of the sky instead of noise. Roughness-aware Schlick Fresnel (IOR 1.33) withscene.environmentor a sky gradient, a GGX sun glint, refraction, Beer–Lambert absorption per colour channel, in-scattering, sunlight through back-lit crests, whitecaps from the Jacobian of the displacement (persisting and fading), surf on the shallows and a soft shoreline. Floating origin: everything is placed relative toorigin. Without WebGPU compute (the WebGL2 fallback) the detail is synthesised once on the CPU and the strongest spectrum components move as Gerstner waves.Rivers: river surfaces for the world's rivers, streamed in camera-centred tiles under a per-frame time budget. Flow follows the slope of the river surface (faster mid-channel); ripples from a wind-chop spectrum are flow-mapped downstream, with rapids foam stretched along the flow where the water runs fast. Water at sea level is left to the ocean (and river ribbons stop at lakes), so surfaces never overlap.- Hydrology for bounded maps (pure functions, no three.js): Priority-Flood+ε depression filling (Barnes et al.
2014), D8 flow routing, flow accumulation, rivers by catchment area (widening downstream), lakes at their spill
level, and
pathheight modifiers that carve the rivers into the world.Lakesdraws the result. - Queries for gameplay and buoyancy:
ocean.waterAt(x, y, z)(wavy surface, depth, normal),waterAt(world, x, z)(sea or river level, depth, flow) andhydrologyWaterAt(result, x, z).
three.js
import * as THREE from 'three/webgpu'
import { World } from 'threejs-biomes'
import { Ocean, Rivers, computeHydrology, riverModifiers, Lakes, waterAt } from 'threejs-water'
const renderer = new THREE.WebGPURenderer({ antialias: true })
await renderer.init()
const world = new World({ seed: 'coast' })
const sun = new THREE.DirectionalLight('#fff4e0', 3)
const ocean = await Ocean.create({ camera, scene, renderer, world, sun, wind: { speed: 9 } })
const rivers = await Rivers.create({ camera, scene, renderer, world, sun, maxDistance: 2000 })
// With `scene`, both update themselves whenever the scene renders.
ocean.set({ deepColor: '#06283a', whitecaps: 0.6, wind: { direction: [0, 1] } }) // live
ocean.setOrigin([x, y, z]) // floating origin, e.g. from threejs-biomes FloatingOrigin.onRebase
const { depth, normal } = ocean.waterAt(boat.position.x, boat.position.y, boat.position.z) // buoyancy
const here = waterAt(world, 120, -40) // { kind: 'river', level, depth, flowX, flowZ }
// Bounded map: real drainage, lakes and carved rivers.
const region = { x: -2000, z: -2000, width: 4000, depth: 4000, cellSize: 8 }
const hydrology = computeHydrology(world.height, region, { seaLevel: world.options.seaLevel, riverArea: 400_000 })
for (const m of riverModifiers(hydrology, world.frame)) world.addModifier(m)
const lakes = await Lakes.create({ hydrology, frame: world.frame, camera, scene, sun })On a planet (new World({ surface: 'sphere', radius: 120_000 })) nothing changes: the ocean bends onto the sea
sphere and the rivers build in a tangent frame that re-centres on the camera as it travels.
React Three Fiber
import { Canvas } from '@react-three/fiber'
import { WebGPURenderer } from 'three/webgpu'
import { Water, Ocean, Rivers, Lakes } from 'threejs-water/react'
<Canvas gl={async (props) => { const r = new WebGPURenderer(props as any); await r.init(); return r }}>
<Water world={world} sun={sun} ocean={{ wind: { speed: 9 } }} rivers={{ maxDistance: 2000 }} />
{/* or separately: */}
<Ocean world={world} deepColor="#06283a" whitecaps={0.6} />
<Rivers world={world} flowSpeed={1.4} />
<Lakes hydrology={hydrology} frame={world.frame} />
</Canvas>Props are the settings below. Every render calls reset(props), so removing a prop reverts it. Use ref for the
underlying class (OceanImpl, RiversImpl, LakesImpl are re-exported).
Settings
Every setting has a default and is live through set(partial) / reset(full). All are plain JSON except sun,
environment (and the world, camera, scene, renderer constructor options).
Appearance (Ocean, Rivers, Lakes)
| Setting | Default (ocean) | What it does |
|---|---|---|
| shallowColor | '#5fd5e6' | Colour white light takes after visibility metres of water: sets the red, green and blue absorption (Beer–Lambert). |
| deepColor | '#0c3d6c' | Colour of bottomless water in daylight (light scattered back by the water). |
| visibility | 5 | Metres for shallowColor. Larger = clearer water. |
| refraction | 0.03 | Screen-space refraction strength. |
| roughness | 0.02 | Micro-roughness on top of the waves' own (which comes from their filtered slopes). |
| reflectivity | 0.02 | Fresnel reflectance looking straight down (IOR 1.33). |
| skyColor, horizonColor | '#5d8fd0', '#c8dcec' | Sky gradient reflected when there is no environment. |
| environment | null | Texture (equirect or cube) to reflect, prefiltered for you; null = scene.environment, else the sky gradient. |
| screenSpaceReflections | false | Also write normal and metalrough (= Fresnel, roughness) MRT outputs for an SSR pass, which should blend its hits over the water by metalrough.x. Only enable inside an MRT pass with those outputs. |
| environmentIntensity | 1 | Multiplier on environment. |
| specular | 1 | Multiplier on the sun glint. |
| sun | null | DirectionalLight for glint and daylight; null = first one in the scene. |
| foamColor | '#f2f5f5' | Foam colour. |
| foamWidth | 0.8 | Ocean: depth (m) where crests break on the shore (structured by a ripple field's Jacobian 'lace'). Lakes/rivers: width of the soft shoreline. |
| foam | 1 | Overall foam amount (surf, whitecaps, rapids). |
| detail | 1 | Multiplier on wave and ripple normals (1 = physical). |
| detailScale | 1.2 | Rivers and lakes' rivers: finest ripple tile, m. |
Ocean
| Setting | Default | What it does |
|---|---|---|
| wind.speed | 7 | Wind 10 m above the water, m/s (3 breeze, 8 whitecaps, 15 gale). |
| wind.direction | [1, 0.35] | Direction the waves run, [x, z] in the tangent plane. |
| wind.fetch | 120000 | Open water upwind, m: short fetch = smaller, shorter waves (JONSWAP). |
| spectrum.resolution | 256 | FFT size per cascade (64 … 512, power of two). |
| spectrum.cascades | [541, 97.3, 17.9] | Tile size per cascade, m; non-integer ratios hide the tiling. |
| spectrum.choppiness | 0.85 | Horizontal displacement that sharpens crests (0 … 1.5). |
| spectrum.spread | 1 | Directional spread: 0.5 long-crested, 1 natural, 2 confused. |
| spectrum.amplitude | 1 | Multiplier on the physical wave heights. |
| spectrum.seed, spectrum.repeat | 1, 1024 | Random phases; seconds after which the field repeats (keeps float32 time exact). |
| waves | { count: 3, amplitude: 0.25, wavelength: [90, 170], … } | Gerstner swell on top of the wind sea (count, seed, amplitude, wavelength, direction, spread, choppiness, speed); count: 0 = none. |
| whitecaps | 0.5 | Whitecap foam from the Jacobian, thresholded per cascade: 0 = none, 0.5 = the measured coverage for the wind (Monahan: 3.84·10⁻⁶·U^3.41), each +0.5 doubles it. |
| foamDecay | 2.5 | Seconds for whitecap foam to fade to ~37%. |
| origin | [0, 0, 0] | World position at render-space (0, 0, 0). |
| seaLevel, radius | 0, 0 | Used only without a world (radius 0 = flat). |
| segments | 128 | Vertices per ring: spacing ≈ distance × 2π / segments. |
| range | 1e7 | Outer ÷ inner ring radius (sets the ring count). |
| innerRadius | 0.01 | Smallest inner ring radius, m. |
| altitudeFactor | 0.02 | Inner ring radius as a fraction of the camera's height above the sea. |
| reanchorDistance | 10000 | Planets: metres travelled before the wave frame re-anchors (phases stay continuous). |
Rivers
| Setting | Default | What it does |
|---|---|---|
| tileSize | 128 | Tile edge, m. |
| resolution | 48 | Grid cells per tile side. |
| maxDistance | 1000 | Radius with rivers, m. Nothing is built while the camera is higher than this. |
| buildBudget | 4 | Milliseconds per frame for building tiles. |
| expand | 2 | Cells the surface extends past the water, to tuck under banks. |
| surfaceOffset | 0 | Raises or lowers the surface, m. |
| minStrength | 0.02 | World river strength where water starts. |
| flowSpeed, minFlowSpeed, maxFlowSpeed | 1, 0.15, 4 | Speed at a 1% slope (grows with √slope) and its limits, m/s. |
| flowPeriod | 2 | Seconds per flow-map cycle. |
| rapids | 2.5 | Speed (m/s) where rapids foam starts. |
| reframeDistance | 20000 | Planets: re-centre the tile frame on the camera after this many metres. |
| seaPriority | true | Leave water at or below sea level to the ocean (no overlap at river mouths). |
| maxDepth | 4 | Deepest river cell, m; deeper = the ground falls away (basin, cliff), so the sheet would float and is left out. |
| bounds | null | Only build inside this frame-local [minX, minZ, maxX, maxZ] (e.g. where your terrain is). |
| origin | [0, 0, 0] | Floating origin. |
River appearance defaults: shallowColor '#7fbf9a', deepColor '#0f2a22', visibility 2.5, foamWidth 0.25,
detailScale 0.9.
Lakes
Appearance, origin, wind (default { speed: 3.5, fetch: 600 }: light, fetch-limited ripples on a near-mirror),
spectrum (resolution 128, cascades [31.7, 5.9]), waves (extra Gerstner, count 0), whitecaps (0), foamDecay, expand
(1 cell), rivers (true: draw the hydrology rivers too), flowSpeed, minFlowSpeed, maxFlowSpeed,
flowPeriod, rapids.
Hydrology (computeHydrology(height, region, options))
region is { x, z, width, depth, cellSize } (min corner and size in the frame, metres).
| Option | Default | What it does |
|---|---|---|
| epsilon | 1e-4 | Gradient (m per cell) put across filled flats so every cell drains. |
| seaLevel | -Infinity | Cells at or below are sea: outlets, never filled. |
| riverArea | 250000 | Catchment (m²) where a channel becomes a river. |
| widthFactor, widthExponent | 4, 0.5 | Width = widthFactor × (area / 1 km²)^widthExponent. |
| minWidth, maxWidth | 1.5, 120 | Width limits, m. |
| depthFactor, minDepth | 0.12, 0.4 | Channel depth as a fraction of width, and its minimum. |
| fill | 0.75 | How full channels are. |
| bankFalloff | 1.5 | Bank slope width × channel half-width (the modifiers' falloff). |
| minLakeArea, minLakeDepth | 400, 0.3 | Smallest lake kept (m², m). |
| smoothing | 2 | Smoothing passes over river polylines. |
The result has filled, receivers, accumulation, lakeIndex (per cell), rivers (polylines with surface, bed,
width and area per point) and lakes (level, area, max depth, cell mask). The steps are exported on their own too:
sampleGrid, fillDepressions, flowReceivers, flowAccumulation, analyzeHydrology, riverModifiers.
Imports from three/webgpu, like threejs-grass. Full API docs: https://askalice.github.io/threejs-worldgen/docs/.
License
Apache-2.0
