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.js
ADDED
|
@@ -0,0 +1,3194 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CHAR,
|
|
3
|
+
DamageField,
|
|
4
|
+
FIELD_SIZE,
|
|
5
|
+
FIRE_CONTRAST,
|
|
6
|
+
FIRE_GLOW,
|
|
7
|
+
FIRE_HEAT_SCALE,
|
|
8
|
+
FIRE_OPACITY,
|
|
9
|
+
FIRE_SOOT_SCALE,
|
|
10
|
+
FIRE_THIN,
|
|
11
|
+
FIRE_ZONES,
|
|
12
|
+
FIXED_DT,
|
|
13
|
+
FLAME_HEIGHT,
|
|
14
|
+
FX_BLOOM,
|
|
15
|
+
FX_BLOOM_THRESHOLD,
|
|
16
|
+
FX_INITIAL_TIER,
|
|
17
|
+
FX_TIER_ORDER,
|
|
18
|
+
HEAT,
|
|
19
|
+
PAPER_WHITE,
|
|
20
|
+
PRESENCE,
|
|
21
|
+
SATURATION,
|
|
22
|
+
emit,
|
|
23
|
+
emitHex,
|
|
24
|
+
fireZones,
|
|
25
|
+
flameAnchors,
|
|
26
|
+
flamePuff,
|
|
27
|
+
fxQualityFor,
|
|
28
|
+
fxQualityNames,
|
|
29
|
+
fxQualityTiers,
|
|
30
|
+
hexToLinear,
|
|
31
|
+
luminance,
|
|
32
|
+
srgbToLinear,
|
|
33
|
+
timesPaperWhite
|
|
34
|
+
} from "./chunk-GB6BMHC3.js";
|
|
35
|
+
import {
|
|
36
|
+
DAMAGE_CHANNELS,
|
|
37
|
+
DAMAGE_LOOK_DEFAULTS
|
|
38
|
+
} from "./chunk-7TLIWPGM.js";
|
|
39
|
+
|
|
40
|
+
// src/fx/particles.ts
|
|
41
|
+
var particlePresets = {
|
|
42
|
+
/**
|
|
43
|
+
* A spark off the burning line. Short-lived, shrinking as it cools, carried
|
|
44
|
+
* up and sideways by the heat.
|
|
45
|
+
*
|
|
46
|
+
* Authored through `emission.ts`, in multiples of paper white, which is what
|
|
47
|
+
* caught the bug in the version before this one. That version said "the hot
|
|
48
|
+
* end is now ~5.6, well over the bloom threshold" — and 5.6 was an ABSOLUTE
|
|
49
|
+
* luminance, while paper white under `window` is 1.6. So the hottest spark
|
|
50
|
+
* in the frame was **3.5× paper, under the spec's own 4× floor**, and its
|
|
51
|
+
* own comment said otherwise. A number that cannot be compared to anything
|
|
52
|
+
* is a number nobody can check.
|
|
53
|
+
*
|
|
54
|
+
* It was also too red. `[12, 4.2, 0.9]` is a saturated red at high
|
|
55
|
+
* intensity, and the tone curve takes a bright red-dominant colour to
|
|
56
|
+
* SALMON — which is `Never_this.png`. The hue is now a blackbody's: a spark
|
|
57
|
+
* leaving the fire is yellow-orange, about 2200 K, and cools to a deep
|
|
58
|
+
* orange-red as it dies. Hue and brightness are separate arguments now, and
|
|
59
|
+
* `emit` keeps them that way.
|
|
60
|
+
*/
|
|
61
|
+
ember: {
|
|
62
|
+
life: [0.5, 1.4],
|
|
63
|
+
speed: [0.15, 0.5],
|
|
64
|
+
direction: [0, 1, 0],
|
|
65
|
+
spread: 0.6,
|
|
66
|
+
lift: 0.6,
|
|
67
|
+
drag: 0.8,
|
|
68
|
+
jitter: 1.5,
|
|
69
|
+
windCatch: 0.8,
|
|
70
|
+
// 0.3-1 mm of core (§5). A sheet is one world unit across, 210 mm, so
|
|
71
|
+
// 0.0035 is 0.7 mm. It was 0.005 — a full millimetre wide before the
|
|
72
|
+
// streak stretched it, which is where "wide bars" started.
|
|
73
|
+
size: [35e-4, 12e-4],
|
|
74
|
+
color: [
|
|
75
|
+
// 6× paper white: inside §4.3's 4–8 band, so it blooms.
|
|
76
|
+
emitHex("#FFC271", 6),
|
|
77
|
+
// 0.7× — UNDER the bloom threshold on purpose, so a dying spark stops
|
|
78
|
+
// glowing rather than merely fading.
|
|
79
|
+
emitHex("#FF5512", 0.7)
|
|
80
|
+
],
|
|
81
|
+
alpha: [1, 0],
|
|
82
|
+
spin: 0,
|
|
83
|
+
blend: "additive",
|
|
84
|
+
shape: "soft",
|
|
85
|
+
flicker: 0.35
|
|
86
|
+
},
|
|
87
|
+
/**
|
|
88
|
+
* What a paper fire mostly makes. Thin, slow, growing as it rises and
|
|
89
|
+
* spreads, and gone well before it could read as a volume — no fluid
|
|
90
|
+
* simulation, on purpose; see the fx plan.
|
|
91
|
+
*/
|
|
92
|
+
smoke: {
|
|
93
|
+
life: [1.8, 3.5],
|
|
94
|
+
speed: [0.05, 0.15],
|
|
95
|
+
direction: [0, 1, 0],
|
|
96
|
+
spread: 0.3,
|
|
97
|
+
lift: 0.25,
|
|
98
|
+
drag: 0.9,
|
|
99
|
+
jitter: 0.3,
|
|
100
|
+
windCatch: 1,
|
|
101
|
+
size: [0.05, 0.35],
|
|
102
|
+
// #6B6560 grey-brown, linear, and thin — the background stays clear.
|
|
103
|
+
color: [
|
|
104
|
+
[0.147, 0.13, 0.117],
|
|
105
|
+
[0.2, 0.19, 0.18]
|
|
106
|
+
],
|
|
107
|
+
alpha: [0.08, 0],
|
|
108
|
+
spin: 0.4,
|
|
109
|
+
blend: "normal",
|
|
110
|
+
shape: "soft",
|
|
111
|
+
flicker: 0
|
|
112
|
+
},
|
|
113
|
+
/**
|
|
114
|
+
* Burnt-through paper, leaving. Lifted a little by the heat it came from,
|
|
115
|
+
* then heavier than the air: it tumbles, flutters and falls — the part of a
|
|
116
|
+
* burn that proves paper was there.
|
|
117
|
+
*/
|
|
118
|
+
ash: {
|
|
119
|
+
life: [2.5, 5],
|
|
120
|
+
// Thrown UP by the heat it came off, before it is heavier than the air.
|
|
121
|
+
// It used to leave at almost nothing (0.02–0.1) with a lift of −0.35, so
|
|
122
|
+
// every flake began falling the moment it was born — straight through the
|
|
123
|
+
// hole it came from, onto the black stage, dark on dark.
|
|
124
|
+
speed: [0.14, 0.3],
|
|
125
|
+
direction: [0, 1, 0],
|
|
126
|
+
spread: 0.55,
|
|
127
|
+
lift: -0.22,
|
|
128
|
+
drag: 1.6,
|
|
129
|
+
jitter: 1.2,
|
|
130
|
+
windCatch: 1,
|
|
131
|
+
// 5–10 mm across (§8.3). A sheet is 210 mm, so 0.03 is 6.3 mm. They were
|
|
132
|
+
// 3.8 mm and there were far too many of them: a shower of specks rather
|
|
133
|
+
// than a few flakes you would notice.
|
|
134
|
+
size: [0.03, 0.026],
|
|
135
|
+
color: [
|
|
136
|
+
[0.07, 0.065, 0.06],
|
|
137
|
+
[0.12, 0.115, 0.11]
|
|
138
|
+
],
|
|
139
|
+
alpha: [0.95, 0],
|
|
140
|
+
spin: 4,
|
|
141
|
+
blend: "normal",
|
|
142
|
+
shape: "flake",
|
|
143
|
+
flicker: 0
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
var PRESET_NAMES = Object.keys(particlePresets);
|
|
147
|
+
var PRESETS = PRESET_NAMES.map((name) => particlePresets[name]);
|
|
148
|
+
function mulberry32(seed) {
|
|
149
|
+
let a = seed >>> 0;
|
|
150
|
+
return () => {
|
|
151
|
+
a = a + 1831565813 >>> 0;
|
|
152
|
+
let t = a;
|
|
153
|
+
t = Math.imul(t ^ t >>> 15, t | 1);
|
|
154
|
+
t ^= t + Math.imul(t ^ t >>> 7, t | 61);
|
|
155
|
+
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
var ParticlePool = class {
|
|
159
|
+
capacity;
|
|
160
|
+
/**
|
|
161
|
+
* The air's own velocity, world units a second. A blow on `/hands` is a
|
|
162
|
+
* wind, and smoke that ignores it is smoke painted on the glass.
|
|
163
|
+
*/
|
|
164
|
+
wind = [0, 0, 0];
|
|
165
|
+
kind;
|
|
166
|
+
position;
|
|
167
|
+
velocity;
|
|
168
|
+
age;
|
|
169
|
+
life;
|
|
170
|
+
angle;
|
|
171
|
+
spin;
|
|
172
|
+
seed;
|
|
173
|
+
live = 0;
|
|
174
|
+
/** Live particles of each preset, kept as they come and go, so a cap costs nothing to check. */
|
|
175
|
+
perKind = new Int32Array(PRESET_NAMES.length);
|
|
176
|
+
/** Embers that popped in the air since the last `takePops` — the sound follows the picture. */
|
|
177
|
+
pops = 0;
|
|
178
|
+
random;
|
|
179
|
+
constructor(capacity, seed = 1) {
|
|
180
|
+
this.capacity = Math.max(0, Math.floor(capacity));
|
|
181
|
+
const n = this.capacity;
|
|
182
|
+
this.kind = new Uint8Array(n);
|
|
183
|
+
this.position = new Float32Array(n * 3);
|
|
184
|
+
this.velocity = new Float32Array(n * 3);
|
|
185
|
+
this.age = new Float32Array(n);
|
|
186
|
+
this.life = new Float32Array(n);
|
|
187
|
+
this.angle = new Float32Array(n);
|
|
188
|
+
this.spin = new Float32Array(n);
|
|
189
|
+
this.seed = new Float32Array(n);
|
|
190
|
+
this.random = mulberry32(seed);
|
|
191
|
+
}
|
|
192
|
+
/** Particles in the air. */
|
|
193
|
+
get count() {
|
|
194
|
+
return this.live;
|
|
195
|
+
}
|
|
196
|
+
/** How many live particles are of one preset. For tests and for a HUD. */
|
|
197
|
+
countOf(name) {
|
|
198
|
+
return this.perKind[PRESET_NAMES.indexOf(name)];
|
|
199
|
+
}
|
|
200
|
+
/** Launch one particle from a point. Never allocates; at capacity it takes the most-spent slot. */
|
|
201
|
+
spawn(name, x, y, z) {
|
|
202
|
+
if (this.capacity === 0) return;
|
|
203
|
+
const preset = particlePresets[name];
|
|
204
|
+
let i = this.live;
|
|
205
|
+
if (i < this.capacity) {
|
|
206
|
+
this.live++;
|
|
207
|
+
} else {
|
|
208
|
+
i = 0;
|
|
209
|
+
let spent = -1;
|
|
210
|
+
for (let j = 0; j < this.live; j++) {
|
|
211
|
+
const f = this.age[j] / this.life[j];
|
|
212
|
+
if (f > spent) {
|
|
213
|
+
spent = f;
|
|
214
|
+
i = j;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
this.perKind[this.kind[i]] = this.perKind[this.kind[i]] - 1;
|
|
218
|
+
}
|
|
219
|
+
const r = this.random;
|
|
220
|
+
this.kind[i] = PRESET_NAMES.indexOf(name);
|
|
221
|
+
this.perKind[this.kind[i]] = this.perKind[this.kind[i]] + 1;
|
|
222
|
+
const i3 = i * 3;
|
|
223
|
+
this.position[i3] = x;
|
|
224
|
+
this.position[i3 + 1] = y;
|
|
225
|
+
this.position[i3 + 2] = z;
|
|
226
|
+
const [dx, dy, dz] = preset.direction;
|
|
227
|
+
let rx = r() * 2 - 1;
|
|
228
|
+
let ry = r() * 2 - 1;
|
|
229
|
+
let rz = r() * 2 - 1;
|
|
230
|
+
const rl = Math.hypot(rx, ry, rz) || 1;
|
|
231
|
+
rx /= rl;
|
|
232
|
+
ry /= rl;
|
|
233
|
+
rz /= rl;
|
|
234
|
+
let ox = dx * (1 - preset.spread) + rx * preset.spread;
|
|
235
|
+
let oy = dy * (1 - preset.spread) + ry * preset.spread;
|
|
236
|
+
let oz = dz * (1 - preset.spread) + rz * preset.spread;
|
|
237
|
+
if (ox * dx + oy * dy + oz * dz < 0) {
|
|
238
|
+
ox = -ox;
|
|
239
|
+
oy = -oy;
|
|
240
|
+
oz = -oz;
|
|
241
|
+
}
|
|
242
|
+
const ol = Math.hypot(ox, oy, oz) || 1;
|
|
243
|
+
const speed = preset.speed[0] + (preset.speed[1] - preset.speed[0]) * r();
|
|
244
|
+
this.velocity[i3] = ox / ol * speed;
|
|
245
|
+
this.velocity[i3 + 1] = oy / ol * speed;
|
|
246
|
+
this.velocity[i3 + 2] = oz / ol * speed;
|
|
247
|
+
this.age[i] = 0;
|
|
248
|
+
this.life[i] = preset.life[0] + (preset.life[1] - preset.life[0]) * r();
|
|
249
|
+
this.angle[i] = r() * Math.PI * 2;
|
|
250
|
+
this.spin[i] = (r() * 2 - 1) * preset.spin;
|
|
251
|
+
this.seed[i] = r();
|
|
252
|
+
}
|
|
253
|
+
/** Advance every particle by `dt` seconds; the dead leave the pool. */
|
|
254
|
+
step(dt) {
|
|
255
|
+
if (dt <= 0) return;
|
|
256
|
+
const r = this.random;
|
|
257
|
+
const [wx, wy, wz] = this.wind;
|
|
258
|
+
for (let i = 0; i < this.live; i++) {
|
|
259
|
+
const before = this.age[i] / this.life[i];
|
|
260
|
+
this.age[i] = this.age[i] + dt;
|
|
261
|
+
if (this.seed[i] < 0.1 && before < 0.86 && this.age[i] / this.life[i] >= 0.86) {
|
|
262
|
+
if (PRESETS[this.kind[i]].blend === "additive") this.pops++;
|
|
263
|
+
}
|
|
264
|
+
if (this.age[i] >= this.life[i]) {
|
|
265
|
+
this.remove(i);
|
|
266
|
+
i--;
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
const preset = PRESETS[this.kind[i]];
|
|
270
|
+
const i3 = i * 3;
|
|
271
|
+
const drag = Math.min(1, preset.drag * dt);
|
|
272
|
+
const catchWind = preset.windCatch;
|
|
273
|
+
const jitter = preset.jitter;
|
|
274
|
+
let vx = this.velocity[i3];
|
|
275
|
+
let vy = this.velocity[i3 + 1];
|
|
276
|
+
let vz = this.velocity[i3 + 2];
|
|
277
|
+
vx += (wx * catchWind - vx) * drag + (r() * 2 - 1) * jitter * dt;
|
|
278
|
+
vy += (wy * catchWind - vy) * drag + (preset.lift + (r() * 2 - 1) * jitter) * dt;
|
|
279
|
+
vz += (wz * catchWind - vz) * drag + (r() * 2 - 1) * jitter * dt;
|
|
280
|
+
this.velocity[i3] = vx;
|
|
281
|
+
this.velocity[i3 + 1] = vy;
|
|
282
|
+
this.velocity[i3 + 2] = vz;
|
|
283
|
+
this.position[i3] = this.position[i3] + vx * dt;
|
|
284
|
+
this.position[i3 + 1] = this.position[i3 + 1] + vy * dt;
|
|
285
|
+
this.position[i3 + 2] = this.position[i3 + 2] + vz * dt;
|
|
286
|
+
this.angle[i] = this.angle[i] + this.spin[i] * dt;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Write every live particle into the target for its blend mode. Returns how
|
|
291
|
+
* many went into each — the draw ranges.
|
|
292
|
+
*/
|
|
293
|
+
write(additive, normal, flakes) {
|
|
294
|
+
let a = 0;
|
|
295
|
+
let n = 0;
|
|
296
|
+
let f = 0;
|
|
297
|
+
for (let i = 0; i < this.live; i++) {
|
|
298
|
+
const preset = PRESETS[this.kind[i]];
|
|
299
|
+
const flake = flakes !== void 0 && preset.shape === "flake";
|
|
300
|
+
const target2 = preset.blend === "additive" ? additive : flake ? flakes : normal;
|
|
301
|
+
const k = preset.blend === "additive" ? a++ : flake ? f++ : n++;
|
|
302
|
+
const t = this.age[i] / this.life[i];
|
|
303
|
+
const i3 = i * 3;
|
|
304
|
+
const k3 = k * 3;
|
|
305
|
+
target2.position[k3] = this.position[i3];
|
|
306
|
+
target2.position[k3 + 1] = this.position[i3 + 1];
|
|
307
|
+
target2.position[k3 + 2] = this.position[i3 + 2];
|
|
308
|
+
if (target2.velocity) {
|
|
309
|
+
target2.velocity[k3] = this.velocity[i3];
|
|
310
|
+
target2.velocity[k3 + 1] = this.velocity[i3 + 1];
|
|
311
|
+
target2.velocity[k3 + 2] = this.velocity[i3 + 2];
|
|
312
|
+
}
|
|
313
|
+
const [c0, c1] = preset.color;
|
|
314
|
+
const fadeIn = Math.min(1, t / 0.1);
|
|
315
|
+
const flicker = preset.flicker > 0 ? 1 - preset.flicker * (0.5 + 0.5 * Math.sin(this.age[i] * 41 + this.seed[i] * 97)) : 1;
|
|
316
|
+
const k4 = k * 4;
|
|
317
|
+
const pop = preset.blend === "additive" && this.seed[i] < 0.1 && t > 0.86 ? 2.6 : 1;
|
|
318
|
+
target2.color[k4] = (c0[0] + (c1[0] - c0[0]) * t) * pop;
|
|
319
|
+
target2.color[k4 + 1] = (c0[1] + (c1[1] - c0[1]) * t) * pop;
|
|
320
|
+
target2.color[k4 + 2] = (c0[2] + (c1[2] - c0[2]) * t) * pop;
|
|
321
|
+
target2.color[k4 + 3] = (preset.alpha[0] + (preset.alpha[1] - preset.alpha[0]) * t) * fadeIn * flicker;
|
|
322
|
+
target2.extra[k4] = preset.size[0] + (preset.size[1] - preset.size[0]) * t;
|
|
323
|
+
target2.extra[k4 + 1] = this.angle[i];
|
|
324
|
+
target2.extra[k4 + 2] = preset.shape === "flake" ? 1 : 0;
|
|
325
|
+
target2.extra[k4 + 3] = this.seed[i];
|
|
326
|
+
}
|
|
327
|
+
return { additive: a, normal: n, flakes: f };
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* How many embers popped in the air since the last call, and reset — one
|
|
331
|
+
* `FireSound.pop()` each keeps the sound on the frame the flash is on.
|
|
332
|
+
*/
|
|
333
|
+
takePops() {
|
|
334
|
+
const n = this.pops;
|
|
335
|
+
this.pops = 0;
|
|
336
|
+
return n;
|
|
337
|
+
}
|
|
338
|
+
/** Empty the air — a fresh sheet. */
|
|
339
|
+
clear() {
|
|
340
|
+
this.live = 0;
|
|
341
|
+
this.perKind.fill(0);
|
|
342
|
+
}
|
|
343
|
+
/** Swap the last live particle into slot `i`. Order does not matter; density does. */
|
|
344
|
+
remove(i) {
|
|
345
|
+
this.perKind[this.kind[i]] = this.perKind[this.kind[i]] - 1;
|
|
346
|
+
const last = --this.live;
|
|
347
|
+
if (i === last) return;
|
|
348
|
+
this.kind[i] = this.kind[last];
|
|
349
|
+
this.age[i] = this.age[last];
|
|
350
|
+
this.life[i] = this.life[last];
|
|
351
|
+
this.angle[i] = this.angle[last];
|
|
352
|
+
this.spin[i] = this.spin[last];
|
|
353
|
+
this.seed[i] = this.seed[last];
|
|
354
|
+
this.position.copyWithin(i * 3, last * 3, last * 3 + 3);
|
|
355
|
+
this.velocity.copyWithin(i * 3, last * 3, last * 3 + 3);
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
// src/fx/fire.ts
|
|
360
|
+
var fireEmitterDefaults = {
|
|
361
|
+
embers: 0.35,
|
|
362
|
+
smoke: 0,
|
|
363
|
+
ash: 0.12,
|
|
364
|
+
seed: 7,
|
|
365
|
+
caps: {}
|
|
366
|
+
};
|
|
367
|
+
var PER_UPDATE = 24;
|
|
368
|
+
var FireEmitter = class {
|
|
369
|
+
field;
|
|
370
|
+
pool;
|
|
371
|
+
locate;
|
|
372
|
+
o;
|
|
373
|
+
emberDebt = 0;
|
|
374
|
+
smokeDebt = 0;
|
|
375
|
+
state;
|
|
376
|
+
constructor(field, pool, locate, options = {}) {
|
|
377
|
+
this.field = field;
|
|
378
|
+
this.pool = pool;
|
|
379
|
+
this.locate = locate;
|
|
380
|
+
this.o = { ...fireEmitterDefaults, ...options };
|
|
381
|
+
this.state = this.o.seed >>> 0 || 1;
|
|
382
|
+
}
|
|
383
|
+
/** Call once a frame, after the field has stepped. */
|
|
384
|
+
update(dt) {
|
|
385
|
+
const { field, o } = this;
|
|
386
|
+
const front = field.frontCount;
|
|
387
|
+
const consumed = field.consumedCount;
|
|
388
|
+
if (front === 0 && consumed === 0) {
|
|
389
|
+
this.emberDebt = 0;
|
|
390
|
+
this.smokeDebt = 0;
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
for (let k = 0; k < consumed; k++) {
|
|
394
|
+
if (this.next() < o.ash) this.emit("ash", field.consumedCells[k]);
|
|
395
|
+
}
|
|
396
|
+
if (front > 0 && dt > 0) {
|
|
397
|
+
const [wx, wy, wz] = this.pool.wind;
|
|
398
|
+
const air = Math.hypot(wx, wy, wz);
|
|
399
|
+
let heat = 0;
|
|
400
|
+
for (let k = 0; k < front; k++) heat += field.data[field.frontCells[k] * 4 + HEAT];
|
|
401
|
+
heat /= front;
|
|
402
|
+
const struggle = 0.35 + 1.6 * Math.max(0, 0.7 - heat) + air * 0.8;
|
|
403
|
+
const oxygen = 1 + air * 1.5;
|
|
404
|
+
this.emberDebt = Math.min(this.emberDebt + front * o.embers * oxygen * dt, PER_UPDATE);
|
|
405
|
+
this.smokeDebt = Math.min(this.smokeDebt + front * o.smoke * struggle * dt, PER_UPDATE);
|
|
406
|
+
while (this.emberDebt >= 1) {
|
|
407
|
+
this.emberDebt -= 1;
|
|
408
|
+
this.emit("ember", field.frontCells[Math.floor(this.next() * front)]);
|
|
409
|
+
}
|
|
410
|
+
while (this.smokeDebt >= 1) {
|
|
411
|
+
this.smokeDebt -= 1;
|
|
412
|
+
this.emit("smoke", field.frontCells[Math.floor(this.next() * front)]);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
emit(name, cell) {
|
|
417
|
+
const cap = this.o.caps[name];
|
|
418
|
+
if (cap !== void 0 && this.pool.countOf(name) >= cap) return;
|
|
419
|
+
const size = this.field.size;
|
|
420
|
+
const last = size - 1;
|
|
421
|
+
const at = this.locate(cell % size / last, (cell / size | 0) / last);
|
|
422
|
+
if (!at) return;
|
|
423
|
+
if (name === "ember") {
|
|
424
|
+
const lift = this.next() * this.next() * 0.09;
|
|
425
|
+
this.pool.spawn(name, at.x + (this.next() - 0.5) * 0.012, at.y + lift, at.z);
|
|
426
|
+
} else {
|
|
427
|
+
this.pool.spawn(name, at.x, at.y, at.z);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
/** xorshift32 — its own stream, so the pool's randomness cannot shift which cells are picked. */
|
|
431
|
+
next() {
|
|
432
|
+
let s = this.state;
|
|
433
|
+
s ^= s << 13;
|
|
434
|
+
s ^= s >>> 17;
|
|
435
|
+
s ^= s << 5;
|
|
436
|
+
this.state = s >>> 0;
|
|
437
|
+
return this.state / 4294967296;
|
|
438
|
+
}
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
// src/fx/FxParticles.tsx
|
|
442
|
+
import * as THREE from "three";
|
|
443
|
+
import { useFrame, useThree } from "@react-three/fiber";
|
|
444
|
+
import { useEffect, useMemo } from "react";
|
|
445
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
446
|
+
var STREAK = 0.045;
|
|
447
|
+
var QUAD_STREAK = new Float32Array([-1, 0, 0, 1, 0, 0, 1, 1, 0, -1, 1, 0]);
|
|
448
|
+
var QUAD_FLAKE = new Float32Array([-1, -1, 0, 1, -1, 0, 1, 1, 0, -1, 1, 0]);
|
|
449
|
+
var QUAD_INDEX = [0, 1, 2, 0, 2, 3];
|
|
450
|
+
function makeTarget(capacity) {
|
|
451
|
+
return {
|
|
452
|
+
position: new Float32Array(capacity * 3),
|
|
453
|
+
color: new Float32Array(capacity * 4),
|
|
454
|
+
extra: new Float32Array(capacity * 4),
|
|
455
|
+
velocity: new Float32Array(capacity * 3)
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
function useInstanced(capacity, quad) {
|
|
459
|
+
const layer = useMemo(() => {
|
|
460
|
+
const target2 = makeTarget(capacity);
|
|
461
|
+
const geometry = new THREE.InstancedBufferGeometry();
|
|
462
|
+
geometry.setAttribute("position", new THREE.BufferAttribute(quad, 3));
|
|
463
|
+
geometry.setIndex(QUAD_INDEX);
|
|
464
|
+
geometry.setAttribute("aPos", new THREE.InstancedBufferAttribute(target2.position, 3));
|
|
465
|
+
geometry.setAttribute("aVel", new THREE.InstancedBufferAttribute(target2.velocity, 3));
|
|
466
|
+
geometry.setAttribute("aColor", new THREE.InstancedBufferAttribute(target2.color, 4));
|
|
467
|
+
geometry.setAttribute("aExtra", new THREE.InstancedBufferAttribute(target2.extra, 4));
|
|
468
|
+
geometry.instanceCount = 0;
|
|
469
|
+
geometry.boundingSphere = new THREE.Sphere(new THREE.Vector3(), Infinity);
|
|
470
|
+
return { target: target2, geometry };
|
|
471
|
+
}, [capacity, quad]);
|
|
472
|
+
useEffect(() => () => layer.geometry.dispose(), [layer]);
|
|
473
|
+
return layer;
|
|
474
|
+
}
|
|
475
|
+
function usePoints(capacity) {
|
|
476
|
+
const layer = useMemo(() => {
|
|
477
|
+
const target2 = makeTarget(capacity);
|
|
478
|
+
const geometry = new THREE.BufferGeometry();
|
|
479
|
+
geometry.setAttribute("position", new THREE.BufferAttribute(target2.position, 3));
|
|
480
|
+
geometry.setAttribute("pColor", new THREE.BufferAttribute(target2.color, 4));
|
|
481
|
+
geometry.setAttribute("pExtra", new THREE.BufferAttribute(target2.extra, 4));
|
|
482
|
+
geometry.setDrawRange(0, 0);
|
|
483
|
+
geometry.boundingSphere = new THREE.Sphere(new THREE.Vector3(), Infinity);
|
|
484
|
+
return { target: target2, geometry };
|
|
485
|
+
}, [capacity]);
|
|
486
|
+
useEffect(() => () => layer.geometry.dispose(), [layer]);
|
|
487
|
+
return layer;
|
|
488
|
+
}
|
|
489
|
+
var OUTPUT = (
|
|
490
|
+
/* glsl */
|
|
491
|
+
`
|
|
492
|
+
#include <tonemapping_fragment>
|
|
493
|
+
#include <colorspace_fragment>
|
|
494
|
+
`
|
|
495
|
+
);
|
|
496
|
+
var EMBER_VERTEX = (
|
|
497
|
+
/* glsl */
|
|
498
|
+
`
|
|
499
|
+
attribute vec3 aPos;
|
|
500
|
+
attribute vec3 aVel;
|
|
501
|
+
attribute vec4 aColor;
|
|
502
|
+
attribute vec4 aExtra; // size, angle, shape, seed
|
|
503
|
+
uniform vec2 uResolution;
|
|
504
|
+
uniform float uStreak;
|
|
505
|
+
varying vec4 vColor;
|
|
506
|
+
varying vec2 vQuad;
|
|
507
|
+
void main() {
|
|
508
|
+
vec4 head = projectionMatrix * viewMatrix * vec4(aPos, 1.0);
|
|
509
|
+
vec4 tail = projectionMatrix * viewMatrix * vec4(aPos - aVel * uStreak, 1.0);
|
|
510
|
+
vec2 half_ = 0.5 * uResolution;
|
|
511
|
+
vec2 d = head.xy / head.w * half_ - tail.xy / tail.w * half_;
|
|
512
|
+
float len = length(d);
|
|
513
|
+
vec2 dir = len > 1e-3 ? d / len : vec2(0.0, 1.0);
|
|
514
|
+
vec2 across = vec2(-dir.y, dir.x);
|
|
515
|
+
// Diameter in pixels from a size in world units: the projection's own scale.
|
|
516
|
+
float px = max(1.5, aExtra.x * projectionMatrix[1][1] * half_.y / head.w);
|
|
517
|
+
float along = len + px;
|
|
518
|
+
vec2 offset = across * position.x * px * 0.5 + dir * (px * 0.5 - position.y * along);
|
|
519
|
+
gl_Position = head + vec4(offset / half_ * head.w, 0.0, 0.0);
|
|
520
|
+
vColor = aColor;
|
|
521
|
+
vQuad = position.xy;
|
|
522
|
+
}
|
|
523
|
+
`
|
|
524
|
+
);
|
|
525
|
+
var EMBER_FRAGMENT = (
|
|
526
|
+
/* glsl */
|
|
527
|
+
`
|
|
528
|
+
varying vec4 vColor;
|
|
529
|
+
varying vec2 vQuad;
|
|
530
|
+
void main() {
|
|
531
|
+
// A thin hot core with a soft edge, fading to NOTHING behind the head.
|
|
532
|
+
//
|
|
533
|
+
// It used to be flat-topped across (smoothstep from 0.35, so the middle
|
|
534
|
+
// seventy per cent of the width was all at full brightness) and to keep a
|
|
535
|
+
// quarter of that brightness all the way to the end of the quad. Both
|
|
536
|
+
// together drew a wide bar with a squared-off end, which is what the review
|
|
537
|
+
// saw up close. A spark is a point of light smeared by its own motion: it
|
|
538
|
+
// is brightest on its centre line and it runs out.
|
|
539
|
+
float across = abs(vQuad.x);
|
|
540
|
+
float body = exp(-across * across * 7.0);
|
|
541
|
+
float tail = clamp(1.0 - vQuad.y, 0.0, 1.0);
|
|
542
|
+
float m = body * tail * tail;
|
|
543
|
+
if (m <= 0.003) discard;
|
|
544
|
+
gl_FragColor = vec4(vColor.rgb * vColor.a * m, 1.0);
|
|
545
|
+
${OUTPUT}
|
|
546
|
+
}
|
|
547
|
+
`
|
|
548
|
+
);
|
|
549
|
+
var SMOKE_VERTEX = (
|
|
550
|
+
/* glsl */
|
|
551
|
+
`
|
|
552
|
+
attribute vec4 pColor;
|
|
553
|
+
attribute vec4 pExtra; // size, angle, shape, seed
|
|
554
|
+
uniform float uScale;
|
|
555
|
+
varying vec4 vColor;
|
|
556
|
+
varying vec2 vSpin;
|
|
557
|
+
varying float vSeed;
|
|
558
|
+
void main() {
|
|
559
|
+
vColor = pColor;
|
|
560
|
+
vSpin = vec2(cos(pExtra.y), sin(pExtra.y));
|
|
561
|
+
vSeed = pExtra.w;
|
|
562
|
+
vec4 view = modelViewMatrix * vec4(position, 1.0);
|
|
563
|
+
bool perspective = projectionMatrix[2][3] == -1.0;
|
|
564
|
+
float depth = perspective ? max(0.0001, -view.z) : 1.0;
|
|
565
|
+
gl_PointSize = max(1.0, pExtra.x * uScale / depth);
|
|
566
|
+
gl_Position = projectionMatrix * view;
|
|
567
|
+
}
|
|
568
|
+
`
|
|
569
|
+
);
|
|
570
|
+
var SMOKE_FRAGMENT = (
|
|
571
|
+
/* glsl */
|
|
572
|
+
`
|
|
573
|
+
varying vec4 vColor;
|
|
574
|
+
varying vec2 vSpin;
|
|
575
|
+
varying float vSeed;
|
|
576
|
+
float smHash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); }
|
|
577
|
+
float smNoise(vec2 p) {
|
|
578
|
+
vec2 i = floor(p);
|
|
579
|
+
vec2 f = fract(p);
|
|
580
|
+
vec2 u = f * f * (3.0 - 2.0 * f);
|
|
581
|
+
return mix(mix(smHash(i), smHash(i + vec2(1.0, 0.0)), u.x), mix(smHash(i + vec2(0.0, 1.0)), smHash(i + vec2(1.0, 1.0)), u.x), u.y);
|
|
582
|
+
}
|
|
583
|
+
void main() {
|
|
584
|
+
vec2 p = gl_PointCoord * 2.0 - 1.0;
|
|
585
|
+
p = vec2(p.x * vSpin.x - p.y * vSpin.y, p.x * vSpin.y + p.y * vSpin.x);
|
|
586
|
+
float r = length(p);
|
|
587
|
+
// Not a disc: a soft body torn by two octaves of noise, different for
|
|
588
|
+
// every puff, so a cloud of them is a texture rather than a pile of dots.
|
|
589
|
+
float n = smNoise(p * 2.2 + vSeed * 19.0) * 0.65 + smNoise(p * 5.1 + vSeed * 7.0) * 0.35;
|
|
590
|
+
float body = (1.0 - smoothstep(0.2, 1.0, r)) * smoothstep(0.25, 0.75, n + 0.25 * (1.0 - r));
|
|
591
|
+
if (body <= 0.0) discard;
|
|
592
|
+
gl_FragColor = vec4(vColor.rgb, vColor.a * body);
|
|
593
|
+
${OUTPUT}
|
|
594
|
+
}
|
|
595
|
+
`
|
|
596
|
+
);
|
|
597
|
+
var FLAKE_VERTEX = (
|
|
598
|
+
/* glsl */
|
|
599
|
+
`
|
|
600
|
+
attribute vec3 aPos;
|
|
601
|
+
attribute vec4 aColor;
|
|
602
|
+
attribute vec4 aExtra; // size, angle, shape, seed
|
|
603
|
+
varying vec2 vQuad;
|
|
604
|
+
varying vec4 vColor;
|
|
605
|
+
varying float vLight;
|
|
606
|
+
varying float vSeed;
|
|
607
|
+
mat3 rotation(vec3 axis, float a) {
|
|
608
|
+
float c = cos(a);
|
|
609
|
+
float s = sin(a);
|
|
610
|
+
float t = 1.0 - c;
|
|
611
|
+
return mat3(
|
|
612
|
+
t * axis.x * axis.x + c, t * axis.x * axis.y + s * axis.z, t * axis.x * axis.z - s * axis.y,
|
|
613
|
+
t * axis.x * axis.y - s * axis.z, t * axis.y * axis.y + c, t * axis.y * axis.z + s * axis.x,
|
|
614
|
+
t * axis.x * axis.z + s * axis.y, t * axis.y * axis.z - s * axis.x, t * axis.z * axis.z + c
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
void main() {
|
|
618
|
+
float seed = aExtra.w;
|
|
619
|
+
// Its own tumbling axis, from its seed: flakes do not spin in step.
|
|
620
|
+
vec3 axis = normalize(vec3(sin(seed * 40.0), cos(seed * 23.0), 0.4 + sin(seed * 71.0) * 0.5));
|
|
621
|
+
mat3 r = rotation(axis, aExtra.y);
|
|
622
|
+
// A little curl: burnt paper does not lie flat.
|
|
623
|
+
vec3 local = vec3(position.x, position.y, position.x * position.x * 0.45 - 0.2) * aExtra.x * 0.5;
|
|
624
|
+
vec3 world = aPos + r * local;
|
|
625
|
+
vec3 n = r * vec3(0.0, 0.0, 1.0);
|
|
626
|
+
// Lit by which way it faces \u2014 double-sided, so either face catches light.
|
|
627
|
+
vLight = 0.25 + 0.75 * abs(dot(n, normalize(vec3(-0.35, 0.8, 0.5))));
|
|
628
|
+
vQuad = position.xy;
|
|
629
|
+
vColor = aColor;
|
|
630
|
+
vSeed = seed;
|
|
631
|
+
gl_Position = projectionMatrix * viewMatrix * vec4(world, 1.0);
|
|
632
|
+
}
|
|
633
|
+
`
|
|
634
|
+
);
|
|
635
|
+
var FLAKE_FRAGMENT = (
|
|
636
|
+
/* glsl */
|
|
637
|
+
`
|
|
638
|
+
varying vec2 vQuad;
|
|
639
|
+
varying vec4 vColor;
|
|
640
|
+
varying float vLight;
|
|
641
|
+
varying float vSeed;
|
|
642
|
+
float flHash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); }
|
|
643
|
+
float flNoise(vec2 p) {
|
|
644
|
+
vec2 i = floor(p);
|
|
645
|
+
vec2 f = fract(p);
|
|
646
|
+
vec2 u = f * f * (3.0 - 2.0 * f);
|
|
647
|
+
return mix(mix(flHash(i), flHash(i + vec2(1.0, 0.0)), u.x), mix(flHash(i + vec2(0.0, 1.0)), flHash(i + vec2(1.0, 1.0)), u.x), u.y);
|
|
648
|
+
}
|
|
649
|
+
void main() {
|
|
650
|
+
float r = length(vQuad);
|
|
651
|
+
float a = atan(vQuad.y, vQuad.x);
|
|
652
|
+
// A torn scrap: an outline that wanders, different for every flake.
|
|
653
|
+
float edge = 0.62 + 0.3 * flNoise(vec2(a * 1.6 + vSeed * 31.0, vSeed * 7.0));
|
|
654
|
+
float mask = 1.0 - smoothstep(edge - 0.06, edge, r);
|
|
655
|
+
if (mask <= 0.0) discard;
|
|
656
|
+
// Char in the middle, pale brittle ash at the edge (\xA78.3) \u2014 in patches
|
|
657
|
+
// along it, not all the way round: a flake is a few pixels across, and a
|
|
658
|
+
// continuous pale rim round a dark middle drew it as a hollow ring.
|
|
659
|
+
float rim = smoothstep(edge - 0.32, edge - 0.04, r);
|
|
660
|
+
float patches = smoothstep(0.45, 0.75, flNoise(vec2(a * 2.2 + vSeed * 17.0, vSeed * 13.0)));
|
|
661
|
+
float grain = flNoise(vQuad * 3.0 + vSeed * 23.0);
|
|
662
|
+
vec3 charC = vec3(0.05, 0.043, 0.038) * (0.7 + 0.6 * grain);
|
|
663
|
+
// #A49E9D, the spec's own sample of ash (\xA78.3), in linear.
|
|
664
|
+
vec3 ashC = vec3(0.372, 0.344, 0.340);
|
|
665
|
+
// Pale ash with char under it, not char with a pale rim.
|
|
666
|
+
//
|
|
667
|
+
// It was the other way round, and the flakes came out near black: at 0.07
|
|
668
|
+
// linear they fell through the hole onto a black stage and read as dust in
|
|
669
|
+
// the void. Burnt paper ash is a LIGHT grey \u2014 what makes a flake flash dark
|
|
670
|
+
// and light as it tumbles is vLight, the face it is showing, not its own
|
|
671
|
+
// colour being nearly black to begin with.
|
|
672
|
+
vec3 c = mix(ashC, charC, (1.0 - rim * patches) * 0.45) * vLight;
|
|
673
|
+
// A few carry a hot edge that fades over their first second or so, on ONE
|
|
674
|
+
// arc of it \u2014 the side that was burning when it tore off. All the way round
|
|
675
|
+
// drew it as an orange ring.
|
|
676
|
+
float young = clamp((vColor.a - 0.55) / 0.4, 0.0, 1.0);
|
|
677
|
+
float arc = smoothstep(0.25, 0.8, cos(a - vSeed * 40.0));
|
|
678
|
+
float hotEdge = step(vSeed, 0.3) * rim * arc * young;
|
|
679
|
+
c += vec3(2.4, 0.6, 0.08) * hotEdge;
|
|
680
|
+
gl_FragColor = vec4(c, smoothstep(0.0, 0.2, vColor.a) * mask);
|
|
681
|
+
${OUTPUT}
|
|
682
|
+
}
|
|
683
|
+
`
|
|
684
|
+
);
|
|
685
|
+
function FxParticles({ pool }) {
|
|
686
|
+
const embers = useInstanced(pool.capacity, QUAD_STREAK);
|
|
687
|
+
const flakes = useInstanced(pool.capacity, QUAD_FLAKE);
|
|
688
|
+
const smoke = usePoints(pool.capacity);
|
|
689
|
+
const materials = useMemo(
|
|
690
|
+
() => ({
|
|
691
|
+
ember: new THREE.ShaderMaterial({
|
|
692
|
+
vertexShader: EMBER_VERTEX,
|
|
693
|
+
fragmentShader: EMBER_FRAGMENT,
|
|
694
|
+
uniforms: { uResolution: { value: new THREE.Vector2(1, 1) }, uStreak: { value: STREAK } },
|
|
695
|
+
transparent: true,
|
|
696
|
+
depthWrite: false,
|
|
697
|
+
blending: THREE.AdditiveBlending
|
|
698
|
+
}),
|
|
699
|
+
smoke: new THREE.ShaderMaterial({
|
|
700
|
+
vertexShader: SMOKE_VERTEX,
|
|
701
|
+
fragmentShader: SMOKE_FRAGMENT,
|
|
702
|
+
uniforms: { uScale: { value: 100 } },
|
|
703
|
+
transparent: true,
|
|
704
|
+
depthWrite: false
|
|
705
|
+
}),
|
|
706
|
+
flake: new THREE.ShaderMaterial({
|
|
707
|
+
vertexShader: FLAKE_VERTEX,
|
|
708
|
+
fragmentShader: FLAKE_FRAGMENT,
|
|
709
|
+
transparent: true,
|
|
710
|
+
depthWrite: false,
|
|
711
|
+
side: THREE.DoubleSide
|
|
712
|
+
})
|
|
713
|
+
}),
|
|
714
|
+
[]
|
|
715
|
+
);
|
|
716
|
+
useEffect(
|
|
717
|
+
() => () => {
|
|
718
|
+
materials.ember.dispose();
|
|
719
|
+
materials.smoke.dispose();
|
|
720
|
+
materials.flake.dispose();
|
|
721
|
+
},
|
|
722
|
+
[materials]
|
|
723
|
+
);
|
|
724
|
+
const size = useThree((s) => s.size);
|
|
725
|
+
useFrame(({ gl, camera }) => {
|
|
726
|
+
const counts = pool.write(embers.target, smoke.target, flakes.target);
|
|
727
|
+
embers.geometry.instanceCount = counts.additive;
|
|
728
|
+
flakes.geometry.instanceCount = counts.flakes;
|
|
729
|
+
smoke.geometry.setDrawRange(0, counts.normal);
|
|
730
|
+
for (const name of ["aPos", "aVel", "aColor", "aExtra"]) {
|
|
731
|
+
;
|
|
732
|
+
embers.geometry.attributes[name].needsUpdate = counts.additive > 0;
|
|
733
|
+
flakes.geometry.attributes[name].needsUpdate = counts.flakes > 0;
|
|
734
|
+
}
|
|
735
|
+
if (counts.normal > 0) {
|
|
736
|
+
for (const name of ["position", "pColor", "pExtra"]) {
|
|
737
|
+
;
|
|
738
|
+
smoke.geometry.attributes[name].needsUpdate = true;
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
const ratio = gl.getPixelRatio();
|
|
742
|
+
materials.ember.uniforms.uResolution.value.set(
|
|
743
|
+
size.width * ratio,
|
|
744
|
+
size.height * ratio
|
|
745
|
+
);
|
|
746
|
+
const projection = camera.projectionMatrix.elements[5] ?? 1;
|
|
747
|
+
materials.smoke.uniforms.uScale.value = size.height * ratio * 0.5 * projection;
|
|
748
|
+
});
|
|
749
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
750
|
+
/* @__PURE__ */ jsx("mesh", { geometry: embers.geometry, material: materials.ember, frustumCulled: false }),
|
|
751
|
+
/* @__PURE__ */ jsx("points", { geometry: smoke.geometry, material: materials.smoke, frustumCulled: false }),
|
|
752
|
+
/* @__PURE__ */ jsx("mesh", { geometry: flakes.geometry, material: materials.flake, frustumCulled: false })
|
|
753
|
+
] });
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// src/fx/FxPost.tsx
|
|
757
|
+
import { Suspense, lazy } from "react";
|
|
758
|
+
import { jsx as jsx2 } from "react/jsx-runtime";
|
|
759
|
+
var Pass = lazy(
|
|
760
|
+
() => import("./FxPostPass-M66QMVVC.js").then((m) => ({ default: m.FxPostPass })).catch((error) => {
|
|
761
|
+
warnOnce(error);
|
|
762
|
+
return { default: (() => null) };
|
|
763
|
+
})
|
|
764
|
+
);
|
|
765
|
+
var warned = false;
|
|
766
|
+
function warnOnce(cause) {
|
|
767
|
+
if (warned) return;
|
|
768
|
+
warned = true;
|
|
769
|
+
console.warn(
|
|
770
|
+
"[paperlab/fx] Rendering without FxPost \u2014 fire will not bloom.\nIt needs two optional peers:\n npm i @react-three/postprocessing postprocessing\nLeave <FxPost> out to go without it deliberately and silence this.",
|
|
771
|
+
...cause === void 0 ? [] : [cause]
|
|
772
|
+
);
|
|
773
|
+
}
|
|
774
|
+
function FxPost(props) {
|
|
775
|
+
return /* @__PURE__ */ jsx2(Suspense, { fallback: null, children: /* @__PURE__ */ jsx2(Pass, { ...props }) });
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// src/fx/afterglow.ts
|
|
779
|
+
var SMOULDER = 0.3;
|
|
780
|
+
var OUT = 4e-3;
|
|
781
|
+
var Afterglow = class {
|
|
782
|
+
size = FIELD_SIZE;
|
|
783
|
+
pixels = new Uint8Array(FIELD_SIZE * FIELD_SIZE * 4);
|
|
784
|
+
field;
|
|
785
|
+
glow = new Float32Array(FIELD_SIZE * FIELD_SIZE);
|
|
786
|
+
rate = new Float32Array(FIELD_SIZE * FIELD_SIZE);
|
|
787
|
+
revision = 0;
|
|
788
|
+
clock = 0;
|
|
789
|
+
flare = 0;
|
|
790
|
+
lastField = -1;
|
|
791
|
+
lit = false;
|
|
792
|
+
constructor(field, options = {}) {
|
|
793
|
+
this.field = field;
|
|
794
|
+
const [shortest, longest] = options.hold ?? [0.6, 2.2];
|
|
795
|
+
const seed = (options.seed ?? 5) >>> 0;
|
|
796
|
+
for (let i = 0; i < this.rate.length; i++) {
|
|
797
|
+
let h = Math.imul(i ^ seed, 2654435761) >>> 0;
|
|
798
|
+
h ^= h >>> 15;
|
|
799
|
+
h = Math.imul(h, 2246822519) >>> 0;
|
|
800
|
+
const life = shortest + (longest - shortest) * ((h >>> 0) / 4294967296);
|
|
801
|
+
this.rate[i] = 1 / life;
|
|
802
|
+
}
|
|
803
|
+
this.pixels.set(field.pixels);
|
|
804
|
+
}
|
|
805
|
+
get version() {
|
|
806
|
+
return this.revision;
|
|
807
|
+
}
|
|
808
|
+
get detail() {
|
|
809
|
+
return this.field.detail;
|
|
810
|
+
}
|
|
811
|
+
/** Its own clock: the beads keep flickering after the field has gone to sleep. */
|
|
812
|
+
get time() {
|
|
813
|
+
return this.clock;
|
|
814
|
+
}
|
|
815
|
+
/** Whether anything is still glowing that the field no longer is. */
|
|
816
|
+
get smouldering() {
|
|
817
|
+
return this.lit;
|
|
818
|
+
}
|
|
819
|
+
/**
|
|
820
|
+
* Advance by `dt`, after the field has stepped. `blow` is the breath on the
|
|
821
|
+
* sheet, 0..1: it flares what is left (§10.6, "on smoulder: beads flare,
|
|
822
|
+
* then fade").
|
|
823
|
+
*/
|
|
824
|
+
step(dt, blow = 0) {
|
|
825
|
+
if (!(dt > 0)) return;
|
|
826
|
+
this.clock += dt;
|
|
827
|
+
this.flare = blow > 0.05 ? Math.min(1, this.flare + dt * 4) : Math.max(0, this.flare - dt * 1.5);
|
|
828
|
+
const data = this.field.data;
|
|
829
|
+
const glow = this.glow;
|
|
830
|
+
let lit = false;
|
|
831
|
+
for (let i = 0; i < glow.length; i++) {
|
|
832
|
+
const heat = data[i * 4 + HEAT];
|
|
833
|
+
const was = glow[i];
|
|
834
|
+
let next;
|
|
835
|
+
if (heat >= was) {
|
|
836
|
+
next = heat;
|
|
837
|
+
} else {
|
|
838
|
+
next = Math.max(heat, Math.min(was, SMOULDER) * Math.exp(-dt * this.rate[i]));
|
|
839
|
+
}
|
|
840
|
+
if (next < OUT) next = 0;
|
|
841
|
+
glow[i] = next;
|
|
842
|
+
if (next > heat + OUT) lit = true;
|
|
843
|
+
}
|
|
844
|
+
this.lit = lit;
|
|
845
|
+
this.sync();
|
|
846
|
+
}
|
|
847
|
+
/** Copy the field in, and lay the remembered heat over its heat channel. */
|
|
848
|
+
sync() {
|
|
849
|
+
const fieldVersion = this.field.version;
|
|
850
|
+
if (!this.lit && fieldVersion === this.lastField && this.flare === 0) return;
|
|
851
|
+
this.lastField = fieldVersion;
|
|
852
|
+
const src = this.field.pixels;
|
|
853
|
+
const dst = this.pixels;
|
|
854
|
+
dst.set(src);
|
|
855
|
+
const boost = 1 + 0.9 * this.flare;
|
|
856
|
+
for (let i = 0; i < this.glow.length; i++) {
|
|
857
|
+
const g = Math.round(Math.min(1, this.glow[i] * boost) * 255);
|
|
858
|
+
const k = i * 4 + HEAT;
|
|
859
|
+
if (g > dst[k]) dst[k] = g;
|
|
860
|
+
}
|
|
861
|
+
this.revision++;
|
|
862
|
+
}
|
|
863
|
+
};
|
|
864
|
+
|
|
865
|
+
// src/fx/FxFlames.tsx
|
|
866
|
+
import { useFrame as useFrame2 } from "@react-three/fiber";
|
|
867
|
+
import { useEffect as useEffect2, useMemo as useMemo2, useRef } from "react";
|
|
868
|
+
|
|
869
|
+
// src/fx/flameShader.ts
|
|
870
|
+
import * as THREE2 from "three";
|
|
871
|
+
var NOISE = (
|
|
872
|
+
/* glsl */
|
|
873
|
+
`
|
|
874
|
+
float flHash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123); }
|
|
875
|
+
float flNoise(vec2 p) {
|
|
876
|
+
vec2 i = floor(p);
|
|
877
|
+
vec2 f = fract(p);
|
|
878
|
+
vec2 u = f * f * (3.0 - 2.0 * f);
|
|
879
|
+
return mix(mix(flHash(i), flHash(i + vec2(1.0, 0.0)), u.x), mix(flHash(i + vec2(0.0, 1.0)), flHash(i + vec2(1.0, 1.0)), u.x), u.y);
|
|
880
|
+
}
|
|
881
|
+
float flFbm(vec2 p) {
|
|
882
|
+
float v = 0.0;
|
|
883
|
+
float a = 0.5;
|
|
884
|
+
for (int i = 0; i < 4; i++) {
|
|
885
|
+
v += a * flNoise(p);
|
|
886
|
+
p = p * 2.03 + 17.0;
|
|
887
|
+
a *= 0.5;
|
|
888
|
+
}
|
|
889
|
+
return v;
|
|
890
|
+
}
|
|
891
|
+
float flHash1(float n) { return fract(sin(n) * 43758.5453); }
|
|
892
|
+
float flNoise1(float x) {
|
|
893
|
+
float i = floor(x);
|
|
894
|
+
float f = fract(x);
|
|
895
|
+
f = f * f * (3.0 - 2.0 * f);
|
|
896
|
+
return mix(flHash1(i), flHash1(i + 1.0), f);
|
|
897
|
+
}
|
|
898
|
+
`
|
|
899
|
+
);
|
|
900
|
+
var VERTEX = (
|
|
901
|
+
/* glsl */
|
|
902
|
+
`
|
|
903
|
+
attribute vec3 aBase;
|
|
904
|
+
attribute vec4 aShape; // height, width, seed, heat
|
|
905
|
+
uniform float uTime;
|
|
906
|
+
uniform vec3 uLean;
|
|
907
|
+
varying vec2 vUv;
|
|
908
|
+
varying float vSeed;
|
|
909
|
+
varying float vHeat;
|
|
910
|
+
${NOISE}
|
|
911
|
+
void main() {
|
|
912
|
+
vUv = position.xy;
|
|
913
|
+
vSeed = aShape.z;
|
|
914
|
+
vHeat = aShape.w;
|
|
915
|
+
// The same puff as \`flamePuff\` in flames.ts \u2014 the fire light reads it too.
|
|
916
|
+
float puff = 0.72 + 0.28 * flNoise1(uTime * 12.5 + aShape.z * 37.0) + 0.12 * (flNoise1(uTime * 23.0 + aShape.z * 11.0) - 0.5);
|
|
917
|
+
float h = aShape.x * puff;
|
|
918
|
+
vec3 up = normalize(vec3(0.0, 1.0, 0.0) + uLean);
|
|
919
|
+
vec3 toCamera = normalize(cameraPosition - aBase);
|
|
920
|
+
vec3 right = normalize(cross(up, toCamera));
|
|
921
|
+
// A curve, not a tilt: the root holds and the tip is carried.
|
|
922
|
+
vec3 p = aBase + toCamera * 0.004 + right * position.x * aShape.y + up * position.y * h
|
|
923
|
+
+ uLean * position.y * position.y * h * 0.8;
|
|
924
|
+
gl_Position = projectionMatrix * viewMatrix * vec4(p, 1.0);
|
|
925
|
+
}
|
|
926
|
+
`
|
|
927
|
+
);
|
|
928
|
+
var FRAGMENT = (
|
|
929
|
+
/* glsl */
|
|
930
|
+
`
|
|
931
|
+
uniform float uTime;
|
|
932
|
+
uniform float uBlue;
|
|
933
|
+
uniform float uGain;
|
|
934
|
+
varying vec2 vUv;
|
|
935
|
+
varying float vSeed;
|
|
936
|
+
varying float vHeat;
|
|
937
|
+
${NOISE}
|
|
938
|
+
void main() {
|
|
939
|
+
// The quad is 1.8\xD7 the flame's width, so the turbulence can carry the
|
|
940
|
+
// flame sideways without the quad's own edge cutting it into a bar.
|
|
941
|
+
float x = vUv.x * 1.8;
|
|
942
|
+
float y = vUv.y;
|
|
943
|
+
// Climbing at a speed that itself wanders \u2014 per flame, and over time \u2014 so
|
|
944
|
+
// the motion never settles into a loop. An offset, not a rate \xD7 time: a
|
|
945
|
+
// rate that changes would make the pattern jump.
|
|
946
|
+
float climb = uTime * (2.7 + 0.9 * vSeed) + 1.8 * flNoise1(uTime * 0.37 + vSeed * 9.1);
|
|
947
|
+
// Three layers of turbulence: a slow sway of the whole tongue, a flutter
|
|
948
|
+
// near the tip, and a fine shiver \u2014 each warping the next.
|
|
949
|
+
vec2 q = vec2(x * 1.2 + vSeed * 13.0, y * 1.9 - climb);
|
|
950
|
+
float sway = flFbm(q);
|
|
951
|
+
float flutter = flFbm(vec2(x * 2.7 + vSeed * 5.0 + sway * 1.3, y * 4.3 - climb * 1.55));
|
|
952
|
+
float shiver = flNoise(vec2(x * 7.0 + vSeed * 3.0, y * 11.0 - climb * 2.4));
|
|
953
|
+
// Every tongue leans its own way \u2014 nothing here is symmetrical.
|
|
954
|
+
float lean = (fract(vSeed * 91.7) - 0.5) * 0.45;
|
|
955
|
+
float xw = x + (sway - 0.5) * 1.35 * y + (flutter - 0.5) * 0.5 * y * y + (shiver - 0.5) * 0.12 * y + lean * y;
|
|
956
|
+
// The teardrop: dense and wide at the source, a point at the top.
|
|
957
|
+
float half_ = 1.0 * pow(max(y, 0.0), 0.3) * pow(max(1.0 - y, 0.0), 1.3);
|
|
958
|
+
// Crisp and dense low down, soft and translucent higher up.
|
|
959
|
+
float body = 1.0 - smoothstep(half_ * mix(0.82, 0.4, y), half_, abs(xw));
|
|
960
|
+
// Tips that split: a hard threshold high up tears the flame into separate
|
|
961
|
+
// tongues and wisps that come loose ...
|
|
962
|
+
float tear = flFbm(vec2(x * 2.4 + vSeed * 7.0, y * 4.2 - climb * 1.7));
|
|
963
|
+
body *= smoothstep(0.0, 0.14, tear + 0.82 - y * 1.1);
|
|
964
|
+
// ... and small gaps open inside the upper body, where the gas is thin.
|
|
965
|
+
float gaps = smoothstep(0.58, 0.72, flFbm(vec2(x * 3.3 - vSeed * 4.0, y * 6.5 - climb * 2.0)));
|
|
966
|
+
body *= 1.0 - gaps * smoothstep(0.3, 0.8, y) * 0.95;
|
|
967
|
+
body *= smoothstep(0.0, 0.04, y);
|
|
968
|
+
// The source is hot and dense: orange-white at the root, yellow above it,
|
|
969
|
+
// orange at the edges and tips, and translucent as it thins.
|
|
970
|
+
vec3 white = vec3(1.0, 0.86, 0.62); // orange-white
|
|
971
|
+
vec3 yellow = vec3(0.98, 0.62, 0.09); // #FDCE54
|
|
972
|
+
vec3 orange = vec3(0.77, 0.2, 0.0); // #E37B04
|
|
973
|
+
// The orange-white is only the very root; above it the flame is saturated
|
|
974
|
+
// yellow, then orange. Mixed broadly it washed the whole tongue pale.
|
|
975
|
+
float base = 1.0 - smoothstep(0.0, 0.26, y);
|
|
976
|
+
float inner = 1.0 - smoothstep(0.0, half_ * 0.5, abs(xw));
|
|
977
|
+
vec3 c = mix(orange, yellow, smoothstep(0.35, 0.85, body * (1.0 - y * 0.85)));
|
|
978
|
+
c = mix(c, white, clamp(base * inner, 0.0, 1.0));
|
|
979
|
+
// Dense at the source, and bright enough there to bloom; thinning above.
|
|
980
|
+
c *= mix(1.0, 4.4, base * (0.45 + 0.55 * inner)) * (0.55 + 0.45 * vHeat) * uGain;
|
|
981
|
+
// Only a match starved of air by moving fast burns blue.
|
|
982
|
+
c = mix(c, vec3(0.027, 0.025, 0.099) * 2.5, uBlue * 0.75);
|
|
983
|
+
float fade = 1.0 - smoothstep(0.55, 1.0, y);
|
|
984
|
+
gl_FragColor = vec4(c * body * fade, 1.0);
|
|
985
|
+
#include <tonemapping_fragment>
|
|
986
|
+
#include <colorspace_fragment>
|
|
987
|
+
}
|
|
988
|
+
`
|
|
989
|
+
);
|
|
990
|
+
function flameGeometry(max) {
|
|
991
|
+
const g = new THREE2.InstancedBufferGeometry();
|
|
992
|
+
g.setAttribute(
|
|
993
|
+
"position",
|
|
994
|
+
new THREE2.BufferAttribute(new Float32Array([-1, 0, 0, 1, 0, 0, 1, 1, 0, -1, 1, 0]), 3)
|
|
995
|
+
);
|
|
996
|
+
g.setIndex([0, 1, 2, 0, 2, 3]);
|
|
997
|
+
g.setAttribute("aBase", new THREE2.InstancedBufferAttribute(new Float32Array(Math.max(1, max) * 3), 3));
|
|
998
|
+
g.setAttribute("aShape", new THREE2.InstancedBufferAttribute(new Float32Array(Math.max(1, max) * 4), 4));
|
|
999
|
+
g.instanceCount = 0;
|
|
1000
|
+
g.boundingSphere = new THREE2.Sphere(new THREE2.Vector3(), Infinity);
|
|
1001
|
+
return g;
|
|
1002
|
+
}
|
|
1003
|
+
function flameMaterial() {
|
|
1004
|
+
return new THREE2.ShaderMaterial({
|
|
1005
|
+
vertexShader: VERTEX,
|
|
1006
|
+
fragmentShader: FRAGMENT,
|
|
1007
|
+
uniforms: {
|
|
1008
|
+
uTime: { value: 0 },
|
|
1009
|
+
uLean: { value: new THREE2.Vector3() },
|
|
1010
|
+
uBlue: { value: 0 },
|
|
1011
|
+
uGain: { value: 1 }
|
|
1012
|
+
},
|
|
1013
|
+
transparent: true,
|
|
1014
|
+
depthWrite: false,
|
|
1015
|
+
blending: THREE2.AdditiveBlending,
|
|
1016
|
+
side: THREE2.DoubleSide
|
|
1017
|
+
});
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
// src/fx/FxFlames.tsx
|
|
1021
|
+
import { jsx as jsx3 } from "react/jsx-runtime";
|
|
1022
|
+
function FxFlames({ field, locate, quality = "medium", wind }) {
|
|
1023
|
+
const max = fxQualityFor(quality).flames;
|
|
1024
|
+
const anchors = useRef([]);
|
|
1025
|
+
const geometry = useMemo2(() => flameGeometry(max), [max]);
|
|
1026
|
+
useEffect2(() => () => geometry.dispose(), [geometry]);
|
|
1027
|
+
const material = useMemo2(() => flameMaterial(), []);
|
|
1028
|
+
useEffect2(() => () => material.dispose(), [material]);
|
|
1029
|
+
useFrame2(() => {
|
|
1030
|
+
const n = flameAnchors(field, locate, max, anchors.current);
|
|
1031
|
+
const base = geometry.getAttribute("aBase");
|
|
1032
|
+
const shape = geometry.getAttribute("aShape");
|
|
1033
|
+
const [wx, wy, wz] = wind ?? [0, 0, 0];
|
|
1034
|
+
const gust = Math.hypot(wx, wy, wz);
|
|
1035
|
+
const shorten = 1 / (1 + gust * 0.6);
|
|
1036
|
+
for (let i = 0; i < n; i++) {
|
|
1037
|
+
const a = anchors.current[i];
|
|
1038
|
+
base.array[i * 3] = a.x;
|
|
1039
|
+
base.array[i * 3 + 1] = a.y;
|
|
1040
|
+
base.array[i * 3 + 2] = a.z;
|
|
1041
|
+
shape.array[i * 4] = a.height * shorten;
|
|
1042
|
+
shape.array[i * 4 + 1] = a.width;
|
|
1043
|
+
shape.array[i * 4 + 2] = a.seed;
|
|
1044
|
+
shape.array[i * 4 + 3] = a.heat;
|
|
1045
|
+
}
|
|
1046
|
+
base.needsUpdate = true;
|
|
1047
|
+
shape.needsUpdate = true;
|
|
1048
|
+
geometry.instanceCount = n;
|
|
1049
|
+
material.uniforms.uTime.value = field.time;
|
|
1050
|
+
material.uniforms.uLean.value.set(wx * 0.5, 0, wz * 0.5);
|
|
1051
|
+
});
|
|
1052
|
+
return /* @__PURE__ */ jsx3("mesh", { geometry, material, frustumCulled: false, renderOrder: 2 });
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
// src/fx/FxWisps.tsx
|
|
1056
|
+
import * as THREE3 from "three";
|
|
1057
|
+
import { useFrame as useFrame3 } from "@react-three/fiber";
|
|
1058
|
+
import { useEffect as useEffect3, useMemo as useMemo3, useRef as useRef2 } from "react";
|
|
1059
|
+
import { jsx as jsx4 } from "react/jsx-runtime";
|
|
1060
|
+
function wispAlpha(age) {
|
|
1061
|
+
if (!(age >= 0 && age < LIFE)) return 0;
|
|
1062
|
+
return Math.min(1, age / 0.25) * (1 - age / LIFE) ** 1.6 * 0.42;
|
|
1063
|
+
}
|
|
1064
|
+
var WISPS = 2;
|
|
1065
|
+
var POINTS = 64;
|
|
1066
|
+
var LIFE = 3.4;
|
|
1067
|
+
var RISE = 0.075;
|
|
1068
|
+
var APART = 9;
|
|
1069
|
+
function FxWisps({ glow, field, locate, wind }) {
|
|
1070
|
+
const state = useRef2(
|
|
1071
|
+
Array.from({ length: WISPS }, (_, w) => ({
|
|
1072
|
+
cell: -1,
|
|
1073
|
+
phase: w * 3.1 + 0.7,
|
|
1074
|
+
// Ring buffer of births: root position and birth time.
|
|
1075
|
+
root: new Float32Array(POINTS * 3),
|
|
1076
|
+
born: new Float32Array(POINTS).fill(-1e9),
|
|
1077
|
+
next: 0,
|
|
1078
|
+
owed: 0
|
|
1079
|
+
}))
|
|
1080
|
+
);
|
|
1081
|
+
const last = useRef2(-1);
|
|
1082
|
+
const geometry = useMemo3(() => {
|
|
1083
|
+
const g = new THREE3.BufferGeometry();
|
|
1084
|
+
const vertices = WISPS * POINTS * 2;
|
|
1085
|
+
g.setAttribute("position", new THREE3.BufferAttribute(new Float32Array(vertices * 3), 3));
|
|
1086
|
+
g.setAttribute("aAlpha", new THREE3.BufferAttribute(new Float32Array(vertices), 1));
|
|
1087
|
+
const index = [];
|
|
1088
|
+
for (let w = 0; w < WISPS; w++) {
|
|
1089
|
+
for (let i = 0; i < POINTS - 1; i++) {
|
|
1090
|
+
const a = (w * POINTS + i) * 2;
|
|
1091
|
+
index.push(a, a + 1, a + 2, a + 1, a + 3, a + 2);
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
g.setIndex(index);
|
|
1095
|
+
g.boundingSphere = new THREE3.Sphere(new THREE3.Vector3(), Infinity);
|
|
1096
|
+
return g;
|
|
1097
|
+
}, []);
|
|
1098
|
+
useEffect3(() => () => geometry.dispose(), [geometry]);
|
|
1099
|
+
const material = useMemo3(
|
|
1100
|
+
() => new THREE3.ShaderMaterial({
|
|
1101
|
+
vertexShader: (
|
|
1102
|
+
/* glsl */
|
|
1103
|
+
`
|
|
1104
|
+
attribute float aAlpha;
|
|
1105
|
+
varying float vAlpha;
|
|
1106
|
+
void main() {
|
|
1107
|
+
vAlpha = aAlpha;
|
|
1108
|
+
gl_Position = projectionMatrix * viewMatrix * vec4(position, 1.0);
|
|
1109
|
+
}
|
|
1110
|
+
`
|
|
1111
|
+
),
|
|
1112
|
+
fragmentShader: (
|
|
1113
|
+
/* glsl */
|
|
1114
|
+
`
|
|
1115
|
+
varying float vAlpha;
|
|
1116
|
+
void main() {
|
|
1117
|
+
// Pale grey smoke, thin enough that only its accumulation reads.
|
|
1118
|
+
gl_FragColor = vec4(vec3(0.62, 0.61, 0.6), vAlpha);
|
|
1119
|
+
#include <tonemapping_fragment>
|
|
1120
|
+
#include <colorspace_fragment>
|
|
1121
|
+
}
|
|
1122
|
+
`
|
|
1123
|
+
),
|
|
1124
|
+
transparent: true,
|
|
1125
|
+
depthWrite: false,
|
|
1126
|
+
side: THREE3.DoubleSide
|
|
1127
|
+
}),
|
|
1128
|
+
[]
|
|
1129
|
+
);
|
|
1130
|
+
useEffect3(() => () => material.dispose(), [material]);
|
|
1131
|
+
useFrame3(({ camera }) => {
|
|
1132
|
+
const now = glow.time;
|
|
1133
|
+
const dt = last.current < 0 ? 0 : Math.min(0.1, Math.max(0, now - last.current));
|
|
1134
|
+
last.current = now;
|
|
1135
|
+
const beads = smoulderingBeads(glow, field, WISPS);
|
|
1136
|
+
const [wx, , wz] = wind ?? [0, 0, 0];
|
|
1137
|
+
const positions = geometry.getAttribute("position");
|
|
1138
|
+
const alphas = geometry.getAttribute("aAlpha");
|
|
1139
|
+
const p = new THREE3.Vector3();
|
|
1140
|
+
const q = new THREE3.Vector3();
|
|
1141
|
+
const toCamera = new THREE3.Vector3();
|
|
1142
|
+
const side = new THREE3.Vector3();
|
|
1143
|
+
const path = new Float32Array(POINTS * 3);
|
|
1144
|
+
const age = new Float32Array(POINTS);
|
|
1145
|
+
for (let w = 0; w < WISPS; w++) {
|
|
1146
|
+
const s = state.current[w];
|
|
1147
|
+
if (!beads.includes(s.cell)) {
|
|
1148
|
+
const taken = beads.find((c) => !state.current.some((o) => o !== s && o.cell === c)) ?? -1;
|
|
1149
|
+
if (taken !== s.cell) {
|
|
1150
|
+
s.born.fill(-1e9);
|
|
1151
|
+
s.next = 0;
|
|
1152
|
+
s.owed = 0;
|
|
1153
|
+
}
|
|
1154
|
+
s.cell = taken;
|
|
1155
|
+
}
|
|
1156
|
+
if (s.cell >= 0 && dt > 0) {
|
|
1157
|
+
s.owed += dt;
|
|
1158
|
+
const every = LIFE / POINTS;
|
|
1159
|
+
while (s.owed >= every) {
|
|
1160
|
+
s.owed -= every;
|
|
1161
|
+
const at = locate(
|
|
1162
|
+
s.cell % FIELD_SIZE / (FIELD_SIZE - 1),
|
|
1163
|
+
(s.cell / FIELD_SIZE | 0) / (FIELD_SIZE - 1)
|
|
1164
|
+
);
|
|
1165
|
+
if (!at) break;
|
|
1166
|
+
const k = s.next;
|
|
1167
|
+
s.root[k * 3] = at.x;
|
|
1168
|
+
s.root[k * 3 + 1] = at.y;
|
|
1169
|
+
s.root[k * 3 + 2] = at.z + 4e-3;
|
|
1170
|
+
s.born[k] = now;
|
|
1171
|
+
s.next = (k + 1) % POINTS;
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
for (let i = 0; i < POINTS; i++) {
|
|
1175
|
+
const k = (s.next - 1 - i + POINTS * 2) % POINTS;
|
|
1176
|
+
const a = now - s.born[k];
|
|
1177
|
+
age[i] = a;
|
|
1178
|
+
const bx = s.root[k * 3];
|
|
1179
|
+
const by = s.root[k * 3 + 1];
|
|
1180
|
+
const bz = s.root[k * 3 + 2];
|
|
1181
|
+
const sway = (noise1(a * 0.8 + s.born[k] * 0.3 + s.phase) - 0.5) * 0.07 * a;
|
|
1182
|
+
const lean = (noise1(a * 0.5 + s.phase * 2.3) - 0.5) * 0.02 * a;
|
|
1183
|
+
path[i * 3] = bx + sway + wx * a * a * 0.25;
|
|
1184
|
+
path[i * 3 + 1] = by + RISE * a * (1 + 0.18 * a);
|
|
1185
|
+
path[i * 3 + 2] = bz + lean + wz * a * a * 0.25;
|
|
1186
|
+
}
|
|
1187
|
+
let anchor = -1;
|
|
1188
|
+
for (let i = 0; i < POINTS; i++) {
|
|
1189
|
+
if (age[i] >= 0 && age[i] < LIFE) {
|
|
1190
|
+
anchor = i;
|
|
1191
|
+
break;
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
for (let i = 0; i < POINTS; i++) {
|
|
1195
|
+
const a = age[i];
|
|
1196
|
+
if (!(a >= 0 && a < LIFE)) {
|
|
1197
|
+
if (anchor >= 0) {
|
|
1198
|
+
path[i * 3] = path[anchor * 3];
|
|
1199
|
+
path[i * 3 + 1] = path[anchor * 3 + 1];
|
|
1200
|
+
path[i * 3 + 2] = path[anchor * 3 + 2];
|
|
1201
|
+
} else {
|
|
1202
|
+
const r = (s.next - 1 + POINTS) % POINTS * 3;
|
|
1203
|
+
path[i * 3] = s.root[r];
|
|
1204
|
+
path[i * 3 + 1] = s.root[r + 1];
|
|
1205
|
+
path[i * 3 + 2] = s.root[r + 2];
|
|
1206
|
+
}
|
|
1207
|
+
} else {
|
|
1208
|
+
anchor = i;
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
for (let i = 0; i < POINTS; i++) {
|
|
1212
|
+
const a = age[i];
|
|
1213
|
+
p.set(path[i * 3], path[i * 3 + 1], path[i * 3 + 2]);
|
|
1214
|
+
const j = Math.min(POINTS - 1, i + 1);
|
|
1215
|
+
const h = Math.max(0, i - 1);
|
|
1216
|
+
q.set(
|
|
1217
|
+
path[j * 3] - path[h * 3],
|
|
1218
|
+
path[j * 3 + 1] - path[h * 3 + 1],
|
|
1219
|
+
path[j * 3 + 2] - path[h * 3 + 2]
|
|
1220
|
+
);
|
|
1221
|
+
toCamera.copy(camera.position).sub(p);
|
|
1222
|
+
const alive = a >= 0 && a < LIFE;
|
|
1223
|
+
if (alive || i === 0) {
|
|
1224
|
+
side.crossVectors(q, toCamera);
|
|
1225
|
+
if (side.lengthSq() < 1e-20) side.set(1, 0, 0);
|
|
1226
|
+
side.normalize();
|
|
1227
|
+
}
|
|
1228
|
+
const half = 11e-4 + 22e-4 * Math.min(1, Math.max(0, a) / LIFE);
|
|
1229
|
+
const v = (w * POINTS + i) * 2;
|
|
1230
|
+
positions.setXYZ(v, p.x - side.x * half, p.y - side.y * half, p.z - side.z * half);
|
|
1231
|
+
positions.setXYZ(v + 1, p.x + side.x * half, p.y + side.y * half, p.z + side.z * half);
|
|
1232
|
+
const alpha = wispAlpha(a);
|
|
1233
|
+
alphas.setX(v, alpha);
|
|
1234
|
+
alphas.setX(v + 1, alpha);
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
positions.needsUpdate = true;
|
|
1238
|
+
alphas.needsUpdate = true;
|
|
1239
|
+
});
|
|
1240
|
+
return /* @__PURE__ */ jsx4("mesh", { geometry, material, frustumCulled: false, renderOrder: 1 });
|
|
1241
|
+
}
|
|
1242
|
+
function smoulderingBeads(glow, field, max) {
|
|
1243
|
+
const size = FIELD_SIZE;
|
|
1244
|
+
const shown = glow.pixels;
|
|
1245
|
+
const heat = field.pixels;
|
|
1246
|
+
const found = [];
|
|
1247
|
+
for (let y = 1; y < size - 1; y++) {
|
|
1248
|
+
for (let x = 1; x < size - 1; x++) {
|
|
1249
|
+
const c = y * size + x;
|
|
1250
|
+
const g = shown[c * 4 + HEAT];
|
|
1251
|
+
if (g < 10 || heat[c * 4 + HEAT] > 4 || shown[c * 4 + PRESENCE] < 128) continue;
|
|
1252
|
+
const rim = shown[(c - 1) * 4 + PRESENCE] < 128 || shown[(c + 1) * 4 + PRESENCE] < 128 || shown[(c - size) * 4 + PRESENCE] < 128 || shown[(c + size) * 4 + PRESENCE] < 128;
|
|
1253
|
+
if (rim) found.push({ cell: c, g });
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
found.sort((a, b) => b.g - a.g || a.cell - b.cell);
|
|
1257
|
+
const picked = [];
|
|
1258
|
+
for (const f of found) {
|
|
1259
|
+
if (picked.length >= max) break;
|
|
1260
|
+
const fx = f.cell % size;
|
|
1261
|
+
const fy = f.cell / size | 0;
|
|
1262
|
+
if (picked.every((p) => Math.hypot(p % size - fx, (p / size | 0) - fy) >= APART)) picked.push(f.cell);
|
|
1263
|
+
}
|
|
1264
|
+
return picked;
|
|
1265
|
+
}
|
|
1266
|
+
function hash1(n) {
|
|
1267
|
+
const s = Math.sin(n) * 43758.5453;
|
|
1268
|
+
return s - Math.floor(s);
|
|
1269
|
+
}
|
|
1270
|
+
function noise1(x) {
|
|
1271
|
+
const i = Math.floor(x);
|
|
1272
|
+
let f = x - i;
|
|
1273
|
+
f = f * f * (3 - 2 * f);
|
|
1274
|
+
return hash1(i) + (hash1(i + 1) - hash1(i)) * f;
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
// src/fx/FxFireFluid.tsx
|
|
1278
|
+
import * as THREE5 from "three";
|
|
1279
|
+
import { useFrame as useFrame4, useThree as useThree2 } from "@react-three/fiber";
|
|
1280
|
+
import { useEffect as useEffect4, useMemo as useMemo4, useRef as useRef3 } from "react";
|
|
1281
|
+
|
|
1282
|
+
// src/fx/fluid/FireFluid.ts
|
|
1283
|
+
import * as THREE4 from "three";
|
|
1284
|
+
|
|
1285
|
+
// src/fx/fluid/passes.ts
|
|
1286
|
+
var MAX_SOURCES = 64;
|
|
1287
|
+
var PASS_VERTEX = (
|
|
1288
|
+
/* glsl */
|
|
1289
|
+
`
|
|
1290
|
+
varying vec2 vUv;
|
|
1291
|
+
void main() {
|
|
1292
|
+
vUv = position.xy * 0.5 + 0.5;
|
|
1293
|
+
gl_Position = vec4(position.xy, 0.0, 1.0);
|
|
1294
|
+
}
|
|
1295
|
+
`
|
|
1296
|
+
);
|
|
1297
|
+
var NOISE2 = (
|
|
1298
|
+
/* glsl */
|
|
1299
|
+
`
|
|
1300
|
+
float fxHash(vec2 p) {
|
|
1301
|
+
vec3 p3 = fract(vec3(p.xyx) * 0.1031);
|
|
1302
|
+
p3 += dot(p3, p3.yzx + 33.33);
|
|
1303
|
+
return fract((p3.x + p3.y) * p3.z);
|
|
1304
|
+
}
|
|
1305
|
+
float fxNoise(vec2 p) {
|
|
1306
|
+
vec2 i = floor(p);
|
|
1307
|
+
vec2 f = fract(p);
|
|
1308
|
+
vec2 u = f * f * (3.0 - 2.0 * f);
|
|
1309
|
+
return mix(mix(fxHash(i), fxHash(i + vec2(1.0, 0.0)), u.x), mix(fxHash(i + vec2(0.0, 1.0)), fxHash(i + vec2(1.0, 1.0)), u.x), u.y);
|
|
1310
|
+
}
|
|
1311
|
+
float fxFbm(vec2 p) {
|
|
1312
|
+
float v = 0.0;
|
|
1313
|
+
float a = 0.5;
|
|
1314
|
+
for (int i = 0; i < 3; i++) {
|
|
1315
|
+
v += a * fxNoise(p);
|
|
1316
|
+
p = p * 2.07 + 13.0;
|
|
1317
|
+
a *= 0.5;
|
|
1318
|
+
}
|
|
1319
|
+
return v;
|
|
1320
|
+
}
|
|
1321
|
+
float fxHash3(vec3 p) {
|
|
1322
|
+
vec3 p3 = fract(p * 0.1031);
|
|
1323
|
+
p3 += dot(p3, p3.zyx + 31.32);
|
|
1324
|
+
return fract((p3.x + p3.y) * p3.z);
|
|
1325
|
+
}
|
|
1326
|
+
float fxNoise3(vec3 p) {
|
|
1327
|
+
vec3 i = floor(p);
|
|
1328
|
+
vec3 f = fract(p);
|
|
1329
|
+
vec3 u = f * f * (3.0 - 2.0 * f);
|
|
1330
|
+
float a = mix(mix(fxHash3(i), fxHash3(i + vec3(1.0, 0.0, 0.0)), u.x), mix(fxHash3(i + vec3(0.0, 1.0, 0.0)), fxHash3(i + vec3(1.0, 1.0, 0.0)), u.x), u.y);
|
|
1331
|
+
float b = mix(mix(fxHash3(i + vec3(0.0, 0.0, 1.0)), fxHash3(i + vec3(1.0, 0.0, 1.0)), u.x), mix(fxHash3(i + vec3(0.0, 1.0, 1.0)), fxHash3(i + vec3(1.0, 1.0, 1.0)), u.x), u.y);
|
|
1332
|
+
return mix(a, b, u.z);
|
|
1333
|
+
}
|
|
1334
|
+
float fxFbm3(vec3 p) {
|
|
1335
|
+
float v = 0.0;
|
|
1336
|
+
float a = 0.5;
|
|
1337
|
+
for (int i = 0; i < 3; i++) {
|
|
1338
|
+
v += a * fxNoise3(p);
|
|
1339
|
+
p = p * 2.07 + 13.0;
|
|
1340
|
+
a *= 0.5;
|
|
1341
|
+
}
|
|
1342
|
+
return v;
|
|
1343
|
+
}
|
|
1344
|
+
`
|
|
1345
|
+
);
|
|
1346
|
+
var SOURCES = (
|
|
1347
|
+
/* glsl */
|
|
1348
|
+
`
|
|
1349
|
+
uniform vec4 uSources[${MAX_SOURCES}];
|
|
1350
|
+
uniform vec4 uAcross[${MAX_SOURCES}];
|
|
1351
|
+
uniform int uCount;
|
|
1352
|
+
uniform float uAspect;
|
|
1353
|
+
float fxEmission(vec2 uv, out vec2 outward, out float smoulder) {
|
|
1354
|
+
float e = 0.0;
|
|
1355
|
+
outward = vec2(0.0);
|
|
1356
|
+
smoulder = 0.0;
|
|
1357
|
+
for (int i = 0; i < ${MAX_SOURCES}; i++) {
|
|
1358
|
+
if (i >= uCount) break;
|
|
1359
|
+
vec4 s = uSources[i];
|
|
1360
|
+
vec4 k = uAcross[i];
|
|
1361
|
+
vec2 d = (uv - s.xy) * vec2(uAspect, 1.0);
|
|
1362
|
+
vec2 n = k.xy;
|
|
1363
|
+
float across = dot(d, n) - k.w;
|
|
1364
|
+
float along = dot(d, vec2(-n.y, n.x));
|
|
1365
|
+
float g = abs(s.w) * exp(-along * along / (s.z * s.z) - across * across / (k.z * k.z));
|
|
1366
|
+
if (s.w < 0.0) {
|
|
1367
|
+
smoulder += g;
|
|
1368
|
+
continue;
|
|
1369
|
+
}
|
|
1370
|
+
e += g;
|
|
1371
|
+
// Off the edge, away from the paper it came from.
|
|
1372
|
+
outward -= g * n;
|
|
1373
|
+
}
|
|
1374
|
+
return e;
|
|
1375
|
+
}
|
|
1376
|
+
`
|
|
1377
|
+
);
|
|
1378
|
+
var EMIT = (
|
|
1379
|
+
/* glsl */
|
|
1380
|
+
`
|
|
1381
|
+
varying vec2 vUv;
|
|
1382
|
+
${SOURCES}
|
|
1383
|
+
void main() {
|
|
1384
|
+
vec2 outward;
|
|
1385
|
+
float smoulder;
|
|
1386
|
+
float e = fxEmission(vUv, outward, smoulder);
|
|
1387
|
+
gl_FragColor = vec4(e, outward, smoulder);
|
|
1388
|
+
}
|
|
1389
|
+
`
|
|
1390
|
+
);
|
|
1391
|
+
var ADVECT = (
|
|
1392
|
+
/* glsl */
|
|
1393
|
+
`
|
|
1394
|
+
uniform sampler2D uVelocity;
|
|
1395
|
+
uniform sampler2D uSource;
|
|
1396
|
+
uniform vec2 uDomain;
|
|
1397
|
+
uniform float uDt;
|
|
1398
|
+
uniform vec4 uKeep;
|
|
1399
|
+
varying vec2 vUv;
|
|
1400
|
+
void main() {
|
|
1401
|
+
vec2 v = texture2D(uVelocity, vUv).xy;
|
|
1402
|
+
vec2 back = vUv - v * uDt / uDomain;
|
|
1403
|
+
gl_FragColor = texture2D(uSource, back) * uKeep;
|
|
1404
|
+
}
|
|
1405
|
+
`
|
|
1406
|
+
);
|
|
1407
|
+
var MACCORMACK = (
|
|
1408
|
+
/* glsl */
|
|
1409
|
+
`
|
|
1410
|
+
uniform sampler2D uVelocity;
|
|
1411
|
+
uniform sampler2D uSource;
|
|
1412
|
+
uniform sampler2D uForward;
|
|
1413
|
+
uniform sampler2D uBackward;
|
|
1414
|
+
uniform vec2 uDomain;
|
|
1415
|
+
uniform vec2 uTexel;
|
|
1416
|
+
uniform float uDt;
|
|
1417
|
+
varying vec2 vUv;
|
|
1418
|
+
void main() {
|
|
1419
|
+
vec4 forward = texture2D(uForward, vUv);
|
|
1420
|
+
vec4 phi = forward + 0.5 * (texture2D(uSource, vUv) - texture2D(uBackward, vUv));
|
|
1421
|
+
vec2 v = texture2D(uVelocity, vUv).xy;
|
|
1422
|
+
vec2 back = vUv - v * uDt / uDomain;
|
|
1423
|
+
vec2 corner = (floor(back / uTexel - 0.5) + 0.5) * uTexel;
|
|
1424
|
+
vec4 a = texture2D(uSource, corner);
|
|
1425
|
+
vec4 b = texture2D(uSource, corner + vec2(uTexel.x, 0.0));
|
|
1426
|
+
vec4 c = texture2D(uSource, corner + vec2(0.0, uTexel.y));
|
|
1427
|
+
vec4 d = texture2D(uSource, corner + uTexel);
|
|
1428
|
+
gl_FragColor = clamp(phi, min(min(a, b), min(c, d)), max(max(a, b), max(c, d)));
|
|
1429
|
+
}
|
|
1430
|
+
`
|
|
1431
|
+
);
|
|
1432
|
+
var REACT = (
|
|
1433
|
+
/* glsl */
|
|
1434
|
+
`
|
|
1435
|
+
uniform sampler2D uA;
|
|
1436
|
+
uniform sampler2D uB;
|
|
1437
|
+
uniform sampler2D uEmit;
|
|
1438
|
+
uniform sampler2D uVelocity;
|
|
1439
|
+
uniform vec2 uDomain;
|
|
1440
|
+
uniform vec2 uTexel;
|
|
1441
|
+
uniform int uOut;
|
|
1442
|
+
uniform float uDt;
|
|
1443
|
+
uniform float uFuel;
|
|
1444
|
+
uniform float uHeat;
|
|
1445
|
+
uniform float uSmoke;
|
|
1446
|
+
uniform float uPremixed;
|
|
1447
|
+
uniform float uAmbient;
|
|
1448
|
+
uniform float uBurnRate;
|
|
1449
|
+
uniform float uHeatRelease;
|
|
1450
|
+
uniform float uCooling;
|
|
1451
|
+
uniform float uSmokeProduction;
|
|
1452
|
+
uniform float uSmokeFade;
|
|
1453
|
+
uniform float uPersistence;
|
|
1454
|
+
uniform float uMixing;
|
|
1455
|
+
uniform float uEntrain;
|
|
1456
|
+
uniform float uFuelBlock;
|
|
1457
|
+
uniform float uSootYield;
|
|
1458
|
+
uniform float uSootHeat;
|
|
1459
|
+
uniform float uStoich;
|
|
1460
|
+
uniform float uSmoulderSmoke;
|
|
1461
|
+
uniform float uSmoulderHeat;
|
|
1462
|
+
varying vec2 vUv;
|
|
1463
|
+
void main() {
|
|
1464
|
+
vec4 a = texture2D(uA, vUv);
|
|
1465
|
+
vec4 b = texture2D(uB, vUv);
|
|
1466
|
+
// What a parcel picked up crossing the rim during the step \u2014 sampled back
|
|
1467
|
+
// along its path, not at the one point it ended at. The band gas comes off
|
|
1468
|
+
// is ~2 mm across and the gas crosses it at ~5 mm a step at 60 Hz, so a
|
|
1469
|
+
// point sample drew one line of fuel per step: horizontal stripes up every
|
|
1470
|
+
// tongue.
|
|
1471
|
+
vec2 path = texture2D(uVelocity, vUv).xy * uDt / uDomain;
|
|
1472
|
+
vec4 em = 0.25 * (texture2D(uEmit, vUv) + texture2D(uEmit, vUv - path * 0.25) +
|
|
1473
|
+
texture2D(uEmit, vUv - path * 0.5) + texture2D(uEmit, vUv - path * 0.75));
|
|
1474
|
+
float e = em.r;
|
|
1475
|
+
// What the rim releases this step \u2014 and, where a flame has gone out, the
|
|
1476
|
+
// warm smoke the spot goes on giving off for a while.
|
|
1477
|
+
float fuel = a.r + e * uFuel * uDt;
|
|
1478
|
+
float heat = a.g + (e * uHeat + em.a * uSmoulderHeat) * uDt;
|
|
1479
|
+
float smoke = a.b + (e * uSmoke + em.a * uSmoulderSmoke) * uDt;
|
|
1480
|
+
float premixed = b.r + e * uFuel * uPremixed * uDt;
|
|
1481
|
+
// Air, mixed in from beside and entrained from in front and behind.
|
|
1482
|
+
float around = 0.25 * (
|
|
1483
|
+
texture2D(uB, vUv + vec2(uTexel.x, 0.0)).g + texture2D(uB, vUv - vec2(uTexel.x, 0.0)).g +
|
|
1484
|
+
texture2D(uB, vUv + vec2(0.0, uTexel.y)).g + texture2D(uB, vUv - vec2(0.0, uTexel.y)).g);
|
|
1485
|
+
float oxygen = mix(b.g, around, uMixing);
|
|
1486
|
+
float reach = exp(-fuel / max(uFuelBlock, 1e-4));
|
|
1487
|
+
oxygen += (uAmbient - oxygen) * (1.0 - exp(-uEntrain * reach * uDt));
|
|
1488
|
+
// Fuel burns only as far as there is oxygen for it \u2014 premixed first \u2014 and a
|
|
1489
|
+
// unit of fuel takes uStoich of air. At one to one (as it once was) fuel was
|
|
1490
|
+
// never denser than the air around it, so nothing ever ran out of air and
|
|
1491
|
+
// every scrap of fuel burnt where it stood: no core, no tip.
|
|
1492
|
+
float burn = min(fuel, premixed + oxygen / uStoich) * (1.0 - exp(-uBurnRate * uDt));
|
|
1493
|
+
fuel = max(0.0, fuel - burn) * exp(-uDt * 0.6);
|
|
1494
|
+
float fromPremixed = min(premixed, burn);
|
|
1495
|
+
premixed = max(0.0, premixed - fromPremixed) * exp(-uDt * 0.6);
|
|
1496
|
+
oxygen = max(0.0, oxygen - (burn - fromPremixed) * uStoich);
|
|
1497
|
+
heat = (heat + burn * uHeatRelease) * exp(-uCooling * uDt);
|
|
1498
|
+
float flameHeat = (b.a + burn * uHeatRelease) * exp(-uCooling * uDt);
|
|
1499
|
+
smoke = (smoke + burn * uSmokeProduction) * exp(-uDt / uSmokeFade);
|
|
1500
|
+
float rate = burn / max(uDt, 1e-4);
|
|
1501
|
+
// Soot: forms from hot fuel, burns where there is air, and is gone once the
|
|
1502
|
+
// gas is too cool to glow.
|
|
1503
|
+
float hot = heat / max(uSootHeat, 1e-3);
|
|
1504
|
+
float soot = a.a + uSootYield * (fuel + burn) * smoothstep(0.3, 0.8, hot) * uDt;
|
|
1505
|
+
float air = oxygen / max(uAmbient, 0.05);
|
|
1506
|
+
// Soot that cools stops glowing within a few frames \u2014 it is smoke now, and
|
|
1507
|
+
// the smoke channel already carries that. Held to glowing only below a
|
|
1508
|
+
// twentieth of uSootHeat, it outlived the flame by a hand's breadth and
|
|
1509
|
+
// drew ribbons and hooks of flame colour high over the sheet.
|
|
1510
|
+
float cold = 1.0 - smoothstep(0.2, 0.6, hot);
|
|
1511
|
+
soot *= exp(-uDt * (air / max(uPersistence, 1e-3) + cold * 25.0));
|
|
1512
|
+
// An open top: whatever reaches it leaves.
|
|
1513
|
+
float open = 1.0 - smoothstep(0.9, 1.0, vUv.y);
|
|
1514
|
+
if (uOut == 0) gl_FragColor = vec4(fuel, heat, smoke * open, soot) * vec4(open, open, 1.0, open);
|
|
1515
|
+
else gl_FragColor = vec4(premixed * open, oxygen, rate, flameHeat * open);
|
|
1516
|
+
}
|
|
1517
|
+
`
|
|
1518
|
+
);
|
|
1519
|
+
var FORCES = (
|
|
1520
|
+
/* glsl */
|
|
1521
|
+
`
|
|
1522
|
+
uniform sampler2D uVelocity;
|
|
1523
|
+
uniform sampler2D uA;
|
|
1524
|
+
uniform sampler2D uEmit;
|
|
1525
|
+
uniform vec2 uTexel;
|
|
1526
|
+
uniform vec2 uDomain;
|
|
1527
|
+
uniform float uDt;
|
|
1528
|
+
uniform float uTime;
|
|
1529
|
+
uniform float uBuoyancy;
|
|
1530
|
+
uniform float uWind;
|
|
1531
|
+
uniform float uTurbulence;
|
|
1532
|
+
uniform float uTurbScale;
|
|
1533
|
+
uniform float uTurbEvolve;
|
|
1534
|
+
uniform float uRadial;
|
|
1535
|
+
uniform vec2 uInitVel;
|
|
1536
|
+
varying vec2 vUv;
|
|
1537
|
+
${NOISE2}
|
|
1538
|
+
void main() {
|
|
1539
|
+
vec2 v = texture2D(uVelocity, vUv).xy;
|
|
1540
|
+
vec4 a = texture2D(uA, vUv);
|
|
1541
|
+
// Hot gas rises.
|
|
1542
|
+
v.y += uBuoyancy * a.g * uDt;
|
|
1543
|
+
v.x += uWind * uDt;
|
|
1544
|
+
// Curl noise: divergence-free eddies, in world units, carried up with the
|
|
1545
|
+
// gas AND changing as they go. The third coordinate is time: a 2D pattern
|
|
1546
|
+
// that only scrolled was a wave travelling up a still flame, never a flame
|
|
1547
|
+
// that flickers.
|
|
1548
|
+
float drift = uTime * 0.8 + 1.7 * fxNoise(vec2(uTime * 0.23, 3.1));
|
|
1549
|
+
vec3 p = vec3(vUv * uDomain * uTurbScale + vec2(0.37 * fxNoise(vec2(uTime * 0.17, 9.0)), -drift), uTime * uTurbEvolve);
|
|
1550
|
+
float h = 0.04;
|
|
1551
|
+
float n = fxFbm3(p);
|
|
1552
|
+
vec2 curl = vec2(fxFbm3(p + vec3(0.0, h, 0.0)) - n, -(fxFbm3(p + vec3(h, 0.0, 0.0)) - n)) / h;
|
|
1553
|
+
v += curl * uTurbulence * uDt * (0.2 + clamp(a.g, 0.0, 2.0));
|
|
1554
|
+
// The gas leaves the paper at its own speed, pushed off the edge if asked.
|
|
1555
|
+
vec4 em = texture2D(uEmit, vUv);
|
|
1556
|
+
float e = em.r;
|
|
1557
|
+
vec2 launch = uInitVel + uRadial * em.gb / max(e, 1e-4);
|
|
1558
|
+
v = mix(v, launch, clamp(e * uDt * 6.0, 0.0, 1.0));
|
|
1559
|
+
// Walls on three sides; the top is open.
|
|
1560
|
+
if (vUv.x < uTexel.x || vUv.x > 1.0 - uTexel.x || vUv.y < uTexel.y) v = vec2(0.0);
|
|
1561
|
+
gl_FragColor = vec4(v, 0.0, 1.0);
|
|
1562
|
+
}
|
|
1563
|
+
`
|
|
1564
|
+
);
|
|
1565
|
+
var CURL = (
|
|
1566
|
+
/* glsl */
|
|
1567
|
+
`
|
|
1568
|
+
uniform sampler2D uVelocity;
|
|
1569
|
+
uniform vec2 uTexel;
|
|
1570
|
+
uniform float uCell;
|
|
1571
|
+
varying vec2 vUv;
|
|
1572
|
+
void main() {
|
|
1573
|
+
float l = texture2D(uVelocity, vUv - vec2(uTexel.x, 0.0)).y;
|
|
1574
|
+
float r = texture2D(uVelocity, vUv + vec2(uTexel.x, 0.0)).y;
|
|
1575
|
+
float b = texture2D(uVelocity, vUv - vec2(0.0, uTexel.y)).x;
|
|
1576
|
+
float t = texture2D(uVelocity, vUv + vec2(0.0, uTexel.y)).x;
|
|
1577
|
+
gl_FragColor = vec4((r - l - t + b) * 0.5 / uCell, 0.0, 0.0, 1.0);
|
|
1578
|
+
}
|
|
1579
|
+
`
|
|
1580
|
+
);
|
|
1581
|
+
var VORTICITY = (
|
|
1582
|
+
/* glsl */
|
|
1583
|
+
`
|
|
1584
|
+
uniform sampler2D uVelocity;
|
|
1585
|
+
uniform sampler2D uCurl;
|
|
1586
|
+
uniform vec2 uTexel;
|
|
1587
|
+
uniform float uCell;
|
|
1588
|
+
uniform float uDt;
|
|
1589
|
+
uniform float uVorticity;
|
|
1590
|
+
varying vec2 vUv;
|
|
1591
|
+
void main() {
|
|
1592
|
+
float l = abs(texture2D(uCurl, vUv - vec2(uTexel.x, 0.0)).x);
|
|
1593
|
+
float r = abs(texture2D(uCurl, vUv + vec2(uTexel.x, 0.0)).x);
|
|
1594
|
+
float b = abs(texture2D(uCurl, vUv - vec2(0.0, uTexel.y)).x);
|
|
1595
|
+
float t = abs(texture2D(uCurl, vUv + vec2(0.0, uTexel.y)).x);
|
|
1596
|
+
float w = texture2D(uCurl, vUv).x;
|
|
1597
|
+
vec2 n = vec2(r - l, t - b);
|
|
1598
|
+
n /= length(n) + 1e-5;
|
|
1599
|
+
vec2 v = texture2D(uVelocity, vUv).xy + uVorticity * uCell * vec2(n.y, -n.x) * w * uDt;
|
|
1600
|
+
gl_FragColor = vec4(v, 0.0, 1.0);
|
|
1601
|
+
}
|
|
1602
|
+
`
|
|
1603
|
+
);
|
|
1604
|
+
var DIVERGENCE = (
|
|
1605
|
+
/* glsl */
|
|
1606
|
+
`
|
|
1607
|
+
uniform sampler2D uVelocity;
|
|
1608
|
+
uniform sampler2D uB;
|
|
1609
|
+
uniform vec2 uTexel;
|
|
1610
|
+
uniform float uCell;
|
|
1611
|
+
uniform float uExpansion;
|
|
1612
|
+
varying vec2 vUv;
|
|
1613
|
+
void main() {
|
|
1614
|
+
float l = texture2D(uVelocity, vUv - vec2(uTexel.x, 0.0)).x;
|
|
1615
|
+
float r = texture2D(uVelocity, vUv + vec2(uTexel.x, 0.0)).x;
|
|
1616
|
+
float b = texture2D(uVelocity, vUv - vec2(0.0, uTexel.y)).y;
|
|
1617
|
+
float t = texture2D(uVelocity, vUv + vec2(0.0, uTexel.y)).y;
|
|
1618
|
+
float div = (r - l + t - b) * 0.5 / uCell - uExpansion * texture2D(uB, vUv).b;
|
|
1619
|
+
gl_FragColor = vec4(div, 0.0, 0.0, 1.0);
|
|
1620
|
+
}
|
|
1621
|
+
`
|
|
1622
|
+
);
|
|
1623
|
+
var PRESSURE = (
|
|
1624
|
+
/* glsl */
|
|
1625
|
+
`
|
|
1626
|
+
uniform sampler2D uPressure;
|
|
1627
|
+
uniform sampler2D uDivergence;
|
|
1628
|
+
uniform vec2 uTexel;
|
|
1629
|
+
uniform float uCell;
|
|
1630
|
+
varying vec2 vUv;
|
|
1631
|
+
void main() {
|
|
1632
|
+
float l = texture2D(uPressure, vUv - vec2(uTexel.x, 0.0)).x;
|
|
1633
|
+
float r = texture2D(uPressure, vUv + vec2(uTexel.x, 0.0)).x;
|
|
1634
|
+
float b = texture2D(uPressure, vUv - vec2(0.0, uTexel.y)).x;
|
|
1635
|
+
float t = texture2D(uPressure, vUv + vec2(0.0, uTexel.y)).x;
|
|
1636
|
+
float div = texture2D(uDivergence, vUv).x;
|
|
1637
|
+
gl_FragColor = vec4((l + r + b + t - div * uCell * uCell) * 0.25, 0.0, 0.0, 1.0);
|
|
1638
|
+
}
|
|
1639
|
+
`
|
|
1640
|
+
);
|
|
1641
|
+
var GRADIENT = (
|
|
1642
|
+
/* glsl */
|
|
1643
|
+
`
|
|
1644
|
+
uniform sampler2D uVelocity;
|
|
1645
|
+
uniform sampler2D uPressure;
|
|
1646
|
+
uniform vec2 uTexel;
|
|
1647
|
+
uniform float uCell;
|
|
1648
|
+
varying vec2 vUv;
|
|
1649
|
+
void main() {
|
|
1650
|
+
float l = texture2D(uPressure, vUv - vec2(uTexel.x, 0.0)).x;
|
|
1651
|
+
float r = texture2D(uPressure, vUv + vec2(uTexel.x, 0.0)).x;
|
|
1652
|
+
float b = texture2D(uPressure, vUv - vec2(0.0, uTexel.y)).x;
|
|
1653
|
+
float t = texture2D(uPressure, vUv + vec2(0.0, uTexel.y)).x;
|
|
1654
|
+
vec2 v = texture2D(uVelocity, vUv).xy - vec2(r - l, t - b) * 0.5 / uCell;
|
|
1655
|
+
gl_FragColor = vec4(v, 0.0, 1.0);
|
|
1656
|
+
}
|
|
1657
|
+
`
|
|
1658
|
+
);
|
|
1659
|
+
var FILL = (
|
|
1660
|
+
/* glsl */
|
|
1661
|
+
`
|
|
1662
|
+
uniform vec4 uValue;
|
|
1663
|
+
void main() {
|
|
1664
|
+
gl_FragColor = uValue;
|
|
1665
|
+
}
|
|
1666
|
+
`
|
|
1667
|
+
);
|
|
1668
|
+
var RENDER_VERTEX = (
|
|
1669
|
+
/* glsl */
|
|
1670
|
+
`
|
|
1671
|
+
varying vec2 vUv;
|
|
1672
|
+
void main() {
|
|
1673
|
+
vUv = uv;
|
|
1674
|
+
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
|
1675
|
+
}
|
|
1676
|
+
`
|
|
1677
|
+
);
|
|
1678
|
+
var RENDER_FRAGMENT = (
|
|
1679
|
+
/* glsl */
|
|
1680
|
+
`
|
|
1681
|
+
uniform sampler2D uA;
|
|
1682
|
+
uniform sampler2D uB;
|
|
1683
|
+
uniform float uTime;
|
|
1684
|
+
uniform float uGlow;
|
|
1685
|
+
uniform float uSmokeDensity;
|
|
1686
|
+
uniform float uPaperWhite;
|
|
1687
|
+
uniform float uHeatScale;
|
|
1688
|
+
uniform float uSootScale;
|
|
1689
|
+
uniform float uContrast;
|
|
1690
|
+
uniform float uOpacity;
|
|
1691
|
+
uniform float uThin;
|
|
1692
|
+
uniform vec3 uTipColor;
|
|
1693
|
+
uniform vec3 uBodyColor;
|
|
1694
|
+
uniform vec3 uCoreColor;
|
|
1695
|
+
uniform vec3 uRootColor;
|
|
1696
|
+
uniform float uTipGlow;
|
|
1697
|
+
uniform float uBodyGlow;
|
|
1698
|
+
uniform float uCoreGlow;
|
|
1699
|
+
uniform float uTipFrom;
|
|
1700
|
+
uniform float uTipTo;
|
|
1701
|
+
uniform float uCoreFrom;
|
|
1702
|
+
uniform float uSoftness;
|
|
1703
|
+
uniform float uTearing;
|
|
1704
|
+
uniform float uRootAmount;
|
|
1705
|
+
uniform float uRootReach;
|
|
1706
|
+
varying vec2 vUv;
|
|
1707
|
+
${NOISE2}
|
|
1708
|
+
void main() {
|
|
1709
|
+
// Detail finer than the grid, none of it touching the solve: a small domain
|
|
1710
|
+
// warp so a tongue's outline is never the grid's.
|
|
1711
|
+
vec2 w = vUv * vec2(26.0, 18.0) + vec2(0.0, -uTime * 1.6);
|
|
1712
|
+
vec2 warp = vec2(fxFbm(w), fxFbm(w + 31.7)) - 0.5;
|
|
1713
|
+
vec4 a = texture2D(uA, vUv + warp * 0.004);
|
|
1714
|
+
float fuel = a.r;
|
|
1715
|
+
float heat = texture2D(uB, vUv + warp * 0.004).a;
|
|
1716
|
+
float smoke = a.b;
|
|
1717
|
+
// How dense the glowing soot is, against a full flame's (FIRE_SOOT_SCALE).
|
|
1718
|
+
float rho = a.a / max(uSootScale, 1e-4);
|
|
1719
|
+
// The tip's tearing: carved into the soot where it is thin, so tongues come
|
|
1720
|
+
// apart at their edges and keep their bodies.
|
|
1721
|
+
vec2 n1 = vUv * vec2(34.0, 24.0) + vec2(0.0, -uTime * 2.4);
|
|
1722
|
+
vec2 n2 = vUv * vec2(92.0, 64.0) + vec2(0.0, -uTime * 5.5);
|
|
1723
|
+
float grain = (fxFbm(n1) - 0.5) * 0.72 + (fxNoise(n2) - 0.5) * 0.28;
|
|
1724
|
+
rho = max(0.0, rho * (1.0 + grain * uTearing * (1.0 - smoothstep(0.1, 0.8, rho))));
|
|
1725
|
+
// Temperature against the hottest gas a flame has (FIRE_HEAT_SCALE).
|
|
1726
|
+
float t = pow(clamp(heat / max(uHeatScale, 1e-3), 0.0, 2.0), uContrast);
|
|
1727
|
+
|
|
1728
|
+
// WHERE: the outline, from the soot.
|
|
1729
|
+
float shape = smoothstep(uTipFrom, uTipFrom + uSoftness, rho);
|
|
1730
|
+
// WHAT COLOUR: the zones, as bands of temperature.
|
|
1731
|
+
float toBody = smoothstep(uTipTo - 0.12, uTipTo + 0.12, t);
|
|
1732
|
+
float toCore = smoothstep(uCoreFrom, uCoreFrom + 0.35, t);
|
|
1733
|
+
vec3 color = mix(mix(uTipColor, uBodyColor, toBody), uCoreColor, toCore);
|
|
1734
|
+
float glow = mix(mix(uTipGlow, uBodyGlow, toBody), uCoreGlow, toCore);
|
|
1735
|
+
// Soot emits and absorbs together: thin soot glows as much as it covers, and
|
|
1736
|
+
// a dense flame covers what is behind it only as far as uOpacity says.
|
|
1737
|
+
float cover = 1.0 - exp(-rho * 3.0);
|
|
1738
|
+
float fireAlpha = shape * cover * clamp(uOpacity / 5.0, 0.0, 1.0);
|
|
1739
|
+
vec3 fire = color * glow * shape * uPaperWhite * uGlow * smoothstep(0.0, uThin, cover);
|
|
1740
|
+
// The root: added as LIGHT, where fresh fuel is still near the paper and the
|
|
1741
|
+
// gas has not heated through. Mixed in instead of added, blue makes grey.
|
|
1742
|
+
float rootMask = smoothstep(mix(0.4, 0.02, uRootReach), mix(0.6, 0.12, uRootReach), fuel) * (1.0 - smoothstep(0.25, 0.6, t));
|
|
1743
|
+
fire += uRootColor * rootMask * uRootAmount * uPaperWhite * uGlow * 0.6;
|
|
1744
|
+
|
|
1745
|
+
// Smoke: warm grey-brown (a cool grey over cream reads lavender), lit by
|
|
1746
|
+
// the fire under it.
|
|
1747
|
+
float smokeAlpha = 1.0 - exp(-smoke * uSmokeDensity);
|
|
1748
|
+
vec3 smokeColor = vec3(0.16, 0.13, 0.10) + vec3(0.22, 0.09, 0.02) * smoothstep(0.15, 0.9, t);
|
|
1749
|
+
float alpha = smokeAlpha + fireAlpha - smokeAlpha * fireAlpha;
|
|
1750
|
+
// No square edge: the domain fades out before its borders.
|
|
1751
|
+
float edge = smoothstep(0.0, 0.06, vUv.x) * smoothstep(1.0, 0.94, vUv.x) * smoothstep(0.0, 0.03, vUv.y) * smoothstep(1.0, 0.9, vUv.y);
|
|
1752
|
+
gl_FragColor = vec4((fire + smokeColor * smokeAlpha) * edge, alpha * edge);
|
|
1753
|
+
#include <tonemapping_fragment>
|
|
1754
|
+
#include <colorspace_fragment>
|
|
1755
|
+
}
|
|
1756
|
+
`
|
|
1757
|
+
);
|
|
1758
|
+
|
|
1759
|
+
// src/fx/fluid/FireFluid.ts
|
|
1760
|
+
var Pair = class {
|
|
1761
|
+
read;
|
|
1762
|
+
write;
|
|
1763
|
+
constructor(w, h) {
|
|
1764
|
+
this.read = target(w, h);
|
|
1765
|
+
this.write = target(w, h);
|
|
1766
|
+
}
|
|
1767
|
+
swap() {
|
|
1768
|
+
const t = this.read;
|
|
1769
|
+
this.read = this.write;
|
|
1770
|
+
this.write = t;
|
|
1771
|
+
}
|
|
1772
|
+
dispose() {
|
|
1773
|
+
this.read.dispose();
|
|
1774
|
+
this.write.dispose();
|
|
1775
|
+
}
|
|
1776
|
+
};
|
|
1777
|
+
function target(w, h) {
|
|
1778
|
+
return new THREE4.WebGLRenderTarget(w, h, {
|
|
1779
|
+
type: THREE4.HalfFloatType,
|
|
1780
|
+
format: THREE4.RGBAFormat,
|
|
1781
|
+
minFilter: THREE4.LinearFilter,
|
|
1782
|
+
magFilter: THREE4.LinearFilter,
|
|
1783
|
+
wrapS: THREE4.ClampToEdgeWrapping,
|
|
1784
|
+
wrapT: THREE4.ClampToEdgeWrapping,
|
|
1785
|
+
depthBuffer: false,
|
|
1786
|
+
stencilBuffer: false,
|
|
1787
|
+
generateMipmaps: false
|
|
1788
|
+
});
|
|
1789
|
+
}
|
|
1790
|
+
function pass(fragmentShader, uniforms) {
|
|
1791
|
+
return new THREE4.ShaderMaterial({
|
|
1792
|
+
vertexShader: PASS_VERTEX,
|
|
1793
|
+
fragmentShader,
|
|
1794
|
+
uniforms,
|
|
1795
|
+
depthTest: false,
|
|
1796
|
+
depthWrite: false,
|
|
1797
|
+
blending: THREE4.NoBlending
|
|
1798
|
+
});
|
|
1799
|
+
}
|
|
1800
|
+
var FireFluid = class {
|
|
1801
|
+
constructor(renderer, grid, domain) {
|
|
1802
|
+
this.renderer = renderer;
|
|
1803
|
+
this.grid = grid;
|
|
1804
|
+
this.domain = domain;
|
|
1805
|
+
const [vw, vh] = grid.velocity;
|
|
1806
|
+
const [dw, dh] = grid.dye;
|
|
1807
|
+
this.scalars = new Pair(dw, dh);
|
|
1808
|
+
this.air = new Pair(dw, dh);
|
|
1809
|
+
this.velocity = new Pair(vw, vh);
|
|
1810
|
+
this.pressure = new Pair(vw, vh);
|
|
1811
|
+
this.emit = target(dw, dh);
|
|
1812
|
+
this.divergence = target(vw, vh);
|
|
1813
|
+
this.curl = target(vw, vh);
|
|
1814
|
+
this.forward = target(dw, dh);
|
|
1815
|
+
this.backward = target(dw, dh);
|
|
1816
|
+
const aspect = domain.width / domain.height;
|
|
1817
|
+
const vTexel = new THREE4.Vector2(1 / vw, 1 / vh);
|
|
1818
|
+
const dTexel = new THREE4.Vector2(1 / dw, 1 / dh);
|
|
1819
|
+
const cell = domain.width / vw;
|
|
1820
|
+
this.m = {
|
|
1821
|
+
emit: pass(EMIT, {
|
|
1822
|
+
uSources: { value: this.sources },
|
|
1823
|
+
uAcross: { value: this.across },
|
|
1824
|
+
uCount: { value: 0 },
|
|
1825
|
+
uAspect: { value: aspect }
|
|
1826
|
+
}),
|
|
1827
|
+
advect: pass(ADVECT, {
|
|
1828
|
+
uVelocity: { value: null },
|
|
1829
|
+
uSource: { value: null },
|
|
1830
|
+
uDomain: { value: new THREE4.Vector2(domain.width, domain.height) },
|
|
1831
|
+
uDt: { value: 0 },
|
|
1832
|
+
uKeep: { value: new THREE4.Vector4(1, 1, 1, 1) }
|
|
1833
|
+
}),
|
|
1834
|
+
react: pass(REACT, {
|
|
1835
|
+
uA: { value: null },
|
|
1836
|
+
uB: { value: null },
|
|
1837
|
+
uEmit: { value: this.emit.texture },
|
|
1838
|
+
uVelocity: { value: null },
|
|
1839
|
+
uDomain: { value: new THREE4.Vector2(domain.width, domain.height) },
|
|
1840
|
+
uTexel: { value: dTexel },
|
|
1841
|
+
uOut: { value: 0 },
|
|
1842
|
+
uDt: { value: 0 },
|
|
1843
|
+
uFuel: { value: 0 },
|
|
1844
|
+
uHeat: { value: 0 },
|
|
1845
|
+
uSmoke: { value: 0 },
|
|
1846
|
+
uPremixed: { value: 0 },
|
|
1847
|
+
uAmbient: { value: 0 },
|
|
1848
|
+
uBurnRate: { value: 0 },
|
|
1849
|
+
uHeatRelease: { value: 0 },
|
|
1850
|
+
uCooling: { value: 0 },
|
|
1851
|
+
uSmokeProduction: { value: 0 },
|
|
1852
|
+
uSmokeFade: { value: 1 },
|
|
1853
|
+
uPersistence: { value: 0.1 },
|
|
1854
|
+
uMixing: { value: 0 },
|
|
1855
|
+
uEntrain: { value: 0 },
|
|
1856
|
+
uFuelBlock: { value: 1 },
|
|
1857
|
+
uSootYield: { value: 0 },
|
|
1858
|
+
uSootHeat: { value: 1 },
|
|
1859
|
+
uStoich: { value: 1 },
|
|
1860
|
+
uSmoulderSmoke: { value: 0 },
|
|
1861
|
+
uSmoulderHeat: { value: 0 }
|
|
1862
|
+
}),
|
|
1863
|
+
forces: pass(FORCES, {
|
|
1864
|
+
uVelocity: { value: null },
|
|
1865
|
+
uA: { value: null },
|
|
1866
|
+
uEmit: { value: this.emit.texture },
|
|
1867
|
+
uTexel: { value: vTexel },
|
|
1868
|
+
uDomain: { value: new THREE4.Vector2(domain.width, domain.height) },
|
|
1869
|
+
uDt: { value: 0 },
|
|
1870
|
+
uTime: { value: 0 },
|
|
1871
|
+
uBuoyancy: { value: 0 },
|
|
1872
|
+
uWind: { value: 0 },
|
|
1873
|
+
uTurbulence: { value: 0 },
|
|
1874
|
+
uTurbScale: { value: 1 },
|
|
1875
|
+
uTurbEvolve: { value: 0 },
|
|
1876
|
+
uRadial: { value: 0 },
|
|
1877
|
+
uInitVel: { value: new THREE4.Vector2() }
|
|
1878
|
+
}),
|
|
1879
|
+
curl: pass(CURL, { uVelocity: { value: null }, uTexel: { value: vTexel }, uCell: { value: cell } }),
|
|
1880
|
+
vorticity: pass(VORTICITY, {
|
|
1881
|
+
uVelocity: { value: null },
|
|
1882
|
+
uCurl: { value: null },
|
|
1883
|
+
uTexel: { value: vTexel },
|
|
1884
|
+
uCell: { value: cell },
|
|
1885
|
+
uDt: { value: 0 },
|
|
1886
|
+
uVorticity: { value: 0 }
|
|
1887
|
+
}),
|
|
1888
|
+
divergence: pass(DIVERGENCE, {
|
|
1889
|
+
uVelocity: { value: null },
|
|
1890
|
+
uB: { value: null },
|
|
1891
|
+
uTexel: { value: vTexel },
|
|
1892
|
+
uCell: { value: cell },
|
|
1893
|
+
uExpansion: { value: 0 }
|
|
1894
|
+
}),
|
|
1895
|
+
pressure: pass(PRESSURE, {
|
|
1896
|
+
uPressure: { value: null },
|
|
1897
|
+
uDivergence: { value: null },
|
|
1898
|
+
uTexel: { value: vTexel },
|
|
1899
|
+
uCell: { value: cell }
|
|
1900
|
+
}),
|
|
1901
|
+
gradient: pass(GRADIENT, {
|
|
1902
|
+
uVelocity: { value: null },
|
|
1903
|
+
uPressure: { value: null },
|
|
1904
|
+
uTexel: { value: vTexel },
|
|
1905
|
+
uCell: { value: cell }
|
|
1906
|
+
}),
|
|
1907
|
+
fill: pass(FILL, { uValue: { value: new THREE4.Vector4() } }),
|
|
1908
|
+
maccormack: pass(MACCORMACK, {
|
|
1909
|
+
uVelocity: { value: null },
|
|
1910
|
+
uSource: { value: null },
|
|
1911
|
+
uForward: { value: null },
|
|
1912
|
+
uBackward: { value: null },
|
|
1913
|
+
uDomain: { value: new THREE4.Vector2(domain.width, domain.height) },
|
|
1914
|
+
uTexel: { value: new THREE4.Vector2(1 / dw, 1 / dh) },
|
|
1915
|
+
uDt: { value: 0 }
|
|
1916
|
+
})
|
|
1917
|
+
};
|
|
1918
|
+
const triangle = new THREE4.BufferGeometry();
|
|
1919
|
+
triangle.setAttribute(
|
|
1920
|
+
"position",
|
|
1921
|
+
new THREE4.BufferAttribute(new Float32Array([-1, -1, 0, 3, -1, 0, -1, 3, 0]), 3)
|
|
1922
|
+
);
|
|
1923
|
+
this.mesh = new THREE4.Mesh(triangle, this.m.fill);
|
|
1924
|
+
this.mesh.frustumCulled = false;
|
|
1925
|
+
this.scene.add(this.mesh);
|
|
1926
|
+
}
|
|
1927
|
+
renderer;
|
|
1928
|
+
grid;
|
|
1929
|
+
domain;
|
|
1930
|
+
/** (fuel, heat, smoke, soot) on the fine grid — what is drawn. */
|
|
1931
|
+
scalars;
|
|
1932
|
+
/** (premixed oxygen, ambient oxygen, burn rate, flame heat) on the fine grid. */
|
|
1933
|
+
air;
|
|
1934
|
+
velocity;
|
|
1935
|
+
pressure;
|
|
1936
|
+
/** The rim's emission this step, on the fine grid — see EMIT. */
|
|
1937
|
+
emit;
|
|
1938
|
+
divergence;
|
|
1939
|
+
curl;
|
|
1940
|
+
/** The two intermediate advections the MacCormack step compares. */
|
|
1941
|
+
forward;
|
|
1942
|
+
backward;
|
|
1943
|
+
/**
|
|
1944
|
+
* Error-compensated advection for the drawn fields. On by default; off is
|
|
1945
|
+
* a single semi-Lagrangian step, which is two passes a step cheaper and
|
|
1946
|
+
* visibly softer — a knob for the lowest tier, and for an A/B.
|
|
1947
|
+
*/
|
|
1948
|
+
sharp = true;
|
|
1949
|
+
scene = new THREE4.Scene();
|
|
1950
|
+
camera = new THREE4.OrthographicCamera(-1, 1, 1, -1, 0, 1);
|
|
1951
|
+
mesh;
|
|
1952
|
+
m;
|
|
1953
|
+
sources = Array.from({ length: MAX_SOURCES }, () => new THREE4.Vector4());
|
|
1954
|
+
across = Array.from({ length: MAX_SOURCES }, () => new THREE4.Vector4());
|
|
1955
|
+
/** Whether this renderer can draw into half-float targets at all. */
|
|
1956
|
+
static supported(renderer) {
|
|
1957
|
+
return renderer.capabilities.isWebGL2 && (renderer.extensions.has("EXT_color_buffer_float") || renderer.extensions.has("EXT_color_buffer_half_float"));
|
|
1958
|
+
}
|
|
1959
|
+
/** Empty air, at the given ambient oxygen. */
|
|
1960
|
+
reset(ambient) {
|
|
1961
|
+
this.withRenderer(() => {
|
|
1962
|
+
const fill = this.m.fill;
|
|
1963
|
+
const value = fill.uniforms.uValue.value;
|
|
1964
|
+
value.set(0, 0, 0, 0);
|
|
1965
|
+
for (const t of [this.scalars, this.velocity, this.pressure]) {
|
|
1966
|
+
this.run(fill, t.read);
|
|
1967
|
+
this.run(fill, t.write);
|
|
1968
|
+
}
|
|
1969
|
+
this.run(fill, this.divergence);
|
|
1970
|
+
this.run(fill, this.curl);
|
|
1971
|
+
this.run(fill, this.emit);
|
|
1972
|
+
value.set(0, ambient, 0, 0);
|
|
1973
|
+
this.run(fill, this.air.read);
|
|
1974
|
+
this.run(fill, this.air.write);
|
|
1975
|
+
});
|
|
1976
|
+
}
|
|
1977
|
+
/**
|
|
1978
|
+
* One step of `dt` seconds. `sources` is (u, v, half-length, strength) per
|
|
1979
|
+
* rim segment and `across` is (toward-paper x, y, half-width, offset onto
|
|
1980
|
+
* the paper) — see EMIT — `count` of each; `time` drives the turbulence.
|
|
1981
|
+
*/
|
|
1982
|
+
step(dt, u, sources, across, count, time) {
|
|
1983
|
+
const n = Math.min(MAX_SOURCES, count);
|
|
1984
|
+
for (let i = 0; i < n; i++) {
|
|
1985
|
+
this.sources[i].fromArray(sources, i * 4);
|
|
1986
|
+
this.across[i].fromArray(across, i * 4);
|
|
1987
|
+
}
|
|
1988
|
+
const m = this.m;
|
|
1989
|
+
m.emit.uniforms.uCount.value = n;
|
|
1990
|
+
this.withRenderer(() => {
|
|
1991
|
+
this.run(m.emit, this.emit);
|
|
1992
|
+
const advect = m.advect;
|
|
1993
|
+
advect.uniforms.uDt.value = dt;
|
|
1994
|
+
advect.uniforms.uVelocity.value = this.velocity.read.texture;
|
|
1995
|
+
advect.uniforms.uKeep.value.set(0.998, 0.998, 1, 1);
|
|
1996
|
+
advect.uniforms.uSource.value = this.velocity.read.texture;
|
|
1997
|
+
this.run(advect, this.velocity.write);
|
|
1998
|
+
this.velocity.swap();
|
|
1999
|
+
advect.uniforms.uVelocity.value = this.velocity.read.texture;
|
|
2000
|
+
advect.uniforms.uKeep.value.set(1, 1, 1, 1);
|
|
2001
|
+
if (this.sharp) {
|
|
2002
|
+
advect.uniforms.uSource.value = this.scalars.read.texture;
|
|
2003
|
+
this.run(advect, this.forward);
|
|
2004
|
+
advect.uniforms.uDt.value = -dt;
|
|
2005
|
+
advect.uniforms.uSource.value = this.forward.texture;
|
|
2006
|
+
this.run(advect, this.backward);
|
|
2007
|
+
advect.uniforms.uDt.value = dt;
|
|
2008
|
+
const mc = m.maccormack;
|
|
2009
|
+
mc.uniforms.uDt.value = dt;
|
|
2010
|
+
mc.uniforms.uVelocity.value = this.velocity.read.texture;
|
|
2011
|
+
mc.uniforms.uSource.value = this.scalars.read.texture;
|
|
2012
|
+
mc.uniforms.uForward.value = this.forward.texture;
|
|
2013
|
+
mc.uniforms.uBackward.value = this.backward.texture;
|
|
2014
|
+
this.run(mc, this.scalars.write);
|
|
2015
|
+
} else {
|
|
2016
|
+
advect.uniforms.uSource.value = this.scalars.read.texture;
|
|
2017
|
+
this.run(advect, this.scalars.write);
|
|
2018
|
+
}
|
|
2019
|
+
this.scalars.swap();
|
|
2020
|
+
advect.uniforms.uSource.value = this.air.read.texture;
|
|
2021
|
+
this.run(advect, this.air.write);
|
|
2022
|
+
this.air.swap();
|
|
2023
|
+
const react = m.react;
|
|
2024
|
+
const r = react.uniforms;
|
|
2025
|
+
r.uDt.value = dt;
|
|
2026
|
+
r.uFuel.value = u.fuel;
|
|
2027
|
+
r.uHeat.value = u.heat;
|
|
2028
|
+
r.uSmoke.value = u.smoke;
|
|
2029
|
+
r.uPremixed.value = u.premixed;
|
|
2030
|
+
r.uAmbient.value = u.ambient;
|
|
2031
|
+
r.uBurnRate.value = u.burnRate;
|
|
2032
|
+
r.uHeatRelease.value = u.heatRelease;
|
|
2033
|
+
r.uCooling.value = u.cooling;
|
|
2034
|
+
r.uSmokeProduction.value = u.smokeProduction;
|
|
2035
|
+
r.uSmokeFade.value = u.smokeFade;
|
|
2036
|
+
r.uPersistence.value = u.persistence;
|
|
2037
|
+
r.uMixing.value = u.mixing;
|
|
2038
|
+
r.uEntrain.value = u.entrain;
|
|
2039
|
+
r.uFuelBlock.value = u.fuelBlock;
|
|
2040
|
+
r.uSootYield.value = u.sootYield;
|
|
2041
|
+
r.uSootHeat.value = u.sootHeat;
|
|
2042
|
+
r.uStoich.value = u.stoich;
|
|
2043
|
+
r.uSmoulderSmoke.value = u.smoulderSmoke;
|
|
2044
|
+
r.uSmoulderHeat.value = u.smoulderHeat;
|
|
2045
|
+
r.uVelocity.value = this.velocity.read.texture;
|
|
2046
|
+
r.uA.value = this.scalars.read.texture;
|
|
2047
|
+
r.uB.value = this.air.read.texture;
|
|
2048
|
+
r.uOut.value = 0;
|
|
2049
|
+
this.run(react, this.scalars.write);
|
|
2050
|
+
r.uOut.value = 1;
|
|
2051
|
+
this.run(react, this.air.write);
|
|
2052
|
+
this.scalars.swap();
|
|
2053
|
+
this.air.swap();
|
|
2054
|
+
const forces = m.forces;
|
|
2055
|
+
const f = forces.uniforms;
|
|
2056
|
+
f.uVelocity.value = this.velocity.read.texture;
|
|
2057
|
+
f.uA.value = this.scalars.read.texture;
|
|
2058
|
+
f.uDt.value = dt;
|
|
2059
|
+
f.uTime.value = time;
|
|
2060
|
+
f.uBuoyancy.value = u.buoyancy;
|
|
2061
|
+
f.uWind.value = u.wind;
|
|
2062
|
+
f.uTurbulence.value = u.turbulence;
|
|
2063
|
+
f.uTurbScale.value = u.turbulenceScale;
|
|
2064
|
+
f.uTurbEvolve.value = u.turbulenceEvolve;
|
|
2065
|
+
f.uRadial.value = u.radial;
|
|
2066
|
+
f.uInitVel.value.set(u.initialVelocity[0], u.initialVelocity[1]);
|
|
2067
|
+
this.run(forces, this.velocity.write);
|
|
2068
|
+
this.velocity.swap();
|
|
2069
|
+
m.curl.uniforms.uVelocity.value = this.velocity.read.texture;
|
|
2070
|
+
this.run(m.curl, this.curl);
|
|
2071
|
+
const vort = m.vorticity;
|
|
2072
|
+
vort.uniforms.uVelocity.value = this.velocity.read.texture;
|
|
2073
|
+
vort.uniforms.uCurl.value = this.curl.texture;
|
|
2074
|
+
vort.uniforms.uDt.value = dt;
|
|
2075
|
+
vort.uniforms.uVorticity.value = u.vorticity;
|
|
2076
|
+
this.run(vort, this.velocity.write);
|
|
2077
|
+
this.velocity.swap();
|
|
2078
|
+
const div = m.divergence;
|
|
2079
|
+
div.uniforms.uVelocity.value = this.velocity.read.texture;
|
|
2080
|
+
div.uniforms.uB.value = this.air.read.texture;
|
|
2081
|
+
div.uniforms.uExpansion.value = u.expansion;
|
|
2082
|
+
this.run(div, this.divergence);
|
|
2083
|
+
const pressure = m.pressure;
|
|
2084
|
+
pressure.uniforms.uDivergence.value = this.divergence.texture;
|
|
2085
|
+
for (let k = 0; k < this.grid.iterations; k++) {
|
|
2086
|
+
pressure.uniforms.uPressure.value = this.pressure.read.texture;
|
|
2087
|
+
this.run(pressure, this.pressure.write);
|
|
2088
|
+
this.pressure.swap();
|
|
2089
|
+
}
|
|
2090
|
+
const gradient = m.gradient;
|
|
2091
|
+
gradient.uniforms.uVelocity.value = this.velocity.read.texture;
|
|
2092
|
+
gradient.uniforms.uPressure.value = this.pressure.read.texture;
|
|
2093
|
+
this.run(gradient, this.velocity.write);
|
|
2094
|
+
this.velocity.swap();
|
|
2095
|
+
});
|
|
2096
|
+
}
|
|
2097
|
+
dispose() {
|
|
2098
|
+
for (const p of [this.scalars, this.air, this.velocity, this.pressure]) p.dispose();
|
|
2099
|
+
this.emit.dispose();
|
|
2100
|
+
this.divergence.dispose();
|
|
2101
|
+
this.curl.dispose();
|
|
2102
|
+
this.forward.dispose();
|
|
2103
|
+
this.backward.dispose();
|
|
2104
|
+
for (const m of Object.values(this.m)) m.dispose();
|
|
2105
|
+
this.mesh.geometry.dispose();
|
|
2106
|
+
}
|
|
2107
|
+
run(material, out) {
|
|
2108
|
+
this.mesh.material = material;
|
|
2109
|
+
this.renderer.setRenderTarget(out);
|
|
2110
|
+
this.renderer.render(this.scene, this.camera);
|
|
2111
|
+
}
|
|
2112
|
+
/** Passes change the renderer's target and clearing; put both back. */
|
|
2113
|
+
withRenderer(body) {
|
|
2114
|
+
const previous = this.renderer.getRenderTarget();
|
|
2115
|
+
const autoClear = this.renderer.autoClear;
|
|
2116
|
+
this.renderer.autoClear = false;
|
|
2117
|
+
try {
|
|
2118
|
+
body();
|
|
2119
|
+
} finally {
|
|
2120
|
+
this.renderer.setRenderTarget(previous);
|
|
2121
|
+
this.renderer.autoClear = autoClear;
|
|
2122
|
+
}
|
|
2123
|
+
}
|
|
2124
|
+
};
|
|
2125
|
+
|
|
2126
|
+
// src/fx/fluid/params.ts
|
|
2127
|
+
var fireFluidDefaults = {
|
|
2128
|
+
fuel: 33.5,
|
|
2129
|
+
premixedOxygen: 0.28,
|
|
2130
|
+
heat: 3.2,
|
|
2131
|
+
smoke: 0.7,
|
|
2132
|
+
// Was 5 — a full unit a second, 210 mm/s, of gas pushed sideways out of the
|
|
2133
|
+
// rim. Against a buoyancy of 1.7 units/s² that is what rolled the flames
|
|
2134
|
+
// into mushroom caps: the grey spirals over the text that the review called
|
|
2135
|
+
// the most synthetic thing in the frame. A flame leaves paper going UP.
|
|
2136
|
+
radialImpulse: 0.8,
|
|
2137
|
+
// In world units a second now (see `solverUniforms`): 1.4 is ~0.3 m/s, the
|
|
2138
|
+
// speed gas leaves burning paper with. It was 0.5 at a quarter scale —
|
|
2139
|
+
// 26 mm/s — and gas held that slow pooled into a bulb at the rim before
|
|
2140
|
+
// buoyancy stretched it into a neck: every flame was a droplet.
|
|
2141
|
+
initialVelocity: [0, 1.4, 0],
|
|
2142
|
+
burnRate: 6.1,
|
|
2143
|
+
// Was 0.65. Expansion is divergence where the gas burns, and it pushes the
|
|
2144
|
+
// gas SIDEWAYS as much as up: at 0.65 each tongue swelled into a puff and
|
|
2145
|
+
// the smoke billowed out over the paper. Your web reference is thin licking
|
|
2146
|
+
// tongues, and swept at 0.2 / 0.65 / 1.3 the low end is the one that looks
|
|
2147
|
+
// like it.
|
|
2148
|
+
gasExpansion: 0.3,
|
|
2149
|
+
// Was 3, when the flame body was authored below paper white and a tongue
|
|
2150
|
+
// needed carrying further to register. Now the gas leaves the rim at its
|
|
2151
|
+
// real speed and glows above paper white, and 3 threw tongues past the
|
|
2152
|
+
// text; 2 holds them lower. (1.5 was no shorter — height is set by how
|
|
2153
|
+
// soon the gas cools, below — only broader and slower.)
|
|
2154
|
+
buoyancy: 2,
|
|
2155
|
+
// Was 0.92. Gas that stays hot all the way up pools into ONE column: the
|
|
2156
|
+
// rim's forty-odd emission points merge a few centimetres above the paper
|
|
2157
|
+
// and the fire reads as a single plume with a couple of licks beside it.
|
|
2158
|
+
// Hero.png is many separate tongues around the whole rim — tall above,
|
|
2159
|
+
// short below — and cooling them faster is what keeps them apart, because
|
|
2160
|
+
// each one runs out of glow before it can merge with its neighbour.
|
|
2161
|
+
//
|
|
2162
|
+
// 1.8 since a flame's light comes from soot that only lives in hot gas:
|
|
2163
|
+
// cooling is now what sets a tongue's HEIGHT. Swept in live play: 1.15
|
|
2164
|
+
// reached the text (~150 mm), 1.6 ~100 mm, 2.1 short licks (~60 mm);
|
|
2165
|
+
// Hero.png's tallest is ~70 mm.
|
|
2166
|
+
cooling: 1.8,
|
|
2167
|
+
// Was 1.4, which made a thick grey column, then 0.5. Noor's direction is
|
|
2168
|
+
// light smoke, and with MacCormack advection keeping the smoke's fine
|
|
2169
|
+
// structure it needs less of it to read: at 0.5 it veiled the upper sheet.
|
|
2170
|
+
smokeProduction: 0.25,
|
|
2171
|
+
ambientOxygen: 0.47,
|
|
2172
|
+
// Was 0.005 — the slider's own floor, which is to say switched off. The
|
|
2173
|
+
// flame channel decayed with a 5 ms time constant, so it was an
|
|
2174
|
+
// instantaneous quantity with no history and therefore no shape.
|
|
2175
|
+
flamePersistence: 0.06,
|
|
2176
|
+
airMixing: 0.5,
|
|
2177
|
+
// Was 0, also switched off, with all the motion coming from vorticity and
|
|
2178
|
+
// the radial impulse — a few big eddies instead of many small ones, which
|
|
2179
|
+
// is the difference between a fire that tears and one that curls. Swept
|
|
2180
|
+
// against the peak frame: at 2 the flames are still a single plume, at 9
|
|
2181
|
+
// they break into separate tongues around the rim.
|
|
2182
|
+
turbulence: 9,
|
|
2183
|
+
// Finer eddies with it: 6.8 was ~24 mm, 12 is ~14 mm, and the smoke stops
|
|
2184
|
+
// reading as a few big hooks.
|
|
2185
|
+
turbulenceScale: 12,
|
|
2186
|
+
// Was 6.1. Vorticity confinement gives back the swirls the grid smooths
|
|
2187
|
+
// away; at 6 it was manufacturing swirls the flow never had.
|
|
2188
|
+
vorticity: 3,
|
|
2189
|
+
wind: 0.8,
|
|
2190
|
+
// Was 4.2 s. Smoke that lingers that long is stretched by the flow into
|
|
2191
|
+
// long grey veins, and sharper advection keeps every one of them — so the
|
|
2192
|
+
// whole upper sheet and the black stage behind it came out MARBLED. Real
|
|
2193
|
+
// paper smoke thins out within a second or two. Swept in live play against
|
|
2194
|
+
// the default: 1.5 s is thin threads and a clean sheet.
|
|
2195
|
+
smokeFade: 1.5,
|
|
2196
|
+
smokeAfter: 4
|
|
2197
|
+
};
|
|
2198
|
+
var fireFluidControls = [
|
|
2199
|
+
{ group: "Emission", key: "fuel", label: "Fuel", min: 0, max: 50, step: 0.1 },
|
|
2200
|
+
{ group: "Emission", key: "premixedOxygen", label: "Premixed oxygen", min: 0, max: 1, step: 0.01 },
|
|
2201
|
+
{ group: "Emission", key: "heat", label: "Heat", min: 0, max: 5, step: 0.1 },
|
|
2202
|
+
{ group: "Emission", key: "smoke", label: "Smoke", min: 0, max: 2, step: 0.05 },
|
|
2203
|
+
{ group: "Emission", key: "radialImpulse", label: "Radial impulse", min: 0, max: 5, step: 0.1 },
|
|
2204
|
+
{ group: "Combustion", key: "burnRate", label: "Burn rate", min: 0, max: 10, step: 0.1 },
|
|
2205
|
+
{ group: "Combustion", key: "gasExpansion", label: "Gas expansion", min: 0, max: 3, step: 0.05 },
|
|
2206
|
+
{ group: "Combustion", key: "buoyancy", label: "Buoyancy", min: 0, max: 6, step: 0.1 },
|
|
2207
|
+
{ group: "Combustion", key: "cooling", label: "Cooling", min: 0, max: 3, step: 0.01 },
|
|
2208
|
+
{ group: "Combustion", key: "smokeProduction", label: "Smoke production", min: 0, max: 4, step: 0.05 },
|
|
2209
|
+
{ group: "Fuel & air", key: "ambientOxygen", label: "Ambient oxygen", min: 0, max: 1, step: 0.01 },
|
|
2210
|
+
{
|
|
2211
|
+
group: "Fuel & air",
|
|
2212
|
+
key: "flamePersistence",
|
|
2213
|
+
label: "Flame persistence",
|
|
2214
|
+
min: 5e-3,
|
|
2215
|
+
max: 1,
|
|
2216
|
+
step: 5e-3
|
|
2217
|
+
},
|
|
2218
|
+
{ group: "Fuel & air", key: "airMixing", label: "Air mixing", min: 0, max: 1, step: 0.01 },
|
|
2219
|
+
// 0–10 while the default was 0. Now that turbulence is what breaks the
|
|
2220
|
+
// flames into tongues, the tuned value has to sit somewhere a slider can
|
|
2221
|
+
// move in BOTH directions — see the test.
|
|
2222
|
+
{ group: "Motion & turbulence", key: "turbulence", label: "Turbulence", min: 0, max: 20, step: 0.1 },
|
|
2223
|
+
{
|
|
2224
|
+
group: "Motion & turbulence",
|
|
2225
|
+
key: "turbulenceScale",
|
|
2226
|
+
label: "Turbulence scale",
|
|
2227
|
+
min: 0.5,
|
|
2228
|
+
max: 24,
|
|
2229
|
+
step: 0.1
|
|
2230
|
+
},
|
|
2231
|
+
{ group: "Motion & turbulence", key: "vorticity", label: "Vorticity", min: 0, max: 8, step: 0.1 },
|
|
2232
|
+
{ group: "Motion & turbulence", key: "wind", label: "Wind", min: -3, max: 3, step: 0.05 },
|
|
2233
|
+
{ group: "Look", key: "smokeFade", label: "Smoke lingers (s)", min: 0.2, max: 10, step: 0.1 },
|
|
2234
|
+
{ group: "Look", key: "smokeAfter", label: "Smoke after the flames (s)", min: 0, max: 12, step: 0.1 }
|
|
2235
|
+
];
|
|
2236
|
+
function solverUniforms(p) {
|
|
2237
|
+
const finite = (x, fallback) => Number.isFinite(x) ? x : fallback;
|
|
2238
|
+
return {
|
|
2239
|
+
// Gas per second per unit of source strength.
|
|
2240
|
+
fuel: Math.max(0, finite(p.fuel, 0)) * 0.12,
|
|
2241
|
+
premixed: Math.min(1, Math.max(0, finite(p.premixedOxygen, 0))),
|
|
2242
|
+
// Temperature released with the gas, per second. At 1.8 the gas at the
|
|
2243
|
+
// source settled near a third of glowing — cooling took the heat faster
|
|
2244
|
+
// than the rim gave it — and the fire was a faint blush, not a flame.
|
|
2245
|
+
heat: Math.max(0, finite(p.heat, 0)) * 7,
|
|
2246
|
+
smoke: Math.max(0, finite(p.smoke, 0)) * 0.45,
|
|
2247
|
+
// World units a second: A4 flames climb a few tens of centimetres a second.
|
|
2248
|
+
radial: Math.max(0, finite(p.radialImpulse, 0)) * 0.2,
|
|
2249
|
+
// World units a second, one to one — a unit is 210 mm.
|
|
2250
|
+
initialVelocity: [finite(p.initialVelocity[0], 0), finite(p.initialVelocity[1], 0)],
|
|
2251
|
+
// Per second.
|
|
2252
|
+
burnRate: Math.max(0, finite(p.burnRate, 0)) * 2,
|
|
2253
|
+
// Temperature a unit of burnt fuel adds.
|
|
2254
|
+
heatRelease: 3,
|
|
2255
|
+
// Divergence per unit of burning a second — the gas expanding as it burns.
|
|
2256
|
+
expansion: Math.max(0, finite(p.gasExpansion, 0)) * 1.5,
|
|
2257
|
+
// World units a second squared per unit of temperature.
|
|
2258
|
+
buoyancy: Math.max(0, finite(p.buoyancy, 0)) * 0.9,
|
|
2259
|
+
// Per second: a flame at A4 scale has cooled out of sight 10–40 mm up.
|
|
2260
|
+
cooling: Math.max(0, finite(p.cooling, 0)) * 6,
|
|
2261
|
+
smokeProduction: Math.max(0, finite(p.smokeProduction, 0)) * 0.3,
|
|
2262
|
+
smokeFade: Math.max(0.05, finite(p.smokeFade, 2.5)),
|
|
2263
|
+
ambient: Math.min(1, Math.max(0, finite(p.ambientOxygen, 0))),
|
|
2264
|
+
persistence: Math.max(5e-3, finite(p.flamePersistence, 0.085)),
|
|
2265
|
+
mixing: Math.min(1, Math.max(0, finite(p.airMixing, 0.5))) * 0.5,
|
|
2266
|
+
entrain: Math.min(1, Math.max(0, finite(p.airMixing, 0.5))) * 40,
|
|
2267
|
+
fuelBlock: 0.05,
|
|
2268
|
+
sootYield: 30,
|
|
2269
|
+
sootHeat: 2,
|
|
2270
|
+
// Paper's gas takes several times its own mass of air to burn; what
|
|
2271
|
+
// matters here is only that it is well above one, so fuel near the rim is
|
|
2272
|
+
// denser than the air that can reach it and burns from the outside in.
|
|
2273
|
+
stoich: 4,
|
|
2274
|
+
// Several times the rim's own smoke per unit strength: a smouldering spot
|
|
2275
|
+
// has no flame to lift its smoke fast, so it pools and rises slowly, and
|
|
2276
|
+
// at 1.5× it read 0.03 at the 99th percentile — about 3% opacity, gone —
|
|
2277
|
+
// at 8× 0.07, and at 30× thin wisps you had to look for.
|
|
2278
|
+
smoulderSmoke: Math.max(0, finite(p.smoke, 0)) * 0.45 * 80,
|
|
2279
|
+
smoulderHeat: 1.2,
|
|
2280
|
+
turbulence: Math.max(0, finite(p.turbulence, 0)) * 0.35,
|
|
2281
|
+
// Noise frequency per WORLD unit — the pass multiplies by the domain's
|
|
2282
|
+
// size, which it did not before (it read UV, and the domain is two units
|
|
2283
|
+
// tall, so every eddy was half the size this said). 12 → ~14 mm eddies.
|
|
2284
|
+
turbulenceScale: Math.max(0.1, finite(p.turbulenceScale, 4.6)) * 1.25,
|
|
2285
|
+
turbulenceEvolve: 3,
|
|
2286
|
+
vorticity: Math.max(0, finite(p.vorticity, 0)),
|
|
2287
|
+
wind: finite(p.wind, 0) * 0.3
|
|
2288
|
+
};
|
|
2289
|
+
}
|
|
2290
|
+
|
|
2291
|
+
// src/fx/FxFireFluid.tsx
|
|
2292
|
+
import { Fragment as Fragment2, jsx as jsx5 } from "react/jsx-runtime";
|
|
2293
|
+
var DOMAIN = { width: 1.5, height: 2 };
|
|
2294
|
+
var BELOW = 0.8;
|
|
2295
|
+
var STEP = 1 / 120;
|
|
2296
|
+
var WARM_STEP = 1 / 60;
|
|
2297
|
+
var MAX_STEPS = 6;
|
|
2298
|
+
var BAND = 1.2 / 210;
|
|
2299
|
+
var SMOULDER_SLOTS = 16;
|
|
2300
|
+
var SMOULDER_AFTER = 1;
|
|
2301
|
+
var WARM = 3;
|
|
2302
|
+
var up = new THREE5.Vector3(0, 1, 0);
|
|
2303
|
+
function FxFireFluid({
|
|
2304
|
+
field,
|
|
2305
|
+
locate,
|
|
2306
|
+
quality = "medium",
|
|
2307
|
+
params,
|
|
2308
|
+
running = true,
|
|
2309
|
+
resetKey,
|
|
2310
|
+
glow = 1,
|
|
2311
|
+
zones,
|
|
2312
|
+
heatScale = FIRE_HEAT_SCALE,
|
|
2313
|
+
sootScale = FIRE_SOOT_SCALE,
|
|
2314
|
+
contrast = FIRE_CONTRAST,
|
|
2315
|
+
opacity = FIRE_OPACITY,
|
|
2316
|
+
thin = FIRE_THIN,
|
|
2317
|
+
warm = WARM,
|
|
2318
|
+
sharp,
|
|
2319
|
+
fallback
|
|
2320
|
+
}) {
|
|
2321
|
+
const gl = useThree2((s) => s.gl);
|
|
2322
|
+
const camera = useThree2((s) => s.camera);
|
|
2323
|
+
const grid = fxQualityFor(quality).fluid;
|
|
2324
|
+
const fluid = useMemo4(() => FireFluid.supported(gl) ? new FireFluid(gl, grid, DOMAIN) : null, [gl, grid]);
|
|
2325
|
+
useEffect4(() => () => fluid?.dispose(), [fluid]);
|
|
2326
|
+
const merged = useMemo4(() => ({ ...fireFluidDefaults, ...params }), [params]);
|
|
2327
|
+
const material = useMemo4(
|
|
2328
|
+
() => new THREE5.ShaderMaterial({
|
|
2329
|
+
vertexShader: RENDER_VERTEX,
|
|
2330
|
+
fragmentShader: RENDER_FRAGMENT,
|
|
2331
|
+
uniforms: {
|
|
2332
|
+
uA: { value: null },
|
|
2333
|
+
uB: { value: null },
|
|
2334
|
+
uTime: { value: 0 },
|
|
2335
|
+
uGlow: { value: 1 },
|
|
2336
|
+
// Thin: smoke over a clear background, not a veil across it.
|
|
2337
|
+
uSmokeDensity: { value: 0.9 },
|
|
2338
|
+
// How bright the fire is, in the one unit `emission.ts` defines.
|
|
2339
|
+
uPaperWhite: { value: PAPER_WHITE },
|
|
2340
|
+
uHeatScale: { value: FIRE_HEAT_SCALE },
|
|
2341
|
+
uSootScale: { value: FIRE_SOOT_SCALE },
|
|
2342
|
+
uContrast: { value: FIRE_CONTRAST },
|
|
2343
|
+
uOpacity: { value: FIRE_OPACITY },
|
|
2344
|
+
uThin: { value: FIRE_THIN },
|
|
2345
|
+
// The four zones (FireZones). Colours linear, glows in multiples of
|
|
2346
|
+
// paper white, boundaries in fractions of the hottest gas.
|
|
2347
|
+
uTipColor: { value: new THREE5.Vector3() },
|
|
2348
|
+
uBodyColor: { value: new THREE5.Vector3() },
|
|
2349
|
+
uCoreColor: { value: new THREE5.Vector3() },
|
|
2350
|
+
uRootColor: { value: new THREE5.Vector3() },
|
|
2351
|
+
uTipGlow: { value: 0 },
|
|
2352
|
+
uBodyGlow: { value: 0 },
|
|
2353
|
+
uCoreGlow: { value: 0 },
|
|
2354
|
+
uTipFrom: { value: 0 },
|
|
2355
|
+
uTipTo: { value: 0 },
|
|
2356
|
+
uCoreFrom: { value: 0 },
|
|
2357
|
+
uSoftness: { value: 0 },
|
|
2358
|
+
uTearing: { value: 0 },
|
|
2359
|
+
uRootAmount: { value: 0 },
|
|
2360
|
+
uRootReach: { value: 0 }
|
|
2361
|
+
},
|
|
2362
|
+
transparent: true,
|
|
2363
|
+
depthWrite: false,
|
|
2364
|
+
// The fire is always in front of the paper it rises from. A sheet that
|
|
2365
|
+
// drapes or curls crosses the plane in places, and a depth test cut
|
|
2366
|
+
// the fire off there in a hard vertical seam.
|
|
2367
|
+
depthTest: false,
|
|
2368
|
+
// Premultiplied: fire adds light, smoke covers what is behind it.
|
|
2369
|
+
blending: THREE5.CustomBlending,
|
|
2370
|
+
blendSrc: THREE5.OneFactor,
|
|
2371
|
+
blendDst: THREE5.OneMinusSrcAlphaFactor,
|
|
2372
|
+
side: THREE5.DoubleSide
|
|
2373
|
+
}),
|
|
2374
|
+
[]
|
|
2375
|
+
);
|
|
2376
|
+
useEffect4(() => () => material.dispose(), [material]);
|
|
2377
|
+
const plane = useMemo4(() => new THREE5.PlaneGeometry(DOMAIN.width, DOMAIN.height), []);
|
|
2378
|
+
useEffect4(() => () => plane.dispose(), [plane]);
|
|
2379
|
+
const mesh = useRef3(null);
|
|
2380
|
+
const state = useRef3({
|
|
2381
|
+
key: /* @__PURE__ */ Symbol("unset"),
|
|
2382
|
+
time: 0,
|
|
2383
|
+
owed: 0,
|
|
2384
|
+
/** The fire's clock when the rim last released anything — flame or smoulder. */
|
|
2385
|
+
lastGas: Number.NEGATIVE_INFINITY,
|
|
2386
|
+
origin: new THREE5.Vector3(),
|
|
2387
|
+
right: new THREE5.Vector3(1, 0, 0),
|
|
2388
|
+
normal: new THREE5.Vector3(0, 0, 1)
|
|
2389
|
+
});
|
|
2390
|
+
const anchors = useRef3([]);
|
|
2391
|
+
const sources = useMemo4(() => new Float32Array(MAX_SOURCES * 4), []);
|
|
2392
|
+
const across = useMemo4(() => new Float32Array(MAX_SOURCES * 4), []);
|
|
2393
|
+
const smoulder = useMemo4(
|
|
2394
|
+
() => Array.from({ length: SMOULDER_SLOTS }, () => ({
|
|
2395
|
+
seen: Number.NEGATIVE_INFINITY,
|
|
2396
|
+
source: new Float32Array(4),
|
|
2397
|
+
across: new Float32Array(4)
|
|
2398
|
+
})),
|
|
2399
|
+
[]
|
|
2400
|
+
);
|
|
2401
|
+
const scratch = useMemo4(() => new THREE5.Vector3(), []);
|
|
2402
|
+
const place = () => {
|
|
2403
|
+
const s = state.current;
|
|
2404
|
+
const centre = locate(0.5, 0.5);
|
|
2405
|
+
const c = new THREE5.Vector3(centre?.x ?? 0, centre?.y ?? 0, centre?.z ?? 0);
|
|
2406
|
+
s.normal.set(camera.position.x - c.x, 0, camera.position.z - c.z);
|
|
2407
|
+
if (s.normal.lengthSq() < 1e-8) s.normal.set(0, 0, 1);
|
|
2408
|
+
s.normal.normalize();
|
|
2409
|
+
s.right.crossVectors(up, s.normal).normalize();
|
|
2410
|
+
s.origin.copy(c).addScaledVector(s.right, -DOMAIN.width / 2).addScaledVector(up, -BELOW);
|
|
2411
|
+
const m = mesh.current;
|
|
2412
|
+
if (m) {
|
|
2413
|
+
m.position.copy(s.origin).addScaledVector(s.right, DOMAIN.width / 2).addScaledVector(up, DOMAIN.height / 2);
|
|
2414
|
+
m.position.addScaledVector(s.normal, 0.03);
|
|
2415
|
+
m.quaternion.setFromRotationMatrix(new THREE5.Matrix4().makeBasis(s.right, up, s.normal));
|
|
2416
|
+
}
|
|
2417
|
+
};
|
|
2418
|
+
const gather = (time) => {
|
|
2419
|
+
const s = state.current;
|
|
2420
|
+
const room = MAX_SOURCES - SMOULDER_SLOTS;
|
|
2421
|
+
const n = Math.min(room, flameAnchors(field, locate, room, anchors.current, time));
|
|
2422
|
+
const band = BAND / DOMAIN.height;
|
|
2423
|
+
for (let i = 0; i < n; i++) {
|
|
2424
|
+
const a = anchors.current[i];
|
|
2425
|
+
scratch.set(a.x, a.y, a.z).sub(s.origin);
|
|
2426
|
+
const k = i * 4;
|
|
2427
|
+
sources[k] = scratch.dot(s.right) / DOMAIN.width;
|
|
2428
|
+
sources[k + 1] = scratch.dot(up) / DOMAIN.height;
|
|
2429
|
+
const nu = a.nx * s.right.x + a.ny * s.right.y + a.nz * s.right.z;
|
|
2430
|
+
const nv = a.ny;
|
|
2431
|
+
const nl = Math.hypot(nu, nv);
|
|
2432
|
+
let area;
|
|
2433
|
+
if (nl > 0.25) {
|
|
2434
|
+
const half = Math.max(1.5 / 210, a.width * 0.5) / DOMAIN.height;
|
|
2435
|
+
sources[k + 2] = half;
|
|
2436
|
+
across[k] = nu / nl;
|
|
2437
|
+
across[k + 1] = nv / nl;
|
|
2438
|
+
across[k + 2] = band;
|
|
2439
|
+
across[k + 3] = band;
|
|
2440
|
+
area = half * band;
|
|
2441
|
+
} else {
|
|
2442
|
+
sources[k + 2] = band * 2;
|
|
2443
|
+
across[k] = 0;
|
|
2444
|
+
across[k + 1] = 1;
|
|
2445
|
+
across[k + 2] = band * 2;
|
|
2446
|
+
across[k + 3] = 0;
|
|
2447
|
+
area = band * band * 4;
|
|
2448
|
+
}
|
|
2449
|
+
const disc = Math.max(25e-4, a.width * 0.6 / DOMAIN.height);
|
|
2450
|
+
const tallest = FLAME_HEIGHT[1] * 0.47 * 0.6 / DOMAIN.height;
|
|
2451
|
+
sources[k + 3] = a.height / FLAME_HEIGHT[1] * (0.5 + 0.5 * a.heat) * Math.min(30, disc * tallest / area);
|
|
2452
|
+
const slot = smoulder[Math.floor(a.seed * SMOULDER_SLOTS) % SMOULDER_SLOTS];
|
|
2453
|
+
slot.seen = time;
|
|
2454
|
+
slot.source.set(sources.subarray(k, k + 4));
|
|
2455
|
+
slot.across.set(across.subarray(k, k + 4));
|
|
2456
|
+
}
|
|
2457
|
+
const after = Math.max(0, merged.smokeAfter ?? 0);
|
|
2458
|
+
let count = n;
|
|
2459
|
+
for (const slot of smoulder) {
|
|
2460
|
+
const since = time - slot.seen;
|
|
2461
|
+
if (!(since > SMOULDER_AFTER) || after <= 0 || since > after * 3) continue;
|
|
2462
|
+
const k = count * 4;
|
|
2463
|
+
sources.set(slot.source, k);
|
|
2464
|
+
across.set(slot.across, k);
|
|
2465
|
+
const onset = Math.min(1, (since - SMOULDER_AFTER) / 0.6);
|
|
2466
|
+
sources[k + 3] = -slot.source[3] * onset * Math.exp(-since / after);
|
|
2467
|
+
count++;
|
|
2468
|
+
}
|
|
2469
|
+
return count;
|
|
2470
|
+
};
|
|
2471
|
+
useFrame4((_, delta) => {
|
|
2472
|
+
if (!fluid) return;
|
|
2473
|
+
const s = state.current;
|
|
2474
|
+
const u = solverUniforms(merged);
|
|
2475
|
+
fluid.sharp = sharp ?? quality !== "low";
|
|
2476
|
+
if (s.key !== resetKey) {
|
|
2477
|
+
s.key = resetKey;
|
|
2478
|
+
place();
|
|
2479
|
+
fluid.reset(u.ambient);
|
|
2480
|
+
for (const slot of smoulder) slot.seen = Number.NEGATIVE_INFINITY;
|
|
2481
|
+
const end = field.time;
|
|
2482
|
+
if (field.frontCount > 0) {
|
|
2483
|
+
const steps = Math.round(warm / WARM_STEP);
|
|
2484
|
+
for (let k = 0; k < steps; k++) {
|
|
2485
|
+
const t = end - warm + k * WARM_STEP;
|
|
2486
|
+
fluid.step(WARM_STEP, u, sources, across, gather(t), t);
|
|
2487
|
+
}
|
|
2488
|
+
s.lastGas = end;
|
|
2489
|
+
} else {
|
|
2490
|
+
s.lastGas = Number.NEGATIVE_INFINITY;
|
|
2491
|
+
}
|
|
2492
|
+
s.time = end;
|
|
2493
|
+
s.owed = 0;
|
|
2494
|
+
}
|
|
2495
|
+
const idle = field.frontCount === 0 && s.time - s.lastGas > Math.max(2, merged.smokeFade * 5);
|
|
2496
|
+
const m = mesh.current;
|
|
2497
|
+
if (m) m.visible = !idle;
|
|
2498
|
+
if (running && idle) {
|
|
2499
|
+
s.owed = 0;
|
|
2500
|
+
s.time += Math.min(0.1, Math.max(0, delta));
|
|
2501
|
+
} else if (running) {
|
|
2502
|
+
s.owed += Math.min(0.1, Math.max(0, delta));
|
|
2503
|
+
let taken = 0;
|
|
2504
|
+
while (s.owed >= STEP && taken < MAX_STEPS) {
|
|
2505
|
+
s.owed -= STEP;
|
|
2506
|
+
s.time += STEP;
|
|
2507
|
+
const count = gather(field.time);
|
|
2508
|
+
fluid.step(STEP, u, sources, across, count, s.time);
|
|
2509
|
+
if (count > 0) s.lastGas = s.time;
|
|
2510
|
+
taken++;
|
|
2511
|
+
}
|
|
2512
|
+
}
|
|
2513
|
+
material.uniforms.uA.value = fluid.scalars.read.texture;
|
|
2514
|
+
material.uniforms.uB.value = fluid.air.read.texture;
|
|
2515
|
+
material.uniforms.uTime.value = s.time;
|
|
2516
|
+
material.uniforms.uGlow.value = glow;
|
|
2517
|
+
material.uniforms.uHeatScale.value = heatScale;
|
|
2518
|
+
material.uniforms.uSootScale.value = sootScale;
|
|
2519
|
+
material.uniforms.uContrast.value = contrast;
|
|
2520
|
+
material.uniforms.uOpacity.value = opacity;
|
|
2521
|
+
material.uniforms.uThin.value = thin;
|
|
2522
|
+
const z = fireZones(zones);
|
|
2523
|
+
const mu = material.uniforms;
|
|
2524
|
+
mu.uTipColor.value.fromArray(hexToLinear(z.tip.color));
|
|
2525
|
+
mu.uBodyColor.value.fromArray(hexToLinear(z.body.color));
|
|
2526
|
+
mu.uCoreColor.value.fromArray(hexToLinear(z.core.color));
|
|
2527
|
+
mu.uRootColor.value.fromArray(hexToLinear(z.root.color));
|
|
2528
|
+
mu.uTipGlow.value = z.tip.glow;
|
|
2529
|
+
mu.uBodyGlow.value = z.body.glow;
|
|
2530
|
+
mu.uCoreGlow.value = z.core.glow;
|
|
2531
|
+
mu.uTipFrom.value = z.tip.from;
|
|
2532
|
+
mu.uTipTo.value = z.tip.to;
|
|
2533
|
+
mu.uCoreFrom.value = z.core.from;
|
|
2534
|
+
mu.uSoftness.value = z.tip.softness;
|
|
2535
|
+
mu.uTearing.value = z.tip.tearing;
|
|
2536
|
+
mu.uRootAmount.value = z.root.amount;
|
|
2537
|
+
mu.uRootReach.value = z.root.reach;
|
|
2538
|
+
});
|
|
2539
|
+
if (!fluid) return /* @__PURE__ */ jsx5(Fragment2, { children: fallback ?? null });
|
|
2540
|
+
return /* @__PURE__ */ jsx5("mesh", { ref: mesh, geometry: plane, material, frustumCulled: false, renderOrder: 2 });
|
|
2541
|
+
}
|
|
2542
|
+
|
|
2543
|
+
// src/fx/FxFireLight.tsx
|
|
2544
|
+
import * as THREE6 from "three";
|
|
2545
|
+
import { useFrame as useFrame5 } from "@react-three/fiber";
|
|
2546
|
+
import { useRef as useRef4 } from "react";
|
|
2547
|
+
import { Fragment as Fragment3, jsx as jsx6, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
2548
|
+
function FxFireLight({ field, locate, gain = FIRE_LIGHT_GAIN }) {
|
|
2549
|
+
const front = useRef4(null);
|
|
2550
|
+
const through = useRef4(null);
|
|
2551
|
+
const anchors = useRef4([]);
|
|
2552
|
+
useFrame5(({ camera }) => {
|
|
2553
|
+
const a = front.current;
|
|
2554
|
+
const b = through.current;
|
|
2555
|
+
if (!a || !b) return;
|
|
2556
|
+
const n = flameAnchors(field, locate, 32, anchors.current);
|
|
2557
|
+
if (n === 0) {
|
|
2558
|
+
a.intensity = 0;
|
|
2559
|
+
b.intensity = 0;
|
|
2560
|
+
return;
|
|
2561
|
+
}
|
|
2562
|
+
let x = 0;
|
|
2563
|
+
let y = 0;
|
|
2564
|
+
let z = 0;
|
|
2565
|
+
let h = 0;
|
|
2566
|
+
let flicker = 0;
|
|
2567
|
+
for (let i = 0; i < n; i++) {
|
|
2568
|
+
const f = anchors.current[i];
|
|
2569
|
+
x += f.x;
|
|
2570
|
+
y += f.y;
|
|
2571
|
+
z += f.z;
|
|
2572
|
+
h += f.height;
|
|
2573
|
+
flicker += flamePuff(f.seed, field.time);
|
|
2574
|
+
}
|
|
2575
|
+
x /= n;
|
|
2576
|
+
y /= n;
|
|
2577
|
+
z /= n;
|
|
2578
|
+
h /= n;
|
|
2579
|
+
flicker /= n;
|
|
2580
|
+
const toCamera = new THREE6.Vector3(camera.position.x - x, 0, camera.position.z - z);
|
|
2581
|
+
toCamera.normalize().multiplyScalar(0.035);
|
|
2582
|
+
const lift = h * 0.5;
|
|
2583
|
+
a.position.set(x + toCamera.x, y + lift, z + toCamera.z);
|
|
2584
|
+
b.position.set(x - toCamera.x, y + lift, z - toCamera.z);
|
|
2585
|
+
const level = field.lastStats.front * gain * flicker;
|
|
2586
|
+
a.intensity = level;
|
|
2587
|
+
b.intensity = level * 0.35;
|
|
2588
|
+
});
|
|
2589
|
+
return /* @__PURE__ */ jsxs2(Fragment3, { children: [
|
|
2590
|
+
/* @__PURE__ */ jsx6("pointLight", { ref: front, color: FIRE_COLOR, intensity: 0, decay: 0.9, distance: 0 }),
|
|
2591
|
+
/* @__PURE__ */ jsx6("pointLight", { ref: through, color: FIRE_COLOR, intensity: 0, decay: 0.9, distance: 0 })
|
|
2592
|
+
] });
|
|
2593
|
+
}
|
|
2594
|
+
var FIRE_COLOR = new THREE6.Color("#ffa24c");
|
|
2595
|
+
var FIRE_LIGHT_GAIN = 42;
|
|
2596
|
+
|
|
2597
|
+
// src/fx/FxMatchFlame.tsx
|
|
2598
|
+
import * as THREE7 from "three";
|
|
2599
|
+
import { useFrame as useFrame6 } from "@react-three/fiber";
|
|
2600
|
+
import { useEffect as useEffect5, useMemo as useMemo5, useRef as useRef5 } from "react";
|
|
2601
|
+
import { Fragment as Fragment4, jsx as jsx7, jsxs as jsxs3 } from "react/jsx-runtime";
|
|
2602
|
+
var HEIGHT = 20 / 210;
|
|
2603
|
+
var FLARE = 0.42;
|
|
2604
|
+
var FLARE_GAIN = 1;
|
|
2605
|
+
var ARM = 0.32;
|
|
2606
|
+
function FxMatchFlame({ match }) {
|
|
2607
|
+
const geometry = useMemo5(() => flameGeometry(1), []);
|
|
2608
|
+
const material = useMemo5(() => flameMaterial(), []);
|
|
2609
|
+
const pool = useMemo5(() => new ParticlePool(160, 3), []);
|
|
2610
|
+
useEffect5(
|
|
2611
|
+
() => () => {
|
|
2612
|
+
geometry.dispose();
|
|
2613
|
+
material.dispose();
|
|
2614
|
+
},
|
|
2615
|
+
[geometry, material]
|
|
2616
|
+
);
|
|
2617
|
+
const light = useRef5(null);
|
|
2618
|
+
const memory = useRef5({
|
|
2619
|
+
time: 0,
|
|
2620
|
+
last: "none",
|
|
2621
|
+
armedAt: 0,
|
|
2622
|
+
flare: 0,
|
|
2623
|
+
flash: 0,
|
|
2624
|
+
spark: 0,
|
|
2625
|
+
was: new THREE7.Vector3(),
|
|
2626
|
+
velocity: new THREE7.Vector3(),
|
|
2627
|
+
lean: new THREE7.Vector3(),
|
|
2628
|
+
at: new THREE7.Vector3(),
|
|
2629
|
+
hasAt: false
|
|
2630
|
+
});
|
|
2631
|
+
useFrame6((_, delta) => {
|
|
2632
|
+
const now = match.current;
|
|
2633
|
+
const m = memory.current;
|
|
2634
|
+
const scripted = now.time !== void 0;
|
|
2635
|
+
const dt = scripted ? Math.max(0, now.time - m.time) : Math.min(0.1, Math.max(0, delta));
|
|
2636
|
+
m.time = scripted ? now.time : m.time + dt;
|
|
2637
|
+
if (now.position) {
|
|
2638
|
+
m.at.set(now.position.x, now.position.y, now.position.z);
|
|
2639
|
+
if (!m.hasAt) m.was.copy(m.at);
|
|
2640
|
+
m.hasAt = true;
|
|
2641
|
+
}
|
|
2642
|
+
if (!scripted && now.state === "lit" && m.last !== "lit") {
|
|
2643
|
+
m.flare = FLARE;
|
|
2644
|
+
m.flash = 0.06;
|
|
2645
|
+
for (let i = 0; i < 16; i++) pool.spawn("ember", m.at.x, m.at.y + HEIGHT * 0.2, m.at.z);
|
|
2646
|
+
for (let i = 0; i < 3; i++) pool.spawn("smoke", m.at.x, m.at.y + HEIGHT * 0.5, m.at.z);
|
|
2647
|
+
}
|
|
2648
|
+
if (!scripted && now.state !== "lit" && m.last === "lit" && m.hasAt) {
|
|
2649
|
+
for (let i = 0; i < 5; i++) pool.spawn("smoke", m.at.x, m.at.y + HEIGHT * 0.6, m.at.z);
|
|
2650
|
+
}
|
|
2651
|
+
if (now.state === "arming" && m.last !== "arming") m.armedAt = m.time;
|
|
2652
|
+
m.last = now.state;
|
|
2653
|
+
if (dt > 0 && m.hasAt) {
|
|
2654
|
+
const v = m.at.clone().sub(m.was).divideScalar(dt);
|
|
2655
|
+
m.velocity.lerp(v, Math.min(1, dt / 0.08));
|
|
2656
|
+
m.was.copy(m.at);
|
|
2657
|
+
}
|
|
2658
|
+
const speed = m.velocity.length();
|
|
2659
|
+
const target2 = m.velocity.clone().multiplyScalar(-0.35);
|
|
2660
|
+
target2.z -= now.blow * 1.2;
|
|
2661
|
+
m.lean.lerp(target2, Math.min(1, dt / 0.15));
|
|
2662
|
+
m.flare = scripted ? Math.max(0, FLARE - (m.time - (now.litAt ?? -FLARE))) : Math.max(0, m.flare - dt);
|
|
2663
|
+
m.flash = scripted ? 0 : Math.max(0, m.flash - dt);
|
|
2664
|
+
const base = geometry.getAttribute("aBase");
|
|
2665
|
+
const shape = geometry.getAttribute("aShape");
|
|
2666
|
+
let height = 0;
|
|
2667
|
+
let width = 0;
|
|
2668
|
+
let gain = 1;
|
|
2669
|
+
let lightLevel = 0;
|
|
2670
|
+
if (now.state === "arming" && m.hasAt) {
|
|
2671
|
+
const p = Math.min(1, (m.time - m.armedAt) / ARM);
|
|
2672
|
+
height = HEIGHT * 0.18 * p;
|
|
2673
|
+
width = height * 0.5;
|
|
2674
|
+
gain = 0.5 + p;
|
|
2675
|
+
lightLevel = 0.012 * p;
|
|
2676
|
+
m.spark -= dt;
|
|
2677
|
+
if (!scripted && m.spark <= 0) {
|
|
2678
|
+
m.spark = 0.07;
|
|
2679
|
+
pool.spawn("ember", m.at.x, m.at.y, m.at.z);
|
|
2680
|
+
}
|
|
2681
|
+
} else if (now.state === "lit" && m.hasAt) {
|
|
2682
|
+
const flare = 1 + FLARE_GAIN * (m.flare / FLARE) ** 2;
|
|
2683
|
+
const stretch = 1 + Math.min(1.2, speed * 0.9);
|
|
2684
|
+
height = HEIGHT * flare * stretch * (now.touching ? 0.6 : 1);
|
|
2685
|
+
width = HEIGHT * 0.32 * (now.touching ? 1.6 : 1) / Math.sqrt(stretch);
|
|
2686
|
+
gain = 1 - now.blow * 0.45 * (0.5 + 0.5 * Math.sin(m.time * 61));
|
|
2687
|
+
lightLevel = 0.035 * flare + (m.flash > 0 ? 0.25 : 0);
|
|
2688
|
+
}
|
|
2689
|
+
material.uniforms.uBlue.value = Math.min(1, Math.max(0, speed * 0.7 - 0.35));
|
|
2690
|
+
material.uniforms.uGain.value = gain;
|
|
2691
|
+
material.uniforms.uTime.value = m.time;
|
|
2692
|
+
material.uniforms.uLean.value.copy(m.lean);
|
|
2693
|
+
base.array[0] = m.at.x;
|
|
2694
|
+
base.array[1] = m.at.y;
|
|
2695
|
+
base.array[2] = m.at.z;
|
|
2696
|
+
shape.array[0] = height;
|
|
2697
|
+
shape.array[1] = width;
|
|
2698
|
+
shape.array[2] = 0.37;
|
|
2699
|
+
shape.array[3] = 1;
|
|
2700
|
+
base.needsUpdate = true;
|
|
2701
|
+
shape.needsUpdate = true;
|
|
2702
|
+
geometry.instanceCount = height > 0 ? 1 : 0;
|
|
2703
|
+
if (light.current) {
|
|
2704
|
+
light.current.position.set(m.at.x, m.at.y + height * 0.5, m.at.z + 0.06);
|
|
2705
|
+
light.current.intensity = lightLevel;
|
|
2706
|
+
}
|
|
2707
|
+
pool.step(dt);
|
|
2708
|
+
});
|
|
2709
|
+
return /* @__PURE__ */ jsxs3(Fragment4, { children: [
|
|
2710
|
+
/* @__PURE__ */ jsx7("mesh", { geometry, material, frustumCulled: false, renderOrder: 3 }),
|
|
2711
|
+
/* @__PURE__ */ jsx7("pointLight", { ref: light, color: "#ffb066", intensity: 0, decay: 1.6, distance: 0 }),
|
|
2712
|
+
/* @__PURE__ */ jsx7(FxParticles, { pool })
|
|
2713
|
+
] });
|
|
2714
|
+
}
|
|
2715
|
+
|
|
2716
|
+
// src/fx/sfx/graph.ts
|
|
2717
|
+
var STEAL_FADE = 0.012;
|
|
2718
|
+
var FxAudio = class {
|
|
2719
|
+
quality;
|
|
2720
|
+
ctx;
|
|
2721
|
+
master;
|
|
2722
|
+
live = [];
|
|
2723
|
+
/**
|
|
2724
|
+
* What each voice owns, until the last of it has actually stopped.
|
|
2725
|
+
*
|
|
2726
|
+
* Outlives the voice's place in `live` on purpose: a stolen voice leaves the
|
|
2727
|
+
* pool at once, so the new sound can have its slot, but its sources are
|
|
2728
|
+
* still fading for a few milliseconds and its gain is still connected. The
|
|
2729
|
+
* nodes are freed when the last source reports `ended`, not before — cut
|
|
2730
|
+
* sooner and the fade that stops stealing from clicking is cut with it.
|
|
2731
|
+
*/
|
|
2732
|
+
slots = /* @__PURE__ */ new Map();
|
|
2733
|
+
nextId = 1;
|
|
2734
|
+
unlocked = false;
|
|
2735
|
+
noise = null;
|
|
2736
|
+
constructor(options) {
|
|
2737
|
+
const { quality = "auto", context, volume = 0.8 } = options;
|
|
2738
|
+
this.quality = typeof quality === "string" ? fxQualityFor(quality) : quality;
|
|
2739
|
+
if (!context) throw new Error("FxAudio needs a context \u2014 see createAudioContext()");
|
|
2740
|
+
this.ctx = context;
|
|
2741
|
+
this.master = this.ctx.createGain();
|
|
2742
|
+
this.master.gain.value = volume;
|
|
2743
|
+
this.master.connect(this.ctx.destination);
|
|
2744
|
+
}
|
|
2745
|
+
get context() {
|
|
2746
|
+
return this.ctx;
|
|
2747
|
+
}
|
|
2748
|
+
/** Live voices right now. Never more than the tier's ceiling. */
|
|
2749
|
+
get voices() {
|
|
2750
|
+
return this.live;
|
|
2751
|
+
}
|
|
2752
|
+
get isUnlocked() {
|
|
2753
|
+
return this.unlocked;
|
|
2754
|
+
}
|
|
2755
|
+
get volume() {
|
|
2756
|
+
return this.master.gain.value;
|
|
2757
|
+
}
|
|
2758
|
+
set volume(value) {
|
|
2759
|
+
this.master.gain.value = Math.min(1, Math.max(0, value));
|
|
2760
|
+
}
|
|
2761
|
+
/**
|
|
2762
|
+
* Let sound happen, from inside a user gesture.
|
|
2763
|
+
*
|
|
2764
|
+
* Every browser starts an `AudioContext` suspended until a real interaction
|
|
2765
|
+
* resumes it, and a page that calls this from anywhere else is a page whose
|
|
2766
|
+
* audio silently never starts. Here the cost is zero: `/hands` already has
|
|
2767
|
+
* a button that turns the camera on, and nothing can be heard before the
|
|
2768
|
+
* camera is on anyway.
|
|
2769
|
+
*
|
|
2770
|
+
* Safe to call more than once — it is a latch, not a toggle.
|
|
2771
|
+
*/
|
|
2772
|
+
async unlock() {
|
|
2773
|
+
if (this.unlocked) return;
|
|
2774
|
+
if (this.ctx.state === "suspended") await this.ctx.resume();
|
|
2775
|
+
this.unlocked = this.ctx.state === "running";
|
|
2776
|
+
}
|
|
2777
|
+
/**
|
|
2778
|
+
* One second of white noise, made once and shared.
|
|
2779
|
+
*
|
|
2780
|
+
* Nearly every paper sound is filtered noise — a crumple is a cloud of
|
|
2781
|
+
* short bursts of it, a fire is a bed of it, a tear is a fast train of it —
|
|
2782
|
+
* so this is the single most reused object in the graph. Generating it per
|
|
2783
|
+
* voice would allocate a second of audio per crackle.
|
|
2784
|
+
*/
|
|
2785
|
+
noiseBuffer() {
|
|
2786
|
+
if (this.noise) return this.noise;
|
|
2787
|
+
const length = Math.floor(this.ctx.sampleRate);
|
|
2788
|
+
const buffer = this.ctx.createBuffer(1, length, this.ctx.sampleRate);
|
|
2789
|
+
const data = buffer.getChannelData(0);
|
|
2790
|
+
let seed = 2654435769;
|
|
2791
|
+
for (let i = 0; i < length; i++) {
|
|
2792
|
+
seed = Math.imul(seed, 1664525) + 1013904223 | 0;
|
|
2793
|
+
data[i] = (seed >>> 0) / 2147483648 - 1;
|
|
2794
|
+
}
|
|
2795
|
+
this.noise = buffer;
|
|
2796
|
+
return buffer;
|
|
2797
|
+
}
|
|
2798
|
+
/**
|
|
2799
|
+
* Take a voice, stealing the least deserving one if the pool is full.
|
|
2800
|
+
*
|
|
2801
|
+
* Returns null only when the ceiling is zero or the caller's priority is
|
|
2802
|
+
* below everything already playing — the one case where refusing is right,
|
|
2803
|
+
* because the alternative is cutting off something more important to play
|
|
2804
|
+
* something less.
|
|
2805
|
+
*/
|
|
2806
|
+
take(kind, priority = 0.5) {
|
|
2807
|
+
if (this.quality.voices <= 0) return null;
|
|
2808
|
+
if (this.live.length >= this.quality.voices) {
|
|
2809
|
+
let weakest = 0;
|
|
2810
|
+
for (let i = 1; i < this.live.length; i++) {
|
|
2811
|
+
const a = this.live[i];
|
|
2812
|
+
const b = this.live[weakest];
|
|
2813
|
+
if (a.priority < b.priority || a.priority === b.priority && a.startedAt < b.startedAt) {
|
|
2814
|
+
weakest = i;
|
|
2815
|
+
}
|
|
2816
|
+
}
|
|
2817
|
+
const victim = this.live[weakest];
|
|
2818
|
+
if (victim.priority > priority) return null;
|
|
2819
|
+
victim.stop();
|
|
2820
|
+
}
|
|
2821
|
+
const gain = this.ctx.createGain();
|
|
2822
|
+
gain.connect(this.master);
|
|
2823
|
+
const id = this.nextId++;
|
|
2824
|
+
this.slots.set(id, { gain, sources: [], nodes: [] });
|
|
2825
|
+
const voice = {
|
|
2826
|
+
id,
|
|
2827
|
+
kind,
|
|
2828
|
+
priority,
|
|
2829
|
+
gain,
|
|
2830
|
+
startedAt: this.ctx.currentTime,
|
|
2831
|
+
own: (source) => this.own(id, source),
|
|
2832
|
+
use: (node) => this.useNode(id, node),
|
|
2833
|
+
stop: () => this.release(id)
|
|
2834
|
+
};
|
|
2835
|
+
this.live.push(voice);
|
|
2836
|
+
return voice;
|
|
2837
|
+
}
|
|
2838
|
+
own(id, source) {
|
|
2839
|
+
const slot = this.slots.get(id);
|
|
2840
|
+
const alive = this.live.some((v) => v.id === id);
|
|
2841
|
+
if (!slot || !alive) {
|
|
2842
|
+
stopNow(source);
|
|
2843
|
+
source.disconnect();
|
|
2844
|
+
return;
|
|
2845
|
+
}
|
|
2846
|
+
slot.sources.push(source);
|
|
2847
|
+
source.onended = () => this.ended(id, source);
|
|
2848
|
+
}
|
|
2849
|
+
useNode(id, node) {
|
|
2850
|
+
const slot = this.slots.get(id);
|
|
2851
|
+
const alive = this.live.some((v) => v.id === id);
|
|
2852
|
+
if (!slot || !alive) {
|
|
2853
|
+
node.disconnect();
|
|
2854
|
+
return;
|
|
2855
|
+
}
|
|
2856
|
+
slot.nodes.push(node);
|
|
2857
|
+
}
|
|
2858
|
+
ended(id, source) {
|
|
2859
|
+
source.disconnect();
|
|
2860
|
+
const slot = this.slots.get(id);
|
|
2861
|
+
if (!slot) return;
|
|
2862
|
+
const at = slot.sources.indexOf(source);
|
|
2863
|
+
if (at >= 0) slot.sources.splice(at, 1);
|
|
2864
|
+
if (slot.sources.length > 0) return;
|
|
2865
|
+
if (this.live.some((v) => v.id === id)) this.release(id);
|
|
2866
|
+
else this.free(id);
|
|
2867
|
+
}
|
|
2868
|
+
/** Disconnect a voice's gain. Only once nothing that feeds it is still running. */
|
|
2869
|
+
free(id) {
|
|
2870
|
+
const slot = this.slots.get(id);
|
|
2871
|
+
if (!slot) return;
|
|
2872
|
+
for (const node of slot.nodes) node.disconnect();
|
|
2873
|
+
slot.gain.disconnect();
|
|
2874
|
+
this.slots.delete(id);
|
|
2875
|
+
}
|
|
2876
|
+
/**
|
|
2877
|
+
* Return a voice to the pool, fading it out first.
|
|
2878
|
+
*
|
|
2879
|
+
* The fade is the whole reason this is not just a splice. Cutting a
|
|
2880
|
+
* waveform mid-cycle produces a step, and a step is a click — audible,
|
|
2881
|
+
* cheap-sounding, and most likely to happen exactly when the most is going
|
|
2882
|
+
* on, since that is when voices get stolen.
|
|
2883
|
+
*/
|
|
2884
|
+
release(id) {
|
|
2885
|
+
const index = this.live.findIndex((v) => v.id === id);
|
|
2886
|
+
if (index < 0) return;
|
|
2887
|
+
const [voice] = this.live.splice(index, 1);
|
|
2888
|
+
if (!voice) return;
|
|
2889
|
+
const slot = this.slots.get(id);
|
|
2890
|
+
if (!slot || slot.sources.length === 0) {
|
|
2891
|
+
this.free(id);
|
|
2892
|
+
return;
|
|
2893
|
+
}
|
|
2894
|
+
const now = this.ctx.currentTime;
|
|
2895
|
+
voice.gain.gain.cancelScheduledValues(now);
|
|
2896
|
+
voice.gain.gain.setValueAtTime(voice.gain.gain.value, now);
|
|
2897
|
+
voice.gain.gain.linearRampToValueAtTime(0, now + STEAL_FADE);
|
|
2898
|
+
for (const source of [...slot.sources]) {
|
|
2899
|
+
try {
|
|
2900
|
+
source.stop(now + STEAL_FADE);
|
|
2901
|
+
} catch {
|
|
2902
|
+
this.ended(id, source);
|
|
2903
|
+
}
|
|
2904
|
+
}
|
|
2905
|
+
}
|
|
2906
|
+
/** Stop everything. The panic button, and what unmounting calls. */
|
|
2907
|
+
stopAll() {
|
|
2908
|
+
for (const voice of [...this.live]) voice.stop();
|
|
2909
|
+
}
|
|
2910
|
+
/**
|
|
2911
|
+
* A short tone, to prove the chain end to end.
|
|
2912
|
+
*
|
|
2913
|
+
* Deliberately part of the shipped surface rather than a test fixture:
|
|
2914
|
+
* "is the audio graph actually connected" is a question that comes up on
|
|
2915
|
+
* every device, and the honest answer is a sound you can hear. It uses the
|
|
2916
|
+
* same path everything else does — a voice from the pool, the noise buffer,
|
|
2917
|
+
* the master gain — so if this is audible the path works.
|
|
2918
|
+
*/
|
|
2919
|
+
test(duration = 0.15) {
|
|
2920
|
+
const voice = this.take("test", 1);
|
|
2921
|
+
if (!voice) return null;
|
|
2922
|
+
const source = this.ctx.createBufferSource();
|
|
2923
|
+
source.buffer = this.noiseBuffer();
|
|
2924
|
+
source.connect(voice.gain);
|
|
2925
|
+
const now = this.ctx.currentTime;
|
|
2926
|
+
voice.gain.gain.setValueAtTime(0, now);
|
|
2927
|
+
voice.gain.gain.linearRampToValueAtTime(0.4, now + 0.01);
|
|
2928
|
+
voice.gain.gain.linearRampToValueAtTime(0, now + duration);
|
|
2929
|
+
source.start(now);
|
|
2930
|
+
source.stop(now + duration);
|
|
2931
|
+
voice.own(source);
|
|
2932
|
+
return voice;
|
|
2933
|
+
}
|
|
2934
|
+
/** Release the context. Nothing survives this. */
|
|
2935
|
+
async dispose() {
|
|
2936
|
+
this.stopAll();
|
|
2937
|
+
for (const id of [...this.slots.keys()]) this.free(id);
|
|
2938
|
+
this.master.disconnect();
|
|
2939
|
+
await this.ctx.close();
|
|
2940
|
+
}
|
|
2941
|
+
};
|
|
2942
|
+
function stopNow(source) {
|
|
2943
|
+
try {
|
|
2944
|
+
source.stop();
|
|
2945
|
+
} catch {
|
|
2946
|
+
}
|
|
2947
|
+
}
|
|
2948
|
+
function createAudioContext() {
|
|
2949
|
+
const Ctor = globalThis.AudioContext;
|
|
2950
|
+
if (!Ctor) throw new Error("no Web Audio support in this environment");
|
|
2951
|
+
return new Ctor();
|
|
2952
|
+
}
|
|
2953
|
+
|
|
2954
|
+
// src/fx/sfx/fire.ts
|
|
2955
|
+
var DEFAULTS = { volume: 0.55, fullFront: 0.04, crackle: 0.3, seed: 5 };
|
|
2956
|
+
var BED_SMOOTHING = 0.12;
|
|
2957
|
+
var CRACKLES_PER_FRAME = 4;
|
|
2958
|
+
var FireSound = class {
|
|
2959
|
+
audio;
|
|
2960
|
+
o;
|
|
2961
|
+
bed = null;
|
|
2962
|
+
level = 0;
|
|
2963
|
+
debt = 0;
|
|
2964
|
+
state;
|
|
2965
|
+
constructor(audio, options = {}) {
|
|
2966
|
+
this.audio = audio;
|
|
2967
|
+
this.o = { ...DEFAULTS, ...options };
|
|
2968
|
+
this.state = this.o.seed >>> 0 || 1;
|
|
2969
|
+
}
|
|
2970
|
+
/** True while the bed is playing. */
|
|
2971
|
+
get burning() {
|
|
2972
|
+
return this.bed !== null;
|
|
2973
|
+
}
|
|
2974
|
+
/**
|
|
2975
|
+
* Call once a frame with what the field just did, and where the sheet is.
|
|
2976
|
+
*
|
|
2977
|
+
* `at` is optional: without it the fire is not placed in the room, which is
|
|
2978
|
+
* right for a sheet filling the frame and wrong for one held at arm's
|
|
2979
|
+
* length.
|
|
2980
|
+
*/
|
|
2981
|
+
update(dt, stats, at) {
|
|
2982
|
+
const target2 = stats.front > 0 ? Math.min(1, Math.sqrt(stats.front / this.o.fullFront)) : 0;
|
|
2983
|
+
const follow = dt > 0 ? Math.min(1, dt / BED_SMOOTHING) : 1;
|
|
2984
|
+
this.level += (target2 - this.level) * follow;
|
|
2985
|
+
if (target2 > 0 || this.level > 0.02) this.startBed(at);
|
|
2986
|
+
else this.stopBed();
|
|
2987
|
+
if (this.bed) this.driveBed(at);
|
|
2988
|
+
this.debt += stats.charred * this.o.crackle;
|
|
2989
|
+
let budget = CRACKLES_PER_FRAME;
|
|
2990
|
+
while (this.debt >= 1 && budget > 0) {
|
|
2991
|
+
this.debt -= 1;
|
|
2992
|
+
budget--;
|
|
2993
|
+
this.crackle();
|
|
2994
|
+
}
|
|
2995
|
+
if (budget === 0) this.debt %= 1;
|
|
2996
|
+
}
|
|
2997
|
+
/**
|
|
2998
|
+
* A match struck: the scratch of the head across the box, then the hiss of
|
|
2999
|
+
* it flaring as the head burns off (spec §10.2). Two bursts of the shared
|
|
3000
|
+
* noise, one bright and short, one breathier and longer.
|
|
3001
|
+
*/
|
|
3002
|
+
strike() {
|
|
3003
|
+
this.burst("fire-strike", 0.9, { type: "bandpass", frequency: 3200, q: 1.1 }, 4e-3, 0.07, 0.8);
|
|
3004
|
+
this.burst("fire-flare", 0.8, { type: "highpass", frequency: 1800, q: 0.7 }, 0.03, 0.45, 0.45, 0.05);
|
|
3005
|
+
}
|
|
3006
|
+
/** Blown out: a soft, low breath of noise (spec §10.6). */
|
|
3007
|
+
puff() {
|
|
3008
|
+
this.burst("fire-puff", 0.6, { type: "lowpass", frequency: 520, q: 0.6 }, 0.02, 0.28, 0.55);
|
|
3009
|
+
}
|
|
3010
|
+
/** One ember popping in the air — a tiny click on the frame it flashes (spec §8.1). */
|
|
3011
|
+
pop() {
|
|
3012
|
+
const f = 3e3 + this.next() * 3e3;
|
|
3013
|
+
this.burst(
|
|
3014
|
+
"fire-pop",
|
|
3015
|
+
0.2,
|
|
3016
|
+
{ type: "bandpass", frequency: f, q: 3 },
|
|
3017
|
+
1e-3,
|
|
3018
|
+
0.012 + this.next() * 0.01,
|
|
3019
|
+
0.35
|
|
3020
|
+
);
|
|
3021
|
+
}
|
|
3022
|
+
/** Silence, now — the flame blown out, or the page going away. */
|
|
3023
|
+
stop() {
|
|
3024
|
+
this.stopBed();
|
|
3025
|
+
this.level = 0;
|
|
3026
|
+
this.debt = 0;
|
|
3027
|
+
}
|
|
3028
|
+
startBed(at) {
|
|
3029
|
+
if (this.bed) return;
|
|
3030
|
+
const ctx = this.audio.context;
|
|
3031
|
+
const voice = this.audio.take("fire-bed", 0.7);
|
|
3032
|
+
if (!voice) return;
|
|
3033
|
+
const source = ctx.createBufferSource();
|
|
3034
|
+
source.buffer = this.audio.noiseBuffer();
|
|
3035
|
+
source.loop = true;
|
|
3036
|
+
const filter = ctx.createBiquadFilter();
|
|
3037
|
+
filter.type = "bandpass";
|
|
3038
|
+
filter.frequency.value = 380;
|
|
3039
|
+
filter.Q.value = 0.7;
|
|
3040
|
+
const panner = at ? ctx.createPanner() : null;
|
|
3041
|
+
source.connect(filter);
|
|
3042
|
+
if (panner) {
|
|
3043
|
+
panner.panningModel = "equalpower";
|
|
3044
|
+
panner.distanceModel = "inverse";
|
|
3045
|
+
panner.refDistance = 1;
|
|
3046
|
+
filter.connect(panner);
|
|
3047
|
+
panner.connect(voice.gain);
|
|
3048
|
+
} else {
|
|
3049
|
+
filter.connect(voice.gain);
|
|
3050
|
+
}
|
|
3051
|
+
voice.gain.gain.setValueAtTime(0, ctx.currentTime);
|
|
3052
|
+
source.start(ctx.currentTime);
|
|
3053
|
+
voice.own(source);
|
|
3054
|
+
voice.use(filter);
|
|
3055
|
+
if (panner) voice.use(panner);
|
|
3056
|
+
this.bed = { voice, filter, panner };
|
|
3057
|
+
}
|
|
3058
|
+
driveBed(at) {
|
|
3059
|
+
const bed = this.bed;
|
|
3060
|
+
if (!bed) return;
|
|
3061
|
+
const ctx = this.audio.context;
|
|
3062
|
+
const now = ctx.currentTime;
|
|
3063
|
+
const gain = bed.voice.gain.gain;
|
|
3064
|
+
gain.cancelScheduledValues(now);
|
|
3065
|
+
gain.setValueAtTime(gain.value, now);
|
|
3066
|
+
gain.linearRampToValueAtTime(this.level * this.o.volume, now + BED_SMOOTHING / 2);
|
|
3067
|
+
bed.filter.frequency.setValueAtTime(380 + 900 * this.level, now);
|
|
3068
|
+
if (at && bed.panner) {
|
|
3069
|
+
bed.panner.positionX.setValueAtTime(at.x, now);
|
|
3070
|
+
bed.panner.positionY.setValueAtTime(at.y, now);
|
|
3071
|
+
bed.panner.positionZ.setValueAtTime(at.z, now);
|
|
3072
|
+
}
|
|
3073
|
+
}
|
|
3074
|
+
stopBed() {
|
|
3075
|
+
if (!this.bed) return;
|
|
3076
|
+
this.bed.voice.stop();
|
|
3077
|
+
this.bed = null;
|
|
3078
|
+
}
|
|
3079
|
+
crackle() {
|
|
3080
|
+
const voice = this.audio.take("fire-crackle", 0.25);
|
|
3081
|
+
if (!voice) return;
|
|
3082
|
+
const ctx = this.audio.context;
|
|
3083
|
+
const now = ctx.currentTime;
|
|
3084
|
+
const buffer = this.audio.noiseBuffer();
|
|
3085
|
+
const seconds = buffer.length / ctx.sampleRate;
|
|
3086
|
+
const duration = 0.018 + this.next() * 0.05;
|
|
3087
|
+
const source = ctx.createBufferSource();
|
|
3088
|
+
source.buffer = buffer;
|
|
3089
|
+
const filter = ctx.createBiquadFilter();
|
|
3090
|
+
filter.type = "bandpass";
|
|
3091
|
+
filter.frequency.value = 1400 + this.next() * 2800;
|
|
3092
|
+
filter.Q.value = 1.6 + this.next() * 3;
|
|
3093
|
+
source.connect(filter);
|
|
3094
|
+
filter.connect(voice.gain);
|
|
3095
|
+
const peak = (0.25 + this.next() * 0.5) * this.o.volume * Math.max(0.25, this.level);
|
|
3096
|
+
const gain = voice.gain.gain;
|
|
3097
|
+
gain.setValueAtTime(0, now);
|
|
3098
|
+
gain.linearRampToValueAtTime(peak, now + 4e-3);
|
|
3099
|
+
gain.linearRampToValueAtTime(0, now + duration);
|
|
3100
|
+
source.start(now, this.next() * Math.max(0, seconds - duration), duration);
|
|
3101
|
+
voice.own(source);
|
|
3102
|
+
voice.use(filter);
|
|
3103
|
+
}
|
|
3104
|
+
/**
|
|
3105
|
+
* One shaped burst of the shared noise through one filter — what strike,
|
|
3106
|
+
* puff and pop are made of. `delay` starts it a moment late, which is how
|
|
3107
|
+
* the flare follows the scratch.
|
|
3108
|
+
*/
|
|
3109
|
+
burst(name, priority, filterSpec, attack, duration, level, delay = 0) {
|
|
3110
|
+
const voice = this.audio.take(name, priority);
|
|
3111
|
+
if (!voice) return;
|
|
3112
|
+
const ctx = this.audio.context;
|
|
3113
|
+
const now = ctx.currentTime + delay;
|
|
3114
|
+
const buffer = this.audio.noiseBuffer();
|
|
3115
|
+
const seconds = buffer.length / ctx.sampleRate;
|
|
3116
|
+
const source = ctx.createBufferSource();
|
|
3117
|
+
source.buffer = buffer;
|
|
3118
|
+
const filter = ctx.createBiquadFilter();
|
|
3119
|
+
filter.type = filterSpec.type;
|
|
3120
|
+
filter.frequency.value = filterSpec.frequency;
|
|
3121
|
+
filter.Q.value = filterSpec.q;
|
|
3122
|
+
source.connect(filter);
|
|
3123
|
+
filter.connect(voice.gain);
|
|
3124
|
+
const gain = voice.gain.gain;
|
|
3125
|
+
gain.setValueAtTime(0, now);
|
|
3126
|
+
gain.linearRampToValueAtTime(level * this.o.volume, now + attack);
|
|
3127
|
+
gain.linearRampToValueAtTime(0, now + attack + duration);
|
|
3128
|
+
source.start(now, this.next() * Math.max(0, seconds - attack - duration), attack + duration);
|
|
3129
|
+
voice.own(source);
|
|
3130
|
+
voice.use(filter);
|
|
3131
|
+
}
|
|
3132
|
+
/** xorshift32 — its own stream, so nothing else can shift the crackle. */
|
|
3133
|
+
next() {
|
|
3134
|
+
let s = this.state;
|
|
3135
|
+
s ^= s << 13;
|
|
3136
|
+
s ^= s >>> 17;
|
|
3137
|
+
s ^= s << 5;
|
|
3138
|
+
this.state = s >>> 0;
|
|
3139
|
+
return this.state / 4294967296;
|
|
3140
|
+
}
|
|
3141
|
+
};
|
|
3142
|
+
export {
|
|
3143
|
+
Afterglow,
|
|
3144
|
+
CHAR,
|
|
3145
|
+
DAMAGE_CHANNELS,
|
|
3146
|
+
DAMAGE_LOOK_DEFAULTS,
|
|
3147
|
+
DamageField,
|
|
3148
|
+
FIELD_SIZE,
|
|
3149
|
+
FIRE_GLOW,
|
|
3150
|
+
FIRE_HEAT_SCALE,
|
|
3151
|
+
FIRE_SOOT_SCALE,
|
|
3152
|
+
FIRE_ZONES,
|
|
3153
|
+
FIXED_DT,
|
|
3154
|
+
FLAME_HEIGHT,
|
|
3155
|
+
FX_BLOOM,
|
|
3156
|
+
FX_BLOOM_THRESHOLD,
|
|
3157
|
+
FX_INITIAL_TIER,
|
|
3158
|
+
FX_TIER_ORDER,
|
|
3159
|
+
FireEmitter,
|
|
3160
|
+
FireFluid,
|
|
3161
|
+
FireSound,
|
|
3162
|
+
FxAudio,
|
|
3163
|
+
FxFireFluid,
|
|
3164
|
+
FxFireLight,
|
|
3165
|
+
FxFlames,
|
|
3166
|
+
FxMatchFlame,
|
|
3167
|
+
FxParticles,
|
|
3168
|
+
FxPost,
|
|
3169
|
+
FxWisps,
|
|
3170
|
+
HEAT,
|
|
3171
|
+
PAPER_WHITE,
|
|
3172
|
+
PRESENCE,
|
|
3173
|
+
ParticlePool,
|
|
3174
|
+
SATURATION,
|
|
3175
|
+
createAudioContext,
|
|
3176
|
+
emit,
|
|
3177
|
+
emitHex,
|
|
3178
|
+
fireEmitterDefaults,
|
|
3179
|
+
fireFluidControls,
|
|
3180
|
+
fireFluidDefaults,
|
|
3181
|
+
fireZones,
|
|
3182
|
+
flameAnchors,
|
|
3183
|
+
flamePuff,
|
|
3184
|
+
fxQualityFor,
|
|
3185
|
+
fxQualityNames,
|
|
3186
|
+
fxQualityTiers,
|
|
3187
|
+
hexToLinear,
|
|
3188
|
+
luminance,
|
|
3189
|
+
particlePresets,
|
|
3190
|
+
solverUniforms,
|
|
3191
|
+
srgbToLinear,
|
|
3192
|
+
timesPaperWhite
|
|
3193
|
+
};
|
|
3194
|
+
//# sourceMappingURL=fx.js.map
|