paperlab 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/fx.cjs ADDED
@@ -0,0 +1,4276 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __esm = (fn, res, err) => function __init() {
9
+ if (err) throw err[0];
10
+ try {
11
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
12
+ } catch (e) {
13
+ throw err = [e], e;
14
+ }
15
+ };
16
+ var __export = (target2, all) => {
17
+ for (var name in all)
18
+ __defProp(target2, name, { get: all[name], enumerable: true });
19
+ };
20
+ var __copyProps = (to, from, except, desc) => {
21
+ if (from && typeof from === "object" || typeof from === "function") {
22
+ for (let key of __getOwnPropNames(from))
23
+ if (!__hasOwnProp.call(to, key) && key !== except)
24
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
25
+ }
26
+ return to;
27
+ };
28
+ var __toESM = (mod, isNodeMode, target2) => (target2 = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
29
+ // If the importer is in node compatibility mode or this is not an ESM
30
+ // file that has been converted to a CommonJS file using a Babel-
31
+ // compatible transform (i.e. "__esModule" has not been set), then set
32
+ // "default" to the CommonJS "module.exports" for node compatibility.
33
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target2, "default", { value: mod, enumerable: true }) : target2,
34
+ mod
35
+ ));
36
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
37
+
38
+ // src/surface/damageContract.ts
39
+ var DAMAGE_LOOK_DEFAULTS, DAMAGE_CHANNELS;
40
+ var init_damageContract = __esm({
41
+ "src/surface/damageContract.ts"() {
42
+ "use strict";
43
+ DAMAGE_LOOK_DEFAULTS = {
44
+ // §5 asks for 0.3–1 mm. 1.8 was outside it, and outside the slider's range
45
+ // it was tuned in — a control at its limit is a report that something
46
+ // underneath is wrong, which in this case was a fire nothing could see.
47
+ emberWidth: 0.9,
48
+ emberIntensity: 1.45,
49
+ emberCoverage: 0.6,
50
+ emberFlicker: 1.65,
51
+ emberGlow: 1.25,
52
+ sparkle: 0.5,
53
+ lipWidth: 1.2,
54
+ // Was 1.5, its slider's ceiling, which made the ash lip brighter than the
55
+ // paper it sits on. Ash is pale GREY; the reference's lip is dimmer than
56
+ // the sheet, not a highlight drawn on it.
57
+ lipBrightness: 0.85,
58
+ // Was 1, also a ceiling. At full warmth the char is milk chocolate —
59
+ // closer to cardboard than to charcoal (§5). Burnt paper keeps a little
60
+ // warmth in the plates and reads near black in a frame with a fire in it.
61
+ charWarmth: 0.3,
62
+ // Was 1, also a ceiling. The cracks are drawn as thin polygon outlines, so
63
+ // at full strength the char reads as a mosaic rather than as broken plates.
64
+ charCracks: 0.55,
65
+ scorchReach: 30,
66
+ scorchDarkness: 1.17,
67
+ fingers: 1.25,
68
+ edgeWave: 8.5,
69
+ edgeBite: 3.6
70
+ };
71
+ DAMAGE_CHANNELS = { char: 0, saturation: 1, heat: 2, presence: 3 };
72
+ }
73
+ });
74
+
75
+ // src/fx/field.ts
76
+ function diffusionTensor(rate, anisotropy, fibre) {
77
+ const along = rate * 2 * anisotropy / (1 + anisotropy);
78
+ const across = rate * 2 / (1 + anisotropy);
79
+ const c = Math.cos(fibre);
80
+ const s = Math.sin(fibre);
81
+ return {
82
+ xx: along * c * c + across * s * s,
83
+ yy: along * s * s + across * c * c,
84
+ xy: (along - across) * c * s
85
+ };
86
+ }
87
+ function stencil(rate, anisotropy, fibre, dt = FIXED_DT) {
88
+ const d = diffusionTensor(rate, anisotropy, fibre);
89
+ const cross = Math.min(Math.abs(d.xy), d.xx, d.yy);
90
+ const scale = dt * (FIELD_SIZE - 1) * (FIELD_SIZE - 1);
91
+ let ax = (d.xx - cross) * scale;
92
+ let ay = (d.yy - cross) * scale;
93
+ let ad = cross * scale;
94
+ const total = ax + ay + ad;
95
+ const clamped = total > STABLE;
96
+ if (clamped) {
97
+ const k = STABLE / total;
98
+ ax *= k;
99
+ ay *= k;
100
+ ad *= k;
101
+ }
102
+ return { ax, ay, ad, diagonal: d.xy >= 0 ? 1 : -1, clamped };
103
+ }
104
+ function hash(x, y, seed) {
105
+ let h = Math.imul(x, 374761393) ^ Math.imul(y, 668265263) ^ Math.imul(seed, 1274126177);
106
+ h = Math.imul(h ^ h >>> 13, 1274126177);
107
+ return ((h ^ h >>> 16) >>> 0) / 4294967296;
108
+ }
109
+ function grow(into, box) {
110
+ if (box.x0 > box.x1) return;
111
+ if (into.x0 > into.x1) {
112
+ into.x0 = box.x0;
113
+ into.y0 = box.y0;
114
+ into.x1 = box.x1;
115
+ into.y1 = box.y1;
116
+ return;
117
+ }
118
+ if (box.x0 < into.x0) into.x0 = box.x0;
119
+ if (box.y0 < into.y0) into.y0 = box.y0;
120
+ if (box.x1 > into.x1) into.x1 = box.x1;
121
+ if (box.y1 > into.y1) into.y1 = box.y1;
122
+ }
123
+ var CHAR, SATURATION, HEAT, PRESENCE, FIELD_SIZE, FIXED_DT, MAX_STEPS_PER_FRAME, REST, IGNITION, STABLE, DEFAULTS, EMPTY, DamageField;
124
+ var init_field = __esm({
125
+ "src/fx/field.ts"() {
126
+ "use strict";
127
+ init_damageContract();
128
+ CHAR = DAMAGE_CHANNELS.char;
129
+ SATURATION = DAMAGE_CHANNELS.saturation;
130
+ HEAT = DAMAGE_CHANNELS.heat;
131
+ PRESENCE = DAMAGE_CHANNELS.presence;
132
+ FIELD_SIZE = 64;
133
+ FIXED_DT = 1 / 120;
134
+ MAX_STEPS_PER_FRAME = 8;
135
+ REST = 1e-3;
136
+ IGNITION = 0.35;
137
+ STABLE = 0.4;
138
+ DEFAULTS = {
139
+ fibre: 0,
140
+ anisotropy: 3,
141
+ heatDiffusion: 58e-5,
142
+ wicking: 45e-4,
143
+ charRate: 2.7,
144
+ consumeRate: 6,
145
+ combustion: 2.4,
146
+ cooling: 0.18,
147
+ wetResistance: 2.6,
148
+ drying: 0.015,
149
+ grain: 0.34,
150
+ seed: 1
151
+ };
152
+ EMPTY = () => ({ x0: 1, y0: 1, x1: 0, y1: 0 });
153
+ DamageField = class {
154
+ size = FIELD_SIZE;
155
+ /** RGBA per texel in float, row-major from v = 0. The simulation's own state. */
156
+ data;
157
+ /** The same, at 8 bits, for upload. Kept in step with `data` over what changed. */
158
+ pixels;
159
+ /**
160
+ * How ragged the sheet DRAWS this field's edges, 0..1 — see
161
+ * `DamageSource.detail`. Not a simulation option, because it changes
162
+ * nothing the field computes: set it from `fxQualityFor(tier).detail`, and
163
+ * again whenever the tier moves.
164
+ */
165
+ detail = 1;
166
+ revision = 0;
167
+ next;
168
+ /** Per-texel ignition threshold, fixed for the life of the sheet. */
169
+ tinder;
170
+ o;
171
+ heat;
172
+ water;
173
+ accumulator = 0;
174
+ /** Fixed steps run since the field was made — its own clock. See {@link time}. */
175
+ steps = 0;
176
+ /** Cells that might change on the next step. Everything outside is at rest. */
177
+ active = EMPTY();
178
+ /** Cells written since the last pack into `pixels`. */
179
+ touched = EMPTY();
180
+ visited = 0;
181
+ /** Running totals, so the stats never have to walk the whole grid. */
182
+ present;
183
+ wetTotal = 0;
184
+ stats = {
185
+ front: 0,
186
+ charred: 0,
187
+ consumed: 0,
188
+ wetted: 0,
189
+ saturation: 0,
190
+ remaining: 1
191
+ };
192
+ /**
193
+ * Texels whose presence reached zero during the last `step`, in the first
194
+ * {@link consumedCount} slots — where ash leaves from.
195
+ *
196
+ * WHERE, not just how many. The stats were enough for the sound, which
197
+ * wants a level; an emitter wants a place. Fixed buffers the size of the
198
+ * grid and written in place: a burning sheet must not allocate a list a
199
+ * step, and a texel is consumed once, so a step can never fill it twice.
200
+ */
201
+ consumedCells = new Int32Array(FIELD_SIZE * FIELD_SIZE);
202
+ /** The burn front as of the last step that ran, in the first {@link frontCount} slots — where embers and smoke leave from. */
203
+ frontCells = new Int32Array(FIELD_SIZE * FIELD_SIZE);
204
+ consumedLength = 0;
205
+ frontLength = 0;
206
+ constructor(options = {}) {
207
+ this.o = { ...DEFAULTS, ...options };
208
+ const n = FIELD_SIZE * FIELD_SIZE;
209
+ this.data = new Float32Array(n * 4);
210
+ this.next = new Float32Array(n * 4);
211
+ this.pixels = new Uint8Array(n * 4);
212
+ this.tinder = new Float32Array(n);
213
+ this.present = n;
214
+ for (let i = 0; i < n; i++) {
215
+ this.data[i * 4 + PRESENCE] = 1;
216
+ this.pixels[i * 4 + PRESENCE] = 255;
217
+ const x = i % FIELD_SIZE;
218
+ const y = i / FIELD_SIZE | 0;
219
+ const coarse = hash(x >> 2, y >> 2, this.o.seed);
220
+ const fine = hash(x, y, this.o.seed * 7 + 11);
221
+ this.tinder[i] = 1 + this.o.grain * (coarse * 0.65 + fine * 0.35 - 0.5);
222
+ }
223
+ this.next.set(this.data);
224
+ this.heat = stencil(this.o.heatDiffusion, this.o.anisotropy, this.o.fibre);
225
+ this.water = stencil(this.o.wicking, this.o.anisotropy, this.o.fibre);
226
+ }
227
+ /** Bumped whenever `pixels` changes. */
228
+ get version() {
229
+ return this.revision;
230
+ }
231
+ /**
232
+ * Simulated seconds this field has burned for — whole fixed steps, never
233
+ * wall time.
234
+ *
235
+ * What anything DRAWN from the field animates on: the ember line's beads
236
+ * flicker and crawl, flames puff, and all of it has to be the same at the
237
+ * same moment of the same burn, or a replay flickers differently from the
238
+ * original and a capture can never be taken twice. Stops while the field
239
+ * sleeps, which is right — a sheet at rest has nothing hot left to move.
240
+ */
241
+ get time() {
242
+ return this.steps * FIXED_DT;
243
+ }
244
+ /** Nothing is happening to this sheet, and stepping it costs nothing. */
245
+ get asleep() {
246
+ return this.active.x0 > this.active.x1;
247
+ }
248
+ /**
249
+ * Cells the last `step` visited.
250
+ *
251
+ * The cost of the field, stated in the one unit that means the same thing on
252
+ * every machine. Milliseconds would make a test of it a test of whoever ran
253
+ * it — which is how the hands harness spent weeks passing on one laptop.
254
+ */
255
+ get cellsVisited() {
256
+ return this.visited;
257
+ }
258
+ /** What the last `step` produced. */
259
+ get lastStats() {
260
+ return this.stats;
261
+ }
262
+ /** How many of {@link consumedCells} the last `step` wrote. Always `lastStats.consumed`. */
263
+ get consumedCount() {
264
+ return this.consumedLength;
265
+ }
266
+ /**
267
+ * How many of {@link frontCells} are current. `lastStats.front` times the
268
+ * texel count — and held, like it, across a frame too short to step.
269
+ */
270
+ get frontCount() {
271
+ return this.frontLength;
272
+ }
273
+ /** Texel index for a UV, clamped to the sheet. */
274
+ at(u, v) {
275
+ const x = Math.min(FIELD_SIZE - 1, Math.max(0, Math.round(u * (FIELD_SIZE - 1))));
276
+ const y = Math.min(FIELD_SIZE - 1, Math.max(0, Math.round(v * (FIELD_SIZE - 1))));
277
+ return y * FIELD_SIZE + x;
278
+ }
279
+ /** The four channels at a UV, for anything that needs to ask a question of a point. */
280
+ sample(u, v) {
281
+ const i = this.at(u, v) * 4;
282
+ return [this.data[i], this.data[i + 1], this.data[i + 2], this.data[i + 3]];
283
+ }
284
+ /**
285
+ * Add to one channel in a soft disc.
286
+ *
287
+ * Every paint operation is this with a different channel, which is the
288
+ * point of having one primitive: `ignite` and `wet` are not two systems
289
+ * that happen to look alike, they are the same write.
290
+ *
291
+ * `plateau` is the fraction of the radius that takes the full amount
292
+ * before the falloff starts. Zero for anything added, so the deposit has a
293
+ * soft peak — a flame held near paper does not deposit a stamped disc of
294
+ * heat, and a too-perfect edge is the first thing that reads as fake.
295
+ * Raised for anything removed, because a pure smoothstep never quite
296
+ * reaches zero even at its centre, and "almost all the way through" is not
297
+ * a hole.
298
+ */
299
+ paint(channel, u, v, radius, amount, plateau = 0) {
300
+ if (radius <= 0 || amount === 0) return;
301
+ const last = FIELD_SIZE - 1;
302
+ const cx = u * last;
303
+ const cy = v * last;
304
+ const r = radius * last;
305
+ const box = {
306
+ x0: Math.max(0, Math.floor(cx - r)),
307
+ y0: Math.max(0, Math.floor(cy - r)),
308
+ x1: Math.min(last, Math.ceil(cx + r)),
309
+ y1: Math.min(last, Math.ceil(cy + r))
310
+ };
311
+ if (box.x0 > box.x1 || box.y0 > box.y1) return;
312
+ const r2 = r * r;
313
+ const { data, next } = this;
314
+ for (let y = box.y0; y <= box.y1; y++) {
315
+ for (let x = box.x0; x <= box.x1; x++) {
316
+ const dx = x - cx;
317
+ const dy = y - cy;
318
+ const d2 = dx * dx + dy * dy;
319
+ if (d2 > r2) continue;
320
+ const t = plateau >= 1 ? 1 : Math.min(1, (1 - Math.sqrt(d2) / r) / (1 - plateau));
321
+ const falloff = t * t * (3 - 2 * t);
322
+ const i = (y * FIELD_SIZE + x) * 4 + channel;
323
+ const before = data[i];
324
+ const after = Math.min(1, Math.max(0, before + amount * falloff));
325
+ if (after === before) continue;
326
+ data[i] = after;
327
+ next[i] = after;
328
+ if (channel === PRESENCE) this.present += after - before;
329
+ if (channel === SATURATION) this.wetTotal += after - before;
330
+ }
331
+ }
332
+ grow(this.active, box);
333
+ grow(this.touched, box);
334
+ this.pack();
335
+ }
336
+ /** Hold a flame near the sheet. Heat, not char — the burning is the field's job. */
337
+ ignite(u, v, radius = 0.06, amount = 1) {
338
+ this.paint(HEAT, u, v, radius, amount);
339
+ }
340
+ /** Wet the sheet. Saturation wicks along the fibre from wherever it lands. */
341
+ wet(u, v, radius = 0.08, amount = 0.9) {
342
+ this.paint(SATURATION, u, v, radius, amount);
343
+ }
344
+ /**
345
+ * Take the paper away along a path: a tear, a cut, a punched hole.
346
+ *
347
+ * Presence only ever decreases. A sheet does not grow back, and a paint op
348
+ * that could raise it would make every burn reversible by accident.
349
+ */
350
+ cut(u0, v0, u1, v1, width = 0.02) {
351
+ const steps = Math.max(1, Math.ceil(Math.hypot(u1 - u0, v1 - v0) * FIELD_SIZE));
352
+ for (let s = 0; s <= steps; s++) {
353
+ const t = s / steps;
354
+ this.paint(PRESENCE, u0 + (u1 - u0) * t, v0 + (v1 - v0) * t, width, -1, 0.5);
355
+ }
356
+ }
357
+ /** Punch a hole. The middle of the sheet, which a fixed-topology mesh cannot do. */
358
+ punch(u, v, radius = 0.04) {
359
+ this.paint(PRESENCE, u, v, radius, -1, 0.5);
360
+ }
361
+ /**
362
+ * Advance the field by a frame's worth of real time.
363
+ *
364
+ * Fixed steps from an accumulator, so the same simulated second is the same
365
+ * fire at any frame rate. A sheet nothing is happening to returns at once
366
+ * and visits no cells at all — which is the condition for keeping this on
367
+ * the main thread, and the thing the first version could not do: an
368
+ * untouched sheet cost exactly what a burning one did.
369
+ */
370
+ step(delta) {
371
+ this.visited = 0;
372
+ this.consumedLength = 0;
373
+ if (delta <= 0) {
374
+ this.stats = { ...this.stats, charred: 0, consumed: 0, wetted: 0 };
375
+ return this.stats;
376
+ }
377
+ if (this.asleep) {
378
+ this.accumulator = 0;
379
+ this.frontLength = 0;
380
+ this.stats = { ...this.stats, front: 0, charred: 0, consumed: 0, wetted: 0 };
381
+ return this.stats;
382
+ }
383
+ this.accumulator = Math.min(this.accumulator + delta, FIXED_DT * MAX_STEPS_PER_FRAME);
384
+ let charred = 0;
385
+ let consumed = 0;
386
+ let wetted = 0;
387
+ let front = 0;
388
+ let stepped = false;
389
+ while (this.accumulator >= FIXED_DT && !this.asleep) {
390
+ this.accumulator -= FIXED_DT;
391
+ this.steps++;
392
+ stepped = true;
393
+ const done = this.substep();
394
+ charred += done.charred;
395
+ consumed += done.consumed;
396
+ wetted += done.wetted;
397
+ front = done.front;
398
+ }
399
+ this.pack();
400
+ const n = FIELD_SIZE * FIELD_SIZE;
401
+ this.stats = {
402
+ // A frame shorter than one fixed step runs none — one frame in six at
403
+ // 144 Hz — and nothing about the fire changed on it. Reporting 0 there
404
+ // would drop the burn's sound to silence mid-burn, six times a second.
405
+ front: stepped ? front / n : this.stats.front,
406
+ charred,
407
+ consumed,
408
+ wetted,
409
+ saturation: this.wetTotal / n,
410
+ remaining: this.present / n
411
+ };
412
+ return this.stats;
413
+ }
414
+ /**
415
+ * One fixed step of diffusion and reaction, over the active region only.
416
+ *
417
+ * The region is the box around every cell that could change, grown by one
418
+ * cell because diffusion reaches exactly one neighbour per step. Cells
419
+ * outside it are at rest and read-only here — their neighbours may read
420
+ * them, and nothing writes them.
421
+ *
422
+ * Written into `next` and copied back over the same region, rather than
423
+ * swapping the two buffers. A swap needs both buffers to agree everywhere
424
+ * the step did not write, and a region that SHRINKS breaks that: a cell the
425
+ * last step wrote into one buffer still holds an older value in the other.
426
+ * Copying the region back keeps them identical outside it, for the cost of
427
+ * the region itself — which is the cost that matters, since it is zero on
428
+ * a sheet at rest rather than a whole grid every step.
429
+ */
430
+ substep() {
431
+ const { data, next, tinder, o } = this;
432
+ const heat = this.heat;
433
+ const water = this.water;
434
+ const size = FIELD_SIZE;
435
+ const last = size - 1;
436
+ const dt = FIXED_DT;
437
+ const region = {
438
+ x0: Math.max(0, this.active.x0 - 1),
439
+ y0: Math.max(0, this.active.y0 - 1),
440
+ x1: Math.min(last, this.active.x1 + 1),
441
+ y1: Math.min(last, this.active.y1 + 1)
442
+ };
443
+ const awake = EMPTY();
444
+ let charred = 0;
445
+ let consumed = 0;
446
+ let wetted = 0;
447
+ let front = 0;
448
+ const { frontCells, consumedCells } = this;
449
+ const hd = heat.diagonal;
450
+ const wd = water.diagonal;
451
+ for (let y = region.y0; y <= region.y1; y++) {
452
+ const hasN = y < region.y1;
453
+ const hasS = y > region.y0;
454
+ for (let x = region.x0; x <= region.x1; x++) {
455
+ this.visited++;
456
+ const i = y * size + x;
457
+ const b = i * 4;
458
+ const presence = data[b + PRESENCE];
459
+ if (presence <= 0) {
460
+ this.wetTotal -= data[b + SATURATION];
461
+ next[b + HEAT] = 0;
462
+ next[b + SATURATION] = 0;
463
+ next[b + CHAR] = data[b + CHAR];
464
+ next[b + PRESENCE] = 0;
465
+ continue;
466
+ }
467
+ const hasE = x < region.x1;
468
+ const hasW = x > region.x0;
469
+ const e = hasE ? i + 1 : i;
470
+ const w = hasW ? i - 1 : i;
471
+ const n = hasN ? i + size : i;
472
+ const s = hasS ? i - size : i;
473
+ const hd1 = hd > 0 ? hasN && hasE ? i + size + 1 : i : hasN && hasW ? i + size - 1 : i;
474
+ const hd2 = hd > 0 ? hasS && hasW ? i - size - 1 : i : hasS && hasE ? i - size + 1 : i;
475
+ const wd1 = wd > 0 ? hasN && hasE ? i + size + 1 : i : hasN && hasW ? i + size - 1 : i;
476
+ const wd2 = wd > 0 ? hasS && hasW ? i - size - 1 : i : hasS && hasE ? i - size + 1 : i;
477
+ const me = e * 4;
478
+ const mw = w * 4;
479
+ const mn = n * 4;
480
+ const ms = s * 4;
481
+ const pe = Math.min(presence, data[me + PRESENCE]);
482
+ const pw = Math.min(presence, data[mw + PRESENCE]);
483
+ const pn = Math.min(presence, data[mn + PRESENCE]);
484
+ const ps = Math.min(presence, data[ms + PRESENCE]);
485
+ const h0 = data[b + HEAT];
486
+ const h1 = hd1 * 4;
487
+ const h2 = hd2 * 4;
488
+ let h = h0 + heat.ax * ((data[me + HEAT] - h0) * pe + (data[mw + HEAT] - h0) * pw) + heat.ay * ((data[mn + HEAT] - h0) * pn + (data[ms + HEAT] - h0) * ps) + heat.ad * ((data[h1 + HEAT] - h0) * Math.min(presence, data[h1 + PRESENCE]) + (data[h2 + HEAT] - h0) * Math.min(presence, data[h2 + PRESENCE]));
489
+ h -= h * o.cooling * dt;
490
+ const sat = data[b + SATURATION];
491
+ const w1 = wd1 * 4;
492
+ const w2 = wd2 * 4;
493
+ let g = sat + water.ax * ((data[me + SATURATION] - sat) * pe + (data[mw + SATURATION] - sat) * pw) + water.ay * ((data[mn + SATURATION] - sat) * pn + (data[ms + SATURATION] - sat) * ps) + water.ad * ((data[w1 + SATURATION] - sat) * Math.min(presence, data[w1 + PRESENCE]) + (data[w2 + SATURATION] - sat) * Math.min(presence, data[w2 + PRESENCE]));
494
+ if (g > 0 && h > 0) {
495
+ const boiled = Math.min(g, h * o.wetResistance * dt);
496
+ g -= boiled;
497
+ h -= boiled / o.wetResistance;
498
+ }
499
+ g -= g * o.drying * dt;
500
+ const char = data[b + CHAR];
501
+ let c = char;
502
+ const threshold = tinder[i] * IGNITION;
503
+ if (h > threshold && g < 0.25) {
504
+ const made = Math.min(1 - c, o.charRate * (h - threshold) * dt);
505
+ if (made > 0) {
506
+ c += made;
507
+ h += made * o.combustion;
508
+ if (char < 0.5 && c >= 0.5) charred++;
509
+ }
510
+ }
511
+ let p = presence;
512
+ if (c > 0.85) {
513
+ p = Math.max(0, p - o.consumeRate * (c - 0.85) * dt);
514
+ if (p <= 0) {
515
+ consumed++;
516
+ consumedCells[this.consumedLength++] = i;
517
+ }
518
+ }
519
+ h = Math.min(1, Math.max(0, h));
520
+ g = Math.min(1, Math.max(0, g));
521
+ c = Math.min(1, c);
522
+ if (g > sat + 1e-6) wetted++;
523
+ if (p > 0.15 && c > 0.08 && c < 0.92 && h > 0.1) frontCells[front++] = i;
524
+ next[b + CHAR] = c;
525
+ next[b + SATURATION] = g;
526
+ next[b + HEAT] = h;
527
+ next[b + PRESENCE] = p;
528
+ this.present += p - presence;
529
+ this.wetTotal += g - sat;
530
+ if (h > REST || g > REST || c > 0.85 && p > 0) {
531
+ if (x < awake.x0) awake.x0 = x;
532
+ if (x > awake.x1) awake.x1 = x;
533
+ if (y < awake.y0) awake.y0 = y;
534
+ if (y > awake.y1) awake.y1 = y;
535
+ }
536
+ }
537
+ }
538
+ for (let y = region.y0; y <= region.y1; y++) {
539
+ const from = (y * size + region.x0) * 4;
540
+ const to = (y * size + region.x1 + 1) * 4;
541
+ data.set(next.subarray(from, to), from);
542
+ }
543
+ grow(this.touched, region);
544
+ this.active = awake;
545
+ this.frontLength = front;
546
+ return { charred, consumed, wetted, front };
547
+ }
548
+ /** Quantise everything touched since the last pack, and mark it uploadable. */
549
+ pack() {
550
+ const t = this.touched;
551
+ if (t.x0 > t.x1) return;
552
+ const { data, pixels } = this;
553
+ for (let y = t.y0; y <= t.y1; y++) {
554
+ const from = (y * FIELD_SIZE + t.x0) * 4;
555
+ const to = (y * FIELD_SIZE + t.x1 + 1) * 4;
556
+ for (let k = from; k < to; k++) pixels[k] = Math.round(data[k] * 255);
557
+ }
558
+ this.touched = EMPTY();
559
+ this.revision++;
560
+ }
561
+ };
562
+ }
563
+ });
564
+
565
+ // src/fx/emission.ts
566
+ function srgbToLinear(c) {
567
+ return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
568
+ }
569
+ function luminance(c) {
570
+ return c[0] * LUMA[0] + c[1] * LUMA[1] + c[2] * LUMA[2];
571
+ }
572
+ function timesPaperWhite(c) {
573
+ return luminance(c) / PAPER_WHITE;
574
+ }
575
+ function emit(srgb, times) {
576
+ const linear = [
577
+ srgbToLinear(srgb[0]),
578
+ srgbToLinear(srgb[1]),
579
+ srgbToLinear(srgb[2])
580
+ ];
581
+ const lum = luminance(linear);
582
+ if (lum <= 1e-6) return [0, 0, 0];
583
+ const gain = times * PAPER_WHITE / lum;
584
+ return [linear[0] * gain, linear[1] * gain, linear[2] * gain];
585
+ }
586
+ function emitHex(hex, times) {
587
+ const n = Number.parseInt(hex.replace("#", ""), 16);
588
+ return emit([(n >> 16 & 255) / 255, (n >> 8 & 255) / 255, (n & 255) / 255], times);
589
+ }
590
+ function fireZones(input) {
591
+ return {
592
+ root: { ...FIRE_ZONES.root, ...input?.root },
593
+ core: { ...FIRE_ZONES.core, ...input?.core },
594
+ body: { ...FIRE_ZONES.body, ...input?.body },
595
+ tip: { ...FIRE_ZONES.tip, ...input?.tip }
596
+ };
597
+ }
598
+ function hexToLinear(hex) {
599
+ const n = Number.parseInt(hex.replace("#", ""), 16);
600
+ return [
601
+ srgbToLinear((n >> 16 & 255) / 255),
602
+ srgbToLinear((n >> 8 & 255) / 255),
603
+ srgbToLinear((n & 255) / 255)
604
+ ];
605
+ }
606
+ var LUMA, PAPER_WHITE, FX_BLOOM_THRESHOLD, FIRE_GLOW, FIRE_HEAT_SCALE, FIRE_SOOT_SCALE, FX_BLOOM, FIRE_CONTRAST, FIRE_OPACITY, FIRE_THIN, FIRE_ZONES;
607
+ var init_emission = __esm({
608
+ "src/fx/emission.ts"() {
609
+ "use strict";
610
+ LUMA = [0.2126, 0.7152, 0.0722];
611
+ PAPER_WHITE = 1.6;
612
+ FX_BLOOM_THRESHOLD = PAPER_WHITE * 2.25;
613
+ FIRE_GLOW = [4, 8];
614
+ FIRE_HEAT_SCALE = 0.65;
615
+ FIRE_SOOT_SCALE = 3;
616
+ FX_BLOOM = 0.55;
617
+ FIRE_CONTRAST = 0.9;
618
+ FIRE_OPACITY = 5;
619
+ FIRE_THIN = 0.55;
620
+ FIRE_ZONES = {
621
+ root: { color: "#3b6bff", amount: 0, reach: 0.5 },
622
+ // glow 6 (was 4.6): once the core became the hottest few percent of a
623
+ // flame instead of its whole root, 4.6 no longer cleared the bloom
624
+ // threshold — bloom's share of the peak frame fell to 0.48%, under the
625
+ // budget. Brighter keeps the same small area: 1.47% bloom, near-white 0.8%.
626
+ // Starting it lower (from 0.45) bloomed 6.4% but put near-white back at 4.7%.
627
+ core: { color: "#fff7d4", glow: 6, from: 0.55 },
628
+ // Saturated, because the tone curve takes saturation away from anything
629
+ // above paper white: #ffdd7c at this glow came out pale yellow (s ~0.4),
630
+ // and measured 7.5% yellow against Flame_base.png's 29%.
631
+ body: { color: "#ffcf3a", glow: 1.3 },
632
+ // to: 0.4, measured against Flame_base.png with bloom off (bloom's halo
633
+ // counts as flame in any diff): near-white / pale / yellow / orange came out
634
+ // 0.7 / 21 / 34 / 44% against the reference's 0.9 / 17 / 29 / 43. At 0.32
635
+ // the body took half the flame and it was 51% yellow; at 0.45, 64% orange.
636
+ tip: { color: "#ff9e2c", glow: 0.9, from: 0.08, to: 0.4, softness: 0.15, tearing: 0.35 }
637
+ };
638
+ }
639
+ });
640
+
641
+ // src/fx/flames.ts
642
+ function flameAnchors(field, locate, max, out, time = field.time) {
643
+ if (max <= 0 || field.frontCount === 0) return 0;
644
+ const size = field.size;
645
+ const last = size - 1;
646
+ const data = field.data;
647
+ const rim = [];
648
+ let cx = 0;
649
+ let cy = 0;
650
+ for (let y = 1; y < last; y++) {
651
+ for (let x = 1; x < last; x++) {
652
+ const cell = y * size + x;
653
+ if (data[cell * 4 + HEAT] < FLAME_HEAT || data[cell * 4 + PRESENCE] < 0.5) continue;
654
+ if (data[(cell - 1) * 4 + PRESENCE] >= 0.5 && data[(cell + 1) * 4 + PRESENCE] >= 0.5 && data[(cell - size) * 4 + PRESENCE] >= 0.5 && data[(cell + size) * 4 + PRESENCE] >= 0.5) {
655
+ continue;
656
+ }
657
+ rim.push(cell);
658
+ cx += x;
659
+ cy += y;
660
+ }
661
+ }
662
+ if (rim.length === 0) return 0;
663
+ cx /= rim.length;
664
+ cy /= rim.length;
665
+ const angle = (cell) => Math.atan2((cell / size | 0) - cy, cell % size - cx);
666
+ rim.sort((a, b) => angle(a) - angle(b) || a - b);
667
+ const picks = [];
668
+ for (const cell of rim) {
669
+ const seed = hash2(cell, 1);
670
+ const a = angle(cell);
671
+ const cluster = clamp01(
672
+ 0.62 * noise2(Math.cos(a) * 1.6 + time * 0.42, Math.sin(a) * 1.6 - time * 0.17) + 0.38 * noise2(Math.cos(a) * 4.1 - time * 0.73 + 7.3, Math.sin(a) * 4.1 + time * 0.31) - 0.12
673
+ );
674
+ const epoch = Math.floor(time * 1.4 + seed * 10);
675
+ if (hash2(cell, epoch + 7) > 0.38 + 0.55 * cluster) continue;
676
+ const breath = noise1(time * 0.9 + seed * 31);
677
+ if (breath < 0.16) continue;
678
+ const life = 0.3 + 0.7 * smoothstep(0.16, 0.5, breath);
679
+ picks.push({ cell, seed, cluster, life, along: (hash2(cell, 5) - 0.5) * 3, scale: 1 });
680
+ if (cluster > 0.5) {
681
+ const extra = cluster > 0.72 ? 2 : 1;
682
+ for (let k = 0; k < extra; k++) {
683
+ const side = hash2(cell, 11 + k);
684
+ picks.push({
685
+ cell,
686
+ seed: hash2(cell, 21 + k),
687
+ cluster,
688
+ life: life * (0.6 + 0.4 * side),
689
+ along: (side < 0.5 ? -1 : 1) * (1.5 + 3 * hash2(cell, 31 + k)),
690
+ scale: 0.4 + 0.45 * hash2(cell, 41 + k)
691
+ });
692
+ }
693
+ }
694
+ }
695
+ picks.sort((p, q) => q.cluster * 0.7 + q.seed * 0.3 - (p.cluster * 0.7 + p.seed * 0.3) || p.cell - q.cell);
696
+ const count = Math.min(max, picks.length);
697
+ let n = 0;
698
+ for (let i = 0; i < count; i++) {
699
+ const pick = picks[i];
700
+ const cell = pick.cell;
701
+ const x = cell % size;
702
+ const y = cell / size | 0;
703
+ const gx = data[(cell + 1) * 4 + PRESENCE] - data[(cell - 1) * 4 + PRESENCE];
704
+ const gy = data[(cell + size) * 4 + PRESENCE] - data[(cell - size) * 4 + PRESENCE];
705
+ const gl = Math.hypot(gx, gy) || 1;
706
+ const shift = pick.along * MM / last;
707
+ const u = x / last + -gy / gl * shift;
708
+ const v = y / last + gx / gl * shift;
709
+ const at = locate(u, v);
710
+ if (!at) continue;
711
+ const rx = at.x;
712
+ const ry = at.y;
713
+ const rz = at.z;
714
+ const toward = locate(u + gx / gl / last, v + gy / gl / last);
715
+ let upper = 0;
716
+ scratchA.x = 0;
717
+ scratchA.y = 0;
718
+ scratchA.z = 0;
719
+ if (toward) {
720
+ const l = Math.hypot(toward.x - rx, toward.y - ry, toward.z - rz) || 1;
721
+ scratchA.x = (toward.x - rx) / l;
722
+ scratchA.y = (toward.y - ry) / l;
723
+ scratchA.z = (toward.z - rz) / l;
724
+ upper = scratchA.y;
725
+ }
726
+ const heat = data[cell * 4 + HEAT];
727
+ const hot = Math.sqrt(Math.min(1, (heat - FLAME_HEAT) / 0.35));
728
+ const rim2 = 0.35 + 0.65 * (0.5 + 0.5 * upper);
729
+ const base = (FLAME_HEIGHT[0] + (FLAME_HEIGHT[1] - FLAME_HEIGHT[0]) * hot) * rim2;
730
+ const height = base * (0.2 + 1.3 * pick.cluster ** 1.4) * (0.6 + 0.8 * pick.seed) * pick.life * pick.scale;
731
+ let anchor = out[n];
732
+ if (!anchor) {
733
+ anchor = { x: 0, y: 0, z: 0, height: 0, width: 0, seed: 0, heat: 0, upper: 0, nx: 0, ny: 0, nz: 0 };
734
+ out[n] = anchor;
735
+ }
736
+ anchor.nx = scratchA.x;
737
+ anchor.ny = scratchA.y;
738
+ anchor.nz = scratchA.z;
739
+ anchor.x = rx;
740
+ anchor.y = ry;
741
+ anchor.z = rz;
742
+ anchor.height = Math.min(FLAME_HEIGHT[1], height);
743
+ anchor.width = anchor.height * (0.32 + 0.3 * hash2(cell, 3));
744
+ anchor.seed = pick.seed;
745
+ anchor.heat = hot;
746
+ anchor.upper = upper;
747
+ n++;
748
+ }
749
+ if (n >= 3 && variation(out, n) < IRREGULAR) {
750
+ for (let i = 0; i < n; i++) {
751
+ const a = out[i];
752
+ a.height = Math.min(FLAME_HEIGHT[1], a.height * (0.3 + 1.4 * hash2(Math.floor(a.seed * 1e6), 9)));
753
+ a.width = a.height * (0.32 + 0.3 * hash2(Math.floor(a.seed * 1e6), 3));
754
+ }
755
+ }
756
+ return n;
757
+ }
758
+ function variation(anchors, n) {
759
+ let sum = 0;
760
+ for (let i = 0; i < n; i++) sum += anchors[i].height;
761
+ const mean = sum / n;
762
+ if (!(mean > 0)) return 0;
763
+ let sq = 0;
764
+ for (let i = 0; i < n; i++) sq += (anchors[i].height - mean) ** 2;
765
+ return Math.sqrt(sq / n) / mean;
766
+ }
767
+ function clamp01(x) {
768
+ return x < 0 ? 0 : x > 1 ? 1 : x;
769
+ }
770
+ function smoothstep(a, b, x) {
771
+ const t = clamp01((x - a) / (b - a));
772
+ return t * t * (3 - 2 * t);
773
+ }
774
+ function hash2(a, b) {
775
+ let h = Math.imul(a ^ Math.imul(b, 668265261), 2654435761) >>> 0;
776
+ h ^= h >>> 15;
777
+ h = Math.imul(h, 2246822519) >>> 0;
778
+ h ^= h >>> 13;
779
+ return (h >>> 0) / 4294967296;
780
+ }
781
+ function noise2(x, y) {
782
+ const ix = Math.floor(x);
783
+ const iy = Math.floor(y);
784
+ let fx = x - ix;
785
+ let fy = y - iy;
786
+ fx = fx * fx * (3 - 2 * fx);
787
+ fy = fy * fy * (3 - 2 * fy);
788
+ const h = (i, j) => hash2(i * 73856093, j * 19349663 + 7);
789
+ const a = h(ix, iy) + (h(ix + 1, iy) - h(ix, iy)) * fx;
790
+ const b = h(ix, iy + 1) + (h(ix + 1, iy + 1) - h(ix, iy + 1)) * fx;
791
+ return a + (b - a) * fy;
792
+ }
793
+ function flamePuff(seed, time) {
794
+ return 0.72 + 0.28 * noise1(time * 12.5 + seed * 37) + 0.12 * (noise1(time * 23 + seed * 11) - 0.5);
795
+ }
796
+ function hash1(n) {
797
+ const s = Math.sin(n) * 43758.5453;
798
+ return s - Math.floor(s);
799
+ }
800
+ function noise1(x) {
801
+ const i = Math.floor(x);
802
+ let f = x - i;
803
+ f = f * f * (3 - 2 * f);
804
+ return hash1(i) + (hash1(i + 1) - hash1(i)) * f;
805
+ }
806
+ var FLAME_HEIGHT, FLAME_HEAT, IRREGULAR, MM, scratchA;
807
+ var init_flames = __esm({
808
+ "src/fx/flames.ts"() {
809
+ "use strict";
810
+ init_field();
811
+ FLAME_HEIGHT = [10 / 210, 40 / 210];
812
+ FLAME_HEAT = 0.22;
813
+ IRREGULAR = 0.4;
814
+ MM = 63 / 210;
815
+ scratchA = { x: 0, y: 0, z: 0 };
816
+ }
817
+ });
818
+
819
+ // src/fx/haze.ts
820
+ var THREE2, import_postprocessing, HAZE_SOURCES, FRAGMENT, HazeGradeEffect;
821
+ var init_haze = __esm({
822
+ "src/fx/haze.ts"() {
823
+ "use strict";
824
+ THREE2 = __toESM(require("three"), 1);
825
+ import_postprocessing = require("postprocessing");
826
+ HAZE_SOURCES = 16;
827
+ FRAGMENT = /* glsl */
828
+ `
829
+ uniform vec4 uSources[${HAZE_SOURCES}];
830
+ uniform int uCount;
831
+ uniform float uTime;
832
+ uniform float uAmount;
833
+ uniform vec3 uWarm;
834
+ uniform float uGrade;
835
+
836
+ float hzHash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); }
837
+ float hzNoise(vec2 p) {
838
+ vec2 i = floor(p);
839
+ vec2 f = fract(p);
840
+ vec2 u = f * f * (3.0 - 2.0 * f);
841
+ return mix(mix(hzHash(i), hzHash(i + vec2(1.0, 0.0)), u.x), mix(hzHash(i + vec2(0.0, 1.0)), hzHash(i + vec2(1.0, 1.0)), u.x), u.y);
842
+ }
843
+
844
+ // Heat haze: the air above a flame is hotter than the air beside it, and
845
+ // light bends through the difference. A few pixels of upward-scrolling
846
+ // displacement, only in the column above each flame, fading with height.
847
+ void mainUv(inout vec2 uv) {
848
+ vec2 offset = vec2(0.0);
849
+ for (int i = 0; i < ${HAZE_SOURCES}; i++) {
850
+ if (i >= uCount) break;
851
+ vec4 s = uSources[i];
852
+ vec2 d = uv - s.xy;
853
+ float h = max(s.z, 1e-4);
854
+ float above = d.y / h;
855
+ if (above < -0.1 || above > 3.2) continue;
856
+ float across = abs(d.x) / (h * 0.55 + 1e-4);
857
+ // Around the flame's own boundary as well as above it: gentle inside the
858
+ // body, strongest in the hot column over it, gone by three flame heights.
859
+ float column = smoothstep(0.3, 0.9, above) * (1.0 - smoothstep(1.4, 3.2, above));
860
+ float around = smoothstep(-0.1, 0.15, above) * (1.0 - smoothstep(0.8, 1.2, above)) * 0.45;
861
+ float m = (1.0 - smoothstep(0.45, 1.15, across)) * max(column, around) * s.w;
862
+ vec2 q = vec2(uv.x * 140.0, uv.y * 90.0 - uTime * 3.0);
863
+ offset += (vec2(hzNoise(q), hzNoise(q + 31.7)) - 0.5) * m;
864
+ }
865
+ uv += offset * uAmount;
866
+ }
867
+
868
+ // The grade: the whole frame warms as the fire grows \u2014 a few percent, no
869
+ // more (spec \xA77). A multiply, so black stays black.
870
+ void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) {
871
+ outputColor = vec4(inputColor.rgb * mix(vec3(1.0), uWarm, uGrade), inputColor.a);
872
+ }
873
+ `;
874
+ HazeGradeEffect = class extends import_postprocessing.Effect {
875
+ constructor() {
876
+ super("HazeGradeEffect", FRAGMENT, {
877
+ blendFunction: import_postprocessing.BlendFunction.NORMAL,
878
+ uniforms: /* @__PURE__ */ new Map([
879
+ ["uSources", new THREE2.Uniform(Array.from({ length: HAZE_SOURCES }, () => new THREE2.Vector4()))],
880
+ ["uCount", new THREE2.Uniform(0)],
881
+ ["uTime", new THREE2.Uniform(0)],
882
+ // 1–3 px at 1080p, as a fraction of the frame.
883
+ ["uAmount", new THREE2.Uniform(2.2 / 1080)],
884
+ ["uWarm", new THREE2.Uniform(new THREE2.Vector3(1.04, 1, 0.94))],
885
+ ["uGrade", new THREE2.Uniform(0)]
886
+ ])
887
+ });
888
+ }
889
+ get sources() {
890
+ return this.uniforms.get("uSources").value;
891
+ }
892
+ set count(n) {
893
+ this.uniforms.get("uCount").value = n;
894
+ }
895
+ set time(t) {
896
+ this.uniforms.get("uTime").value = t;
897
+ }
898
+ /** 0 turns the shimmer off (the low tier) and keeps the grade. */
899
+ set amount(px1080) {
900
+ this.uniforms.get("uAmount").value = px1080 / 1080;
901
+ }
902
+ /** 0..1 — how much of the warm grade to apply. */
903
+ set grade(g) {
904
+ this.uniforms.get("uGrade").value = g;
905
+ }
906
+ };
907
+ }
908
+ });
909
+
910
+ // src/fx/quality.ts
911
+ function fxQualityFor(name) {
912
+ return fxQualityTiers[name === "auto" ? FX_INITIAL_TIER : name];
913
+ }
914
+ var fxQualityNames, fxQualityTiers, FX_INITIAL_TIER, FX_TIER_ORDER;
915
+ var init_quality = __esm({
916
+ "src/fx/quality.ts"() {
917
+ "use strict";
918
+ fxQualityNames = ["auto", "low", "medium", "high"];
919
+ fxQualityTiers = {
920
+ /** A desktop GPU, or a phone that has measured its way up here. */
921
+ high: {
922
+ particles: 2e3,
923
+ voices: 16,
924
+ detail: 1,
925
+ bloomScale: 1,
926
+ flames: 64,
927
+ caps: { ember: 200, smoke: 200, ash: 120 },
928
+ haze: 2,
929
+ fluid: { velocity: [192, 256], dye: [384, 512], iterations: 36 }
930
+ },
931
+ /** The default worth aiming at: a recent phone, or an integrated laptop GPU. */
932
+ medium: {
933
+ particles: 900,
934
+ voices: 10,
935
+ detail: 1,
936
+ bloomScale: 1,
937
+ flames: 36,
938
+ caps: { ember: 80, smoke: 80, ash: 60 },
939
+ haze: 0,
940
+ fluid: { velocity: [144, 192], dye: [288, 384], iterations: 30 }
941
+ },
942
+ /**
943
+ * A throttled phone with the camera and the tracker already running, which
944
+ * is the realistic case rather than the pessimistic one. The fire is the
945
+ * same fire; the shower is thinner, fewer crackles overlap, and its edge is
946
+ * the grid's own.
947
+ */
948
+ low: {
949
+ particles: 350,
950
+ voices: 6,
951
+ detail: 0,
952
+ bloomScale: 0.5,
953
+ flames: 16,
954
+ caps: { ember: 30, smoke: 30, ash: 20 },
955
+ haze: 0,
956
+ fluid: { velocity: [96, 128], dye: [192, 256], iterations: 20 }
957
+ }
958
+ };
959
+ FX_INITIAL_TIER = "medium";
960
+ FX_TIER_ORDER = ["low", "medium", "high"];
961
+ }
962
+ });
963
+
964
+ // src/fx/FxPostPass.tsx
965
+ var FxPostPass_exports = {};
966
+ __export(FxPostPass_exports, {
967
+ FX_BLOOM: () => FX_BLOOM,
968
+ FX_BLOOM_THRESHOLD: () => FX_BLOOM_THRESHOLD,
969
+ FxPostPass: () => FxPostPass,
970
+ PAPER_WHITE: () => PAPER_WHITE
971
+ });
972
+ function FxPostPass({
973
+ film = "neutral",
974
+ quality = "medium",
975
+ bloom = FX_BLOOM,
976
+ threshold = FX_BLOOM_THRESHOLD,
977
+ field,
978
+ locate,
979
+ haze: hazeOverride
980
+ }) {
981
+ const { bloomScale, haze: tierHaze } = fxQualityFor(quality);
982
+ const hazePx = hazeOverride ?? tierHaze;
983
+ const haze = (0, import_react2.useMemo)(() => new HazeGradeEffect(), []);
984
+ const anchors = (0, import_react2.useRef)([]);
985
+ (0, import_fiber2.useFrame)(({ camera }) => {
986
+ if (!field || !locate) {
987
+ haze.count = 0;
988
+ haze.grade = 0;
989
+ return;
990
+ }
991
+ const n = hazePx > 0 ? flameAnchors(field, locate, HAZE_SOURCES, anchors.current) : 0;
992
+ const sources = haze.sources;
993
+ for (let i = 0; i < n; i++) {
994
+ const a = anchors.current[i];
995
+ root.set(a.x, a.y, a.z).project(camera);
996
+ top.set(a.x, a.y + a.height, a.z).project(camera);
997
+ sources[i].set(root.x * 0.5 + 0.5, root.y * 0.5 + 0.5, Math.max(0, (top.y - root.y) * 0.5), a.heat);
998
+ }
999
+ haze.count = n;
1000
+ haze.time = field.time;
1001
+ haze.amount = hazePx;
1002
+ haze.grade = Math.min(1, field.lastStats.front / 0.03);
1003
+ });
1004
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_postprocessing2.EffectComposer, { children: [
1005
+ bloom > 0 ? /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1006
+ import_postprocessing2.Bloom,
1007
+ {
1008
+ intensity: bloom,
1009
+ luminanceThreshold: threshold,
1010
+ luminanceSmoothing: 0.02,
1011
+ mipmapBlur: true,
1012
+ resolutionScale: bloomScale
1013
+ }
1014
+ ) : null,
1015
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("primitive", { object: haze }),
1016
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_postprocessing2.ToneMapping, { mode: modes[film] })
1017
+ ] });
1018
+ }
1019
+ var import_postprocessing2, import_fiber2, import_postprocessing3, import_react2, THREE3, import_jsx_runtime2, modes, root, top;
1020
+ var init_FxPostPass = __esm({
1021
+ "src/fx/FxPostPass.tsx"() {
1022
+ "use strict";
1023
+ import_postprocessing2 = require("@react-three/postprocessing");
1024
+ import_fiber2 = require("@react-three/fiber");
1025
+ import_postprocessing3 = require("postprocessing");
1026
+ import_react2 = require("react");
1027
+ THREE3 = __toESM(require("three"), 1);
1028
+ init_emission();
1029
+ init_flames();
1030
+ init_haze();
1031
+ init_quality();
1032
+ import_jsx_runtime2 = require("react/jsx-runtime");
1033
+ modes = {
1034
+ agx: import_postprocessing3.ToneMappingMode.AGX,
1035
+ neutral: import_postprocessing3.ToneMappingMode.NEUTRAL,
1036
+ filmic: import_postprocessing3.ToneMappingMode.ACES_FILMIC
1037
+ };
1038
+ root = new THREE3.Vector3();
1039
+ top = new THREE3.Vector3();
1040
+ }
1041
+ });
1042
+
1043
+ // src/fx.ts
1044
+ var fx_exports = {};
1045
+ __export(fx_exports, {
1046
+ Afterglow: () => Afterglow,
1047
+ CHAR: () => CHAR,
1048
+ DAMAGE_CHANNELS: () => DAMAGE_CHANNELS,
1049
+ DAMAGE_LOOK_DEFAULTS: () => DAMAGE_LOOK_DEFAULTS,
1050
+ DamageField: () => DamageField,
1051
+ FIELD_SIZE: () => FIELD_SIZE,
1052
+ FIRE_GLOW: () => FIRE_GLOW,
1053
+ FIRE_HEAT_SCALE: () => FIRE_HEAT_SCALE,
1054
+ FIRE_SOOT_SCALE: () => FIRE_SOOT_SCALE,
1055
+ FIRE_ZONES: () => FIRE_ZONES,
1056
+ FIXED_DT: () => FIXED_DT,
1057
+ FLAME_HEIGHT: () => FLAME_HEIGHT,
1058
+ FX_BLOOM: () => FX_BLOOM,
1059
+ FX_BLOOM_THRESHOLD: () => FX_BLOOM_THRESHOLD,
1060
+ FX_INITIAL_TIER: () => FX_INITIAL_TIER,
1061
+ FX_TIER_ORDER: () => FX_TIER_ORDER,
1062
+ FireEmitter: () => FireEmitter,
1063
+ FireFluid: () => FireFluid,
1064
+ FireSound: () => FireSound,
1065
+ FxAudio: () => FxAudio,
1066
+ FxFireFluid: () => FxFireFluid,
1067
+ FxFireLight: () => FxFireLight,
1068
+ FxFlames: () => FxFlames,
1069
+ FxMatchFlame: () => FxMatchFlame,
1070
+ FxParticles: () => FxParticles,
1071
+ FxPost: () => FxPost,
1072
+ FxWisps: () => FxWisps,
1073
+ HEAT: () => HEAT,
1074
+ PAPER_WHITE: () => PAPER_WHITE,
1075
+ PRESENCE: () => PRESENCE,
1076
+ ParticlePool: () => ParticlePool,
1077
+ SATURATION: () => SATURATION,
1078
+ createAudioContext: () => createAudioContext,
1079
+ emit: () => emit,
1080
+ emitHex: () => emitHex,
1081
+ fireEmitterDefaults: () => fireEmitterDefaults,
1082
+ fireFluidControls: () => fireFluidControls,
1083
+ fireFluidDefaults: () => fireFluidDefaults,
1084
+ fireZones: () => fireZones,
1085
+ flameAnchors: () => flameAnchors,
1086
+ flamePuff: () => flamePuff,
1087
+ fxQualityFor: () => fxQualityFor,
1088
+ fxQualityNames: () => fxQualityNames,
1089
+ fxQualityTiers: () => fxQualityTiers,
1090
+ hexToLinear: () => hexToLinear,
1091
+ luminance: () => luminance,
1092
+ particlePresets: () => particlePresets,
1093
+ solverUniforms: () => solverUniforms,
1094
+ srgbToLinear: () => srgbToLinear,
1095
+ timesPaperWhite: () => timesPaperWhite
1096
+ });
1097
+ module.exports = __toCommonJS(fx_exports);
1098
+ init_field();
1099
+ init_damageContract();
1100
+
1101
+ // src/fx/particles.ts
1102
+ init_emission();
1103
+ var particlePresets = {
1104
+ /**
1105
+ * A spark off the burning line. Short-lived, shrinking as it cools, carried
1106
+ * up and sideways by the heat.
1107
+ *
1108
+ * Authored through `emission.ts`, in multiples of paper white, which is what
1109
+ * caught the bug in the version before this one. That version said "the hot
1110
+ * end is now ~5.6, well over the bloom threshold" — and 5.6 was an ABSOLUTE
1111
+ * luminance, while paper white under `window` is 1.6. So the hottest spark
1112
+ * in the frame was **3.5× paper, under the spec's own 4× floor**, and its
1113
+ * own comment said otherwise. A number that cannot be compared to anything
1114
+ * is a number nobody can check.
1115
+ *
1116
+ * It was also too red. `[12, 4.2, 0.9]` is a saturated red at high
1117
+ * intensity, and the tone curve takes a bright red-dominant colour to
1118
+ * SALMON — which is `Never_this.png`. The hue is now a blackbody's: a spark
1119
+ * leaving the fire is yellow-orange, about 2200 K, and cools to a deep
1120
+ * orange-red as it dies. Hue and brightness are separate arguments now, and
1121
+ * `emit` keeps them that way.
1122
+ */
1123
+ ember: {
1124
+ life: [0.5, 1.4],
1125
+ speed: [0.15, 0.5],
1126
+ direction: [0, 1, 0],
1127
+ spread: 0.6,
1128
+ lift: 0.6,
1129
+ drag: 0.8,
1130
+ jitter: 1.5,
1131
+ windCatch: 0.8,
1132
+ // 0.3-1 mm of core (§5). A sheet is one world unit across, 210 mm, so
1133
+ // 0.0035 is 0.7 mm. It was 0.005 — a full millimetre wide before the
1134
+ // streak stretched it, which is where "wide bars" started.
1135
+ size: [35e-4, 12e-4],
1136
+ color: [
1137
+ // 6× paper white: inside §4.3's 4–8 band, so it blooms.
1138
+ emitHex("#FFC271", 6),
1139
+ // 0.7× — UNDER the bloom threshold on purpose, so a dying spark stops
1140
+ // glowing rather than merely fading.
1141
+ emitHex("#FF5512", 0.7)
1142
+ ],
1143
+ alpha: [1, 0],
1144
+ spin: 0,
1145
+ blend: "additive",
1146
+ shape: "soft",
1147
+ flicker: 0.35
1148
+ },
1149
+ /**
1150
+ * What a paper fire mostly makes. Thin, slow, growing as it rises and
1151
+ * spreads, and gone well before it could read as a volume — no fluid
1152
+ * simulation, on purpose; see the fx plan.
1153
+ */
1154
+ smoke: {
1155
+ life: [1.8, 3.5],
1156
+ speed: [0.05, 0.15],
1157
+ direction: [0, 1, 0],
1158
+ spread: 0.3,
1159
+ lift: 0.25,
1160
+ drag: 0.9,
1161
+ jitter: 0.3,
1162
+ windCatch: 1,
1163
+ size: [0.05, 0.35],
1164
+ // #6B6560 grey-brown, linear, and thin — the background stays clear.
1165
+ color: [
1166
+ [0.147, 0.13, 0.117],
1167
+ [0.2, 0.19, 0.18]
1168
+ ],
1169
+ alpha: [0.08, 0],
1170
+ spin: 0.4,
1171
+ blend: "normal",
1172
+ shape: "soft",
1173
+ flicker: 0
1174
+ },
1175
+ /**
1176
+ * Burnt-through paper, leaving. Lifted a little by the heat it came from,
1177
+ * then heavier than the air: it tumbles, flutters and falls — the part of a
1178
+ * burn that proves paper was there.
1179
+ */
1180
+ ash: {
1181
+ life: [2.5, 5],
1182
+ // Thrown UP by the heat it came off, before it is heavier than the air.
1183
+ // It used to leave at almost nothing (0.02–0.1) with a lift of −0.35, so
1184
+ // every flake began falling the moment it was born — straight through the
1185
+ // hole it came from, onto the black stage, dark on dark.
1186
+ speed: [0.14, 0.3],
1187
+ direction: [0, 1, 0],
1188
+ spread: 0.55,
1189
+ lift: -0.22,
1190
+ drag: 1.6,
1191
+ jitter: 1.2,
1192
+ windCatch: 1,
1193
+ // 5–10 mm across (§8.3). A sheet is 210 mm, so 0.03 is 6.3 mm. They were
1194
+ // 3.8 mm and there were far too many of them: a shower of specks rather
1195
+ // than a few flakes you would notice.
1196
+ size: [0.03, 0.026],
1197
+ color: [
1198
+ [0.07, 0.065, 0.06],
1199
+ [0.12, 0.115, 0.11]
1200
+ ],
1201
+ alpha: [0.95, 0],
1202
+ spin: 4,
1203
+ blend: "normal",
1204
+ shape: "flake",
1205
+ flicker: 0
1206
+ }
1207
+ };
1208
+ var PRESET_NAMES = Object.keys(particlePresets);
1209
+ var PRESETS = PRESET_NAMES.map((name) => particlePresets[name]);
1210
+ function mulberry32(seed) {
1211
+ let a = seed >>> 0;
1212
+ return () => {
1213
+ a = a + 1831565813 >>> 0;
1214
+ let t = a;
1215
+ t = Math.imul(t ^ t >>> 15, t | 1);
1216
+ t ^= t + Math.imul(t ^ t >>> 7, t | 61);
1217
+ return ((t ^ t >>> 14) >>> 0) / 4294967296;
1218
+ };
1219
+ }
1220
+ var ParticlePool = class {
1221
+ capacity;
1222
+ /**
1223
+ * The air's own velocity, world units a second. A blow on `/hands` is a
1224
+ * wind, and smoke that ignores it is smoke painted on the glass.
1225
+ */
1226
+ wind = [0, 0, 0];
1227
+ kind;
1228
+ position;
1229
+ velocity;
1230
+ age;
1231
+ life;
1232
+ angle;
1233
+ spin;
1234
+ seed;
1235
+ live = 0;
1236
+ /** Live particles of each preset, kept as they come and go, so a cap costs nothing to check. */
1237
+ perKind = new Int32Array(PRESET_NAMES.length);
1238
+ /** Embers that popped in the air since the last `takePops` — the sound follows the picture. */
1239
+ pops = 0;
1240
+ random;
1241
+ constructor(capacity, seed = 1) {
1242
+ this.capacity = Math.max(0, Math.floor(capacity));
1243
+ const n = this.capacity;
1244
+ this.kind = new Uint8Array(n);
1245
+ this.position = new Float32Array(n * 3);
1246
+ this.velocity = new Float32Array(n * 3);
1247
+ this.age = new Float32Array(n);
1248
+ this.life = new Float32Array(n);
1249
+ this.angle = new Float32Array(n);
1250
+ this.spin = new Float32Array(n);
1251
+ this.seed = new Float32Array(n);
1252
+ this.random = mulberry32(seed);
1253
+ }
1254
+ /** Particles in the air. */
1255
+ get count() {
1256
+ return this.live;
1257
+ }
1258
+ /** How many live particles are of one preset. For tests and for a HUD. */
1259
+ countOf(name) {
1260
+ return this.perKind[PRESET_NAMES.indexOf(name)];
1261
+ }
1262
+ /** Launch one particle from a point. Never allocates; at capacity it takes the most-spent slot. */
1263
+ spawn(name, x, y, z) {
1264
+ if (this.capacity === 0) return;
1265
+ const preset = particlePresets[name];
1266
+ let i = this.live;
1267
+ if (i < this.capacity) {
1268
+ this.live++;
1269
+ } else {
1270
+ i = 0;
1271
+ let spent = -1;
1272
+ for (let j = 0; j < this.live; j++) {
1273
+ const f = this.age[j] / this.life[j];
1274
+ if (f > spent) {
1275
+ spent = f;
1276
+ i = j;
1277
+ }
1278
+ }
1279
+ this.perKind[this.kind[i]] = this.perKind[this.kind[i]] - 1;
1280
+ }
1281
+ const r = this.random;
1282
+ this.kind[i] = PRESET_NAMES.indexOf(name);
1283
+ this.perKind[this.kind[i]] = this.perKind[this.kind[i]] + 1;
1284
+ const i3 = i * 3;
1285
+ this.position[i3] = x;
1286
+ this.position[i3 + 1] = y;
1287
+ this.position[i3 + 2] = z;
1288
+ const [dx, dy, dz] = preset.direction;
1289
+ let rx = r() * 2 - 1;
1290
+ let ry = r() * 2 - 1;
1291
+ let rz = r() * 2 - 1;
1292
+ const rl = Math.hypot(rx, ry, rz) || 1;
1293
+ rx /= rl;
1294
+ ry /= rl;
1295
+ rz /= rl;
1296
+ let ox = dx * (1 - preset.spread) + rx * preset.spread;
1297
+ let oy = dy * (1 - preset.spread) + ry * preset.spread;
1298
+ let oz = dz * (1 - preset.spread) + rz * preset.spread;
1299
+ if (ox * dx + oy * dy + oz * dz < 0) {
1300
+ ox = -ox;
1301
+ oy = -oy;
1302
+ oz = -oz;
1303
+ }
1304
+ const ol = Math.hypot(ox, oy, oz) || 1;
1305
+ const speed = preset.speed[0] + (preset.speed[1] - preset.speed[0]) * r();
1306
+ this.velocity[i3] = ox / ol * speed;
1307
+ this.velocity[i3 + 1] = oy / ol * speed;
1308
+ this.velocity[i3 + 2] = oz / ol * speed;
1309
+ this.age[i] = 0;
1310
+ this.life[i] = preset.life[0] + (preset.life[1] - preset.life[0]) * r();
1311
+ this.angle[i] = r() * Math.PI * 2;
1312
+ this.spin[i] = (r() * 2 - 1) * preset.spin;
1313
+ this.seed[i] = r();
1314
+ }
1315
+ /** Advance every particle by `dt` seconds; the dead leave the pool. */
1316
+ step(dt) {
1317
+ if (dt <= 0) return;
1318
+ const r = this.random;
1319
+ const [wx, wy, wz] = this.wind;
1320
+ for (let i = 0; i < this.live; i++) {
1321
+ const before = this.age[i] / this.life[i];
1322
+ this.age[i] = this.age[i] + dt;
1323
+ if (this.seed[i] < 0.1 && before < 0.86 && this.age[i] / this.life[i] >= 0.86) {
1324
+ if (PRESETS[this.kind[i]].blend === "additive") this.pops++;
1325
+ }
1326
+ if (this.age[i] >= this.life[i]) {
1327
+ this.remove(i);
1328
+ i--;
1329
+ continue;
1330
+ }
1331
+ const preset = PRESETS[this.kind[i]];
1332
+ const i3 = i * 3;
1333
+ const drag = Math.min(1, preset.drag * dt);
1334
+ const catchWind = preset.windCatch;
1335
+ const jitter = preset.jitter;
1336
+ let vx = this.velocity[i3];
1337
+ let vy = this.velocity[i3 + 1];
1338
+ let vz = this.velocity[i3 + 2];
1339
+ vx += (wx * catchWind - vx) * drag + (r() * 2 - 1) * jitter * dt;
1340
+ vy += (wy * catchWind - vy) * drag + (preset.lift + (r() * 2 - 1) * jitter) * dt;
1341
+ vz += (wz * catchWind - vz) * drag + (r() * 2 - 1) * jitter * dt;
1342
+ this.velocity[i3] = vx;
1343
+ this.velocity[i3 + 1] = vy;
1344
+ this.velocity[i3 + 2] = vz;
1345
+ this.position[i3] = this.position[i3] + vx * dt;
1346
+ this.position[i3 + 1] = this.position[i3 + 1] + vy * dt;
1347
+ this.position[i3 + 2] = this.position[i3 + 2] + vz * dt;
1348
+ this.angle[i] = this.angle[i] + this.spin[i] * dt;
1349
+ }
1350
+ }
1351
+ /**
1352
+ * Write every live particle into the target for its blend mode. Returns how
1353
+ * many went into each — the draw ranges.
1354
+ */
1355
+ write(additive, normal, flakes) {
1356
+ let a = 0;
1357
+ let n = 0;
1358
+ let f = 0;
1359
+ for (let i = 0; i < this.live; i++) {
1360
+ const preset = PRESETS[this.kind[i]];
1361
+ const flake = flakes !== void 0 && preset.shape === "flake";
1362
+ const target2 = preset.blend === "additive" ? additive : flake ? flakes : normal;
1363
+ const k = preset.blend === "additive" ? a++ : flake ? f++ : n++;
1364
+ const t = this.age[i] / this.life[i];
1365
+ const i3 = i * 3;
1366
+ const k3 = k * 3;
1367
+ target2.position[k3] = this.position[i3];
1368
+ target2.position[k3 + 1] = this.position[i3 + 1];
1369
+ target2.position[k3 + 2] = this.position[i3 + 2];
1370
+ if (target2.velocity) {
1371
+ target2.velocity[k3] = this.velocity[i3];
1372
+ target2.velocity[k3 + 1] = this.velocity[i3 + 1];
1373
+ target2.velocity[k3 + 2] = this.velocity[i3 + 2];
1374
+ }
1375
+ const [c0, c1] = preset.color;
1376
+ const fadeIn = Math.min(1, t / 0.1);
1377
+ const flicker = preset.flicker > 0 ? 1 - preset.flicker * (0.5 + 0.5 * Math.sin(this.age[i] * 41 + this.seed[i] * 97)) : 1;
1378
+ const k4 = k * 4;
1379
+ const pop = preset.blend === "additive" && this.seed[i] < 0.1 && t > 0.86 ? 2.6 : 1;
1380
+ target2.color[k4] = (c0[0] + (c1[0] - c0[0]) * t) * pop;
1381
+ target2.color[k4 + 1] = (c0[1] + (c1[1] - c0[1]) * t) * pop;
1382
+ target2.color[k4 + 2] = (c0[2] + (c1[2] - c0[2]) * t) * pop;
1383
+ target2.color[k4 + 3] = (preset.alpha[0] + (preset.alpha[1] - preset.alpha[0]) * t) * fadeIn * flicker;
1384
+ target2.extra[k4] = preset.size[0] + (preset.size[1] - preset.size[0]) * t;
1385
+ target2.extra[k4 + 1] = this.angle[i];
1386
+ target2.extra[k4 + 2] = preset.shape === "flake" ? 1 : 0;
1387
+ target2.extra[k4 + 3] = this.seed[i];
1388
+ }
1389
+ return { additive: a, normal: n, flakes: f };
1390
+ }
1391
+ /**
1392
+ * How many embers popped in the air since the last call, and reset — one
1393
+ * `FireSound.pop()` each keeps the sound on the frame the flash is on.
1394
+ */
1395
+ takePops() {
1396
+ const n = this.pops;
1397
+ this.pops = 0;
1398
+ return n;
1399
+ }
1400
+ /** Empty the air — a fresh sheet. */
1401
+ clear() {
1402
+ this.live = 0;
1403
+ this.perKind.fill(0);
1404
+ }
1405
+ /** Swap the last live particle into slot `i`. Order does not matter; density does. */
1406
+ remove(i) {
1407
+ this.perKind[this.kind[i]] = this.perKind[this.kind[i]] - 1;
1408
+ const last = --this.live;
1409
+ if (i === last) return;
1410
+ this.kind[i] = this.kind[last];
1411
+ this.age[i] = this.age[last];
1412
+ this.life[i] = this.life[last];
1413
+ this.angle[i] = this.angle[last];
1414
+ this.spin[i] = this.spin[last];
1415
+ this.seed[i] = this.seed[last];
1416
+ this.position.copyWithin(i * 3, last * 3, last * 3 + 3);
1417
+ this.velocity.copyWithin(i * 3, last * 3, last * 3 + 3);
1418
+ }
1419
+ };
1420
+
1421
+ // src/fx/fire.ts
1422
+ init_field();
1423
+ var fireEmitterDefaults = {
1424
+ embers: 0.35,
1425
+ smoke: 0,
1426
+ ash: 0.12,
1427
+ seed: 7,
1428
+ caps: {}
1429
+ };
1430
+ var PER_UPDATE = 24;
1431
+ var FireEmitter = class {
1432
+ field;
1433
+ pool;
1434
+ locate;
1435
+ o;
1436
+ emberDebt = 0;
1437
+ smokeDebt = 0;
1438
+ state;
1439
+ constructor(field, pool, locate, options = {}) {
1440
+ this.field = field;
1441
+ this.pool = pool;
1442
+ this.locate = locate;
1443
+ this.o = { ...fireEmitterDefaults, ...options };
1444
+ this.state = this.o.seed >>> 0 || 1;
1445
+ }
1446
+ /** Call once a frame, after the field has stepped. */
1447
+ update(dt) {
1448
+ const { field, o } = this;
1449
+ const front = field.frontCount;
1450
+ const consumed = field.consumedCount;
1451
+ if (front === 0 && consumed === 0) {
1452
+ this.emberDebt = 0;
1453
+ this.smokeDebt = 0;
1454
+ return;
1455
+ }
1456
+ for (let k = 0; k < consumed; k++) {
1457
+ if (this.next() < o.ash) this.emit("ash", field.consumedCells[k]);
1458
+ }
1459
+ if (front > 0 && dt > 0) {
1460
+ const [wx, wy, wz] = this.pool.wind;
1461
+ const air = Math.hypot(wx, wy, wz);
1462
+ let heat = 0;
1463
+ for (let k = 0; k < front; k++) heat += field.data[field.frontCells[k] * 4 + HEAT];
1464
+ heat /= front;
1465
+ const struggle = 0.35 + 1.6 * Math.max(0, 0.7 - heat) + air * 0.8;
1466
+ const oxygen = 1 + air * 1.5;
1467
+ this.emberDebt = Math.min(this.emberDebt + front * o.embers * oxygen * dt, PER_UPDATE);
1468
+ this.smokeDebt = Math.min(this.smokeDebt + front * o.smoke * struggle * dt, PER_UPDATE);
1469
+ while (this.emberDebt >= 1) {
1470
+ this.emberDebt -= 1;
1471
+ this.emit("ember", field.frontCells[Math.floor(this.next() * front)]);
1472
+ }
1473
+ while (this.smokeDebt >= 1) {
1474
+ this.smokeDebt -= 1;
1475
+ this.emit("smoke", field.frontCells[Math.floor(this.next() * front)]);
1476
+ }
1477
+ }
1478
+ }
1479
+ emit(name, cell) {
1480
+ const cap = this.o.caps[name];
1481
+ if (cap !== void 0 && this.pool.countOf(name) >= cap) return;
1482
+ const size = this.field.size;
1483
+ const last = size - 1;
1484
+ const at = this.locate(cell % size / last, (cell / size | 0) / last);
1485
+ if (!at) return;
1486
+ if (name === "ember") {
1487
+ const lift = this.next() * this.next() * 0.09;
1488
+ this.pool.spawn(name, at.x + (this.next() - 0.5) * 0.012, at.y + lift, at.z);
1489
+ } else {
1490
+ this.pool.spawn(name, at.x, at.y, at.z);
1491
+ }
1492
+ }
1493
+ /** xorshift32 — its own stream, so the pool's randomness cannot shift which cells are picked. */
1494
+ next() {
1495
+ let s = this.state;
1496
+ s ^= s << 13;
1497
+ s ^= s >>> 17;
1498
+ s ^= s << 5;
1499
+ this.state = s >>> 0;
1500
+ return this.state / 4294967296;
1501
+ }
1502
+ };
1503
+
1504
+ // src/fx/FxParticles.tsx
1505
+ var THREE = __toESM(require("three"), 1);
1506
+ var import_fiber = require("@react-three/fiber");
1507
+ var import_react = require("react");
1508
+ var import_jsx_runtime = require("react/jsx-runtime");
1509
+ var STREAK = 0.045;
1510
+ var QUAD_STREAK = new Float32Array([-1, 0, 0, 1, 0, 0, 1, 1, 0, -1, 1, 0]);
1511
+ var QUAD_FLAKE = new Float32Array([-1, -1, 0, 1, -1, 0, 1, 1, 0, -1, 1, 0]);
1512
+ var QUAD_INDEX = [0, 1, 2, 0, 2, 3];
1513
+ function makeTarget(capacity) {
1514
+ return {
1515
+ position: new Float32Array(capacity * 3),
1516
+ color: new Float32Array(capacity * 4),
1517
+ extra: new Float32Array(capacity * 4),
1518
+ velocity: new Float32Array(capacity * 3)
1519
+ };
1520
+ }
1521
+ function useInstanced(capacity, quad) {
1522
+ const layer = (0, import_react.useMemo)(() => {
1523
+ const target2 = makeTarget(capacity);
1524
+ const geometry = new THREE.InstancedBufferGeometry();
1525
+ geometry.setAttribute("position", new THREE.BufferAttribute(quad, 3));
1526
+ geometry.setIndex(QUAD_INDEX);
1527
+ geometry.setAttribute("aPos", new THREE.InstancedBufferAttribute(target2.position, 3));
1528
+ geometry.setAttribute("aVel", new THREE.InstancedBufferAttribute(target2.velocity, 3));
1529
+ geometry.setAttribute("aColor", new THREE.InstancedBufferAttribute(target2.color, 4));
1530
+ geometry.setAttribute("aExtra", new THREE.InstancedBufferAttribute(target2.extra, 4));
1531
+ geometry.instanceCount = 0;
1532
+ geometry.boundingSphere = new THREE.Sphere(new THREE.Vector3(), Infinity);
1533
+ return { target: target2, geometry };
1534
+ }, [capacity, quad]);
1535
+ (0, import_react.useEffect)(() => () => layer.geometry.dispose(), [layer]);
1536
+ return layer;
1537
+ }
1538
+ function usePoints(capacity) {
1539
+ const layer = (0, import_react.useMemo)(() => {
1540
+ const target2 = makeTarget(capacity);
1541
+ const geometry = new THREE.BufferGeometry();
1542
+ geometry.setAttribute("position", new THREE.BufferAttribute(target2.position, 3));
1543
+ geometry.setAttribute("pColor", new THREE.BufferAttribute(target2.color, 4));
1544
+ geometry.setAttribute("pExtra", new THREE.BufferAttribute(target2.extra, 4));
1545
+ geometry.setDrawRange(0, 0);
1546
+ geometry.boundingSphere = new THREE.Sphere(new THREE.Vector3(), Infinity);
1547
+ return { target: target2, geometry };
1548
+ }, [capacity]);
1549
+ (0, import_react.useEffect)(() => () => layer.geometry.dispose(), [layer]);
1550
+ return layer;
1551
+ }
1552
+ var OUTPUT = (
1553
+ /* glsl */
1554
+ `
1555
+ #include <tonemapping_fragment>
1556
+ #include <colorspace_fragment>
1557
+ `
1558
+ );
1559
+ var EMBER_VERTEX = (
1560
+ /* glsl */
1561
+ `
1562
+ attribute vec3 aPos;
1563
+ attribute vec3 aVel;
1564
+ attribute vec4 aColor;
1565
+ attribute vec4 aExtra; // size, angle, shape, seed
1566
+ uniform vec2 uResolution;
1567
+ uniform float uStreak;
1568
+ varying vec4 vColor;
1569
+ varying vec2 vQuad;
1570
+ void main() {
1571
+ vec4 head = projectionMatrix * viewMatrix * vec4(aPos, 1.0);
1572
+ vec4 tail = projectionMatrix * viewMatrix * vec4(aPos - aVel * uStreak, 1.0);
1573
+ vec2 half_ = 0.5 * uResolution;
1574
+ vec2 d = head.xy / head.w * half_ - tail.xy / tail.w * half_;
1575
+ float len = length(d);
1576
+ vec2 dir = len > 1e-3 ? d / len : vec2(0.0, 1.0);
1577
+ vec2 across = vec2(-dir.y, dir.x);
1578
+ // Diameter in pixels from a size in world units: the projection's own scale.
1579
+ float px = max(1.5, aExtra.x * projectionMatrix[1][1] * half_.y / head.w);
1580
+ float along = len + px;
1581
+ vec2 offset = across * position.x * px * 0.5 + dir * (px * 0.5 - position.y * along);
1582
+ gl_Position = head + vec4(offset / half_ * head.w, 0.0, 0.0);
1583
+ vColor = aColor;
1584
+ vQuad = position.xy;
1585
+ }
1586
+ `
1587
+ );
1588
+ var EMBER_FRAGMENT = (
1589
+ /* glsl */
1590
+ `
1591
+ varying vec4 vColor;
1592
+ varying vec2 vQuad;
1593
+ void main() {
1594
+ // A thin hot core with a soft edge, fading to NOTHING behind the head.
1595
+ //
1596
+ // It used to be flat-topped across (smoothstep from 0.35, so the middle
1597
+ // seventy per cent of the width was all at full brightness) and to keep a
1598
+ // quarter of that brightness all the way to the end of the quad. Both
1599
+ // together drew a wide bar with a squared-off end, which is what the review
1600
+ // saw up close. A spark is a point of light smeared by its own motion: it
1601
+ // is brightest on its centre line and it runs out.
1602
+ float across = abs(vQuad.x);
1603
+ float body = exp(-across * across * 7.0);
1604
+ float tail = clamp(1.0 - vQuad.y, 0.0, 1.0);
1605
+ float m = body * tail * tail;
1606
+ if (m <= 0.003) discard;
1607
+ gl_FragColor = vec4(vColor.rgb * vColor.a * m, 1.0);
1608
+ ${OUTPUT}
1609
+ }
1610
+ `
1611
+ );
1612
+ var SMOKE_VERTEX = (
1613
+ /* glsl */
1614
+ `
1615
+ attribute vec4 pColor;
1616
+ attribute vec4 pExtra; // size, angle, shape, seed
1617
+ uniform float uScale;
1618
+ varying vec4 vColor;
1619
+ varying vec2 vSpin;
1620
+ varying float vSeed;
1621
+ void main() {
1622
+ vColor = pColor;
1623
+ vSpin = vec2(cos(pExtra.y), sin(pExtra.y));
1624
+ vSeed = pExtra.w;
1625
+ vec4 view = modelViewMatrix * vec4(position, 1.0);
1626
+ bool perspective = projectionMatrix[2][3] == -1.0;
1627
+ float depth = perspective ? max(0.0001, -view.z) : 1.0;
1628
+ gl_PointSize = max(1.0, pExtra.x * uScale / depth);
1629
+ gl_Position = projectionMatrix * view;
1630
+ }
1631
+ `
1632
+ );
1633
+ var SMOKE_FRAGMENT = (
1634
+ /* glsl */
1635
+ `
1636
+ varying vec4 vColor;
1637
+ varying vec2 vSpin;
1638
+ varying float vSeed;
1639
+ float smHash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); }
1640
+ float smNoise(vec2 p) {
1641
+ vec2 i = floor(p);
1642
+ vec2 f = fract(p);
1643
+ vec2 u = f * f * (3.0 - 2.0 * f);
1644
+ return mix(mix(smHash(i), smHash(i + vec2(1.0, 0.0)), u.x), mix(smHash(i + vec2(0.0, 1.0)), smHash(i + vec2(1.0, 1.0)), u.x), u.y);
1645
+ }
1646
+ void main() {
1647
+ vec2 p = gl_PointCoord * 2.0 - 1.0;
1648
+ p = vec2(p.x * vSpin.x - p.y * vSpin.y, p.x * vSpin.y + p.y * vSpin.x);
1649
+ float r = length(p);
1650
+ // Not a disc: a soft body torn by two octaves of noise, different for
1651
+ // every puff, so a cloud of them is a texture rather than a pile of dots.
1652
+ float n = smNoise(p * 2.2 + vSeed * 19.0) * 0.65 + smNoise(p * 5.1 + vSeed * 7.0) * 0.35;
1653
+ float body = (1.0 - smoothstep(0.2, 1.0, r)) * smoothstep(0.25, 0.75, n + 0.25 * (1.0 - r));
1654
+ if (body <= 0.0) discard;
1655
+ gl_FragColor = vec4(vColor.rgb, vColor.a * body);
1656
+ ${OUTPUT}
1657
+ }
1658
+ `
1659
+ );
1660
+ var FLAKE_VERTEX = (
1661
+ /* glsl */
1662
+ `
1663
+ attribute vec3 aPos;
1664
+ attribute vec4 aColor;
1665
+ attribute vec4 aExtra; // size, angle, shape, seed
1666
+ varying vec2 vQuad;
1667
+ varying vec4 vColor;
1668
+ varying float vLight;
1669
+ varying float vSeed;
1670
+ mat3 rotation(vec3 axis, float a) {
1671
+ float c = cos(a);
1672
+ float s = sin(a);
1673
+ float t = 1.0 - c;
1674
+ return mat3(
1675
+ t * axis.x * axis.x + c, t * axis.x * axis.y + s * axis.z, t * axis.x * axis.z - s * axis.y,
1676
+ t * axis.x * axis.y - s * axis.z, t * axis.y * axis.y + c, t * axis.y * axis.z + s * axis.x,
1677
+ t * axis.x * axis.z + s * axis.y, t * axis.y * axis.z - s * axis.x, t * axis.z * axis.z + c
1678
+ );
1679
+ }
1680
+ void main() {
1681
+ float seed = aExtra.w;
1682
+ // Its own tumbling axis, from its seed: flakes do not spin in step.
1683
+ vec3 axis = normalize(vec3(sin(seed * 40.0), cos(seed * 23.0), 0.4 + sin(seed * 71.0) * 0.5));
1684
+ mat3 r = rotation(axis, aExtra.y);
1685
+ // A little curl: burnt paper does not lie flat.
1686
+ vec3 local = vec3(position.x, position.y, position.x * position.x * 0.45 - 0.2) * aExtra.x * 0.5;
1687
+ vec3 world = aPos + r * local;
1688
+ vec3 n = r * vec3(0.0, 0.0, 1.0);
1689
+ // Lit by which way it faces \u2014 double-sided, so either face catches light.
1690
+ vLight = 0.25 + 0.75 * abs(dot(n, normalize(vec3(-0.35, 0.8, 0.5))));
1691
+ vQuad = position.xy;
1692
+ vColor = aColor;
1693
+ vSeed = seed;
1694
+ gl_Position = projectionMatrix * viewMatrix * vec4(world, 1.0);
1695
+ }
1696
+ `
1697
+ );
1698
+ var FLAKE_FRAGMENT = (
1699
+ /* glsl */
1700
+ `
1701
+ varying vec2 vQuad;
1702
+ varying vec4 vColor;
1703
+ varying float vLight;
1704
+ varying float vSeed;
1705
+ float flHash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); }
1706
+ float flNoise(vec2 p) {
1707
+ vec2 i = floor(p);
1708
+ vec2 f = fract(p);
1709
+ vec2 u = f * f * (3.0 - 2.0 * f);
1710
+ return mix(mix(flHash(i), flHash(i + vec2(1.0, 0.0)), u.x), mix(flHash(i + vec2(0.0, 1.0)), flHash(i + vec2(1.0, 1.0)), u.x), u.y);
1711
+ }
1712
+ void main() {
1713
+ float r = length(vQuad);
1714
+ float a = atan(vQuad.y, vQuad.x);
1715
+ // A torn scrap: an outline that wanders, different for every flake.
1716
+ float edge = 0.62 + 0.3 * flNoise(vec2(a * 1.6 + vSeed * 31.0, vSeed * 7.0));
1717
+ float mask = 1.0 - smoothstep(edge - 0.06, edge, r);
1718
+ if (mask <= 0.0) discard;
1719
+ // Char in the middle, pale brittle ash at the edge (\xA78.3) \u2014 in patches
1720
+ // along it, not all the way round: a flake is a few pixels across, and a
1721
+ // continuous pale rim round a dark middle drew it as a hollow ring.
1722
+ float rim = smoothstep(edge - 0.32, edge - 0.04, r);
1723
+ float patches = smoothstep(0.45, 0.75, flNoise(vec2(a * 2.2 + vSeed * 17.0, vSeed * 13.0)));
1724
+ float grain = flNoise(vQuad * 3.0 + vSeed * 23.0);
1725
+ vec3 charC = vec3(0.05, 0.043, 0.038) * (0.7 + 0.6 * grain);
1726
+ // #A49E9D, the spec's own sample of ash (\xA78.3), in linear.
1727
+ vec3 ashC = vec3(0.372, 0.344, 0.340);
1728
+ // Pale ash with char under it, not char with a pale rim.
1729
+ //
1730
+ // It was the other way round, and the flakes came out near black: at 0.07
1731
+ // linear they fell through the hole onto a black stage and read as dust in
1732
+ // the void. Burnt paper ash is a LIGHT grey \u2014 what makes a flake flash dark
1733
+ // and light as it tumbles is vLight, the face it is showing, not its own
1734
+ // colour being nearly black to begin with.
1735
+ vec3 c = mix(ashC, charC, (1.0 - rim * patches) * 0.45) * vLight;
1736
+ // A few carry a hot edge that fades over their first second or so, on ONE
1737
+ // arc of it \u2014 the side that was burning when it tore off. All the way round
1738
+ // drew it as an orange ring.
1739
+ float young = clamp((vColor.a - 0.55) / 0.4, 0.0, 1.0);
1740
+ float arc = smoothstep(0.25, 0.8, cos(a - vSeed * 40.0));
1741
+ float hotEdge = step(vSeed, 0.3) * rim * arc * young;
1742
+ c += vec3(2.4, 0.6, 0.08) * hotEdge;
1743
+ gl_FragColor = vec4(c, smoothstep(0.0, 0.2, vColor.a) * mask);
1744
+ ${OUTPUT}
1745
+ }
1746
+ `
1747
+ );
1748
+ function FxParticles({ pool }) {
1749
+ const embers = useInstanced(pool.capacity, QUAD_STREAK);
1750
+ const flakes = useInstanced(pool.capacity, QUAD_FLAKE);
1751
+ const smoke = usePoints(pool.capacity);
1752
+ const materials = (0, import_react.useMemo)(
1753
+ () => ({
1754
+ ember: new THREE.ShaderMaterial({
1755
+ vertexShader: EMBER_VERTEX,
1756
+ fragmentShader: EMBER_FRAGMENT,
1757
+ uniforms: { uResolution: { value: new THREE.Vector2(1, 1) }, uStreak: { value: STREAK } },
1758
+ transparent: true,
1759
+ depthWrite: false,
1760
+ blending: THREE.AdditiveBlending
1761
+ }),
1762
+ smoke: new THREE.ShaderMaterial({
1763
+ vertexShader: SMOKE_VERTEX,
1764
+ fragmentShader: SMOKE_FRAGMENT,
1765
+ uniforms: { uScale: { value: 100 } },
1766
+ transparent: true,
1767
+ depthWrite: false
1768
+ }),
1769
+ flake: new THREE.ShaderMaterial({
1770
+ vertexShader: FLAKE_VERTEX,
1771
+ fragmentShader: FLAKE_FRAGMENT,
1772
+ transparent: true,
1773
+ depthWrite: false,
1774
+ side: THREE.DoubleSide
1775
+ })
1776
+ }),
1777
+ []
1778
+ );
1779
+ (0, import_react.useEffect)(
1780
+ () => () => {
1781
+ materials.ember.dispose();
1782
+ materials.smoke.dispose();
1783
+ materials.flake.dispose();
1784
+ },
1785
+ [materials]
1786
+ );
1787
+ const size = (0, import_fiber.useThree)((s) => s.size);
1788
+ (0, import_fiber.useFrame)(({ gl, camera }) => {
1789
+ const counts = pool.write(embers.target, smoke.target, flakes.target);
1790
+ embers.geometry.instanceCount = counts.additive;
1791
+ flakes.geometry.instanceCount = counts.flakes;
1792
+ smoke.geometry.setDrawRange(0, counts.normal);
1793
+ for (const name of ["aPos", "aVel", "aColor", "aExtra"]) {
1794
+ ;
1795
+ embers.geometry.attributes[name].needsUpdate = counts.additive > 0;
1796
+ flakes.geometry.attributes[name].needsUpdate = counts.flakes > 0;
1797
+ }
1798
+ if (counts.normal > 0) {
1799
+ for (const name of ["position", "pColor", "pExtra"]) {
1800
+ ;
1801
+ smoke.geometry.attributes[name].needsUpdate = true;
1802
+ }
1803
+ }
1804
+ const ratio = gl.getPixelRatio();
1805
+ materials.ember.uniforms.uResolution.value.set(
1806
+ size.width * ratio,
1807
+ size.height * ratio
1808
+ );
1809
+ const projection = camera.projectionMatrix.elements[5] ?? 1;
1810
+ materials.smoke.uniforms.uScale.value = size.height * ratio * 0.5 * projection;
1811
+ });
1812
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
1813
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("mesh", { geometry: embers.geometry, material: materials.ember, frustumCulled: false }),
1814
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("points", { geometry: smoke.geometry, material: materials.smoke, frustumCulled: false }),
1815
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("mesh", { geometry: flakes.geometry, material: materials.flake, frustumCulled: false })
1816
+ ] });
1817
+ }
1818
+
1819
+ // src/fx/FxPost.tsx
1820
+ var import_react3 = require("react");
1821
+ var import_jsx_runtime3 = require("react/jsx-runtime");
1822
+ var Pass = (0, import_react3.lazy)(
1823
+ () => Promise.resolve().then(() => (init_FxPostPass(), FxPostPass_exports)).then((m) => ({ default: m.FxPostPass })).catch((error) => {
1824
+ warnOnce(error);
1825
+ return { default: (() => null) };
1826
+ })
1827
+ );
1828
+ var warned = false;
1829
+ function warnOnce(cause) {
1830
+ if (warned) return;
1831
+ warned = true;
1832
+ console.warn(
1833
+ "[paperlab/fx] Rendering without FxPost \u2014 fire will not bloom.\nIt needs two optional peers:\n npm i @react-three/postprocessing postprocessing\nLeave <FxPost> out to go without it deliberately and silence this.",
1834
+ ...cause === void 0 ? [] : [cause]
1835
+ );
1836
+ }
1837
+ function FxPost(props) {
1838
+ return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react3.Suspense, { fallback: null, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(Pass, { ...props }) });
1839
+ }
1840
+
1841
+ // src/fx.ts
1842
+ init_emission();
1843
+
1844
+ // src/fx/afterglow.ts
1845
+ init_field();
1846
+ var SMOULDER = 0.3;
1847
+ var OUT = 4e-3;
1848
+ var Afterglow = class {
1849
+ size = FIELD_SIZE;
1850
+ pixels = new Uint8Array(FIELD_SIZE * FIELD_SIZE * 4);
1851
+ field;
1852
+ glow = new Float32Array(FIELD_SIZE * FIELD_SIZE);
1853
+ rate = new Float32Array(FIELD_SIZE * FIELD_SIZE);
1854
+ revision = 0;
1855
+ clock = 0;
1856
+ flare = 0;
1857
+ lastField = -1;
1858
+ lit = false;
1859
+ constructor(field, options = {}) {
1860
+ this.field = field;
1861
+ const [shortest, longest] = options.hold ?? [0.6, 2.2];
1862
+ const seed = (options.seed ?? 5) >>> 0;
1863
+ for (let i = 0; i < this.rate.length; i++) {
1864
+ let h = Math.imul(i ^ seed, 2654435761) >>> 0;
1865
+ h ^= h >>> 15;
1866
+ h = Math.imul(h, 2246822519) >>> 0;
1867
+ const life = shortest + (longest - shortest) * ((h >>> 0) / 4294967296);
1868
+ this.rate[i] = 1 / life;
1869
+ }
1870
+ this.pixels.set(field.pixels);
1871
+ }
1872
+ get version() {
1873
+ return this.revision;
1874
+ }
1875
+ get detail() {
1876
+ return this.field.detail;
1877
+ }
1878
+ /** Its own clock: the beads keep flickering after the field has gone to sleep. */
1879
+ get time() {
1880
+ return this.clock;
1881
+ }
1882
+ /** Whether anything is still glowing that the field no longer is. */
1883
+ get smouldering() {
1884
+ return this.lit;
1885
+ }
1886
+ /**
1887
+ * Advance by `dt`, after the field has stepped. `blow` is the breath on the
1888
+ * sheet, 0..1: it flares what is left (§10.6, "on smoulder: beads flare,
1889
+ * then fade").
1890
+ */
1891
+ step(dt, blow = 0) {
1892
+ if (!(dt > 0)) return;
1893
+ this.clock += dt;
1894
+ this.flare = blow > 0.05 ? Math.min(1, this.flare + dt * 4) : Math.max(0, this.flare - dt * 1.5);
1895
+ const data = this.field.data;
1896
+ const glow = this.glow;
1897
+ let lit = false;
1898
+ for (let i = 0; i < glow.length; i++) {
1899
+ const heat = data[i * 4 + HEAT];
1900
+ const was = glow[i];
1901
+ let next;
1902
+ if (heat >= was) {
1903
+ next = heat;
1904
+ } else {
1905
+ next = Math.max(heat, Math.min(was, SMOULDER) * Math.exp(-dt * this.rate[i]));
1906
+ }
1907
+ if (next < OUT) next = 0;
1908
+ glow[i] = next;
1909
+ if (next > heat + OUT) lit = true;
1910
+ }
1911
+ this.lit = lit;
1912
+ this.sync();
1913
+ }
1914
+ /** Copy the field in, and lay the remembered heat over its heat channel. */
1915
+ sync() {
1916
+ const fieldVersion = this.field.version;
1917
+ if (!this.lit && fieldVersion === this.lastField && this.flare === 0) return;
1918
+ this.lastField = fieldVersion;
1919
+ const src = this.field.pixels;
1920
+ const dst = this.pixels;
1921
+ dst.set(src);
1922
+ const boost = 1 + 0.9 * this.flare;
1923
+ for (let i = 0; i < this.glow.length; i++) {
1924
+ const g = Math.round(Math.min(1, this.glow[i] * boost) * 255);
1925
+ const k = i * 4 + HEAT;
1926
+ if (g > dst[k]) dst[k] = g;
1927
+ }
1928
+ this.revision++;
1929
+ }
1930
+ };
1931
+
1932
+ // src/fx/FxFlames.tsx
1933
+ var import_fiber3 = require("@react-three/fiber");
1934
+ var import_react4 = require("react");
1935
+ init_flames();
1936
+
1937
+ // src/fx/flameShader.ts
1938
+ var THREE4 = __toESM(require("three"), 1);
1939
+ var NOISE = (
1940
+ /* glsl */
1941
+ `
1942
+ float flHash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); }
1943
+ float flNoise(vec2 p) {
1944
+ vec2 i = floor(p);
1945
+ vec2 f = fract(p);
1946
+ vec2 u = f * f * (3.0 - 2.0 * f);
1947
+ return mix(mix(flHash(i), flHash(i + vec2(1.0, 0.0)), u.x), mix(flHash(i + vec2(0.0, 1.0)), flHash(i + vec2(1.0, 1.0)), u.x), u.y);
1948
+ }
1949
+ float flFbm(vec2 p) {
1950
+ float v = 0.0;
1951
+ float a = 0.5;
1952
+ for (int i = 0; i < 4; i++) {
1953
+ v += a * flNoise(p);
1954
+ p = p * 2.03 + 17.0;
1955
+ a *= 0.5;
1956
+ }
1957
+ return v;
1958
+ }
1959
+ float flHash1(float n) { return fract(sin(n) * 43758.5453); }
1960
+ float flNoise1(float x) {
1961
+ float i = floor(x);
1962
+ float f = fract(x);
1963
+ f = f * f * (3.0 - 2.0 * f);
1964
+ return mix(flHash1(i), flHash1(i + 1.0), f);
1965
+ }
1966
+ `
1967
+ );
1968
+ var VERTEX = (
1969
+ /* glsl */
1970
+ `
1971
+ attribute vec3 aBase;
1972
+ attribute vec4 aShape; // height, width, seed, heat
1973
+ uniform float uTime;
1974
+ uniform vec3 uLean;
1975
+ varying vec2 vUv;
1976
+ varying float vSeed;
1977
+ varying float vHeat;
1978
+ ${NOISE}
1979
+ void main() {
1980
+ vUv = position.xy;
1981
+ vSeed = aShape.z;
1982
+ vHeat = aShape.w;
1983
+ // The same puff as \`flamePuff\` in flames.ts \u2014 the fire light reads it too.
1984
+ float puff = 0.72 + 0.28 * flNoise1(uTime * 12.5 + aShape.z * 37.0) + 0.12 * (flNoise1(uTime * 23.0 + aShape.z * 11.0) - 0.5);
1985
+ float h = aShape.x * puff;
1986
+ vec3 up = normalize(vec3(0.0, 1.0, 0.0) + uLean);
1987
+ vec3 toCamera = normalize(cameraPosition - aBase);
1988
+ vec3 right = normalize(cross(up, toCamera));
1989
+ // A curve, not a tilt: the root holds and the tip is carried.
1990
+ vec3 p = aBase + toCamera * 0.004 + right * position.x * aShape.y + up * position.y * h
1991
+ + uLean * position.y * position.y * h * 0.8;
1992
+ gl_Position = projectionMatrix * viewMatrix * vec4(p, 1.0);
1993
+ }
1994
+ `
1995
+ );
1996
+ var FRAGMENT2 = (
1997
+ /* glsl */
1998
+ `
1999
+ uniform float uTime;
2000
+ uniform float uBlue;
2001
+ uniform float uGain;
2002
+ varying vec2 vUv;
2003
+ varying float vSeed;
2004
+ varying float vHeat;
2005
+ ${NOISE}
2006
+ void main() {
2007
+ // The quad is 1.8\xD7 the flame's width, so the turbulence can carry the
2008
+ // flame sideways without the quad's own edge cutting it into a bar.
2009
+ float x = vUv.x * 1.8;
2010
+ float y = vUv.y;
2011
+ // Climbing at a speed that itself wanders \u2014 per flame, and over time \u2014 so
2012
+ // the motion never settles into a loop. An offset, not a rate \xD7 time: a
2013
+ // rate that changes would make the pattern jump.
2014
+ float climb = uTime * (2.7 + 0.9 * vSeed) + 1.8 * flNoise1(uTime * 0.37 + vSeed * 9.1);
2015
+ // Three layers of turbulence: a slow sway of the whole tongue, a flutter
2016
+ // near the tip, and a fine shiver \u2014 each warping the next.
2017
+ vec2 q = vec2(x * 1.2 + vSeed * 13.0, y * 1.9 - climb);
2018
+ float sway = flFbm(q);
2019
+ float flutter = flFbm(vec2(x * 2.7 + vSeed * 5.0 + sway * 1.3, y * 4.3 - climb * 1.55));
2020
+ float shiver = flNoise(vec2(x * 7.0 + vSeed * 3.0, y * 11.0 - climb * 2.4));
2021
+ // Every tongue leans its own way \u2014 nothing here is symmetrical.
2022
+ float lean = (fract(vSeed * 91.7) - 0.5) * 0.45;
2023
+ float xw = x + (sway - 0.5) * 1.35 * y + (flutter - 0.5) * 0.5 * y * y + (shiver - 0.5) * 0.12 * y + lean * y;
2024
+ // The teardrop: dense and wide at the source, a point at the top.
2025
+ float half_ = 1.0 * pow(max(y, 0.0), 0.3) * pow(max(1.0 - y, 0.0), 1.3);
2026
+ // Crisp and dense low down, soft and translucent higher up.
2027
+ float body = 1.0 - smoothstep(half_ * mix(0.82, 0.4, y), half_, abs(xw));
2028
+ // Tips that split: a hard threshold high up tears the flame into separate
2029
+ // tongues and wisps that come loose ...
2030
+ float tear = flFbm(vec2(x * 2.4 + vSeed * 7.0, y * 4.2 - climb * 1.7));
2031
+ body *= smoothstep(0.0, 0.14, tear + 0.82 - y * 1.1);
2032
+ // ... and small gaps open inside the upper body, where the gas is thin.
2033
+ float gaps = smoothstep(0.58, 0.72, flFbm(vec2(x * 3.3 - vSeed * 4.0, y * 6.5 - climb * 2.0)));
2034
+ body *= 1.0 - gaps * smoothstep(0.3, 0.8, y) * 0.95;
2035
+ body *= smoothstep(0.0, 0.04, y);
2036
+ // The source is hot and dense: orange-white at the root, yellow above it,
2037
+ // orange at the edges and tips, and translucent as it thins.
2038
+ vec3 white = vec3(1.0, 0.86, 0.62); // orange-white
2039
+ vec3 yellow = vec3(0.98, 0.62, 0.09); // #FDCE54
2040
+ vec3 orange = vec3(0.77, 0.2, 0.0); // #E37B04
2041
+ // The orange-white is only the very root; above it the flame is saturated
2042
+ // yellow, then orange. Mixed broadly it washed the whole tongue pale.
2043
+ float base = 1.0 - smoothstep(0.0, 0.26, y);
2044
+ float inner = 1.0 - smoothstep(0.0, half_ * 0.5, abs(xw));
2045
+ vec3 c = mix(orange, yellow, smoothstep(0.35, 0.85, body * (1.0 - y * 0.85)));
2046
+ c = mix(c, white, clamp(base * inner, 0.0, 1.0));
2047
+ // Dense at the source, and bright enough there to bloom; thinning above.
2048
+ c *= mix(1.0, 4.4, base * (0.45 + 0.55 * inner)) * (0.55 + 0.45 * vHeat) * uGain;
2049
+ // Only a match starved of air by moving fast burns blue.
2050
+ c = mix(c, vec3(0.027, 0.025, 0.099) * 2.5, uBlue * 0.75);
2051
+ float fade = 1.0 - smoothstep(0.55, 1.0, y);
2052
+ gl_FragColor = vec4(c * body * fade, 1.0);
2053
+ #include <tonemapping_fragment>
2054
+ #include <colorspace_fragment>
2055
+ }
2056
+ `
2057
+ );
2058
+ function flameGeometry(max) {
2059
+ const g = new THREE4.InstancedBufferGeometry();
2060
+ g.setAttribute(
2061
+ "position",
2062
+ new THREE4.BufferAttribute(new Float32Array([-1, 0, 0, 1, 0, 0, 1, 1, 0, -1, 1, 0]), 3)
2063
+ );
2064
+ g.setIndex([0, 1, 2, 0, 2, 3]);
2065
+ g.setAttribute("aBase", new THREE4.InstancedBufferAttribute(new Float32Array(Math.max(1, max) * 3), 3));
2066
+ g.setAttribute("aShape", new THREE4.InstancedBufferAttribute(new Float32Array(Math.max(1, max) * 4), 4));
2067
+ g.instanceCount = 0;
2068
+ g.boundingSphere = new THREE4.Sphere(new THREE4.Vector3(), Infinity);
2069
+ return g;
2070
+ }
2071
+ function flameMaterial() {
2072
+ return new THREE4.ShaderMaterial({
2073
+ vertexShader: VERTEX,
2074
+ fragmentShader: FRAGMENT2,
2075
+ uniforms: {
2076
+ uTime: { value: 0 },
2077
+ uLean: { value: new THREE4.Vector3() },
2078
+ uBlue: { value: 0 },
2079
+ uGain: { value: 1 }
2080
+ },
2081
+ transparent: true,
2082
+ depthWrite: false,
2083
+ blending: THREE4.AdditiveBlending,
2084
+ side: THREE4.DoubleSide
2085
+ });
2086
+ }
2087
+
2088
+ // src/fx/FxFlames.tsx
2089
+ init_quality();
2090
+ var import_jsx_runtime4 = require("react/jsx-runtime");
2091
+ function FxFlames({ field, locate, quality = "medium", wind }) {
2092
+ const max = fxQualityFor(quality).flames;
2093
+ const anchors = (0, import_react4.useRef)([]);
2094
+ const geometry = (0, import_react4.useMemo)(() => flameGeometry(max), [max]);
2095
+ (0, import_react4.useEffect)(() => () => geometry.dispose(), [geometry]);
2096
+ const material = (0, import_react4.useMemo)(() => flameMaterial(), []);
2097
+ (0, import_react4.useEffect)(() => () => material.dispose(), [material]);
2098
+ (0, import_fiber3.useFrame)(() => {
2099
+ const n = flameAnchors(field, locate, max, anchors.current);
2100
+ const base = geometry.getAttribute("aBase");
2101
+ const shape = geometry.getAttribute("aShape");
2102
+ const [wx, wy, wz] = wind ?? [0, 0, 0];
2103
+ const gust = Math.hypot(wx, wy, wz);
2104
+ const shorten = 1 / (1 + gust * 0.6);
2105
+ for (let i = 0; i < n; i++) {
2106
+ const a = anchors.current[i];
2107
+ base.array[i * 3] = a.x;
2108
+ base.array[i * 3 + 1] = a.y;
2109
+ base.array[i * 3 + 2] = a.z;
2110
+ shape.array[i * 4] = a.height * shorten;
2111
+ shape.array[i * 4 + 1] = a.width;
2112
+ shape.array[i * 4 + 2] = a.seed;
2113
+ shape.array[i * 4 + 3] = a.heat;
2114
+ }
2115
+ base.needsUpdate = true;
2116
+ shape.needsUpdate = true;
2117
+ geometry.instanceCount = n;
2118
+ material.uniforms.uTime.value = field.time;
2119
+ material.uniforms.uLean.value.set(wx * 0.5, 0, wz * 0.5);
2120
+ });
2121
+ return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("mesh", { geometry, material, frustumCulled: false, renderOrder: 2 });
2122
+ }
2123
+
2124
+ // src/fx/FxWisps.tsx
2125
+ var THREE5 = __toESM(require("three"), 1);
2126
+ var import_fiber4 = require("@react-three/fiber");
2127
+ var import_react5 = require("react");
2128
+ init_field();
2129
+ var import_jsx_runtime5 = require("react/jsx-runtime");
2130
+ function wispAlpha(age) {
2131
+ if (!(age >= 0 && age < LIFE)) return 0;
2132
+ return Math.min(1, age / 0.25) * (1 - age / LIFE) ** 1.6 * 0.42;
2133
+ }
2134
+ var WISPS = 2;
2135
+ var POINTS = 64;
2136
+ var LIFE = 3.4;
2137
+ var RISE = 0.075;
2138
+ var APART = 9;
2139
+ function FxWisps({ glow, field, locate, wind }) {
2140
+ const state = (0, import_react5.useRef)(
2141
+ Array.from({ length: WISPS }, (_, w) => ({
2142
+ cell: -1,
2143
+ phase: w * 3.1 + 0.7,
2144
+ // Ring buffer of births: root position and birth time.
2145
+ root: new Float32Array(POINTS * 3),
2146
+ born: new Float32Array(POINTS).fill(-1e9),
2147
+ next: 0,
2148
+ owed: 0
2149
+ }))
2150
+ );
2151
+ const last = (0, import_react5.useRef)(-1);
2152
+ const geometry = (0, import_react5.useMemo)(() => {
2153
+ const g = new THREE5.BufferGeometry();
2154
+ const vertices = WISPS * POINTS * 2;
2155
+ g.setAttribute("position", new THREE5.BufferAttribute(new Float32Array(vertices * 3), 3));
2156
+ g.setAttribute("aAlpha", new THREE5.BufferAttribute(new Float32Array(vertices), 1));
2157
+ const index = [];
2158
+ for (let w = 0; w < WISPS; w++) {
2159
+ for (let i = 0; i < POINTS - 1; i++) {
2160
+ const a = (w * POINTS + i) * 2;
2161
+ index.push(a, a + 1, a + 2, a + 1, a + 3, a + 2);
2162
+ }
2163
+ }
2164
+ g.setIndex(index);
2165
+ g.boundingSphere = new THREE5.Sphere(new THREE5.Vector3(), Infinity);
2166
+ return g;
2167
+ }, []);
2168
+ (0, import_react5.useEffect)(() => () => geometry.dispose(), [geometry]);
2169
+ const material = (0, import_react5.useMemo)(
2170
+ () => new THREE5.ShaderMaterial({
2171
+ vertexShader: (
2172
+ /* glsl */
2173
+ `
2174
+ attribute float aAlpha;
2175
+ varying float vAlpha;
2176
+ void main() {
2177
+ vAlpha = aAlpha;
2178
+ gl_Position = projectionMatrix * viewMatrix * vec4(position, 1.0);
2179
+ }
2180
+ `
2181
+ ),
2182
+ fragmentShader: (
2183
+ /* glsl */
2184
+ `
2185
+ varying float vAlpha;
2186
+ void main() {
2187
+ // Pale grey smoke, thin enough that only its accumulation reads.
2188
+ gl_FragColor = vec4(vec3(0.62, 0.61, 0.6), vAlpha);
2189
+ #include <tonemapping_fragment>
2190
+ #include <colorspace_fragment>
2191
+ }
2192
+ `
2193
+ ),
2194
+ transparent: true,
2195
+ depthWrite: false,
2196
+ side: THREE5.DoubleSide
2197
+ }),
2198
+ []
2199
+ );
2200
+ (0, import_react5.useEffect)(() => () => material.dispose(), [material]);
2201
+ (0, import_fiber4.useFrame)(({ camera }) => {
2202
+ const now = glow.time;
2203
+ const dt = last.current < 0 ? 0 : Math.min(0.1, Math.max(0, now - last.current));
2204
+ last.current = now;
2205
+ const beads = smoulderingBeads(glow, field, WISPS);
2206
+ const [wx, , wz] = wind ?? [0, 0, 0];
2207
+ const positions = geometry.getAttribute("position");
2208
+ const alphas = geometry.getAttribute("aAlpha");
2209
+ const p = new THREE5.Vector3();
2210
+ const q = new THREE5.Vector3();
2211
+ const toCamera = new THREE5.Vector3();
2212
+ const side = new THREE5.Vector3();
2213
+ const path = new Float32Array(POINTS * 3);
2214
+ const age = new Float32Array(POINTS);
2215
+ for (let w = 0; w < WISPS; w++) {
2216
+ const s = state.current[w];
2217
+ if (!beads.includes(s.cell)) {
2218
+ const taken = beads.find((c) => !state.current.some((o) => o !== s && o.cell === c)) ?? -1;
2219
+ if (taken !== s.cell) {
2220
+ s.born.fill(-1e9);
2221
+ s.next = 0;
2222
+ s.owed = 0;
2223
+ }
2224
+ s.cell = taken;
2225
+ }
2226
+ if (s.cell >= 0 && dt > 0) {
2227
+ s.owed += dt;
2228
+ const every = LIFE / POINTS;
2229
+ while (s.owed >= every) {
2230
+ s.owed -= every;
2231
+ const at = locate(
2232
+ s.cell % FIELD_SIZE / (FIELD_SIZE - 1),
2233
+ (s.cell / FIELD_SIZE | 0) / (FIELD_SIZE - 1)
2234
+ );
2235
+ if (!at) break;
2236
+ const k = s.next;
2237
+ s.root[k * 3] = at.x;
2238
+ s.root[k * 3 + 1] = at.y;
2239
+ s.root[k * 3 + 2] = at.z + 4e-3;
2240
+ s.born[k] = now;
2241
+ s.next = (k + 1) % POINTS;
2242
+ }
2243
+ }
2244
+ for (let i = 0; i < POINTS; i++) {
2245
+ const k = (s.next - 1 - i + POINTS * 2) % POINTS;
2246
+ const a = now - s.born[k];
2247
+ age[i] = a;
2248
+ const bx = s.root[k * 3];
2249
+ const by = s.root[k * 3 + 1];
2250
+ const bz = s.root[k * 3 + 2];
2251
+ const sway = (noise12(a * 0.8 + s.born[k] * 0.3 + s.phase) - 0.5) * 0.07 * a;
2252
+ const lean = (noise12(a * 0.5 + s.phase * 2.3) - 0.5) * 0.02 * a;
2253
+ path[i * 3] = bx + sway + wx * a * a * 0.25;
2254
+ path[i * 3 + 1] = by + RISE * a * (1 + 0.18 * a);
2255
+ path[i * 3 + 2] = bz + lean + wz * a * a * 0.25;
2256
+ }
2257
+ let anchor = -1;
2258
+ for (let i = 0; i < POINTS; i++) {
2259
+ if (age[i] >= 0 && age[i] < LIFE) {
2260
+ anchor = i;
2261
+ break;
2262
+ }
2263
+ }
2264
+ for (let i = 0; i < POINTS; i++) {
2265
+ const a = age[i];
2266
+ if (!(a >= 0 && a < LIFE)) {
2267
+ if (anchor >= 0) {
2268
+ path[i * 3] = path[anchor * 3];
2269
+ path[i * 3 + 1] = path[anchor * 3 + 1];
2270
+ path[i * 3 + 2] = path[anchor * 3 + 2];
2271
+ } else {
2272
+ const r = (s.next - 1 + POINTS) % POINTS * 3;
2273
+ path[i * 3] = s.root[r];
2274
+ path[i * 3 + 1] = s.root[r + 1];
2275
+ path[i * 3 + 2] = s.root[r + 2];
2276
+ }
2277
+ } else {
2278
+ anchor = i;
2279
+ }
2280
+ }
2281
+ for (let i = 0; i < POINTS; i++) {
2282
+ const a = age[i];
2283
+ p.set(path[i * 3], path[i * 3 + 1], path[i * 3 + 2]);
2284
+ const j = Math.min(POINTS - 1, i + 1);
2285
+ const h = Math.max(0, i - 1);
2286
+ q.set(
2287
+ path[j * 3] - path[h * 3],
2288
+ path[j * 3 + 1] - path[h * 3 + 1],
2289
+ path[j * 3 + 2] - path[h * 3 + 2]
2290
+ );
2291
+ toCamera.copy(camera.position).sub(p);
2292
+ const alive = a >= 0 && a < LIFE;
2293
+ if (alive || i === 0) {
2294
+ side.crossVectors(q, toCamera);
2295
+ if (side.lengthSq() < 1e-20) side.set(1, 0, 0);
2296
+ side.normalize();
2297
+ }
2298
+ const half = 11e-4 + 22e-4 * Math.min(1, Math.max(0, a) / LIFE);
2299
+ const v = (w * POINTS + i) * 2;
2300
+ positions.setXYZ(v, p.x - side.x * half, p.y - side.y * half, p.z - side.z * half);
2301
+ positions.setXYZ(v + 1, p.x + side.x * half, p.y + side.y * half, p.z + side.z * half);
2302
+ const alpha = wispAlpha(a);
2303
+ alphas.setX(v, alpha);
2304
+ alphas.setX(v + 1, alpha);
2305
+ }
2306
+ }
2307
+ positions.needsUpdate = true;
2308
+ alphas.needsUpdate = true;
2309
+ });
2310
+ return /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("mesh", { geometry, material, frustumCulled: false, renderOrder: 1 });
2311
+ }
2312
+ function smoulderingBeads(glow, field, max) {
2313
+ const size = FIELD_SIZE;
2314
+ const shown = glow.pixels;
2315
+ const heat = field.pixels;
2316
+ const found = [];
2317
+ for (let y = 1; y < size - 1; y++) {
2318
+ for (let x = 1; x < size - 1; x++) {
2319
+ const c = y * size + x;
2320
+ const g = shown[c * 4 + HEAT];
2321
+ if (g < 10 || heat[c * 4 + HEAT] > 4 || shown[c * 4 + PRESENCE] < 128) continue;
2322
+ const rim = shown[(c - 1) * 4 + PRESENCE] < 128 || shown[(c + 1) * 4 + PRESENCE] < 128 || shown[(c - size) * 4 + PRESENCE] < 128 || shown[(c + size) * 4 + PRESENCE] < 128;
2323
+ if (rim) found.push({ cell: c, g });
2324
+ }
2325
+ }
2326
+ found.sort((a, b) => b.g - a.g || a.cell - b.cell);
2327
+ const picked = [];
2328
+ for (const f of found) {
2329
+ if (picked.length >= max) break;
2330
+ const fx = f.cell % size;
2331
+ const fy = f.cell / size | 0;
2332
+ if (picked.every((p) => Math.hypot(p % size - fx, (p / size | 0) - fy) >= APART)) picked.push(f.cell);
2333
+ }
2334
+ return picked;
2335
+ }
2336
+ function hash12(n) {
2337
+ const s = Math.sin(n) * 43758.5453;
2338
+ return s - Math.floor(s);
2339
+ }
2340
+ function noise12(x) {
2341
+ const i = Math.floor(x);
2342
+ let f = x - i;
2343
+ f = f * f * (3 - 2 * f);
2344
+ return hash12(i) + (hash12(i + 1) - hash12(i)) * f;
2345
+ }
2346
+
2347
+ // src/fx/FxFireFluid.tsx
2348
+ var THREE7 = __toESM(require("three"), 1);
2349
+ var import_fiber5 = require("@react-three/fiber");
2350
+ var import_react6 = require("react");
2351
+ init_emission();
2352
+ init_flames();
2353
+
2354
+ // src/fx/fluid/FireFluid.ts
2355
+ var THREE6 = __toESM(require("three"), 1);
2356
+
2357
+ // src/fx/fluid/passes.ts
2358
+ var MAX_SOURCES = 64;
2359
+ var PASS_VERTEX = (
2360
+ /* glsl */
2361
+ `
2362
+ varying vec2 vUv;
2363
+ void main() {
2364
+ vUv = position.xy * 0.5 + 0.5;
2365
+ gl_Position = vec4(position.xy, 0.0, 1.0);
2366
+ }
2367
+ `
2368
+ );
2369
+ var NOISE2 = (
2370
+ /* glsl */
2371
+ `
2372
+ float fxHash(vec2 p) {
2373
+ vec3 p3 = fract(vec3(p.xyx) * 0.1031);
2374
+ p3 += dot(p3, p3.yzx + 33.33);
2375
+ return fract((p3.x + p3.y) * p3.z);
2376
+ }
2377
+ float fxNoise(vec2 p) {
2378
+ vec2 i = floor(p);
2379
+ vec2 f = fract(p);
2380
+ vec2 u = f * f * (3.0 - 2.0 * f);
2381
+ return mix(mix(fxHash(i), fxHash(i + vec2(1.0, 0.0)), u.x), mix(fxHash(i + vec2(0.0, 1.0)), fxHash(i + vec2(1.0, 1.0)), u.x), u.y);
2382
+ }
2383
+ float fxFbm(vec2 p) {
2384
+ float v = 0.0;
2385
+ float a = 0.5;
2386
+ for (int i = 0; i < 3; i++) {
2387
+ v += a * fxNoise(p);
2388
+ p = p * 2.07 + 13.0;
2389
+ a *= 0.5;
2390
+ }
2391
+ return v;
2392
+ }
2393
+ float fxHash3(vec3 p) {
2394
+ vec3 p3 = fract(p * 0.1031);
2395
+ p3 += dot(p3, p3.zyx + 31.32);
2396
+ return fract((p3.x + p3.y) * p3.z);
2397
+ }
2398
+ float fxNoise3(vec3 p) {
2399
+ vec3 i = floor(p);
2400
+ vec3 f = fract(p);
2401
+ vec3 u = f * f * (3.0 - 2.0 * f);
2402
+ float a = mix(mix(fxHash3(i), fxHash3(i + vec3(1.0, 0.0, 0.0)), u.x), mix(fxHash3(i + vec3(0.0, 1.0, 0.0)), fxHash3(i + vec3(1.0, 1.0, 0.0)), u.x), u.y);
2403
+ float b = mix(mix(fxHash3(i + vec3(0.0, 0.0, 1.0)), fxHash3(i + vec3(1.0, 0.0, 1.0)), u.x), mix(fxHash3(i + vec3(0.0, 1.0, 1.0)), fxHash3(i + vec3(1.0, 1.0, 1.0)), u.x), u.y);
2404
+ return mix(a, b, u.z);
2405
+ }
2406
+ float fxFbm3(vec3 p) {
2407
+ float v = 0.0;
2408
+ float a = 0.5;
2409
+ for (int i = 0; i < 3; i++) {
2410
+ v += a * fxNoise3(p);
2411
+ p = p * 2.07 + 13.0;
2412
+ a *= 0.5;
2413
+ }
2414
+ return v;
2415
+ }
2416
+ `
2417
+ );
2418
+ var SOURCES = (
2419
+ /* glsl */
2420
+ `
2421
+ uniform vec4 uSources[${MAX_SOURCES}];
2422
+ uniform vec4 uAcross[${MAX_SOURCES}];
2423
+ uniform int uCount;
2424
+ uniform float uAspect;
2425
+ float fxEmission(vec2 uv, out vec2 outward, out float smoulder) {
2426
+ float e = 0.0;
2427
+ outward = vec2(0.0);
2428
+ smoulder = 0.0;
2429
+ for (int i = 0; i < ${MAX_SOURCES}; i++) {
2430
+ if (i >= uCount) break;
2431
+ vec4 s = uSources[i];
2432
+ vec4 k = uAcross[i];
2433
+ vec2 d = (uv - s.xy) * vec2(uAspect, 1.0);
2434
+ vec2 n = k.xy;
2435
+ float across = dot(d, n) - k.w;
2436
+ float along = dot(d, vec2(-n.y, n.x));
2437
+ float g = abs(s.w) * exp(-along * along / (s.z * s.z) - across * across / (k.z * k.z));
2438
+ if (s.w < 0.0) {
2439
+ smoulder += g;
2440
+ continue;
2441
+ }
2442
+ e += g;
2443
+ // Off the edge, away from the paper it came from.
2444
+ outward -= g * n;
2445
+ }
2446
+ return e;
2447
+ }
2448
+ `
2449
+ );
2450
+ var EMIT = (
2451
+ /* glsl */
2452
+ `
2453
+ varying vec2 vUv;
2454
+ ${SOURCES}
2455
+ void main() {
2456
+ vec2 outward;
2457
+ float smoulder;
2458
+ float e = fxEmission(vUv, outward, smoulder);
2459
+ gl_FragColor = vec4(e, outward, smoulder);
2460
+ }
2461
+ `
2462
+ );
2463
+ var ADVECT = (
2464
+ /* glsl */
2465
+ `
2466
+ uniform sampler2D uVelocity;
2467
+ uniform sampler2D uSource;
2468
+ uniform vec2 uDomain;
2469
+ uniform float uDt;
2470
+ uniform vec4 uKeep;
2471
+ varying vec2 vUv;
2472
+ void main() {
2473
+ vec2 v = texture2D(uVelocity, vUv).xy;
2474
+ vec2 back = vUv - v * uDt / uDomain;
2475
+ gl_FragColor = texture2D(uSource, back) * uKeep;
2476
+ }
2477
+ `
2478
+ );
2479
+ var MACCORMACK = (
2480
+ /* glsl */
2481
+ `
2482
+ uniform sampler2D uVelocity;
2483
+ uniform sampler2D uSource;
2484
+ uniform sampler2D uForward;
2485
+ uniform sampler2D uBackward;
2486
+ uniform vec2 uDomain;
2487
+ uniform vec2 uTexel;
2488
+ uniform float uDt;
2489
+ varying vec2 vUv;
2490
+ void main() {
2491
+ vec4 forward = texture2D(uForward, vUv);
2492
+ vec4 phi = forward + 0.5 * (texture2D(uSource, vUv) - texture2D(uBackward, vUv));
2493
+ vec2 v = texture2D(uVelocity, vUv).xy;
2494
+ vec2 back = vUv - v * uDt / uDomain;
2495
+ vec2 corner = (floor(back / uTexel - 0.5) + 0.5) * uTexel;
2496
+ vec4 a = texture2D(uSource, corner);
2497
+ vec4 b = texture2D(uSource, corner + vec2(uTexel.x, 0.0));
2498
+ vec4 c = texture2D(uSource, corner + vec2(0.0, uTexel.y));
2499
+ vec4 d = texture2D(uSource, corner + uTexel);
2500
+ gl_FragColor = clamp(phi, min(min(a, b), min(c, d)), max(max(a, b), max(c, d)));
2501
+ }
2502
+ `
2503
+ );
2504
+ var REACT = (
2505
+ /* glsl */
2506
+ `
2507
+ uniform sampler2D uA;
2508
+ uniform sampler2D uB;
2509
+ uniform sampler2D uEmit;
2510
+ uniform sampler2D uVelocity;
2511
+ uniform vec2 uDomain;
2512
+ uniform vec2 uTexel;
2513
+ uniform int uOut;
2514
+ uniform float uDt;
2515
+ uniform float uFuel;
2516
+ uniform float uHeat;
2517
+ uniform float uSmoke;
2518
+ uniform float uPremixed;
2519
+ uniform float uAmbient;
2520
+ uniform float uBurnRate;
2521
+ uniform float uHeatRelease;
2522
+ uniform float uCooling;
2523
+ uniform float uSmokeProduction;
2524
+ uniform float uSmokeFade;
2525
+ uniform float uPersistence;
2526
+ uniform float uMixing;
2527
+ uniform float uEntrain;
2528
+ uniform float uFuelBlock;
2529
+ uniform float uSootYield;
2530
+ uniform float uSootHeat;
2531
+ uniform float uStoich;
2532
+ uniform float uSmoulderSmoke;
2533
+ uniform float uSmoulderHeat;
2534
+ varying vec2 vUv;
2535
+ void main() {
2536
+ vec4 a = texture2D(uA, vUv);
2537
+ vec4 b = texture2D(uB, vUv);
2538
+ // What a parcel picked up crossing the rim during the step \u2014 sampled back
2539
+ // along its path, not at the one point it ended at. The band gas comes off
2540
+ // is ~2 mm across and the gas crosses it at ~5 mm a step at 60 Hz, so a
2541
+ // point sample drew one line of fuel per step: horizontal stripes up every
2542
+ // tongue.
2543
+ vec2 path = texture2D(uVelocity, vUv).xy * uDt / uDomain;
2544
+ vec4 em = 0.25 * (texture2D(uEmit, vUv) + texture2D(uEmit, vUv - path * 0.25) +
2545
+ texture2D(uEmit, vUv - path * 0.5) + texture2D(uEmit, vUv - path * 0.75));
2546
+ float e = em.r;
2547
+ // What the rim releases this step \u2014 and, where a flame has gone out, the
2548
+ // warm smoke the spot goes on giving off for a while.
2549
+ float fuel = a.r + e * uFuel * uDt;
2550
+ float heat = a.g + (e * uHeat + em.a * uSmoulderHeat) * uDt;
2551
+ float smoke = a.b + (e * uSmoke + em.a * uSmoulderSmoke) * uDt;
2552
+ float premixed = b.r + e * uFuel * uPremixed * uDt;
2553
+ // Air, mixed in from beside and entrained from in front and behind.
2554
+ float around = 0.25 * (
2555
+ texture2D(uB, vUv + vec2(uTexel.x, 0.0)).g + texture2D(uB, vUv - vec2(uTexel.x, 0.0)).g +
2556
+ texture2D(uB, vUv + vec2(0.0, uTexel.y)).g + texture2D(uB, vUv - vec2(0.0, uTexel.y)).g);
2557
+ float oxygen = mix(b.g, around, uMixing);
2558
+ float reach = exp(-fuel / max(uFuelBlock, 1e-4));
2559
+ oxygen += (uAmbient - oxygen) * (1.0 - exp(-uEntrain * reach * uDt));
2560
+ // Fuel burns only as far as there is oxygen for it \u2014 premixed first \u2014 and a
2561
+ // unit of fuel takes uStoich of air. At one to one (as it once was) fuel was
2562
+ // never denser than the air around it, so nothing ever ran out of air and
2563
+ // every scrap of fuel burnt where it stood: no core, no tip.
2564
+ float burn = min(fuel, premixed + oxygen / uStoich) * (1.0 - exp(-uBurnRate * uDt));
2565
+ fuel = max(0.0, fuel - burn) * exp(-uDt * 0.6);
2566
+ float fromPremixed = min(premixed, burn);
2567
+ premixed = max(0.0, premixed - fromPremixed) * exp(-uDt * 0.6);
2568
+ oxygen = max(0.0, oxygen - (burn - fromPremixed) * uStoich);
2569
+ heat = (heat + burn * uHeatRelease) * exp(-uCooling * uDt);
2570
+ float flameHeat = (b.a + burn * uHeatRelease) * exp(-uCooling * uDt);
2571
+ smoke = (smoke + burn * uSmokeProduction) * exp(-uDt / uSmokeFade);
2572
+ float rate = burn / max(uDt, 1e-4);
2573
+ // Soot: forms from hot fuel, burns where there is air, and is gone once the
2574
+ // gas is too cool to glow.
2575
+ float hot = heat / max(uSootHeat, 1e-3);
2576
+ float soot = a.a + uSootYield * (fuel + burn) * smoothstep(0.3, 0.8, hot) * uDt;
2577
+ float air = oxygen / max(uAmbient, 0.05);
2578
+ // Soot that cools stops glowing within a few frames \u2014 it is smoke now, and
2579
+ // the smoke channel already carries that. Held to glowing only below a
2580
+ // twentieth of uSootHeat, it outlived the flame by a hand's breadth and
2581
+ // drew ribbons and hooks of flame colour high over the sheet.
2582
+ float cold = 1.0 - smoothstep(0.2, 0.6, hot);
2583
+ soot *= exp(-uDt * (air / max(uPersistence, 1e-3) + cold * 25.0));
2584
+ // An open top: whatever reaches it leaves.
2585
+ float open = 1.0 - smoothstep(0.9, 1.0, vUv.y);
2586
+ if (uOut == 0) gl_FragColor = vec4(fuel, heat, smoke * open, soot) * vec4(open, open, 1.0, open);
2587
+ else gl_FragColor = vec4(premixed * open, oxygen, rate, flameHeat * open);
2588
+ }
2589
+ `
2590
+ );
2591
+ var FORCES = (
2592
+ /* glsl */
2593
+ `
2594
+ uniform sampler2D uVelocity;
2595
+ uniform sampler2D uA;
2596
+ uniform sampler2D uEmit;
2597
+ uniform vec2 uTexel;
2598
+ uniform vec2 uDomain;
2599
+ uniform float uDt;
2600
+ uniform float uTime;
2601
+ uniform float uBuoyancy;
2602
+ uniform float uWind;
2603
+ uniform float uTurbulence;
2604
+ uniform float uTurbScale;
2605
+ uniform float uTurbEvolve;
2606
+ uniform float uRadial;
2607
+ uniform vec2 uInitVel;
2608
+ varying vec2 vUv;
2609
+ ${NOISE2}
2610
+ void main() {
2611
+ vec2 v = texture2D(uVelocity, vUv).xy;
2612
+ vec4 a = texture2D(uA, vUv);
2613
+ // Hot gas rises.
2614
+ v.y += uBuoyancy * a.g * uDt;
2615
+ v.x += uWind * uDt;
2616
+ // Curl noise: divergence-free eddies, in world units, carried up with the
2617
+ // gas AND changing as they go. The third coordinate is time: a 2D pattern
2618
+ // that only scrolled was a wave travelling up a still flame, never a flame
2619
+ // that flickers.
2620
+ float drift = uTime * 0.8 + 1.7 * fxNoise(vec2(uTime * 0.23, 3.1));
2621
+ vec3 p = vec3(vUv * uDomain * uTurbScale + vec2(0.37 * fxNoise(vec2(uTime * 0.17, 9.0)), -drift), uTime * uTurbEvolve);
2622
+ float h = 0.04;
2623
+ float n = fxFbm3(p);
2624
+ vec2 curl = vec2(fxFbm3(p + vec3(0.0, h, 0.0)) - n, -(fxFbm3(p + vec3(h, 0.0, 0.0)) - n)) / h;
2625
+ v += curl * uTurbulence * uDt * (0.2 + clamp(a.g, 0.0, 2.0));
2626
+ // The gas leaves the paper at its own speed, pushed off the edge if asked.
2627
+ vec4 em = texture2D(uEmit, vUv);
2628
+ float e = em.r;
2629
+ vec2 launch = uInitVel + uRadial * em.gb / max(e, 1e-4);
2630
+ v = mix(v, launch, clamp(e * uDt * 6.0, 0.0, 1.0));
2631
+ // Walls on three sides; the top is open.
2632
+ if (vUv.x < uTexel.x || vUv.x > 1.0 - uTexel.x || vUv.y < uTexel.y) v = vec2(0.0);
2633
+ gl_FragColor = vec4(v, 0.0, 1.0);
2634
+ }
2635
+ `
2636
+ );
2637
+ var CURL = (
2638
+ /* glsl */
2639
+ `
2640
+ uniform sampler2D uVelocity;
2641
+ uniform vec2 uTexel;
2642
+ uniform float uCell;
2643
+ varying vec2 vUv;
2644
+ void main() {
2645
+ float l = texture2D(uVelocity, vUv - vec2(uTexel.x, 0.0)).y;
2646
+ float r = texture2D(uVelocity, vUv + vec2(uTexel.x, 0.0)).y;
2647
+ float b = texture2D(uVelocity, vUv - vec2(0.0, uTexel.y)).x;
2648
+ float t = texture2D(uVelocity, vUv + vec2(0.0, uTexel.y)).x;
2649
+ gl_FragColor = vec4((r - l - t + b) * 0.5 / uCell, 0.0, 0.0, 1.0);
2650
+ }
2651
+ `
2652
+ );
2653
+ var VORTICITY = (
2654
+ /* glsl */
2655
+ `
2656
+ uniform sampler2D uVelocity;
2657
+ uniform sampler2D uCurl;
2658
+ uniform vec2 uTexel;
2659
+ uniform float uCell;
2660
+ uniform float uDt;
2661
+ uniform float uVorticity;
2662
+ varying vec2 vUv;
2663
+ void main() {
2664
+ float l = abs(texture2D(uCurl, vUv - vec2(uTexel.x, 0.0)).x);
2665
+ float r = abs(texture2D(uCurl, vUv + vec2(uTexel.x, 0.0)).x);
2666
+ float b = abs(texture2D(uCurl, vUv - vec2(0.0, uTexel.y)).x);
2667
+ float t = abs(texture2D(uCurl, vUv + vec2(0.0, uTexel.y)).x);
2668
+ float w = texture2D(uCurl, vUv).x;
2669
+ vec2 n = vec2(r - l, t - b);
2670
+ n /= length(n) + 1e-5;
2671
+ vec2 v = texture2D(uVelocity, vUv).xy + uVorticity * uCell * vec2(n.y, -n.x) * w * uDt;
2672
+ gl_FragColor = vec4(v, 0.0, 1.0);
2673
+ }
2674
+ `
2675
+ );
2676
+ var DIVERGENCE = (
2677
+ /* glsl */
2678
+ `
2679
+ uniform sampler2D uVelocity;
2680
+ uniform sampler2D uB;
2681
+ uniform vec2 uTexel;
2682
+ uniform float uCell;
2683
+ uniform float uExpansion;
2684
+ varying vec2 vUv;
2685
+ void main() {
2686
+ float l = texture2D(uVelocity, vUv - vec2(uTexel.x, 0.0)).x;
2687
+ float r = texture2D(uVelocity, vUv + vec2(uTexel.x, 0.0)).x;
2688
+ float b = texture2D(uVelocity, vUv - vec2(0.0, uTexel.y)).y;
2689
+ float t = texture2D(uVelocity, vUv + vec2(0.0, uTexel.y)).y;
2690
+ float div = (r - l + t - b) * 0.5 / uCell - uExpansion * texture2D(uB, vUv).b;
2691
+ gl_FragColor = vec4(div, 0.0, 0.0, 1.0);
2692
+ }
2693
+ `
2694
+ );
2695
+ var PRESSURE = (
2696
+ /* glsl */
2697
+ `
2698
+ uniform sampler2D uPressure;
2699
+ uniform sampler2D uDivergence;
2700
+ uniform vec2 uTexel;
2701
+ uniform float uCell;
2702
+ varying vec2 vUv;
2703
+ void main() {
2704
+ float l = texture2D(uPressure, vUv - vec2(uTexel.x, 0.0)).x;
2705
+ float r = texture2D(uPressure, vUv + vec2(uTexel.x, 0.0)).x;
2706
+ float b = texture2D(uPressure, vUv - vec2(0.0, uTexel.y)).x;
2707
+ float t = texture2D(uPressure, vUv + vec2(0.0, uTexel.y)).x;
2708
+ float div = texture2D(uDivergence, vUv).x;
2709
+ gl_FragColor = vec4((l + r + b + t - div * uCell * uCell) * 0.25, 0.0, 0.0, 1.0);
2710
+ }
2711
+ `
2712
+ );
2713
+ var GRADIENT = (
2714
+ /* glsl */
2715
+ `
2716
+ uniform sampler2D uVelocity;
2717
+ uniform sampler2D uPressure;
2718
+ uniform vec2 uTexel;
2719
+ uniform float uCell;
2720
+ varying vec2 vUv;
2721
+ void main() {
2722
+ float l = texture2D(uPressure, vUv - vec2(uTexel.x, 0.0)).x;
2723
+ float r = texture2D(uPressure, vUv + vec2(uTexel.x, 0.0)).x;
2724
+ float b = texture2D(uPressure, vUv - vec2(0.0, uTexel.y)).x;
2725
+ float t = texture2D(uPressure, vUv + vec2(0.0, uTexel.y)).x;
2726
+ vec2 v = texture2D(uVelocity, vUv).xy - vec2(r - l, t - b) * 0.5 / uCell;
2727
+ gl_FragColor = vec4(v, 0.0, 1.0);
2728
+ }
2729
+ `
2730
+ );
2731
+ var FILL = (
2732
+ /* glsl */
2733
+ `
2734
+ uniform vec4 uValue;
2735
+ void main() {
2736
+ gl_FragColor = uValue;
2737
+ }
2738
+ `
2739
+ );
2740
+ var RENDER_VERTEX = (
2741
+ /* glsl */
2742
+ `
2743
+ varying vec2 vUv;
2744
+ void main() {
2745
+ vUv = uv;
2746
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
2747
+ }
2748
+ `
2749
+ );
2750
+ var RENDER_FRAGMENT = (
2751
+ /* glsl */
2752
+ `
2753
+ uniform sampler2D uA;
2754
+ uniform sampler2D uB;
2755
+ uniform float uTime;
2756
+ uniform float uGlow;
2757
+ uniform float uSmokeDensity;
2758
+ uniform float uPaperWhite;
2759
+ uniform float uHeatScale;
2760
+ uniform float uSootScale;
2761
+ uniform float uContrast;
2762
+ uniform float uOpacity;
2763
+ uniform float uThin;
2764
+ uniform vec3 uTipColor;
2765
+ uniform vec3 uBodyColor;
2766
+ uniform vec3 uCoreColor;
2767
+ uniform vec3 uRootColor;
2768
+ uniform float uTipGlow;
2769
+ uniform float uBodyGlow;
2770
+ uniform float uCoreGlow;
2771
+ uniform float uTipFrom;
2772
+ uniform float uTipTo;
2773
+ uniform float uCoreFrom;
2774
+ uniform float uSoftness;
2775
+ uniform float uTearing;
2776
+ uniform float uRootAmount;
2777
+ uniform float uRootReach;
2778
+ varying vec2 vUv;
2779
+ ${NOISE2}
2780
+ void main() {
2781
+ // Detail finer than the grid, none of it touching the solve: a small domain
2782
+ // warp so a tongue's outline is never the grid's.
2783
+ vec2 w = vUv * vec2(26.0, 18.0) + vec2(0.0, -uTime * 1.6);
2784
+ vec2 warp = vec2(fxFbm(w), fxFbm(w + 31.7)) - 0.5;
2785
+ vec4 a = texture2D(uA, vUv + warp * 0.004);
2786
+ float fuel = a.r;
2787
+ float heat = texture2D(uB, vUv + warp * 0.004).a;
2788
+ float smoke = a.b;
2789
+ // How dense the glowing soot is, against a full flame's (FIRE_SOOT_SCALE).
2790
+ float rho = a.a / max(uSootScale, 1e-4);
2791
+ // The tip's tearing: carved into the soot where it is thin, so tongues come
2792
+ // apart at their edges and keep their bodies.
2793
+ vec2 n1 = vUv * vec2(34.0, 24.0) + vec2(0.0, -uTime * 2.4);
2794
+ vec2 n2 = vUv * vec2(92.0, 64.0) + vec2(0.0, -uTime * 5.5);
2795
+ float grain = (fxFbm(n1) - 0.5) * 0.72 + (fxNoise(n2) - 0.5) * 0.28;
2796
+ rho = max(0.0, rho * (1.0 + grain * uTearing * (1.0 - smoothstep(0.1, 0.8, rho))));
2797
+ // Temperature against the hottest gas a flame has (FIRE_HEAT_SCALE).
2798
+ float t = pow(clamp(heat / max(uHeatScale, 1e-3), 0.0, 2.0), uContrast);
2799
+
2800
+ // WHERE: the outline, from the soot.
2801
+ float shape = smoothstep(uTipFrom, uTipFrom + uSoftness, rho);
2802
+ // WHAT COLOUR: the zones, as bands of temperature.
2803
+ float toBody = smoothstep(uTipTo - 0.12, uTipTo + 0.12, t);
2804
+ float toCore = smoothstep(uCoreFrom, uCoreFrom + 0.35, t);
2805
+ vec3 color = mix(mix(uTipColor, uBodyColor, toBody), uCoreColor, toCore);
2806
+ float glow = mix(mix(uTipGlow, uBodyGlow, toBody), uCoreGlow, toCore);
2807
+ // Soot emits and absorbs together: thin soot glows as much as it covers, and
2808
+ // a dense flame covers what is behind it only as far as uOpacity says.
2809
+ float cover = 1.0 - exp(-rho * 3.0);
2810
+ float fireAlpha = shape * cover * clamp(uOpacity / 5.0, 0.0, 1.0);
2811
+ vec3 fire = color * glow * shape * uPaperWhite * uGlow * smoothstep(0.0, uThin, cover);
2812
+ // The root: added as LIGHT, where fresh fuel is still near the paper and the
2813
+ // gas has not heated through. Mixed in instead of added, blue makes grey.
2814
+ float rootMask = smoothstep(mix(0.4, 0.02, uRootReach), mix(0.6, 0.12, uRootReach), fuel) * (1.0 - smoothstep(0.25, 0.6, t));
2815
+ fire += uRootColor * rootMask * uRootAmount * uPaperWhite * uGlow * 0.6;
2816
+
2817
+ // Smoke: warm grey-brown (a cool grey over cream reads lavender), lit by
2818
+ // the fire under it.
2819
+ float smokeAlpha = 1.0 - exp(-smoke * uSmokeDensity);
2820
+ vec3 smokeColor = vec3(0.16, 0.13, 0.10) + vec3(0.22, 0.09, 0.02) * smoothstep(0.15, 0.9, t);
2821
+ float alpha = smokeAlpha + fireAlpha - smokeAlpha * fireAlpha;
2822
+ // No square edge: the domain fades out before its borders.
2823
+ float edge = smoothstep(0.0, 0.06, vUv.x) * smoothstep(1.0, 0.94, vUv.x) * smoothstep(0.0, 0.03, vUv.y) * smoothstep(1.0, 0.9, vUv.y);
2824
+ gl_FragColor = vec4((fire + smokeColor * smokeAlpha) * edge, alpha * edge);
2825
+ #include <tonemapping_fragment>
2826
+ #include <colorspace_fragment>
2827
+ }
2828
+ `
2829
+ );
2830
+
2831
+ // src/fx/fluid/FireFluid.ts
2832
+ var Pair = class {
2833
+ read;
2834
+ write;
2835
+ constructor(w, h) {
2836
+ this.read = target(w, h);
2837
+ this.write = target(w, h);
2838
+ }
2839
+ swap() {
2840
+ const t = this.read;
2841
+ this.read = this.write;
2842
+ this.write = t;
2843
+ }
2844
+ dispose() {
2845
+ this.read.dispose();
2846
+ this.write.dispose();
2847
+ }
2848
+ };
2849
+ function target(w, h) {
2850
+ return new THREE6.WebGLRenderTarget(w, h, {
2851
+ type: THREE6.HalfFloatType,
2852
+ format: THREE6.RGBAFormat,
2853
+ minFilter: THREE6.LinearFilter,
2854
+ magFilter: THREE6.LinearFilter,
2855
+ wrapS: THREE6.ClampToEdgeWrapping,
2856
+ wrapT: THREE6.ClampToEdgeWrapping,
2857
+ depthBuffer: false,
2858
+ stencilBuffer: false,
2859
+ generateMipmaps: false
2860
+ });
2861
+ }
2862
+ function pass(fragmentShader, uniforms) {
2863
+ return new THREE6.ShaderMaterial({
2864
+ vertexShader: PASS_VERTEX,
2865
+ fragmentShader,
2866
+ uniforms,
2867
+ depthTest: false,
2868
+ depthWrite: false,
2869
+ blending: THREE6.NoBlending
2870
+ });
2871
+ }
2872
+ var FireFluid = class {
2873
+ constructor(renderer, grid, domain) {
2874
+ this.renderer = renderer;
2875
+ this.grid = grid;
2876
+ this.domain = domain;
2877
+ const [vw, vh] = grid.velocity;
2878
+ const [dw, dh] = grid.dye;
2879
+ this.scalars = new Pair(dw, dh);
2880
+ this.air = new Pair(dw, dh);
2881
+ this.velocity = new Pair(vw, vh);
2882
+ this.pressure = new Pair(vw, vh);
2883
+ this.emit = target(dw, dh);
2884
+ this.divergence = target(vw, vh);
2885
+ this.curl = target(vw, vh);
2886
+ this.forward = target(dw, dh);
2887
+ this.backward = target(dw, dh);
2888
+ const aspect = domain.width / domain.height;
2889
+ const vTexel = new THREE6.Vector2(1 / vw, 1 / vh);
2890
+ const dTexel = new THREE6.Vector2(1 / dw, 1 / dh);
2891
+ const cell = domain.width / vw;
2892
+ this.m = {
2893
+ emit: pass(EMIT, {
2894
+ uSources: { value: this.sources },
2895
+ uAcross: { value: this.across },
2896
+ uCount: { value: 0 },
2897
+ uAspect: { value: aspect }
2898
+ }),
2899
+ advect: pass(ADVECT, {
2900
+ uVelocity: { value: null },
2901
+ uSource: { value: null },
2902
+ uDomain: { value: new THREE6.Vector2(domain.width, domain.height) },
2903
+ uDt: { value: 0 },
2904
+ uKeep: { value: new THREE6.Vector4(1, 1, 1, 1) }
2905
+ }),
2906
+ react: pass(REACT, {
2907
+ uA: { value: null },
2908
+ uB: { value: null },
2909
+ uEmit: { value: this.emit.texture },
2910
+ uVelocity: { value: null },
2911
+ uDomain: { value: new THREE6.Vector2(domain.width, domain.height) },
2912
+ uTexel: { value: dTexel },
2913
+ uOut: { value: 0 },
2914
+ uDt: { value: 0 },
2915
+ uFuel: { value: 0 },
2916
+ uHeat: { value: 0 },
2917
+ uSmoke: { value: 0 },
2918
+ uPremixed: { value: 0 },
2919
+ uAmbient: { value: 0 },
2920
+ uBurnRate: { value: 0 },
2921
+ uHeatRelease: { value: 0 },
2922
+ uCooling: { value: 0 },
2923
+ uSmokeProduction: { value: 0 },
2924
+ uSmokeFade: { value: 1 },
2925
+ uPersistence: { value: 0.1 },
2926
+ uMixing: { value: 0 },
2927
+ uEntrain: { value: 0 },
2928
+ uFuelBlock: { value: 1 },
2929
+ uSootYield: { value: 0 },
2930
+ uSootHeat: { value: 1 },
2931
+ uStoich: { value: 1 },
2932
+ uSmoulderSmoke: { value: 0 },
2933
+ uSmoulderHeat: { value: 0 }
2934
+ }),
2935
+ forces: pass(FORCES, {
2936
+ uVelocity: { value: null },
2937
+ uA: { value: null },
2938
+ uEmit: { value: this.emit.texture },
2939
+ uTexel: { value: vTexel },
2940
+ uDomain: { value: new THREE6.Vector2(domain.width, domain.height) },
2941
+ uDt: { value: 0 },
2942
+ uTime: { value: 0 },
2943
+ uBuoyancy: { value: 0 },
2944
+ uWind: { value: 0 },
2945
+ uTurbulence: { value: 0 },
2946
+ uTurbScale: { value: 1 },
2947
+ uTurbEvolve: { value: 0 },
2948
+ uRadial: { value: 0 },
2949
+ uInitVel: { value: new THREE6.Vector2() }
2950
+ }),
2951
+ curl: pass(CURL, { uVelocity: { value: null }, uTexel: { value: vTexel }, uCell: { value: cell } }),
2952
+ vorticity: pass(VORTICITY, {
2953
+ uVelocity: { value: null },
2954
+ uCurl: { value: null },
2955
+ uTexel: { value: vTexel },
2956
+ uCell: { value: cell },
2957
+ uDt: { value: 0 },
2958
+ uVorticity: { value: 0 }
2959
+ }),
2960
+ divergence: pass(DIVERGENCE, {
2961
+ uVelocity: { value: null },
2962
+ uB: { value: null },
2963
+ uTexel: { value: vTexel },
2964
+ uCell: { value: cell },
2965
+ uExpansion: { value: 0 }
2966
+ }),
2967
+ pressure: pass(PRESSURE, {
2968
+ uPressure: { value: null },
2969
+ uDivergence: { value: null },
2970
+ uTexel: { value: vTexel },
2971
+ uCell: { value: cell }
2972
+ }),
2973
+ gradient: pass(GRADIENT, {
2974
+ uVelocity: { value: null },
2975
+ uPressure: { value: null },
2976
+ uTexel: { value: vTexel },
2977
+ uCell: { value: cell }
2978
+ }),
2979
+ fill: pass(FILL, { uValue: { value: new THREE6.Vector4() } }),
2980
+ maccormack: pass(MACCORMACK, {
2981
+ uVelocity: { value: null },
2982
+ uSource: { value: null },
2983
+ uForward: { value: null },
2984
+ uBackward: { value: null },
2985
+ uDomain: { value: new THREE6.Vector2(domain.width, domain.height) },
2986
+ uTexel: { value: new THREE6.Vector2(1 / dw, 1 / dh) },
2987
+ uDt: { value: 0 }
2988
+ })
2989
+ };
2990
+ const triangle = new THREE6.BufferGeometry();
2991
+ triangle.setAttribute(
2992
+ "position",
2993
+ new THREE6.BufferAttribute(new Float32Array([-1, -1, 0, 3, -1, 0, -1, 3, 0]), 3)
2994
+ );
2995
+ this.mesh = new THREE6.Mesh(triangle, this.m.fill);
2996
+ this.mesh.frustumCulled = false;
2997
+ this.scene.add(this.mesh);
2998
+ }
2999
+ renderer;
3000
+ grid;
3001
+ domain;
3002
+ /** (fuel, heat, smoke, soot) on the fine grid — what is drawn. */
3003
+ scalars;
3004
+ /** (premixed oxygen, ambient oxygen, burn rate, flame heat) on the fine grid. */
3005
+ air;
3006
+ velocity;
3007
+ pressure;
3008
+ /** The rim's emission this step, on the fine grid — see EMIT. */
3009
+ emit;
3010
+ divergence;
3011
+ curl;
3012
+ /** The two intermediate advections the MacCormack step compares. */
3013
+ forward;
3014
+ backward;
3015
+ /**
3016
+ * Error-compensated advection for the drawn fields. On by default; off is
3017
+ * a single semi-Lagrangian step, which is two passes a step cheaper and
3018
+ * visibly softer — a knob for the lowest tier, and for an A/B.
3019
+ */
3020
+ sharp = true;
3021
+ scene = new THREE6.Scene();
3022
+ camera = new THREE6.OrthographicCamera(-1, 1, 1, -1, 0, 1);
3023
+ mesh;
3024
+ m;
3025
+ sources = Array.from({ length: MAX_SOURCES }, () => new THREE6.Vector4());
3026
+ across = Array.from({ length: MAX_SOURCES }, () => new THREE6.Vector4());
3027
+ /** Whether this renderer can draw into half-float targets at all. */
3028
+ static supported(renderer) {
3029
+ return renderer.capabilities.isWebGL2 && (renderer.extensions.has("EXT_color_buffer_float") || renderer.extensions.has("EXT_color_buffer_half_float"));
3030
+ }
3031
+ /** Empty air, at the given ambient oxygen. */
3032
+ reset(ambient) {
3033
+ this.withRenderer(() => {
3034
+ const fill = this.m.fill;
3035
+ const value = fill.uniforms.uValue.value;
3036
+ value.set(0, 0, 0, 0);
3037
+ for (const t of [this.scalars, this.velocity, this.pressure]) {
3038
+ this.run(fill, t.read);
3039
+ this.run(fill, t.write);
3040
+ }
3041
+ this.run(fill, this.divergence);
3042
+ this.run(fill, this.curl);
3043
+ this.run(fill, this.emit);
3044
+ value.set(0, ambient, 0, 0);
3045
+ this.run(fill, this.air.read);
3046
+ this.run(fill, this.air.write);
3047
+ });
3048
+ }
3049
+ /**
3050
+ * One step of `dt` seconds. `sources` is (u, v, half-length, strength) per
3051
+ * rim segment and `across` is (toward-paper x, y, half-width, offset onto
3052
+ * the paper) — see EMIT — `count` of each; `time` drives the turbulence.
3053
+ */
3054
+ step(dt, u, sources, across, count, time) {
3055
+ const n = Math.min(MAX_SOURCES, count);
3056
+ for (let i = 0; i < n; i++) {
3057
+ this.sources[i].fromArray(sources, i * 4);
3058
+ this.across[i].fromArray(across, i * 4);
3059
+ }
3060
+ const m = this.m;
3061
+ m.emit.uniforms.uCount.value = n;
3062
+ this.withRenderer(() => {
3063
+ this.run(m.emit, this.emit);
3064
+ const advect = m.advect;
3065
+ advect.uniforms.uDt.value = dt;
3066
+ advect.uniforms.uVelocity.value = this.velocity.read.texture;
3067
+ advect.uniforms.uKeep.value.set(0.998, 0.998, 1, 1);
3068
+ advect.uniforms.uSource.value = this.velocity.read.texture;
3069
+ this.run(advect, this.velocity.write);
3070
+ this.velocity.swap();
3071
+ advect.uniforms.uVelocity.value = this.velocity.read.texture;
3072
+ advect.uniforms.uKeep.value.set(1, 1, 1, 1);
3073
+ if (this.sharp) {
3074
+ advect.uniforms.uSource.value = this.scalars.read.texture;
3075
+ this.run(advect, this.forward);
3076
+ advect.uniforms.uDt.value = -dt;
3077
+ advect.uniforms.uSource.value = this.forward.texture;
3078
+ this.run(advect, this.backward);
3079
+ advect.uniforms.uDt.value = dt;
3080
+ const mc = m.maccormack;
3081
+ mc.uniforms.uDt.value = dt;
3082
+ mc.uniforms.uVelocity.value = this.velocity.read.texture;
3083
+ mc.uniforms.uSource.value = this.scalars.read.texture;
3084
+ mc.uniforms.uForward.value = this.forward.texture;
3085
+ mc.uniforms.uBackward.value = this.backward.texture;
3086
+ this.run(mc, this.scalars.write);
3087
+ } else {
3088
+ advect.uniforms.uSource.value = this.scalars.read.texture;
3089
+ this.run(advect, this.scalars.write);
3090
+ }
3091
+ this.scalars.swap();
3092
+ advect.uniforms.uSource.value = this.air.read.texture;
3093
+ this.run(advect, this.air.write);
3094
+ this.air.swap();
3095
+ const react = m.react;
3096
+ const r = react.uniforms;
3097
+ r.uDt.value = dt;
3098
+ r.uFuel.value = u.fuel;
3099
+ r.uHeat.value = u.heat;
3100
+ r.uSmoke.value = u.smoke;
3101
+ r.uPremixed.value = u.premixed;
3102
+ r.uAmbient.value = u.ambient;
3103
+ r.uBurnRate.value = u.burnRate;
3104
+ r.uHeatRelease.value = u.heatRelease;
3105
+ r.uCooling.value = u.cooling;
3106
+ r.uSmokeProduction.value = u.smokeProduction;
3107
+ r.uSmokeFade.value = u.smokeFade;
3108
+ r.uPersistence.value = u.persistence;
3109
+ r.uMixing.value = u.mixing;
3110
+ r.uEntrain.value = u.entrain;
3111
+ r.uFuelBlock.value = u.fuelBlock;
3112
+ r.uSootYield.value = u.sootYield;
3113
+ r.uSootHeat.value = u.sootHeat;
3114
+ r.uStoich.value = u.stoich;
3115
+ r.uSmoulderSmoke.value = u.smoulderSmoke;
3116
+ r.uSmoulderHeat.value = u.smoulderHeat;
3117
+ r.uVelocity.value = this.velocity.read.texture;
3118
+ r.uA.value = this.scalars.read.texture;
3119
+ r.uB.value = this.air.read.texture;
3120
+ r.uOut.value = 0;
3121
+ this.run(react, this.scalars.write);
3122
+ r.uOut.value = 1;
3123
+ this.run(react, this.air.write);
3124
+ this.scalars.swap();
3125
+ this.air.swap();
3126
+ const forces = m.forces;
3127
+ const f = forces.uniforms;
3128
+ f.uVelocity.value = this.velocity.read.texture;
3129
+ f.uA.value = this.scalars.read.texture;
3130
+ f.uDt.value = dt;
3131
+ f.uTime.value = time;
3132
+ f.uBuoyancy.value = u.buoyancy;
3133
+ f.uWind.value = u.wind;
3134
+ f.uTurbulence.value = u.turbulence;
3135
+ f.uTurbScale.value = u.turbulenceScale;
3136
+ f.uTurbEvolve.value = u.turbulenceEvolve;
3137
+ f.uRadial.value = u.radial;
3138
+ f.uInitVel.value.set(u.initialVelocity[0], u.initialVelocity[1]);
3139
+ this.run(forces, this.velocity.write);
3140
+ this.velocity.swap();
3141
+ m.curl.uniforms.uVelocity.value = this.velocity.read.texture;
3142
+ this.run(m.curl, this.curl);
3143
+ const vort = m.vorticity;
3144
+ vort.uniforms.uVelocity.value = this.velocity.read.texture;
3145
+ vort.uniforms.uCurl.value = this.curl.texture;
3146
+ vort.uniforms.uDt.value = dt;
3147
+ vort.uniforms.uVorticity.value = u.vorticity;
3148
+ this.run(vort, this.velocity.write);
3149
+ this.velocity.swap();
3150
+ const div = m.divergence;
3151
+ div.uniforms.uVelocity.value = this.velocity.read.texture;
3152
+ div.uniforms.uB.value = this.air.read.texture;
3153
+ div.uniforms.uExpansion.value = u.expansion;
3154
+ this.run(div, this.divergence);
3155
+ const pressure = m.pressure;
3156
+ pressure.uniforms.uDivergence.value = this.divergence.texture;
3157
+ for (let k = 0; k < this.grid.iterations; k++) {
3158
+ pressure.uniforms.uPressure.value = this.pressure.read.texture;
3159
+ this.run(pressure, this.pressure.write);
3160
+ this.pressure.swap();
3161
+ }
3162
+ const gradient = m.gradient;
3163
+ gradient.uniforms.uVelocity.value = this.velocity.read.texture;
3164
+ gradient.uniforms.uPressure.value = this.pressure.read.texture;
3165
+ this.run(gradient, this.velocity.write);
3166
+ this.velocity.swap();
3167
+ });
3168
+ }
3169
+ dispose() {
3170
+ for (const p of [this.scalars, this.air, this.velocity, this.pressure]) p.dispose();
3171
+ this.emit.dispose();
3172
+ this.divergence.dispose();
3173
+ this.curl.dispose();
3174
+ this.forward.dispose();
3175
+ this.backward.dispose();
3176
+ for (const m of Object.values(this.m)) m.dispose();
3177
+ this.mesh.geometry.dispose();
3178
+ }
3179
+ run(material, out) {
3180
+ this.mesh.material = material;
3181
+ this.renderer.setRenderTarget(out);
3182
+ this.renderer.render(this.scene, this.camera);
3183
+ }
3184
+ /** Passes change the renderer's target and clearing; put both back. */
3185
+ withRenderer(body) {
3186
+ const previous = this.renderer.getRenderTarget();
3187
+ const autoClear = this.renderer.autoClear;
3188
+ this.renderer.autoClear = false;
3189
+ try {
3190
+ body();
3191
+ } finally {
3192
+ this.renderer.setRenderTarget(previous);
3193
+ this.renderer.autoClear = autoClear;
3194
+ }
3195
+ }
3196
+ };
3197
+
3198
+ // src/fx/fluid/params.ts
3199
+ var fireFluidDefaults = {
3200
+ fuel: 33.5,
3201
+ premixedOxygen: 0.28,
3202
+ heat: 3.2,
3203
+ smoke: 0.7,
3204
+ // Was 5 — a full unit a second, 210 mm/s, of gas pushed sideways out of the
3205
+ // rim. Against a buoyancy of 1.7 units/s² that is what rolled the flames
3206
+ // into mushroom caps: the grey spirals over the text that the review called
3207
+ // the most synthetic thing in the frame. A flame leaves paper going UP.
3208
+ radialImpulse: 0.8,
3209
+ // In world units a second now (see `solverUniforms`): 1.4 is ~0.3 m/s, the
3210
+ // speed gas leaves burning paper with. It was 0.5 at a quarter scale —
3211
+ // 26 mm/s — and gas held that slow pooled into a bulb at the rim before
3212
+ // buoyancy stretched it into a neck: every flame was a droplet.
3213
+ initialVelocity: [0, 1.4, 0],
3214
+ burnRate: 6.1,
3215
+ // Was 0.65. Expansion is divergence where the gas burns, and it pushes the
3216
+ // gas SIDEWAYS as much as up: at 0.65 each tongue swelled into a puff and
3217
+ // the smoke billowed out over the paper. Your web reference is thin licking
3218
+ // tongues, and swept at 0.2 / 0.65 / 1.3 the low end is the one that looks
3219
+ // like it.
3220
+ gasExpansion: 0.3,
3221
+ // Was 3, when the flame body was authored below paper white and a tongue
3222
+ // needed carrying further to register. Now the gas leaves the rim at its
3223
+ // real speed and glows above paper white, and 3 threw tongues past the
3224
+ // text; 2 holds them lower. (1.5 was no shorter — height is set by how
3225
+ // soon the gas cools, below — only broader and slower.)
3226
+ buoyancy: 2,
3227
+ // Was 0.92. Gas that stays hot all the way up pools into ONE column: the
3228
+ // rim's forty-odd emission points merge a few centimetres above the paper
3229
+ // and the fire reads as a single plume with a couple of licks beside it.
3230
+ // Hero.png is many separate tongues around the whole rim — tall above,
3231
+ // short below — and cooling them faster is what keeps them apart, because
3232
+ // each one runs out of glow before it can merge with its neighbour.
3233
+ //
3234
+ // 1.8 since a flame's light comes from soot that only lives in hot gas:
3235
+ // cooling is now what sets a tongue's HEIGHT. Swept in live play: 1.15
3236
+ // reached the text (~150 mm), 1.6 ~100 mm, 2.1 short licks (~60 mm);
3237
+ // Hero.png's tallest is ~70 mm.
3238
+ cooling: 1.8,
3239
+ // Was 1.4, which made a thick grey column, then 0.5. Noor's direction is
3240
+ // light smoke, and with MacCormack advection keeping the smoke's fine
3241
+ // structure it needs less of it to read: at 0.5 it veiled the upper sheet.
3242
+ smokeProduction: 0.25,
3243
+ ambientOxygen: 0.47,
3244
+ // Was 0.005 — the slider's own floor, which is to say switched off. The
3245
+ // flame channel decayed with a 5 ms time constant, so it was an
3246
+ // instantaneous quantity with no history and therefore no shape.
3247
+ flamePersistence: 0.06,
3248
+ airMixing: 0.5,
3249
+ // Was 0, also switched off, with all the motion coming from vorticity and
3250
+ // the radial impulse — a few big eddies instead of many small ones, which
3251
+ // is the difference between a fire that tears and one that curls. Swept
3252
+ // against the peak frame: at 2 the flames are still a single plume, at 9
3253
+ // they break into separate tongues around the rim.
3254
+ turbulence: 9,
3255
+ // Finer eddies with it: 6.8 was ~24 mm, 12 is ~14 mm, and the smoke stops
3256
+ // reading as a few big hooks.
3257
+ turbulenceScale: 12,
3258
+ // Was 6.1. Vorticity confinement gives back the swirls the grid smooths
3259
+ // away; at 6 it was manufacturing swirls the flow never had.
3260
+ vorticity: 3,
3261
+ wind: 0.8,
3262
+ // Was 4.2 s. Smoke that lingers that long is stretched by the flow into
3263
+ // long grey veins, and sharper advection keeps every one of them — so the
3264
+ // whole upper sheet and the black stage behind it came out MARBLED. Real
3265
+ // paper smoke thins out within a second or two. Swept in live play against
3266
+ // the default: 1.5 s is thin threads and a clean sheet.
3267
+ smokeFade: 1.5,
3268
+ smokeAfter: 4
3269
+ };
3270
+ var fireFluidControls = [
3271
+ { group: "Emission", key: "fuel", label: "Fuel", min: 0, max: 50, step: 0.1 },
3272
+ { group: "Emission", key: "premixedOxygen", label: "Premixed oxygen", min: 0, max: 1, step: 0.01 },
3273
+ { group: "Emission", key: "heat", label: "Heat", min: 0, max: 5, step: 0.1 },
3274
+ { group: "Emission", key: "smoke", label: "Smoke", min: 0, max: 2, step: 0.05 },
3275
+ { group: "Emission", key: "radialImpulse", label: "Radial impulse", min: 0, max: 5, step: 0.1 },
3276
+ { group: "Combustion", key: "burnRate", label: "Burn rate", min: 0, max: 10, step: 0.1 },
3277
+ { group: "Combustion", key: "gasExpansion", label: "Gas expansion", min: 0, max: 3, step: 0.05 },
3278
+ { group: "Combustion", key: "buoyancy", label: "Buoyancy", min: 0, max: 6, step: 0.1 },
3279
+ { group: "Combustion", key: "cooling", label: "Cooling", min: 0, max: 3, step: 0.01 },
3280
+ { group: "Combustion", key: "smokeProduction", label: "Smoke production", min: 0, max: 4, step: 0.05 },
3281
+ { group: "Fuel & air", key: "ambientOxygen", label: "Ambient oxygen", min: 0, max: 1, step: 0.01 },
3282
+ {
3283
+ group: "Fuel & air",
3284
+ key: "flamePersistence",
3285
+ label: "Flame persistence",
3286
+ min: 5e-3,
3287
+ max: 1,
3288
+ step: 5e-3
3289
+ },
3290
+ { group: "Fuel & air", key: "airMixing", label: "Air mixing", min: 0, max: 1, step: 0.01 },
3291
+ // 0–10 while the default was 0. Now that turbulence is what breaks the
3292
+ // flames into tongues, the tuned value has to sit somewhere a slider can
3293
+ // move in BOTH directions — see the test.
3294
+ { group: "Motion & turbulence", key: "turbulence", label: "Turbulence", min: 0, max: 20, step: 0.1 },
3295
+ {
3296
+ group: "Motion & turbulence",
3297
+ key: "turbulenceScale",
3298
+ label: "Turbulence scale",
3299
+ min: 0.5,
3300
+ max: 24,
3301
+ step: 0.1
3302
+ },
3303
+ { group: "Motion & turbulence", key: "vorticity", label: "Vorticity", min: 0, max: 8, step: 0.1 },
3304
+ { group: "Motion & turbulence", key: "wind", label: "Wind", min: -3, max: 3, step: 0.05 },
3305
+ { group: "Look", key: "smokeFade", label: "Smoke lingers (s)", min: 0.2, max: 10, step: 0.1 },
3306
+ { group: "Look", key: "smokeAfter", label: "Smoke after the flames (s)", min: 0, max: 12, step: 0.1 }
3307
+ ];
3308
+ function solverUniforms(p) {
3309
+ const finite = (x, fallback) => Number.isFinite(x) ? x : fallback;
3310
+ return {
3311
+ // Gas per second per unit of source strength.
3312
+ fuel: Math.max(0, finite(p.fuel, 0)) * 0.12,
3313
+ premixed: Math.min(1, Math.max(0, finite(p.premixedOxygen, 0))),
3314
+ // Temperature released with the gas, per second. At 1.8 the gas at the
3315
+ // source settled near a third of glowing — cooling took the heat faster
3316
+ // than the rim gave it — and the fire was a faint blush, not a flame.
3317
+ heat: Math.max(0, finite(p.heat, 0)) * 7,
3318
+ smoke: Math.max(0, finite(p.smoke, 0)) * 0.45,
3319
+ // World units a second: A4 flames climb a few tens of centimetres a second.
3320
+ radial: Math.max(0, finite(p.radialImpulse, 0)) * 0.2,
3321
+ // World units a second, one to one — a unit is 210 mm.
3322
+ initialVelocity: [finite(p.initialVelocity[0], 0), finite(p.initialVelocity[1], 0)],
3323
+ // Per second.
3324
+ burnRate: Math.max(0, finite(p.burnRate, 0)) * 2,
3325
+ // Temperature a unit of burnt fuel adds.
3326
+ heatRelease: 3,
3327
+ // Divergence per unit of burning a second — the gas expanding as it burns.
3328
+ expansion: Math.max(0, finite(p.gasExpansion, 0)) * 1.5,
3329
+ // World units a second squared per unit of temperature.
3330
+ buoyancy: Math.max(0, finite(p.buoyancy, 0)) * 0.9,
3331
+ // Per second: a flame at A4 scale has cooled out of sight 10–40 mm up.
3332
+ cooling: Math.max(0, finite(p.cooling, 0)) * 6,
3333
+ smokeProduction: Math.max(0, finite(p.smokeProduction, 0)) * 0.3,
3334
+ smokeFade: Math.max(0.05, finite(p.smokeFade, 2.5)),
3335
+ ambient: Math.min(1, Math.max(0, finite(p.ambientOxygen, 0))),
3336
+ persistence: Math.max(5e-3, finite(p.flamePersistence, 0.085)),
3337
+ mixing: Math.min(1, Math.max(0, finite(p.airMixing, 0.5))) * 0.5,
3338
+ entrain: Math.min(1, Math.max(0, finite(p.airMixing, 0.5))) * 40,
3339
+ fuelBlock: 0.05,
3340
+ sootYield: 30,
3341
+ sootHeat: 2,
3342
+ // Paper's gas takes several times its own mass of air to burn; what
3343
+ // matters here is only that it is well above one, so fuel near the rim is
3344
+ // denser than the air that can reach it and burns from the outside in.
3345
+ stoich: 4,
3346
+ // Several times the rim's own smoke per unit strength: a smouldering spot
3347
+ // has no flame to lift its smoke fast, so it pools and rises slowly, and
3348
+ // at 1.5× it read 0.03 at the 99th percentile — about 3% opacity, gone —
3349
+ // at 8× 0.07, and at 30× thin wisps you had to look for.
3350
+ smoulderSmoke: Math.max(0, finite(p.smoke, 0)) * 0.45 * 80,
3351
+ smoulderHeat: 1.2,
3352
+ turbulence: Math.max(0, finite(p.turbulence, 0)) * 0.35,
3353
+ // Noise frequency per WORLD unit — the pass multiplies by the domain's
3354
+ // size, which it did not before (it read UV, and the domain is two units
3355
+ // tall, so every eddy was half the size this said). 12 → ~14 mm eddies.
3356
+ turbulenceScale: Math.max(0.1, finite(p.turbulenceScale, 4.6)) * 1.25,
3357
+ turbulenceEvolve: 3,
3358
+ vorticity: Math.max(0, finite(p.vorticity, 0)),
3359
+ wind: finite(p.wind, 0) * 0.3
3360
+ };
3361
+ }
3362
+
3363
+ // src/fx/FxFireFluid.tsx
3364
+ init_quality();
3365
+ var import_jsx_runtime6 = require("react/jsx-runtime");
3366
+ var DOMAIN = { width: 1.5, height: 2 };
3367
+ var BELOW = 0.8;
3368
+ var STEP = 1 / 120;
3369
+ var WARM_STEP = 1 / 60;
3370
+ var MAX_STEPS = 6;
3371
+ var BAND = 1.2 / 210;
3372
+ var SMOULDER_SLOTS = 16;
3373
+ var SMOULDER_AFTER = 1;
3374
+ var WARM = 3;
3375
+ var up = new THREE7.Vector3(0, 1, 0);
3376
+ function FxFireFluid({
3377
+ field,
3378
+ locate,
3379
+ quality = "medium",
3380
+ params,
3381
+ running = true,
3382
+ resetKey,
3383
+ glow = 1,
3384
+ zones,
3385
+ heatScale = FIRE_HEAT_SCALE,
3386
+ sootScale = FIRE_SOOT_SCALE,
3387
+ contrast = FIRE_CONTRAST,
3388
+ opacity = FIRE_OPACITY,
3389
+ thin = FIRE_THIN,
3390
+ warm = WARM,
3391
+ sharp,
3392
+ fallback
3393
+ }) {
3394
+ const gl = (0, import_fiber5.useThree)((s) => s.gl);
3395
+ const camera = (0, import_fiber5.useThree)((s) => s.camera);
3396
+ const grid = fxQualityFor(quality).fluid;
3397
+ const fluid = (0, import_react6.useMemo)(() => FireFluid.supported(gl) ? new FireFluid(gl, grid, DOMAIN) : null, [gl, grid]);
3398
+ (0, import_react6.useEffect)(() => () => fluid?.dispose(), [fluid]);
3399
+ const merged = (0, import_react6.useMemo)(() => ({ ...fireFluidDefaults, ...params }), [params]);
3400
+ const material = (0, import_react6.useMemo)(
3401
+ () => new THREE7.ShaderMaterial({
3402
+ vertexShader: RENDER_VERTEX,
3403
+ fragmentShader: RENDER_FRAGMENT,
3404
+ uniforms: {
3405
+ uA: { value: null },
3406
+ uB: { value: null },
3407
+ uTime: { value: 0 },
3408
+ uGlow: { value: 1 },
3409
+ // Thin: smoke over a clear background, not a veil across it.
3410
+ uSmokeDensity: { value: 0.9 },
3411
+ // How bright the fire is, in the one unit `emission.ts` defines.
3412
+ uPaperWhite: { value: PAPER_WHITE },
3413
+ uHeatScale: { value: FIRE_HEAT_SCALE },
3414
+ uSootScale: { value: FIRE_SOOT_SCALE },
3415
+ uContrast: { value: FIRE_CONTRAST },
3416
+ uOpacity: { value: FIRE_OPACITY },
3417
+ uThin: { value: FIRE_THIN },
3418
+ // The four zones (FireZones). Colours linear, glows in multiples of
3419
+ // paper white, boundaries in fractions of the hottest gas.
3420
+ uTipColor: { value: new THREE7.Vector3() },
3421
+ uBodyColor: { value: new THREE7.Vector3() },
3422
+ uCoreColor: { value: new THREE7.Vector3() },
3423
+ uRootColor: { value: new THREE7.Vector3() },
3424
+ uTipGlow: { value: 0 },
3425
+ uBodyGlow: { value: 0 },
3426
+ uCoreGlow: { value: 0 },
3427
+ uTipFrom: { value: 0 },
3428
+ uTipTo: { value: 0 },
3429
+ uCoreFrom: { value: 0 },
3430
+ uSoftness: { value: 0 },
3431
+ uTearing: { value: 0 },
3432
+ uRootAmount: { value: 0 },
3433
+ uRootReach: { value: 0 }
3434
+ },
3435
+ transparent: true,
3436
+ depthWrite: false,
3437
+ // The fire is always in front of the paper it rises from. A sheet that
3438
+ // drapes or curls crosses the plane in places, and a depth test cut
3439
+ // the fire off there in a hard vertical seam.
3440
+ depthTest: false,
3441
+ // Premultiplied: fire adds light, smoke covers what is behind it.
3442
+ blending: THREE7.CustomBlending,
3443
+ blendSrc: THREE7.OneFactor,
3444
+ blendDst: THREE7.OneMinusSrcAlphaFactor,
3445
+ side: THREE7.DoubleSide
3446
+ }),
3447
+ []
3448
+ );
3449
+ (0, import_react6.useEffect)(() => () => material.dispose(), [material]);
3450
+ const plane = (0, import_react6.useMemo)(() => new THREE7.PlaneGeometry(DOMAIN.width, DOMAIN.height), []);
3451
+ (0, import_react6.useEffect)(() => () => plane.dispose(), [plane]);
3452
+ const mesh = (0, import_react6.useRef)(null);
3453
+ const state = (0, import_react6.useRef)({
3454
+ key: /* @__PURE__ */ Symbol("unset"),
3455
+ time: 0,
3456
+ owed: 0,
3457
+ /** The fire's clock when the rim last released anything — flame or smoulder. */
3458
+ lastGas: Number.NEGATIVE_INFINITY,
3459
+ origin: new THREE7.Vector3(),
3460
+ right: new THREE7.Vector3(1, 0, 0),
3461
+ normal: new THREE7.Vector3(0, 0, 1)
3462
+ });
3463
+ const anchors = (0, import_react6.useRef)([]);
3464
+ const sources = (0, import_react6.useMemo)(() => new Float32Array(MAX_SOURCES * 4), []);
3465
+ const across = (0, import_react6.useMemo)(() => new Float32Array(MAX_SOURCES * 4), []);
3466
+ const smoulder = (0, import_react6.useMemo)(
3467
+ () => Array.from({ length: SMOULDER_SLOTS }, () => ({
3468
+ seen: Number.NEGATIVE_INFINITY,
3469
+ source: new Float32Array(4),
3470
+ across: new Float32Array(4)
3471
+ })),
3472
+ []
3473
+ );
3474
+ const scratch = (0, import_react6.useMemo)(() => new THREE7.Vector3(), []);
3475
+ const place = () => {
3476
+ const s = state.current;
3477
+ const centre = locate(0.5, 0.5);
3478
+ const c = new THREE7.Vector3(centre?.x ?? 0, centre?.y ?? 0, centre?.z ?? 0);
3479
+ s.normal.set(camera.position.x - c.x, 0, camera.position.z - c.z);
3480
+ if (s.normal.lengthSq() < 1e-8) s.normal.set(0, 0, 1);
3481
+ s.normal.normalize();
3482
+ s.right.crossVectors(up, s.normal).normalize();
3483
+ s.origin.copy(c).addScaledVector(s.right, -DOMAIN.width / 2).addScaledVector(up, -BELOW);
3484
+ const m = mesh.current;
3485
+ if (m) {
3486
+ m.position.copy(s.origin).addScaledVector(s.right, DOMAIN.width / 2).addScaledVector(up, DOMAIN.height / 2);
3487
+ m.position.addScaledVector(s.normal, 0.03);
3488
+ m.quaternion.setFromRotationMatrix(new THREE7.Matrix4().makeBasis(s.right, up, s.normal));
3489
+ }
3490
+ };
3491
+ const gather = (time) => {
3492
+ const s = state.current;
3493
+ const room = MAX_SOURCES - SMOULDER_SLOTS;
3494
+ const n = Math.min(room, flameAnchors(field, locate, room, anchors.current, time));
3495
+ const band = BAND / DOMAIN.height;
3496
+ for (let i = 0; i < n; i++) {
3497
+ const a = anchors.current[i];
3498
+ scratch.set(a.x, a.y, a.z).sub(s.origin);
3499
+ const k = i * 4;
3500
+ sources[k] = scratch.dot(s.right) / DOMAIN.width;
3501
+ sources[k + 1] = scratch.dot(up) / DOMAIN.height;
3502
+ const nu = a.nx * s.right.x + a.ny * s.right.y + a.nz * s.right.z;
3503
+ const nv = a.ny;
3504
+ const nl = Math.hypot(nu, nv);
3505
+ let area;
3506
+ if (nl > 0.25) {
3507
+ const half = Math.max(1.5 / 210, a.width * 0.5) / DOMAIN.height;
3508
+ sources[k + 2] = half;
3509
+ across[k] = nu / nl;
3510
+ across[k + 1] = nv / nl;
3511
+ across[k + 2] = band;
3512
+ across[k + 3] = band;
3513
+ area = half * band;
3514
+ } else {
3515
+ sources[k + 2] = band * 2;
3516
+ across[k] = 0;
3517
+ across[k + 1] = 1;
3518
+ across[k + 2] = band * 2;
3519
+ across[k + 3] = 0;
3520
+ area = band * band * 4;
3521
+ }
3522
+ const disc = Math.max(25e-4, a.width * 0.6 / DOMAIN.height);
3523
+ const tallest = FLAME_HEIGHT[1] * 0.47 * 0.6 / DOMAIN.height;
3524
+ sources[k + 3] = a.height / FLAME_HEIGHT[1] * (0.5 + 0.5 * a.heat) * Math.min(30, disc * tallest / area);
3525
+ const slot = smoulder[Math.floor(a.seed * SMOULDER_SLOTS) % SMOULDER_SLOTS];
3526
+ slot.seen = time;
3527
+ slot.source.set(sources.subarray(k, k + 4));
3528
+ slot.across.set(across.subarray(k, k + 4));
3529
+ }
3530
+ const after = Math.max(0, merged.smokeAfter ?? 0);
3531
+ let count = n;
3532
+ for (const slot of smoulder) {
3533
+ const since = time - slot.seen;
3534
+ if (!(since > SMOULDER_AFTER) || after <= 0 || since > after * 3) continue;
3535
+ const k = count * 4;
3536
+ sources.set(slot.source, k);
3537
+ across.set(slot.across, k);
3538
+ const onset = Math.min(1, (since - SMOULDER_AFTER) / 0.6);
3539
+ sources[k + 3] = -slot.source[3] * onset * Math.exp(-since / after);
3540
+ count++;
3541
+ }
3542
+ return count;
3543
+ };
3544
+ (0, import_fiber5.useFrame)((_, delta) => {
3545
+ if (!fluid) return;
3546
+ const s = state.current;
3547
+ const u = solverUniforms(merged);
3548
+ fluid.sharp = sharp ?? quality !== "low";
3549
+ if (s.key !== resetKey) {
3550
+ s.key = resetKey;
3551
+ place();
3552
+ fluid.reset(u.ambient);
3553
+ for (const slot of smoulder) slot.seen = Number.NEGATIVE_INFINITY;
3554
+ const end = field.time;
3555
+ if (field.frontCount > 0) {
3556
+ const steps = Math.round(warm / WARM_STEP);
3557
+ for (let k = 0; k < steps; k++) {
3558
+ const t = end - warm + k * WARM_STEP;
3559
+ fluid.step(WARM_STEP, u, sources, across, gather(t), t);
3560
+ }
3561
+ s.lastGas = end;
3562
+ } else {
3563
+ s.lastGas = Number.NEGATIVE_INFINITY;
3564
+ }
3565
+ s.time = end;
3566
+ s.owed = 0;
3567
+ }
3568
+ const idle = field.frontCount === 0 && s.time - s.lastGas > Math.max(2, merged.smokeFade * 5);
3569
+ const m = mesh.current;
3570
+ if (m) m.visible = !idle;
3571
+ if (running && idle) {
3572
+ s.owed = 0;
3573
+ s.time += Math.min(0.1, Math.max(0, delta));
3574
+ } else if (running) {
3575
+ s.owed += Math.min(0.1, Math.max(0, delta));
3576
+ let taken = 0;
3577
+ while (s.owed >= STEP && taken < MAX_STEPS) {
3578
+ s.owed -= STEP;
3579
+ s.time += STEP;
3580
+ const count = gather(field.time);
3581
+ fluid.step(STEP, u, sources, across, count, s.time);
3582
+ if (count > 0) s.lastGas = s.time;
3583
+ taken++;
3584
+ }
3585
+ }
3586
+ material.uniforms.uA.value = fluid.scalars.read.texture;
3587
+ material.uniforms.uB.value = fluid.air.read.texture;
3588
+ material.uniforms.uTime.value = s.time;
3589
+ material.uniforms.uGlow.value = glow;
3590
+ material.uniforms.uHeatScale.value = heatScale;
3591
+ material.uniforms.uSootScale.value = sootScale;
3592
+ material.uniforms.uContrast.value = contrast;
3593
+ material.uniforms.uOpacity.value = opacity;
3594
+ material.uniforms.uThin.value = thin;
3595
+ const z = fireZones(zones);
3596
+ const mu = material.uniforms;
3597
+ mu.uTipColor.value.fromArray(hexToLinear(z.tip.color));
3598
+ mu.uBodyColor.value.fromArray(hexToLinear(z.body.color));
3599
+ mu.uCoreColor.value.fromArray(hexToLinear(z.core.color));
3600
+ mu.uRootColor.value.fromArray(hexToLinear(z.root.color));
3601
+ mu.uTipGlow.value = z.tip.glow;
3602
+ mu.uBodyGlow.value = z.body.glow;
3603
+ mu.uCoreGlow.value = z.core.glow;
3604
+ mu.uTipFrom.value = z.tip.from;
3605
+ mu.uTipTo.value = z.tip.to;
3606
+ mu.uCoreFrom.value = z.core.from;
3607
+ mu.uSoftness.value = z.tip.softness;
3608
+ mu.uTearing.value = z.tip.tearing;
3609
+ mu.uRootAmount.value = z.root.amount;
3610
+ mu.uRootReach.value = z.root.reach;
3611
+ });
3612
+ if (!fluid) return /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(import_jsx_runtime6.Fragment, { children: fallback ?? null });
3613
+ return /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("mesh", { ref: mesh, geometry: plane, material, frustumCulled: false, renderOrder: 2 });
3614
+ }
3615
+
3616
+ // src/fx/FxFireLight.tsx
3617
+ var THREE8 = __toESM(require("three"), 1);
3618
+ var import_fiber6 = require("@react-three/fiber");
3619
+ var import_react7 = require("react");
3620
+ init_flames();
3621
+ var import_jsx_runtime7 = require("react/jsx-runtime");
3622
+ function FxFireLight({ field, locate, gain = FIRE_LIGHT_GAIN }) {
3623
+ const front = (0, import_react7.useRef)(null);
3624
+ const through = (0, import_react7.useRef)(null);
3625
+ const anchors = (0, import_react7.useRef)([]);
3626
+ (0, import_fiber6.useFrame)(({ camera }) => {
3627
+ const a = front.current;
3628
+ const b = through.current;
3629
+ if (!a || !b) return;
3630
+ const n = flameAnchors(field, locate, 32, anchors.current);
3631
+ if (n === 0) {
3632
+ a.intensity = 0;
3633
+ b.intensity = 0;
3634
+ return;
3635
+ }
3636
+ let x = 0;
3637
+ let y = 0;
3638
+ let z = 0;
3639
+ let h = 0;
3640
+ let flicker = 0;
3641
+ for (let i = 0; i < n; i++) {
3642
+ const f = anchors.current[i];
3643
+ x += f.x;
3644
+ y += f.y;
3645
+ z += f.z;
3646
+ h += f.height;
3647
+ flicker += flamePuff(f.seed, field.time);
3648
+ }
3649
+ x /= n;
3650
+ y /= n;
3651
+ z /= n;
3652
+ h /= n;
3653
+ flicker /= n;
3654
+ const toCamera = new THREE8.Vector3(camera.position.x - x, 0, camera.position.z - z);
3655
+ toCamera.normalize().multiplyScalar(0.035);
3656
+ const lift = h * 0.5;
3657
+ a.position.set(x + toCamera.x, y + lift, z + toCamera.z);
3658
+ b.position.set(x - toCamera.x, y + lift, z - toCamera.z);
3659
+ const level = field.lastStats.front * gain * flicker;
3660
+ a.intensity = level;
3661
+ b.intensity = level * 0.35;
3662
+ });
3663
+ return /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)(import_jsx_runtime7.Fragment, { children: [
3664
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("pointLight", { ref: front, color: FIRE_COLOR, intensity: 0, decay: 0.9, distance: 0 }),
3665
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("pointLight", { ref: through, color: FIRE_COLOR, intensity: 0, decay: 0.9, distance: 0 })
3666
+ ] });
3667
+ }
3668
+ var FIRE_COLOR = new THREE8.Color("#ffa24c");
3669
+ var FIRE_LIGHT_GAIN = 42;
3670
+
3671
+ // src/fx/FxMatchFlame.tsx
3672
+ var THREE9 = __toESM(require("three"), 1);
3673
+ var import_fiber7 = require("@react-three/fiber");
3674
+ var import_react8 = require("react");
3675
+ var import_jsx_runtime8 = require("react/jsx-runtime");
3676
+ var HEIGHT = 20 / 210;
3677
+ var FLARE = 0.42;
3678
+ var FLARE_GAIN = 1;
3679
+ var ARM = 0.32;
3680
+ function FxMatchFlame({ match }) {
3681
+ const geometry = (0, import_react8.useMemo)(() => flameGeometry(1), []);
3682
+ const material = (0, import_react8.useMemo)(() => flameMaterial(), []);
3683
+ const pool = (0, import_react8.useMemo)(() => new ParticlePool(160, 3), []);
3684
+ (0, import_react8.useEffect)(
3685
+ () => () => {
3686
+ geometry.dispose();
3687
+ material.dispose();
3688
+ },
3689
+ [geometry, material]
3690
+ );
3691
+ const light = (0, import_react8.useRef)(null);
3692
+ const memory = (0, import_react8.useRef)({
3693
+ time: 0,
3694
+ last: "none",
3695
+ armedAt: 0,
3696
+ flare: 0,
3697
+ flash: 0,
3698
+ spark: 0,
3699
+ was: new THREE9.Vector3(),
3700
+ velocity: new THREE9.Vector3(),
3701
+ lean: new THREE9.Vector3(),
3702
+ at: new THREE9.Vector3(),
3703
+ hasAt: false
3704
+ });
3705
+ (0, import_fiber7.useFrame)((_, delta) => {
3706
+ const now = match.current;
3707
+ const m = memory.current;
3708
+ const scripted = now.time !== void 0;
3709
+ const dt = scripted ? Math.max(0, now.time - m.time) : Math.min(0.1, Math.max(0, delta));
3710
+ m.time = scripted ? now.time : m.time + dt;
3711
+ if (now.position) {
3712
+ m.at.set(now.position.x, now.position.y, now.position.z);
3713
+ if (!m.hasAt) m.was.copy(m.at);
3714
+ m.hasAt = true;
3715
+ }
3716
+ if (!scripted && now.state === "lit" && m.last !== "lit") {
3717
+ m.flare = FLARE;
3718
+ m.flash = 0.06;
3719
+ for (let i = 0; i < 16; i++) pool.spawn("ember", m.at.x, m.at.y + HEIGHT * 0.2, m.at.z);
3720
+ for (let i = 0; i < 3; i++) pool.spawn("smoke", m.at.x, m.at.y + HEIGHT * 0.5, m.at.z);
3721
+ }
3722
+ if (!scripted && now.state !== "lit" && m.last === "lit" && m.hasAt) {
3723
+ for (let i = 0; i < 5; i++) pool.spawn("smoke", m.at.x, m.at.y + HEIGHT * 0.6, m.at.z);
3724
+ }
3725
+ if (now.state === "arming" && m.last !== "arming") m.armedAt = m.time;
3726
+ m.last = now.state;
3727
+ if (dt > 0 && m.hasAt) {
3728
+ const v = m.at.clone().sub(m.was).divideScalar(dt);
3729
+ m.velocity.lerp(v, Math.min(1, dt / 0.08));
3730
+ m.was.copy(m.at);
3731
+ }
3732
+ const speed = m.velocity.length();
3733
+ const target2 = m.velocity.clone().multiplyScalar(-0.35);
3734
+ target2.z -= now.blow * 1.2;
3735
+ m.lean.lerp(target2, Math.min(1, dt / 0.15));
3736
+ m.flare = scripted ? Math.max(0, FLARE - (m.time - (now.litAt ?? -FLARE))) : Math.max(0, m.flare - dt);
3737
+ m.flash = scripted ? 0 : Math.max(0, m.flash - dt);
3738
+ const base = geometry.getAttribute("aBase");
3739
+ const shape = geometry.getAttribute("aShape");
3740
+ let height = 0;
3741
+ let width = 0;
3742
+ let gain = 1;
3743
+ let lightLevel = 0;
3744
+ if (now.state === "arming" && m.hasAt) {
3745
+ const p = Math.min(1, (m.time - m.armedAt) / ARM);
3746
+ height = HEIGHT * 0.18 * p;
3747
+ width = height * 0.5;
3748
+ gain = 0.5 + p;
3749
+ lightLevel = 0.012 * p;
3750
+ m.spark -= dt;
3751
+ if (!scripted && m.spark <= 0) {
3752
+ m.spark = 0.07;
3753
+ pool.spawn("ember", m.at.x, m.at.y, m.at.z);
3754
+ }
3755
+ } else if (now.state === "lit" && m.hasAt) {
3756
+ const flare = 1 + FLARE_GAIN * (m.flare / FLARE) ** 2;
3757
+ const stretch = 1 + Math.min(1.2, speed * 0.9);
3758
+ height = HEIGHT * flare * stretch * (now.touching ? 0.6 : 1);
3759
+ width = HEIGHT * 0.32 * (now.touching ? 1.6 : 1) / Math.sqrt(stretch);
3760
+ gain = 1 - now.blow * 0.45 * (0.5 + 0.5 * Math.sin(m.time * 61));
3761
+ lightLevel = 0.035 * flare + (m.flash > 0 ? 0.25 : 0);
3762
+ }
3763
+ material.uniforms.uBlue.value = Math.min(1, Math.max(0, speed * 0.7 - 0.35));
3764
+ material.uniforms.uGain.value = gain;
3765
+ material.uniforms.uTime.value = m.time;
3766
+ material.uniforms.uLean.value.copy(m.lean);
3767
+ base.array[0] = m.at.x;
3768
+ base.array[1] = m.at.y;
3769
+ base.array[2] = m.at.z;
3770
+ shape.array[0] = height;
3771
+ shape.array[1] = width;
3772
+ shape.array[2] = 0.37;
3773
+ shape.array[3] = 1;
3774
+ base.needsUpdate = true;
3775
+ shape.needsUpdate = true;
3776
+ geometry.instanceCount = height > 0 ? 1 : 0;
3777
+ if (light.current) {
3778
+ light.current.position.set(m.at.x, m.at.y + height * 0.5, m.at.z + 0.06);
3779
+ light.current.intensity = lightLevel;
3780
+ }
3781
+ pool.step(dt);
3782
+ });
3783
+ return /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)(import_jsx_runtime8.Fragment, { children: [
3784
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("mesh", { geometry, material, frustumCulled: false, renderOrder: 3 }),
3785
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("pointLight", { ref: light, color: "#ffb066", intensity: 0, decay: 1.6, distance: 0 }),
3786
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(FxParticles, { pool })
3787
+ ] });
3788
+ }
3789
+
3790
+ // src/fx.ts
3791
+ init_flames();
3792
+
3793
+ // src/fx/sfx/graph.ts
3794
+ init_quality();
3795
+ var STEAL_FADE = 0.012;
3796
+ var FxAudio = class {
3797
+ quality;
3798
+ ctx;
3799
+ master;
3800
+ live = [];
3801
+ /**
3802
+ * What each voice owns, until the last of it has actually stopped.
3803
+ *
3804
+ * Outlives the voice's place in `live` on purpose: a stolen voice leaves the
3805
+ * pool at once, so the new sound can have its slot, but its sources are
3806
+ * still fading for a few milliseconds and its gain is still connected. The
3807
+ * nodes are freed when the last source reports `ended`, not before — cut
3808
+ * sooner and the fade that stops stealing from clicking is cut with it.
3809
+ */
3810
+ slots = /* @__PURE__ */ new Map();
3811
+ nextId = 1;
3812
+ unlocked = false;
3813
+ noise = null;
3814
+ constructor(options) {
3815
+ const { quality = "auto", context, volume = 0.8 } = options;
3816
+ this.quality = typeof quality === "string" ? fxQualityFor(quality) : quality;
3817
+ if (!context) throw new Error("FxAudio needs a context \u2014 see createAudioContext()");
3818
+ this.ctx = context;
3819
+ this.master = this.ctx.createGain();
3820
+ this.master.gain.value = volume;
3821
+ this.master.connect(this.ctx.destination);
3822
+ }
3823
+ get context() {
3824
+ return this.ctx;
3825
+ }
3826
+ /** Live voices right now. Never more than the tier's ceiling. */
3827
+ get voices() {
3828
+ return this.live;
3829
+ }
3830
+ get isUnlocked() {
3831
+ return this.unlocked;
3832
+ }
3833
+ get volume() {
3834
+ return this.master.gain.value;
3835
+ }
3836
+ set volume(value) {
3837
+ this.master.gain.value = Math.min(1, Math.max(0, value));
3838
+ }
3839
+ /**
3840
+ * Let sound happen, from inside a user gesture.
3841
+ *
3842
+ * Every browser starts an `AudioContext` suspended until a real interaction
3843
+ * resumes it, and a page that calls this from anywhere else is a page whose
3844
+ * audio silently never starts. Here the cost is zero: `/hands` already has
3845
+ * a button that turns the camera on, and nothing can be heard before the
3846
+ * camera is on anyway.
3847
+ *
3848
+ * Safe to call more than once — it is a latch, not a toggle.
3849
+ */
3850
+ async unlock() {
3851
+ if (this.unlocked) return;
3852
+ if (this.ctx.state === "suspended") await this.ctx.resume();
3853
+ this.unlocked = this.ctx.state === "running";
3854
+ }
3855
+ /**
3856
+ * One second of white noise, made once and shared.
3857
+ *
3858
+ * Nearly every paper sound is filtered noise — a crumple is a cloud of
3859
+ * short bursts of it, a fire is a bed of it, a tear is a fast train of it —
3860
+ * so this is the single most reused object in the graph. Generating it per
3861
+ * voice would allocate a second of audio per crackle.
3862
+ */
3863
+ noiseBuffer() {
3864
+ if (this.noise) return this.noise;
3865
+ const length = Math.floor(this.ctx.sampleRate);
3866
+ const buffer = this.ctx.createBuffer(1, length, this.ctx.sampleRate);
3867
+ const data = buffer.getChannelData(0);
3868
+ let seed = 2654435769;
3869
+ for (let i = 0; i < length; i++) {
3870
+ seed = Math.imul(seed, 1664525) + 1013904223 | 0;
3871
+ data[i] = (seed >>> 0) / 2147483648 - 1;
3872
+ }
3873
+ this.noise = buffer;
3874
+ return buffer;
3875
+ }
3876
+ /**
3877
+ * Take a voice, stealing the least deserving one if the pool is full.
3878
+ *
3879
+ * Returns null only when the ceiling is zero or the caller's priority is
3880
+ * below everything already playing — the one case where refusing is right,
3881
+ * because the alternative is cutting off something more important to play
3882
+ * something less.
3883
+ */
3884
+ take(kind, priority = 0.5) {
3885
+ if (this.quality.voices <= 0) return null;
3886
+ if (this.live.length >= this.quality.voices) {
3887
+ let weakest = 0;
3888
+ for (let i = 1; i < this.live.length; i++) {
3889
+ const a = this.live[i];
3890
+ const b = this.live[weakest];
3891
+ if (a.priority < b.priority || a.priority === b.priority && a.startedAt < b.startedAt) {
3892
+ weakest = i;
3893
+ }
3894
+ }
3895
+ const victim = this.live[weakest];
3896
+ if (victim.priority > priority) return null;
3897
+ victim.stop();
3898
+ }
3899
+ const gain = this.ctx.createGain();
3900
+ gain.connect(this.master);
3901
+ const id = this.nextId++;
3902
+ this.slots.set(id, { gain, sources: [], nodes: [] });
3903
+ const voice = {
3904
+ id,
3905
+ kind,
3906
+ priority,
3907
+ gain,
3908
+ startedAt: this.ctx.currentTime,
3909
+ own: (source) => this.own(id, source),
3910
+ use: (node) => this.useNode(id, node),
3911
+ stop: () => this.release(id)
3912
+ };
3913
+ this.live.push(voice);
3914
+ return voice;
3915
+ }
3916
+ own(id, source) {
3917
+ const slot = this.slots.get(id);
3918
+ const alive = this.live.some((v) => v.id === id);
3919
+ if (!slot || !alive) {
3920
+ stopNow(source);
3921
+ source.disconnect();
3922
+ return;
3923
+ }
3924
+ slot.sources.push(source);
3925
+ source.onended = () => this.ended(id, source);
3926
+ }
3927
+ useNode(id, node) {
3928
+ const slot = this.slots.get(id);
3929
+ const alive = this.live.some((v) => v.id === id);
3930
+ if (!slot || !alive) {
3931
+ node.disconnect();
3932
+ return;
3933
+ }
3934
+ slot.nodes.push(node);
3935
+ }
3936
+ ended(id, source) {
3937
+ source.disconnect();
3938
+ const slot = this.slots.get(id);
3939
+ if (!slot) return;
3940
+ const at = slot.sources.indexOf(source);
3941
+ if (at >= 0) slot.sources.splice(at, 1);
3942
+ if (slot.sources.length > 0) return;
3943
+ if (this.live.some((v) => v.id === id)) this.release(id);
3944
+ else this.free(id);
3945
+ }
3946
+ /** Disconnect a voice's gain. Only once nothing that feeds it is still running. */
3947
+ free(id) {
3948
+ const slot = this.slots.get(id);
3949
+ if (!slot) return;
3950
+ for (const node of slot.nodes) node.disconnect();
3951
+ slot.gain.disconnect();
3952
+ this.slots.delete(id);
3953
+ }
3954
+ /**
3955
+ * Return a voice to the pool, fading it out first.
3956
+ *
3957
+ * The fade is the whole reason this is not just a splice. Cutting a
3958
+ * waveform mid-cycle produces a step, and a step is a click — audible,
3959
+ * cheap-sounding, and most likely to happen exactly when the most is going
3960
+ * on, since that is when voices get stolen.
3961
+ */
3962
+ release(id) {
3963
+ const index = this.live.findIndex((v) => v.id === id);
3964
+ if (index < 0) return;
3965
+ const [voice] = this.live.splice(index, 1);
3966
+ if (!voice) return;
3967
+ const slot = this.slots.get(id);
3968
+ if (!slot || slot.sources.length === 0) {
3969
+ this.free(id);
3970
+ return;
3971
+ }
3972
+ const now = this.ctx.currentTime;
3973
+ voice.gain.gain.cancelScheduledValues(now);
3974
+ voice.gain.gain.setValueAtTime(voice.gain.gain.value, now);
3975
+ voice.gain.gain.linearRampToValueAtTime(0, now + STEAL_FADE);
3976
+ for (const source of [...slot.sources]) {
3977
+ try {
3978
+ source.stop(now + STEAL_FADE);
3979
+ } catch {
3980
+ this.ended(id, source);
3981
+ }
3982
+ }
3983
+ }
3984
+ /** Stop everything. The panic button, and what unmounting calls. */
3985
+ stopAll() {
3986
+ for (const voice of [...this.live]) voice.stop();
3987
+ }
3988
+ /**
3989
+ * A short tone, to prove the chain end to end.
3990
+ *
3991
+ * Deliberately part of the shipped surface rather than a test fixture:
3992
+ * "is the audio graph actually connected" is a question that comes up on
3993
+ * every device, and the honest answer is a sound you can hear. It uses the
3994
+ * same path everything else does — a voice from the pool, the noise buffer,
3995
+ * the master gain — so if this is audible the path works.
3996
+ */
3997
+ test(duration = 0.15) {
3998
+ const voice = this.take("test", 1);
3999
+ if (!voice) return null;
4000
+ const source = this.ctx.createBufferSource();
4001
+ source.buffer = this.noiseBuffer();
4002
+ source.connect(voice.gain);
4003
+ const now = this.ctx.currentTime;
4004
+ voice.gain.gain.setValueAtTime(0, now);
4005
+ voice.gain.gain.linearRampToValueAtTime(0.4, now + 0.01);
4006
+ voice.gain.gain.linearRampToValueAtTime(0, now + duration);
4007
+ source.start(now);
4008
+ source.stop(now + duration);
4009
+ voice.own(source);
4010
+ return voice;
4011
+ }
4012
+ /** Release the context. Nothing survives this. */
4013
+ async dispose() {
4014
+ this.stopAll();
4015
+ for (const id of [...this.slots.keys()]) this.free(id);
4016
+ this.master.disconnect();
4017
+ await this.ctx.close();
4018
+ }
4019
+ };
4020
+ function stopNow(source) {
4021
+ try {
4022
+ source.stop();
4023
+ } catch {
4024
+ }
4025
+ }
4026
+ function createAudioContext() {
4027
+ const Ctor = globalThis.AudioContext;
4028
+ if (!Ctor) throw new Error("no Web Audio support in this environment");
4029
+ return new Ctor();
4030
+ }
4031
+
4032
+ // src/fx/sfx/fire.ts
4033
+ var DEFAULTS2 = { volume: 0.55, fullFront: 0.04, crackle: 0.3, seed: 5 };
4034
+ var BED_SMOOTHING = 0.12;
4035
+ var CRACKLES_PER_FRAME = 4;
4036
+ var FireSound = class {
4037
+ audio;
4038
+ o;
4039
+ bed = null;
4040
+ level = 0;
4041
+ debt = 0;
4042
+ state;
4043
+ constructor(audio, options = {}) {
4044
+ this.audio = audio;
4045
+ this.o = { ...DEFAULTS2, ...options };
4046
+ this.state = this.o.seed >>> 0 || 1;
4047
+ }
4048
+ /** True while the bed is playing. */
4049
+ get burning() {
4050
+ return this.bed !== null;
4051
+ }
4052
+ /**
4053
+ * Call once a frame with what the field just did, and where the sheet is.
4054
+ *
4055
+ * `at` is optional: without it the fire is not placed in the room, which is
4056
+ * right for a sheet filling the frame and wrong for one held at arm's
4057
+ * length.
4058
+ */
4059
+ update(dt, stats, at) {
4060
+ const target2 = stats.front > 0 ? Math.min(1, Math.sqrt(stats.front / this.o.fullFront)) : 0;
4061
+ const follow = dt > 0 ? Math.min(1, dt / BED_SMOOTHING) : 1;
4062
+ this.level += (target2 - this.level) * follow;
4063
+ if (target2 > 0 || this.level > 0.02) this.startBed(at);
4064
+ else this.stopBed();
4065
+ if (this.bed) this.driveBed(at);
4066
+ this.debt += stats.charred * this.o.crackle;
4067
+ let budget = CRACKLES_PER_FRAME;
4068
+ while (this.debt >= 1 && budget > 0) {
4069
+ this.debt -= 1;
4070
+ budget--;
4071
+ this.crackle();
4072
+ }
4073
+ if (budget === 0) this.debt %= 1;
4074
+ }
4075
+ /**
4076
+ * A match struck: the scratch of the head across the box, then the hiss of
4077
+ * it flaring as the head burns off (spec §10.2). Two bursts of the shared
4078
+ * noise, one bright and short, one breathier and longer.
4079
+ */
4080
+ strike() {
4081
+ this.burst("fire-strike", 0.9, { type: "bandpass", frequency: 3200, q: 1.1 }, 4e-3, 0.07, 0.8);
4082
+ this.burst("fire-flare", 0.8, { type: "highpass", frequency: 1800, q: 0.7 }, 0.03, 0.45, 0.45, 0.05);
4083
+ }
4084
+ /** Blown out: a soft, low breath of noise (spec §10.6). */
4085
+ puff() {
4086
+ this.burst("fire-puff", 0.6, { type: "lowpass", frequency: 520, q: 0.6 }, 0.02, 0.28, 0.55);
4087
+ }
4088
+ /** One ember popping in the air — a tiny click on the frame it flashes (spec §8.1). */
4089
+ pop() {
4090
+ const f = 3e3 + this.next() * 3e3;
4091
+ this.burst(
4092
+ "fire-pop",
4093
+ 0.2,
4094
+ { type: "bandpass", frequency: f, q: 3 },
4095
+ 1e-3,
4096
+ 0.012 + this.next() * 0.01,
4097
+ 0.35
4098
+ );
4099
+ }
4100
+ /** Silence, now — the flame blown out, or the page going away. */
4101
+ stop() {
4102
+ this.stopBed();
4103
+ this.level = 0;
4104
+ this.debt = 0;
4105
+ }
4106
+ startBed(at) {
4107
+ if (this.bed) return;
4108
+ const ctx = this.audio.context;
4109
+ const voice = this.audio.take("fire-bed", 0.7);
4110
+ if (!voice) return;
4111
+ const source = ctx.createBufferSource();
4112
+ source.buffer = this.audio.noiseBuffer();
4113
+ source.loop = true;
4114
+ const filter = ctx.createBiquadFilter();
4115
+ filter.type = "bandpass";
4116
+ filter.frequency.value = 380;
4117
+ filter.Q.value = 0.7;
4118
+ const panner = at ? ctx.createPanner() : null;
4119
+ source.connect(filter);
4120
+ if (panner) {
4121
+ panner.panningModel = "equalpower";
4122
+ panner.distanceModel = "inverse";
4123
+ panner.refDistance = 1;
4124
+ filter.connect(panner);
4125
+ panner.connect(voice.gain);
4126
+ } else {
4127
+ filter.connect(voice.gain);
4128
+ }
4129
+ voice.gain.gain.setValueAtTime(0, ctx.currentTime);
4130
+ source.start(ctx.currentTime);
4131
+ voice.own(source);
4132
+ voice.use(filter);
4133
+ if (panner) voice.use(panner);
4134
+ this.bed = { voice, filter, panner };
4135
+ }
4136
+ driveBed(at) {
4137
+ const bed = this.bed;
4138
+ if (!bed) return;
4139
+ const ctx = this.audio.context;
4140
+ const now = ctx.currentTime;
4141
+ const gain = bed.voice.gain.gain;
4142
+ gain.cancelScheduledValues(now);
4143
+ gain.setValueAtTime(gain.value, now);
4144
+ gain.linearRampToValueAtTime(this.level * this.o.volume, now + BED_SMOOTHING / 2);
4145
+ bed.filter.frequency.setValueAtTime(380 + 900 * this.level, now);
4146
+ if (at && bed.panner) {
4147
+ bed.panner.positionX.setValueAtTime(at.x, now);
4148
+ bed.panner.positionY.setValueAtTime(at.y, now);
4149
+ bed.panner.positionZ.setValueAtTime(at.z, now);
4150
+ }
4151
+ }
4152
+ stopBed() {
4153
+ if (!this.bed) return;
4154
+ this.bed.voice.stop();
4155
+ this.bed = null;
4156
+ }
4157
+ crackle() {
4158
+ const voice = this.audio.take("fire-crackle", 0.25);
4159
+ if (!voice) return;
4160
+ const ctx = this.audio.context;
4161
+ const now = ctx.currentTime;
4162
+ const buffer = this.audio.noiseBuffer();
4163
+ const seconds = buffer.length / ctx.sampleRate;
4164
+ const duration = 0.018 + this.next() * 0.05;
4165
+ const source = ctx.createBufferSource();
4166
+ source.buffer = buffer;
4167
+ const filter = ctx.createBiquadFilter();
4168
+ filter.type = "bandpass";
4169
+ filter.frequency.value = 1400 + this.next() * 2800;
4170
+ filter.Q.value = 1.6 + this.next() * 3;
4171
+ source.connect(filter);
4172
+ filter.connect(voice.gain);
4173
+ const peak = (0.25 + this.next() * 0.5) * this.o.volume * Math.max(0.25, this.level);
4174
+ const gain = voice.gain.gain;
4175
+ gain.setValueAtTime(0, now);
4176
+ gain.linearRampToValueAtTime(peak, now + 4e-3);
4177
+ gain.linearRampToValueAtTime(0, now + duration);
4178
+ source.start(now, this.next() * Math.max(0, seconds - duration), duration);
4179
+ voice.own(source);
4180
+ voice.use(filter);
4181
+ }
4182
+ /**
4183
+ * One shaped burst of the shared noise through one filter — what strike,
4184
+ * puff and pop are made of. `delay` starts it a moment late, which is how
4185
+ * the flare follows the scratch.
4186
+ */
4187
+ burst(name, priority, filterSpec, attack, duration, level, delay = 0) {
4188
+ const voice = this.audio.take(name, priority);
4189
+ if (!voice) return;
4190
+ const ctx = this.audio.context;
4191
+ const now = ctx.currentTime + delay;
4192
+ const buffer = this.audio.noiseBuffer();
4193
+ const seconds = buffer.length / ctx.sampleRate;
4194
+ const source = ctx.createBufferSource();
4195
+ source.buffer = buffer;
4196
+ const filter = ctx.createBiquadFilter();
4197
+ filter.type = filterSpec.type;
4198
+ filter.frequency.value = filterSpec.frequency;
4199
+ filter.Q.value = filterSpec.q;
4200
+ source.connect(filter);
4201
+ filter.connect(voice.gain);
4202
+ const gain = voice.gain.gain;
4203
+ gain.setValueAtTime(0, now);
4204
+ gain.linearRampToValueAtTime(level * this.o.volume, now + attack);
4205
+ gain.linearRampToValueAtTime(0, now + attack + duration);
4206
+ source.start(now, this.next() * Math.max(0, seconds - attack - duration), attack + duration);
4207
+ voice.own(source);
4208
+ voice.use(filter);
4209
+ }
4210
+ /** xorshift32 — its own stream, so nothing else can shift the crackle. */
4211
+ next() {
4212
+ let s = this.state;
4213
+ s ^= s << 13;
4214
+ s ^= s >>> 17;
4215
+ s ^= s << 5;
4216
+ this.state = s >>> 0;
4217
+ return this.state / 4294967296;
4218
+ }
4219
+ };
4220
+
4221
+ // src/fx.ts
4222
+ init_quality();
4223
+ // Annotate the CommonJS export names for ESM import in node:
4224
+ 0 && (module.exports = {
4225
+ Afterglow,
4226
+ CHAR,
4227
+ DAMAGE_CHANNELS,
4228
+ DAMAGE_LOOK_DEFAULTS,
4229
+ DamageField,
4230
+ FIELD_SIZE,
4231
+ FIRE_GLOW,
4232
+ FIRE_HEAT_SCALE,
4233
+ FIRE_SOOT_SCALE,
4234
+ FIRE_ZONES,
4235
+ FIXED_DT,
4236
+ FLAME_HEIGHT,
4237
+ FX_BLOOM,
4238
+ FX_BLOOM_THRESHOLD,
4239
+ FX_INITIAL_TIER,
4240
+ FX_TIER_ORDER,
4241
+ FireEmitter,
4242
+ FireFluid,
4243
+ FireSound,
4244
+ FxAudio,
4245
+ FxFireFluid,
4246
+ FxFireLight,
4247
+ FxFlames,
4248
+ FxMatchFlame,
4249
+ FxParticles,
4250
+ FxPost,
4251
+ FxWisps,
4252
+ HEAT,
4253
+ PAPER_WHITE,
4254
+ PRESENCE,
4255
+ ParticlePool,
4256
+ SATURATION,
4257
+ createAudioContext,
4258
+ emit,
4259
+ emitHex,
4260
+ fireEmitterDefaults,
4261
+ fireFluidControls,
4262
+ fireFluidDefaults,
4263
+ fireZones,
4264
+ flameAnchors,
4265
+ flamePuff,
4266
+ fxQualityFor,
4267
+ fxQualityNames,
4268
+ fxQualityTiers,
4269
+ hexToLinear,
4270
+ luminance,
4271
+ particlePresets,
4272
+ solverUniforms,
4273
+ srgbToLinear,
4274
+ timesPaperWhite
4275
+ });
4276
+ //# sourceMappingURL=fx.cjs.map