Generated banner for 3d Ultra Realistic Water
Generated logo for 3d Ultra Realistic Water

3d Ultra Realistic Water

Meng ToCommunityCreated by Meng To

  • Skill
  • Tool integration
  • Software development
  • MIT
  • Medium risk
  • Has scripts

From the author

Build an ultra-realistic open ocean in Three.js with a deep-water Gerstner spectrum shaded per pixel from analytic derivatives, each wave faded at its own pixel footprint, plus Fresnel sky-cube and planar reflections, subsurface light through the crests, HDR sun glitter, Jacobian lace foam, a Kelvin wake, hull foam, buoyancy queries and aerial perspective into the sky's horizon haze. Use for seas, oceans, sunset or daylight water, sun glitter paths, ship or boat scenes, wakes, coastal flyovers and any 3D scene with a moving camera and a water horizon. Includes the approved water from Pirate Ship Sunset as a reusable module and demo.
— the description by Meng To

AutoGineer summary

Written by AutoGineer from the skill’s own files, not by the author

This skill provides a reusable Three.js module for rendering open ocean water. It implements a deep-water Gerstner spectrum with per-pixel shading, analytic derivatives, and various environmental effects including Fresnel reflections, HDR sun glitter, Jacobian-based foam, and Kelvin wakes. The system includes support for buoyancy queries and aerial perspective, designed for scenes requiring a moving camera and a water horizon.

What it does

  • Calculates deep-water Gerstner waves with per-pixel normal computation.
  • Implements footprint-based wave fading to prevent aliasing at distance.
  • Provides HDR sun glitter and subsurface light scattering through wave crests.
  • Includes modules for planar reflections, Kelvin wakes, and hull-water interaction.
  • Supports buoyancy calculations for floating objects based on the wave spectrum.
  • Handles floating-origin rebasing to maintain precision in large-scale scenes.

When to use it

  • When building 3D scenes in Three.js that require an open ocean or sea.
  • When you need a water surface that interacts with ships or floating objects.
  • When rendering scenes with a moving camera and a visible horizon.
  • When you require specific water features like sun glitter, foam, and wakes.

Advantages

  • Uses analytic derivatives for sharp detail and glitter at distance.
  • Includes a pre-built module for consistent integration across projects.
  • Provides a mechanism for buoyancy to align objects with the wave surface.
  • Includes logic for floating-origin rebasing to manage large coordinate spaces.

Usage examples

“How do I integrate the ocean module into my Three.js scene?”
Import the createOcean function from ocean.mjs, configure it with your wave spectrum and sky cube, and add the resulting mesh to your scene.
“How can I make a ship float on the waves?”
Use the waves.height() method provided by the module to sample the wave spectrum at the ship's hull coordinates and apply the resulting height to the ship's position.

Install

  1. Download the skill as a ZIP:

    https://mp-staging.autogineer.com/agent-skills/mengto/3d-ultra-realistic-water/download
  2. Unzip it into the cross-client skills folder of your project:

    mkdir -p .agents/skills && unzip 3d-ultra-realistic-water.zip -d .agents/skills

Documentation by Meng To

3D Ultra-Realistic Water

Use this for an open sea seen by a moving camera, with a horizon and a sun. Reach for something else in these cases:

  • Mirror-flat water seen from the waterline with no mesh: a full-screen analytic water plane is cheaper.

  • Ripples that follow the cursor on a UI surface: use shaders-cursor-ripples.

  • A small pool that reacts to touch: use a ping-pong height field.

Source: extracted from Pirate Ship Sunset, a single-file Three.js r169 scene in which a three-masted ship sails a rolling sea at sunset under an auto-cycling camera. The water had to stay convincing from 2 m above the waves out to a 12 km horizon.

The mechanism

Shade the sea per pixel from the analytic derivatives of a deep-water Gerstner sum, and fade each wave out once its wavelength falls below the pixel footprint. The per-pixel derivatives keep the glitter and foam sharp on a mesh whose triangles are 30 m wide at the horizon. The footprint fade stops the far sea from boiling into moiré.

