Spaces:
Running
Running
File size: 8,192 Bytes
6742ef0 14ad8f2 6742ef0 14ad8f2 6742ef0 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 | // 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();
},
};
}
|