Taos Engine

Rendering

Ocean

A WebGPU ocean renderer built into the engine's render graph. The sample (ocean_test.html) is the visual harness for the subsystem. Each section below describes one capability of the renderer and the code that implements it.

Reflection probe + atmosphere + clouds#

The ocean surface shader now optionally samples a cube texture instead of the static sky panorama for its reflection, so a ReflectionProbeFeature capturing atmosphere + clouds + scene gets visibly bounced off the water.

OceanPass changes:

ReflectionProbeFeature extension:

Sample wiring (samples/ocean_test.ts):

OceanFeatureOptions.skyTexture is now optional — when omitted, OceanFeature binds a 1×1 black panorama into the BGL spare slot so the binding stays valid, and the host is expected to wire a real reflection probe via setReflectionProbeView. The sample drops the static clear_sky.hdr entirely; the surface samples atmosphere + clouds exclusively through the probe.

Cloud settings — defaults (cloudBase=5, cloudTop=15) park clouds too low to read at our camera altitude (y≈3); the sample overrides to cloudBase: 80, cloudTop: 240, coverage: 0.7, density: 1.8 so cumulus shapes float clearly above the camera and into the probe cube. Discovered iteratively after the first attempt with coverage: 0.6, density: 1.4 showed an empty sky.

Underwater curtain#

New full-screen pass that runs after OceanPass and before tonemap (ocean_underwater_pass.ts + ocean_underwater.wgsl).

Layered FFT + softer foam#

Analytical seafloor depth#

Subsurface scatter strength + sample look defaults#

The SSS multiplier in the fragment is 0.45× so the wave-face teal glow matches the reference renderer's saturation. Sample look defaults retuned: shallowColor (0.05, 0.44, 0.55), scatterColor (0.16, 0.75, 0.65), depthFogDensity 0.18, skyIntensity 0.65 (lower so the gray-sky HDR doesn't wash the surface out), foamIntensity 0.35, J-foam threshold default 0.30. Vertex FFT-foam scale dropped from 3× to 1.4× — combined with the lower threshold, foam now appears only where the surface is actually folding, not on every chop crest.

Breaking waves — chop, Jacobian foam, subsurface scatter#

These three features close the visual gap with reference FFT renderers on breaking waves. All three live in the same compute pass and surface shader, no new bindings.

FFT-spectrum heightfield#

A Phillips-spectrum heightfield layered on top of the Gerstner cascade + dynamic-wave sim. Gives the surface a continuous wave-number range — the rolling multi-scale crests that Gerstner's 8-spec spectrum can only approximate.

Architecture (kept deliberately small):

Public API: OceanFeatureOptions.fftSim?: OceanFftSimOptions ({ N, tileSize, windSpeed, windDir, amplitude, capillarySuppression, seed, strength }). The pass is always created, even when omitted, so the surface BGL always has a real texture to bind — disabled mode uses N = 4 + strength = 0 so the per-frame DFT collapses to a trivial cost.

Sample demo layers a Phillips spectrum on top of the existing Gerstner swell + dynamic-wave + foam sim. The combined surface has visibly richer multi-scale wave structure than Gerstner alone produces. Strength 0.3 keeps the FFT contribution complementary to the swell rather than dominating it.

Not yet done on this layer:

(The single-FFT tile repeat and the missing chop are handled by the layered-FFT and breaking-wave features described above.)

Dynamic-sim surface integration#

CPU surface query#

Dynamic-wave + foam sim#

A persistent dynamic-wave + foam compute sim layered on top of the composable-wave LOD cascade. Boat splashes (and any other registered OceanDynamicSource) leave a proper trail behind them — the per-frame disturbance propagates outward via a 2D wave equation and decays over time, instead of riding on the source's current position the way the stateless WaveInputRippleSource does.

