NYCTOR is a camera housing that does not exist, rated to 300 metres. Its page has one idea: the scroll bar is a depth gauge. Move down and you go down, and everything on the screen is recomputed from how deep you are — not faded on a timeline, computed from published models of seawater.
The films were generated; the numbers were not. Every figure the page prints can be checked with a calculator and a paper from the reference list.
Go down yourself
Six painted swatches, lit by the sunlight that is left at each depth. The top strip of each is its colour at the surface; below it is what reaches it underwater. The slider is the page's own scroll mapping, so it starts slow and gets faster.
The exposure is held, the way a camera's would be, so what changes is the hue rather than the brightness. Red goes first. Cyan — the site's own accent — goes blue on the way down.
1 · The scroll is a logarithm
Almost everything interesting happens near the surface: red is gone in the first ten metres, green in the first fifty. A linear scroll would give those ten metres 3 % of the page. So the section's scroll is mapped to depth exponentially.
const MAX_DEPTH = 300;
function depthNow() { // depth belongs to one section, not the document
const sec = document.getElementById('depth');
const span = sec.offsetHeight - innerHeight * 0.7;
const seen = scrollY - sec.offsetTop + innerHeight * 0.45;
const t = Math.min(1, Math.max(0, seen / Math.max(1, span)));
return Math.pow(MAX_DEPTH, t) - 1 + t; // 0 m at t = 0, exactly 300 m at t = 1
}
With that curve the first 10 m take 41 % of the scroll, the first 42 m take 66 %, and
the last 200 m — where only blue is left and nothing changes quickly — take the final 19 %. The
− 1 + t is there so the ends land exactly: 300⁰ − 1 is 0, and 300¹ − 1 + 1 is 300.
2 · Depth is a path through water
Sunlight reaching something at depth d has travelled d metres of water. Beer–Lambert gives the fraction of each wavelength that survives, from the water's absorption and scattering.
// pure-water absorption, m⁻¹ — Pope & Fry (1997), every 25 nm across the visible band
const ABSORPTION = [[400, 0.00663], [425, 0.00619], [450, 0.00922], /* … */ [700, 0.62400]];
const scattering = (nm) => 0.0029 * (400 / nm) ** 4.32; // Morel (1974)
const attenuation = (nm) => absorption(nm) + scattering(nm); // c = a + b
function transmittance(nm, metres) { // Beer–Lambert: I / I₀ = e^(−c·L)
return Math.exp(-attenuation(nm) * metres);
}
The table is the measured data, linearly interpolated between rows. Nothing here was tuned: the colour of the page at 40 m is whatever those two papers say it is.
3 · Three wavelengths stand for red, green and blue
A screen has three channels, so the model is sampled at three wavelengths near the peaks of the sRGB primaries, and each channel is scaled by what survives.
const PRIMARIES = { r: 610, g: 545, b: 465 }; // nm
function colourAtDepth(depth) {
return {
r: transmittance(PRIMARIES.r, depth),
g: transmittance(PRIMARIES.g, depth),
b: transmittance(PRIMARIES.b, depth),
};
}
const extinctionDepth = (nm, fraction = 0.1) => -Math.log(fraction) / attenuation(nm);
The attenuation is 0.249 per metre for red, 0.054 for green and 0.012 for blue — blue is dimmed twenty-one times more slowly than red. Ninety per cent of red is gone by 9.2 m, orange by 13.7 m, yellow by 29 m, green by 42.4 m. Blue holds to 194 m, which is why everything below that looks the same colour.
4 · The films cross-fade on the same scale
Four films were made, one at each of 8, 40, 120 and 300 m, each an eight-second take on a locked camera that ends where it began. Between two of them the page cross-fades on log depth, and each film plays through once across its own band, so scrolling either way never jumps.
const PLATES = [{ depth: 8 }, { depth: 40 }, { depth: 120 }, { depth: 300 }];
function mix(depth) { // which films, and how strongly
const ln = (x) => Math.log(Math.max(1, x));
for (let i = 1; i < PLATES.length; i++) {
if (depth <= PLATES[i].depth) {
const a = PLATES[i - 1].depth, b = PLATES[i].depth;
const t = (ln(depth) - ln(a)) / (ln(b) - ln(a));
return [{ i: i - 1, w: 1 - t }, { i, w: t }];
}
}
}
At 20 m that is 43 % of the 8 m film and 57 % of the 40 m one; at 60 m it is 63 % of the 40 m film. The light falls off exponentially, so a cross-fade on a log scale tracks it; a linear one would hold the bright film too long.
5 · Everything else is a function of depth
The same number feeds five more models, and every card on the page prints their output.
const pressure = (d) => 101325 + 1025 * 9.80665 * d; // Pa, seawater
const portTonnes = (d) => (pressure(d) - 101325) * Math.PI * 0.045 ** 2 / (1000 * 9.80665);
const trappedFraction = (d) => 101325 / pressure(d); // Boyle: pV = constant
function reach(lumens = 3200, floor = 0.5) { // the lamp: inverse square × Beer–Lambert
const I = lumens / (4 * Math.PI), c = attenuation(545);
const E = (r) => I / (r * r) * Math.exp(-c * r);
let lo = 0.05, hi = 200;
for (let i = 0; i < 80; i++) { const mid = (lo + hi) / 2; if (E(mid) > floor) lo = mid; else hi = mid; }
return (lo + hi) / 2; // metres at which the light drops to 0.5 lux
}
At 300 m that is 31.2 bar, 1.96 tonnes on a 90 mm sapphire port, the air sealed at the dock squeezed to 3.25 % of its volume, and 0.0085 lux of daylight — against 100 000 at the surface. The lamps reach 15.0 m. A sonar ping to the surface and back takes 401 ms (Mackenzie's sound speed; Thorp's attenuation says how far it carries).
What it costs
| Part | Size | Note |
|---|---|---|
The physics (js/depth.js) | 6.8 KB | six models, no dependencies |
The descent (js/descent.js) | 5.4 KB | scroll mapping, film mix, loading |
| Every reading at one depth | 0.34 µs | pressure, load, colour, daylight, air, ping; M1 Pro |
| The films | 48 MB | ten eight-second takes, loaded whole so they can be scrubbed |
The lamp's reach is the only reading that iterates — eighty halvings, 0.6 µs — and it does not even depend on depth. As on every page like this, the physics is a rounding error next to the video.
What we would do differently
Give type its own rule from the start. An early version drained the whole interface with the water, text included, by multiplying every colour by what survives. By the deep end the type had turned a violet that could not be read. The rule that came out of it: decide first which colours carry information and which carry atmosphere. The accent can take the physics at full strength and die into blue; the type only cools, between two whites that stay readable at every depth.
const drain = 1 - t.g; // 0 at the surface, 1 once green is spent
const accent = scale(BRAND, t); // the accent takes the loss neat
const ink = lerp([238, 246, 248], [196, 218, 245], drain); // the type only cools
Lock the camera before asking for anything else. Every film take came back with a slow push-in — about 1.3× over eight seconds. Scrubbed by scroll, that reads as the camera lurching whenever the reader moves, and a take that does not end where it began cannot be scrubbed back without a jump. What held it was giving the generator the same still as the first and the last frame, and saying so in the prompt. The drift fell to five pixels or less at 480 wide, the exposure came back to within 1 %, and a script now checks every take for exactly that before it goes on a page.
Say which water it is. The model is pure seawater — the clearest water there is. Real water carries plankton and sediment and loses its light far sooner, so every depth on the page is the best case. The water type belongs among the inputs, not in a footnote.
Absorption: Pope & Fry, Absorption spectrum (380–700 nm) of pure water, Applied Optics 36 (1997). Scattering: Morel, Optical properties of pure water and pure sea water (1974). Sound speed: Mackenzie, Nine-term equation for sound speed in the oceans, JASA 70 (1981). Attenuation: Thorp (1967). Hydrostatics and Boyle's law as in any physics text. NYCTOR is fictional.