@godot-scene-web/effects 0.1.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.
@@ -0,0 +1,757 @@
1
+ //#region src/particles/godot-renderer.ts
2
+ /** The default backend: Godot's own default for a new project. */
3
+ const DEFAULT_GODOT_RENDERER = "forward_plus";
4
+ /** Coerce an untrusted value (a hand-authored spec blob, a consumer option) to a backend. */
5
+ function normalizeGodotRenderer(value) {
6
+ return value === "mobile" || value === "gl_compatibility" || value === "forward_plus" ? value : DEFAULT_GODOT_RENDERER;
7
+ }
8
+ /**
9
+ * True when the backend runs `ParticleProcessMaterial.color` through
10
+ * `Color::srgb_to_linear()` at UBO-upload time. Both RendererRD backends do;
11
+ * Compatibility/GLES3 does not.
12
+ */
13
+ function linearizesParticleColor(renderer) {
14
+ return renderer !== "gl_compatibility";
15
+ }
16
+ /**
17
+ * Godot's `Color::srgb_to_linear()`, one channel — `core/math/color.h:191-197`:
18
+ *
19
+ * ```cpp
20
+ * r < 0.04045f ? r * (1.0f / 12.92f)
21
+ * : Math::pow(float((r + 0.055) * (1.0 / (1.0 + 0.055))), 2.4f)
22
+ * ```
23
+ *
24
+ * The piecewise IEC 61966-2-1 curve with exponent **2.4**, not a `pow(x, 2.2)`
25
+ * approximation, and the threshold comparison is strict `<`. Written here the way Godot
26
+ * writes it — reciprocal multiplies rather than divides — so the two read the same;
27
+ * Godot evaluates the inner expression in double and then narrows to float before
28
+ * `pow`, while JS stays in double throughout, a difference orders of magnitude below
29
+ * the 1/255 the comparison is made at.
30
+ *
31
+ * Note the fixed point at both ends: `srgbToLinear(0) === 0` and `srgbToLinear(1) === 1`.
32
+ * A fully saturated channel is where this curve is the identity, which is why it is the
33
+ * per-channel regression check on the parity fixture (see `docs/parity.md`).
34
+ */
35
+ function srgbToLinear(value) {
36
+ return value < .04045 ? value * (1 / 12.92) : ((value + .055) * (1 / 1.055)) ** 2.4;
37
+ }
38
+ /**
39
+ * A particle system's base colour as the given backend uploads it: RGB through
40
+ * {@link srgbToLinear} on the RendererRD backends, returned unchanged on
41
+ * Compatibility/GLES3.
42
+ *
43
+ * `fromProcessMaterial` is the scope gate. Only `ParticleProcessMaterial.color` — a
44
+ * `GPUParticles2D` with a process material — rides the UBO path that linearizes. When the
45
+ * base colour instead came from `CPUParticles2D.color` or the node's `modulate`, it reaches
46
+ * the canvas by a route Godot leaves in sRGB whatever the backend, and this returns it
47
+ * untouched.
48
+ *
49
+ * **ALPHA IS NEVER TOUCHED.** `Color::srgb_to_linear()` passes `a` straight through
50
+ * (`color.h:196`), so the coverage the blend algebra runs on is the authored value on
51
+ * every backend and every path.
52
+ *
53
+ * Always returns a fresh tuple, so callers never alias the raw colour they passed in.
54
+ */
55
+ function linearizeParticleBaseColor(color, renderer, fromProcessMaterial) {
56
+ return fromProcessMaterial && linearizesParticleColor(renderer) ? [
57
+ srgbToLinear(color[0]),
58
+ srgbToLinear(color[1]),
59
+ srgbToLinear(color[2]),
60
+ color[3]
61
+ ] : [
62
+ color[0],
63
+ color[1],
64
+ color[2],
65
+ color[3]
66
+ ];
67
+ }
68
+ //#endregion
69
+ //#region src/particles/instance-buffer.ts
70
+ /** Floats per particle render instance: center.xy, scale.xy, rotation, color.rgba, frame. */
71
+ const INSTANCE_STRIDE = 10;
72
+ /**
73
+ * CPU-owned packed particle instances. This class deliberately knows nothing about
74
+ * WebGL, WebGPU, a canvas, or a device lifecycle; render adapters own residency.
75
+ */
76
+ var InstanceBuffer = class {
77
+ data;
78
+ /** Number of instances written since the last reset. */
79
+ count = 0;
80
+ capacity;
81
+ constructor(initialCapacity = 256) {
82
+ this.capacity = Math.max(1, initialCapacity);
83
+ this.data = new Float32Array(this.capacity * 10);
84
+ }
85
+ reset() {
86
+ this.count = 0;
87
+ }
88
+ push(x, y, scaleX, scaleY, rotation, r, g, b, a, frame) {
89
+ this.ensureCapacity(this.count + 1);
90
+ const offset = this.count * 10;
91
+ const data = this.data;
92
+ data[offset] = x;
93
+ data[offset + 1] = y;
94
+ data[offset + 2] = scaleX;
95
+ data[offset + 3] = scaleY;
96
+ data[offset + 4] = rotation;
97
+ data[offset + 5] = r;
98
+ data[offset + 6] = g;
99
+ data[offset + 7] = b;
100
+ data[offset + 8] = a;
101
+ data[offset + 9] = frame;
102
+ this.count += 1;
103
+ }
104
+ ensureCapacity(instances) {
105
+ if (instances <= this.capacity) return;
106
+ let next = this.capacity;
107
+ while (next < instances) next *= 2;
108
+ const grown = new Float32Array(next * 10);
109
+ grown.set(this.data);
110
+ this.data = grown;
111
+ this.capacity = next;
112
+ }
113
+ };
114
+ //#endregion
115
+ //#region src/particles/pack-instances.ts
116
+ /** The sprite-sheet grid used for a texture; untextured particles always have one frame. */
117
+ function frameGridFor(textured, hframes, vframes) {
118
+ return textured ? [Math.max(1, hframes), Math.max(1, vframes)] : [1, 1];
119
+ }
120
+ /** Pack live particle state into a caller-owned, reusable GPU instance buffer. */
121
+ function packParticleInstances(input) {
122
+ const { state, config, instances } = input;
123
+ const originX = input.origin?.[0] ?? 0;
124
+ const originY = input.origin?.[1] ?? 0;
125
+ const frameW = config.flipbookCropOnly ? input.textureWidth : input.textureWidth / Math.max(1, config.hframes);
126
+ const frameH = config.flipbookCropOnly ? input.textureHeight : input.textureHeight / Math.max(1, config.vframes);
127
+ const transform = input.transform;
128
+ const modulate = input.modulate;
129
+ const xx = transform?.xx ?? 1, xy = transform?.xy ?? 0, yx = transform?.yx ?? 0, yy = transform?.yy ?? 1;
130
+ const tx = transform?.originX ?? 0, ty = transform?.originY ?? 0;
131
+ const scale = transform?.scale ?? 1, rotation = transform?.rotation ?? 0;
132
+ const mr = modulate?.[0] ?? 1, mg = modulate?.[1] ?? 1, mb = modulate?.[2] ?? 1, ma = modulate?.[3] ?? 1;
133
+ instances.reset();
134
+ for (let i = 0; i < state.particles.length; i += 1) {
135
+ const p = state.particles[i];
136
+ if (!(p.active && p.a > 0)) continue;
137
+ const localX = originX + p.x, localY = originY + p.y;
138
+ instances.push(xx * localX + yx * localY + tx, xy * localX + yy * localY + ty, Math.max(0, frameW * p.scaleX * scale), Math.max(0, frameH * p.scaleY * scale), p.rotation + rotation, p.r * mr, p.g * mg, p.b * mb, p.a * ma, p.frame);
139
+ }
140
+ return instances.count;
141
+ }
142
+ //#endregion
143
+ //#region src/particles/sampling.ts
144
+ function lerp$1(a, b, t) {
145
+ return a + (b - a) * t;
146
+ }
147
+ /** Godot-style endpoint-clamped gradient sampling. */
148
+ function sampleParticleGradient(stops, t, interpolationMode = 0) {
149
+ if (stops.length === 0) return [
150
+ 0,
151
+ 0,
152
+ 0,
153
+ 1
154
+ ];
155
+ if (t <= stops[0].offset) return stops[0].color;
156
+ const last = stops[stops.length - 1];
157
+ if (t >= last.offset) return last.color;
158
+ for (let index = 0; index < stops.length - 1; index += 1) {
159
+ const a = stops[index];
160
+ const b = stops[index + 1];
161
+ if (t >= a.offset && t <= b.offset) {
162
+ if (interpolationMode === 1) return a.color;
163
+ const fraction = (t - a.offset) / (b.offset - a.offset || 1);
164
+ return [
165
+ lerp$1(a.color[0], b.color[0], fraction),
166
+ lerp$1(a.color[1], b.color[1], fraction),
167
+ lerp$1(a.color[2], b.color[2], fraction),
168
+ lerp$1(a.color[3], b.color[3], fraction)
169
+ ];
170
+ }
171
+ }
172
+ return last.color;
173
+ }
174
+ /** Allocation-free gradient sampling for simulation hot paths. */
175
+ function sampleParticleGradientInto(stops, t, out, interpolationMode = 0) {
176
+ if (stops.length === 0) {
177
+ out[0] = 0;
178
+ out[1] = 0;
179
+ out[2] = 0;
180
+ out[3] = 1;
181
+ return;
182
+ }
183
+ let a = stops[0];
184
+ if (t <= a.offset) {
185
+ out[0] = a.color[0];
186
+ out[1] = a.color[1];
187
+ out[2] = a.color[2];
188
+ out[3] = a.color[3];
189
+ return;
190
+ }
191
+ const last = stops[stops.length - 1];
192
+ if (t >= last.offset) {
193
+ out[0] = last.color[0];
194
+ out[1] = last.color[1];
195
+ out[2] = last.color[2];
196
+ out[3] = last.color[3];
197
+ return;
198
+ }
199
+ for (let index = 0; index < stops.length - 1; index += 1) {
200
+ a = stops[index];
201
+ const b = stops[index + 1];
202
+ if (t >= a.offset && t <= b.offset) {
203
+ const f = interpolationMode === 1 ? 0 : (t - a.offset) / (b.offset - a.offset || 1);
204
+ out[0] = lerp$1(a.color[0], b.color[0], f);
205
+ out[1] = lerp$1(a.color[1], b.color[1], f);
206
+ out[2] = lerp$1(a.color[2], b.color[2], f);
207
+ out[3] = lerp$1(a.color[3], b.color[3], f);
208
+ return;
209
+ }
210
+ }
211
+ out[0] = last.color[0];
212
+ out[1] = last.color[1];
213
+ out[2] = last.color[2];
214
+ out[3] = last.color[3];
215
+ }
216
+ /** Godot-style endpoint-clamped linear curve sampling. */
217
+ function sampleParticleCurve(points, t) {
218
+ if (points.length === 0) return 0;
219
+ if (t <= points[0].x) return points[0].y;
220
+ const last = points[points.length - 1];
221
+ if (t >= last.x) return last.y;
222
+ for (let index = 0; index < points.length - 1; index += 1) {
223
+ const a = points[index];
224
+ const b = points[index + 1];
225
+ if (t >= a.x && t <= b.x) return lerp$1(a.y, b.y, (t - a.x) / (b.x - a.x || 1));
226
+ }
227
+ return last.y;
228
+ }
229
+ function normalizeParticleCurve(points) {
230
+ return points?.filter((point) => Number.isFinite(point.x) && Number.isFinite(point.y)).slice().sort((a, b) => a.x - b.x);
231
+ }
232
+ //#endregion
233
+ //#region src/particles/simulate.ts
234
+ const TAU = Math.PI * 2;
235
+ const DEG2RAD = Math.PI / 180;
236
+ function lerp(a, b, t) {
237
+ return a + (b - a) * t;
238
+ }
239
+ function clamp01$1(value) {
240
+ return value < 0 ? 0 : value > 1 ? 1 : value;
241
+ }
242
+ function randFromSeed(holder) {
243
+ let s = holder.seed | 0;
244
+ if (s === 0) s = 305420679;
245
+ const k = Math.trunc(s / 127773);
246
+ s = 16807 * (s - k * 127773) - 2836 * k;
247
+ if (s < 0) s += 2147483647;
248
+ holder.seed = s >>> 0;
249
+ return holder.seed % 65536 / 65535;
250
+ }
251
+ const spawnRng = { seed: 1 };
252
+ const forceRng = { seed: 1 };
253
+ const displayColor = [
254
+ 1,
255
+ 1,
256
+ 1,
257
+ 1
258
+ ];
259
+ function mixSeed(s) {
260
+ return Math.imul(s >>> 0 ^ 2654435769, 2654435761) >>> 0;
261
+ }
262
+ function hash01(i, seed) {
263
+ let h = Math.imul(i + 1 ^ (seed | 0), 2654435761) >>> 0;
264
+ h ^= h >>> 15;
265
+ h = Math.imul(h, 2246822519) >>> 0;
266
+ h ^= h >>> 13;
267
+ return (h >>> 0) / 4294967296;
268
+ }
269
+ function hueRotateInto(p, r, g, b, angle) {
270
+ const c = Math.cos(angle);
271
+ const s = Math.sin(angle);
272
+ const rr = r * (.299 + .701 * c + .168 * s) + g * (.587 - .587 * c + .33 * s) + b * (.114 - .114 * c - .497 * s);
273
+ const gg = r * (.299 - .299 * c - .328 * s) + g * (.587 + .413 * c + .035 * s) + b * (.114 - .114 * c + .292 * s);
274
+ const bb = r * (.299 - .3 * c + 1.25 * s) + g * (.587 - .588 * c - 1.05 * s) + b * (.114 + .886 * c - .203 * s);
275
+ p.r = rr;
276
+ p.g = gg;
277
+ p.b = bb;
278
+ }
279
+ function sampleEmissionInto(cfg, p) {
280
+ let x = 0;
281
+ let y = 0;
282
+ const shape = cfg.emissionShape;
283
+ if (shape === 1 || shape === 2) {
284
+ const a = randFromSeed(spawnRng) * TAU;
285
+ const r = cfg.emissionSphereRadius * Math.sqrt(randFromSeed(spawnRng));
286
+ x = Math.cos(a) * r;
287
+ y = Math.sin(a) * r;
288
+ } else if (shape === 3) {
289
+ x = (randFromSeed(spawnRng) * 2 - 1) * cfg.emissionBoxExtents[0];
290
+ y = (randFromSeed(spawnRng) * 2 - 1) * cfg.emissionBoxExtents[1];
291
+ } else if (shape === 6) {
292
+ const a = randFromSeed(spawnRng) * TAU;
293
+ const outer = Math.max(cfg.emissionRingRadius, cfg.emissionRingInnerRadius, 1e-4);
294
+ const inner = Math.max(0, Math.min(cfg.emissionRingInnerRadius, outer));
295
+ const r = Math.sqrt(randFromSeed(spawnRng) * (outer * outer - inner * inner) + inner * inner);
296
+ x = Math.cos(a) * r;
297
+ y = Math.sin(a) * r;
298
+ }
299
+ p.x = x * cfg.emissionScale[0] + cfg.emissionOffset[0];
300
+ p.y = y * cfg.emissionScale[1] + cfg.emissionOffset[1];
301
+ }
302
+ function restartPhase(cfg, i) {
303
+ let rp = cfg.amount > 0 ? i / cfg.amount : 0;
304
+ if (cfg.randomness > 0) rp += cfg.randomness * hash01(i, cfg.seed) / cfg.amount;
305
+ return rp * (1 - cfg.explosiveness);
306
+ }
307
+ function restartParticle(state, i, cycle) {
308
+ const cfg = state.config;
309
+ const p = state.particles[i];
310
+ const baseSeed = cfg.seed + i * 2 + cycle >>> 0;
311
+ p.seed = baseSeed || 1;
312
+ spawnRng.seed = mixSeed(baseSeed) || 1;
313
+ p.angleRand = randFromSeed(spawnRng);
314
+ p.scaleRand = randFromSeed(spawnRng);
315
+ p.hueRand = randFromSeed(spawnRng);
316
+ p.animOffsetRand = randFromSeed(spawnRng);
317
+ if (cfg.colorInitialRamp?.length) sampleParticleGradientInto(cfg.colorInitialRamp, randFromSeed(spawnRng), p.startColor);
318
+ else {
319
+ p.startColor[0] = 1;
320
+ p.startColor[1] = 1;
321
+ p.startColor[2] = 1;
322
+ p.startColor[3] = 1;
323
+ }
324
+ const angle = Math.atan2(cfg.direction[1], cfg.direction[0]) + (randFromSeed(spawnRng) * 2 - 1) * cfg.spread * DEG2RAD;
325
+ const speed = lerp(cfg.initialVelocityMin, cfg.initialVelocityMax, randFromSeed(spawnRng));
326
+ p.vx = Math.cos(angle) * speed;
327
+ p.vy = Math.sin(angle) * speed;
328
+ p.rotation = lerp(cfg.angleMin, cfg.angleMax, p.angleRand) * DEG2RAD;
329
+ if (cfg.alignY) {
330
+ if (Math.hypot(p.vx, p.vy) > 0) p.rotation = Math.atan2(p.vy, p.vx) + Math.PI / 2;
331
+ }
332
+ p.lifetime = Math.max(.01, cfg.lifetime * (1 - randFromSeed(spawnRng) * cfg.lifetimeRandomness));
333
+ sampleEmissionInto(cfg, p);
334
+ p.time = 0;
335
+ p.active = true;
336
+ updateDisplay(cfg, p, 0);
337
+ }
338
+ function updateDisplay(cfg, p, tv) {
339
+ let sx = 1;
340
+ let sy = 1;
341
+ if (cfg.scaleCurveX && cfg.scaleCurveX.length > 0 || cfg.scaleCurveY && cfg.scaleCurveY.length > 0) {
342
+ sx = cfg.scaleCurveX?.length ? sampleParticleCurve(cfg.scaleCurveX, tv) : 1;
343
+ sy = cfg.scaleCurveY?.length ? sampleParticleCurve(cfg.scaleCurveY, tv) : 1;
344
+ } else if (cfg.scaleCurve && cfg.scaleCurve.length > 0) {
345
+ sx = sampleParticleCurve(cfg.scaleCurve, tv);
346
+ sy = sx;
347
+ }
348
+ const base = lerp(cfg.scaleMin, cfg.scaleMax, p.scaleRand);
349
+ p.scaleX = Math.max(1e-5, sx * base);
350
+ p.scaleY = Math.max(1e-5, sy * base);
351
+ if (cfg.colorRamp?.length) sampleParticleGradientInto(cfg.colorRamp, tv, displayColor);
352
+ else {
353
+ displayColor[0] = 1;
354
+ displayColor[1] = 1;
355
+ displayColor[2] = 1;
356
+ displayColor[3] = 1;
357
+ }
358
+ let r = displayColor[0];
359
+ let g = displayColor[1];
360
+ let b = displayColor[2];
361
+ let a = displayColor[3];
362
+ r *= cfg.baseColorRender[0];
363
+ g *= cfg.baseColorRender[1];
364
+ b *= cfg.baseColorRender[2];
365
+ a *= cfg.baseColorRender[3];
366
+ if (cfg.alphaCurve && cfg.alphaCurve.length > 0) a *= sampleParticleCurve(cfg.alphaCurve, tv);
367
+ const hueMag = lerp(cfg.hueVariationMin, cfg.hueVariationMax, p.hueRand) * (cfg.hueCurve && cfg.hueCurve.length > 0 ? sampleParticleCurve(cfg.hueCurve, tv) : 1);
368
+ if (hueMag !== 0) {
369
+ hueRotateInto(p, r, g, b, hueMag * TAU);
370
+ r = p.r;
371
+ g = p.g;
372
+ b = p.b;
373
+ }
374
+ p.r = r * p.startColor[0];
375
+ p.g = g * p.startColor[1];
376
+ p.b = b * p.startColor[2];
377
+ p.a = a * p.startColor[3];
378
+ const cells = cfg.hframes * cfg.vframes;
379
+ const total = cfg.frameCount && cfg.frameCount > 0 ? cfg.frameCount : cells;
380
+ if (cells > 1) {
381
+ const animSpeed = lerp(cfg.animSpeedMin, cfg.animSpeedMax, p.animOffsetRand);
382
+ const phase = lerp(cfg.animOffsetMin, cfg.animOffsetMax, p.animOffsetRand) + tv * animSpeed;
383
+ const f = cfg.animLoop ? phase - Math.floor(phase) : clamp01$1(phase);
384
+ p.frame = Math.min(total - 1, Math.max(0, Math.floor(f * total))) % cells;
385
+ } else p.frame = 0;
386
+ }
387
+ function integrate(cfg, p, dt) {
388
+ p.time += dt;
389
+ if (p.time >= p.lifetime) {
390
+ p.active = false;
391
+ return;
392
+ }
393
+ const tv = p.time / p.lifetime;
394
+ forceRng.seed = p.seed;
395
+ let fx = cfg.gravity[0];
396
+ let fy = cfg.gravity[1];
397
+ const speed = Math.hypot(p.vx, p.vy);
398
+ const la = lerp(cfg.linearAccelMin, cfg.linearAccelMax, randFromSeed(forceRng));
399
+ if (speed > 0 && la !== 0) {
400
+ fx += p.vx / speed * la;
401
+ fy += p.vy / speed * la;
402
+ }
403
+ const dlen = Math.hypot(p.x, p.y);
404
+ const ra = lerp(cfg.radialAccelMin, cfg.radialAccelMax, randFromSeed(forceRng));
405
+ if (dlen > 0 && ra !== 0) {
406
+ fx += p.x / dlen * ra;
407
+ fy += p.y / dlen * ra;
408
+ }
409
+ const ta = lerp(cfg.tangentialAccelMin, cfg.tangentialAccelMax, randFromSeed(forceRng));
410
+ if (dlen > 0 && ta !== 0) {
411
+ fx += -p.y / dlen * ta;
412
+ fy += p.x / dlen * ta;
413
+ }
414
+ p.vx += fx * dt;
415
+ p.vy += fy * dt;
416
+ const orbit = lerp(cfg.orbitVelocityMin, cfg.orbitVelocityMax, randFromSeed(forceRng));
417
+ if (orbit !== 0) {
418
+ const a = -orbit * dt * TAU;
419
+ const cos = Math.cos(a);
420
+ const sin = Math.sin(a);
421
+ const nx = p.x * cos - p.y * sin;
422
+ const ny = p.x * sin + p.y * cos;
423
+ p.x = nx;
424
+ p.y = ny;
425
+ }
426
+ const damp = lerp(cfg.dampingMin, cfg.dampingMax, randFromSeed(forceRng));
427
+ if (damp > 0) {
428
+ const cur = Math.hypot(p.vx, p.vy);
429
+ if (cur > 0) {
430
+ const dec = cfg.dampingAsFriction ? cur * damp * .05 * dt : damp * dt;
431
+ const v = Math.max(0, cur - dec);
432
+ p.vx = p.vx / cur * v;
433
+ p.vy = p.vy / cur * v;
434
+ }
435
+ }
436
+ const av = lerp(cfg.angularVelocityMin, cfg.angularVelocityMax, randFromSeed(forceRng));
437
+ p.rotation = (lerp(cfg.angleMin, cfg.angleMax, p.angleRand) + p.time * av) * DEG2RAD;
438
+ p.x += p.vx * dt;
439
+ p.y += p.vy * dt;
440
+ if (cfg.alignY) {
441
+ if (Math.hypot(p.vx, p.vy) > 0) p.rotation = Math.atan2(p.vy, p.vx) + Math.PI / 2;
442
+ }
443
+ updateDisplay(cfg, p, tv);
444
+ }
445
+ function step(state, dt) {
446
+ const cfg = state.config;
447
+ const lifetime = cfg.lifetime;
448
+ const prevTime = state.time;
449
+ let time = prevTime + dt;
450
+ if (time >= lifetime) {
451
+ const cycles = Math.floor(time / lifetime);
452
+ state.cycle += cycles;
453
+ time -= cycles * lifetime;
454
+ if (cfg.oneShot) state.emitting = false;
455
+ }
456
+ state.time = time;
457
+ const count = state.count;
458
+ for (let i = 0; i < count; i += 1) {
459
+ const p = state.particles[i];
460
+ if (state.emitting) {
461
+ const restartTime = restartPhase(cfg, i) * lifetime;
462
+ if (time > prevTime ? restartTime >= prevTime && restartTime < time : restartTime >= prevTime || restartTime < time) restartParticle(state, i, state.cycle);
463
+ }
464
+ if (p.active) integrate(cfg, p, dt);
465
+ }
466
+ }
467
+ /**
468
+ * Advance the system by `dt` seconds (real time), stepping the simulation in fixed
469
+ * `1/fixed_fps` (or 1/30) chunks so the look is frame-rate independent. Mutates
470
+ * `state` in place. `maxSteps` bounds the loop (warm-up / tab-switch spikes).
471
+ *
472
+ * Returns the number of fixed sub-steps it actually executed — the unit of work this function
473
+ * does, and the only honest denominator for its cost: one display frame can run zero steps (a
474
+ * fast display under a 30Hz `fixed_fps`, or `speed_scale: 0`) or many (a long dt, a warm-up), so
475
+ * a profiler that divided wall-clock by FRAMES would be measuring the display, not the sim (see
476
+ * `ParticleProfile.simSteps` in `./runtime`). Purely additive: every caller may ignore it, and
477
+ * this function stays pure of any clock.
478
+ */
479
+ function simulateParticles(state, dt, maxSteps = 1e3) {
480
+ if (!(dt > 0)) return 0;
481
+ const cfg = state.config;
482
+ const frameTime = cfg.fixedFps > 0 ? 1 / cfg.fixedFps : 1 / 30;
483
+ state.remainder += dt * cfg.speedScale;
484
+ let steps = 0;
485
+ while (state.remainder >= frameTime && steps < maxSteps) {
486
+ step(state, frameTime);
487
+ state.remainder -= frameTime;
488
+ steps += 1;
489
+ }
490
+ return steps;
491
+ }
492
+ /**
493
+ * Warm-start a freshly created system by its `preprocess` time (Godot pre-simulates
494
+ * that much before first draw, so a long-lived ambient — fog with preprocess=100 —
495
+ * appears mid-drift instead of empty/bursty). A REPEATING system reaches its steady
496
+ * state within two lifetime cycles (a particle's look depends on its age, not absolute
497
+ * time), so simulating `min(preprocess, 2 x lifetime)` is visually identical to the
498
+ * full preprocess at bounded cost; a one-shot's whole life fits in that window too.
499
+ * Steps are sized to cover the window (the default `maxSteps` caps at ~33s), and the
500
+ * sub-step remainder is dropped so the leftover doesn't fast-forward the first live
501
+ * frames at ~1000 steps per tick.
502
+ */
503
+ function preprocessParticles(state) {
504
+ const cfg = state.config;
505
+ if (cfg.preprocess <= 0) return;
506
+ const warm = Math.min(cfg.preprocess, cfg.lifetime * 2);
507
+ const stepHz = cfg.fixedFps > 0 ? cfg.fixedFps : 30;
508
+ simulateParticles(state, warm, Math.ceil(warm * stepHz) + 2);
509
+ state.remainder = 0;
510
+ }
511
+ const STATIC_WARM_ONESHOT_FRACTION = .4;
512
+ const STATIC_WARM_REPEAT_FRACTION = 1;
513
+ /**
514
+ * Warm a system to a representative FROZEN state for the runtime's static/particles mode. Reuses the authored
515
+ * `preprocess` (an ambient emitter with preprocess>0 reaches its steady drift — identical to `preprocessParticles`),
516
+ * and for a system that would otherwise sit at spawn (preprocess<=0, e.g. a one-shot burst or an un-preprocessed
517
+ * emitter) advances a representative slice of a lifetime so the frozen frame is populated. Mutates `state`; the
518
+ * caller draws once and then stops simulating (see the particle runtime's static mode).
519
+ *
520
+ * This warm has no notion of the burst ENDING — it is a single representative frame, so a one-shot warmed here
521
+ * would otherwise be drawn as mid-flight forever. `staticOneShotExpired` is what retires it.
522
+ */
523
+ function warmStaticParticles(state) {
524
+ const cfg = state.config;
525
+ if (cfg.preprocess > 0) {
526
+ preprocessParticles(state);
527
+ return;
528
+ }
529
+ const warm = cfg.lifetime * (cfg.oneShot ? STATIC_WARM_ONESHOT_FRACTION : STATIC_WARM_REPEAT_FRACTION);
530
+ const stepHz = cfg.fixedFps > 0 ? cfg.fixedFps : 30;
531
+ simulateParticles(state, warm, Math.ceil(warm * stepHz) + 2);
532
+ state.remainder = 0;
533
+ }
534
+ /**
535
+ * Godot's own ACTIVE WINDOW for one one-shot cycle, in seconds: `lifetime * (2 - explosiveness)`
536
+ * (particles.cpp `active_time`). At explosiveness 1 every particle is born at t=0, so the cycle is one
537
+ * lifetime; at 0 the births are spread over a full lifetime, so the last particle dies at 2x lifetime.
538
+ *
539
+ * This is the SAME law the game-side mod uses to schedule a frozen one-shot's synthesized end-of-burst
540
+ * (`CouchCoopHeadlessVisualSuspender.FinishNudgeDelaySeconds`), deliberately: the two sides have to agree on
541
+ * when a burst is over, or one of them keeps drawing/reporting it after the other has stopped. No clamp and no
542
+ * margin here — the mod's margin exists so its removal delta lands AFTER the client's tail, and this side IS
543
+ * that tail. `normalizeParticleConfig` already guarantees a finite `lifetime >= 0.01` and `explosiveness` in
544
+ * [0,1], so the result is finite and positive.
545
+ *
546
+ * `speedScale` is deliberately NOT folded in, for the same reason: the mod's law does not either, and a
547
+ * disagreement would be worse than the (rare, small) inaccuracy of a re-timed burst.
548
+ */
549
+ function oneShotBurstSeconds(cfg) {
550
+ return cfg.lifetime * (2 - cfg.explosiveness);
551
+ }
552
+ /**
553
+ * FROZEN-MODE expiry decision: has a one-shot burst the client has been drawing statically outlived its own
554
+ * active window, so the runtime should stop drawing it? Pure (no clock, no DOM) — the caller supplies the
555
+ * seconds elapsed since IT first saw this system emitting.
556
+ *
557
+ * WHY THIS EXISTS. In frozen/static mode a system is warmed to a representative mid-flight frame and that frame
558
+ * is parked forever — which is right for an ambient emitter (it really does emit forever) and wrong for a
559
+ * one-shot (it is a BURST; it ends). Nothing else can retire it: the frozen runtime never simulates, so the
560
+ * sim's own end-of-cycle never runs, and the only other input is the host's `emitting` flag — which a host can
561
+ * get stuck on (the live case: a game-side freeze left `Emitting` latched true on every energy-counter VFX, so
562
+ * the mirror drew a permanent "energy ring" over a counter the game was showing bare).
563
+ *
564
+ * WHY "since FIRST SIGHT". The client cannot know when the game started the burst — it sees only "this spec
565
+ * says emitting". One full active window from first sight is exactly what the burst itself would do, so a
566
+ * legitimate transient (a hit spark, a card-play flourish) still shows for its natural life; only a burst that
567
+ * outlives its own window — i.e. one nothing ever turned off — is dropped.
568
+ */
569
+ function staticOneShotExpired(cfg, secondsSinceFirstEmitting) {
570
+ if (!cfg.oneShot || !cfg.emitting) return false;
571
+ return secondsSinceFirstEmitting >= oneShotBurstSeconds(cfg);
572
+ }
573
+ /** Live particle count (for tests / draw). */
574
+ function activeParticleCount(state) {
575
+ let n = 0;
576
+ for (const p of state.particles) if (p.active) n += 1;
577
+ return n;
578
+ }
579
+ //#endregion
580
+ //#region src/particles/state.ts
581
+ function makeParticle() {
582
+ return {
583
+ active: false,
584
+ time: 0,
585
+ lifetime: 1,
586
+ x: 0,
587
+ y: 0,
588
+ vx: 0,
589
+ vy: 0,
590
+ rotation: 0,
591
+ seed: 0,
592
+ angleRand: 0,
593
+ scaleRand: 0,
594
+ hueRand: 0,
595
+ animOffsetRand: 0,
596
+ startColor: [
597
+ 1,
598
+ 1,
599
+ 1,
600
+ 1
601
+ ],
602
+ scaleX: 1,
603
+ scaleY: 1,
604
+ r: 1,
605
+ g: 1,
606
+ b: 1,
607
+ a: 1,
608
+ frame: 0
609
+ };
610
+ }
611
+ const DEFAULT_MAX_INSTANCES = 2048;
612
+ function createParticleState(config, maxInstances = DEFAULT_MAX_INSTANCES) {
613
+ const count = Math.max(1, Math.min(maxInstances, Math.round(config.amount)));
614
+ const particles = [];
615
+ for (let i = 0; i < count; i += 1) particles.push(makeParticle());
616
+ return {
617
+ config,
618
+ particles,
619
+ time: 0,
620
+ cycle: 0,
621
+ emitting: config.emitting,
622
+ remainder: 0,
623
+ count
624
+ };
625
+ }
626
+ /** Whether the system still needs simulating (live particles or still emitting). */
627
+ function particlesAreLive(state) {
628
+ if (state.emitting) return true;
629
+ for (const p of state.particles) if (p.active) return true;
630
+ return false;
631
+ }
632
+ function num(value, fallback) {
633
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
634
+ }
635
+ function bool(value, fallback) {
636
+ return typeof value === "boolean" ? value : fallback;
637
+ }
638
+ function vec2(value, fallback) {
639
+ return Array.isArray(value) && value.length >= 2 && typeof value[0] === "number" && typeof value[1] === "number" ? [value[0], value[1]] : fallback;
640
+ }
641
+ function rgba(value, fallback) {
642
+ return Array.isArray(value) && value.length >= 4 ? [
643
+ num(value[0], fallback[0]),
644
+ num(value[1], fallback[1]),
645
+ num(value[2], fallback[2]),
646
+ num(value[3], fallback[3])
647
+ ] : fallback;
648
+ }
649
+ function normalizeParticleConfig(raw) {
650
+ const c = raw ?? {};
651
+ const kind = c.kind === "CPUParticles2D" ? "CPUParticles2D" : "GPUParticles2D";
652
+ const godotRenderer = normalizeGodotRenderer(c.godotRenderer);
653
+ const baseColor = rgba(c.baseColor, [
654
+ 1,
655
+ 1,
656
+ 1,
657
+ 1
658
+ ]);
659
+ const baseColorFromProcessMaterial = typeof c.baseColorFromProcessMaterial === "boolean" ? c.baseColorFromProcessMaterial : kind === "GPUParticles2D";
660
+ return {
661
+ kind,
662
+ godotRenderer,
663
+ amount: Math.max(1, Math.round(num(c.amount, 8))),
664
+ amountRatio: clamp01(num(c.amountRatio, 1)),
665
+ lifetime: Math.max(.01, num(c.lifetime, 1)),
666
+ lifetimeRandomness: clamp01(num(c.lifetimeRandomness, 0)),
667
+ oneShot: bool(c.oneShot, false),
668
+ emitting: bool(c.emitting, true),
669
+ explosiveness: clamp01(num(c.explosiveness, 0)),
670
+ randomness: clamp01(num(c.randomness, 0)),
671
+ preprocess: Math.max(0, num(c.preprocess, 0)),
672
+ speedScale: num(c.speedScale, 1),
673
+ fixedFps: Math.max(0, Math.round(num(c.fixedFps, 0))),
674
+ localCoords: bool(c.localCoords, false),
675
+ drawOrder: Math.round(num(c.drawOrder, 0)),
676
+ seed: Math.round(num(c.seed, 0)),
677
+ emissionShape: Math.round(num(c.emissionShape, 0)),
678
+ emissionOffset: vec2(c.emissionOffset, [0, 0]),
679
+ emissionScale: vec2(c.emissionScale, [1, 1]),
680
+ emissionSphereRadius: num(c.emissionSphereRadius, 0),
681
+ emissionRingRadius: num(c.emissionRingRadius, 0),
682
+ emissionRingInnerRadius: num(c.emissionRingInnerRadius, 0),
683
+ emissionRingHeight: num(c.emissionRingHeight, 0),
684
+ emissionBoxExtents: vec2(c.emissionBoxExtents, [0, 0]),
685
+ direction: vec2(c.direction, [1, 0]),
686
+ spread: num(c.spread, 45),
687
+ initialVelocityMin: num(c.initialVelocityMin, 0),
688
+ initialVelocityMax: num(c.initialVelocityMax, 0),
689
+ angleMin: num(c.angleMin, 0),
690
+ angleMax: num(c.angleMax, 0),
691
+ angularVelocityMin: num(c.angularVelocityMin, 0),
692
+ angularVelocityMax: num(c.angularVelocityMax, 0),
693
+ gravity: vec2(c.gravity, [0, 980]),
694
+ linearAccelMin: num(c.linearAccelMin, 0),
695
+ linearAccelMax: num(c.linearAccelMax, 0),
696
+ radialAccelMin: num(c.radialAccelMin, 0),
697
+ radialAccelMax: num(c.radialAccelMax, 0),
698
+ tangentialAccelMin: num(c.tangentialAccelMin, 0),
699
+ tangentialAccelMax: num(c.tangentialAccelMax, 0),
700
+ dampingMin: num(c.dampingMin, 0),
701
+ dampingMax: num(c.dampingMax, 0),
702
+ dampingAsFriction: bool(c.dampingAsFriction, false),
703
+ orbitVelocityMin: num(c.orbitVelocityMin, 0),
704
+ orbitVelocityMax: num(c.orbitVelocityMax, 0),
705
+ scaleMin: num(c.scaleMin, 1),
706
+ scaleMax: num(c.scaleMax, 1),
707
+ hueVariationMin: num(c.hueVariationMin, 0),
708
+ hueVariationMax: num(c.hueVariationMax, 0),
709
+ alignY: bool(c.alignY, false),
710
+ baseColor,
711
+ baseColorFromProcessMaterial,
712
+ baseColorRender: linearizeParticleBaseColor(baseColor, godotRenderer, baseColorFromProcessMaterial),
713
+ hframes: Math.max(1, Math.round(num(c.hframes, 1))),
714
+ vframes: Math.max(1, Math.round(num(c.vframes, 1))),
715
+ frameCount: Math.max(0, Math.round(num(c.frameCount, 0))),
716
+ animLoop: bool(c.animLoop, false),
717
+ animSpeedMin: num(c.animSpeedMin, 0),
718
+ animSpeedMax: num(c.animSpeedMax, 0),
719
+ animOffsetMin: num(c.animOffsetMin, 0),
720
+ animOffsetMax: num(c.animOffsetMax, 0),
721
+ colorRamp: c.colorRamp,
722
+ colorInitialRamp: c.colorInitialRamp,
723
+ scaleCurve: normalizeParticleCurve(c.scaleCurve),
724
+ scaleCurveX: normalizeParticleCurve(c.scaleCurveX),
725
+ scaleCurveY: normalizeParticleCurve(c.scaleCurveY),
726
+ alphaCurve: normalizeParticleCurve(c.alphaCurve),
727
+ hueCurve: normalizeParticleCurve(c.hueCurve)
728
+ };
729
+ }
730
+ /** Normalize portable renderer inputs without admitting HTML URL or placement fields. */
731
+ function normalizeParticleRenderConfig(raw) {
732
+ const value = raw ?? {};
733
+ const erode = value.alphaErode;
734
+ const alphaErode = erode && Number.isFinite(erode.threshold) && Number.isFinite(erode.softness) ? {
735
+ threshold: erode.threshold,
736
+ softness: Math.max(0, erode.softness)
737
+ } : null;
738
+ return {
739
+ ...normalizeParticleConfig(value),
740
+ textureWidth: Math.max(0, num(value.textureWidth, 0)),
741
+ textureHeight: Math.max(0, num(value.textureHeight, 0)),
742
+ flipbookCropOnly: value.flipbookCropOnly === true,
743
+ blendMode: Math.round(num(value.blendMode, 0)),
744
+ colorLut: value.colorLut,
745
+ colorLutInterpolation: value.colorLutInterpolation,
746
+ alphaFromRed: value.alphaFromRed === true,
747
+ alphaErode,
748
+ uvPolar: value.uvPolar === true
749
+ };
750
+ }
751
+ function clamp01(value) {
752
+ return value < 0 ? 0 : value > 1 ? 1 : value;
753
+ }
754
+ //#endregion
755
+ export { srgbToLinear as C, normalizeGodotRenderer as S, INSTANCE_STRIDE as _, activeParticleCount as a, linearizeParticleBaseColor as b, simulateParticles as c, normalizeParticleCurve as d, sampleParticleCurve as f, packParticleInstances as g, frameGridFor as h, particlesAreLive as i, staticOneShotExpired as l, sampleParticleGradientInto as m, normalizeParticleConfig as n, oneShotBurstSeconds as o, sampleParticleGradient as p, normalizeParticleRenderConfig as r, preprocessParticles as s, createParticleState as t, warmStaticParticles as u, InstanceBuffer as v, linearizesParticleColor as x, DEFAULT_GODOT_RENDERER as y };
756
+
757
+ //# sourceMappingURL=particles-CQC9BFSI.js.map