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.d.cts ADDED
@@ -0,0 +1,1796 @@
1
+ import { D as DamageSource } from './damageContract-DPF1YFLZ.cjs';
2
+ export { a as DAMAGE_CHANNELS, b as DAMAGE_LOOK_DEFAULTS, c as DamageLook } from './damageContract-DPF1YFLZ.cjs';
3
+ import * as react from 'react';
4
+ import { ReactNode } from 'react';
5
+ import * as THREE from 'three';
6
+
7
+ /**
8
+ * The damage field: what has happened to this sheet, as four numbers per
9
+ * texel, over the sheet's own UV.
10
+ *
11
+ * One primitive rather than one subsystem per effect. Fire is heat diffusing
12
+ * into char and char eating presence; water is saturation diffusing along the
13
+ * fibre; a tear is presence zeroed along a path. They share a grid, a step
14
+ * and a set of paint operations, which is the whole reason the second effect
15
+ * costs a chunk and a preset instead of a new system.
16
+ *
17
+ * R char scorch colour, the brown halo, shrinkage and curl, smoke density
18
+ * G saturation wet darkening, roughness, translucency, added mass, sag
19
+ * B heat the glowing ignition line
20
+ * A presence alpha erosion — burn-away, tear-away, punched holes
21
+ *
22
+ * **It runs on the CPU because of who reads it.** Three of the four layers an
23
+ * effect has to land on consume the field on the CPU: the physics coupling
24
+ * needs stiffness and mass per vertex, the emitters spawn ash where presence
25
+ * reaches zero and drips from the lowest wet vertex, and the sound is driven
26
+ * by the length of the burn front every frame. A GPU field would have fed all
27
+ * three through a `readPixels` per frame — a pipeline stall that tile-based
28
+ * phone GPUs pay the most for. Only the shading reads the field on the GPU,
29
+ * and it gets an 8-bit texture uploaded from here. Testability without a
30
+ * renderer comes free with that, and it is how every property below is known.
31
+ *
32
+ * It stays on the main thread. A worker would need `SharedArrayBuffer` to
33
+ * avoid copying the field every frame, and that needs COOP/COEP headers that
34
+ * GitHub Pages cannot send. The main thread is affordable only because the
35
+ * field costs nothing when nothing is happening to it — see `step`.
36
+ *
37
+ * **It is not tiered.** Every device runs the same grid at the same timestep
38
+ * and produces the same fire. With explicit diffusion, halving the cell size
39
+ * quadruples both the cells and the steps stability needs, so the same fire
40
+ * costs N⁴: a 128 grid is sixteen times a 64 one. The first version tiered
41
+ * grid size and substeps as though they were independent, and the measured
42
+ * result was a fire whose speed depended on the device. Presentation is what
43
+ * tiers now — see `fx/quality.ts`.
44
+ */
45
+
46
+ /**
47
+ * Channel offsets into a texel — the contract's, not this file's. The sheet
48
+ * defines what the four bytes mean because the sheet is what draws them;
49
+ * the field is one thing that writes them.
50
+ */
51
+ declare const CHAR: 0;
52
+ declare const SATURATION: 1;
53
+ declare const HEAT: 2;
54
+ declare const PRESENCE: 3;
55
+ /**
56
+ * The grid, in texels along each edge. One number for every device.
57
+ *
58
+ * 64 until a real phone says otherwise — the P1 gate is where this gets
59
+ * settled for good, by rendering a burn at 64 and 96 and looking. What the
60
+ * field carries are soft quantities with soft edges; the RAGGED edge a burn
61
+ * reads as comes from the anisotropy and the grain, which survive a coarse
62
+ * grid, and from per-fragment detail in the shader, which does not need one.
63
+ */
64
+ declare const FIELD_SIZE = 64;
65
+ /**
66
+ * The simulation's own clock, independent of the frame rate.
67
+ *
68
+ * The first version ran a fixed number of substeps per FRAME, so a slow frame
69
+ * meant a longer substep, the stability clamp scaled the diffusion down to
70
+ * compensate, and the same simulated second burned half as far at 30 fps as
71
+ * at 144. Measured on the high tier: 0.202, 0.290 and 0.439 UV of burn radius
72
+ * at 30, 60 and 144 fps. A fire that burns slower when the phone is busy is a
73
+ * fire that behaves differently on every device and every run.
74
+ *
75
+ * `ClothSim` already solved this with the same accumulator and the same
76
+ * number, and the field now matches it. The rates are chosen so that the
77
+ * stability bound is never reached at 1/120 — the clamp in `stencil` is a
78
+ * safety net for extreme options, not something defaults lean on, and a test
79
+ * says so.
80
+ */
81
+ declare const FIXED_DT: number;
82
+ interface DamageFieldOptions {
83
+ /**
84
+ * Which way the paper's fibres run, in radians over the sheet's UV, where
85
+ * 0 points along +u.
86
+ *
87
+ * Paper is not isotropic and this is the single number that makes it look
88
+ * like paper rather than like a fluid. Fibres are laid down along the
89
+ * machine direction when the sheet is made; liquid wicks along them far
90
+ * faster than across them, and a flame follows them for the same reason.
91
+ * A wet front with anisotropy at 1 is a circle, which reads as a stain on
92
+ * cloth; with it raised, the front is an ellipse along this angle, which
93
+ * reads as paper. Any angle — see `diffusionTensor`.
94
+ */
95
+ fibre?: number;
96
+ /** How much faster things travel along the fibre than across it. */
97
+ anisotropy?: number;
98
+ /** Heat spread, in UV² per second, averaged over direction. */
99
+ heatDiffusion?: number;
100
+ /** Liquid wicking, in UV² per second, averaged over direction. */
101
+ wicking?: number;
102
+ /** How fast heat turns paper to char. */
103
+ charRate?: number;
104
+ /** How fast char consumes presence — the burn-away. */
105
+ consumeRate?: number;
106
+ /** How much heat burning gives back. Above ~1 the front sustains itself. */
107
+ combustion?: number;
108
+ /** Heat lost to the room each second, as a fraction. */
109
+ cooling?: number;
110
+ /** How much heat it costs to boil a wet cell dry before it can char. */
111
+ wetResistance?: number;
112
+ /** How fast standing water leaves the sheet, as a fraction per second. */
113
+ drying?: number;
114
+ /**
115
+ * Per-texel variation in how readily the paper takes light, 0..1.
116
+ *
117
+ * Real paper is not uniform, and a burn front on a uniform sheet advances
118
+ * as a perfect circle — the single most synthetic-looking thing this
119
+ * simulation could do. This is what makes the edge ragged, and it is fixed
120
+ * per field rather than per frame, because a sheet's grain does not change
121
+ * while you hold it.
122
+ */
123
+ grain?: number;
124
+ /** Seed for the grain, so a field is reproducible. */
125
+ seed?: number;
126
+ }
127
+ /** What the field did this step. The numbers the sound and the emitters read. */
128
+ interface FieldStats {
129
+ /**
130
+ * How much sheet is actively burning right now, 0..1.
131
+ *
132
+ * The fraction of texels part-way through charring AND still hot.
133
+ * Resolution-independent by construction. Both halves matter: combustion
134
+ * pumps heat into everything it has already burnt, so "hot" alone lights
135
+ * up the whole interior; and a fire that went out leaves partly charred
136
+ * cells behind, so "part-charred" alone would report a burn that is over as
137
+ * one still going.
138
+ *
139
+ * This is the number fire's audio is driven by, and it is the right one:
140
+ * a fire gets louder as the front gets LONGER, not as the burnt area gets
141
+ * bigger. A sheet nearly consumed is quiet again, which is true, and which
142
+ * a level driven by char would get exactly backwards.
143
+ */
144
+ front: number;
145
+ /** Texels that crossed into char this step. Drives crackle rate and smoke. */
146
+ charred: number;
147
+ /** Texels whose presence reached zero this step. Drives ash, and burn-through. */
148
+ consumed: number;
149
+ /** Texels that became wetter this step. Drives the water bed's level. */
150
+ wetted: number;
151
+ /** Mean saturation over the sheet, 0..1. Drives added mass in the coupling. */
152
+ saturation: number;
153
+ /** Fraction of the sheet still present, 1 at the start. */
154
+ remaining: number;
155
+ }
156
+ declare class DamageField implements DamageSource {
157
+ readonly size = 64;
158
+ /** RGBA per texel in float, row-major from v = 0. The simulation's own state. */
159
+ readonly data: Float32Array;
160
+ /** The same, at 8 bits, for upload. Kept in step with `data` over what changed. */
161
+ readonly pixels: Uint8Array;
162
+ /**
163
+ * How ragged the sheet DRAWS this field's edges, 0..1 — see
164
+ * `DamageSource.detail`. Not a simulation option, because it changes
165
+ * nothing the field computes: set it from `fxQualityFor(tier).detail`, and
166
+ * again whenever the tier moves.
167
+ */
168
+ detail: number;
169
+ private revision;
170
+ private readonly next;
171
+ /** Per-texel ignition threshold, fixed for the life of the sheet. */
172
+ private readonly tinder;
173
+ private readonly o;
174
+ private readonly heat;
175
+ private readonly water;
176
+ private accumulator;
177
+ /** Fixed steps run since the field was made — its own clock. See {@link time}. */
178
+ private steps;
179
+ /** Cells that might change on the next step. Everything outside is at rest. */
180
+ private active;
181
+ /** Cells written since the last pack into `pixels`. */
182
+ private touched;
183
+ private visited;
184
+ /** Running totals, so the stats never have to walk the whole grid. */
185
+ private present;
186
+ private wetTotal;
187
+ private stats;
188
+ /**
189
+ * Texels whose presence reached zero during the last `step`, in the first
190
+ * {@link consumedCount} slots — where ash leaves from.
191
+ *
192
+ * WHERE, not just how many. The stats were enough for the sound, which
193
+ * wants a level; an emitter wants a place. Fixed buffers the size of the
194
+ * grid and written in place: a burning sheet must not allocate a list a
195
+ * step, and a texel is consumed once, so a step can never fill it twice.
196
+ */
197
+ readonly consumedCells: Int32Array<ArrayBuffer>;
198
+ /** The burn front as of the last step that ran, in the first {@link frontCount} slots — where embers and smoke leave from. */
199
+ readonly frontCells: Int32Array<ArrayBuffer>;
200
+ private consumedLength;
201
+ private frontLength;
202
+ constructor(options?: DamageFieldOptions);
203
+ /** Bumped whenever `pixels` changes. */
204
+ get version(): number;
205
+ /**
206
+ * Simulated seconds this field has burned for — whole fixed steps, never
207
+ * wall time.
208
+ *
209
+ * What anything DRAWN from the field animates on: the ember line's beads
210
+ * flicker and crawl, flames puff, and all of it has to be the same at the
211
+ * same moment of the same burn, or a replay flickers differently from the
212
+ * original and a capture can never be taken twice. Stops while the field
213
+ * sleeps, which is right — a sheet at rest has nothing hot left to move.
214
+ */
215
+ get time(): number;
216
+ /** Nothing is happening to this sheet, and stepping it costs nothing. */
217
+ get asleep(): boolean;
218
+ /**
219
+ * Cells the last `step` visited.
220
+ *
221
+ * The cost of the field, stated in the one unit that means the same thing on
222
+ * every machine. Milliseconds would make a test of it a test of whoever ran
223
+ * it — which is how the hands harness spent weeks passing on one laptop.
224
+ */
225
+ get cellsVisited(): number;
226
+ /** What the last `step` produced. */
227
+ get lastStats(): FieldStats;
228
+ /** How many of {@link consumedCells} the last `step` wrote. Always `lastStats.consumed`. */
229
+ get consumedCount(): number;
230
+ /**
231
+ * How many of {@link frontCells} are current. `lastStats.front` times the
232
+ * texel count — and held, like it, across a frame too short to step.
233
+ */
234
+ get frontCount(): number;
235
+ /** Texel index for a UV, clamped to the sheet. */
236
+ private at;
237
+ /** The four channels at a UV, for anything that needs to ask a question of a point. */
238
+ sample(u: number, v: number): [number, number, number, number];
239
+ /**
240
+ * Add to one channel in a soft disc.
241
+ *
242
+ * Every paint operation is this with a different channel, which is the
243
+ * point of having one primitive: `ignite` and `wet` are not two systems
244
+ * that happen to look alike, they are the same write.
245
+ *
246
+ * `plateau` is the fraction of the radius that takes the full amount
247
+ * before the falloff starts. Zero for anything added, so the deposit has a
248
+ * soft peak — a flame held near paper does not deposit a stamped disc of
249
+ * heat, and a too-perfect edge is the first thing that reads as fake.
250
+ * Raised for anything removed, because a pure smoothstep never quite
251
+ * reaches zero even at its centre, and "almost all the way through" is not
252
+ * a hole.
253
+ */
254
+ paint(channel: number, u: number, v: number, radius: number, amount: number, plateau?: number): void;
255
+ /** Hold a flame near the sheet. Heat, not char — the burning is the field's job. */
256
+ ignite(u: number, v: number, radius?: number, amount?: number): void;
257
+ /** Wet the sheet. Saturation wicks along the fibre from wherever it lands. */
258
+ wet(u: number, v: number, radius?: number, amount?: number): void;
259
+ /**
260
+ * Take the paper away along a path: a tear, a cut, a punched hole.
261
+ *
262
+ * Presence only ever decreases. A sheet does not grow back, and a paint op
263
+ * that could raise it would make every burn reversible by accident.
264
+ */
265
+ cut(u0: number, v0: number, u1: number, v1: number, width?: number): void;
266
+ /** Punch a hole. The middle of the sheet, which a fixed-topology mesh cannot do. */
267
+ punch(u: number, v: number, radius?: number): void;
268
+ /**
269
+ * Advance the field by a frame's worth of real time.
270
+ *
271
+ * Fixed steps from an accumulator, so the same simulated second is the same
272
+ * fire at any frame rate. A sheet nothing is happening to returns at once
273
+ * and visits no cells at all — which is the condition for keeping this on
274
+ * the main thread, and the thing the first version could not do: an
275
+ * untouched sheet cost exactly what a burning one did.
276
+ */
277
+ step(delta: number): FieldStats;
278
+ /**
279
+ * One fixed step of diffusion and reaction, over the active region only.
280
+ *
281
+ * The region is the box around every cell that could change, grown by one
282
+ * cell because diffusion reaches exactly one neighbour per step. Cells
283
+ * outside it are at rest and read-only here — their neighbours may read
284
+ * them, and nothing writes them.
285
+ *
286
+ * Written into `next` and copied back over the same region, rather than
287
+ * swapping the two buffers. A swap needs both buffers to agree everywhere
288
+ * the step did not write, and a region that SHRINKS breaks that: a cell the
289
+ * last step wrote into one buffer still holds an older value in the other.
290
+ * Copying the region back keeps them identical outside it, for the cost of
291
+ * the region itself — which is the cost that matters, since it is zero on
292
+ * a sheet at rest rather than a whole grid every step.
293
+ */
294
+ private substep;
295
+ /** Quantise everything touched since the last pack, and mark it uploadable. */
296
+ private pack;
297
+ }
298
+
299
+ /**
300
+ * Particles: one pool, and presets that are only parameter sets.
301
+ *
302
+ * Embers, smoke and ash are not three systems. They are the same particle —
303
+ * a position, a velocity, an age — told different things about how long it
304
+ * lives, which way the air takes it and what it looks like. So there is one
305
+ * pool, and a preset is a row of numbers; the next effect's droplet or dust is
306
+ * another row, not another file. That is the P3 gate stated early: each effect
307
+ * after the second should cost a preset, not a subsystem.
308
+ *
309
+ * **Simulated on the CPU,** for the same reason the damage field is: what
310
+ * spawns them is read on the CPU. Ash leaves the texel that just burnt
311
+ * through, and that texel is in a `Float32Array`, not a texture. A GPU
312
+ * particle system would have to be told every spawn through an upload anyway,
313
+ * and the counts here — hundreds, not hundreds of thousands — are ones a CPU
314
+ * steps in a fraction of a millisecond.
315
+ *
316
+ * **A fixed capacity, from the tier,** allocated once and never grown. When
317
+ * it is full, the particle closest to the end of its life gives up its slot:
318
+ * dropping the NEW one instead would starve the burn of embers the moment the
319
+ * room filled with old smoke, which is exactly backwards — the newest
320
+ * particle is the one leaving the fire you are looking at.
321
+ *
322
+ * Drawing is `FxParticles`. Nothing in this file touches three.
323
+ */
324
+ interface ParticlePreset {
325
+ /** Seconds a particle lives, drawn uniformly between the two. */
326
+ readonly life: readonly [number, number];
327
+ /** Launch speed in world units a second, drawn between the two. */
328
+ readonly speed: readonly [number, number];
329
+ /** Launch direction before spread, in world space. */
330
+ readonly direction: readonly [number, number, number];
331
+ /** 0 launches along `direction` exactly; 1 anywhere in the hemisphere around it. */
332
+ readonly spread: number;
333
+ /** Acceleration straight up, world units a second squared. Hot air rises; ash is heavier than it. */
334
+ readonly lift: number;
335
+ /** How fast the air brings a particle to its own speed, per second. */
336
+ readonly drag: number;
337
+ /** A random acceleration, world units a second squared, drawn afresh every step — the flutter. */
338
+ readonly jitter: number;
339
+ /** How much of the wind's velocity it takes on, 0..1. */
340
+ readonly windCatch: number;
341
+ /** Diameter in world units, at birth and at death. */
342
+ readonly size: readonly [number, number];
343
+ /** Linear RGB at birth and at death. Above 1 for anything that is light rather than matter. */
344
+ readonly color: readonly [readonly [number, number, number], readonly [number, number, number]];
345
+ /** Opacity at birth and at death. Every particle also fades IN over the first tenth of its life. */
346
+ readonly alpha: readonly [number, number];
347
+ /** Spin in radians a second, drawn either way up to this. */
348
+ readonly spin: number;
349
+ /** Added to what is behind it — light — or drawn over it — matter. */
350
+ readonly blend: 'additive' | 'normal';
351
+ /** A soft disc, or a jagged flake. */
352
+ readonly shape: 'soft' | 'flake';
353
+ /** Brightness wobble, 0..1: an ember flickers, a puff of smoke does not. */
354
+ readonly flicker: number;
355
+ }
356
+ /**
357
+ * The fire's three. World units are the sheet's: a default sheet is one unit
358
+ * across, about the width of A4, so 0.01 is two millimetres.
359
+ */
360
+ declare const particlePresets: {
361
+ /**
362
+ * A spark off the burning line. Short-lived, shrinking as it cools, carried
363
+ * up and sideways by the heat.
364
+ *
365
+ * Authored through `emission.ts`, in multiples of paper white, which is what
366
+ * caught the bug in the version before this one. That version said "the hot
367
+ * end is now ~5.6, well over the bloom threshold" — and 5.6 was an ABSOLUTE
368
+ * luminance, while paper white under `window` is 1.6. So the hottest spark
369
+ * in the frame was **3.5× paper, under the spec's own 4× floor**, and its
370
+ * own comment said otherwise. A number that cannot be compared to anything
371
+ * is a number nobody can check.
372
+ *
373
+ * It was also too red. `[12, 4.2, 0.9]` is a saturated red at high
374
+ * intensity, and the tone curve takes a bright red-dominant colour to
375
+ * SALMON — which is `Never_this.png`. The hue is now a blackbody's: a spark
376
+ * leaving the fire is yellow-orange, about 2200 K, and cools to a deep
377
+ * orange-red as it dies. Hue and brightness are separate arguments now, and
378
+ * `emit` keeps them that way.
379
+ */
380
+ readonly ember: {
381
+ readonly life: readonly [0.5, 1.4];
382
+ readonly speed: readonly [0.15, 0.5];
383
+ readonly direction: readonly [0, 1, 0];
384
+ readonly spread: 0.6;
385
+ readonly lift: 0.6;
386
+ readonly drag: 0.8;
387
+ readonly jitter: 1.5;
388
+ readonly windCatch: 0.8;
389
+ readonly size: readonly [0.0035, 0.0012];
390
+ readonly color: readonly [[number, number, number], [number, number, number]];
391
+ readonly alpha: readonly [1, 0];
392
+ readonly spin: 0;
393
+ readonly blend: "additive";
394
+ readonly shape: "soft";
395
+ readonly flicker: 0.35;
396
+ };
397
+ /**
398
+ * What a paper fire mostly makes. Thin, slow, growing as it rises and
399
+ * spreads, and gone well before it could read as a volume — no fluid
400
+ * simulation, on purpose; see the fx plan.
401
+ */
402
+ readonly smoke: {
403
+ readonly life: readonly [1.8, 3.5];
404
+ readonly speed: readonly [0.05, 0.15];
405
+ readonly direction: readonly [0, 1, 0];
406
+ readonly spread: 0.3;
407
+ readonly lift: 0.25;
408
+ readonly drag: 0.9;
409
+ readonly jitter: 0.3;
410
+ readonly windCatch: 1;
411
+ readonly size: readonly [0.05, 0.35];
412
+ readonly color: readonly [readonly [0.147, 0.13, 0.117], readonly [0.2, 0.19, 0.18]];
413
+ readonly alpha: readonly [0.08, 0];
414
+ readonly spin: 0.4;
415
+ readonly blend: "normal";
416
+ readonly shape: "soft";
417
+ readonly flicker: 0;
418
+ };
419
+ /**
420
+ * Burnt-through paper, leaving. Lifted a little by the heat it came from,
421
+ * then heavier than the air: it tumbles, flutters and falls — the part of a
422
+ * burn that proves paper was there.
423
+ */
424
+ readonly ash: {
425
+ readonly life: readonly [2.5, 5];
426
+ readonly speed: readonly [0.14, 0.3];
427
+ readonly direction: readonly [0, 1, 0];
428
+ readonly spread: 0.55;
429
+ readonly lift: -0.22;
430
+ readonly drag: 1.6;
431
+ readonly jitter: 1.2;
432
+ readonly windCatch: 1;
433
+ readonly size: readonly [0.03, 0.026];
434
+ readonly color: readonly [readonly [0.07, 0.065, 0.06], readonly [0.12, 0.115, 0.11]];
435
+ readonly alpha: readonly [0.95, 0];
436
+ readonly spin: 4;
437
+ readonly blend: "normal";
438
+ readonly shape: "flake";
439
+ readonly flicker: 0;
440
+ };
441
+ };
442
+ type ParticlePresetName = keyof typeof particlePresets;
443
+ /**
444
+ * Where `write` puts one blend mode's particles, as flat arrays sized for the
445
+ * pool's capacity: xyz, rgba, and four extras — size, spin angle, shape (0
446
+ * soft, 1 flake) and a per-particle seed the sprite uses for its outline.
447
+ */
448
+ interface ParticleTarget {
449
+ readonly position: Float32Array;
450
+ readonly color: Float32Array;
451
+ readonly extra: Float32Array;
452
+ /** Velocity, xyz, if the target wants it — an ember is drawn stretched along its own. */
453
+ readonly velocity?: Float32Array;
454
+ }
455
+ declare class ParticlePool {
456
+ readonly capacity: number;
457
+ /**
458
+ * The air's own velocity, world units a second. A blow on `/hands` is a
459
+ * wind, and smoke that ignores it is smoke painted on the glass.
460
+ */
461
+ readonly wind: [number, number, number];
462
+ private readonly kind;
463
+ private readonly position;
464
+ private readonly velocity;
465
+ private readonly age;
466
+ private readonly life;
467
+ private readonly angle;
468
+ private readonly spin;
469
+ private readonly seed;
470
+ private live;
471
+ /** Live particles of each preset, kept as they come and go, so a cap costs nothing to check. */
472
+ private readonly perKind;
473
+ /** Embers that popped in the air since the last `takePops` — the sound follows the picture. */
474
+ private pops;
475
+ private readonly random;
476
+ constructor(capacity: number, seed?: number);
477
+ /** Particles in the air. */
478
+ get count(): number;
479
+ /** How many live particles are of one preset. For tests and for a HUD. */
480
+ countOf(name: ParticlePresetName): number;
481
+ /** Launch one particle from a point. Never allocates; at capacity it takes the most-spent slot. */
482
+ spawn(name: ParticlePresetName, x: number, y: number, z: number): void;
483
+ /** Advance every particle by `dt` seconds; the dead leave the pool. */
484
+ step(dt: number): void;
485
+ /**
486
+ * Write every live particle into the target for its blend mode. Returns how
487
+ * many went into each — the draw ranges.
488
+ */
489
+ write(additive: ParticleTarget, normal: ParticleTarget,
490
+ /**
491
+ * Where flakes go, if they are to be drawn apart from the smoke — ash is
492
+ * a tumbling plane and smoke is a soft sprite, and one blend mode is not
493
+ * one way of drawing. Omitted, flakes go with `normal` as they always did.
494
+ */
495
+ flakes?: ParticleTarget): {
496
+ additive: number;
497
+ normal: number;
498
+ flakes: number;
499
+ };
500
+ /**
501
+ * How many embers popped in the air since the last call, and reset — one
502
+ * `FireSound.pop()` each keeps the sound on the frame the flash is on.
503
+ */
504
+ takePops(): number;
505
+ /** Empty the air — a fresh sheet. */
506
+ clear(): void;
507
+ /** Swap the last live particle into slot `i`. Order does not matter; density does. */
508
+ private remove;
509
+ }
510
+
511
+ /**
512
+ * Where a point of the sheet is in the world, given its UV — `v = 0` at the
513
+ * bottom edge, as the damage grid has it. Null when it cannot say (the sheet
514
+ * has not mounted yet).
515
+ *
516
+ * On `<Paper>` this is `(u, v) => handle.surfacePoint(u, v, scratch)`: the
517
+ * drawn surface this frame, after the cloth and the deformers. A callback and
518
+ * not the handle, because `paperlab/fx` cannot name a type from the main
519
+ * entry without reaching into it — see `fx/boundary.test.ts`.
520
+ */
521
+ type SurfaceLocator = (u: number, v: number) => {
522
+ readonly x: number;
523
+ readonly y: number;
524
+ readonly z: number;
525
+ } | null;
526
+ interface FireEmitterOptions {
527
+ /** Embers a second for every texel on the burn front. */
528
+ embers?: number;
529
+ /** Puffs of smoke a second for every texel on the burn front. */
530
+ smoke?: number;
531
+ /** The chance a texel that burns through leaves a flake of ash. */
532
+ ash?: number;
533
+ /** Seed for which cells are chosen, so a replayed burn throws the same sparks. */
534
+ seed?: number;
535
+ /** The most of each kind in the air at once — `fxQualityFor(tier).caps`. Uncapped if omitted. */
536
+ caps?: {
537
+ ember?: number;
538
+ smoke?: number;
539
+ ash?: number;
540
+ };
541
+ }
542
+ /**
543
+ * How much each kind leaves the front, per texel per second.
544
+ *
545
+ * `smoke` is 0 on purpose. There were three smoke systems running at once —
546
+ * the fire simulator's own, these sprite puffs at 8% alpha, and the smoulder
547
+ * wisp — and the review's note was that only one of them should exist. The
548
+ * simulator's is the one that belongs to a burning sheet: it is the same
549
+ * fluid the flames are made of, so it rises with them instead of beside them.
550
+ * `FxWisps` still carries the thread after the flames are out, which is the
551
+ * one moment the simulator has nothing left to make. The preset stays: it is
552
+ * a parameter set, and anything may still ask the pool for smoke.
553
+ *
554
+ * Embers and ash are both down. Ash at 0.86 a texel was a flake for nearly
555
+ * every one that burnt through — a dust shower. Fewer and larger is the note.
556
+ */
557
+ declare const fireEmitterDefaults: Required<FireEmitterOptions>;
558
+ /**
559
+ * What a burn throws into the air, read off the field that is burning.
560
+ *
561
+ * Nothing here decides where a fire IS — the field does. Embers and smoke
562
+ * leave the front, at a rate set by how long the front is (a fire gets
563
+ * busier as its edge gets longer, the same number its sound follows). Ash
564
+ * leaves exactly the texels that burnt through this frame, so a hole opening
565
+ * is a hole shedding. On a sheet nothing is happening to, `update` reads two
566
+ * zeros and returns.
567
+ */
568
+ declare class FireEmitter {
569
+ private readonly field;
570
+ private readonly pool;
571
+ private readonly locate;
572
+ private readonly o;
573
+ private emberDebt;
574
+ private smokeDebt;
575
+ private state;
576
+ constructor(field: DamageField, pool: ParticlePool, locate: SurfaceLocator, options?: FireEmitterOptions);
577
+ /** Call once a frame, after the field has stepped. */
578
+ update(dt: number): void;
579
+ private emit;
580
+ /** xorshift32 — its own stream, so the pool's randomness cannot shift which cells are picked. */
581
+ private next;
582
+ }
583
+
584
+ /**
585
+ * The pool, drawn — three kinds, three ways, because the spec's three
586
+ * particles are three different things (paperlab-fx-fire-spec.md §8).
587
+ *
588
+ * - **Embers** are light, moving fast: quads STRETCHED along their own
589
+ * screen-space velocity into streaks, a bright head and a fading tail,
590
+ * additive and HDR so they bloom. A spark is never a static dot.
591
+ * - **Smoke** is soft sprites broken up by noise — thin, grey-brown, widening
592
+ * as it rises. Camera-facing points: one vertex each is still the right
593
+ * price for something this soft.
594
+ * - **Ash** is paper: thin curled planes that tumble in three dimensions,
595
+ * double-sided, char-dark with pale ash edges, lit by where they face
596
+ * rather than glowing — a few keep a hot edge for their first second.
597
+ *
598
+ * This only draws. The page owns the order — field, then emitters, then the
599
+ * pool's own step — because only the page knows which field belongs to which
600
+ * sheet.
601
+ */
602
+ interface FxParticlesProps {
603
+ pool: ParticlePool;
604
+ }
605
+ declare function FxParticles({ pool }: FxParticlesProps): react.JSX.Element;
606
+
607
+ /**
608
+ * What an effect is allowed to cost to SHOW, per device class.
609
+ *
610
+ * Presentation only, deliberately. The simulation is not tiered: every device
611
+ * runs the same damage field on the same grid at the same timestep, so every
612
+ * device gets the same fire — which is also the only way a burn can be shared
613
+ * or replayed and come out the same. See the note at the top of
614
+ * `fx/field.ts`.
615
+ *
616
+ * That is a correction. The first version of this table tiered the field's
617
+ * grid size and its substep count as two independent knobs, and they are not
618
+ * independent: with explicit diffusion, halving the cell size quadruples the
619
+ * cells AND the steps stability needs, so the same fire costs N⁴ — the 128
620
+ * grid was sixteen times the 64 one. What the table actually produced was a
621
+ * fire whose SPEED depended on the device. It is the same shape of mistake as
622
+ * the `stage/quality.ts` knob this file's first preamble warned about, made
623
+ * one file over.
624
+ *
625
+ * What remains here is what a weaker device can genuinely show less of
626
+ * without the effect meaning something different: how many particles are in
627
+ * the air, how many sounds play at once, and how finely a burnt edge is drawn.
628
+ * A knob belongs here only when something reads it.
629
+ */
630
+ declare const fxQualityNames: readonly ["auto", "low", "medium", "high"];
631
+ type FxQualityName = (typeof fxQualityNames)[number];
632
+ type FxQualityTier = Exclude<FxQualityName, 'auto'>;
633
+ interface FxQualitySettings {
634
+ /**
635
+ * Hard ceiling on live particles across every emitter at once.
636
+ *
637
+ * Allocated at this size and never grown, so it is a size rather than a
638
+ * suggestion. Read by the emitters, which arrive with fire.
639
+ */
640
+ particles: number;
641
+ /**
642
+ * Simultaneous synthesised voices.
643
+ *
644
+ * A phone running MediaPipe and WebGL has no headroom for an unbounded
645
+ * grain cloud, and a grain cloud is exactly what fire and crumple both
646
+ * want to be. The ceiling is the whole design, not a safety net. Read by
647
+ * `FxAudio`.
648
+ */
649
+ voices: number;
650
+ /**
651
+ * How ragged a burnt edge is drawn, 0..1 — per-fragment noise that frays
652
+ * the edge finer than the damage grid can. Handed to the sheet as the
653
+ * field's `detail`. The one part of drawing damage that costs per PIXEL,
654
+ * so the part a fill-rate-bound phone gives up first; at 0 the shader
655
+ * skips the noise outright.
656
+ */
657
+ detail: number;
658
+ /**
659
+ * The resolution bloom is drawn at, as a fraction of the frame.
660
+ *
661
+ * Bloom is a blur, and a blur at half resolution is a quarter of the
662
+ * fill-rate for a glow that is soft anyway — the cheapest thing a phone
663
+ * can give up that still leaves every zone and every flame on screen
664
+ * (spec §12: tiers thin post quality, never what is shown). Read by
665
+ * `FxPost`.
666
+ */
667
+ bloomScale: number;
668
+ /**
669
+ * The most flames standing at once. The spec's §12 said 8 / 16 / 32; a ring
670
+ * of fire that is dense in clusters and broken by gaps needs more tongues
671
+ * than that, and Noor's direction (a living, uneven ring, never a crown)
672
+ * came later and wins — 16 / 36 / 64. Every tier has real flames; a phone
673
+ * gets fewer tongues, never the cheap version.
674
+ * Read by `FxFlames`.
675
+ */
676
+ flames: number;
677
+ /**
678
+ * The most of each particle kind in the air at once (spec §12), inside the
679
+ * `particles` budget. Read by `FireEmitter` through its `caps` option.
680
+ */
681
+ caps: {
682
+ ember: number;
683
+ smoke: number;
684
+ ash: number;
685
+ };
686
+ /**
687
+ * Heat haze strength in pixels at 1080p; 0 turns it off. Read by `FxPost`.
688
+ *
689
+ * High tier only, and half what it was. It was 3.8 px on two tiers against
690
+ * §10's 1–3, and invisible in every capture at either — a few pixels of
691
+ * wobble reads as nothing while the fluid beside it is moving. It is also
692
+ * still driven from `flameAnchors`, which is where the SPRITE flames stand,
693
+ * not where the fluid actually burns; until it reads the fluid's own heat
694
+ * texture it is the last polish item rather than a look, so it is off
695
+ * everywhere it cannot be afforded to be good.
696
+ */
697
+ haze: number;
698
+ /**
699
+ * The fire simulator's grids (`FxFireFluid`): a velocity grid, a finer one
700
+ * for what is drawn, and the pressure solve's iterations. Every tier
701
+ * simulates a real fire; a phone solves a coarser one.
702
+ *
703
+ * The velocity grid is half the dye's resolution, not a quarter as it was.
704
+ * At a quarter its cells were 4.4 mm and a tongue was one to five cells
705
+ * wide, so everything that makes a flame flicker happened inside a cell and
706
+ * the solve never saw it — sharper advection of the dye could only draw the
707
+ * edges of a motion that was itself butter. The pressure solve is the cheap
708
+ * part (a quarter of the dye grid's texels); the iterations rise with it
709
+ * because Jacobi needs more of them to converge over more cells.
710
+ */
711
+ fluid: {
712
+ velocity: readonly [number, number];
713
+ dye: readonly [number, number];
714
+ iterations: number;
715
+ };
716
+ }
717
+ declare const fxQualityTiers: Record<FxQualityTier, FxQualitySettings>;
718
+ /** Where `auto` starts before anything has been measured. */
719
+ declare const FX_INITIAL_TIER: FxQualityTier;
720
+ declare const FX_TIER_ORDER: FxQualityTier[];
721
+ declare function fxQualityFor(name: FxQualityName): FxQualitySettings;
722
+
723
+ /** The rig's film. Mirrors `FilmName` — which fx cannot import; see `boundary.test.ts`. */
724
+ type FxFilm = 'agx' | 'neutral' | 'filmic';
725
+ interface FxPostProps {
726
+ /**
727
+ * The film the scene's lighting uses. The composer takes the tone curve off
728
+ * the renderer, so the pass has to be told which one to put back. Every
729
+ * built-in preset uses `neutral`.
730
+ */
731
+ film?: FxFilm;
732
+ /** How much the pass may cost — bloom is drawn at half resolution on `low`. */
733
+ quality?: FxQualityTier;
734
+ /** Bloom strength; 0 keeps the tone curve and drops the bloom. */
735
+ bloom?: number;
736
+ /** Bloom threshold in scene luminance. Must stay above paper white. */
737
+ threshold?: number;
738
+ /**
739
+ * The burning field, and where its sheet is — for the heat haze above its
740
+ * flames and the frame's warm grade as it grows (§7). Leave both out and
741
+ * the pass is bloom and the tone curve alone.
742
+ */
743
+ field?: DamageField;
744
+ locate?: SurfaceLocator;
745
+ /** Heat haze in pixels at 1080p, overriding the tier's; 0 turns it off. */
746
+ haze?: number;
747
+ }
748
+ /**
749
+ * `fallback={null}`: the scene behind it is already drawn. One frame without
750
+ * the pass, then one with it.
751
+ */
752
+ declare function FxPost(props: FxPostProps): react.JSX.Element;
753
+
754
+ /**
755
+ * The scene luminance of the brightest paper any built-in preset lights.
756
+ *
757
+ * **Measured, not chosen.** A clean sheet was photographed under every preset
758
+ * with bloom on and bloom off, stepping the threshold until the two pictures
759
+ * were identical: most presets are safe from 0.8–0.9, `studio` from 1.1, and
760
+ * `window` — the brightest — only from 1.6. The brightest preset has to set
761
+ * this, because a threshold that lets paper bloom under any preset brings back
762
+ * the painted-glow failure of §0.
763
+ *
764
+ * Re-measure it with `pnpm test:fire-look` if a preset's lighting changes.
765
+ */
766
+ declare const PAPER_WHITE = 1.6;
767
+ /**
768
+ * The bloom threshold, in scene luminance before the tone curve.
769
+ *
770
+ * Paper white plus headroom for a highlight at a grazing angle that the test
771
+ * camera does not happen to see. Stated as a multiple so that the reason for
772
+ * the number survives: it is not "3.6", it is "clear of the brightest paper".
773
+ *
774
+ * **Do not lower it to make fire bloom.** That is the wrong end of the
775
+ * problem, and the review that found the fluid's flames under it said so:
776
+ * `window`'s paper blooms from 1.6, so lowering the threshold blooms paper,
777
+ * which is the painted glow again. Raise what the fire EMITS.
778
+ */
779
+ declare const FX_BLOOM_THRESHOLD: number;
780
+ /**
781
+ * What the spec asks a glowing part of a fire to reach, in multiples of paper
782
+ * white (§4.3). The band `emission.test.ts` holds every emitter to.
783
+ */
784
+ declare const FIRE_GLOW: readonly [4, 8];
785
+ /** One sRGB channel (0..1) to linear. The transfer function, not a gamma. */
786
+ declare function srgbToLinear(c: number): number;
787
+ /** Rec. 709 luminance of a linear colour. */
788
+ declare function luminance(c: readonly [number, number, number]): number;
789
+ /** How many times paper white a linear colour is — the inverse of {@link emit}. */
790
+ declare function timesPaperWhite(c: readonly [number, number, number]): number;
791
+ /**
792
+ * An emissive colour, authored as a hue and a brightness.
793
+ *
794
+ * `srgb` is the colour it should LOOK — the ordinary 0..1 sRGB you would pick
795
+ * in a colour picker — and `times` is how many times paper white it should
796
+ * BE. The two are independent on purpose: hue is a judgement about fire, and
797
+ * brightness is a number the spec fixes and a test can check. Authoring them
798
+ * together in one linear triple is what let `[12, 4.2, 0.9]` sit at 3.5×
799
+ * paper white under a comment claiming it cleared 4×.
800
+ *
801
+ * The returned triple has exactly `times * PAPER_WHITE` luminance, so it can
802
+ * be handed straight to a shader.
803
+ */
804
+ declare function emit(srgb: readonly [number, number, number], times: number): [number, number, number];
805
+ /** `emit` from a `#rrggbb` string, for colours sampled off a reference still. */
806
+ declare function emitHex(hex: string, times: number): [number, number, number];
807
+ /**
808
+ * The FLAME HEAT that counts as the hottest gas in a flame — the heat burning
809
+ * made (see `REACT`), which is what a flame's colour is drawn from.
810
+ *
811
+ * It has no natural ceiling, so the render pass needs a reference before
812
+ * "hot" can mean anything. Every band in `RENDER_FRAGMENT`
813
+ * is a fraction of this, which is what gives a flame a core, a body and a
814
+ * tip instead of one saturated colour.
815
+ *
816
+ * It is NOT independent of the solver's `cooling` and heat terms: this is a
817
+ * reference temperature, and they decide how much gas ever reaches it.
818
+ * `pnpm test:fire-budget` is what holds the pair honest.
819
+ *
820
+ * 0.65, measured: inside a flame (where its soot is visible) flame heat runs
821
+ * 0.17 / 0.27 / 0.40 / 0.48 at the 10th / 50th / 90th / 99th percentile.
822
+ * At 0.4 most of the flame sat past the core's threshold and was 36%
823
+ * near-white; at 0.8 all of it sat in the tip's band and was 93% orange.
824
+ * 0.65 puts the median in the body and only the top few percent in the core.
825
+ */
826
+ declare const FIRE_HEAT_SCALE = 0.65;
827
+ /**
828
+ * The soot density that counts as a full flame.
829
+ *
830
+ * Soot is what the render pass draws a flame FROM — its outline, its opacity,
831
+ * how much light it can give — while temperature only picks the colour (see
832
+ * `RENDER_FRAGMENT`). Like the heat, the solver's soot has no natural unit,
833
+ * so this is the reference: `tip.from` is a fraction of it. At 1 the soot's
834
+ * 90th percentile (~1.4) was already past it, so every tongue was solid to
835
+ * its edge with a hard outline; 3 leaves the thin parts room to fade.
836
+ */
837
+ declare const FIRE_SOOT_SCALE = 3;
838
+ /**
839
+ * How strongly light past {@link FX_BLOOM_THRESHOLD} spreads.
840
+ *
841
+ * It lives beside the threshold because the two are one setting: bloom is a
842
+ * multiplier on whatever clears the threshold, so its right value depends
843
+ * entirely on how much fire is authored above it. 1.55 was tuned when the
844
+ * flames were UNDER the threshold and bloom had almost nothing to work on —
845
+ * measured, 0.07% of the frame. Once the flames cleared it, that same 1.55
846
+ * tinted the entire black stage olive. Swept at 0.25, 0.6 and 1.2 against the
847
+ * peak frame; past about 0.4 the stage stops being black.
848
+ */
849
+ declare const FX_BLOOM = 0.55;
850
+ /**
851
+ * A flame, in four zones — drawn the way the sheet's burn is (ember line, ash
852
+ * lip, char, scorch): each part named for what it is, with its own controls.
853
+ * Base to tip, as a flame spreading over a sheet actually is:
854
+ *
855
+ * root the blue leading edge where fresh gas meets the air at the paper.
856
+ * Its light comes from excited molecules, not soot, so it is faint
857
+ * and blue — and it starts OFF, because blue light added over cream
858
+ * paper reads lavender.
859
+ * core the hottest gas, where soot forms densest and glows pale. The only
860
+ * zone allowed to over-expose past the bloom threshold, and so the
861
+ * one that blooms.
862
+ * body the luminous bulk: soot glowing yellow-orange as it rises.
863
+ * tip where soot cools and burns off at the outer edge — orange-red,
864
+ * dimmer, tearing into tongues. What survives escapes as smoke.
865
+ *
866
+ * Colour and brightness are separate in every zone, the way professional
867
+ * fire shading keeps an intensity ramp apart from a colour ramp. The DEFAULTS
868
+ * keep the order a hot body glows in — each zone brighter and yellower than
869
+ * the one outside it — because breaking it is how dim yellow turns olive;
870
+ * `emission.test.ts` holds them to that.
871
+ *
872
+ * WHERE the flame is comes from its soot: it begins at `tip.from`, a fraction
873
+ * of FIRE_SOOT_SCALE, over `tip.softness`. WHAT COLOUR it is comes from its
874
+ * temperature (a fraction of FIRE_HEAT_SCALE): the tip gives way to the body
875
+ * around `tip.to`, and the core begins at `core.from`. Brightness is in
876
+ * multiples of paper white; colours are sRGB, the way a colour picker gives
877
+ * them.
878
+ *
879
+ * The body is ABOVE paper white. It was once held below it, because under a
880
+ * bright preset paper sits at the top of the tone curve and anything brighter
881
+ * loses its colour — and a flame dimmer than the paper behind it, drawn
882
+ * opaque, is a yellow decal on the sheet, not light. Fire photographed in a
883
+ * bright room DOES wash out; that is the lighting's to answer (the lab shoots
884
+ * under `noir`, as the references were), not the flame's.
885
+ */
886
+ interface FireZones {
887
+ root: {
888
+ color: string;
889
+ amount: number;
890
+ reach: number;
891
+ };
892
+ core: {
893
+ color: string;
894
+ glow: number;
895
+ from: number;
896
+ };
897
+ body: {
898
+ color: string;
899
+ glow: number;
900
+ };
901
+ tip: {
902
+ color: string;
903
+ glow: number;
904
+ from: number;
905
+ to: number;
906
+ softness: number;
907
+ tearing: number;
908
+ };
909
+ }
910
+ /** Any part of any zone, over the defaults. */
911
+ type FireZonesInput = {
912
+ [Z in keyof FireZones]?: Partial<FireZones[Z]>;
913
+ };
914
+ /**
915
+ * The defaults reproduce the look arrived at by measurement against
916
+ * Flame_base.png (yellow ~30%, orange ~40%, pale ~18% of a flame's pixels),
917
+ * now expressed zone by zone. Each colour is the linear emission the previous
918
+ * ramp used in that band, written as the sRGB a picker would show.
919
+ */
920
+ declare const FIRE_ZONES: FireZones;
921
+ /** The defaults with `input` laid over them, zone by zone. */
922
+ declare function fireZones(input?: FireZonesInput): FireZones;
923
+ /** A `#rrggbb` colour as linear RGB — what a shader multiplies light by. */
924
+ declare function hexToLinear(hex: string): [number, number, number];
925
+
926
+ interface AfterglowOptions {
927
+ /**
928
+ * How long a bead keeps glowing after the heat under it has gone, in
929
+ * seconds — a range, each cell drawn its own life from it by a seeded hash.
930
+ */
931
+ hold?: readonly [number, number];
932
+ /** Seed for which bead outlives which. */
933
+ seed?: number;
934
+ }
935
+ /**
936
+ * The embers a burn leaves behind, drawn over the field it came from.
937
+ *
938
+ * `paperlab-fx-fire-spec.md` §9, "Smoulder": after the flames, glowing spots
939
+ * crawl along the edge, flare on a breath, and go out one by one over
940
+ * seconds (§4.10). The field cannot do this, and it is not a tuning problem:
941
+ * a burn blown out loses its heat in a fraction of a second — below ignition
942
+ * everywhere or it recovers — so its own heat channel has nothing left to
943
+ * smoulder with.
944
+ *
945
+ * So this is presentation, and holds no simulation state. It wraps a field as
946
+ * a `DamageSource`: every channel is the field's, except HEAT, which is the
947
+ * field's heat or what each cell remembers of it, whichever is more. When the
948
+ * heat under a cell goes, the cell keeps a smoulder's worth of it and lets it
949
+ * go at its own pace — each cell's pace drawn once from a seeded hash, so
950
+ * beads go out one at a time, and the same burn goes out the same way. A
951
+ * breath flares what is left.
952
+ *
953
+ * Only the sheet reads it. Flames and the fire light read the FIELD, so the
954
+ * flames die with the gas (§4.1) while the edge keeps glowing; the physics
955
+ * reads char, saturation and presence, which pass through untouched.
956
+ */
957
+ declare class Afterglow implements DamageSource {
958
+ readonly size = 64;
959
+ readonly pixels: Uint8Array<ArrayBuffer>;
960
+ private readonly field;
961
+ private readonly glow;
962
+ private readonly rate;
963
+ private revision;
964
+ private clock;
965
+ private flare;
966
+ private lastField;
967
+ private lit;
968
+ constructor(field: DamageField, options?: AfterglowOptions);
969
+ get version(): number;
970
+ get detail(): number | undefined;
971
+ /** Its own clock: the beads keep flickering after the field has gone to sleep. */
972
+ get time(): number;
973
+ /** Whether anything is still glowing that the field no longer is. */
974
+ get smouldering(): boolean;
975
+ /**
976
+ * Advance by `dt`, after the field has stepped. `blow` is the breath on the
977
+ * sheet, 0..1: it flares what is left (§10.6, "on smoulder: beads flare,
978
+ * then fade").
979
+ */
980
+ step(dt: number, blow?: number): void;
981
+ /** Copy the field in, and lay the remembered heat over its heat channel. */
982
+ private sync;
983
+ }
984
+
985
+ interface FxFlamesProps {
986
+ /** The burning field. Flames stand on its hot rim — see `flameAnchors`. */
987
+ field: DamageField;
988
+ /** Where a point of the sheet is in the world — `(u, v) => handle.surfacePoint(u, v, scratch)`. */
989
+ locate: SurfaceLocator;
990
+ /** How many flames may stand at once: 8 / 16 / 32 on low / medium / high. */
991
+ quality?: FxQualityTier;
992
+ /**
993
+ * The air, world units a second — pass the particle pool's `wind`, which is
994
+ * read every frame. Flames lean away from it and shorten (§10.6).
995
+ */
996
+ wind?: readonly [number, number, number];
997
+ }
998
+ /**
999
+ * The flames of a burn: separate tongues of burning gas, standing just off
1000
+ * the char, rising straight up whatever the sheet is doing.
1001
+ *
1002
+ * `paperlab-fx-fire-spec.md` §6. Each tongue is a quad that turns to face the
1003
+ * camera about WORLD up — never the sheet's normal (§13.9) — so a flame on a
1004
+ * sheet held flat still rises to the ceiling. The shader is upward-scrolling,
1005
+ * domain-warped noise inside a teardrop, coloured on the flame's own
1006
+ * blackbody ramp: a dim indigo root, a yellow-white core, orange edges and
1007
+ * tips, and a torn top that sheds wisps. Every flame puffs on its own noise
1008
+ * at 10–15 Hz, so no two ever move in step and nothing is a sine.
1009
+ *
1010
+ * Additive and HDR: the core is several times paper white, so `FxPost`
1011
+ * blooms it; it writes no depth, so the flames never occlude each other or
1012
+ * flicker as they sort. It animates on the FIELD's clock, so a replayed burn
1013
+ * burns with the same flames.
1014
+ */
1015
+ declare function FxFlames({ field, locate, quality, wind }: FxFlamesProps): react.JSX.Element;
1016
+
1017
+ interface FxWispsProps {
1018
+ /** What the sheet draws: where the smouldering beads are. */
1019
+ glow: Afterglow;
1020
+ /** The field under it — a wisp rises where the glow outlives the heat. */
1021
+ field: DamageField;
1022
+ locate: SurfaceLocator;
1023
+ /** The air, read every frame — pass the pool's `wind`. */
1024
+ wind?: readonly [number, number, number];
1025
+ }
1026
+ /**
1027
+ * The smoulder wisp: the shot people screenshot (`Stage_5__cold.png`).
1028
+ *
1029
+ * Not smoke sprites. A thin, pale thread — a trail of connected points drawn
1030
+ * as a ribbon — rising from a glowing bead once the flames are gone, curving
1031
+ * in a slow S as it climbs, and lingering a few seconds after the last bead
1032
+ * has gone out. One or two at a time.
1033
+ *
1034
+ * Every point's position is a pure function of where and when it left its
1035
+ * bead, on the afterglow's clock, so a scripted burn draws the same wisp
1036
+ * every time and nothing here integrates anything that could drift.
1037
+ */
1038
+ declare function FxWisps({ glow, field, locate, wind }: FxWispsProps): react.JSX.Element;
1039
+
1040
+ /**
1041
+ * The fire simulator's controls — by the names, and at the defaults, of the
1042
+ * fluid-fire tool Noor pointed at (Emission, Combustion, Fuel & air, Motion
1043
+ * & turbulence), so the lab's panel reads like the one she knows.
1044
+ *
1045
+ * The numbers are the panel's, not the solver's. `solverUniforms` is the one
1046
+ * place they are turned into this solver's units — world units (a default
1047
+ * sheet is one unit, 210 mm, across) and seconds — so a value can be copied
1048
+ * between the two tools and mean roughly the same thing, and so tuning the
1049
+ * look never means renaming a control.
1050
+ *
1051
+ * What the chemistry terms mean here, since no tool documents its own:
1052
+ *
1053
+ * fuel gas released at the burning rim, per second.
1054
+ * premixed oxygen the share of that gas that arrives already mixed with
1055
+ * oxygen — it can burn the moment it leaves the paper.
1056
+ * ambient oxygen oxygen in the surrounding air, which fuel burns with as
1057
+ * it mixes in. At 0 only the premixed share ever burns,
1058
+ * so flames stay close to the rim.
1059
+ * heat temperature released with the gas — paper's gas leaves
1060
+ * it hot, and that heat is what lets its soot form and
1061
+ * glow before it has burnt (swept to 1, most of the flame
1062
+ * went out).
1063
+ * flame persistence how long glowing soot lasts in open air before it
1064
+ * burns away — the soot a flame's light comes from.
1065
+ * air mixing how fast air works into the fuel, and so how far up a
1066
+ * tongue's fuel core survives.
1067
+ */
1068
+ interface FireFluidParams {
1069
+ fuel: number;
1070
+ premixedOxygen: number;
1071
+ heat: number;
1072
+ smoke: number;
1073
+ radialImpulse: number;
1074
+ initialVelocity: readonly [number, number, number];
1075
+ burnRate: number;
1076
+ gasExpansion: number;
1077
+ buoyancy: number;
1078
+ cooling: number;
1079
+ smokeProduction: number;
1080
+ ambientOxygen: number;
1081
+ flamePersistence: number;
1082
+ /**
1083
+ * How fast air works its way into the fuel, 0..1. Ours, not the panel's.
1084
+ * Low and the fuel core survives a long way up — tall tongues with long
1085
+ * tips; high and the fuel burns out close to the paper — short licks.
1086
+ */
1087
+ airMixing: number;
1088
+ turbulence: number;
1089
+ turbulenceScale: number;
1090
+ vorticity: number;
1091
+ wind: number;
1092
+ /** How long smoke lingers, in seconds — long enough and it hangs as a haze. Ours, not the panel's. */
1093
+ smokeFade: number;
1094
+ /**
1095
+ * How long a spot of the rim goes on smoking once its flame is out, in
1096
+ * seconds. Ours, not the panel's. Paper does not stop smoking when it stops
1097
+ * burning: the char is still hot, and a thin thread goes on rising from it.
1098
+ */
1099
+ smokeAfter: number;
1100
+ }
1101
+ /**
1102
+ * The defaults — the panel's names, at the values Noor tuned in the lab on
1103
+ * 2026-09-12. No turbulence of its own: the motion comes from vorticity,
1104
+ * the radial impulse and the uneven rim that feeds it.
1105
+ */
1106
+ declare const fireFluidDefaults: FireFluidParams;
1107
+ /** Slider ranges for the lab, grouped the way the panel groups them. */
1108
+ declare const fireFluidControls: readonly {
1109
+ group: string;
1110
+ key: Exclude<keyof FireFluidParams, 'initialVelocity'>;
1111
+ label: string;
1112
+ min: number;
1113
+ max: number;
1114
+ step: number;
1115
+ }[];
1116
+ /** What the solver's passes read, in its own units. */
1117
+ interface SolverUniforms {
1118
+ fuel: number;
1119
+ premixed: number;
1120
+ heat: number;
1121
+ smoke: number;
1122
+ radial: number;
1123
+ initialVelocity: [number, number];
1124
+ burnRate: number;
1125
+ heatRelease: number;
1126
+ expansion: number;
1127
+ buoyancy: number;
1128
+ cooling: number;
1129
+ smokeProduction: number;
1130
+ smokeFade: number;
1131
+ ambient: number;
1132
+ persistence: number;
1133
+ /** Share of the neighbours' oxygen mixed in per step, in the plane. */
1134
+ mixing: number;
1135
+ /** Per second: air drawn in from in front of and behind the slice. */
1136
+ entrain: number;
1137
+ /** Fuel density at which entrainment is down to 1/e — dense fuel keeps air out. */
1138
+ fuelBlock: number;
1139
+ /** Soot formed per unit of hot fuel a second. */
1140
+ sootYield: number;
1141
+ /** The temperature soot needs to form and glow at. */
1142
+ sootHeat: number;
1143
+ /** Units of air a unit of fuel burns with. */
1144
+ stoich: number;
1145
+ /** Smoke a second, per unit of smouldering strength, where a flame has gone out. */
1146
+ smoulderSmoke: number;
1147
+ /** Heat with it — enough that the smoke rises, not enough to glow. */
1148
+ smoulderHeat: number;
1149
+ turbulence: number;
1150
+ turbulenceScale: number;
1151
+ /** Noise units a second the turbulence changes by, on top of rising with the gas. */
1152
+ turbulenceEvolve: number;
1153
+ vorticity: number;
1154
+ wind: number;
1155
+ }
1156
+ /**
1157
+ * The panel's numbers, in the solver's units. Every scale here is a tuning
1158
+ * decision made by eye against the references, stated once so it is never
1159
+ * smeared across shaders.
1160
+ */
1161
+ declare function solverUniforms(p: FireFluidParams): SolverUniforms;
1162
+
1163
+ interface FxFireFluidProps {
1164
+ field: DamageField;
1165
+ locate: SurfaceLocator;
1166
+ quality?: FxQualityTier;
1167
+ /** The simulator's controls; anything left out takes the panel's default. */
1168
+ params?: Partial<FireFluidParams>;
1169
+ /** False freezes the fire where it is — a paused lab. */
1170
+ running?: boolean;
1171
+ /**
1172
+ * Change it to start the fire over: the air is emptied and the fire is
1173
+ * warmed up from the rim as it stands now, so a burn that was just seeked
1174
+ * to a moment shows that moment's flames rather than empty air.
1175
+ */
1176
+ resetKey?: unknown;
1177
+ /** How bright the fire glows, against paper white. */
1178
+ glow?: number;
1179
+ /**
1180
+ * The flame's four zones — root, core, body and tip — each part laid over
1181
+ * `FIRE_ZONES`. See `FireZones` for what each zone is and why it looks the
1182
+ * way it does.
1183
+ */
1184
+ zones?: FireZonesInput;
1185
+ /** The solver temperature that counts as a flame's hottest gas. */
1186
+ heatScale?: number;
1187
+ /** The soot density that counts as a full flame — see `FIRE_SOOT_SCALE`. */
1188
+ sootScale?: number;
1189
+ /** Gamma on the flame's temperature — above 1 darkens the body against the core. */
1190
+ contrast?: number;
1191
+ /** How opaque the densest flame gas is; 0 is purely additive fire. */
1192
+ opacity?: number;
1193
+ /** How opaque gas must be to glow fully; see `FIRE_THIN`. */
1194
+ thin?: number;
1195
+ /**
1196
+ * Seconds of fire run, unseen, whenever it starts over (see `resetKey`).
1197
+ * A plume started from still air rolls its leading edge into a mushroom cap
1198
+ * — the starting vortex — and a fire that has burned for seconds has long
1199
+ * since shed it. Too short and a seeked-to frame shows that cap.
1200
+ */
1201
+ warm?: number;
1202
+ /**
1203
+ * Error-compensated (MacCormack) advection for what is drawn. Defaults to
1204
+ * on everywhere but the `low` tier, where the two extra passes a step are
1205
+ * the first thing a throttled phone should give back.
1206
+ */
1207
+ sharp?: boolean;
1208
+ /**
1209
+ * What to draw where the simulator cannot run (no half-float render
1210
+ * targets) — typically `<FxFlames>`. Drawn INSTEAD, never as well.
1211
+ */
1212
+ fallback?: ReactNode;
1213
+ }
1214
+ /**
1215
+ * Fire, simulated — the flames and the smoke of a burning sheet as a fluid:
1216
+ * gas released along the hot rim, burning where it has oxygen, rising on its
1217
+ * own heat, torn by turbulence, cooling out of sight, leaving smoke.
1218
+ *
1219
+ * The damage field still decides everything about the PAPER; this is only
1220
+ * what the air above it does. The rim is read on the CPU as emission points —
1221
+ * the same irregular clusters `flameAnchors` stands the sprite flames on, so
1222
+ * the fire still gathers in tall tongues above a hole and short licks below
1223
+ * it — and nothing is ever read back from the GPU.
1224
+ *
1225
+ * It lives in a vertical plane through the sheet, facing the camera, so the
1226
+ * flames rise straight up whatever the sheet is doing. Needs half-float
1227
+ * render targets; where there are none it renders nothing, and the caller
1228
+ * falls back to `FxFlames` (see `FireFluid.supported`).
1229
+ */
1230
+ declare function FxFireFluid({ field, locate, quality, params, running, resetKey, glow, zones, heatScale, sootScale, contrast, opacity, thin, warm, sharp, fallback, }: FxFireFluidProps): react.JSX.Element;
1231
+
1232
+ /** Grid sizes for one tier. Both grids share the domain's aspect, so cells are square. */
1233
+ interface FluidGrid {
1234
+ /** Velocity and pressure — coarse, because they are smooth. */
1235
+ velocity: readonly [number, number];
1236
+ /** Fuel, heat, smoke, flame and air — fine, because that is what is drawn. */
1237
+ dye: readonly [number, number];
1238
+ /** Jacobi iterations for the pressure solve. */
1239
+ iterations: number;
1240
+ }
1241
+ /** A pair of targets, read one and write the other, swapped after each pass. */
1242
+ declare class Pair {
1243
+ read: THREE.WebGLRenderTarget;
1244
+ write: THREE.WebGLRenderTarget;
1245
+ constructor(w: number, h: number);
1246
+ swap(): void;
1247
+ dispose(): void;
1248
+ }
1249
+ /**
1250
+ * A fire, simulated on the GPU: a 2D grid fluid with combustion.
1251
+ *
1252
+ * Owns its render targets and the passes over them, and nothing about where
1253
+ * the fire is drawn — `FxFireFluid` places it. Every step reads the burning
1254
+ * rim as a list of emission points (position, radius, strength) the caller
1255
+ * works out on the CPU from the damage field, so nothing is ever read back
1256
+ * from the GPU.
1257
+ */
1258
+ declare class FireFluid {
1259
+ private readonly renderer;
1260
+ readonly grid: FluidGrid;
1261
+ /** The domain's size in world units — width, height. */
1262
+ readonly domain: {
1263
+ width: number;
1264
+ height: number;
1265
+ };
1266
+ /** (fuel, heat, smoke, soot) on the fine grid — what is drawn. */
1267
+ readonly scalars: Pair;
1268
+ /** (premixed oxygen, ambient oxygen, burn rate, flame heat) on the fine grid. */
1269
+ readonly air: Pair;
1270
+ readonly velocity: Pair;
1271
+ readonly pressure: Pair;
1272
+ /** The rim's emission this step, on the fine grid — see EMIT. */
1273
+ private readonly emit;
1274
+ private readonly divergence;
1275
+ private readonly curl;
1276
+ /** The two intermediate advections the MacCormack step compares. */
1277
+ private readonly forward;
1278
+ private readonly backward;
1279
+ /**
1280
+ * Error-compensated advection for the drawn fields. On by default; off is
1281
+ * a single semi-Lagrangian step, which is two passes a step cheaper and
1282
+ * visibly softer — a knob for the lowest tier, and for an A/B.
1283
+ */
1284
+ sharp: boolean;
1285
+ private readonly scene;
1286
+ private readonly camera;
1287
+ private readonly mesh;
1288
+ private readonly m;
1289
+ private readonly sources;
1290
+ private readonly across;
1291
+ /** Whether this renderer can draw into half-float targets at all. */
1292
+ static supported(renderer: THREE.WebGLRenderer): boolean;
1293
+ constructor(renderer: THREE.WebGLRenderer, grid: FluidGrid,
1294
+ /** The domain's size in world units — width, height. */
1295
+ domain: {
1296
+ width: number;
1297
+ height: number;
1298
+ });
1299
+ /** Empty air, at the given ambient oxygen. */
1300
+ reset(ambient: number): void;
1301
+ /**
1302
+ * One step of `dt` seconds. `sources` is (u, v, half-length, strength) per
1303
+ * rim segment and `across` is (toward-paper x, y, half-width, offset onto
1304
+ * the paper) — see EMIT — `count` of each; `time` drives the turbulence.
1305
+ */
1306
+ step(dt: number, u: SolverUniforms, sources: Float32Array, across: Float32Array, count: number, time: number): void;
1307
+ dispose(): void;
1308
+ private run;
1309
+ /** Passes change the renderer's target and clearing; put both back. */
1310
+ private withRenderer;
1311
+ }
1312
+
1313
+ interface FxFireLightProps {
1314
+ field: DamageField;
1315
+ locate: SurfaceLocator;
1316
+ /** How bright the fire is per unit of burning front. Tuned against Hero.png. */
1317
+ gain?: number;
1318
+ }
1319
+ /**
1320
+ * Fire is a light source (§4.8, §7). A fire that changes nothing around it
1321
+ * looks pasted on.
1322
+ *
1323
+ * A warm point light — about 1900 K — at the middle of the burning rim,
1324
+ * raised by half a flame, as bright as the front is long and flickering on
1325
+ * the very noise the flames puff on, so light and flame agree. It reaches the
1326
+ * whole sheet: `decay` is held below the physical 2, because a sheet is a
1327
+ * small thing next to a fire and Hero.png warms it to the top edge.
1328
+ *
1329
+ * And a dimmer twin on the FAR side of the sheet, for translucency: paper is
1330
+ * thin, and a fire in front of it glows through to the back, warm and
1331
+ * diffused (§7). A second light is the whole trick — the sheet's own
1332
+ * material already lights its back face from whatever is behind it.
1333
+ */
1334
+ declare function FxFireLight({ field, locate, gain }: FxFireLightProps): react.JSX.Element;
1335
+
1336
+ /** What the page says about the match this frame. Written in place — never a prop change. */
1337
+ interface MatchFlameState {
1338
+ /** Where the flame's root is, in world space; null when there is no match. */
1339
+ position: {
1340
+ x: number;
1341
+ y: number;
1342
+ z: number;
1343
+ } | null;
1344
+ /** Arming is the dwell before a pinch becomes a match; lit is a match. */
1345
+ state: 'none' | 'arming' | 'lit';
1346
+ /** How hard the viewer is blowing, 0..1. */
1347
+ blow: number;
1348
+ /** Whether the flame is held against the sheet. */
1349
+ touching: boolean;
1350
+ /**
1351
+ * A clock to animate on, in seconds, instead of the frame clock — and
1352
+ * `litAt`, when on that clock the match was struck.
1353
+ *
1354
+ * For a scripted burn (`/fx-lab`) that has to photograph the same match
1355
+ * twice: given both, the flare is a function of time rather than of which
1356
+ * frame saw the strike, and no random sparks or puffs are thrown. A live
1357
+ * hand leaves both out.
1358
+ */
1359
+ time?: number;
1360
+ litAt?: number;
1361
+ }
1362
+ interface FxMatchFlameProps {
1363
+ /** Read every frame. A ref, so a hand moving the match re-renders nothing. */
1364
+ match: {
1365
+ readonly current: MatchFlameState;
1366
+ };
1367
+ }
1368
+ /**
1369
+ * A match, held: `paperlab-fx-fire-spec.md` §10, the parts that are the
1370
+ * flame's to draw.
1371
+ *
1372
+ * - **Arming.** Friction building: a few tiny sparks and a warm point that
1373
+ * grows at the pinch. A pinch released early (a flick) fizzles — nothing
1374
+ * lights, because the page never says `lit`.
1375
+ * - **Strike.** A one-frame flash of light, a burst of sparks, a puff, and
1376
+ * the flame flares to about twice its height for under half a second and
1377
+ * settles — a real match head flares as it burns off.
1378
+ * - **Held.** A 20 mm teardrop that flickers, leans away from where the hand
1379
+ * is going with a lag, and stretches thin and burns blue and dim when moved
1380
+ * fast. Against the paper it flattens and spills upward.
1381
+ * - **Blown.** It leans away and flickers hard; when the page says it has
1382
+ * gone out, it leaves a soft puff behind.
1383
+ * - **Light.** It lights the sheet warm before anything scorches — bringing
1384
+ * the flame near the paper is the first thing people notice (§10.4).
1385
+ *
1386
+ * It owns its own clock and its own handful of particles: nothing here is
1387
+ * part of a burn anyone replays.
1388
+ */
1389
+ declare function FxMatchFlame({ match }: FxMatchFlameProps): react.JSX.Element;
1390
+
1391
+ /**
1392
+ * Where a burn's flames stand, read off the field.
1393
+ *
1394
+ * `paperlab-fx-fire-spec.md` §4.1: paper does not burn, the gas does. Heat
1395
+ * cooks the cellulose into gas, which burns just ABOVE the surface, rooted
1396
+ * over the char right behind the ember line — so a flame belongs on the edge
1397
+ * of the hole, where it is hot, and nowhere else. Where the heat has gone
1398
+ * there is no gas left and no flame, even if the edge still glows.
1399
+ *
1400
+ * Pure and deterministic, because flames are part of the picture a capture
1401
+ * compares: the same burn at the same step stands the same flames in the
1402
+ * same places. Anchors are spread along the rim by binning the grid — the
1403
+ * hottest edge cell in each bin — rather than taken in scan order, which
1404
+ * would put all of them on the first rows of the hole.
1405
+ */
1406
+ /** One flame: where it stands, how big it is, and who it is. */
1407
+ interface FlameAnchor {
1408
+ /** Root, in world space. */
1409
+ x: number;
1410
+ y: number;
1411
+ z: number;
1412
+ /** World units. 10–40 mm at A4 (§6), scaled by heat and by which rim it is on. */
1413
+ height: number;
1414
+ width: number;
1415
+ /** Stable while the cell burns, so a flame keeps its own flicker frame to frame. */
1416
+ seed: number;
1417
+ /** 0..1 — how hot the paper under it is. */
1418
+ heat: number;
1419
+ /** -1 on the lower rim of a hole, +1 on the upper. */
1420
+ upper: number;
1421
+ /**
1422
+ * Which way the paper lies from the root, as a world-space unit vector —
1423
+ * across the rim, into the sheet. Zero where it could not be found. The
1424
+ * simulator lays its gas along the rim with this, rather than in a disc.
1425
+ */
1426
+ nx: number;
1427
+ ny: number;
1428
+ nz: number;
1429
+ }
1430
+ /** 10 and 40 mm, in world units: a default sheet is one unit (210 mm) across. */
1431
+ declare const FLAME_HEIGHT: readonly [number, number];
1432
+ /**
1433
+ * Fill `out` with up to `max` flames for the field as it stands at `time`.
1434
+ * Returns how many. `out` is reused and grown, never shrunk.
1435
+ *
1436
+ * Not one flame per stretch of rim — that was a crown. A living, uneven
1437
+ * ring: a noise field around the rim, drifting in time, decides where the
1438
+ * fire gathers. Where it is strong there are clusters of tall tongues with
1439
+ * short ones packed beside them; where it is weak, short licks and gaps.
1440
+ * Which cells carry a flame is redrawn every fraction of a second, and each
1441
+ * flame has its own life that fades it in and out, so clusters break apart
1442
+ * and re-form rather than standing still. All of it is a pure function of
1443
+ * the field and the time: the same burn stands the same flames.
1444
+ */
1445
+ declare function flameAnchors(field: DamageField, locate: SurfaceLocator, max: number, out: FlameAnchor[], time?: number): number;
1446
+ /**
1447
+ * How much a flame has puffed up at a moment — the same function the flame
1448
+ * shader runs, so the fire light flickers WITH the flames rather than beside
1449
+ * them (§7). Value noise, 10–15 Hz, seeded per flame: neighbours never move
1450
+ * in step, and nothing is a sine.
1451
+ */
1452
+ declare function flamePuff(seed: number, time: number): number;
1453
+
1454
+ /**
1455
+ * The audio graph: a context, a ceiling, and somewhere for a sound to go.
1456
+ *
1457
+ * There was no audio anywhere in this library or its three apps before this —
1458
+ * not an `AudioContext`, not a clip. Sound was not under-built, it was
1459
+ * absent, and that is most of why the wind reads as weightless: a sheet
1460
+ * moving in silence is a picture of a sheet moving.
1461
+ *
1462
+ * Two decisions are baked in here rather than left to each effect.
1463
+ *
1464
+ * **Synthesis, not samples.** A triggered crumple clip is the same crumple
1465
+ * every time, it does not know how fast you crushed it, and two overlapping
1466
+ * copies turn to mush. Paper sounds are broadband noise shaped by an
1467
+ * envelope, which is the cheapest thing a synthesiser does and the thing
1468
+ * sample playback is worst at. It also means no licensing question at all, in
1469
+ * a repo that has already had to reason carefully about the licence on the
1470
+ * hand-tracking weights.
1471
+ *
1472
+ * **A hard ceiling, from the tier.** Fire and crumple both want to be grain
1473
+ * clouds — dozens of short bursts a second — and a grain cloud with no
1474
+ * ceiling is an unbounded number of live nodes on a phone that is already
1475
+ * running a camera, a hand tracker and a cloth simulation. So voices come
1476
+ * from a fixed pool sized by `fx/quality.ts`, and asking for one past the
1477
+ * ceiling steals the least audible voice rather than allocating. Dropping the
1478
+ * NEW sound would be the obvious alternative and it is wrong: the newest
1479
+ * sound is the one the viewer just caused, and silence in response to an
1480
+ * action reads as a broken page.
1481
+ *
1482
+ * **What is testable, and what is not.** Everything above is policy — pool
1483
+ * size, stealing, the unlock state machine, gain arithmetic — and none of it
1484
+ * needs a browser. The DSP is a handful of node connections that only a
1485
+ * browser can make. So the context is injected rather than constructed, the
1486
+ * policy is tested against a stub, and what ships to the browser is the same
1487
+ * code path with a real `AudioContext` in it.
1488
+ */
1489
+ /**
1490
+ * The part of the Web Audio API this uses.
1491
+ *
1492
+ * Written out rather than referring to `AudioContext` so that the policy can
1493
+ * be tested without a DOM, and so that the surface this depends on is small
1494
+ * enough to read. Anything added here is a new thing to stub.
1495
+ */
1496
+ interface AudioLike {
1497
+ readonly currentTime: number;
1498
+ readonly state: 'suspended' | 'running' | 'closed';
1499
+ readonly destination: AudioNodeLike;
1500
+ createGain(): GainLike;
1501
+ createBufferSource(): BufferSourceLike;
1502
+ /**
1503
+ * Every paper sound is noise with the wrong frequencies taken out of it —
1504
+ * a fire is a low rumble under a bright crackle, a tear is the same noise
1505
+ * with the bottom removed. Without a filter there is one sound in the whole
1506
+ * library: white noise at different volumes.
1507
+ */
1508
+ createBiquadFilter(): BiquadFilterLike;
1509
+ /**
1510
+ * Where the sound is. A sheet you are holding at arm's length, burning at
1511
+ * one corner, is not a sound in the middle of your head.
1512
+ */
1513
+ createPanner(): PannerLike;
1514
+ createBuffer(channels: number, length: number, sampleRate: number): AudioBufferLike;
1515
+ readonly sampleRate: number;
1516
+ resume(): Promise<void>;
1517
+ close(): Promise<void>;
1518
+ }
1519
+ interface AudioNodeLike {
1520
+ connect(destination: AudioNodeLike): void;
1521
+ disconnect(): void;
1522
+ }
1523
+ interface AudioParamLike {
1524
+ value: number;
1525
+ setValueAtTime(value: number, when: number): void;
1526
+ linearRampToValueAtTime(value: number, when: number): void;
1527
+ cancelScheduledValues(when: number): void;
1528
+ }
1529
+ interface GainLike extends AudioNodeLike {
1530
+ readonly gain: AudioParamLike;
1531
+ }
1532
+ interface BiquadFilterLike extends AudioNodeLike {
1533
+ type: 'lowpass' | 'highpass' | 'bandpass' | 'notch' | 'peaking' | 'lowshelf' | 'highshelf' | 'allpass';
1534
+ readonly frequency: AudioParamLike;
1535
+ readonly Q: AudioParamLike;
1536
+ }
1537
+ interface PannerLike extends AudioNodeLike {
1538
+ panningModel: 'equalpower' | 'HRTF';
1539
+ distanceModel: 'linear' | 'inverse' | 'exponential';
1540
+ refDistance: number;
1541
+ maxDistance: number;
1542
+ rolloffFactor: number;
1543
+ readonly positionX: AudioParamLike;
1544
+ readonly positionY: AudioParamLike;
1545
+ readonly positionZ: AudioParamLike;
1546
+ }
1547
+ interface AudioBufferLike {
1548
+ getChannelData(channel: number): Float32Array;
1549
+ readonly length: number;
1550
+ }
1551
+ interface BufferSourceLike extends AudioNodeLike {
1552
+ buffer: AudioBufferLike | null;
1553
+ loop: boolean;
1554
+ /**
1555
+ * `offset` and `duration` are what make one shared second of noise into
1556
+ * every crackle in a fire: a different slice each time, ending itself
1557
+ * without anything having to schedule a stop.
1558
+ */
1559
+ start(when?: number, offset?: number, duration?: number): void;
1560
+ stop(when?: number): void;
1561
+ onended: (() => void) | null;
1562
+ }
1563
+ interface FxAudioOptions {
1564
+ quality?: FxQualityName | FxQualitySettings;
1565
+ /**
1566
+ * The context to use. Required, and never made for you: constructing an
1567
+ * `AudioContext` is a side effect with a user-visible cost (a browser tab
1568
+ * shows an audio indicator), and a class that made one on construction
1569
+ * would take that from a page that never asked. Get a browser one from
1570
+ * `createAudioContext()`, inside the gesture that should start the sound.
1571
+ */
1572
+ context: AudioLike;
1573
+ /** Master level, 0..1. */
1574
+ volume?: number;
1575
+ }
1576
+ /** A sound in flight. Handed back so a caller can shape or stop it. */
1577
+ interface Voice {
1578
+ readonly id: number;
1579
+ /** What this voice is for. Only used to explain who got stolen. */
1580
+ readonly kind: string;
1581
+ /**
1582
+ * How much this voice deserves to survive, 0..1.
1583
+ *
1584
+ * The ONLY input to stealing, and it is not loudness. A near-silent tail of
1585
+ * a crackle is worth less than a quiet new ignition, because one is ending
1586
+ * and the other is something the viewer just did. Effects set this and the
1587
+ * pool obeys it.
1588
+ */
1589
+ priority: number;
1590
+ readonly gain: GainLike;
1591
+ readonly startedAt: number;
1592
+ /**
1593
+ * Hand a source to this voice, so it lives and dies with it.
1594
+ *
1595
+ * Every source that plays through a voice must be given to it, because the
1596
+ * voice is the only thing that knows when to stop it. A voice that did not
1597
+ * own its sources could only fade its gain when stolen — and a looping bed
1598
+ * behind a silent gain keeps running, and keeps its nodes, for the life of
1599
+ * the page.
1600
+ *
1601
+ * A one-shot that ends on its own ends the voice with it; a voice already
1602
+ * released stops the source at once rather than letting it play unheard.
1603
+ */
1604
+ own(source: BufferSourceLike): void;
1605
+ /**
1606
+ * Hand a NODE to this voice, so it is disconnected with it.
1607
+ *
1608
+ * The same rule as {@link own}, for the filters and panners a sound is
1609
+ * shaped by. A crackle makes its own filter — a few dozen a second in a
1610
+ * good burn — and a filter left connected to a freed gain is the same leak
1611
+ * the sources used to have, in a part of the graph nothing was watching.
1612
+ */
1613
+ use(node: AudioNodeLike): void;
1614
+ /** Stop and return this voice to the pool. */
1615
+ stop(): void;
1616
+ }
1617
+ declare class FxAudio {
1618
+ readonly quality: FxQualitySettings;
1619
+ private readonly ctx;
1620
+ private readonly master;
1621
+ private readonly live;
1622
+ /**
1623
+ * What each voice owns, until the last of it has actually stopped.
1624
+ *
1625
+ * Outlives the voice's place in `live` on purpose: a stolen voice leaves the
1626
+ * pool at once, so the new sound can have its slot, but its sources are
1627
+ * still fading for a few milliseconds and its gain is still connected. The
1628
+ * nodes are freed when the last source reports `ended`, not before — cut
1629
+ * sooner and the fade that stops stealing from clicking is cut with it.
1630
+ */
1631
+ private readonly slots;
1632
+ private nextId;
1633
+ private unlocked;
1634
+ private noise;
1635
+ constructor(options: FxAudioOptions);
1636
+ get context(): AudioLike;
1637
+ /** Live voices right now. Never more than the tier's ceiling. */
1638
+ get voices(): readonly Voice[];
1639
+ get isUnlocked(): boolean;
1640
+ get volume(): number;
1641
+ set volume(value: number);
1642
+ /**
1643
+ * Let sound happen, from inside a user gesture.
1644
+ *
1645
+ * Every browser starts an `AudioContext` suspended until a real interaction
1646
+ * resumes it, and a page that calls this from anywhere else is a page whose
1647
+ * audio silently never starts. Here the cost is zero: `/hands` already has
1648
+ * a button that turns the camera on, and nothing can be heard before the
1649
+ * camera is on anyway.
1650
+ *
1651
+ * Safe to call more than once — it is a latch, not a toggle.
1652
+ */
1653
+ unlock(): Promise<void>;
1654
+ /**
1655
+ * One second of white noise, made once and shared.
1656
+ *
1657
+ * Nearly every paper sound is filtered noise — a crumple is a cloud of
1658
+ * short bursts of it, a fire is a bed of it, a tear is a fast train of it —
1659
+ * so this is the single most reused object in the graph. Generating it per
1660
+ * voice would allocate a second of audio per crackle.
1661
+ */
1662
+ noiseBuffer(): AudioBufferLike;
1663
+ /**
1664
+ * Take a voice, stealing the least deserving one if the pool is full.
1665
+ *
1666
+ * Returns null only when the ceiling is zero or the caller's priority is
1667
+ * below everything already playing — the one case where refusing is right,
1668
+ * because the alternative is cutting off something more important to play
1669
+ * something less.
1670
+ */
1671
+ take(kind: string, priority?: number): Voice | null;
1672
+ private own;
1673
+ private useNode;
1674
+ private ended;
1675
+ /** Disconnect a voice's gain. Only once nothing that feeds it is still running. */
1676
+ private free;
1677
+ /**
1678
+ * Return a voice to the pool, fading it out first.
1679
+ *
1680
+ * The fade is the whole reason this is not just a splice. Cutting a
1681
+ * waveform mid-cycle produces a step, and a step is a click — audible,
1682
+ * cheap-sounding, and most likely to happen exactly when the most is going
1683
+ * on, since that is when voices get stolen.
1684
+ */
1685
+ private release;
1686
+ /** Stop everything. The panic button, and what unmounting calls. */
1687
+ stopAll(): void;
1688
+ /**
1689
+ * A short tone, to prove the chain end to end.
1690
+ *
1691
+ * Deliberately part of the shipped surface rather than a test fixture:
1692
+ * "is the audio graph actually connected" is a question that comes up on
1693
+ * every device, and the honest answer is a sound you can hear. It uses the
1694
+ * same path everything else does — a voice from the pool, the noise buffer,
1695
+ * the master gain — so if this is audible the path works.
1696
+ */
1697
+ test(duration?: number): Voice | null;
1698
+ /** Release the context. Nothing survives this. */
1699
+ dispose(): Promise<void>;
1700
+ }
1701
+ /**
1702
+ * Make a real browser context.
1703
+ *
1704
+ * Separate from the constructor so that importing this module never starts an
1705
+ * audio context — a browser shows an indicator for one, and a library that
1706
+ * lights it up on import is a library that has taken something that was not
1707
+ * offered.
1708
+ */
1709
+ declare function createAudioContext(): AudioLike;
1710
+
1711
+ /**
1712
+ * What a burning sheet sounds like, driven by the numbers that make it burn.
1713
+ *
1714
+ * Two layers, and the split is the whole design.
1715
+ *
1716
+ * A **bed**: one looping band of filtered noise whose level follows the
1717
+ * LENGTH of the burn front. Not the burnt area — a fire gets louder as its
1718
+ * edge gets longer, and a nearly-consumed sheet is quiet again, which a level
1719
+ * driven by char would get exactly backwards. It brightens as it grows too:
1720
+ * a big fire is not a small fire turned up.
1721
+ *
1722
+ * A **crackle**: one short burst of bright noise for every few texels that
1723
+ * char, so the rate is the rate the paper is actually catching. Each is a
1724
+ * different slice of the shared second of noise through its own bandpass, so
1725
+ * no two are the same sound; each ends itself.
1726
+ *
1727
+ * Both read `FieldStats` and nothing else. Sound and picture therefore cannot
1728
+ * drift apart: the number that raises the flame's glow is the number that
1729
+ * raises its voice. A seed makes a replayed burn crackle the same way, which
1730
+ * is the same property the untiered simulation exists to give.
1731
+ */
1732
+ interface FireSoundOptions {
1733
+ /** Loudest the bed gets, 0..1. */
1734
+ volume?: number;
1735
+ /**
1736
+ * The front length that counts as a fire at full blast, as the fraction of
1737
+ * the sheet's texels on it. Four percent of a 64² grid is about 160 cells,
1738
+ * which is a sheet burning across its whole width.
1739
+ */
1740
+ fullFront?: number;
1741
+ /** Crackles per texel that chars. */
1742
+ crackle?: number;
1743
+ /** So the same burn sounds the same twice. */
1744
+ seed?: number;
1745
+ }
1746
+ /** A place in the world, as little of one as this needs. */
1747
+ interface SoundAt {
1748
+ readonly x: number;
1749
+ readonly y: number;
1750
+ readonly z: number;
1751
+ }
1752
+ declare class FireSound {
1753
+ private readonly audio;
1754
+ private readonly o;
1755
+ private bed;
1756
+ private level;
1757
+ private debt;
1758
+ private state;
1759
+ constructor(audio: FxAudio, options?: FireSoundOptions);
1760
+ /** True while the bed is playing. */
1761
+ get burning(): boolean;
1762
+ /**
1763
+ * Call once a frame with what the field just did, and where the sheet is.
1764
+ *
1765
+ * `at` is optional: without it the fire is not placed in the room, which is
1766
+ * right for a sheet filling the frame and wrong for one held at arm's
1767
+ * length.
1768
+ */
1769
+ update(dt: number, stats: FieldStats, at?: SoundAt | null): void;
1770
+ /**
1771
+ * A match struck: the scratch of the head across the box, then the hiss of
1772
+ * it flaring as the head burns off (spec §10.2). Two bursts of the shared
1773
+ * noise, one bright and short, one breathier and longer.
1774
+ */
1775
+ strike(): void;
1776
+ /** Blown out: a soft, low breath of noise (spec §10.6). */
1777
+ puff(): void;
1778
+ /** One ember popping in the air — a tiny click on the frame it flashes (spec §8.1). */
1779
+ pop(): void;
1780
+ /** Silence, now — the flame blown out, or the page going away. */
1781
+ stop(): void;
1782
+ private startBed;
1783
+ private driveBed;
1784
+ private stopBed;
1785
+ private crackle;
1786
+ /**
1787
+ * One shaped burst of the shared noise through one filter — what strike,
1788
+ * puff and pop are made of. `delay` starts it a moment late, which is how
1789
+ * the flare follows the scratch.
1790
+ */
1791
+ private burst;
1792
+ /** xorshift32 — its own stream, so nothing else can shift the crackle. */
1793
+ private next;
1794
+ }
1795
+
1796
+ export { Afterglow, type AfterglowOptions, type AudioLike, type BiquadFilterLike, CHAR, DamageField, type DamageFieldOptions, DamageSource, FIELD_SIZE, FIRE_GLOW, FIRE_HEAT_SCALE, FIRE_SOOT_SCALE, FIRE_ZONES, FIXED_DT, FLAME_HEIGHT, FX_BLOOM, FX_BLOOM_THRESHOLD, FX_INITIAL_TIER, FX_TIER_ORDER, type FieldStats, FireEmitter, type FireEmitterOptions, FireFluid, type FireFluidParams, FireSound, type FireSoundOptions, type FireZones, type FireZonesInput, type FlameAnchor, type FluidGrid, FxAudio, type FxAudioOptions, type FxFilm, FxFireFluid, type FxFireFluidProps, FxFireLight, type FxFireLightProps, FxFlames, type FxFlamesProps, FxMatchFlame, type FxMatchFlameProps, FxParticles, type FxParticlesProps, FxPost, type FxPostProps, type FxQualityName, type FxQualitySettings, type FxQualityTier, FxWisps, type FxWispsProps, HEAT, type MatchFlameState, PAPER_WHITE, PRESENCE, type PannerLike, ParticlePool, type ParticlePreset, type ParticlePresetName, type ParticleTarget, SATURATION, type SolverUniforms, type SoundAt, type SurfaceLocator, type Voice, createAudioContext, emit, emitHex, fireEmitterDefaults, fireFluidControls, fireFluidDefaults, fireZones, flameAnchors, flamePuff, fxQualityFor, fxQualityNames, fxQualityTiers, hexToLinear, luminance, particlePresets, solverUniforms, srgbToLinear, timesPaperWhite };