babylon.quarks 0.27.0 → 0.28.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.
Files changed (52) hide show
  1. package/CHANGELOG.md +425 -0
  2. package/README.md +3 -1
  3. package/dist/babylon.quarks.cjs +1 -1
  4. package/dist/babylon.quarks.cjs.map +1 -1
  5. package/dist/babylon.quarks.esm.js +1 -1
  6. package/dist/babylon.quarks.esm.js.map +1 -1
  7. package/dist/babylon.quarks.umd.min.js +1 -1
  8. package/dist/babylon.quarks.umd.min.js.map +1 -1
  9. package/dist/types/index.d.cts +4256 -831
  10. package/dist/types/index.d.ts +4256 -831
  11. package/package.json +7 -2
  12. package/src/BatchedRenderer.ts +93 -78
  13. package/src/EffectPool.ts +91 -12
  14. package/src/MeshSurfaceEmitter.ts +24 -13
  15. package/src/ParticleEmitter.ts +31 -3
  16. package/src/ParticleSystem.ts +92 -25
  17. package/src/QuarksLoader.ts +289 -171
  18. package/src/QuarksPrefab.ts +301 -94
  19. package/src/QuarksSerializer.ts +43 -15
  20. package/src/QuarksUtil.ts +64 -9
  21. package/src/SpriteBatch.ts +21 -6
  22. package/src/TrailBatch.ts +12 -6
  23. package/src/VFXBatch.ts +70 -528
  24. package/src/batchKey.ts +4 -2
  25. package/src/batchSettings.ts +546 -0
  26. package/src/effectDocumentJSON.ts +261 -0
  27. package/src/effectResources.ts +21 -24
  28. package/src/envAtlas.ts +8 -2
  29. package/src/geometryUtil.ts +1 -1
  30. package/src/index.ts +39 -4
  31. package/src/nodeId.ts +25 -2
  32. package/src/shaderProgram.ts +110 -16
  33. package/src/shaderVariants.ts +24 -4
  34. package/src/shaders/chunks/blend.ts +31 -0
  35. package/src/shaders/chunks/fog.ts +8 -3
  36. package/src/shaders/chunks/soft.ts +6 -5
  37. package/src/shaders/chunks/surface.ts +2 -2
  38. package/src/shaders/particle_frag.glsl.ts +3 -1
  39. package/src/shaders/particle_frag.wgsl.ts +2 -0
  40. package/src/shaders/particle_physics_frag.glsl.ts +5 -2
  41. package/src/shaders/particle_physics_frag.wgsl.ts +2 -0
  42. package/src/shaders/trail_frag.glsl.ts +4 -1
  43. package/src/shaders/trail_frag.wgsl.ts +2 -0
  44. package/src/system/EmissionController.ts +2 -4
  45. package/src/system/EventDispatcher.ts +26 -10
  46. package/src/system/geometry.ts +22 -29
  47. package/src/system/materialResolve.ts +56 -7
  48. package/src/system/normalize.ts +6 -1
  49. package/src/system/serialization.ts +54 -33
  50. package/src/system/settingsSchema.ts +32 -10
  51. package/src/system/types.ts +67 -28
  52. package/src/trailSettings.ts +9 -6
@@ -1,9 +1,11 @@
1
1
  import {TransformNode} from '@babylonjs/core/Meshes/transformNode';
2
+ import {Observer} from '@babylonjs/core/Misc/observable';
2
3
  import {Scene} from '@babylonjs/core/scene';
3
4
  import {IAnimationData, IPrefab} from 'quarks.core';
4
5
  import {BatchedRenderer} from './BatchedRenderer';
5
6
  import {ParticleEmitter} from './ParticleEmitter';
6
- import {quarksNodeId} from './nodeId';
7
+ import {ParticleSystem} from './ParticleSystem';
8
+ import {fileNodeId, quarksNodeId} from './nodeId';
7
9
 
8
10
  /**
9
11
  * A clip a {@link QuarksPrefab} timeline can drive, such as a Babylon `AnimationGroup`. Every
@@ -24,6 +26,14 @@ export interface QuarksTimelineClip {
24
26
  setTime?: (time: number) => void;
25
27
  }
26
28
 
29
+ /** The particle system a `'ps'` track plays, if it has one. */
30
+ function particleSystemOf(animation: AnimationData): ParticleSystem | undefined {
31
+ if (animation.type === 'ps' && animation.target instanceof ParticleEmitter) {
32
+ return animation.target.system as unknown as ParticleSystem;
33
+ }
34
+ return undefined;
35
+ }
36
+
27
37
  /** One track on a {@link QuarksPrefab} timeline. */
