npx skills add ...
npx skills add mengto/skills --skill 3d-ultra-realistic-water
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.
npx skills add mengto/skills --skill 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:
shaders-cursor-ripples.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.
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é.
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.
| 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 |
All of these were A/B tested in the demo scene with the sim frozen, so only the one change differed between frames.
setSeaState() lets amplitude keep rising but caps steepness at ΣQ = 0.95.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./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.ω·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.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.renderer.toneMapping = NoToneMapping and applies ACES itself, at exposure 0.9 and bloom 0.35.shadowMap.autoUpdate = false).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.
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.__OD.hold() implements this.setAnimationLoop stops when the page is hidden, and the dt clamp absorbs the gap when it resumes.height() inverted), and the wake arms open at about 19°.