babylon.quarks 0.26.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 (83) hide show
  1. package/CHANGELOG.md +1194 -8
  2. package/README.md +13 -9
  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 +10108 -0
  10. package/dist/types/index.d.ts +5794 -998
  11. package/package.json +18 -7
  12. package/src/BatchedRenderer.ts +274 -156
  13. package/src/EffectPool.ts +94 -12
  14. package/src/MeshSurfaceEmitter.ts +50 -14
  15. package/src/ParticleEmitter.ts +75 -17
  16. package/src/ParticleSystem.ts +981 -2066
  17. package/src/QuarksLoader.ts +666 -194
  18. package/src/QuarksPrefab.ts +367 -73
  19. package/src/QuarksSerializer.ts +512 -0
  20. package/src/QuarksUtil.ts +77 -7
  21. package/src/SpriteBatch.ts +127 -252
  22. package/src/TrailBatch.ts +545 -348
  23. package/src/VFXBatch.ts +238 -596
  24. package/src/batchKey.ts +239 -0
  25. package/src/batchSettings.ts +546 -0
  26. package/src/effectDocumentJSON.ts +261 -0
  27. package/src/effectResources.ts +76 -0
  28. package/src/envAtlas.ts +126 -18
  29. package/src/geometryUtil.ts +1 -1
  30. package/src/index.ts +69 -35
  31. package/src/nodeId.ts +36 -0
  32. package/src/shaderProgram.ts +419 -0
  33. package/src/shaderVariants.ts +245 -0
  34. package/src/shaders/chunks/blend.ts +31 -0
  35. package/src/shaders/chunks/fog.ts +12 -5
  36. package/src/shaders/chunks/sheet.ts +111 -0
  37. package/src/shaders/chunks/soft.ts +85 -0
  38. package/src/shaders/chunks/surface.ts +12 -2
  39. package/src/shaders/local_particle_physics_vert.glsl.ts +14 -28
  40. package/src/shaders/local_particle_physics_vert.wgsl.ts +16 -29
  41. package/src/shaders/particle_frag.glsl.ts +5 -22
  42. package/src/shaders/particle_frag.wgsl.ts +5 -23
  43. package/src/shaders/particle_physics_frag.glsl.ts +16 -26
  44. package/src/shaders/particle_physics_frag.wgsl.ts +14 -26
  45. package/src/shaders/particle_vert.glsl.ts +5 -27
  46. package/src/shaders/particle_vert.wgsl.ts +5 -26
  47. package/src/shaders/stretched_bb_particle_vert.glsl.ts +7 -33
  48. package/src/shaders/stretched_bb_particle_vert.wgsl.ts +7 -33
  49. package/src/shaders/trail_frag.glsl.ts +13 -0
  50. package/src/shaders/trail_frag.wgsl.ts +13 -0
  51. package/src/shaders/trail_vert.glsl.ts +45 -36
  52. package/src/shaders/trail_vert.wgsl.ts +46 -40
  53. package/src/softDepth.ts +136 -0
  54. package/src/system/BehaviorPlan.ts +85 -0
  55. package/src/system/BoundsTracker.ts +181 -0
  56. package/src/system/EmissionController.ts +358 -0
  57. package/src/system/EventDispatcher.ts +60 -0
  58. package/src/system/ParticleSpawner.ts +274 -0
  59. package/src/system/SimulationClock.ts +130 -0
  60. package/src/system/geometry.ts +104 -0
  61. package/src/system/integrate.ts +428 -0
  62. package/src/system/materialResolve.ts +165 -0
  63. package/src/system/normalize.ts +100 -0
  64. package/src/system/serialization.ts +422 -0
  65. package/src/system/settingsSchema.ts +339 -0
  66. package/src/system/types.ts +449 -0
  67. package/src/textureSource.ts +30 -0
  68. package/src/trailSettings.ts +54 -0
  69. package/src/BatchedParticleRenderer.ts +0 -3
  70. package/src/materials/ParticleMaterials.ts +0 -23
  71. package/src/materials/index.ts +0 -1
  72. package/src/shaders/chunks/index.ts +0 -1
  73. package/src/shaders/chunks/register-shader-chunks.ts +0 -32
  74. package/src/shaders/chunks/soft_fragment.glsl.ts +0 -13
  75. package/src/shaders/chunks/soft_pars_fragment.glsl.ts +0 -20
  76. package/src/shaders/chunks/soft_pars_vertex.glsl.ts +0 -6
  77. package/src/shaders/chunks/soft_vertex.glsl.ts +0 -6
  78. package/src/shaders/chunks/tile_fragment.glsl.ts +0 -9
  79. package/src/shaders/chunks/tile_pars_fragment.glsl.ts +0 -9
  80. package/src/shaders/chunks/tile_pars_vertex.glsl.ts +0 -28
  81. package/src/shaders/chunks/tile_vertex.glsl.ts +0 -18
  82. package/src/shaders/index.ts +0 -9
  83. package/src/shaders/local_particle_vert.glsl.ts +0 -50
