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.
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.
#2FA3C7 to hadal black #010409.pow(sunlight, 0.65), so they die on cue around 300 m.mod(y − camY)), so the same particles fall past you for 4,207 meters.FogExp2 density and bloom strength both ease up as light dies. The deep isn't darker so much as thicker.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.
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.
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.
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.
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.