Reuse the working effect

Copy assets/ocean.mjs. You pass in the project's own THREE, and the module does not import a second copy. Before you integrate it, read references/ocean.md, which covers the full constants, render order, buoyancy and post. The demo runs this same module inside the original scene. demo/build.mjs rebuilds it from the reference page.

import { createWaves, bakeOceanDetail, createOcean, createWake, createPlanarReflection } from './ocean.mjs';

const waves = createWaves({ direction: 1.22 });                 // wind heading, radians
const ocean = createOcean(THREE, {
  waves,
  detailTexture: bakeOceanDetail(THREE, renderer),              // baked once, 512²
  skyCube,                                                      // cube rendered from YOUR sky shader
  hazeGLSL: SKY_GLSL + 'vec3 oceanHaze(vec3 d){ return hazeColor(d); }',
  hazeUniforms: skyUniforms,                                    // may include uSunDir
  wake: createWake(THREE, renderer, { shipLength: 34 }),        // optional
  hull: { halfWidths, zMin: -18, zMax: 18, bowZ },              // optional, ship-local metres
});
scene.add(ocean.mesh);
const reflection = createPlanarReflection(THREE, renderer, { layer: 1 });
ocean.setReflection(reflection);

// every frame, in this order
waves.update(dt);                                  // CPU phases, before any height() query
ship.y = waves.height(ship.x, ship.z);             // buoyancy uses the same spectrum
ocean.update({ time, camera, ship: { x, z, fx, fz } });
wake.update(x, z, fx, fz, speed * dt);
reflection.render(scene, camera);                  // before the main pass
renderer.render(scene, camera);                    // into a HalfFloat target, then bloom + ACES

The stack, with the numbers that shipped

layerwhat it doesconstants
Spectrum8 Gerstner waves, deep-water dispersion ω = √(g·k)L 78 → 3.1 m (×0.63 per step), A 0.8 → 0.02 m, headings 0, +23, −17, +38, −31, +12, −49, +64° off the wind, Q 0.17 → 0.03 (Σ 0.77)
Gridexponential rings centred under the camerar = 11.92·(e^(0.033·i) − 1), 210 rings × 400 segments = 84,001 verts, 167,600 tris, reaches ~12.2 km; spacing 0.033·(r + 12)
Footprint fadeper wave, in both shadersfade = 1 − smoothstep(0.18·L, 0.5·L, footprint); vertex footprint 1.2 × ring spacing, pixel footprint 2 × (fwidth(x) + fwidth(z))
Normalper pixel, normalize(cross(dP/dz, dP/dx))Jacobian J = dPdx.x·dPdz.z − dPdx.z·dPdz.x drives crest foam
Micro detailbaked 512² tile: 48 sines with integer wave vectors, amplitude ∝ |k|^−1.35two octaves at 19 m and 7.3 m (the second rotated 90°), strength 0.4·e^(−d/500) + 0.08
ReflectionSchlick with water F0 = 0.02; 256² sky cube re-rendered every 0.25 s; half-res planar pass for the ship and islandsplanar UV nudged by N.xz · (0.06, 0.03) · clamp(30/d, 0.2, 1)
Bodydeep colour plus sunlit subsurface through the wave crestsdeep (0.006, 0.03, 0.048), scatter (0.035, 0.3, 0.27) linear; scatter × toward-sun² × crest height × (0.35 + 4·(1 − N.y))
GlitterHDR specular, left for the bloom to catchpow(R·L, 90)·1.2 + pow(R·L, 12)·0.06 + pow(R·L, 1400)·40, sun colour (4.2, 2.3, 1.1); bloom threshold 1.6, knee 0.8
Foamthresholds a Worley lattice by a mask, so the foam stays lacycrest smoothstep(0.6, 0.28, J); foam = smoothstep(1 − m, 1.2 − m, pattern·0.88 + m·0.22); gone by 1600 m
Wake512² half-float field over 380 m, trailing the shipKelvin arm offset 1.2 + 0.35·a (atan 0.35 ≈ 19.3°), arm decay 115 m, stern wash 170 m, calm slick 260 m
Hazefogs into the sky's own horizon colourfog = (1 − e^(−d·2.8e-4))^1.6

