@flighthq/particleemitter 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.
- package/dist/emitParticleBurst.d.ts +15 -0
- package/dist/emitParticleBurst.d.ts.map +1 -0
- package/dist/emitParticleBurst.js +113 -0
- package/dist/emitParticleBurst.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/particleEmitter.d.ts +72 -0
- package/dist/particleEmitter.d.ts.map +1 -0
- package/dist/particleEmitter.js +328 -0
- package/dist/particleEmitter.js.map +1 -0
- package/dist/prewarmParticleEmitter.d.ts +5 -0
- package/dist/prewarmParticleEmitter.d.ts.map +1 -0
- package/dist/prewarmParticleEmitter.js +13 -0
- package/dist/prewarmParticleEmitter.js.map +1 -0
- package/dist/stepParticleEmitter.d.ts +21 -0
- package/dist/stepParticleEmitter.d.ts.map +1 -0
- package/dist/stepParticleEmitter.js +30 -0
- package/dist/stepParticleEmitter.js.map +1 -0
- package/dist/updateParticleEmitter.d.ts +8 -0
- package/dist/updateParticleEmitter.d.ts.map +1 -0
- package/dist/updateParticleEmitter.js +313 -0
- package/dist/updateParticleEmitter.js.map +1 -0
- package/package.json +42 -0
- package/src/deterministic.test.ts +53 -0
- package/src/emitParticleBurst.test.ts +102 -0
- package/src/particleEmitter.test.ts +516 -0
- package/src/prewarmParticleEmitter.test.ts +67 -0
- package/src/stepParticleEmitter.test.ts +86 -0
- package/src/updateParticleEmitter.test.ts +630 -0
- package/src/updateParticleEmitterIntegration.test.ts +270 -0
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
import { createRandomSource } from '@flighthq/math';
|
|
2
|
+
import { createParticleEmitterConfig, createParticleEmitterState } from '@flighthq/particles';
|
|
3
|
+
import type { TextureAtlas } from '@flighthq/types';
|
|
4
|
+
|
|
5
|
+
import { createParticleEmitter, reserveParticleEmitter } from './particleEmitter';
|
|
6
|
+
import { prewarmParticleEmitter } from './prewarmParticleEmitter';
|
|
7
|
+
import { isParticleEmitterComplete, updateParticleEmitter } from './updateParticleEmitter';
|
|
8
|
+
|
|
9
|
+
function makeAtlas(): TextureAtlas {
|
|
10
|
+
return {
|
|
11
|
+
image: null,
|
|
12
|
+
regions: [{ id: 0, x: 0, y: 0, width: 32, height: 32, pivotX: null, pivotY: null }],
|
|
13
|
+
} as TextureAtlas;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('isParticleEmitterComplete', () => {
|
|
17
|
+
it('isParticleEmitterComplete is true once a one-shot emitter has finished and all particles died', () => {
|
|
18
|
+
const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
|
|
19
|
+
const state = createParticleEmitterState();
|
|
20
|
+
const config = createParticleEmitterConfig({
|
|
21
|
+
spawnRate: 10,
|
|
22
|
+
maxParticles: 1000,
|
|
23
|
+
lifetimeMin: 0.5,
|
|
24
|
+
lifetimeMax: 0.5,
|
|
25
|
+
duration: 1,
|
|
26
|
+
loop: false,
|
|
27
|
+
});
|
|
28
|
+
expect(isParticleEmitterComplete(emitter, state, config)).toBe(false);
|
|
29
|
+
// Emit for 1s.
|
|
30
|
+
for (let i = 0; i < 10; i++) updateParticleEmitter(emitter, state, config, 0.1);
|
|
31
|
+
expect(emitter.data.particleCount).toBeGreaterThan(0);
|
|
32
|
+
expect(isParticleEmitterComplete(emitter, state, config)).toBe(false); // particles still alive
|
|
33
|
+
// Let every particle die out (lifetime 0.5s).
|
|
34
|
+
for (let i = 0; i < 10; i++) updateParticleEmitter(emitter, state, config, 0.1);
|
|
35
|
+
expect(emitter.data.particleCount).toBe(0);
|
|
36
|
+
expect(isParticleEmitterComplete(emitter, state, config)).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('isParticleEmitterComplete is always false for infinite or looping emitters', () => {
|
|
40
|
+
const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
|
|
41
|
+
const state = createParticleEmitterState();
|
|
42
|
+
const infinite = createParticleEmitterConfig({ spawnRate: 0, duration: 0 });
|
|
43
|
+
expect(isParticleEmitterComplete(emitter, state, infinite)).toBe(false);
|
|
44
|
+
const looping = createParticleEmitterConfig({ spawnRate: 0, duration: 1, loop: true });
|
|
45
|
+
expect(isParticleEmitterComplete(emitter, state, looping)).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('updateParticleEmitter', () => {
|
|
50
|
+
it('spawns particles up to spawnRate × deltaTime', () => {
|
|
51
|
+
const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
|
|
52
|
+
const state = createParticleEmitterState();
|
|
53
|
+
const config = createParticleEmitterConfig({ spawnRate: 10, maxParticles: 100 });
|
|
54
|
+
updateParticleEmitter(emitter, state, config, 1);
|
|
55
|
+
expect(emitter.data.particleCount).toBe(10);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('respects maxParticles limit', () => {
|
|
59
|
+
const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
|
|
60
|
+
const state = createParticleEmitterState();
|
|
61
|
+
const config = createParticleEmitterConfig({ spawnRate: 100, maxParticles: 5 });
|
|
62
|
+
updateParticleEmitter(emitter, state, config, 1);
|
|
63
|
+
expect(emitter.data.particleCount).toBe(5);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('ages particles over time', () => {
|
|
67
|
+
const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
|
|
68
|
+
const state = createParticleEmitterState();
|
|
69
|
+
const config = createParticleEmitterConfig({
|
|
70
|
+
spawnRate: 1,
|
|
71
|
+
lifetimeMin: 10,
|
|
72
|
+
lifetimeMax: 10,
|
|
73
|
+
});
|
|
74
|
+
updateParticleEmitter(emitter, state, config, 1); // spawn 1 particle (spawnRate*deltaTime=1)
|
|
75
|
+
expect(emitter.data.particleCount).toBe(1);
|
|
76
|
+
updateParticleEmitter(emitter, state, config, 0.1); // age by 0.1
|
|
77
|
+
expect(state.lifetimes[0]).toBeCloseTo(0.1);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('removes particles when lifetime expires', () => {
|
|
81
|
+
const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
|
|
82
|
+
const state = createParticleEmitterState();
|
|
83
|
+
const config = createParticleEmitterConfig({
|
|
84
|
+
spawnRate: 1,
|
|
85
|
+
lifetimeMin: 0.5,
|
|
86
|
+
lifetimeMax: 0.5,
|
|
87
|
+
});
|
|
88
|
+
// Spawn one particle
|
|
89
|
+
updateParticleEmitter(emitter, state, config, 1);
|
|
90
|
+
expect(emitter.data.particleCount).toBe(1);
|
|
91
|
+
// Advance past lifetime
|
|
92
|
+
updateParticleEmitter(emitter, state, config, 0.6);
|
|
93
|
+
expect(emitter.data.particleCount).toBe(0);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('integrates velocity with gravity', () => {
|
|
97
|
+
const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
|
|
98
|
+
const state = createParticleEmitterState();
|
|
99
|
+
const config = createParticleEmitterConfig({
|
|
100
|
+
spawnRate: 1,
|
|
101
|
+
lifetimeMin: 10,
|
|
102
|
+
lifetimeMax: 10,
|
|
103
|
+
speedMin: 0,
|
|
104
|
+
speedMax: 0,
|
|
105
|
+
gravityX: 100,
|
|
106
|
+
gravityY: 0,
|
|
107
|
+
spread: 0,
|
|
108
|
+
directionX: 0,
|
|
109
|
+
directionY: -1,
|
|
110
|
+
});
|
|
111
|
+
updateParticleEmitter(emitter, state, config, 1);
|
|
112
|
+
// After spawn, position is (0,0). After next update with gravity...
|
|
113
|
+
updateParticleEmitter(emitter, state, config, 1);
|
|
114
|
+
// vx = 100*1 = 100, x += 100*1 = 100 (gravity applied to velocity then integrated)
|
|
115
|
+
const x = emitter.data.transforms[0];
|
|
116
|
+
expect(x).toBeGreaterThan(0);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('interpolates alpha from alphaStart to alphaEnd over lifetime', () => {
|
|
120
|
+
const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
|
|
121
|
+
const state = createParticleEmitterState();
|
|
122
|
+
const config = createParticleEmitterConfig({
|
|
123
|
+
spawnRate: 1,
|
|
124
|
+
lifetimeMin: 1,
|
|
125
|
+
lifetimeMax: 1,
|
|
126
|
+
alphaStart: 1,
|
|
127
|
+
alphaEnd: 0,
|
|
128
|
+
speedMin: 0,
|
|
129
|
+
speedMax: 0,
|
|
130
|
+
});
|
|
131
|
+
updateParticleEmitter(emitter, state, config, 1); // spawn 1 particle
|
|
132
|
+
updateParticleEmitter(emitter, state, config, 0.5); // advance to half lifetime
|
|
133
|
+
expect(emitter.data.alphas[0]).toBeCloseTo(0.5, 1);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('accumulates fractional spawn debt across frames', () => {
|
|
137
|
+
const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
|
|
138
|
+
const state = createParticleEmitterState();
|
|
139
|
+
const config = createParticleEmitterConfig({
|
|
140
|
+
spawnRate: 10,
|
|
141
|
+
lifetimeMin: 10,
|
|
142
|
+
lifetimeMax: 10,
|
|
143
|
+
maxParticles: 100,
|
|
144
|
+
});
|
|
145
|
+
// 10 frames of deltaTime=0.05 → total deltaTime=0.5 → 5 particles
|
|
146
|
+
for (let i = 0; i < 10; i++) updateParticleEmitter(emitter, state, config, 0.05);
|
|
147
|
+
expect(emitter.data.particleCount).toBe(5);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('grows emitter arrays as needed', () => {
|
|
151
|
+
const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
|
|
152
|
+
const state = createParticleEmitterState();
|
|
153
|
+
const config = createParticleEmitterConfig({
|
|
154
|
+
spawnRate: 100,
|
|
155
|
+
maxParticles: 50,
|
|
156
|
+
lifetimeMin: 10,
|
|
157
|
+
lifetimeMax: 10,
|
|
158
|
+
});
|
|
159
|
+
updateParticleEmitter(emitter, state, config, 1);
|
|
160
|
+
expect(emitter.data.particleCount).toBe(50);
|
|
161
|
+
expect(emitter.data.transforms.length).toBeGreaterThanOrEqual(50 * 4);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('pre-reserved emitters do not allocate during update', () => {
|
|
165
|
+
const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
|
|
166
|
+
reserveParticleEmitter(emitter, 100);
|
|
167
|
+
const state = createParticleEmitterState();
|
|
168
|
+
const config = createParticleEmitterConfig({ spawnRate: 10, maxParticles: 100 });
|
|
169
|
+
const { transforms } = emitter.data;
|
|
170
|
+
updateParticleEmitter(emitter, state, config, 1);
|
|
171
|
+
expect(emitter.data.transforms).toBe(transforms);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('interpolates color from colorStart to colorEnd over lifetime', () => {
|
|
175
|
+
const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
|
|
176
|
+
const state = createParticleEmitterState();
|
|
177
|
+
const config = createParticleEmitterConfig({
|
|
178
|
+
spawnRate: 1,
|
|
179
|
+
lifetimeMin: 1,
|
|
180
|
+
lifetimeMax: 1,
|
|
181
|
+
speedMin: 0,
|
|
182
|
+
speedMax: 0,
|
|
183
|
+
colorStartR: 1,
|
|
184
|
+
colorStartG: 0,
|
|
185
|
+
colorStartB: 0,
|
|
186
|
+
colorEndR: 0,
|
|
187
|
+
colorEndG: 0,
|
|
188
|
+
colorEndB: 1,
|
|
189
|
+
});
|
|
190
|
+
updateParticleEmitter(emitter, state, config, 1); // spawn
|
|
191
|
+
updateParticleEmitter(emitter, state, config, 0.5); // half-life
|
|
192
|
+
expect(emitter.data.colors[0]).toBeCloseTo(0.5, 1); // R
|
|
193
|
+
expect(emitter.data.colors[1]).toBeCloseTo(0, 1); // G
|
|
194
|
+
expect(emitter.data.colors[2]).toBeCloseTo(0.5, 1); // B
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('animates scale over lifetime when scaleEnd differs from 1', () => {
|
|
198
|
+
const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
|
|
199
|
+
const state = createParticleEmitterState();
|
|
200
|
+
const config = createParticleEmitterConfig({
|
|
201
|
+
spawnRate: 1,
|
|
202
|
+
lifetimeMin: 1,
|
|
203
|
+
lifetimeMax: 1,
|
|
204
|
+
speedMin: 0,
|
|
205
|
+
speedMax: 0,
|
|
206
|
+
scaleMin: 2,
|
|
207
|
+
scaleMax: 2,
|
|
208
|
+
scaleEnd: 0,
|
|
209
|
+
});
|
|
210
|
+
updateParticleEmitter(emitter, state, config, 1); // spawn with scale=2
|
|
211
|
+
updateParticleEmitter(emitter, state, config, 0.5); // half-life → scale=1
|
|
212
|
+
expect(emitter.data.transforms[3]).toBeCloseTo(1, 1);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('rotates particles at their individual rotation speed', () => {
|
|
216
|
+
const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
|
|
217
|
+
const state = createParticleEmitterState();
|
|
218
|
+
const config = createParticleEmitterConfig({
|
|
219
|
+
spawnRate: 1,
|
|
220
|
+
lifetimeMin: 10,
|
|
221
|
+
lifetimeMax: 10,
|
|
222
|
+
speedMin: 0,
|
|
223
|
+
speedMax: 0,
|
|
224
|
+
spread: 0,
|
|
225
|
+
directionX: 0,
|
|
226
|
+
directionY: -1,
|
|
227
|
+
rotationSpeedMin: Math.PI,
|
|
228
|
+
rotationSpeedMax: Math.PI,
|
|
229
|
+
});
|
|
230
|
+
updateParticleEmitter(emitter, state, config, 1); // spawn
|
|
231
|
+
const rotBefore = emitter.data.transforms[2];
|
|
232
|
+
updateParticleEmitter(emitter, state, config, 1); // advance 1s at π rad/s
|
|
233
|
+
const rotAfter = emitter.data.transforms[2];
|
|
234
|
+
expect(rotAfter - rotBefore).toBeCloseTo(Math.PI, 3);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('spawns particles within the circle radius', () => {
|
|
238
|
+
const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
|
|
239
|
+
const state = createParticleEmitterState();
|
|
240
|
+
const config = createParticleEmitterConfig({
|
|
241
|
+
spawnRate: 50,
|
|
242
|
+
maxParticles: 50,
|
|
243
|
+
lifetimeMin: 10,
|
|
244
|
+
lifetimeMax: 10,
|
|
245
|
+
speedMin: 0,
|
|
246
|
+
speedMax: 0,
|
|
247
|
+
emitterShape: 'circle',
|
|
248
|
+
emitterRadius: 100,
|
|
249
|
+
});
|
|
250
|
+
updateParticleEmitter(emitter, state, config, 1);
|
|
251
|
+
for (let i = 0; i < emitter.data.particleCount; i++) {
|
|
252
|
+
const x = emitter.data.transforms[i * 4];
|
|
253
|
+
const y = emitter.data.transforms[i * 4 + 1];
|
|
254
|
+
expect(Math.sqrt(x * x + y * y)).toBeLessThanOrEqual(100 + 1e-4);
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('spawns particles within the rect bounds', () => {
|
|
259
|
+
const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
|
|
260
|
+
const state = createParticleEmitterState();
|
|
261
|
+
const config = createParticleEmitterConfig({
|
|
262
|
+
spawnRate: 50,
|
|
263
|
+
maxParticles: 50,
|
|
264
|
+
lifetimeMin: 10,
|
|
265
|
+
lifetimeMax: 10,
|
|
266
|
+
speedMin: 0,
|
|
267
|
+
speedMax: 0,
|
|
268
|
+
emitterShape: 'rect',
|
|
269
|
+
emitterWidth: 200,
|
|
270
|
+
emitterHeight: 100,
|
|
271
|
+
});
|
|
272
|
+
updateParticleEmitter(emitter, state, config, 1);
|
|
273
|
+
for (let i = 0; i < emitter.data.particleCount; i++) {
|
|
274
|
+
const x = emitter.data.transforms[i * 4];
|
|
275
|
+
const y = emitter.data.transforms[i * 4 + 1];
|
|
276
|
+
expect(Math.abs(x)).toBeLessThanOrEqual(100 + 1e-4);
|
|
277
|
+
expect(Math.abs(y)).toBeLessThanOrEqual(50 + 1e-4);
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('fires a one-shot burst on the first frame', () => {
|
|
282
|
+
const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
|
|
283
|
+
const state = createParticleEmitterState();
|
|
284
|
+
const config = createParticleEmitterConfig({
|
|
285
|
+
spawnRate: 0,
|
|
286
|
+
burstCount: 20,
|
|
287
|
+
burstInterval: 0,
|
|
288
|
+
maxParticles: 100,
|
|
289
|
+
lifetimeMin: 10,
|
|
290
|
+
lifetimeMax: 10,
|
|
291
|
+
});
|
|
292
|
+
updateParticleEmitter(emitter, state, config, 1 / 60);
|
|
293
|
+
expect(emitter.data.particleCount).toBe(20);
|
|
294
|
+
updateParticleEmitter(emitter, state, config, 1 / 60);
|
|
295
|
+
expect(emitter.data.particleCount).toBe(20); // no second burst
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('fires repeated bursts at the configured interval', () => {
|
|
299
|
+
const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
|
|
300
|
+
const state = createParticleEmitterState();
|
|
301
|
+
const config = createParticleEmitterConfig({
|
|
302
|
+
spawnRate: 0,
|
|
303
|
+
burstCount: 5,
|
|
304
|
+
burstInterval: 1,
|
|
305
|
+
maxParticles: 100,
|
|
306
|
+
lifetimeMin: 10,
|
|
307
|
+
lifetimeMax: 10,
|
|
308
|
+
});
|
|
309
|
+
updateParticleEmitter(emitter, state, config, 0.01); // burst 1 fires (burstTimer=0)
|
|
310
|
+
expect(emitter.data.particleCount).toBe(5);
|
|
311
|
+
updateParticleEmitter(emitter, state, config, 1.0); // burst 2 fires after 1s
|
|
312
|
+
expect(emitter.data.particleCount).toBe(10);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('advances flipbook frame based on age and frameRate', () => {
|
|
316
|
+
const atlas = {
|
|
317
|
+
image: null,
|
|
318
|
+
regions: [
|
|
319
|
+
{ id: 0, x: 0, y: 0, width: 16, height: 16, pivotX: null, pivotY: null },
|
|
320
|
+
{ id: 1, x: 16, y: 0, width: 16, height: 16, pivotX: null, pivotY: null },
|
|
321
|
+
{ id: 2, x: 32, y: 0, width: 16, height: 16, pivotX: null, pivotY: null },
|
|
322
|
+
],
|
|
323
|
+
} as TextureAtlas;
|
|
324
|
+
const emitter = createParticleEmitter({ data: { atlas } });
|
|
325
|
+
const state = createParticleEmitterState();
|
|
326
|
+
const config = createParticleEmitterConfig({
|
|
327
|
+
spawnRate: 1,
|
|
328
|
+
lifetimeMin: 10,
|
|
329
|
+
lifetimeMax: 10,
|
|
330
|
+
speedMin: 0,
|
|
331
|
+
speedMax: 0,
|
|
332
|
+
regionIdMin: 0,
|
|
333
|
+
frameCount: 3,
|
|
334
|
+
frameRate: 1,
|
|
335
|
+
});
|
|
336
|
+
updateParticleEmitter(emitter, state, config, 1); // spawn → frame 0
|
|
337
|
+
expect(emitter.data.ids[0]).toBe(0);
|
|
338
|
+
updateParticleEmitter(emitter, state, config, 1); // +1s → frame 1
|
|
339
|
+
expect(emitter.data.ids[0]).toBe(1);
|
|
340
|
+
updateParticleEmitter(emitter, state, config, 1); // +1s → frame 2
|
|
341
|
+
expect(emitter.data.ids[0]).toBe(2);
|
|
342
|
+
updateParticleEmitter(emitter, state, config, 1); // +1s → frame 0 (wraps)
|
|
343
|
+
expect(emitter.data.ids[0]).toBe(0);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('fires onSpawn callback for each spawned particle', () => {
|
|
347
|
+
const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
|
|
348
|
+
const state = createParticleEmitterState();
|
|
349
|
+
const config = createParticleEmitterConfig({ spawnRate: 3, lifetimeMin: 10, lifetimeMax: 10 });
|
|
350
|
+
let spawnCount = 0;
|
|
351
|
+
updateParticleEmitter(emitter, state, config, 1, {
|
|
352
|
+
onSpawn: () => {
|
|
353
|
+
spawnCount++;
|
|
354
|
+
},
|
|
355
|
+
});
|
|
356
|
+
expect(spawnCount).toBe(3);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it('fires onDeath callback with particle position when it expires', () => {
|
|
360
|
+
const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
|
|
361
|
+
const state = createParticleEmitterState();
|
|
362
|
+
const config = createParticleEmitterConfig({
|
|
363
|
+
spawnRate: 1,
|
|
364
|
+
lifetimeMin: 0.5,
|
|
365
|
+
lifetimeMax: 0.5,
|
|
366
|
+
speedMin: 0,
|
|
367
|
+
speedMax: 0,
|
|
368
|
+
});
|
|
369
|
+
updateParticleEmitter(emitter, state, config, 1); // spawn
|
|
370
|
+
let deathFired = false;
|
|
371
|
+
updateParticleEmitter(emitter, state, config, 0.6, {
|
|
372
|
+
onDeath: () => {
|
|
373
|
+
deathFired = true;
|
|
374
|
+
},
|
|
375
|
+
});
|
|
376
|
+
expect(deathFired).toBe(true);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it('prewarm produces a non-empty particle state', () => {
|
|
380
|
+
const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
|
|
381
|
+
const state = createParticleEmitterState();
|
|
382
|
+
const config = createParticleEmitterConfig({ spawnRate: 10, lifetimeMin: 2, lifetimeMax: 2 });
|
|
383
|
+
prewarmParticleEmitter(emitter, state, config, 1);
|
|
384
|
+
expect(emitter.data.particleCount).toBeGreaterThan(0);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it('applies color variance — each particle gets its own birth/death color', () => {
|
|
388
|
+
const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
|
|
389
|
+
const state = createParticleEmitterState();
|
|
390
|
+
const config = createParticleEmitterConfig({
|
|
391
|
+
spawnRate: 5,
|
|
392
|
+
lifetimeMin: 10,
|
|
393
|
+
lifetimeMax: 10,
|
|
394
|
+
speedMin: 0,
|
|
395
|
+
speedMax: 0,
|
|
396
|
+
colorStartR: 0.5,
|
|
397
|
+
colorStartVarianceR: 0.5,
|
|
398
|
+
colorEndR: 0.5,
|
|
399
|
+
colorEndVarianceR: 0.5,
|
|
400
|
+
});
|
|
401
|
+
updateParticleEmitter(emitter, state, config, 1);
|
|
402
|
+
// With full variance all born-red channels should be in [0, 1]
|
|
403
|
+
for (let i = 0; i < emitter.data.particleCount; i++) {
|
|
404
|
+
expect(state.colorBirth[i * 3]).toBeGreaterThanOrEqual(0);
|
|
405
|
+
expect(state.colorBirth[i * 3]).toBeLessThanOrEqual(1);
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it('world-space flag is synced to emitter.data.worldSpace', () => {
|
|
410
|
+
const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
|
|
411
|
+
const state = createParticleEmitterState();
|
|
412
|
+
const config = createParticleEmitterConfig({ worldSpace: true, spawnRate: 0 });
|
|
413
|
+
updateParticleEmitter(emitter, state, config, 1 / 60);
|
|
414
|
+
expect(emitter.data.worldSpace).toBe(true);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it('world-space: spawned particle position is transformed to world space', () => {
|
|
418
|
+
const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
|
|
419
|
+
const state = createParticleEmitterState();
|
|
420
|
+
const config = createParticleEmitterConfig({
|
|
421
|
+
spawnRate: 1,
|
|
422
|
+
lifetimeMin: 10,
|
|
423
|
+
lifetimeMax: 10,
|
|
424
|
+
speedMin: 0,
|
|
425
|
+
speedMax: 0,
|
|
426
|
+
worldSpace: true,
|
|
427
|
+
});
|
|
428
|
+
const worldTransform = { a: 1, b: 0, c: 0, d: 1, tx: 200, ty: 300 };
|
|
429
|
+
updateParticleEmitter(emitter, state, config, 1, undefined, worldTransform);
|
|
430
|
+
expect(emitter.data.particleCount).toBe(1);
|
|
431
|
+
// Particle origin should be at world position (200, 300), not (0, 0)
|
|
432
|
+
expect(emitter.data.transforms[0]).toBeCloseTo(200);
|
|
433
|
+
expect(emitter.data.transforms[1]).toBeCloseTo(300);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it('trail interpolation: particles spawned at interpolated positions along path', () => {
|
|
437
|
+
const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
|
|
438
|
+
const state = createParticleEmitterState();
|
|
439
|
+
// spawnRate=5 with deltaTime=1 → 5 particles per frame; maxParticles=10 leaves room for frame 2
|
|
440
|
+
const config = createParticleEmitterConfig({
|
|
441
|
+
spawnRate: 5,
|
|
442
|
+
maxParticles: 10,
|
|
443
|
+
lifetimeMin: 100,
|
|
444
|
+
lifetimeMax: 100,
|
|
445
|
+
speedMin: 0,
|
|
446
|
+
speedMax: 0,
|
|
447
|
+
worldSpace: true,
|
|
448
|
+
});
|
|
449
|
+
// Frame 1: emitter at (0, 0)
|
|
450
|
+
const wt1 = { a: 1, b: 0, c: 0, d: 1, tx: 0, ty: 0 };
|
|
451
|
+
updateParticleEmitter(emitter, state, config, 1, undefined, wt1);
|
|
452
|
+
const countAfterFrame1 = emitter.data.particleCount;
|
|
453
|
+
expect(countAfterFrame1).toBe(5);
|
|
454
|
+
|
|
455
|
+
// Frame 2: emitter moves to (100, 0) — trail interpolates spawn positions
|
|
456
|
+
const wt2 = { a: 1, b: 0, c: 0, d: 1, tx: 100, ty: 0 };
|
|
457
|
+
updateParticleEmitter(emitter, state, config, 1, undefined, wt2);
|
|
458
|
+
expect(emitter.data.particleCount).toBe(10);
|
|
459
|
+
|
|
460
|
+
// The 5 new particles should span the path from x=0 to x=100
|
|
461
|
+
let minX = Infinity,
|
|
462
|
+
maxX = -Infinity;
|
|
463
|
+
for (let i = countAfterFrame1; i < emitter.data.particleCount; i++) {
|
|
464
|
+
const x = emitter.data.transforms[i * 4];
|
|
465
|
+
if (x < minX) minX = x;
|
|
466
|
+
if (x > maxX) maxX = x;
|
|
467
|
+
}
|
|
468
|
+
expect(maxX - minX).toBeGreaterThan(10);
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
it('velocity inheritance: new particles receive fraction of emitter velocity', () => {
|
|
472
|
+
const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
|
|
473
|
+
const state = createParticleEmitterState();
|
|
474
|
+
const config = createParticleEmitterConfig({
|
|
475
|
+
spawnRate: 0,
|
|
476
|
+
burstCount: 1,
|
|
477
|
+
burstInterval: 0,
|
|
478
|
+
lifetimeMin: 10,
|
|
479
|
+
lifetimeMax: 10,
|
|
480
|
+
speedMin: 0,
|
|
481
|
+
speedMax: 0,
|
|
482
|
+
spread: 0,
|
|
483
|
+
directionX: 0,
|
|
484
|
+
directionY: -1,
|
|
485
|
+
velocityInheritance: 1,
|
|
486
|
+
});
|
|
487
|
+
// Establish prev position
|
|
488
|
+
emitter.x = 0;
|
|
489
|
+
updateParticleEmitter(emitter, state, config, 1 / 60);
|
|
490
|
+
|
|
491
|
+
// Move emitter, trigger burst
|
|
492
|
+
state.burstTimer = 0;
|
|
493
|
+
emitter.x = 100;
|
|
494
|
+
updateParticleEmitter(emitter, state, config, 1 / 60);
|
|
495
|
+
// Emitter moved 100px in 1/60s → velocity ≈ 6000 px/s
|
|
496
|
+
// With inheritance=1, vx of new particle should be ≈ 6000
|
|
497
|
+
const vx = state.velocities[(emitter.data.particleCount - 1) * 2];
|
|
498
|
+
expect(vx).toBeGreaterThan(100);
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it('ignores a zero-deltaTime frame: no spawning, aging, or velocity corruption', () => {
|
|
502
|
+
const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
|
|
503
|
+
const state = createParticleEmitterState();
|
|
504
|
+
const config = createParticleEmitterConfig({
|
|
505
|
+
spawnRate: 0,
|
|
506
|
+
burstCount: 1,
|
|
507
|
+
burstInterval: 0,
|
|
508
|
+
lifetimeMin: 10,
|
|
509
|
+
lifetimeMax: 10,
|
|
510
|
+
speedMin: 0,
|
|
511
|
+
speedMax: 0,
|
|
512
|
+
spread: 0,
|
|
513
|
+
velocityInheritance: 1,
|
|
514
|
+
});
|
|
515
|
+
// Establish a previous position, then move the emitter and step with deltaTime=0.
|
|
516
|
+
emitter.x = 0;
|
|
517
|
+
updateParticleEmitter(emitter, state, config, 1 / 60);
|
|
518
|
+
const countBefore = emitter.data.particleCount;
|
|
519
|
+
state.burstTimer = 0; // arm a burst that would otherwise fire on the next step
|
|
520
|
+
emitter.x = 100;
|
|
521
|
+
updateParticleEmitter(emitter, state, config, 0);
|
|
522
|
+
// No new particles spawned on a zero-deltaTime frame...
|
|
523
|
+
expect(emitter.data.particleCount).toBe(countBefore);
|
|
524
|
+
// ...and existing particle velocities remain finite (no divide-by-deltaTime poisoning).
|
|
525
|
+
for (let i = 0; i < emitter.data.particleCount; i++) {
|
|
526
|
+
expect(Number.isFinite(state.velocities[i * 2])).toBe(true);
|
|
527
|
+
expect(Number.isFinite(state.velocities[i * 2 + 1])).toBe(true);
|
|
528
|
+
}
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
it('ignores a negative-deltaTime frame', () => {
|
|
532
|
+
const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
|
|
533
|
+
const state = createParticleEmitterState();
|
|
534
|
+
const config = createParticleEmitterConfig({ spawnRate: 10, lifetimeMin: 10, lifetimeMax: 10 });
|
|
535
|
+
updateParticleEmitter(emitter, state, config, 1); // spawn 10
|
|
536
|
+
const countBefore = emitter.data.particleCount;
|
|
537
|
+
const accBefore = state.spawnAccumulator;
|
|
538
|
+
updateParticleEmitter(emitter, state, config, -1);
|
|
539
|
+
expect(emitter.data.particleCount).toBe(countBefore);
|
|
540
|
+
expect(state.spawnAccumulator).toBe(accBefore); // accumulator not driven negative
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
it('still syncs the world-space flag on a zero-deltaTime frame', () => {
|
|
544
|
+
const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
|
|
545
|
+
const state = createParticleEmitterState();
|
|
546
|
+
const config = createParticleEmitterConfig({ worldSpace: true, spawnRate: 10 });
|
|
547
|
+
updateParticleEmitter(emitter, state, config, 0);
|
|
548
|
+
expect(emitter.data.worldSpace).toBe(true);
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
it('a finite, non-looping emitter stops spawning after its duration', () => {
|
|
552
|
+
const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
|
|
553
|
+
const state = createParticleEmitterState();
|
|
554
|
+
const config = createParticleEmitterConfig({
|
|
555
|
+
spawnRate: 10,
|
|
556
|
+
maxParticles: 1000,
|
|
557
|
+
lifetimeMin: 100, // long-lived so none die during the test window
|
|
558
|
+
lifetimeMax: 100,
|
|
559
|
+
duration: 1,
|
|
560
|
+
loop: false,
|
|
561
|
+
});
|
|
562
|
+
// Step well past the 1s duration so emission has definitely stopped.
|
|
563
|
+
for (let i = 0; i < 20; i++) updateParticleEmitter(emitter, state, config, 0.1);
|
|
564
|
+
const countAfterDuration = emitter.data.particleCount;
|
|
565
|
+
expect(countAfterDuration).toBeGreaterThan(0);
|
|
566
|
+
expect(countAfterDuration).toBeLessThan(15); // ~10 particles, not unbounded
|
|
567
|
+
// Stepping further spawns nothing more (and nothing dies yet).
|
|
568
|
+
for (let i = 0; i < 20; i++) updateParticleEmitter(emitter, state, config, 0.1);
|
|
569
|
+
expect(emitter.data.particleCount).toBe(countAfterDuration);
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
it('a looping emitter keeps spawning past its duration', () => {
|
|
573
|
+
const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
|
|
574
|
+
const state = createParticleEmitterState();
|
|
575
|
+
const config = createParticleEmitterConfig({
|
|
576
|
+
spawnRate: 10,
|
|
577
|
+
maxParticles: 1000,
|
|
578
|
+
lifetimeMin: 100,
|
|
579
|
+
lifetimeMax: 100,
|
|
580
|
+
duration: 1,
|
|
581
|
+
loop: true,
|
|
582
|
+
});
|
|
583
|
+
for (let i = 0; i < 30; i++) updateParticleEmitter(emitter, state, config, 0.1);
|
|
584
|
+
// 3 seconds at rate 10 → ~30 particles, well past the 1s duration.
|
|
585
|
+
expect(emitter.data.particleCount).toBeGreaterThan(20);
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
it('replays byte-identically when stepped from the same seed (deterministic replay)', () => {
|
|
589
|
+
// North star: the same seed + the same step sequence reproduces the same SoA
|
|
590
|
+
// buffers exactly. The engine draws only from the injected RandomSource, so two
|
|
591
|
+
// runs seeded identically must produce bit-identical particle state.
|
|
592
|
+
const run = (): {
|
|
593
|
+
transforms: number[];
|
|
594
|
+
colors: number[];
|
|
595
|
+
velocities: number[];
|
|
596
|
+
lifetimes: number[];
|
|
597
|
+
count: number;
|
|
598
|
+
} => {
|
|
599
|
+
const emitter = createParticleEmitter({ data: { atlas: makeAtlas() } });
|
|
600
|
+
const state = createParticleEmitterState(createRandomSource(1234));
|
|
601
|
+
const config = createParticleEmitterConfig({
|
|
602
|
+
spawnRate: 30,
|
|
603
|
+
maxParticles: 200,
|
|
604
|
+
speedMin: 10,
|
|
605
|
+
speedMax: 200,
|
|
606
|
+
spread: Math.PI,
|
|
607
|
+
lifetimeMin: 0.5,
|
|
608
|
+
lifetimeMax: 2,
|
|
609
|
+
rotationSpeedMin: -2,
|
|
610
|
+
rotationSpeedMax: 2,
|
|
611
|
+
scaleMin: 0.5,
|
|
612
|
+
scaleMax: 2,
|
|
613
|
+
gravityY: 50,
|
|
614
|
+
});
|
|
615
|
+
for (let i = 0; i < 60; i++) updateParticleEmitter(emitter, state, config, 1 / 60);
|
|
616
|
+
const count = emitter.data.particleCount;
|
|
617
|
+
return {
|
|
618
|
+
transforms: Array.from(emitter.data.transforms.slice(0, count * 4)),
|
|
619
|
+
colors: Array.from(emitter.data.colors.slice(0, count * 3)),
|
|
620
|
+
velocities: Array.from(state.velocities.slice(0, count * 2)),
|
|
621
|
+
lifetimes: Array.from(state.lifetimes.slice(0, count * 2)),
|
|
622
|
+
count,
|
|
623
|
+
};
|
|
624
|
+
};
|
|
625
|
+
const first = run();
|
|
626
|
+
const second = run();
|
|
627
|
+
expect(first.count).toBeGreaterThan(0);
|
|
628
|
+
expect(second).toEqual(first);
|
|
629
|
+
});
|
|
630
|
+
});
|