# Open Water (realistic WebGL2 ocean): vet + JKCA hand-off pack (2026-10-22)

**What it is:** a real-time, physically based ocean that runs in the browser. It uses plain WebGL2 with no three.js and no runtime dependencies. You get FFT waves, foam, a physical sky with volumetric clouds, rain, storms, lightning, a moonlit night and optional sound. Every image is computed: there are no textures or models. The only recorded files are 8 rain and thunder MP3s.
**Source:** X post by @lexnlin (https://x.com/lexnlin/status/2113333862625648715) → t.co/1uhypTNhAr → **https://github.com/Leonxlnx/open-water**
**Live demo (author's):** https://leonxlnx.github.io/open-water/
**Status:** vetted and test-rendered on the box only. **Nothing deployed, posted, or sent. No client repos touched.**

## Repo facts (checked 2026-10-22, 2:45 PM PT)
| | |
|---|---|
| Stars / forks | 23 / 3 |
| Created | Oct 20, 2026 4:48 PM PT (2 days old) |
| Last commit | `c6009ac`, Oct 22, 2026 11:19 AM PT ("Rain falls in drifting cells…"). 45 commits, all by Leonxlnx (`@tasteskill.dev`). Still changing several times an hour |
| Stack | Pure WebGL2 + GLSL ES 3.0, ES modules, **no build step**. Needs `EXT_color_buffer_float`. Not three.js, not WebGPU |
| Runtime deps | **None** |
| Dev deps | `puppeteer-core@^23.11.1`, used only by `tools/` and `render/` (screenshots and the offline film renderer). No lockfile package has an install script |
| Size | 73 MB working tree. 61 MB of that is `media/open-water.mp4`, plus ~10 MB of stray debug PNGs (`seam_*.png`). **The runtime is ~330 KB of JS + 2.4 MB of optional audio** |
| Code | `src/` ≈ 270 KB across 22 files: ocean/, sky/, fx/, audio/, main.js, gl.js |

## License
- **Code: MIT** (© 2026 Leonxlnx). Commercial use, modification and redistribution are allowed. **Keep the LICENSE/copyright notice** in the vendored copy. GitHub shows "Other" only because the LICENSE file also has an extra paragraph about the audio.
- **Audio (`assets/audio/*.mp3`) is NOT MIT.** The files are Wikimedia Commons excerpts under **CC BY-SA 4.0 / CC BY 3.0** (see CREDITS.md). **JKCA rule: delete `assets/audio/` from any vendored copy.** Sound is off by default and only loads if someone presses M or taps the sound button, which our `ui=0` embed removes. Removing the folder also removes any attribution or share-alike obligation.
- Side note: the X post says "no assets were used". That's true for the visuals. The audio is the one exception, and the README says so.

## Vet verdict: **SAFE to use (code)**, with conditions
Checked by hand in the clone at `/workspace/jkca-stack/vendor-review/ocean-water/`. `npm install` was **not** run, because nothing needs it to work.
- ✅ No `preinstall`/`postinstall`/`prepare` scripts. `package.json` scripts are only `dev` (local static server), `shot` and `render`. `hasInstallScript` count in the lockfile is 0.
- ✅ No minified or obfuscated blobs. No line in src/tools/render is longer than 400 chars, and everything is readable, commented source.
- ✅ No `eval`, `new Function`, dynamic `import()`, workers, cookies, localStorage, `sendBeacon`, WebSockets or analytics/telemetry. The **only network call** in the browser code is `fetch()` of its own `assets/audio/*.mp3` (same-origin, relative URL, sound-on only). There are no external URLs in `src/` or `index.html`.
- ✅ `tools/serve.mjs` is a 46-line zero-dep static server with a path-traversal guard. The `tools/`/`render/` scripts only reach `localhost:5173` and spawn `ffmpeg`. Dev-only, never shipped.
- ⚠️ Conditions: (1) **pin to commit `c6009ac`** (or a later reviewed one) and vendor `index.html` + `src/` + `LICENSE` only, without `media/`, `seam_*.png`, `.claude/`, `tools/` or `render/`. (2) Delete `assets/audio/`. (3) The repo is 2 days old with one author and changes daily. Treat it as a snapshot, not a dependency to auto-update. Re-vet any update.
- ⚠️ `.claude/launch.json` shows it was built with an AI coding agent (as the post says). That's not a risk by itself, but it's one more reason to pin and review.

## Test runs on the box (headless, no GPU)
The box has **no GPU**, so everything rendered in software (Chromium → SwiftShader, WebKit → Mesa). **The FPS numbers below say nothing about real devices.** They only prove the code runs and the shaders compile.
| Run | Engine | Result | Box FPS (software) | Screenshot |
|---|---|---|---|---|
| Desktop 1440×900, `preset=calm&elev=8` | Chromium 151 (Playwright) | ✅ renders, no errors | 0.07 | `screens/desktop-1440x900-calm.png` |
| iPhone 13 profile 390×844 (DPR 3), same scene | **WebKit** (Playwright, iOS UA) | ✅ renders, no errors | 3.4 | `screens/iphone-webkit-390x844-calm.png` |
| iPhone, dusk lilac: `elev=-2&wind=2.5&h=6&pitch=4` | WebKit | ✅ | 1.9 | `screens/iphone-webkit-390x844-iphone-dusk.png` |
| Desktop aerial dusk: `elev=1.5&az=250&h=120&pitch=-25&fov=55` | WebKit | ✅ | 0.9 | `screens/desktop-webkit-1440x900-aerial-dusk.png` |
| `embed/embed-demo.html` on iPhone, motion OK / reduced motion | WebKit | ✅ live iframe fades in / ✅ shows the static poster only | n/a | `screens/embed-demo-iphone-*.png` |
| Patched `?ui=0&still=30` | WebKit | ✅ no UI, loop stops after 30 frames (app time frozen) | n/a | n/a |

(The cloud streaks in the Chromium desktop shot come from temporal accumulation at 0.07 fps. They won't appear at real frame rates.)

**iPhone Safari:** **Likely to work, but not yet confirmed on a real phone.** It needs WebGL2 + `EXT_color_buffer_float`, which iOS Safari has supported since iOS 15. The code only linear-filters half-float textures (core WebGL2) and uses 32-bit float textures only with nearest filtering, so it doesn't need `OES_texture_float_linear`, which is weaker on mobile. It renders in Playwright WebKit with no errors. That is the WebKit engine, not an iPhone GPU. **WebGPU isn't used**, so iOS WebGPU support doesn't matter.
**Perf, judging from the code:** the default `rt` tier targets integrated GPUs. It uses FFT 256/256/128/128 across 4 cascades, a 44-step volumetric cloud raymarch at 1/3 resolution, 4× MSAA and an R11G11B10F HDR buffer. Dynamic resolution keeps it near **30 fps**, scaling render size between 0.5× and min(DPR, 1.5). On an iPhone, expect it to run but get warm. **Do not ship it as an always-on background on mobile without a real-device test (iPhone 12 or newer, check heat after 2 minutes).** The fallback plan below defaults phones to the still or video.

## How it works (techniques)
- **Waves:** Tessendorf **FFT** ocean on the GPU (not Gerstner sums). It has 4 cascades from 1800 m down to 3.7 m and uses the Elfouhaily spectrum with Donelan–Banner/ECKV directional spreading plus a narrow swell band. Choppy horizontal displacement gives trochoidal crests. Wave heights match the Beaufort table.
- **Light on water:** LEAN slope-variance BRDF (Bruneton 2010), so geometry, normals and BRDF blend with distance. Cox–Munk sub-pixel roughness. Sun and moon **glitter as individual Poisson-sampled sparkles**. Averaged **Fresnel**, **sky reflection** from a sky env map with a below-horizon "next wave" fix, ocean-optics water color (ultramarine → grey-green with bubbles), backlit crest scattering (SSS look), cloud shadows, Earth curvature, aerial perspective.
- **Foam:** whitecaps appear where the surface **Jacobian folds**. There are two stages, active (~0.4 s) and residual (seconds), drawn as a procedural bubble-raft lace. It forms wind rows at gale force and is calibrated to the Monahan whitecap law.
- **Sky:** Hillaire 2020 atmosphere LUTs, Perlin–Worley volumetric clouds, squall fronts, crepuscular rays, stars, moon.
- **FX/post:** Marshall–Palmer rain streaks + ripples + splashes, fractal lightning, spindrift, auto exposure, AgX tone map + S-curve, bloom, film grain.
- **Camera:** floats on and rides the waves below ~12 m eye height. Above that it holds still.

## Tuning knobs (all via URL query, no code changes)
| Param | What | JKCA calm values |
|---|---|---|
| `preset` | `calm` · `breeze` · `rain` · `storm` | `calm` |
| `wind` | m/s (0.5–26). Drives wave size, chop and foam | 2–3.5 (glassy), 5 (gentle) |
| `waveScale` / `choppy` | wave height multiplier / crest sharpness | 0.6–0.9 / 0.5–0.75 |
| `swellHs`, `swellT`, `swellDir` | swell height m / period s / direction° | 0.5 / 12 / 40 |
| `clouds`, `cloudDark`, `cloudBase` | cover 0–1, darkness, base height m | 0.1–0.25, 0, 1400 |
| `rain`, `lightning`, `mist`, `haze` | weather FX (keep at 0 for kids) | 0, 0, 0, 1 |
| `elev`, `az` | sun elevation° (negative = dusk, < −7 = moonlight) and bearing° | dusk lilac: `elev=-2`; golden: `elev=4–8` |
| `h`, `yaw`, `pitch`, `fov` | eye height m, look direction°, tilt° (negative = look down), field of view° | horizon hero `h=6&pitch=4`; aerial `h=120&pitch=-25&fov=55` |
| `ev`, `wb`, `look` | exposure bias, white balance, tone curve | `ev=0.3` to lift lilac |
| `q` | `rt` (default) · `high` · `ultra` | `rt` live; `high` for baking stills/video |
| `scale` | fixes render scale and turns dynamic resolution off | leave unset |
| `float=0` | stop the camera bobbing (good for reduced motion and maps) | `float=0` |
| JKCA patch: `ui=0`, `still=N` | hide the UI / render N frames, then stop | see below |

## Embed: minimal snippet
1. Vendor the pinned files into the site at `/vendor/open-water/`: `index.html`, `src/`, `LICENSE`. **No `assets/audio`.**
2. Apply **`jkca-embed.patch`** (~15 added lines, tested). It adds `?ui=0` (no interface) and `?still=N`, and makes the loop stop after N frames when `prefers-reduced-motion: reduce` is set. When it stops it posts `'open-water:still'` to the parent.
3. Use it in a page (full working version with an IntersectionObserver pause in `embed/embed-demo.html`):
```html
<section class="hero" style="position:relative;height:100svh;background:#1b1f3a url(/img/ocean-dusk.jpg) center/cover">
  <!-- JS adds the iframe only when WebGL2 works, motion is allowed, and it's not a low-power device -->
</section>
<script type="module">
  const hero = document.querySelector('.hero');
  const ok = (() => { const g = document.createElement('canvas').getContext('webgl2'); return !!(g && g.getExtension('EXT_color_buffer_float')); })();
  if (ok && !matchMedia('(prefers-reduced-motion: reduce)').matches) {
    const f = Object.assign(document.createElement('iframe'), { title: '', tabIndex: -1, loading: 'lazy',
      src: '/vendor/open-water/index.html?ui=0&float=0&preset=calm&wind=2.5&elev=-2&az=250&h=6&pitch=4&clouds=0.15' });
    f.setAttribute('aria-hidden', 'true');
    f.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;border:0;pointer-events:none;opacity:0;transition:opacity 1.2s';
    f.onload = () => setTimeout(() => (f.style.opacity = 1), 1500); // let auto exposure settle
    hero.prepend(f);
  }
</script>
```
An iframe keeps its WebGL context, its `window` resize/key listeners and its global `#c` canvas lookup sealed off from the host app. That matters because `main.js` assumes it owns the page. Lazy loading also stops it from hurting LCP.

## Static-image / video fallback plan
1. **Bake posters** with the repo's own deterministic renderer: `?render&q=high&scale=1` plus our scene params, captured with `embed/ow-shot.cjs`. That needs a GPU machine, because the box is CPU-only and slow. Export AVIF + JPG at 1440×900, 2880×1800 and 1170×2532 (phone).
2. **Optional loop video:** `npm install --ignore-scripts` in a sandbox, then `npm run render -- --fps 30 --w 1920 --h 1080` with a short calm timeline, cut to a seamless 8–12 s loop and encoded H.264 + HEVC, ~1.5–3 MB each. Mobile gets the video. This gives the look without the GPU/battery cost.
3. **Decision ladder:** reduced motion → poster only. No WebGL2/`EXT_color_buffer_float`, Save-Data, `deviceMemory < 4`, or phone (by default, until real-device testing passes) → muted `playsinline` loop video, falling back to the poster. Desktop with WebGL2 → live iframe over the poster, faded in, unloaded when off-screen (IntersectionObserver), paused on `visibilitychange` (rAF already stops in hidden tabs).
4. The poster is always the CSS background, so the page looks finished at first paint.
- A starter poster is included: `embed/poster-dusk.jpg`, the dusk lilac render, 262 KB (390×844@DPR3). Re-bake at quality settings on a GPU before using it in production.

## 3 JKCA uses
**(a) Treebones 3D tilt-shift resort map** (`../treebones-3d-map/`, R3F stack per BUILD-SPEC). Open Water has its own WebGL2 context and a sea-level physics camera, so it **can't be dropped into the R3F scene**. The two options:
  - **Recommended v1:** bake a **top-down/oblique ocean loop** (`h=120–300&pitch=-25…-45&fov=40&float=0&preset=calm&wind=4&elev=25&az=250`, golden or late afternoon over the Pacific to the west). Use it as a `VideoTexture` on a plane along the map's top edge. Tilt-shift blur hides the seam into the coastline, and it costs nearly nothing on an iPhone. See `screens/desktop-webkit-1440x900-aerial-dusk.png` for the aerial look.
  - **v2 (optional):** port only the FFT cascade + foam shaders (`src/ocean/ocean.js`, `foam.js`, `water.js` shading, MIT) into a three.js `ShaderMaterial` for a live sea band. That's real work (multi-pass FFT in R3F). Only do it if the static loop looks too repetitive.
  - Static 2D fallback: the official hand-drawn map already has its ocean, so no change is needed.

**(b) Mok Cove (kids' app, ocean/Seal Beach theme, dusk lilac + navy-to-teal).** Use the **calm dusk** scene: `preset=calm&wind=2.5&waveScale=0.7&choppy=0.5&swellHs=0.4&clouds=0.15&rain=0&lightning=0&elev=-2&az=250&h=6&pitch=4&float=0`. See `screens/iphone-webkit-390x844-iphone-dusk.png`, which matches the lilac sky and navy water. For navy-to-teal, raise `elev` to 3–6 and/or adjust `wb`/`look`. Kid-safe rules: **never** storm, rain, lightning or audio (remove `assets/audio`, `ui=0`). `float=0` stops camera bob, which helps with motion sickness. **Reduced motion → still poster** (built into the patch and the snippet). The app ships a **baked loop video/stills** rather than live WebGL to protect battery and heat on kids' iPads and iPhones. Live WebGL is at most an opt-in "ocean window" screen.

**(c) JKCA marketing hero (alternative to pigment-drift).** A full-bleed **live golden-hour or dusk horizon** on desktop (`preset=calm&wind=3.5&elev=4&az=250&h=8&pitch=3&float=0`; `screens/desktop-1440x900-calm.png`), lazy iframe over a baked poster, and a loop video on phones. It's photoreal, which is very different from pigment-drift's painterly look, so it suits "Seal Beach studio" storytelling. **Tradeoff:** heavier GPU than pigment-drift, and it's only 2 days old, so A/B it on real hardware first.

## Files in this pack
| File | What |
|---|---|
| `README.md` | this |
| `jkca-embed.patch` | unified diff against `src/main.js` @ `c6009ac`: `?ui=0`, `?still=N`, reduced-motion stop |
| `embed/embed-demo.html` | tested hero embed (WebGL2/reduced-motion/low-power gating, fade-in, off-screen unload). Place next to the vendored `index.html` |
| `embed/poster-dusk.jpg` | starter dusk-lilac poster (box software render) |
| `embed/ow-shot.cjs`, `embed/ow-embed-test.cjs` | Playwright scripts used for the screenshots/tests (point them at your playwright-core path) |
| `screens/*.png` | 6 screenshots listed above |
Review clone: `/workspace/jkca-stack/vendor-review/ocean-water/` (pristine) and `/workspace/jkca-stack/vendor-review/ow-jkca/` (trimmed + patched test copy, no media).

## Open items for json
1. Real-iPhone test (Safari, iPhone 12 or newer): FPS + heat after 2 minutes, on the author's live demo with `?preset=calm`. The box has no GPU.
2. Bake production posters/loops on a GPU machine.
3. Pick (c) live hero vs. pigment-drift after an A/B.
