SURVEY VESSEL LUMEN · TECHNICAL LOG
Neon Depths is a single full-screen WebGL2 fragment shader — no textures, no 3D models, no frameworks. Every ray of light, every drifting mote of marine snow and every jellyfish is pure math, evaluated per pixel, sixty times a second.
The site is structured as a dive. The page is five viewport-tall sections — one per
real oceanic depth zone (surface, mesopelagic, bathypelagic, abyssopelagic, hadal) — and the
scroll position is collapsed into a single number, uDepth, between 0 and 1.
That one uniform drives everything: the water column darkens, the god rays die
out below the twilight zone, marine snow gains parallax speed, and the HUD readouts
(depth, pressure, temperature, light) interpolate through real oceanographic values.
Interaction follows a submersible metaphor: the cursor is your lamp. Creatures are gently attracted to it (real deep-sea animals are famously drawn to submersible lights), and a click emits a sonar-like pulse ring that startles them away.
Typography pairs Michroma (wide, instrument-panel display caps) against Spectral in light italic (the poetic voice of the log entries), with a system monospace for HUD data. The tension between instrument and literature is the art direction.
Raw WebGL2. A single full-screen triangle generated
from gl_VertexID — no vertex buffers at all. One fragment shader paints the entire ocean.
One requestAnimationFrame loop. All easing is
exponential-decay lerp (1 − 0.002^dt), so motion is frame-rate independent and always eases.
Plain HTML sections over the canvas. Reveals via
IntersectionObserver adding a class; CSS transitions with expo-out curves do the rest.
devicePixelRatio capped at 2, context-loss
handlers rebuild the program, CSS-gradient fallback if WebGL2 is unavailable, and
prefers-reduced-motion slows time to 14%.
The bufferless full-screen triangle is the whole vertex stage:
// vertex shader — no attributes, no buffers
void main(){
vec2 v = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2);
gl_Position = vec4(v * 2.0 - 1.0, 0.0, 1.0);
}
The water column is two vertical gradients — one for the surface mood, one for the trench —
crossfaded by uDepth. A 4-octave value-noise FBM adds drifting volumetric murk.
God rays are cheap and lovely: a sine field warped by another sine, raised to a power so only
the crests survive, masked to the top of the frame and multiplied by 1 − smoothstep(0, .42, uDepth)
so they physically die out as you sink past the twilight zone:
// god rays — die below the twilight zone
float rayAmt = 1.0 - smoothstep(0.0, 0.42, uDepth);
float rx = p.x * (1.35 + (1.0 - frag.y) * 0.9); // splay outward with depth
float rays = sin(rx * 26.0 + sin(rx * 11.0 + t * 0.45) * 2.2 + t * 0.22);
rays = pow(max(rays, 0.0), 3.0) * smoothstep(0.15, 1.05, frag.y);
col += vec3(0.085, 0.26, 0.32) * rays * rayAmt * 0.38;
Marine snow is three parallax layers of a hashed grid: each cell hides at most one mote
(a step() on the hash keeps it sparse), orbiting its cell centre on its own sine
schedule. Crucially, snow brightness is multiplied by proximity to the cursor lamp — so the
blizzard mostly appears inside your beam, the way it does from a real submersible window.
Each of the seven jellyfish is drawn analytically in local space: a breathing bell
(a circle SDF whose radius and vertical squash pulse on a sine), a glowing core, five
tentacles (thin exp() glow lines whose sway amplitude grows with distance
below the bell — so they trail like silk), and a wide ambient halo. All glow is exponential
falloff; the additive sum over creatures is what makes overlaps bloom.
// tentacles — roots fade in under the bell, sway grows with depth
float yy = -(q.y - 0.06); // positive going down
float fadeIn = smoothstep(0.0, 0.30, yy);
float fadeOut = 1.0 - smoothstep(0.25, 0.9, yy);
for(int i = 0; i < 5; i++){
float fi = float(i) - 2.0;
float x0 = fi * 0.052;
float sway = 0.05 * sin(t * 2.1 + phase + yy * 6.5 + fi * 1.7) * yy;
float d = abs(q.x - x0 * (1.0 + yy * 0.7) - sway);
tent += exp(-d * 95.0) * exp(-yy * 1.6) * fadeIn * fadeOut * 0.6;
}
Their minds live in JavaScript. Every frame, each creature sums three urges — sinusoidal wander, attraction to the lamp inside a 0.85-radius (with a polite orbit when it gets within 0.14 so it never sits on the cursor), and a scatter impulse when a click pulse detonates. Positions stream up the screen as you scroll, scaled by creature size — bigger jellies get more parallax, selling the sense of depth:
// drawn to the lamp — but keep a respectful distance
const dd = Math.hypot(dx, dy);
if (dd < 0.85 && dd > 0.14){
const f = 0.16 * (1.0 - dd / 0.85) * dt;
c.vx += (dx / dd) * f; c.vy += (dy / dd) * f;
} else if (dd <= 0.14){ // orbit, don't collide
c.vx += (-dy / dd) * 0.18 * dt;
c.vy += ( dx / dd) * 0.18 * dt;
}
Positions, phases and sizes are packed into a vec4 array uniform —
seven uniform4fv floats per frame is the entire CPU→GPU traffic.
1 − exp(−col × 1.4) soft-clips the additive glow so
overlapping creatures bloom instead of clipping to white.uFade eases the scene up from black and the
title lines rise with staggered expo-out reveals. No flash of unstyled content, ever.exp(−|dist − t·0.85| × 15) · exp(−t·2.1),
plus a radial velocity kick to every creature. Cause in JS, effect in GLSL, one shared uniform.mix(bottomColor, topColor, uv.y). Get the resize/DPR/uniform plumbing right first.uDepth and make at least three visual systems
listen to it — the descent only reads if the whole world responds.No AI-generated raster assets were used — every pixel is procedural. The only external resources are two Google Fonts (Michroma, Spectral).
Below the descent, the dive opens into a survey catalogue — three linked sections that turn the experience from a scroll-poem into a real, navigable site: a sticky bioluminescent nav with anchor links and a scroll-spy, a species index, a sonar depth-strata table, and a footer written in the same instrument language.
Seven creature cards, each holding its own
live WebGL2 portrait — a second, self-contained SDF jellyfish shader rendered
into a per-card canvas. Portraits only animate while on screen (an IntersectionObserver
toggles each one), and breathe a little harder on hover via a smoothed uHover uniform.
The five true oceanic zones as a sonar-return table — real numbers (90% of life above 200 m, 1% surface light at the twilight floor, 1,000 atm in the hadal) with a colour-coded band edge per row and a slow scanning sweep behind the panel.
A click fires twin expanding rings
plus an origin flash in the shader, kicks every creature outward, surfaces a HUD contact chip
(bearing, range, return count) and plays a synthesised WebAudio ping — an oscillator swept
880→340 Hz. And the depths answer: the ring travels at a fixed 0.9 units/s,
so each creature inside range blinks back (a uFlash uniform per medusa, with its own
swelling echo ring) exactly when the wavefront reaches it — with a soft 1.48 kHz return blip
scheduled at the same instant, while the chip's RET counter ticks up live with each echo.
uDepth is remapped to reach 1.0 exactly
as the hadal zone bottoms out, then stays pinned at the trench floor through the catalogue — so the
water stays black and heavy while you read the record.
The portrait shader is a trimmed cousin of the main creature function — bell SDF, six swaying
exp() tentacles, core and halo, over a dark porthole gradient with a few drifting motes:
// per-card portrait — one centred, breathing medusa
float pulse = 0.5 + 0.5 * sin(t * 1.4);
float breathe = 1.0 + uHover * 0.12 * pulse; // leans in on hover
vec2 b = q / breathe; b.y *= 1.0 + 0.30 * pulse;
float bell = exp(-abs(length(b) - r) * 22.0) * 0.95;
col += uCol * (bell + fill + core + tent + halo) * (0.85 + uHover * 0.5);
Eight WebGL2 contexts run at once (one scene + seven portraits), all capped in DPR and paused when off-screen — the page holds 60fps and never trips a context-limit warning.