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();
    },
  };
}