Rules, each with the failure it prevents

All of these were A/B tested in the demo scene with the sim frozen, so only the one change differed between frames.

  • Fade every wave at its own footprint, in both the vertex and fragment shaders. Without the fade, the 3–8 m waves are sampled below their wavelength. The far sea turns into dense grain right up to a hard horizon, and the sun's sheen on the far water disappears. Between consecutive frames, the flicker in the far band went from 2.3 to 5.8 grey levels on average, with the 95th percentile rising from 5 to 23. In motion, that reads as boiling.

  • Compute the normal in the fragment shader from the analytic derivatives. If you shade from the interpolated vertex normal, detail disappears past the near field. The far sea turns into long smooth streaks that follow the mesh rings, and the glitter smears instead of breaking into sparkles.

  • Keep ΣQ below 1. Steepness scales the sideways displacement. Past a sum of about 1, the Jacobian goes negative: the surface folds through itself, and the foam mask saturates into flat white slabs on the crests. setSeaState() lets amplitude keep rising but caps steepness at ΣQ = 0.95.

  • Clamp the reflection vector above the horizon: R.y = abs(R.y) + 0.002. Some wave backs reflect downward. Without the clamp, those faces sample the flat lower half of the sky cube and read as pale grey smears with no glitter. At a grazing shot, 3–4% of the frame changed by as much as 154 levels.

  • Fog into the same haze function the sky draws. One flat fog colour matches the sky only on the sun side. Everywhere else it leaves a hard line where the water meets the sky.

  • Scroll detail and foam textures with the floating-origin offset, using the same divisor as the sampling scale. The reference sampled foam at /9 and /3.3 but offset it by /11 and /4.1. At every 1200 m rebase, the foam pattern jumped by about 24 tiles. The module fixes this.

  • Wrap wave phases to [0, 2π) on the CPU in float64, including the floating-origin term. If the shader receives raw time, ω·t passes 16,000 rad for the 3 m wave within an hour. That makes the float32 sin() argument the part that degrades first. This rule is precision reasoning and was not observed in the demo.

  • Query height with the same spectrum you render. Gerstner waves move points sideways by up to 4.5 m here, so height(x, z) inverts the horizontal displacement with 4 fixed-point steps before it reads y. Reading disp(x, z).y directly was off by 0.15 m on average and by up to 1.17 m on steep crests, across 20,000 samples. After 4 steps, the worst horizontal miss is 0.18 m.

Colour and pipeline

  • Every uniform colour is linear HDR. Render the scene into a HalfFloat target, and tone-map once in the composite: ACES, then sRGB. The reference sets renderer.toneMapping = NoToneMapping and applies ACES itself, at exposure 0.9 and bloom 0.35.

  • The sky cube is rendered from the page's own sky shader, with the tiny sun disc left out. The analytic specular term draws the sun's path on the water.

  • The planar reflection renders only a layer holding the objects that stand in the water. It clips at y > −0.05, and it reuses the frame's shadow map (shadowMap.autoUpdate = false).

Cost

The ocean mesh is 167,600 triangles. Its fragment shader runs the 8-wave loop once per pixel, plus 6 texture taps, 1 cube tap and 1 planar tap. The planar pass re-renders the reflection layer at half resolution. Under heavy machine load, headless Chrome ran the whole Pirate Ship Sunset scene in 10–13 ms per frame at 1440×900. Hiding the ocean or skipping the reflection pass each changed that by less than the run-to-run noise (about ±2 ms), so neither is the bottleneck. The reference's lever is adaptive resolution: DPR is capped at 1.6, and a resolution scale of 0.6–1.0 steps down when the 60-frame average passes 26 ms and back up below 14.5 ms.