The sim is a single rgba16float ping-pong texture (256² × 64 m × 64 m by default), centered at a configurable world XZ. Per frame:

  1. Verlet wave step — h_new = (2·h_cur − h_prev + coupling · ∇²h_cur) · waveDecay. coupling = (c·dt/dx)² is computed host-side and clamped to ≤ 0.4 (the 2D wave equation's CFL bound). Frame dt is capped at 1/30 s so a frame-rate dip can't blow up the sim.
  2. Source injection — every registered OceanDynamicSource pulls h_new toward amplitude · gauss(r/radius) at a fixed rate (~10/s, so a fresh splat ramps in over ≈ 100 ms). The pull model saturates as h_new reaches the target, so a stationary source produces a bounded bulge — important because the boat orbit dwells in each "place" for many frames before moving a splat-width, and an additive h_new += amp · dt model accumulates amp / (1 − waveDecay) · dt ≈ 8 · amp of energy at the same texel (a 30 m wake column from amp = 4 m/s, a trap worth avoiding).
  3. Foam update — foam_new = max(foam_old · foamDecay, max(0, h_cur − foamThreshold) · foamSpawn · dt). Foam accumulates wherever a crest sits above the spawn threshold and decays exponentially.

Channel layout: r = h_cur, g = h_prev (Verlet state), b = foam. Ping-pong between two persistent textures sidesteps the WebGPU read-after-write rule (compute reads from src, writes dst; host swaps each frame). Both textures are created with STORAGE_BINDING | TEXTURE_BINDING via extraUsage so they can be both compute-written and sampled across frames.

Surface integration: the OceanPass binds the sim as binding 5 (texture_2d<f32>, VERTEX | FRAGMENT) in its scene bind group, plus four floats in OceanUniforms for the sim region (dynSimCenterX/Z, dynSimSize, dynSimStrength). The surface vertex shader samples the sim's r channel at the post-snap world XZ and adds it to vertical displacement; the b channel folds into the foam factor that's already interpolated to the fragment. World XZ outside the sim region (or dynSimSize <= 0 for the disabled case) returns zero so the contribution naturally fades at the sim edge.

Public API: OceanFeature.addDynamicSource(source) / removeDynamicSource(source). A source is a plain object { position: {x, z}, amplitude, radius }; mutating its position between frames (e.g. a beforeFrame hook tracking a GameObject) is enough to drive a moving wake.

Sample demo: the boat cube drives an OceanDynamicSource at its world XZ. The wake actually trails behind the cube as it orbits, with foam streaks accumulating where the crests pile up.

The camera-following variant (so the sim window pans with an open-ocean camera) and the dynamic-sim normal contribution are covered by the Dynamic-sim surface integration section above. Sea-floor depth LOD against real terrain is not yet integrated (no terrain to integrate with yet).

Composable wave inputs#

A pluggable per-LOD render-pass pipeline followed by a separate ShapeCombine compute, replacing the original single-compute wave bake. Same external behavior for the global Gerstner spectrum (cascaded displacement looks identical for the default-registered batch), but the wave field is composable from multiple inputs — the foundation that the object-attached wakes and foam brushes build on. The sample demonstrates the API with a boat-cube that drifts in a slow ellipse, carrying a point-ripple wave input whose contribution rides on top of the global spectrum.

The two replacement passes:

The WaveInput interface is intentionally small. Each input owns its pipeline + bind groups; the orchestrator only manages per-LOD render-pass setup, the shared draw-context binding, and the lifecycle (setup, updateFrame, affectsLod, draw, destroy). Group 0 is the dynamic-offset draw context (always bound by the orchestrator); group 1+ is each input's own data.

Concrete inputs that landed:

OceanFeature.addWaveInput(input) / removeWaveInput(input) is the public API for registering custom inputs from sample / game code. The Gerstner batch is registered automatically; samples opt-in to anything else.

Surface-shading detail — detail normals, SSR, caustics#

Surface-shading detail on top of the LOD cascade — fragment-shader work plus one one-shot procedural texture. Three independent features, all gated on the same OceanFeature, no new render-graph passes:

Tunable from the fragment shader only — no new uniforms, no new bind-group entries beyond the detail-normal texture (added to the existing scene bind group as @binding(4)). Detail-normal generation is a single device.queue.writeTexture at startup.

LOD-clipmap surface cascade#

The ocean surface uses an LOD-clipmap layout: each LOD is a ring of mesh tiles whose triangle pitch matches the wavelength band that LOD carries. A compute pass bakes the Gerstner spectrum into a 2D-array displacement texture (one slice per LOD), and the surface vertex shader samples two slices and blends — so the whole ocean surface is a textured cascade rather than a fixed grid of inline wave math.

The pieces:

Fragment-shader surface polish#

Fragment-shader-only additions on top of the LOD cascade — all tunable from the fragment shader, no new uniforms / textures / passes:

File map#

FileRole
src/renderer/render_graph/passes/ocean/ocean_lod_transform.tsCPU LOD math + the OCEAN_MAX_LODS / OCEAN_MAX_WAVES constants
src/renderer/render_graph/passes/ocean/ocean_wave_input.tsWaveInput interface + shared draw-context BGL constants
src/renderer/render_graph/passes/ocean/ocean_wave_input_gerstner.tsWaveInputGerstnerBatch (full-coverage spectrum input)
src/renderer/render_graph/passes/ocean/ocean_wave_input_ripple.tsWaveInputRippleSource (point ripple — boats / drop disturbances)
src/renderer/render_graph/passes/ocean/ocean_wave_inputs_pass.tsN per-LOD render-pass orchestrator; owns lods buffer + draw-context
src/renderer/render_graph/passes/ocean/ocean_shape_combine_pass.tscascade compute (raw → cascaded)
src/renderer/render_graph/passes/ocean/ocean_dyn_waves_pass.tspersistent dynamic-wave sim (ping-pong)
src/shaders/ocean/ocean_dyn_waves.wgslwave step + source injection (+ camera-following shift)
src/renderer/render_graph/passes/ocean/ocean_foam_sim_pass.tspersistent foam sim
src/shaders/ocean/ocean_foam_sim.wgslfoam update
src/renderer/render_graph/passes/ocean/ocean_surface_sampler.tsCPU sampleGerstnerSurface(x, z, t, waves, oceanLevel)
src/renderer/render_graph/passes/ocean/ocean_fft_spectrum.tsCPU Phillips spectrum bake
src/renderer/render_graph/passes/ocean/ocean_fft_sim_pass.tssingle-dispatch time-evolution + direct DFT pass
src/shaders/ocean/ocean_fft_evolve.wgslFFT compute — spectrum time-evolution (k-space)
src/shaders/ocean/ocean_fft_butterfly.wgslFFT compute — shared-memory radix-2 inverse FFT (rows/cols)
src/shaders/ocean/ocean_fft_assemble.wgslFFT compute — spatial heightfield + Jacobian foam
src/renderer/render_graph/passes/ocean/ocean_underwater_pass.ts + src/shaders/ocean/ocean_underwater.wgslsubmerged HDR tint pass
src/shaders/ocean/ocean_wave_input_ctx.wgslshared draw-context struct + worldXZ/NDC helpers
src/shaders/ocean/ocean_wave_input_gerstner.wgslGerstner spectrum fragment
src/shaders/ocean/ocean_wave_input_ripple.wgslpoint-ripple vertex + fragment
src/shaders/ocean/ocean_shape_combine.wgslcascade compute
src/renderer/render_graph/passes/ocean/ocean_chunk_mesh.tsInterior + Ring mesh generators
src/renderer/render_graph/passes/ocean/ocean_detail_normal.tsprocedural tiling detail-normal texture (one-shot CPU bake)
src/renderer/render_graph/passes/ocean/ocean_pass.tsSurface pass — N draws (1 Interior + lodCount-1 Rings)
src/shaders/ocean/ocean_surface.wgslSurface vertex + fragment (LOD displacement sampling + detail-normal / SSR / caustics)
src/shaders/ocean/ocean_helpers.wgslShared GerstnerWave + evalGerstner + sky / depth helpers
src/renderer/features/ocean_feature.tsRenderFeature wrapper — wires all three passes, exposes addWaveInput / removeWaveInput
tests/renderer/render_graph/ocean_lod_transform.test.tsUnit tests for LOD math
samples/ocean_test.tsThis sample's chain assembly + scene + boat-ripple demo

Why compose the chain manually?#

deferredPreset() hard-codes its feature order with no injection point. The sample slots OceanFeature between DeferredLightingFeature and TonemapFeature — composing the chain directly. When ocean joins the engine's standard preset we can revisit whether the preset grows an extraFeatures hook.