astara / src /depth.js
system's picture
system HF Staff
20a0160: Stop the dust overtaking the man
14ad8f2 verified
Raw History Blame Contribute Delete
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();
},
};
}