@@ -1,55 +1,202 @@
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';
7
+ import {ParticleSystem} from './ParticleSystem';
8
+ import {fileNodeId, quarksNodeId} from './nodeId';
6
9
 
10
+ /**
11
+ * A clip a {@link QuarksPrefab} timeline can drive, such as a Babylon `AnimationGroup`. Every
12
+ * member is optional; the prefab calls only what is there.
13
+ */
7
14
  export interface QuarksTimelineClip {
15
+ /** Names the clip in a saved prefab, so it can be found again on load. */
8
16
  uuid?: string;
17
+ /** Length in seconds, used when the track gives none. */
9
18
  duration?: number;
19
+ /** Called when the timeline enters the track. */
10
20
  play?: () => void;
21
+ /** Called when the prefab pauses. */
11
22
  pause?: () => void;
23
+ /** Called when the timeline leaves the track or the prefab stops. */
12
24
  stop?: () => void;
25
+ /** Called while scrubbing with {@link QuarksPrefab.setTime}, with the time into the track in seconds. */
13
26
  setTime?: (time: number) => void;
14
27
  }
15
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
+
37
+ /** One track on a {@link QuarksPrefab} timeline. */
16
38
  export interface AnimationData extends IAnimationData {
39
+ /**
40
+ * `'ps'` plays a particle system, `'three'` plays a {@link QuarksTimelineClip}. The names are
41
+ * the effect format's, shared with three.quarks, where a `'three'` track plays a three.js
42
+ * `AnimationClip`; here the clip is whatever the host registered on the target node.
43
+ */
17
44
  type: 'three' | 'ps';
45
+ /** The node the track drives: a {@link ParticleEmitter} for `'ps'`, any node for `'three'`. */
18
46
  target: TransformNode;
47
+ /** Restart when the track ends instead of letting it finish. */
19
48
  loop: boolean;
49
+ /** The clip a `'three'` track plays. */
20
50
  clip?: QuarksTimelineClip;
51
+ /** The id of the clip on `target`, written to and read from JSON. */
21
52
  clipUUID?: string;
22
53
  }
23
54
 
