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/README.md +5 -1
- package/dist/FxPostPass-M66QMVVC.js +166 -0
- package/dist/FxPostPass-M66QMVVC.js.map +1 -0
- package/dist/Grade-SBGYELKV.js +49 -0
- package/dist/Grade-SBGYELKV.js.map +1 -0
- package/dist/chunk-7TLIWPGM.js +36 -0
- package/dist/chunk-7TLIWPGM.js.map +1 -0
- package/dist/chunk-GB6BMHC3.js +811 -0
- package/dist/chunk-GB6BMHC3.js.map +1 -0
- package/dist/{chunk-E22ILEDO.js → chunk-MZCAN3AR.js} +1264 -197
- package/dist/chunk-MZCAN3AR.js.map +1 -0
- package/dist/damageContract-DPF1YFLZ.d.cts +129 -0
- package/dist/damageContract-DPF1YFLZ.d.ts +129 -0
- package/dist/fx.cjs +4276 -0
- package/dist/fx.cjs.map +1 -0
- package/dist/fx.d.cts +1796 -0
- package/dist/fx.d.ts +1796 -0
- package/dist/fx.js +3194 -0
- package/dist/fx.js.map +1 -0
- package/dist/index.cjs +2652 -914
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +34 -4
- package/dist/index.d.ts +34 -4
- package/dist/index.js +7 -1
- package/dist/index.js.map +1 -1
- package/dist/stage.cjs +2864 -1087
- package/dist/stage.cjs.map +1 -1
- package/dist/stage.js +31 -51
- package/dist/stage.js.map +1 -1
- package/package.json +16 -3
- package/dist/chunk-E22ILEDO.js.map +0 -1
package/dist/fx.d.ts
ADDED
|
@@ -0,0 +1,1796 @@
|
|
|
1
|
+
import { D as DamageSource } from './damageContract-DPF1YFLZ.js';
|
|
2
|
+
export { a as DAMAGE_CHANNELS, b as DAMAGE_LOOK_DEFAULTS, c as DamageLook } from './damageContract-DPF1YFLZ.js';
|
|
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 };
|