Spaces:
Running
Running
Download src/depth.js from cloudronin/astara: direct link, hf CLI and curl.
- Browser
- Download file 8.19 kB
-
https://huggingface.co/spaces/cloudronin/astara/resolve/main/src/depth.js
- Command line
-
hf download hf://spaces/cloudronin/astara/src/depth.js
-
curl -L -o depth.js https://huggingface.co/spaces/cloudronin/astara/resolve/main/src/depth.js
8.19 kB
| // 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(); | |
| }, | |
| }; | |
| } | |