24
- interface AnimationJSON {
55
+ /** One track of a {@link QuarksPrefabJSON}. */
56
+ export interface AnimationJSON {
57
+ /** When the track starts, in seconds. */
25
58
  startTime: number;
59
+ /** How long it lasts, in seconds. */
26
60
  duration: number;
61
+ /** What it plays; see {@link AnimationData.type}. */
27
62
  type: 'three' | 'ps';
63
+ /** The id of the node it drives. */
28
64
  targetUUID: string;
65
+ /** A `'three'` track's clip id on that node. */
29
66
  clipUUID?: string;
67
+ /** Whether the track restarts when it ends. */
30
68
  loop: boolean;
31
69
  }
32
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
+
102
+ /**
103
+ * A node with a timeline that starts and stops the particle systems and clips under it, like a
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.
113
+ *
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.
117
+ */
33
118
  export class QuarksPrefab extends TransformNode implements IPrefab {
119
+ /** Always `'QuarksPrefab'`; written to JSON. */
34
120
  type = 'QuarksPrefab';
121
+ /** The tracks, in the order they were added. */
35
122
  animationData: Array<AnimationData> = [];
123
+ /** Whether {@link QuarksPrefab.update} advances the timeline. */
36
124
  isPlaying = false;
125
+ /** Time on the timeline in seconds; just below 0 before the first update, so a track at 0 starts. */
37
126
  currentTime = -0.00001;
38
- timeScale = 1;
127
+ /** End of the last track in seconds; the timeline ends when it reaches it. */
39
128
  duration = 0;
40
129
 
41
- private _lastUpdateTimeMs = Date.now();
130
+ private _timeScale = 1;
42
131
  private _batchedRenderer?: BatchedRenderer;
43
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>();
44
141
 
45
142
  constructor(name = 'QuarksPrefab', scene?: Scene) {
46
143
  super(name, scene);
47
144
  }
48
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
+
187
+ /** The renderer that a particle system is added to again when its track starts. */
49
188
  registerBatchedRenderer(renderer: BatchedRenderer): void {
50
189
  this._batchedRenderer = renderer;
51
190
  }
52
191
 
192
+ /**
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.
196
+ * @param startTime - Seconds into the timeline.
197
+ * @param duration - Seconds; 0 or less uses the system's own duration.
198
+ * @param loop - Restart the system when the timeline leaves the track before its end.
199
+ */
53
200
  addParticleSystemAnimation(emitter: ParticleEmitter, startTime = 0, duration = 0, loop = false): AnimationData {
54
201
  const animationDuration = duration > 0 ? duration : emitter.system.duration;
55
202
  const data: AnimationData = {
@@ -60,11 +207,18 @@ export class QuarksPrefab extends TransformNode implements IPrefab {
60
207
  target: emitter,
61
208
  };
62
209
  this.animationData.push(data);
63
- this.pause();
210
+ (emitter.system as unknown as ParticleSystem).timeDilation = this._timeScale;
64
211
  this.updateDuration();
65
212
  return data;
66
213
  }
67
214
 
215
+ /**
216
+ * Adds a track that plays `clip` on `target`.
217
+ * @param startTime - Seconds into the timeline.
218
+ * @param duration - Seconds; 0 or less uses the clip's duration.
219
+ * @param loop - Play the clip again when the track ends.
220
+ * @param clipUUID - Saved in JSON to find the clip on load; defaults to `clip.uuid`.
221
+ */
68
222
  addThreeAnimation(
69
223
  target: TransformNode,
70
224
  clip?: QuarksTimelineClip,
@@ -88,128 +242,258 @@ export class QuarksPrefab extends TransformNode implements IPrefab {
88
242
  return data;
89
243
  }
90
244
 
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
+ */
91
250
  removeAnimation(index: number): void {
92
- 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
+ }
93
261
  this.updateDuration();
94
262
  }
95
263
 
264
+ /**
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.
268
+ */
96
269
  play(): void {
97
270
  if (this.isPlaying) return;
98
271
  this.isPlaying = true;
99
- this._lastUpdateTimeMs = Date.now();
272
+ for (const system of this._pausedSystems) {
273
+ system.resume();
274
+ }
275
+ this._pausedSystems.clear();
276
+ this.animationData.forEach((animation) => {
277
+ if (animation.type === 'three' && this.isInside(animation, this.currentTime)) {
278
+ animation.clip?.play?.();
279
+ }
280
+ });
100
281
  }
101
282
 
283
+ /** Pauses the timeline, every system on it that is running and every clip. */
102
284
  pause(): void {
103
285
  if (!this.isPlaying) return;
104
286
  this.isPlaying = false;
105
287
  this.animationData.forEach((animation) => {
106
- if (animation.type === 'ps' && animation.target instanceof ParticleEmitter) {
107
- 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
+ }
108
294
  return;
109
295
  }
110
296
  animation.clip?.pause?.();
111
297
  });
112
298
  }
113
299
 
300
+ /** Pauses, rewinds to the start, and stops every system (clearing its particles) and clip on it. */
114
301
  stop(): void {
115
302
  this.pause();
116
303
  this.currentTime = -0.00001;
304
+ this.clearPending();
305
+ this._started.clear();
306
+ this._pausedSystems.clear();
117
307
  this.animationData.forEach((animation) => {
118
- if (animation.type === 'ps' && animation.target instanceof ParticleEmitter) {
119
- animation.target.system.stop();
308
+ const system = particleSystemOf(animation);
309
+ if (system !== undefined) {
310
+ system.stop();
120
311
  return;
121
312
  }
122
313
  animation.clip?.stop?.();
123
314
  });
124
315
  }
125
316
 
317
+ /**
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}.
323
+ */
126
324
  update(forceDelta?: number): void {
325
+ this.flushPending();
127
326
  if (!this.isPlaying) return;
128
- const now = Date.now();
129
- const delta = forceDelta ?? Math.max((now - this._lastUpdateTimeMs) / 1000, 0);
130
- this._lastUpdateTimeMs = now;
327
+ const delta = forceDelta ?? this.getScene().getEngine().getDeltaTime() / 1000;
131
328
  const previousTime = this.currentTime;
132
- this.currentTime += delta * this.timeScale;
133
-
134
- if (this.currentTime > this.duration) {
135
- this.stop();
136
- return;
137
- }
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;
138
331
 
139
332
  this.animationData.forEach((animation) => {
140
- const startTime = animation.startTime;
141
- const endTime = animation.startTime + animation.duration;
142
- const isActive = this.currentTime >= startTime && this.currentTime <= endTime;
143
- 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;
144
340
  if (isActive && !wasActive) {
145
- if (animation.type === 'ps' && animation.target instanceof ParticleEmitter) {
146
- animation.target.system.restart();
147
- if (this._batchedRenderer) {
148
- this._batchedRenderer.addSystem(animation.target.system);
149
- }
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);
150
348
  } else {
151
- animation.clip?.play?.();
349
+ animation.clip?.stop?.();
152
350
  }
153
- return;
351
+ } else if (wasActive && !isActive) {
352
+ this.leaveTrack(animation, !ended);
154
353
  }
354
+ });
155
355
 
156
- if (!isActive && wasActive) {
157
- if (animation.loop && animation.type === 'ps' && animation.target instanceof ParticleEmitter) {
158
- animation.target.system.restart();
159
- return;
160
- }
161
- if (animation.type === 'ps' && animation.target instanceof ParticleEmitter) {
162
- animation.target.system.endEmit();
163
- } else if (animation.loop) {
164
- animation.clip?.play?.();
165
- } else {
166
- 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();
167
361
  }
168
362
  }
169
- });
363
+ this._started.clear();
364
+ this.isPlaying = false;
365
+ this.currentTime = -0.00001;
366
+ }
170
367
  }
