3d Ultra Realistic Water
Meng ToCommunityCreated by Meng To
- Skill
- Tool integration
- Software development
- MIT
- Medium risk
- Has scripts
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
Download the skill as a ZIP:
https://autogineer.com/agent-skills/mengto/3d-ultra-realistic-water/downloadUnzip 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 + ACESThe stack, with the numbers that shipped
| layer | what it does | constants |
|---|---|---|
| Spectrum | 8 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) |
| Grid | exponential rings centred under the camera | r = 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 fade | per wave, in both shaders | fade = 1 − smoothstep(0.18·L, 0.5·L, footprint); vertex footprint 1.2 × ring spacing, pixel footprint 2 × (fwidth(x) + fwidth(z)) |
| Normal | per pixel, normalize(cross(dP/dz, dP/dx)) | Jacobian J = dPdx.x·dPdz.z − dPdx.z·dPdz.x drives crest foam |
| Micro detail | baked 512² tile: 48 sines with integer wave vectors, amplitude ∝ |k|^−1.35 | two octaves at 19 m and 7.3 m (the second rotated 90°), strength 0.4·e^(−d/500) + 0.08 |
| Reflection | Schlick with water F0 = 0.02; 256² sky cube re-rendered every 0.25 s; half-res planar pass for the ship and islands | planar UV nudged by N.xz · (0.06, 0.03) · clamp(30/d, 0.2, 1) |
| Body | deep colour plus sunlit subsurface through the wave crests | deep (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)) |
| Glitter | HDR specular, left for the bloom to catch | pow(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 |
| Foam | thresholds a Worley lattice by a mask, so the foam stays lacy | crest smoothstep(0.6, 0.28, J); foam = smoothstep(1 − m, 1.2 − m, pattern·0.88 + m·0.22); gone by 1600 m |
| Wake | 512² half-float field over 380 m, trailing the ship | Kelvin arm offset 1.2 + 0.35·a (atan 0.35 ≈ 19.3°), arm decay 115 m, stern wash 170 m, calm slick 260 m |
| Haze | fogs into the sky's own horizon colour | fog = (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
/9and/3.3but offset it by/11and/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,
ω·tpasses 16,000 rad for the 3 m wave within an hour. That makes the float32sin()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. Readingdisp(x, z).ydirectly 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 = NoToneMappingand 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
dtto 1/20 s. The reference also rebases the world every 1200 m, shifting the ship, wake, particles, camera andwaves.offX/offZtogether.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:
setAnimationLoopstops when the page is hidden, and thedtclamp 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
- ATTRIBUTION.mdAdded by AutoGineer862 B
- LICENSELicence file added by AutoGineer1.0 KiB
- SKILL.md10.6 KiB
- agents/openai.yaml328 B
- assets/ocean.mjsScript29.6 KiB
- demo/PROMPT.md3.7 KiB
- demo/build.mjsScript5.3 KiB
- demo/index.html802.4 KiB
- demo/preview.jpg219.3 KiB
- demo/src/ui.css5.7 KiB
- demo/src/ui.html1.8 KiB
- demo/src/ui.jsScript3.4 KiB
- references/ocean.md5.9 KiB
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)
| export | returns | notes |
|---|---|---|
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 }) | Texture | RG micro-normal, B foam lattice, A fbm. Tileable, mipmapped, anisotropy ≤ 8, NoColorSpace |
createOceanGeometry(THREE, { rings, segments }) | BufferGeometry | exponential 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
dt = min(realDt, 1/20)waves.update(dt): phases are wrapped on the CPUShip buoyancy from
waves.height()(below)Floating-origin rebase when the ship is more than 1200 m from the origin
Camera update. Clamp the camera to at least 2.2 m above
waves.height()at its position.ocean.update({ time, camera, ship }), thenwake.update(...)withstep = speed·dtRe-render the sky cube every 0.25 s while the clouds drift
reflection.render(scene, camera)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.85roll 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 12a 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)
| uniform | value |
|---|---|
| sun direction | normalize(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 density | 2.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 or Meng To.
Download ZIP
The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download 3d-ultra-realistic-water.zip