Lifecycle and accessibility

  • Clamp dt to 1/20 s. The reference also rebases the world every 1200 m, shifting the ship, wake, particles, camera and waves.offX/offZ together.

  • Under reduced motion, stop simulation time and keep rendering until the intro fade finishes, then hold the frame. Re-render only when a control changes. The demo's __OD.hold() implements this.

  • Pause with the tab: setAnimationLoop stops when the page is hidden, and the dt clamp absorbs the gap when it resumes.

Verify

  • [ ] With the camera 2 m above the water facing the sun, the far sea resolves into haze and sheen, not grain. Toggling the footprint fade off makes the far band flicker between frames.

  • [ ] Per-vertex normals visibly lose the far micro detail. If they don't, the normal is still being taken from the vertices.

  • [ ] Raise the sea state to 1.6: the crests stay lacy, with no flat white slabs.

  • [ ] At a grazing view, no pale grey smears appear on the wave backs.

  • [ ] Anti-sun horizon: the water blends into the sky with no line.

  • [ ] Floating objects ride the crests (height() inverted), and the wake arms open at about 19°.

  • [ ] Reduced motion holds a composed still, controls still re-render, and the console is clean at 1440×900 and 390×844.

Files

references/ocean.md

Ocean integration reference

All values below come from Pirate Ship Sunset. Units are metres, seconds and linear HDR colour.

Module API (assets/ocean.mjs)

exportreturnsnotes
WAVE_SPECTRUM[L, A, headingDeg, Q][]8 rows, wind-relative headings
createWaves({ direction, spectrum, gravity }){ list, uPhase, time, offX, offZ, amplitude, steepness, setSeaState, update, disp, height }CPU twin of the GPU surface
bakeOceanDetail(THREE, renderer, { size })TextureRG micro-normal, B foam lattice, A fbm. Tileable, mipmapped, anisotropy ≤ 8, NoColorSpace
createOceanGeometry(THREE, { rings, segments })BufferGeometryexponential rings; bounding sphere 1e5 so it is never culled
createOcean(THREE, opts){ mesh, material, uniforms, setReflection, update, dispose }opts: waves, detailTexture, skyCube, sunDirection, hazeGLSL, hazeUniforms, wake, hull, colours, fogDensity
createWake(THREE, renderer, { shipLength, span, resolution }){ rt, uniforms, reset, shift, update, dispose }R arms, G stern wash, B calm slick, A Kelvin height
createPlanarReflection(THREE, renderer, { layer, clipBias, resolutionScale }){ target, texture, matrix, camera, setSize, render, dispose }mirror camera renders only layer

hazeGLSL must define vec3 oceanHaze(vec3 dir). It may declare uSunDir; when it does not, the module declares it and uses sunDirection. Without hazeGLSL, haze is one flat colour, uHazeColor, which leaves the hard horizon line described in SKILL.md.

wake and hull are compiled in through #define OCEAN_WAKE and #define OCEAN_HULL. Leave them out for open water with no vessel.

Comparison uniforms for demos: uFootprint (1 on, 0 off) and uPixelNormals (1 per pixel, 0 per vertex). Ship them at 1.

Frame order

  1. dt = min(realDt, 1/20)

  2. waves.update(dt): phases are wrapped on the CPU

  3. Ship buoyancy from waves.height() (below)

  4. Floating-origin rebase when the ship is more than 1200 m from the origin

  5. Camera update. Clamp the camera to at least 2.2 m above waves.height() at its position.

  6. ocean.update({ time, camera, ship }), then wake.update(...) with step = speed·dt

  7. Re-render the sky cube every 0.25 s while the clouds drift

  8. reflection.render(scene, camera)

  9. Main pass into a HalfFloat, 4× MSAA target, then bloom, light shafts and the composite

Buoyancy (reference constants)

Sample height() at 9 hull points in ship space (forward, side): the centre counted twice, (±13, 0), (0, ±4.2), (7, ±3.8) and (−8, ±3.8). Then:

  • heave target = weighted mean − 0.05

  • pitch target = atan2(mean(bow three) − mean(stern three), 22) · 0.85

  • roll target = atan2(mean(port) − mean(starboard), 8) · 0.42 − 0.05

