// DEPTH EXPERIMENT. Not a feature. Everything here is off by default, toggled // independently, and reversible. The output is a table of numbers and a // judgement, not a merged system. // // The question: how much of Limbo's depth can this palette afford? // // THE RULE THAT GOVERNS ALL OF IT: under 2% of the frame between luminance 30 // and 90. The build sits at 0.78% before any of this, so there is 1.22% of // budget for five cues put together. // // The principle that decides what is cheap and what is not: // // softening an edge between BLACK and BRIGHT creates zone pixels // softening an edge between two values already above 90 creates none // // That single line is why blur is allowed on the hills and forbidden on the // canopy, and why additive light is free while a vignette is not. import { PALETTE, DEPTH } from './tuning.js'; const TAU = Math.PI * 2; /** Deterministic 0..1. Same walk, same dust. */ function hash01(i, salt) { let h = Math.imul((i | 0) ^ salt, 2654435761) >>> 0; h ^= h >>> 15; h = Math.imul(h, 2246822507) >>> 0; h ^= h >>> 13; return (h >>> 0) / 4294967296; } // --- particles ------------------------------------------------------------- // Dust, spores, seeds. Three bands at three rates, which is the whole cue: one // band is just specks, three bands moving at different speeds is volume. // // Generated from position like the watchers rather than kept in a list, so // there is nothing to spawn, nothing to cull and nothing to get out of sync. // // The far band is BRIGHTER than the sky it sits on. A dark speck on a bright sky // is an edge from black to bright, which is the one thing the palette cannot // buy. A bright speck on a bright sky is free. // // RATE IS PARALLAX, AND 1.0 IS HIS OWN PLANE. The near band was 2.20, which // puts it at less than half his distance from the camera -- so the biggest, // most visible specks swept backwards past him at 889 px/s while he ran at // 404. Dust that overtakes the man reads as wind in his face, not as dust, and // at that speed the fall and the sway are invisible underneath it: the // horizontal beat the vertical 36 to 1. // // The near band is 1.05 now -- a couple of metres in front of him, moving at // his speed, which is what dust at that distance actually does. Standing still // it falls and sways as before; the only thing that changed is what running // does to it. const BANDS = [ { key: 'far', rate: 0.22, bright: true, min: 1, max: 2, fall: 9, sway: 10, share: 0.42 }, { key: 'mid', rate: 0.55, bright: false, min: 2, max: 3, fall: 15, sway: 16, share: 0.34 }, { key: 'near', rate: 1.05, bright: false, min: 4, max: 6, fall: 24, sway: 26, share: 0.24 }, ]; function drawBand(c, band, count, camX, viewW, viewH, t, hard) { if (count <= 0) return 0; // One particle per cell, so the cell size is set by how many are wanted. const cell = Math.max(24, Math.sqrt((viewW * viewH) / count)); const cols = Math.ceil(viewW / cell) + 2; const rows = Math.ceil(viewH / cell) + 2; const span = rows * cell; const shift = camX * band.rate; const i0 = Math.floor(shift / cell) - 1; c.fillStyle = band.bright ? DEPTH.MOTE : '#000'; let drawn = 0; for (let i = i0; i < i0 + cols; i++) { for (let j = 0; j < rows; j++) { const k = i * 7919 + j; const hx = hash01(k, 0x51ed); const hy = hash01(k, 0x2f9a); const hs = hash01(k, 0x7c3b); const sx = i * cell + hx * cell - shift + Math.sin(t * (0.25 + hs * 0.4) + hy * TAU) * band.sway; if (sx < -8 || sx > viewW + 8) continue; // Falls forever and wraps. No spawning, no lifetimes. const sy = (j * cell + hy * cell + t * band.fall * (0.6 + hs * 0.8)) % span; if (sy < -8 || sy > viewH + 8) continue; const size = band.min + hs * (band.max - band.min); if (hard) { // Integer-aligned rectangles are the one thing canvas will not // antialias, which is what makes this row cost nothing. c.fillRect(Math.round(sx), Math.round(sy), Math.round(size), Math.round(size)); } else { c.beginPath(); c.arc(sx, sy, size / 2, 0, TAU); c.fill(); } drawn++; } } return drawn; } // --- grain ----------------------------------------------------------------- // One tile, built once. Generating noise per frame is the expensive way and it // buys nothing: a random offset per frame already breaks the pattern. function buildGrain(size = 256) { const cv = document.createElement('canvas'); cv.width = cv.height = size; const x = cv.getContext('2d'); const im = x.createImageData(size, size); let s = 0x1d872b41; for (let i = 0; i < im.data.length; i += 4) { s ^= s << 13; s ^= s >>> 17; s ^= s << 5; s >>>= 0; const v = 128 + ((s % 255) - 127) * 0.8; im.data[i] = im.data[i + 1] = im.data[i + 2] = v; im.data[i + 3] = 255; } x.putImageData(im, 0, 0); return cv; } // --- light shafts ---------------------------------------------------------- // Angled beams where sky is visible. Additive, so they move AWAY from the // forbidden zone and can never fall into it. // // They fade on the same curve that closes the canopy and fills the forest with // watchers. One curve, three systems, and the light literally runs out. function drawShafts(c, camX, viewW, groundY, openness, strength) { if (strength <= 0 || openness <= 0) return 0; const SLANT = 0.42; const shift = camX * 0.06; // tied to the sky, so they do not swim const SP = 520; const i0 = Math.floor((shift - 400) / SP); const i1 = Math.ceil((shift + viewW + 400) / SP); c.save(); c.globalCompositeOperation = 'lighter'; let drawn = 0; for (let i = i0; i <= i1; i++) { const h = hash01(i, 0x3ab1); const w = 90 + h * 150; const top = i * SP + hash01(i, 0x77c2) * 260 - shift; const bottom = top + groundY * SLANT; // Off the ceiling on purpose. An additive effect at full brightness has // nowhere left to go — the same mistake the kept-object halo made when its // centre clipped at 255 and a 34% pulse moved it eleven levels. const a = 0.05 * strength * openness * (0.6 + h * 0.7); const g = c.createLinearGradient(top, 0, top + w, 0); g.addColorStop(0, 'rgba(255,240,210,0)'); g.addColorStop(0.5, `rgba(255,240,210,${a.toFixed(4)})`); g.addColorStop(1, 'rgba(255,240,210,0)'); c.fillStyle = g; c.beginPath(); c.moveTo(top, 0); c.lineTo(top + w, 0); c.lineTo(bottom + w, groundY); c.lineTo(bottom, groundY); c.closePath(); c.fill(); drawn++; } c.restore(); return drawn; } export function createDepth() { let grain = null; const stats = { particles: 0, shafts: 0 }; return { stats, /** Far and mid dust. Goes in behind the actors. */ drawParticlesBehind(c, camX, viewW, viewH, t) { stats.particles = 0; if (DEPTH.PART <= 0) return; const hard = DEPTH.PART_HARD > 0; for (const b of BANDS) { if (b.key === 'near') continue; stats.particles += drawBand(c, b, Math.round(DEPTH.PART * b.share), camX, viewW, viewH, t, hard); } }, /** Near dust, in front of everything. The band that sells the volume. */ drawParticlesFront(c, camX, viewW, viewH, t) { if (DEPTH.PART <= 0) return; const b = BANDS[2]; stats.particles += drawBand(c, b, Math.round(DEPTH.PART * b.share), camX, viewW, viewH, t, DEPTH.PART_HARD > 0); }, drawShafts(c, camX, viewW, groundY, openness) { stats.shafts = drawShafts(c, camX, viewW, groundY, openness, DEPTH.SHAFT); }, /** Last thing in the frame, over everything including the torch. */ drawGrain(c, viewW, viewH) { if (DEPTH.GRAIN <= 0) return; if (!grain) grain = buildGrain(); const ox = -Math.floor(Math.random() * grain.width); const oy = -Math.floor(Math.random() * grain.height); c.save(); c.globalAlpha = DEPTH.GRAIN; c.globalCompositeOperation = 'overlay'; for (let x = ox; x < viewW; x += grain.width) { for (let y = oy; y < viewH; y += grain.height) c.drawImage(grain, x, y); } c.restore(); }, }; }