@zakkster/lite-ambient-fx 1.0.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/AmbientFX.d.ts +179 -0
- package/AmbientFX.js +868 -0
- package/CHANGELOG.md +74 -0
- package/LICENSE.txt +21 -0
- package/README.md +436 -0
- package/llms.txt +210 -0
- package/package.json +60 -0
package/AmbientFX.js
ADDED
|
@@ -0,0 +1,868 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zakkster/lite-ambient-fx
|
|
3
|
+
*
|
|
4
|
+
* Full-screen ambient particle atmospheres in one file. Six themed presets
|
|
5
|
+
* (Fire, Night, Ice, Frost, Toxic, Void) across four particle behaviors
|
|
6
|
+
* (EMBER, MIST, FLOAT, CHAOS), plus a registry hook for adding your own.
|
|
7
|
+
* Sprite-cached radial gradients, zero-alloc render loop, monomorphic
|
|
8
|
+
* particle shape, DPR-aware rasterization, delta-time scaled,
|
|
9
|
+
* resize-preserving, visibility-paused. Zero runtime dependencies.
|
|
10
|
+
*
|
|
11
|
+
* (c) 2026 Zahary Shinikchiev. MIT.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const VERSION = '1.0.0';
|
|
15
|
+
|
|
16
|
+
// ============================================================
|
|
17
|
+
// THEME PRESETS
|
|
18
|
+
// ============================================================
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Six shipped presets. Each is a full config; `behavior` is a key into the
|
|
22
|
+
* BEHAVIORS registry; every other field is live-tunable via updateConfig.
|
|
23
|
+
*/
|
|
24
|
+
export const THEMES = {
|
|
25
|
+
Fire: {
|
|
26
|
+
behavior: 'EMBER',
|
|
27
|
+
colors: ['#ff4500', '#ff7f50', '#ffd700'],
|
|
28
|
+
spark: '#ffff00',
|
|
29
|
+
count: 300,
|
|
30
|
+
wind: { x: -0.2, y: -0.5 },
|
|
31
|
+
decay: 0.004,
|
|
32
|
+
speed: 2.5,
|
|
33
|
+
size: 5,
|
|
34
|
+
alpha: 1.0,
|
|
35
|
+
turbulence: 0.5,
|
|
36
|
+
},
|
|
37
|
+
Night: {
|
|
38
|
+
behavior: 'EMBER',
|
|
39
|
+
colors: ['#ff4500', '#ffa500', '#ffd700'],
|
|
40
|
+
spark: '#ffffff',
|
|
41
|
+
count: 200,
|
|
42
|
+
wind: { x: 0.1, y: -0.3 },
|
|
43
|
+
decay: 0.004,
|
|
44
|
+
speed: 2.0,
|
|
45
|
+
size: 5,
|
|
46
|
+
alpha: 1.0,
|
|
47
|
+
turbulence: 0.5,
|
|
48
|
+
},
|
|
49
|
+
Ice: {
|
|
50
|
+
behavior: 'MIST',
|
|
51
|
+
colors: ['#00bfff', '#87ceeb', '#e0ffff'],
|
|
52
|
+
spark: '#ffffff',
|
|
53
|
+
count: 40,
|
|
54
|
+
wind: { x: 1.5, y: 0.2 },
|
|
55
|
+
decay: 0.0003,
|
|
56
|
+
speed: 1.0,
|
|
57
|
+
size: 200,
|
|
58
|
+
alpha: 0.08,
|
|
59
|
+
turbulence: 0.5,
|
|
60
|
+
},
|
|
61
|
+
Frost: {
|
|
62
|
+
behavior: 'MIST',
|
|
63
|
+
colors: ['#ffffff', '#f0f8ff', '#e6e6fa'],
|
|
64
|
+
spark: '#ffffff',
|
|
65
|
+
count: 40,
|
|
66
|
+
wind: { x: 0.5, y: 0.1 },
|
|
67
|
+
decay: 0.0003,
|
|
68
|
+
speed: 1.0,
|
|
69
|
+
size: 200,
|
|
70
|
+
alpha: 0.08,
|
|
71
|
+
turbulence: 0.5,
|
|
72
|
+
},
|
|
73
|
+
Toxic: {
|
|
74
|
+
behavior: 'FLOAT',
|
|
75
|
+
colors: ['#39ff14', '#7fff00', '#00ff00'],
|
|
76
|
+
spark: '#ccff00',
|
|
77
|
+
count: 150,
|
|
78
|
+
wind: { x: 0, y: -0.2 },
|
|
79
|
+
decay: 0.0005,
|
|
80
|
+
speed: 0.6,
|
|
81
|
+
size: 8,
|
|
82
|
+
alpha: 0.7,
|
|
83
|
+
turbulence: 0.5,
|
|
84
|
+
},
|
|
85
|
+
Void: {
|
|
86
|
+
behavior: 'CHAOS',
|
|
87
|
+
colors: ['#4b0082', '#9400d3', '#8a2be2'],
|
|
88
|
+
spark: '#da70d6',
|
|
89
|
+
count: 150,
|
|
90
|
+
wind: { x: 0, y: 0 },
|
|
91
|
+
decay: 0.02,
|
|
92
|
+
speed: 1.5,
|
|
93
|
+
size: 3,
|
|
94
|
+
alpha: 0.9,
|
|
95
|
+
turbulence: 0.5,
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** Human-readable metadata for UI builders. */
|
|
100
|
+
export const THEME_META = [
|
|
101
|
+
{ id: 'Fire', name: 'Inferno', icon: 'flame', behavior: 'EMBER' },
|
|
102
|
+
{ id: 'Night', name: 'Stardust', icon: 'sparks', behavior: 'EMBER' },
|
|
103
|
+
{ id: 'Ice', name: 'Blizzard', icon: 'flake', behavior: 'MIST' },
|
|
104
|
+
{ id: 'Frost', name: 'Deep Fog', icon: 'fog', behavior: 'MIST' },
|
|
105
|
+
{ id: 'Toxic', name: 'Biohazard', icon: 'radio', behavior: 'FLOAT' },
|
|
106
|
+
{ id: 'Void', name: 'Dark Matter', icon: 'orb', behavior: 'CHAOS' },
|
|
107
|
+
];
|
|
108
|
+
|
|
109
|
+
// ============================================================
|
|
110
|
+
// SHARED CONSTANTS
|
|
111
|
+
// ============================================================
|
|
112
|
+
|
|
113
|
+
const TAU = Math.PI * 2;
|
|
114
|
+
const DEG_TO_RAD = Math.PI / 180;
|
|
115
|
+
|
|
116
|
+
// 360-entry sine LUT for turbulence and MIST breathing.
|
|
117
|
+
const SIN = new Float32Array(360);
|
|
118
|
+
for (let i = 0; i < 360; i++) SIN[i] = Math.sin(i * DEG_TO_RAD);
|
|
119
|
+
|
|
120
|
+
// Sprite logical sizes per behavior class.
|
|
121
|
+
const SPRITE_LOGICAL_MIST = 128;
|
|
122
|
+
const SPRITE_LOGICAL_CORE = 64;
|
|
123
|
+
|
|
124
|
+
// Never advance more than 50ms in one step (10x a 60fps frame). Prevents
|
|
125
|
+
// catch-up spikes when a tab wakes up.
|
|
126
|
+
const DT_CLAMP_MS = 50;
|
|
127
|
+
|
|
128
|
+
// Delta-time reference: 60fps = 16.667ms/frame. Velocities are tuned per
|
|
129
|
+
// frame at 60fps; scaling by dt/16 makes any refresh rate match.
|
|
130
|
+
const DT_REF_MS = 16;
|
|
131
|
+
|
|
132
|
+
// Alpha cutoff below which we skip the drawImage call altogether.
|
|
133
|
+
const ALPHA_EPSILON = 0.01;
|
|
134
|
+
|
|
135
|
+
// MIST life is a ms accumulator; wrap keeps the breath phase stable and
|
|
136
|
+
// avoids Float32 drift on multi-minute sessions.
|
|
137
|
+
const MIST_LIFE_WRAP_MS = 72_000;
|
|
138
|
+
|
|
139
|
+
// How far past the top edge an EMBER/FLOAT particle can go before we
|
|
140
|
+
// consider it dead and respawn from the bottom.
|
|
141
|
+
const RESPAWN_MARGIN_Y = 50;
|
|
142
|
+
|
|
143
|
+
// Alpha-envelope constants, pre-computed reciprocals so hot loops multiply
|
|
144
|
+
// instead of divide. EMBER fade-in is at 0..0.2 (life * 5 = alpha frac);
|
|
145
|
+
// EMBER fade-out from 0.2..1 simplifies to 1.25 * (1 - life). FLOAT fade
|
|
146
|
+
// windows are 0..0.1 and 0.9..1 (life * 10 either direction).
|
|
147
|
+
const EMBER_FADE_IN_INV = 5; // 1 / 0.2
|
|
148
|
+
const EMBER_FADE_OUT_INV = 1.25; // 1 / 0.8
|
|
149
|
+
const FLOAT_FADE_INV = 10; // 1 / 0.1
|
|
150
|
+
|
|
151
|
+
// ============================================================
|
|
152
|
+
// DPR-AWARE SPRITE CACHE (nested Map, zero string allocation)
|
|
153
|
+
// ============================================================
|
|
154
|
+
|
|
155
|
+
// Two-level index: Map<color, Map<physicalSize, HTMLCanvasElement>>. Avoids
|
|
156
|
+
// the `${color}:${physical}` string concatenation that would otherwise burn
|
|
157
|
+
// one string allocation per lookup.
|
|
158
|
+
const _sprites = new Map();
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Get or build a sprite. `logicalSize` is what the caller draws at; we
|
|
162
|
+
* rasterize at `logicalSize * dpr` and let drawImage downsample.
|
|
163
|
+
*/
|
|
164
|
+
function getSprite(color, logicalSize, dpr) {
|
|
165
|
+
const physical = Math.max(1, Math.ceil(logicalSize * dpr));
|
|
166
|
+
let byPhysical = _sprites.get(color);
|
|
167
|
+
if (byPhysical === undefined) {
|
|
168
|
+
byPhysical = new Map();
|
|
169
|
+
_sprites.set(color, byPhysical);
|
|
170
|
+
}
|
|
171
|
+
const cached = byPhysical.get(physical);
|
|
172
|
+
if (cached !== undefined) return cached;
|
|
173
|
+
|
|
174
|
+
const c = document.createElement('canvas');
|
|
175
|
+
c.width = physical;
|
|
176
|
+
c.height = physical;
|
|
177
|
+
const g2d = c.getContext('2d');
|
|
178
|
+
const center = physical / 2;
|
|
179
|
+
const grad = g2d.createRadialGradient(center, center, 0, center, center, center);
|
|
180
|
+
|
|
181
|
+
if (logicalSize >= SPRITE_LOGICAL_MIST) {
|
|
182
|
+
// Mist: soft blob, no bright core.
|
|
183
|
+
grad.addColorStop(0, color);
|
|
184
|
+
grad.addColorStop(1, 'transparent');
|
|
185
|
+
} else {
|
|
186
|
+
// Ember/Float/Chaos: bright white core, colored halo.
|
|
187
|
+
grad.addColorStop(0, '#fff');
|
|
188
|
+
grad.addColorStop(0.4, color);
|
|
189
|
+
grad.addColorStop(1, 'transparent');
|
|
190
|
+
}
|
|
191
|
+
g2d.fillStyle = grad;
|
|
192
|
+
g2d.beginPath();
|
|
193
|
+
g2d.arc(center, center, center, 0, TAU);
|
|
194
|
+
g2d.fill();
|
|
195
|
+
byPhysical.set(physical, c);
|
|
196
|
+
return c;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Drop sprites matching a color set (or the whole cache when `colors` is
|
|
201
|
+
* omitted). Called on updateConfig({colors|spark}) and on destroy().
|
|
202
|
+
*/
|
|
203
|
+
export function clearAmbientSpriteCache(colors) {
|
|
204
|
+
if (!colors) {
|
|
205
|
+
for (const byPhysical of _sprites.values()) {
|
|
206
|
+
for (const c of byPhysical.values()) {
|
|
207
|
+
c.width = 0;
|
|
208
|
+
c.height = 0;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
_sprites.clear();
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
for (let i = 0; i < colors.length; i++) {
|
|
215
|
+
const color = colors[i];
|
|
216
|
+
const byPhysical = _sprites.get(color);
|
|
217
|
+
if (byPhysical === undefined) continue;
|
|
218
|
+
for (const c of byPhysical.values()) {
|
|
219
|
+
c.width = 0;
|
|
220
|
+
c.height = 0;
|
|
221
|
+
}
|
|
222
|
+
_sprites.delete(color);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ============================================================
|
|
227
|
+
// PURE HELPERS (exported for tests and downstream reuse)
|
|
228
|
+
// ============================================================
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Merge a theme with overrides. `wind` is a nested object; a partial `wind`
|
|
232
|
+
* override merges shallowly rather than replacing the whole vector.
|
|
233
|
+
*/
|
|
234
|
+
export function mergeThemeConfig(base, overrides) {
|
|
235
|
+
if (!overrides) return { ...base, wind: { ...base.wind } };
|
|
236
|
+
const wind = overrides.wind
|
|
237
|
+
? { x: overrides.wind.x ?? base.wind.x, y: overrides.wind.y ?? base.wind.y }
|
|
238
|
+
: { ...base.wind };
|
|
239
|
+
return { ...base, ...overrides, wind };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Validate a config object. Throws with a specific message on the first
|
|
244
|
+
* violation. Cheap enough to run on every setTheme.
|
|
245
|
+
*/
|
|
246
|
+
export function validateConfig(cfg) {
|
|
247
|
+
if (!cfg || typeof cfg !== 'object') throw new TypeError('AmbientFX: config must be an object');
|
|
248
|
+
if (typeof cfg.behavior !== 'string' || BEHAVIORS[cfg.behavior] === undefined) {
|
|
249
|
+
throw new RangeError('AmbientFX: behavior must be a registered name (' + Object.keys(BEHAVIORS).join(', ') + ')');
|
|
250
|
+
}
|
|
251
|
+
if (!Array.isArray(cfg.colors) || cfg.colors.length === 0) {
|
|
252
|
+
throw new TypeError('AmbientFX: colors must be a non-empty array');
|
|
253
|
+
}
|
|
254
|
+
if (typeof cfg.count !== 'number' || cfg.count < 0 || (cfg.count | 0) !== cfg.count) {
|
|
255
|
+
throw new RangeError('AmbientFX: count must be a non-negative integer');
|
|
256
|
+
}
|
|
257
|
+
if (typeof cfg.alpha !== 'number' || cfg.alpha < 0 || cfg.alpha > 1) {
|
|
258
|
+
throw new RangeError('AmbientFX: alpha must be in [0,1]');
|
|
259
|
+
}
|
|
260
|
+
return cfg;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Delta-time scale factor. Frame velocities are tuned at 60fps; multiply
|
|
265
|
+
* per-frame constants by this to remain framerate-independent.
|
|
266
|
+
*/
|
|
267
|
+
export function deltaScale(dtMs) {
|
|
268
|
+
return dtMs / DT_REF_MS;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Signed modulo that keeps SIN LUT indices in [0, 359] under negative input.
|
|
273
|
+
*/
|
|
274
|
+
export function sinLut(index) {
|
|
275
|
+
const i = ((index | 0) % 360 + 360) % 360;
|
|
276
|
+
return SIN[i];
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Alpha envelope for a life value in [0, 1]. Kept as a shared helper for
|
|
281
|
+
* tests and downstream reuse; the built-in behaviors inline these curves
|
|
282
|
+
* inside their tick loops to avoid the function call per particle.
|
|
283
|
+
*/
|
|
284
|
+
export function envelopeAlpha(mode, life, maxAlpha) {
|
|
285
|
+
if (mode === 'EMBER') {
|
|
286
|
+
if (life < 0.2) return life * EMBER_FADE_IN_INV * maxAlpha;
|
|
287
|
+
return EMBER_FADE_OUT_INV * (1 - life) * maxAlpha;
|
|
288
|
+
}
|
|
289
|
+
if (mode === 'FLOAT') {
|
|
290
|
+
if (life < 0.1) return life * FLOAT_FADE_INV * maxAlpha;
|
|
291
|
+
if (life > 0.9) return (1 - life) * FLOAT_FADE_INV * maxAlpha;
|
|
292
|
+
return maxAlpha;
|
|
293
|
+
}
|
|
294
|
+
return maxAlpha;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Empty-particle shape. Every instance-owned particle carries the union of
|
|
299
|
+
* fields used by every behavior, always. This keeps V8's hidden class
|
|
300
|
+
* stable across behavior swaps: no property is ever added or removed at
|
|
301
|
+
* runtime, only mutated. Field-init values are 0/null/'' so the initial
|
|
302
|
+
* hidden class picks up the right slot types (SMI, tagged, string).
|
|
303
|
+
*/
|
|
304
|
+
function makeParticle(id) {
|
|
305
|
+
return {
|
|
306
|
+
id,
|
|
307
|
+
color: '',
|
|
308
|
+
spriteCanvas: null,
|
|
309
|
+
z: 0,
|
|
310
|
+
life: 0,
|
|
311
|
+
x: 0, y: 0,
|
|
312
|
+
size: 0,
|
|
313
|
+
vx: 0, vy: 0,
|
|
314
|
+
decay: 0,
|
|
315
|
+
maxAlpha: 0,
|
|
316
|
+
// MIST-specific fields -- always present, zeroed for other behaviors.
|
|
317
|
+
anchorX: 0, anchorY: 0,
|
|
318
|
+
pulseOffset: 0,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ============================================================
|
|
323
|
+
// BEHAVIOR REGISTRY
|
|
324
|
+
// ============================================================
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Every behavior is a `{ spriteLogical, spawn, tick }` triple.
|
|
328
|
+
*
|
|
329
|
+
* - `spriteLogical` -- the CSS-pixel size at which sprites for this
|
|
330
|
+
* behavior are rasterized. Two categories today: MIST at 128, everything
|
|
331
|
+
* else at 64. Custom behaviors pick their own.
|
|
332
|
+
* - `spawn(p, frame)` -- populate/reset a particle from cfg + frame state.
|
|
333
|
+
* MUST set every field, including a resolved `spriteCanvas` via
|
|
334
|
+
* `frame.getSprite(color, spriteLogical)`. MUST NOT add new fields to
|
|
335
|
+
* `p` (the shape is monomorphic; see makeParticle).
|
|
336
|
+
* - `tick(particles, ctx, frame)` -- advance and render every particle for
|
|
337
|
+
* one frame. Owns its own physics + alpha envelope. Calls
|
|
338
|
+
* `frame.respawn(p, false)` for dead particles.
|
|
339
|
+
*
|
|
340
|
+
* `frame` is a pooled context object owned by the instance. Do not retain
|
|
341
|
+
* references to it or its fields past the current call.
|
|
342
|
+
*/
|
|
343
|
+
export const BEHAVIORS = Object.create(null);
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Register a custom behavior. Overwrites any existing entry with the same
|
|
347
|
+
* name; the four built-in behaviors are registered during module init and
|
|
348
|
+
* can be replaced if needed.
|
|
349
|
+
*/
|
|
350
|
+
export function registerBehavior(name, def) {
|
|
351
|
+
if (typeof name !== 'string' || name.length === 0) {
|
|
352
|
+
throw new TypeError('registerBehavior: name must be a non-empty string');
|
|
353
|
+
}
|
|
354
|
+
if (!def || typeof def.spawn !== 'function' || typeof def.tick !== 'function') {
|
|
355
|
+
throw new TypeError('registerBehavior: def must have spawn() and tick() functions');
|
|
356
|
+
}
|
|
357
|
+
if (typeof def.spriteLogical !== 'number' || def.spriteLogical <= 0) {
|
|
358
|
+
throw new TypeError('registerBehavior: def.spriteLogical must be a positive number');
|
|
359
|
+
}
|
|
360
|
+
BEHAVIORS[name] = def;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Look up a behavior by name. Throws with a helpful message if unknown. */
|
|
364
|
+
function resolveBehavior(name) {
|
|
365
|
+
const b = BEHAVIORS[name];
|
|
366
|
+
if (b === undefined) {
|
|
367
|
+
throw new RangeError('AmbientFX: unknown behavior "' + name + '"; registered: ' + Object.keys(BEHAVIORS).join(', '));
|
|
368
|
+
}
|
|
369
|
+
return b;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// ---- Built-in: EMBER ----------------------------------------
|
|
373
|
+
BEHAVIORS.EMBER = {
|
|
374
|
+
spriteLogical: SPRITE_LOGICAL_CORE,
|
|
375
|
+
|
|
376
|
+
spawn(p, frame) {
|
|
377
|
+
const cfg = frame.cfg;
|
|
378
|
+
const W = frame.W;
|
|
379
|
+
const H = frame.H;
|
|
380
|
+
p.z = Math.random() * 0.8 + 0.2;
|
|
381
|
+
p.color = Math.random() > 0.9 ? cfg.spark : cfg.colors[(Math.random() * cfg.colors.length) | 0];
|
|
382
|
+
p.spriteCanvas = frame.getSprite(p.color, SPRITE_LOGICAL_CORE);
|
|
383
|
+
p.life = 0;
|
|
384
|
+
p.x = Math.random() * W;
|
|
385
|
+
p.y = frame.isInit ? Math.random() * H : H + 20;
|
|
386
|
+
p.size = (Math.random() * cfg.size + 2) * p.z;
|
|
387
|
+
p.vx = (Math.random() - 0.5) * 0.5;
|
|
388
|
+
const isSuper = Math.random() > 0.85;
|
|
389
|
+
p.decay = isSuper ? cfg.decay * 0.3 : cfg.decay;
|
|
390
|
+
p.vy = isSuper ? cfg.speed * 1.5 : cfg.speed;
|
|
391
|
+
p.maxAlpha = cfg.alpha * p.z;
|
|
392
|
+
// Zero MIST-only fields so the monomorphic shape is stable.
|
|
393
|
+
p.anchorX = 0;
|
|
394
|
+
p.anchorY = 0;
|
|
395
|
+
p.pulseOffset = 0;
|
|
396
|
+
},
|
|
397
|
+
|
|
398
|
+
tick(particles, ctx, frame) {
|
|
399
|
+
const cfg = frame.cfg;
|
|
400
|
+
const wind = cfg.wind;
|
|
401
|
+
const turbFactor = cfg.turbulence;
|
|
402
|
+
const ds = frame.ds;
|
|
403
|
+
const respawn = frame.respawn;
|
|
404
|
+
for (let i = 0; i < particles.length; i++) {
|
|
405
|
+
const p = particles[i];
|
|
406
|
+
const turbIdx = (p.y * 0.5) | 0;
|
|
407
|
+
const turb = sinLut(turbIdx) * turbFactor;
|
|
408
|
+
const moveX = (p.vx + wind.x + turb) * p.z * ds;
|
|
409
|
+
const moveY = (p.vy + wind.y) * p.z * ds;
|
|
410
|
+
p.y -= moveY;
|
|
411
|
+
p.x += moveX;
|
|
412
|
+
p.life += p.decay * ds;
|
|
413
|
+
let alpha;
|
|
414
|
+
if (p.life < 0.2) alpha = p.life * EMBER_FADE_IN_INV * p.maxAlpha;
|
|
415
|
+
else alpha = EMBER_FADE_OUT_INV * (1 - p.life) * p.maxAlpha;
|
|
416
|
+
if (p.y < -RESPAWN_MARGIN_Y || alpha <= 0) { respawn(p, false); continue; }
|
|
417
|
+
if (alpha > ALPHA_EPSILON) {
|
|
418
|
+
const a = alpha > 1 ? 1 : alpha;
|
|
419
|
+
ctx.globalAlpha = a;
|
|
420
|
+
const half = p.size * 0.5;
|
|
421
|
+
ctx.drawImage(
|
|
422
|
+
p.spriteCanvas,
|
|
423
|
+
(p.x - half) | 0,
|
|
424
|
+
(p.y - half) | 0,
|
|
425
|
+
p.size | 0,
|
|
426
|
+
p.size | 0,
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
},
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
// ---- Built-in: MIST -----------------------------------------
|
|
434
|
+
BEHAVIORS.MIST = {
|
|
435
|
+
spriteLogical: SPRITE_LOGICAL_MIST,
|
|
436
|
+
|
|
437
|
+
spawn(p, frame) {
|
|
438
|
+
const cfg = frame.cfg;
|
|
439
|
+
const W = frame.W;
|
|
440
|
+
const H = frame.H;
|
|
441
|
+
p.z = Math.random() * 0.8 + 0.2;
|
|
442
|
+
p.color = Math.random() > 0.9 ? cfg.spark : cfg.colors[(Math.random() * cfg.colors.length) | 0];
|
|
443
|
+
p.spriteCanvas = frame.getSprite(p.color, SPRITE_LOGICAL_MIST);
|
|
444
|
+
p.life = 0;
|
|
445
|
+
p.x = Math.random() * W;
|
|
446
|
+
p.y = Math.random() * H;
|
|
447
|
+
p.anchorX = p.x;
|
|
448
|
+
p.anchorY = p.y;
|
|
449
|
+
p.size = cfg.size * (0.6 + p.z * 0.4);
|
|
450
|
+
p.pulseOffset = (Math.random() * 360) | 0;
|
|
451
|
+
p.maxAlpha = cfg.alpha * p.z;
|
|
452
|
+
p.vx = 0;
|
|
453
|
+
p.vy = 0;
|
|
454
|
+
p.decay = 0;
|
|
455
|
+
},
|
|
456
|
+
|
|
457
|
+
tick(particles, ctx, frame) {
|
|
458
|
+
const cfg = frame.cfg;
|
|
459
|
+
const wind = cfg.wind;
|
|
460
|
+
const turbFactor = cfg.turbulence;
|
|
461
|
+
const ds = frame.ds;
|
|
462
|
+
const dt = frame.dt;
|
|
463
|
+
const W = frame.W;
|
|
464
|
+
const H = frame.H;
|
|
465
|
+
const margin = cfg.size + 100;
|
|
466
|
+
for (let i = 0; i < particles.length; i++) {
|
|
467
|
+
const p = particles[i];
|
|
468
|
+
// Accumulator: ms since spawn, wrapped to keep breath phase stable.
|
|
469
|
+
p.life += dt;
|
|
470
|
+
if (p.life > MIST_LIFE_WRAP_MS) p.life -= MIST_LIFE_WRAP_MS;
|
|
471
|
+
const turbIdx = (p.anchorY * 0.5) | 0;
|
|
472
|
+
const turb = sinLut(turbIdx) * turbFactor;
|
|
473
|
+
const moveX = (wind.x + turb) * p.z * ds;
|
|
474
|
+
const moveY = wind.y * p.z * ds;
|
|
475
|
+
p.anchorX += moveX;
|
|
476
|
+
p.anchorY += moveY;
|
|
477
|
+
if (p.anchorX > W + margin) p.anchorX = -margin;
|
|
478
|
+
else if (p.anchorX < -margin) p.anchorX = W + margin;
|
|
479
|
+
if (p.anchorY > H + margin) p.anchorY = -margin;
|
|
480
|
+
else if (p.anchorY < -margin) p.anchorY = H + margin;
|
|
481
|
+
const timeIdx = (p.life * 0.05) | 0;
|
|
482
|
+
const breath = (sinLut(timeIdx + p.pulseOffset) + 1) * 0.5;
|
|
483
|
+
const alpha = 0.05 + breath * p.maxAlpha;
|
|
484
|
+
const drawSize = p.size * (0.9 + breath * 0.2);
|
|
485
|
+
if (alpha > ALPHA_EPSILON) {
|
|
486
|
+
const a = alpha > 1 ? 1 : alpha;
|
|
487
|
+
ctx.globalAlpha = a;
|
|
488
|
+
const half = drawSize * 0.5;
|
|
489
|
+
ctx.drawImage(
|
|
490
|
+
p.spriteCanvas,
|
|
491
|
+
(p.anchorX - half) | 0,
|
|
492
|
+
(p.anchorY - half) | 0,
|
|
493
|
+
drawSize | 0,
|
|
494
|
+
drawSize | 0,
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
},
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
// ---- Built-in: FLOAT ----------------------------------------
|
|
502
|
+
BEHAVIORS.FLOAT = {
|
|
503
|
+
spriteLogical: SPRITE_LOGICAL_CORE,
|
|
504
|
+
|
|
505
|
+
spawn(p, frame) {
|
|
506
|
+
const cfg = frame.cfg;
|
|
507
|
+
const W = frame.W;
|
|
508
|
+
const H = frame.H;
|
|
509
|
+
p.z = Math.random() * 0.8 + 0.2;
|
|
510
|
+
p.color = Math.random() > 0.9 ? cfg.spark : cfg.colors[(Math.random() * cfg.colors.length) | 0];
|
|
511
|
+
p.spriteCanvas = frame.getSprite(p.color, SPRITE_LOGICAL_CORE);
|
|
512
|
+
p.life = 0;
|
|
513
|
+
p.x = Math.random() * W;
|
|
514
|
+
p.y = frame.isInit ? Math.random() * H : H + 20;
|
|
515
|
+
p.size = (Math.random() * cfg.size + 4) * p.z;
|
|
516
|
+
p.vx = 0;
|
|
517
|
+
p.vy = cfg.speed * (Math.random() * 0.5 + 0.5);
|
|
518
|
+
p.decay = cfg.decay;
|
|
519
|
+
p.maxAlpha = cfg.alpha * p.z;
|
|
520
|
+
p.anchorX = 0;
|
|
521
|
+
p.anchorY = 0;
|
|
522
|
+
p.pulseOffset = 0;
|
|
523
|
+
},
|
|
524
|
+
|
|
525
|
+
tick(particles, ctx, frame) {
|
|
526
|
+
const cfg = frame.cfg;
|
|
527
|
+
const wind = cfg.wind;
|
|
528
|
+
const ds = frame.ds;
|
|
529
|
+
const respawn = frame.respawn;
|
|
530
|
+
for (let i = 0; i < particles.length; i++) {
|
|
531
|
+
const p = particles[i];
|
|
532
|
+
const moveY = (p.vy + wind.y) * p.z * ds;
|
|
533
|
+
p.y -= moveY;
|
|
534
|
+
p.x += Math.sin(p.y * 0.05) * 0.5;
|
|
535
|
+
p.life += p.decay * ds;
|
|
536
|
+
if (p.y < -RESPAWN_MARGIN_Y || p.life >= 1) { respawn(p, false); continue; }
|
|
537
|
+
let alpha;
|
|
538
|
+
if (p.life < 0.1) alpha = p.life * FLOAT_FADE_INV * p.maxAlpha;
|
|
539
|
+
else if (p.life > 0.9) alpha = (1 - p.life) * FLOAT_FADE_INV * p.maxAlpha;
|
|
540
|
+
else alpha = p.maxAlpha;
|
|
541
|
+
if (alpha > ALPHA_EPSILON) {
|
|
542
|
+
const a = alpha > 1 ? 1 : alpha;
|
|
543
|
+
ctx.globalAlpha = a;
|
|
544
|
+
const half = p.size * 0.5;
|
|
545
|
+
ctx.drawImage(
|
|
546
|
+
p.spriteCanvas,
|
|
547
|
+
(p.x - half) | 0,
|
|
548
|
+
(p.y - half) | 0,
|
|
549
|
+
p.size | 0,
|
|
550
|
+
p.size | 0,
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
},
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
// ---- Built-in: CHAOS ----------------------------------------
|
|
558
|
+
BEHAVIORS.CHAOS = {
|
|
559
|
+
spriteLogical: SPRITE_LOGICAL_CORE,
|
|
560
|
+
|
|
561
|
+
spawn(p, frame) {
|
|
562
|
+
const cfg = frame.cfg;
|
|
563
|
+
const W = frame.W;
|
|
564
|
+
const H = frame.H;
|
|
565
|
+
p.z = Math.random() * 0.8 + 0.2;
|
|
566
|
+
p.color = Math.random() > 0.9 ? cfg.spark : cfg.colors[(Math.random() * cfg.colors.length) | 0];
|
|
567
|
+
p.spriteCanvas = frame.getSprite(p.color, SPRITE_LOGICAL_CORE);
|
|
568
|
+
p.life = 0;
|
|
569
|
+
p.x = Math.random() * W;
|
|
570
|
+
p.y = Math.random() * H;
|
|
571
|
+
p.size = Math.random() * cfg.size + 1;
|
|
572
|
+
p.vx = (Math.random() - 0.5) * cfg.speed * 2;
|
|
573
|
+
p.vy = (Math.random() - 0.5) * cfg.speed * 2;
|
|
574
|
+
p.decay = cfg.decay;
|
|
575
|
+
p.maxAlpha = cfg.alpha;
|
|
576
|
+
p.anchorX = 0;
|
|
577
|
+
p.anchorY = 0;
|
|
578
|
+
p.pulseOffset = 0;
|
|
579
|
+
},
|
|
580
|
+
|
|
581
|
+
tick(particles, ctx, frame) {
|
|
582
|
+
const ds = frame.ds;
|
|
583
|
+
const respawn = frame.respawn;
|
|
584
|
+
const W = frame.W;
|
|
585
|
+
const H = frame.H;
|
|
586
|
+
// Math.trunc avoids the 32-bit signed cast that `timestamp >> 7`
|
|
587
|
+
// does; safe over long sessions (`performance.now()` past ~24 days).
|
|
588
|
+
const phase = Math.trunc(frame.timestamp / 128);
|
|
589
|
+
for (let i = 0; i < particles.length; i++) {
|
|
590
|
+
const p = particles[i];
|
|
591
|
+
p.x += p.vx * ds;
|
|
592
|
+
p.y += p.vy * ds;
|
|
593
|
+
p.life += p.decay * ds;
|
|
594
|
+
if (p.life >= 1 || p.x < 0 || p.x > W || p.y < 0 || p.y > H) {
|
|
595
|
+
respawn(p, false);
|
|
596
|
+
continue;
|
|
597
|
+
}
|
|
598
|
+
const flickerBit = (phase + p.id) & 1;
|
|
599
|
+
const alpha = flickerBit ? p.maxAlpha : p.maxAlpha * 0.3;
|
|
600
|
+
if (alpha > ALPHA_EPSILON) {
|
|
601
|
+
const a = alpha > 1 ? 1 : alpha;
|
|
602
|
+
ctx.globalAlpha = a;
|
|
603
|
+
const half = p.size * 0.5;
|
|
604
|
+
ctx.drawImage(
|
|
605
|
+
p.spriteCanvas,
|
|
606
|
+
(p.x - half) | 0,
|
|
607
|
+
(p.y - half) | 0,
|
|
608
|
+
p.size | 0,
|
|
609
|
+
p.size | 0,
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
},
|
|
614
|
+
};
|
|
615
|
+
|
|
616
|
+
// ============================================================
|
|
617
|
+
// AMBIENT FX RENDERER
|
|
618
|
+
// ============================================================
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* @typedef {Object} AmbientOptions
|
|
622
|
+
* @property {string} [theme='Fire'] Preset name from THEMES.
|
|
623
|
+
* @property {Object} [overrides] Partial config to merge over the theme.
|
|
624
|
+
* @property {boolean} [autoStart=true] Start the RAF loop immediately.
|
|
625
|
+
*/
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Create a fullscreen ambient particle atmosphere on the given canvas.
|
|
629
|
+
*
|
|
630
|
+
* @param {HTMLCanvasElement} canvas
|
|
631
|
+
* @param {AmbientOptions} [options]
|
|
632
|
+
*/
|
|
633
|
+
export function createAmbientFX(canvas, options) {
|
|
634
|
+
if (!canvas || typeof canvas.getContext !== 'function') {
|
|
635
|
+
throw new TypeError('AmbientFX: first argument must be an HTMLCanvasElement');
|
|
636
|
+
}
|
|
637
|
+
const opts = options || {};
|
|
638
|
+
const themeName = opts.theme || 'Fire';
|
|
639
|
+
const themeBase = THEMES[themeName];
|
|
640
|
+
if (!themeBase) throw new RangeError('AmbientFX: unknown theme "' + themeName + '"');
|
|
641
|
+
|
|
642
|
+
const ctx = canvas.getContext('2d', { alpha: true });
|
|
643
|
+
if (!ctx) throw new Error('AmbientFX: 2d context unavailable on this canvas');
|
|
644
|
+
|
|
645
|
+
let cfg = validateConfig(mergeThemeConfig(themeBase, opts.overrides));
|
|
646
|
+
let currentThemeName = themeName;
|
|
647
|
+
|
|
648
|
+
// Monomorphic particle pool.
|
|
649
|
+
/** @type {Array<Object>} */
|
|
650
|
+
const particles = [];
|
|
651
|
+
|
|
652
|
+
// Viewport state (logical CSS pixels).
|
|
653
|
+
let W = 0;
|
|
654
|
+
let H = 0;
|
|
655
|
+
let dpr = 1;
|
|
656
|
+
|
|
657
|
+
// Loop state.
|
|
658
|
+
let lastTime = -1;
|
|
659
|
+
let raf = null;
|
|
660
|
+
let running = false;
|
|
661
|
+
let destroyed = false;
|
|
662
|
+
|
|
663
|
+
// Pooled frame context. Allocated once, mutated per frame/spawn.
|
|
664
|
+
// Behaviors receive this and MUST NOT retain references.
|
|
665
|
+
const frame = {
|
|
666
|
+
cfg,
|
|
667
|
+
W: 0,
|
|
668
|
+
H: 0,
|
|
669
|
+
dt: 0,
|
|
670
|
+
ds: 0,
|
|
671
|
+
timestamp: 0,
|
|
672
|
+
isInit: false,
|
|
673
|
+
getSprite(color, logicalSize) {
|
|
674
|
+
return getSprite(color, logicalSize, dpr);
|
|
675
|
+
},
|
|
676
|
+
respawn(p, isInit) {
|
|
677
|
+
resetParticle(p, isInit);
|
|
678
|
+
},
|
|
679
|
+
};
|
|
680
|
+
|
|
681
|
+
function resize() {
|
|
682
|
+
const prevW = W;
|
|
683
|
+
const prevH = H;
|
|
684
|
+
W = canvas.clientWidth || canvas.width || 1;
|
|
685
|
+
H = canvas.clientHeight || canvas.height || 1;
|
|
686
|
+
const newDpr = window.devicePixelRatio || 1;
|
|
687
|
+
canvas.width = (W * newDpr) | 0;
|
|
688
|
+
canvas.height = (H * newDpr) | 0;
|
|
689
|
+
ctx.setTransform(newDpr, 0, 0, newDpr, 0, 0);
|
|
690
|
+
|
|
691
|
+
// Preserve particle positions across resize: rescale proportionally.
|
|
692
|
+
if (prevW > 0 && prevH > 0 && (prevW !== W || prevH !== H)) {
|
|
693
|
+
const sx = W / prevW;
|
|
694
|
+
const sy = H / prevH;
|
|
695
|
+
for (let i = 0; i < particles.length; i++) {
|
|
696
|
+
const p = particles[i];
|
|
697
|
+
p.x *= sx;
|
|
698
|
+
p.y *= sy;
|
|
699
|
+
p.anchorX *= sx;
|
|
700
|
+
p.anchorY *= sy;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// If DPR actually changed, sprites at the old physical size are
|
|
705
|
+
// stale. Re-prime at the new size; the old bucket is dropped on
|
|
706
|
+
// next color/theme change.
|
|
707
|
+
const dprChanged = newDpr !== dpr;
|
|
708
|
+
dpr = newDpr;
|
|
709
|
+
if (dprChanged) primeSprites();
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function primeSprites() {
|
|
713
|
+
const behavior = resolveBehavior(cfg.behavior);
|
|
714
|
+
const palette = uniqueColors(cfg);
|
|
715
|
+
for (let i = 0; i < palette.length; i++) {
|
|
716
|
+
getSprite(palette[i], behavior.spriteLogical, dpr);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function uniqueColors(c) {
|
|
721
|
+
const seen = new Set();
|
|
722
|
+
const out = [];
|
|
723
|
+
for (let i = 0; i < c.colors.length; i++) {
|
|
724
|
+
const col = c.colors[i];
|
|
725
|
+
if (!seen.has(col)) { seen.add(col); out.push(col); }
|
|
726
|
+
}
|
|
727
|
+
if (!seen.has(c.spark)) out.push(c.spark);
|
|
728
|
+
return out;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
function resetParticle(p, isInit) {
|
|
732
|
+
const behavior = resolveBehavior(cfg.behavior);
|
|
733
|
+
frame.cfg = cfg;
|
|
734
|
+
frame.W = W;
|
|
735
|
+
frame.H = H;
|
|
736
|
+
frame.isInit = isInit;
|
|
737
|
+
behavior.spawn(p, frame);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function initParticles() {
|
|
741
|
+
const n = cfg.count | 0;
|
|
742
|
+
// Grow the pool: every push uses makeParticle to pin the hidden
|
|
743
|
+
// class before any spawn writes to it.
|
|
744
|
+
while (particles.length < n) particles.push(makeParticle(particles.length));
|
|
745
|
+
if (particles.length > n) particles.length = n;
|
|
746
|
+
for (let i = 0; i < n; i++) {
|
|
747
|
+
particles[i].id = i;
|
|
748
|
+
resetParticle(particles[i], true);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function loop(timestamp) {
|
|
753
|
+
if (destroyed || !running) return;
|
|
754
|
+
raf = requestAnimationFrame(loop);
|
|
755
|
+
|
|
756
|
+
if (lastTime < 0) { lastTime = timestamp; return; }
|
|
757
|
+
const dt = Math.min(timestamp - lastTime, DT_CLAMP_MS);
|
|
758
|
+
lastTime = timestamp;
|
|
759
|
+
if (dt < 1) return;
|
|
760
|
+
|
|
761
|
+
const behavior = resolveBehavior(cfg.behavior);
|
|
762
|
+
|
|
763
|
+
// Populate the pooled frame context. No allocation.
|
|
764
|
+
frame.cfg = cfg;
|
|
765
|
+
frame.W = W;
|
|
766
|
+
frame.H = H;
|
|
767
|
+
frame.dt = dt;
|
|
768
|
+
frame.ds = dt / DT_REF_MS;
|
|
769
|
+
frame.timestamp = timestamp;
|
|
770
|
+
frame.isInit = false;
|
|
771
|
+
|
|
772
|
+
ctx.clearRect(0, 0, W, H);
|
|
773
|
+
behavior.tick(particles, ctx, frame);
|
|
774
|
+
ctx.globalAlpha = 1;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function onVisibility() {
|
|
778
|
+
if (!document.hidden) lastTime = -1;
|
|
779
|
+
}
|
|
780
|
+
document.addEventListener('visibilitychange', onVisibility);
|
|
781
|
+
|
|
782
|
+
let ro = null;
|
|
783
|
+
let resizeScheduled = false;
|
|
784
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
785
|
+
ro = new ResizeObserver(() => {
|
|
786
|
+
if (resizeScheduled || destroyed) return;
|
|
787
|
+
resizeScheduled = true;
|
|
788
|
+
requestAnimationFrame(() => {
|
|
789
|
+
resizeScheduled = false;
|
|
790
|
+
if (!destroyed) resize();
|
|
791
|
+
});
|
|
792
|
+
});
|
|
793
|
+
ro.observe(canvas.parentElement || canvas);
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// Boot.
|
|
797
|
+
resize();
|
|
798
|
+
primeSprites();
|
|
799
|
+
initParticles();
|
|
800
|
+
if (opts.autoStart !== false) {
|
|
801
|
+
running = true;
|
|
802
|
+
raf = requestAnimationFrame(loop);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
return {
|
|
806
|
+
setTheme(name) {
|
|
807
|
+
if (destroyed) return;
|
|
808
|
+
const base = THEMES[name];
|
|
809
|
+
if (!base) throw new RangeError('AmbientFX: unknown theme "' + name + '"');
|
|
810
|
+
const prevColors = uniqueColors(cfg);
|
|
811
|
+
cfg = validateConfig(mergeThemeConfig(base, null));
|
|
812
|
+
currentThemeName = name;
|
|
813
|
+
clearAmbientSpriteCache(prevColors);
|
|
814
|
+
primeSprites();
|
|
815
|
+
initParticles();
|
|
816
|
+
},
|
|
817
|
+
|
|
818
|
+
updateConfig(overrides) {
|
|
819
|
+
if (destroyed || !overrides) return;
|
|
820
|
+
const prevColors = uniqueColors(cfg);
|
|
821
|
+
const prevBehavior = cfg.behavior;
|
|
822
|
+
cfg = validateConfig(mergeThemeConfig(cfg, overrides));
|
|
823
|
+
const behaviorChanged = cfg.behavior !== prevBehavior;
|
|
824
|
+
if (overrides.colors !== undefined || overrides.spark !== undefined || behaviorChanged) {
|
|
825
|
+
clearAmbientSpriteCache(prevColors);
|
|
826
|
+
primeSprites();
|
|
827
|
+
}
|
|
828
|
+
if (overrides.count !== undefined || behaviorChanged) initParticles();
|
|
829
|
+
},
|
|
830
|
+
|
|
831
|
+
get config() {
|
|
832
|
+
return { ...cfg, wind: { ...cfg.wind } };
|
|
833
|
+
},
|
|
834
|
+
|
|
835
|
+
get theme() { return currentThemeName; },
|
|
836
|
+
|
|
837
|
+
get count() { return particles.length; },
|
|
838
|
+
|
|
839
|
+
get running() { return running; },
|
|
840
|
+
|
|
841
|
+
pause() {
|
|
842
|
+
if (!running || destroyed) return;
|
|
843
|
+
running = false;
|
|
844
|
+
if (raf !== null) { cancelAnimationFrame(raf); raf = null; }
|
|
845
|
+
},
|
|
846
|
+
|
|
847
|
+
resume() {
|
|
848
|
+
if (running || destroyed) return;
|
|
849
|
+
running = true;
|
|
850
|
+
lastTime = -1;
|
|
851
|
+
raf = requestAnimationFrame(loop);
|
|
852
|
+
},
|
|
853
|
+
|
|
854
|
+
destroy() {
|
|
855
|
+
if (destroyed) return;
|
|
856
|
+
destroyed = true;
|
|
857
|
+
running = false;
|
|
858
|
+
if (raf !== null) { cancelAnimationFrame(raf); raf = null; }
|
|
859
|
+
document.removeEventListener('visibilitychange', onVisibility);
|
|
860
|
+
if (ro !== null) { ro.disconnect(); ro = null; }
|
|
861
|
+
const palette = uniqueColors(cfg);
|
|
862
|
+
clearAmbientSpriteCache(palette);
|
|
863
|
+
particles.length = 0;
|
|
864
|
+
},
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
export default createAmbientFX;
|