How Sounding was made

One fixed WebGL canvas, one very long page, and a single rule: scroll position is depth. Everything else — color, fog, creatures, instruments — is a function of that one number.

01 — The spine: scroll → depth

The page is ~1,300vh of mostly-empty sections. Each section carries a data-d attribute (the depth in meters at which its top edge crosses the viewport top). At load, a piecewise-linear map is built from the actual DOM offsets, so copy and physics can never drift apart.

depth = piecewiseLerp(scrollY / maxScroll)   // 0 → 4,207 m
camera.position.y = -depth * 0.05            // 1 unit = 20 m
waterColor  = gradientStops(depth)           // 9 hand-picked stops
sunlight    = exp(-0.023 * depth)            // ~1% at 200 m
temperature = 1.8 + 22.2 * exp(-depth / 380) // °C, for the HUD

Smooth scrolling is Lenis; DOM reveals are GSAP ScrollTrigger. The 3D loop reads the same scroll value, so the two worlds stay glued.

02 — The water

03 — The living things

04 — Art direction

0 m
200 m
1 000 m
2 600 m
4 207 m
biolight
the lure

Type is Instrument Serif (display, italic for zone names) against IBM Plex Mono (the instrument: depth, temperature, pressure, sunlight). The HUD depth counter lerps toward the true value so it feels mechanical, not digital. Grain, vignette, and a cursor glow that only exists once the sun is gone — you are the light now — finish the atmosphere.

05 — Iteration log (three passes)

Pass 1 — correctness

Killed a blown-out god ray (clamped ray alpha, lowered surface bloom), fixed a HUD label wrap, replaced missing ↑ glyphs with inline SVG arrows, faded the HUD out on arrival so it can't collide with the colophon, removed a jellyfish position glitch (sawtooth modulo), reduced reveal blur cost, forced scroll restoration to the surface.

Pass 2 — complexify

Added the drowned sun, jellyfish heart-glows, depth-boundary hairline markers in the DOM, ambient self-igniting plankton below 1,200 m, and the depth-scaled cursor glow. Verified 120 fps at 1,600×900 with bloom.

Pass 3 — polish

Type-scale and tracking audit across breakpoints, mobile layout (rail hidden, stats collapsed, zone heads left-aligned), reduced-motion path, WebGL context-loss recovery, headless framebuffer verification of every zone, meta/OG/favicon, and a final console-clean check.

06 — Reproduce it

abyss/
├── index.html          # importmap: three → ./lib/three.module.js
├── css/main.css        # palette, HUD, zones, grain, vignette
├── js/main.js          # ~700 lines: scene, shaders, scroll rig, HUD
├── lib/                # three.module.js + jsm/postprocessing, gsap, lenis
└── fonts/              # Instrument Serif + IBM Plex Mono (woff2, self-hosted)

# serve
python3 -m http.server 4791
# deploy
npx wrangler pages deploy abyss --project-name=<name>

No build step, no framework, no textures, no 3D models — every visual is math in a shader or a DOM node. Total payload ≈ 1 MB, almost all of it three.js itself.