171
368
 
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
+ */
172
374
  setTime(time: number): void {
173
375
  const previousTime = this.currentTime;
174
376
  this.currentTime = time;
175
377
  this.animationData.forEach((animation) => {
176
- const startTime = animation.startTime;
177
- const endTime = animation.startTime + animation.duration;
178
- const isActive = this.currentTime >= startTime && this.currentTime < endTime;
179
- const wasActive = previousTime >= startTime && previousTime < endTime;
378
+ const isActive = this.isInside(animation, time);
379
+ const wasActive = this.isInside(animation, previousTime);
180
380
  if (isActive && !wasActive) {
181
- if (animation.type === 'ps' && animation.target instanceof ParticleEmitter) {
182
- animation.target.system.restart();
183
- } else {
184
- animation.clip?.play?.();
185
- }
186
- return;
187
- }
188
- if (!isActive && wasActive) {
189
- if (animation.type === 'ps' && animation.target instanceof ParticleEmitter) {
190
- animation.target.system.endEmit();
191
- } else {
192
- animation.clip?.stop?.();
193
- }
381
+ this.enterTrack(animation);
382
+ } else if (!isActive && wasActive) {
383
+ this.leaveTrack(animation, false);
194
384
  return;
195
385
  }
196
386
  if (isActive && animation.type === 'three') {
197
- animation.clip?.setTime?.(Math.max(time - startTime, 0));
387
+ animation.clip?.setTime?.(Math.max(time - animation.startTime, 0));
198
388
  }
199
389
  });
200
390
  }
201
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
+
476
+ /** Same as {@link QuarksPrefab.duration}. */
202
477
  getDuration(): number {
203
478
  return this.duration;
204
479
  }
205
480
 
