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:
- New binding 8 in the scene group:
texture_cube<f32>(FRAGMENT). Always present — a 1×1×6 zero-filled dummy is bound by default so the BGL is valid before any probe is wired. - New
OceanPass.setReflectionProbeView(view: GPUTextureView | null)setter. Passing a view withdimension: 'cube'swaps it into the bind group and setsuseReflectionProbe = 1; passingnullfalls back to the dummy + flag 0. - New
useReflectionProbeu32 field inOceanUniforms. The surface fragment's reflection sample (and the horizon-haze sample) check it: when 1,textureSampleLevel(probe_cube, samp, reflDir, 0.0); when 0, the existingtextureSampleLevel(sky_tex, samp, oceanSkyUv(reflDir), 0.0). OceanFeature.setReflectionProbeViewexposes the setter at the feature level.
ReflectionProbeFeature extension:
- New
prefilteredCubeArray: GPUTexture | nullgetter so consumers can slice their own cube views out of the cube-array texture without going throughframe.extras.
Sample wiring (samples/ocean_test.ts):
- Swaps
SkyTextureFeatureforAtmosphereLutsFeature+AtmosphereFeature(Earth mode — camera lives at world y≈3) +CloudFeature. - Adds a
ReflectionProbeFeature({ maxProbes: 1 })and aReflectionProbeGameObject at world (0, 1, 0) withcaptureContent = 'sky-and-clouds'andupdateMode = 'every-frame'so the cube tracks the live sky. - After the engine wires features (synchronously during
addFeature), pullsreflectionProbe.prefilteredCubeArray, creates a cube view of slot 0 (baseArrayLayer: 0, arrayLayerCount: 6), and callsocean.setReflectionProbeView(view).
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).
- Detects camera-under-water by comparing
camera.position().yagainstoceanLevel. When submerged, theenableduniform is 1.0; the fragment reads HDR + depth, reconstructs the world position for each pixel viainvViewProj, and blends towardunderwaterColorby1 − exp(−dist · extinction). Sky pixels (depth ≥ 1) saturate to the tinted color scaled byskyTint. - When the camera is above water, the pass is a passthrough — same render-graph topology runs every frame regardless of submersion state, so the chain doesn't need to be conditionally rewired.
- The pass needs an HDR copy ping-pong (same pattern as OceanPass's refraction copy) since you can't read + write the same texture in one render pass.
- Not yet done: rendering the underside of the wave field as a separate back-facing draw + a meniscus band at the waterline. The current "tint everything" pass is enough to make diving look right without those.
Layered FFT + softer foam#
- Layered FFT — the renderer runs two
OceanFftSimPassinstances.OceanFeatureOptions.fftSim2adds a second pass at a different tile size and wind direction. The surface vertex shader samples both and sums (h,Dx,Dz, foam-seed). Sample UV for the second FFT is rotated by ~37° before scaling so the two tiles' wrap patterns don't align. Foam contributions are capped at 1.5 to prevent the two layers from saturating the foam mask between them. - Per-pass texture key —
OceanFftSimPassallocates a unique persistent-texture key per instance (ocean:fftSim.height#<seq>) so two simultaneous instances don't collide on the render graph's persistent-texture cache. - Softer noise-textured foam — fragment foam mask uses 3 octaves of value noise drifting in different directions, then reshaped through a final
smoothstepso the foam-or-not edge stays crisp while the interior varies smoothly. Reads as soft-edged whisker streaks rather than the hard blobs a 2-octave version produces.
Analytical seafloor depth#
OceanFeatureOptions.seafloorY(default −25 m) sets a flat synthetic seabed.- The surface fragment derives a
seafloorDepth = world_pos.y − seafloorY. The depth-fog source is nowmax(sceneZ − waterZ, 0)when there's actual geometry below the surface (cubes, future terrain), or falls back toseafloorDepthwhen the rendered scene at this pixel is the sky / open ocean. Result: distant water reads as deep blue instead of staying at the shallow refraction color the sky would otherwise drive. - Stored in
OceanUniformsas a single float; no new bindings.
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 chop (lateral xz displacement). Tessendorf §4.4 chop derivation:
D(x, t) = Σ −i·(k/|k|)·h(k, t)·exp(ik·x). The compute shader's IDFT already iterates k-space per output texel; computing chop alongside height just adds two more accumulators. Output texture moves fromr16floattorgba16floatwith(r=h, g=Dx, b=Dz). Chop strength λ is baked into the stored Dx/Dz so the surface shader uses them directly without a second uniform. The surface vertex shader adds(Dx, 0, Dz)to the world-space position alongside the existing Gerstner displacement — gives the asymmetric "steep windward face, flat leeward face" wave profile instead of smooth rolling swell. - Jacobian foam seed. The Jacobian determinant of the displacement field,
J = (1 − λ·∂Dx/∂x)(1 − λ·∂Dz/∂z) − (λ·∂Dx/∂z)², goes negative wherever the surface is folding back on itself — i.e. breaking-wave fronts. The compute shader computes the three partials in the same per-texel sum (Tessendorf §4.4 gives them as∂Dx/∂x = Re(Σ (kx²/|k|)·h·exp(ik·x))etc.) and storesmax(0, foamThreshold − J)into the FFT output's alpha. The surface vertex shader folds this into the existingfoam_factorinterpolant (× 3 to push through the fragment'sfoamStartthreshold). Result: directional foam streaks along the windward face of every breaking crest, not the patchy blobs that height-only foam produces. - Subsurface scatter on wave faces. Fragment-shader-only addition between the underwater/reflection blend and the foam mix:
sss = scatterColor · pow(max(0, dot(−viewDir, sunForward)), 4) · heightFactor · backLight · ... · sunIntensitywherebackLight = clamp(0.5 − dot(normal, sunForward)·0.5, 0, 1). Brightens the side of each wave that's between the viewer and the sun and is tall enough to read as a thin backlit column of water. The bright teal pop on the wave faces in modern ocean renderers comes from this; without it, the surface only reflects the sky and shows through to depth-fogged underwater, no through-wave lighting. - Live control panel (ocean_test.html) — sliders for depth fog, foam intensity, sky reflection, sun intensity / sharpness, FFT strength / chop / J-foam threshold, boat wake amplitude / radius. Press
Hto toggle.
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):
- CPU-baked initial spectrum (
ocean_fft_spectrum.ts). Phillips formulaP(k) = A · exp(−1 / (k·L)²) / k⁴ · (k̂·ŵ)² · exp(−k²·ℓ²)evaluated on anN × Ngrid (defaultN = 64, tile size128 m). Per texel: two Gaussian-random Box-Muller samples scale√(P/2)to giveh₀(k) = (ξ_r + i·ξ_i) · √(P/2). Result is a staticrg32floatGPU texture uploaded once at setup. Seedable for reproducible spectra. - Per-frame compute — a real Cooley–Tukey FFT in four dispatches. A shared-memory radix-2 inverse FFT (
O(N²·log N)) so the grid size scales — the sample runsN = 256. The dispatches are: (1) evolve (ocean_fft_evolve.wgsl) —h(k, t) = h₀(k)·exp(iωt) + h₀(−k)*·exp(−iωt)per k in k-space, plus the packed chop fieldC(k) = D̂x + i·D̂zwhereD̂x = i·(kx/|k|)·h; (2) FFT rows and (3) FFT cols (ocean_fft_butterfly.wgsl, one workgroup per line, bit-reversal + butterfly in shared memory, ping-ponged between two complex working buffers); (4) assemble (ocean_fft_assemble.wgsl) — takes the real/imaginary parts of the transformed fields and computes the Jacobian foam by central-differencing the spatial chop. Height and the two chop channels travel as one packedvec4per element, so a single transform carries both (the IFFT is linear). This replaced an earlierO(N⁴)direct DFT that cappedN ≈ 128. - No 1/N normalization. The inverse FFT is left unnormalized so its convention matches the spectrum bake — Tessendorf's Phillips amplitudes are scaled so the raw inverse sum lands in meters directly.
- Output is
rgba16float—r= height,g/b= chopDx/Dz(× chop strength),a= Jacobian foam seed. The transformed fields are real by construction becauseh₀(−k) = conj(h₀(k)). - Surface integration. OceanPass scene group binding 6 (
texture_2d<f32>, vertex-only).OceanUniformsgainsfftTileSize+fftStrength. Vertex samplesworldXZ / fftTileSizewith the scene sampler's repeat addressing, multiplies byfftStrength, and adds to the surface height alongside the cascade displacement + dynamic-sim height.fftStrength <= 0skips the sample.
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:
- Mipmap chain on the heightfield so distant LOD chunks can sample a low-passed version without aliasing.
- A runtime toggle to switch between Gerstner / FFT / both (currently both stack by default; toggling FFT off means setting
strength: 0).
(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#
- Dynamic-sim → fragment normal contribution. The surface fragment central-differences the dynamic-sim height at ±1 sim-texel along x and z and folds the resulting slope into the same xz channel as the detail normal. The wake catches sun specular and reflection at low camera angles instead of only being visible as a height change from above.
- Camera-following sim. When
OceanDynamicSimOptions.followCamerais true, the host snaps the sim center to the camera's XZ on a per-texel grid each frame and writes the integer texel delta as a shift into the compute params. The compute readssrcTexattexel + shiftso wake data stays anchored in world space as the sim window pans. Newly-uncovered texels read zero, so just-entered regions start as flat water. The fixed-center mode is still available for samples that want a sim anchored to a specific spot. - Dynamic-sim foam scaled by 5× in the vertex shader before it folds into the surface
foam_factorinterpolant. Trail foam values from the sim are typically in[0.1, 0.5]— below the surface fragment'sfoamStart=0.7threshold without the boost — so without it only the saturated foam right under the boat renders and the trail itself is masked out.
CPU surface query#
OceanFeature.sampleSurface(x, z): { y, normal }— CPU port of the WGSLevalGerstnerfromocean_helpers.wgsl. Sums the current Gerstner spectrum at the given world XZ at the latest frame'stimeand returns the rest-plane Y plus the world-space surface normal. Uses therest_xz = world_xzapproximation rather than inverse-mapping the lateral chop — fine for low-steepness waves and the buoyancy / camera-on-water use cases this is mainly for. Does NOT include the dynamic-sim contribution (GPU-resident; an object driving its own wake doesn't need to query it).- Sample demo updates. The boat cube now floats: each frame it samples the surface at its orbit position and snaps its Y to
surf.y + 0.3 m. It also pitches and rolls — the boat's rotation is built astilt × yaw, wheretiltis the quaternion that rotates world-up tosurf.normal(constructed from the cross / dot of the two and normalized, sidesteps theacos+axis-angle path) andyawis the orbit heading. The cube is visibly tilting on the waves now instead of being pinned at a fixed Y.
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:
- 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. - Source injection — every registered
OceanDynamicSourcepullsh_newtowardamplitude · gauss(r/radius)at a fixed rate (~10/s, so a fresh splat ramps in over ≈ 100 ms). The pull model saturates ash_newreaches 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 additiveh_new += amp · dtmodel accumulatesamp / (1 − waveDecay) · dt ≈ 8 · ampof energy at the same texel (a 30 m wake column fromamp = 4 m/s, a trap worth avoiding). - 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:
OceanWaveInputsPass— for each LODi, opens a render pass withraw[i](a slice of a persistenttexture_2d_array<rgba16float>) as a color attachment, clears to zero, then invokes each registeredWaveInput'sdraw()with additive blending. The pass owns the shared draw-context buffer (per-LODcenterX/Z,lodScale,lodIdx,time,texelRes) bound at group 0 via dynamic offset so all input pipelines see consistent per-LOD state without per-input frame uniform writes. Lods buffer (thecenterSnapped + lodScaleper slice used by ShapeCombine + the surface shader) moves to this pass too — single source of truth, back-channelled into the other two consumers.OceanShapeCombinePass— one compute dispatch over(texelRes, texelRes, lodCount)that buildscascaded[i] = Σ raw[k] sampled at this texel's worldXZfor everyk ≥ i. Equivalent in result to the recursivecascaded[i] = raw[i] + sample(cascaded[i+1], worldXZ), but avoids the read-after-write hazard on a single texture: every read here is fromraw[], every write tocascaded[], so the cascade stays a single dispatch. Bilinear sampling across LOD boundaries gives the same low-passed result as the recursive version for waves that respect the per-LOD wavelength band.
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:
WaveInputGerstnerBatch— the routed global Gerstner spectrum as a wave input. Vertex shader emits a single-triangle fullscreen quad; fragment iterates the routed spectrum and sums every wave whose ownlodIdxmatches the current slice. Default-registered byOceanFeaturesosetWaves()still works unchanged. Each Gerstner wave contributes to its own coarsest viable LOD only — ShapeCombine cascades the band into finer slices.WaveInputRippleSource— a circular outgoing wave centered on a world XZ position. Wavelength picks the LOD viapickLodForWavelength; the orchestrator only invokesdraw()for that single LOD. Vertex shader emits a tight world-space quad sized by the ripple's radius (auto-clipped by the rasterizer when the boat drifts out of the slice's coverage). Fragment computesamplitude * gauss(r/radius) * cos(k·r − ω·t + φ)and writes vertical + lateral (steepness-modulated, radially outward) displacement. The sample updates the ripple's position once per frame to track the boat's GameObject — that's the whole object-attached pattern.
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:
- Scrolled detail-normal map — a 256×256 tileable
rg8unormslope texture (FBM heightfield → central-diff slope, encodedslope*0.5+0.5) is generated procedurally on the CPU at setup. The fragment shader samples two scrolling layers (3 m and 0.9 m world-space tile periods, drifting at ~0.07 and ~0.13 m/s along different directions), maps the rg back to [-1, +1], and adds the resulting xz-slope to the per-vertex LOD-derived normal before re-normalizing. Replaces Polish 1.5's analytical 4-cosine capillary perturbation: the sampler's mip chain low-passes the detail at distance, so unlike the analytical version it doesn't moiré into specular sparkle past ~40 m. Detail fades exponentially with view distance (exp(-d · 0.025)) so far water doesn't read as choppy when the per-vertex normal has already filtered out short waves. - Screen-space scene reflections — the surface fragment marches the world-space reflection ray against the existing scene depth buffer (24 steps, dithered start, 4 binary-search refinement steps on a coarse hit). Hits sample the pre-ocean refraction copy of HDR (already bound for refraction lookup) — no new resources, no extra full-screen pass. The result is blended over the sky-panorama fallback by a hit mask that fades to zero as the hit UV approaches the screen edge or as the reflection ray climbs into the sky region. Lets the water surface reflect actual scene geometry (the sunken cubes, terrain, cloud layer) instead of only the sky.
- Procedural caustics — a sum of three rotated half-rectified cosines in world XZ, cubed to sharpen peaks, produces a streaky caustic pattern that drifts with time. The caustic value modulates the refraction color with a warm tint (
1.55, 1.40, 1.10) — strongest where the water is shallow, exponentially fading past ~6 m depth (exp(-waterDepth · 0.2)). The seabed under the surface now reads with visible animated light streaks instead of uniform-tinted depth fog.
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:
OceanLodTransform(CPU) — per-LODlodScale = baseScale * 2^i,extent = 4 * lodScale,texelWidth = extent / texelResolution,maxWavelength = 2 * texelWidth * minTexelsPerWave.snapToCameraupdates per-slicecenterSnappedXZ(floored to each LOD's texel grid).pickLodForWavelengthroutes a Gerstner spec to the LOD whose band contains it. 7 unit tests cover the math.OceanAnimWavesPass(compute) — per-frame writes atexture_2d_array<rgba16float>(texelRes × texelRes × lodCount). Each invocation reads its slice center + LOD scale, computes the world xz of its texel, sums every Gerstner whose own LOD is ≥ this slice's index, and storesvec4(disp.xyz, sss=0). Cascade rule (each slice carries its own band + all coarser) collapses the separate "render Gerstners per LOD + ShapeCombine cascade" steps into a single dispatch.OceanChunkMeshes— two reusable meshes: Interior (full grid in[-1, +1]², drawn at LOD 0 with scalelodScale_0) and Ring (hollow square in[-2, +2]²minus[-1, +1]², drawn at every LOD ≥ 1 with scalelodScale_i / 2). Inner edge of LODi's ring lands exactly on LODi-1's outer edge — no seams, no overlap.- Surface vertex shader (rewritten) — per-chunk data (scale / center / LOD index / morph snap / inner-outer bounds) lives in a uniform bound via DYNAMIC OFFSET (one 256-byte slot per chunk; offset stride equals the device's
minUniformBufferOffsetAlignment). Vertex samples its own LOD slice and the next coarser, blends byalpha = (taxicab − innerBound) / (outerBound − innerBound)so both sides of a seam converge to LODi+1at the shared edge. Normal is reconstructed via 5-tap central differences per slice (10 total samples per vertex) and passed to the fragment as an interpolant. - CDLOD per-vertex morph (mirrors the seam fix used by the heightmap-terrain system, docs in mountain_fox.md): each vertex's world XZ morphs from its native position to a
floor(off / coarsePitch) * coarsePitchsnapped position by the samealpha. Every chunk shares the SAME center (LOD 0's snapped grid), so at the seam between LODiandi+1, the finer chunk's outer-edge vertices land exactly on the coarser chunk's native vertex positions — fixing the density-mismatch cracks that appear between Ringi(vertex pitchp) and Ringi+1(pitch2p). - Bind groups — 0: camera + per-chunk uniform (dyn-offset), 1: ocean look + sun + time + ocean center + lodCount, 2: scene textures (refraction copy / depth / sky / sampler), 3: displacement texture array + sampler + lod-slices buffer (shared with the compute pass — single source of truth for slice center/scale).
- Why dynamic offset instead of
firstInstance—enc.drawIndexed(c, 1, 0, 0, i)+@builtin(instance_index)would save one bind-group rebind per draw (theindirect-first-instanceWebGPU feature, which this engine requests by default, is supposed to enable it for direct draws too). But on Chrome / Dawn on Windows,instance_indexconsistently comes through as 0 regardless offirstInstance, even with the feature enabled. Likely a Dawn bug worth filing upstream — until then, dynamic-offset uniform binding is the safe pattern. Switch back when fixed; the swap is a few-line change at thesetBindGroupsite in ocean_pass.ts.
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:
- Per-pixel capillary normals — 4 short-wavelength (1–2 m) Gerstner-flavor wavelets evaluated analytically per fragment, adding a small slope perturbation (amplitude 0.035) that fades exponentially with view distance (rate 0.09, so the effect is concentrated within ~25 m of the camera). Breaks up the otherwise-glassy specular highlight without aliasing into moiré at distance.
- Procedural foam noise — 2-octave value noise sampled in world XZ (rates 0.7 and 1.9) modulates the crest mask, so foam reads as cellular streaks instead of uniform white blobs. Noise UVs drift slowly with time so foam isn't perfectly static.
- Distance haze — exponential blend toward the horizon's sky color (
hazeT = 1 − exp(−viewDistance · 0.0015), max 85% blend at far plane). Softens the ocean / sky transition and hides the outermost ring's edge-fade discontinuity.
File map#
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.