Drive each with a damped spring, v += (k·(target − x) − c·v)·dt, using k, c = heave 5, 3.2; pitch 3.6, 2.6; roll 2, 1.7. Add a small bank into turns: roll −0.8 × turn rate.

Floating-origin rebase

When |x| or |z| of the ship passes 1200 m, round the ship position to whole metres (tx, tz). Subtract that from every world-space thing: ship, camera, camera rig origin, wake trail (wake.shift), particles and islands. Add it to waves.offX/offZ, then call waves.update(0) so the phases absorb the shift. The detail and foam scroll offsets divide offX/offZ by the same scale they sample at (19, 7.3, 9, 3.3). A mismatched divisor makes the pattern jump at the rebase.

Hull interaction

hull.halfWidths holds the waterline half-width at 36 stations from zMin to zMax (stern to bow). The reference sampled its procedural hull 0.1 m above the waterline. The shader uses them for:

  • a bow hump: +0.55 · exp(−((lz − (bow − 3))/5)²) · exp(−(out/2.6)²)

  • a shallow trough alongside: −0.18, between lz −16 and 12

  • a foam band hugging the hull: width 0.55 + 1.5·bowF + 0.8·sternF, stronger toward the bow, plus a bow wave 3–16 m aft of the stem

The true waterline moves with roll and heave, so the foam band sits on both sides of the static line rather than on it.

Wake field

The path is a polyline of up to 64 points. The bow sits at index 0, the ship centre at index 1, and a new trail point drops every 3 m travelled. reset() lays a straight 120 m trail so the first frame already has a wake. The field is centred 120 m behind the ship because almost all of the wake lies aft. Outside the 380 m square, the sampled wake is 0.

The wake adds Kelvin-height normal perturbation to N (0.9 gain in texel units), lightens the water body with the wash, dims the glitter under the wash (×0.4), and calms the detail normals under the slick (×0.4).

Post (reference)

  • Bloom: 6 mips from half resolution. Prefilter threshold 1.6, knee 0.8, input clamped at 60. Upsample tent with gain 0.62. Composite × 0.35.

  • Light shafts: sky-only mask (alpha 0 marks sky) × exp(−5·d²) around the sun at ¼ resolution, two radial passes (28 taps, step 0.034 then 0.011), × 0.55, tint (1, 0.72, 0.45).

  • Composite: slight edge chromatic aberration, exposure 0.9, then ACES (RRT/ODT fit). A warm-highlight / cool-shadow grade and a gentle S-curve follow, then a vignette to 0.72, sRGB, and ±0.011 grain.

Sunset palette (linear)

uniformvalue
sun directionnormalize(0.66, 0.10, 0.745), about 5.7° elevation
uSunColor (ocean)(3.6, 1.9, 0.95) in the scene; module default (4.2, 2.3, 1.1)
uAmbient(0.2, 0.15, 0.24) in the scene; module default (0.22, 0.18, 0.28)
uDeep(0.006, 0.03, 0.048)
uSSS(0.035, 0.3, 0.27)
uFoamCol(0.92, 0.9, 0.86)
fog density2.8e-4

A starting point for daylight, not tested in the reference: raise the sun above 30°, cut uSunColor to about (2.2, 2.0, 1.8), and set uDeep toward (0.004, 0.02, 0.04). Change the palette for the time of day and leave the spectrum, footprint fade, Fresnel and foam as they are.

Requirements and compatibility

Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta

“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.

Needs: Needs Node.js

Security

What our scanner noticed· Medium risk

  • Scripts that your agent may run
  • Zero-width Unicode characters

This skill includes scripts that your agent may run. Read them before you use it.

A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.

Attribution and licence

Created by
Meng To
Publisher
Meng To (Community)
Source
MengTo/Skills · agent-skills/3d/3d-ultra-realistic-water · commit 5ea1661
Licence
MIT License (redistribution allowed)
  • LICENSE (added by AutoGineer from LICENSE)
  • ATTRIBUTION.md (written by AutoGineer)
Synced

AutoGineer is not affiliated with or endorsed by Meng To.

Download ZIP

The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download 3d-ultra-realistic-water.zip