481
+ /**
482
+ * Links the tracks read by {@link QuarksPrefab.fromJSON} to the nodes under `root` they name.
483
+ * {@link QuarksLoader} calls it once the whole effect is built; tracks whose node is missing are dropped.
484
+ */
206
485
  resolveReferences(root: TransformNode): void {
207
486
  const nodesMap: {[uuid: string]: TransformNode} = {};
208
487
  const traverse = (node: TransformNode) => {
209
- if ((node as any)._quarksUUID) {
210
- nodesMap[(node as any)._quarksUUID] = node;
488
+ // A file's uuid names the node it was loaded as; a Babylon uniqueId, which older files
489
+ // wrote for nodes made in code, only names a node that has no file uuid, and never
490
+ // takes over an id already claimed.
491
+ const fileId = fileNodeId(node);
492
+ if (fileId) {
493
+ nodesMap[fileId] = node;
494
+ } else {
495
+ nodesMap[node.uniqueId.toString()] ??= node;
211
496
  }
212
- nodesMap[node.uniqueId.toString()] = node;
213
497
  const children = node.getChildren();
214
498
  for (const child of children) {
215
499
  if (child instanceof TransformNode) {
@@ -246,44 +530,54 @@ export class QuarksPrefab extends TransformNode implements IPrefab {
246
530
  this._tempAnimationJSON = [];
247
531
  }
248
532
 
249
- toJSON(): any {
533
+ /** Writes the prefab's name, time scale (when not 1) and tracks. */
534
+ toJSON(): QuarksPrefabJSON {
250
535
  return {
251
536
  type: this.type,
252
537
  name: this.name,
538
+ ...(this.timeScale !== 1 ? {timeScale: this.timeScale} : {}),
253
539
  animationData: this.animationData.map((animation) => ({
254
540
  startTime: animation.startTime,
255
541
  duration: animation.duration,
256
542
  type: animation.type,
257
- targetUUID: (animation.target as any)._quarksUUID ?? animation.target.uniqueId.toString(),
543
+ targetUUID: quarksNodeId(animation.target),
258
544
  clipUUID: animation.type === 'three' ? (animation.clipUUID ?? animation.clip?.uuid) : undefined,
259
545
  loop: animation.loop,
260
546
  })),
261
547
  };
262
548
  }
263
549
 
264
- static fromJSON(json: any, scene?: Scene): QuarksPrefab {
550
+ /** Reads a prefab written by {@link QuarksPrefab.toJSON}; call {@link QuarksPrefab.resolveReferences} afterwards. */
551
+ static fromJSON(json: Partial<QuarksPrefabJSON>, scene?: Scene): QuarksPrefab {
265
552
  const prefab = new QuarksPrefab(json.name || 'QuarksPrefab', scene);
553
+ if (typeof json.timeScale === 'number') {
554
+ prefab.timeScale = json.timeScale;
555
+ }
266
556
  if (Array.isArray(json.animationData)) {
267
557
  prefab._tempAnimationJSON = json.animationData.filter(
268
- (item: any) => item?.type === 'ps' || item?.type === 'three'
558
+ (item: AnimationJSON | null) => item?.type === 'ps' || item?.type === 'three'
269
559
  );
270
560
  }
271
561
  return prefab;
272
562
  }
273
563
 
274
564
  private resolveTimelineClip(target: TransformNode, clipUUID?: string): QuarksTimelineClip | undefined {
565
+ const node: ClipHolder = target;
566
+ const metadata: ClipHolder | null | undefined = target.metadata;
275
567
  const animationSources = [
276
- (target as any).animations,
277
- (target as any).animationGroups,
278
- (target.metadata as any)?.animations,
279
- (target.metadata as any)?.animationGroups,
568
+ node.animations,
569
+ node.animationGroups,
570
+ metadata?.animations,
571
+ metadata?.animationGroups,
280
572
  ];
281
573
  for (const source of animationSources) {
282
574
  if (!Array.isArray(source)) continue;
575
+ const clips: unknown[] = source;
283
576
  if (!clipUUID) {
284
- return source[0];
577
+ // The host registered the clips there; without an id the first one plays.
578
+ return clips.find(isClip);
285
579
  }
286
- const found = source.find((item: any) => item?.uuid === clipUUID);
580
+ const found = clips.find((item): item is QuarksTimelineClip => isClipWithId(item, clipUUID));
287
581
  if (found) {
288
582
  return found;
289
583
  }