28
38
  export interface AnimationData extends IAnimationData {
29
39
  /**
@@ -42,22 +52,68 @@ export interface AnimationData extends IAnimationData {
42
52
  clipUUID?: string;
43
53
  }
44
54
 
45
- interface AnimationJSON {
55
+ /** One track of a {@link QuarksPrefabJSON}. */
56
+ export interface AnimationJSON {
57
+ /** When the track starts, in seconds. */
46
58
  startTime: number;
59
+ /** How long it lasts, in seconds. */
47
60
  duration: number;
61
+ /** What it plays; see {@link AnimationData.type}. */
48
62
  type: 'three' | 'ps';
63
+ /** The id of the node it drives. */
49
64
  targetUUID: string;
65
+ /** A `'three'` track's clip id on that node. */
50
66
  clipUUID?: string;
67
+ /** Whether the track restarts when it ends. */
51
68
  loop: boolean;
52
69
  }
53
70
 
71
+ /**
72
+ * A {@link QuarksPrefab} as {@link QuarksPrefab.toJSON} writes it, inside its node's JSON.
73
+ * {@link QuarksPrefab.fromJSON} reads a missing field as its default.
74
+ */
75
+ export interface QuarksPrefabJSON {
76
+ /** Always `'QuarksPrefab'`. */
77
+ type: string;
78
+ /** The prefab's name. */
79
+ name: string;
80
+ /** Written only when not 1. */
81
+ timeScale?: number;
82
+ /** The timeline's tracks. */
83
+ animationData: AnimationJSON[];
84
+ }
85
+
86
+ /** Where a host may keep a node's clips: on the node, or in its `metadata`. */
87
+ interface ClipHolder {
88
+ animations?: unknown;
89
+ animationGroups?: unknown;
90
+ }
91
+
92
+ /** Whether `value` can be a clip: every member of one is optional, so any object. */
93
+ function isClip(value: unknown): value is QuarksTimelineClip {
94
+ return typeof value === 'object' && value !== null;
95
+ }
96
+
97
+ /** Whether `value` is a clip whose id is `uuid`. */
98
+ function isClipWithId(value: unknown, uuid: string): value is QuarksTimelineClip {
99
+ return isClip(value) && value.uuid === uuid;
100
+ }
101
+
54
102
  /**
55
103
  * A node with a timeline that starts and stops the particle systems and clips under it, like a
56
- * Unity Timeline or a Niagara System's emitter spawn schedule. Each track starts at `startTime`
57
- * and lasts `duration` seconds: a particle system restarts when its track begins and stops
58
- * emitting when it ends, a clip plays and stops.
104
+ * Unity Timeline or a Niagara System's emitter spawn schedule. Each track covers the window from
105
+ * `startTime` to `startTime + duration` seconds (the end not included): a particle system restarts
106
+ * when the timeline enters its window and stops emitting when it leaves it, letting its particles
107
+ * die out; a clip plays and stops.
108
+ *
109
+ * The timeline advances in {@link QuarksPrefab.update}: call it once a frame, or set
110
+ * {@link QuarksPrefab.autoUpdate} and the scene does. {@link QuarksPrefab.setTime} scrubs. When the
111
+ * timeline passes its end, the systems still emitting end their emission, the clips stop, the
112
+ * prefab stops playing and rewinds, and {@link QuarksPrefab.play} plays it again.
59
113
  *
60
- * Call {@link QuarksPrefab.update} every frame (or {@link QuarksPrefab.setTime} to scrub).
114
+ * A window shorter than a frame is not missed: a frame that steps over it whole starts its system,
115
+ * which ends its emission once it has simulated a step (so it still fires what its window began
116
+ * with), or plays and stops its clip.
61
117
  */
62
118
  export class QuarksPrefab extends TransformNode implements IPrefab {
63
119
  /** Always `'QuarksPrefab'`; written to JSON. */
@@ -68,29 +124,78 @@ export class QuarksPrefab extends TransformNode implements IPrefab {
68
124
  isPlaying = false;
69
125
  /** Time on the timeline in seconds; just below 0 before the first update, so a track at 0 starts. */
70
126
  currentTime = -0.00001;
71
- /** Playback speed; 1 is real time. */
72
- timeScale = 1;
73
- /** End of the last track in seconds; the prefab stops when it passes it. */
127
+ /** End of the last track in seconds; the timeline ends when it reaches it. */
74
128
  duration = 0;
75
129
 
76
- private _lastUpdateTimeMs = Date.now();
130
+ private _timeScale = 1;
77
131
  private _batchedRenderer?: BatchedRenderer;
78
132
  private _tempAnimationJSON: Array<AnimationJSON> = [];
133
+ private _autoUpdate: Observer<Scene> | null = null;
134
+ /** Systems whose window a single update stepped over: they end their emission once they have stepped. */
135
+ private readonly _pendingEndEmit = new Set<ParticleSystem>();
136
+ private _pendingObserver: Observer<Scene> | null = null;
137
+ /** Systems the timeline started this play; their emission ends with the timeline. */
138
+ private readonly _started = new Set<ParticleSystem>();
139
+ /** Systems {@link QuarksPrefab.pause} paused, which {@link QuarksPrefab.play} resumes. */
140
+ private readonly _pausedSystems = new Set<ParticleSystem>();
79
141
 
80
142
  constructor(name = 'QuarksPrefab', scene?: Scene) {
81
143
  super(name, scene);
82
144
  }
83
145
 
146
+ /**
147
+ * Playback speed of the timeline and of the particle systems on it: 1 is real time, 2 twice as
148
+ * fast, 0 frozen. It sets each track's system's {@link ParticleSystem.timeDilation}, so the
149
+ * systems keep pace with the timeline without their own `playbackSpeed` changing. Clips are
150
+ * started and stopped on the scaled timeline but play at their own speed; scale them yourself
151
+ * (an `AnimationGroup`'s `speedRatio`). Negative, NaN or infinite is read as 0. Saved in JSON
152
+ * when not 1.
153
+ */
154
+ get timeScale(): number {
155
+ return this._timeScale;
156
+ }
157
+
158
+ set timeScale(scale: number) {
159
+ this._timeScale = scale > 0 && scale < Infinity ? scale : 0;
160
+ for (const animation of this.animationData) {
161
+ const system = particleSystemOf(animation);
162
+ if (system !== undefined) {
163
+ system.timeDilation = this._timeScale;
164
+ }
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Whether the scene advances the timeline: before each render, by the engine's frame time (game
170
+ * time, as Unity Timeline's default update mode). Off by default; then call
171
+ * {@link QuarksPrefab.update} yourself. Turned off when the prefab is disposed.
172
+ */
173
+ get autoUpdate(): boolean {
174
+ return this._autoUpdate !== null;
175
+ }
176
+
177
+ set autoUpdate(enabled: boolean) {
178
+ if (enabled === this.autoUpdate) return;
179
+ if (enabled) {
180
+ this._autoUpdate = this.getScene().onBeforeRenderObservable.add(() => this.update());
181
+ } else {
182
+ this.getScene().onBeforeRenderObservable.remove(this._autoUpdate);
183
+ this._autoUpdate = null;
184
+ }
185
+ }
186
+
84
187
  /** The renderer that a particle system is added to again when its track starts. */
85
188
  registerBatchedRenderer(renderer: BatchedRenderer): void {
86
189
  this._batchedRenderer = renderer;
87
190
  }
88
191
 
89
192
  /**
90
- * Adds a track that plays `emitter`'s system, and pauses the prefab.
193
+ * Adds a track that plays `emitter`'s system. Its system runs at the prefab's
194
+ * {@link QuarksPrefab.timeScale} from now on. A track added while the timeline is inside its
195
+ * window starts the next time the timeline enters it.
91
196
  * @param startTime - Seconds into the timeline.
92
197
  * @param duration - Seconds; 0 or less uses the system's own duration.
93
- * @param loop - Restart the system when the track ends.
198
+ * @param loop - Restart the system when the timeline leaves the track before its end.
94
199
  */
95
200
  addParticleSystemAnimation(emitter: ParticleEmitter, startTime = 0, duration = 0, loop = false): AnimationData {
96
201
  const animationDuration = duration > 0 ? duration : emitter.system.duration;
@@ -102,7 +207,7 @@ export class QuarksPrefab extends TransformNode implements IPrefab {
102
207
  target: emitter,
103
208
  };
104
209
  this.animationData.push(data);
105
- this.pause();
210
+ (emitter.system as unknown as ParticleSystem).timeDilation = this._timeScale;
106
211
  this.updateDuration();
107
212
  return data;
108
213
  }
@@ -137,52 +242,72 @@ export class QuarksPrefab extends TransformNode implements IPrefab {
137
242
  return data;
138
243
  }
139
244
 
140
- /** Removes the track at `index` in {@link QuarksPrefab.animationData}. */
245
+ /**
246
+ * Removes the track at `index` in {@link QuarksPrefab.animationData}. When no other track plays
247
+ * its particle system, the system's {@link ParticleSystem.timeDilation} goes back to 1, and an
248
+ * end of emission still due from a stepped-over window happens now.
249
+ */
141
250
  removeAnimation(index: number): void {
142
- this.animationData.splice(index, 1);
251
+ const [removed] = this.animationData.splice(index, 1);
252
+ const system = removed && particleSystemOf(removed);
253
+ if (system !== undefined && !this.animationData.some((other) => particleSystemOf(other) === system)) {
254
+ system.timeDilation = 1;
255
+ if (this._pendingEndEmit.delete(system)) {
256
+ system.endEmit();
257
+ }
258
+ this._started.delete(system);
259
+ this._pausedSystems.delete(system);
260
+ }
143
261
  this.updateDuration();
144
262
  }
145
263
 
146
264
  /**
147
- * Starts or resumes the timeline from {@link QuarksPrefab.currentTime}, resuming the systems and
148
- * clips of the tracks it is inside, which {@link QuarksPrefab.pause} paused.
265
+ * Starts or resumes the timeline from {@link QuarksPrefab.currentTime}. The systems
266
+ * {@link QuarksPrefab.pause} paused resume where they were (those dying out after their window
267
+ * included), and the clips of the tracks the timeline is inside play again.
149
268
  */
150
269
  play(): void {
151
270
  if (this.isPlaying) return;
152
271
  this.isPlaying = true;
153
- this._lastUpdateTimeMs = Date.now();
272
+ for (const system of this._pausedSystems) {
273
+ system.resume();
274
+ }
275
+ this._pausedSystems.clear();
154
276
  this.animationData.forEach((animation) => {
155
- const inside =
156
- this.currentTime >= animation.startTime && this.currentTime <= animation.startTime + animation.duration;
157
- if (!inside) return;
158
- if (animation.type === 'ps' && animation.target instanceof ParticleEmitter) {
159
- animation.target.system.play();
160
- return;
277
+ if (animation.type === 'three' && this.isInside(animation, this.currentTime)) {
278
+ animation.clip?.play?.();
161
279
  }
162
- animation.clip?.play?.();
163
280
  });
164
281
  }
165
282
 
166
- /** Pauses the timeline and every system and clip on it. */
283
+ /** Pauses the timeline, every system on it that is running and every clip. */
167
284
  pause(): void {
168
285
  if (!this.isPlaying) return;
169
286
  this.isPlaying = false;
170
287
  this.animationData.forEach((animation) => {
171
- if (animation.type === 'ps' && animation.target instanceof ParticleEmitter) {
172
- animation.target.system.pause();
288
+ const system = particleSystemOf(animation);
289
+ if (system !== undefined) {
290
+ if (!system.paused) {
291
+ system.pause();
292
+ this._pausedSystems.add(system);
293
+ }
173
294
  return;
174
295
  }
175
296
  animation.clip?.pause?.();
176
297
  });
177
298
  }
178
299
 
179
- /** Pauses, rewinds to the start, and stops every system and clip on it. */
300
+ /** Pauses, rewinds to the start, and stops every system (clearing its particles) and clip on it. */
180
301
  stop(): void {
181
302
  this.pause();
182
303
  this.currentTime = -0.00001;
304
+ this.clearPending();
305
+ this._started.clear();
306
+ this._pausedSystems.clear();
183
307
  this.animationData.forEach((animation) => {
184
- if (animation.type === 'ps' && animation.target instanceof ParticleEmitter) {
185
- animation.target.system.stop();
308
+ const system = particleSystemOf(animation);
309
+ if (system !== undefined) {
310
+ system.stop();
186
311
  return;
187
312
  }
188
313
  animation.clip?.stop?.();
@@ -190,86 +315,164 @@ export class QuarksPrefab extends TransformNode implements IPrefab {
190
315
  }
191
316
 
192
317
  /**
193
- * Advances the timeline, starting and ending tracks it crosses. Stops past {@link QuarksPrefab.duration}.
194
- * @param forceDelta - Seconds to advance; by default the wall-clock time since the last update.
318
+ * Advances the timeline, starting and ending the tracks whose windows it enters, leaves or
319
+ * steps over. At the end of the timeline the systems still emitting end their emission and
320
+ * their particles die out, the clips stop, and the prefab stops playing and rewinds.
321
+ * @param forceDelta - Seconds to advance, before {@link QuarksPrefab.timeScale}; by default the
322
+ * scene's frame time. Call it once a frame, or use {@link QuarksPrefab.autoUpdate}.
195
323
  */
196
324
  update(forceDelta?: number): void {
325
+ this.flushPending();
197
326
  if (!this.isPlaying) return;
198
- const now = Date.now();
199
- const delta = forceDelta ?? Math.max((now - this._lastUpdateTimeMs) / 1000, 0);
200
- this._lastUpdateTimeMs = now;
327
+ const delta = forceDelta ?? this.getScene().getEngine().getDeltaTime() / 1000;
201
328
  const previousTime = this.currentTime;
202
- this.currentTime += delta * this.timeScale;
203
-
204
- if (this.currentTime > this.duration) {
205
- this.stop();
206
- return;
207
- }
329
+ const ended = previousTime + Math.max(delta, 0) * this._timeScale >= this.duration;
330
+ this.currentTime = ended ? this.duration : previousTime + Math.max(delta, 0) * this._timeScale;
208
331
 
209
332
  this.animationData.forEach((animation) => {
210
- const startTime = animation.startTime;
211
- const endTime = animation.startTime + animation.duration;
212
- const isActive = this.currentTime >= startTime && this.currentTime <= endTime;
213
- const wasActive = previousTime >= startTime && previousTime <= endTime;
333
+ const wasActive = this.isInside(animation, previousTime);
334
+ const isActive = this.isInside(animation, this.currentTime);
335
+ const steppedOver =
336
+ !wasActive &&
337
+ !isActive &&
338
+ previousTime < animation.startTime &&
339
+ this.currentTime >= animation.startTime + animation.duration;
214
340
  if (isActive && !wasActive) {
215
- if (animation.type === 'ps' && animation.target instanceof ParticleEmitter) {
216
- animation.target.system.restart();
217
- if (this._batchedRenderer) {
218
- this._batchedRenderer.addSystem(animation.target.system);
219
- }
341
+ this.enterTrack(animation);
342
+ } else if (steppedOver) {
343
+ this.enterTrack(animation);
344
+ const system = particleSystemOf(animation);
345
+ if (system !== undefined) {
346
+ // Ended once it has stepped, so that it emits what its window began with.
347
+ this.addPending(system);
220
348
  } else {
221
- animation.clip?.play?.();
349
+ animation.clip?.stop?.();
222
350
  }
223
- return;
351
+ } else if (wasActive && !isActive) {
352
+ this.leaveTrack(animation, !ended);
224
353
  }
354
+ });
225
355
 
226
- if (!isActive && wasActive) {
227
- if (animation.loop && animation.type === 'ps' && animation.target instanceof ParticleEmitter) {
228
- animation.target.system.restart();
229
- return;
230
- }
231
- if (animation.type === 'ps' && animation.target instanceof ParticleEmitter) {
232
- animation.target.system.endEmit();
233
- } else if (animation.loop) {
234
- animation.clip?.play?.();
235
- } else {
236
- animation.clip?.stop?.();
356
+ if (ended) {
357
+ // Looping tracks restarted after their window, and anything else still emitting, end with the timeline.
358
+ for (const system of this._started) {
359
+ if (!this._pendingEndEmit.has(system) && !system.isEmitEnded) {
360
+ system.endEmit();
237
361
  }
238
362
  }
239
- });
363
+ this._started.clear();
364
+ this.isPlaying = false;
365
+ this.currentTime = -0.00001;
366
+ }
240
367
  }
241
368
 
242
- /** Jumps to `time` seconds, starting and ending tracks it crosses and scrubbing clips to it. */
369
+ /**
370
+ * Jumps to `time` seconds, starting the tracks whose windows it lands in, ending those it
371
+ * leaves, and scrubbing clips to it. A scrub that jumps over a whole window does not play it.
372
+ * While the prefab is paused, a system it starts stays paused until {@link QuarksPrefab.play}.
373
+ */
243
374
  setTime(time: number): void {
244
375
  const previousTime = this.currentTime;
245
376
  this.currentTime = time;
246
377
  this.animationData.forEach((animation) => {
247
- const startTime = animation.startTime;
248
- const endTime = animation.startTime + animation.duration;
249
- const isActive = this.currentTime >= startTime && this.currentTime < endTime;
250
- const wasActive = previousTime >= startTime && previousTime < endTime;
378
+ const isActive = this.isInside(animation, time);
379
+ const wasActive = this.isInside(animation, previousTime);
251
380
  if (isActive && !wasActive) {
252
- if (animation.type === 'ps' && animation.target instanceof ParticleEmitter) {
253
- animation.target.system.restart();
254
- } else {
255
- animation.clip?.play?.();
256
- }
257
- return;
258
- }
259
- if (!isActive && wasActive) {
260
- if (animation.type === 'ps' && animation.target instanceof ParticleEmitter) {
261
- animation.target.system.endEmit();
262
- } else {
263
- animation.clip?.stop?.();
264
- }
381
+ this.enterTrack(animation);
382
+ } else if (!isActive && wasActive) {
383
+ this.leaveTrack(animation, false);
265
384
  return;
266
385
  }
267
386
  if (isActive && animation.type === 'three') {
268
- animation.clip?.setTime?.(Math.max(time - startTime, 0));
387
+ animation.clip?.setTime?.(Math.max(time - animation.startTime, 0));
269
388
  }
270
389
  });
271
390
  }
272
391
 
392
+ /** Stops advancing with the scene, ends an emission still due, and disposes the node. */
393
+ override dispose(doNotRecurse?: boolean, disposeMaterialAndTextures?: boolean): void {
394
+ this.autoUpdate = false;
395
+ for (const system of this._pendingEndEmit) {
396
+ system.endEmit();
397
+ }
398
+ this.clearPending();
399
+ this._started.clear();
400
+ this._pausedSystems.clear();
401
+ super.dispose(doNotRecurse, disposeMaterialAndTextures);
402
+ }
403
+
404
+ /** Marks a system to end its emission once it has simulated a step since it restarted. */
405
+ private addPending(system: ParticleSystem): void {
406
+ this._pendingEndEmit.add(system);
407
+ // Also checked after each render, so it ends even when update() is no longer called.
408
+ this._pendingObserver ??= this.getScene().onAfterRenderObservable.add(() => this.flushPending());
409
+ }
410
+
411
+ /** Ends the emission of the pending systems that have stepped (or were disposed). */
412
+ private flushPending(): void {
413
+ for (const system of this._pendingEndEmit) {
414
+ if (system.emissionState.time > 0 || system.isDisposed) {
415
+ system.endEmit();
416
+ this._pendingEndEmit.delete(system);
417
+ }
418
+ }
419
+ if (this._pendingEndEmit.size === 0) {
420
+ this.clearPending();
421
+ }
422
+ }
423
+
424
+ private clearPending(): void {
425
+ this._pendingEndEmit.clear();
426
+ if (this._pendingObserver !== null) {
427
+ this.getScene().onAfterRenderObservable.remove(this._pendingObserver);
428
+ this._pendingObserver = null;
429
+ }
430
+ }
431
+
432
+ /** Whether `time` is inside the track's window, the end excluded. */
433
+ private isInside(animation: AnimationData, time: number): boolean {
434
+ return time >= animation.startTime && time < animation.startTime + animation.duration;
435
+ }
436
+
437
+ /** The timeline entered the track: restart its system, or play its clip. */
438
+ private enterTrack(animation: AnimationData): void {
439
+ const system = particleSystemOf(animation);
440
+ if (system !== undefined) {
441
+ this._pendingEndEmit.delete(system);
442
+ system.restart();
443
+ this._started.add(system);
444
+ if (!this.isPlaying) {
445
+ // Scrubbed into while paused: it waits for play().
446
+ system.pause();
447
+ this._pausedSystems.add(system);
448
+ }
449
+ this._batchedRenderer?.addSystem(system);
450
+ return;
451
+ }
452
+ animation.clip?.play?.();
453
+ }
454
+
455
+ /**
456
+ * The timeline left the track: a looping track starts over when `mayLoop` (the timeline goes
457
+ * on), anything else ends its system's emission or stops its clip.
458
+ */
459
+ private leaveTrack(animation: AnimationData, mayLoop: boolean): void {
460
+ const system = particleSystemOf(animation);
461
+ if (system !== undefined) {
462
+ if (animation.loop && mayLoop) {
463
+ system.restart();
464
+ } else {
465
+ system.endEmit();
466
+ }
467
+ return;
468
+ }
469
+ if (animation.loop && mayLoop) {
470
+ animation.clip?.play?.();
471
+ } else {
472
+ animation.clip?.stop?.();
473
+ }
474
+ }
475
+
273
476
  /** Same as {@link QuarksPrefab.duration}. */
274
477
  getDuration(): number {
275
478
  return this.duration;
@@ -285,7 +488,7 @@ export class QuarksPrefab extends TransformNode implements IPrefab {
285
488
  // A file's uuid names the node it was loaded as; a Babylon uniqueId, which older files
286
489
  // wrote for nodes made in code, only names a node that has no file uuid, and never
287
490
  // takes over an id already claimed.
288
- const fileId = (node as any)._quarksUUID as string | undefined;
491
+ const fileId = fileNodeId(node);
289
492
  if (fileId) {
290
493
  nodesMap[fileId] = node;
291
494
  } else {
@@ -328,7 +531,7 @@ export class QuarksPrefab extends TransformNode implements IPrefab {
328
531
  }
329
532
 
330
533
  /** Writes the prefab's name, time scale (when not 1) and tracks. */
331
- toJSON(): any {
534
+ toJSON(): QuarksPrefabJSON {
332
535
  return {
333
536
  type: this.type,
334
537
  name: this.name,
@@ -345,32 +548,36 @@ export class QuarksPrefab extends TransformNode implements IPrefab {
345
548
  }
346
549
 
347
550
  /** Reads a prefab written by {@link QuarksPrefab.toJSON}; call {@link QuarksPrefab.resolveReferences} afterwards. */
348
- static fromJSON(json: any, scene?: Scene): QuarksPrefab {
551
+ static fromJSON(json: Partial<QuarksPrefabJSON>, scene?: Scene): QuarksPrefab {
349
552
  const prefab = new QuarksPrefab(json.name || 'QuarksPrefab', scene);
350
553
  if (typeof json.timeScale === 'number') {
351
554
  prefab.timeScale = json.timeScale;
352
555
  }
353
556
  if (Array.isArray(json.animationData)) {
354
557
  prefab._tempAnimationJSON = json.animationData.filter(
355
- (item: any) => item?.type === 'ps' || item?.type === 'three'
558
+ (item: AnimationJSON | null) => item?.type === 'ps' || item?.type === 'three'
356
559
  );
357
560
  }
358
561
  return prefab;
359
562
  }
360
563
 
361
564
  private resolveTimelineClip(target: TransformNode, clipUUID?: string): QuarksTimelineClip | undefined {
565
+ const node: ClipHolder = target;
566
+ const metadata: ClipHolder | null | undefined = target.metadata;
362
567
  const animationSources = [
363
- (target as any).animations,
364
- (target as any).animationGroups,
365
- (target.metadata as any)?.animations,
366
- (target.metadata as any)?.animationGroups,
568
+ node.animations,
569
+ node.animationGroups,
570
+ metadata?.animations,
571
+ metadata?.animationGroups,
367
572
  ];
368
573
  for (const source of animationSources) {
369
574
  if (!Array.isArray(source)) continue;
575
+ const clips: unknown[] = source;
370
576
  if (!clipUUID) {
371
- return source[0];
577
+ // The host registered the clips there; without an id the first one plays.
578
+ return clips.find(isClip);
372
579
  }
373
- const found = source.find((item: any) => item?.uuid === clipUUID);
580
+ const found = clips.find((item): item is QuarksTimelineClip => isClipWithId(item, clipUUID));
374
581
  if (found) {
375
582
  return found;
376
583
  }