babylon.quarks 0.27.0 → 0.29.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 (55) hide show
  1. package/CHANGELOG.md +585 -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 +4811 -974
  10. package/dist/types/index.d.ts +4811 -974
  11. package/package.json +7 -2
  12. package/src/BatchedRenderer.ts +106 -79
  13. package/src/EffectPool.ts +96 -16
  14. package/src/MeshSurfaceEmitter.ts +24 -13
  15. package/src/ParticleEmitter.ts +119 -26
  16. package/src/ParticleSystem.ts +305 -55
  17. package/src/QuarksLoader.ts +298 -171
  18. package/src/QuarksPrefab.ts +301 -94
  19. package/src/QuarksSerializer.ts +44 -16
  20. package/src/QuarksUtil.ts +88 -11
  21. package/src/SpriteBatch.ts +24 -6
  22. package/src/TrailBatch.ts +20 -7
  23. package/src/VFXBatch.ts +182 -528
  24. package/src/batchKey.ts +6 -2
  25. package/src/batchSettings.ts +548 -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 +41 -4
  31. package/src/nodeId.ts +25 -2
  32. package/src/shaderProgram.ts +213 -16
  33. package/src/shaderVariants.ts +124 -7
  34. package/src/shaders/chunks/blend.ts +39 -0
  35. package/src/shaders/chunks/fog.ts +8 -3
  36. package/src/shaders/chunks/imageProcessing.ts +143 -0
  37. package/src/shaders/chunks/soft.ts +6 -5
  38. package/src/shaders/chunks/surface.ts +2 -2
  39. package/src/shaders/particle_frag.glsl.ts +5 -1
  40. package/src/shaders/particle_frag.wgsl.ts +4 -0
  41. package/src/shaders/particle_physics_frag.glsl.ts +7 -2
  42. package/src/shaders/particle_physics_frag.wgsl.ts +4 -0
  43. package/src/shaders/trail_frag.glsl.ts +6 -1
  44. package/src/shaders/trail_frag.wgsl.ts +4 -0
  45. package/src/system/EmissionController.ts +23 -20
  46. package/src/system/EventDispatcher.ts +26 -10
  47. package/src/system/ParticleSpawner.ts +96 -19
  48. package/src/system/geometry.ts +22 -29
  49. package/src/system/integrate.ts +61 -4
  50. package/src/system/materialResolve.ts +56 -7
  51. package/src/system/normalize.ts +34 -2
  52. package/src/system/serialization.ts +54 -33
  53. package/src/system/settingsSchema.ts +57 -17
  54. package/src/system/types.ts +150 -42
  55. package/src/trailSettings.ts +9 -6
@@ -23,6 +23,7 @@ import {
23
23
  ParticleSystemEventType,
24
24
  Quaternion,
25
25
  RandomSource,
26
+ readFormatPart,
26
27
  RendererEmitterSettings,
27
28
  RotationGenerator,
28
29
  SerializationOptions,
@@ -60,10 +61,11 @@ import {
60
61
  moveTrails,
61
62
  recordTrailHistory,
62
63
  removeDeadTrails,
64
+ RingBufferStep,
63
65
  runBehaviorSteps,
64
66
  snapshotPreviousState,
65
67
  } from './system/integrate';
66
- import {liveAtlas, MaterialOverrides, resolveMaterialSettings} from './system/materialResolve';
68
+ import {liveAtlas, MaterialOverrides, ParticleMaterialSource, resolveMaterialSettings} from './system/materialResolve';
67
69
  import {
68
70
  DEFAULT_SOFT_FAR_FADE,
69
71
  nonZeroFlip,
@@ -73,9 +75,14 @@ import {
73
75
  validAlphaTest,
74
76
  validCullingMode,
75
77
  validLighting,
78
+ validLoopRange,
76
79
  validMaxParticles,
80
+ validRenderFace,
81
+ validRingBufferMode,
82
+ validScalingMode,
77
83
  validSoftFade,
78
84
  validSortMode,
85
+ validStopAction,
79
86
  validTileCount,
80
87
  } from './system/normalize';
81
88
  import {ParticleSpawner} from './system/ParticleSpawner';
@@ -101,6 +108,7 @@ import {
101
108
  ParticleGradientMap,
102
109
  ParticleUVDistortion,
103
110
  ParticleUVDistortionOptions,
111
+ RenderFace,
104
112
  RenderMode,
105
113
  SortMode,
106
114
  TextureUV,
@@ -113,7 +121,9 @@ import type {
113
121
  CullingMode,
114
122
  ParticleSystemJSONParameters,
115
123
  ParticleSystemParameters,
124
+ RingBufferMode,
116
125
  ScalingMode,
126
+ StopAction,
117
127
  } from './system/types';
118
128
 
119
129
  export type {
@@ -124,7 +134,9 @@ export type {
124
134
  CullingMode,
125
135
  ParticleSystemJSONParameters,
126
136
  ParticleSystemParameters,
137
+ RingBufferMode,
127
138
  ScalingMode,
139
+ StopAction,
128
140
  } from './system/types';
129
141
 
130
142
  // Scratch for the per-step emitter matrix of a frame that interpolates, see `update`.
@@ -152,8 +164,42 @@ const stepScale = new Vector3();
152
164
  * start values and emission, and the particle's age over its life inside behaviors.
153
165
  */
154
166
  export class ParticleSystem implements IParticleSystem {
155
- /** Dispose the system, its emitter and its batch slot when it finishes. Default false. */
156
- autoDestroy: boolean;
167
+ /**
168
+ * What the system does once it has finished (Unity's Stop Action): nothing, disable its
169
+ * emitter, or dispose it; see {@link StopAction}. `'none'` by default.
170
+ *
171
+ * As in Unity, the action waits for the system's sub emitters: it runs once the system has
172
+ * finished and none of the systems it emits into, nor theirs, has a particle left (see
173
+ * {@link isAlive}), so disabling or disposing a parent does not cut its sub emitters'
174
+ * particles short. A sub emitter target ({@link onlyUsedByOther}) takes no stop action of its
175
+ * own: it is empty between its parent's bursts, and its parent decides when the effect is done.
176
+ */
177
+ stopAction: StopAction;
178
+
179
+ /**
180
+ * Skips the {@link stopAction} while true, so a tool can play an effect over and over (an
181
+ * editor's preview, a gallery, a pool of reused copies) and still save the stop action it
182
+ * was authored with, as Unity applies stop actions only in Play mode. The `finished` event
183
+ * fires either way. False by default; never saved, cloned or loaded.
184
+ */
185
+ ignoreStopAction = false;
186
+
187
+ /**
188
+ * Whether the system disposes itself when it finishes: `stopAction === 'destroy'`. Setting it
189
+ * true sets that stop action, and false turns a `'destroy'` one into `'none'`.
190
+ * @deprecated Use {@link stopAction}.
191
+ */
192
+ get autoDestroy(): boolean {
193
+ return this.stopAction === 'destroy';
194
+ }
195
+
196
+ set autoDestroy(value: boolean) {
197
+ if (value) {
198
+ this.stopAction = 'destroy';
199
+ } else if (this.stopAction === 'destroy') {
200
+ this.stopAction = 'none';
201
+ }
202
+ }
157
203
  /**
158
204
  * Start a looping system as if it had already run one full cycle (Unity's Prewarm), so a fire
159
205
  * or a mist is full from its first frame. Default false.
@@ -233,9 +279,24 @@ export class ParticleSystem implements IParticleSystem {
233
279
  /**
234
280
  * Most particles alive at once (Unity's Max Particles); emission past it is dropped. Undefined
235
281
  * means no limit, as does a negative or NaN count given to the constructor; a fraction is
236
- * rounded down.
282
+ * rounded down. With a {@link ringBufferMode}, new particles replace the oldest instead.
237
283
  */
238
284
  maxParticles?: number;
285
+ /**
286
+ * What a full system does with new particles (Unity's Ring Buffer Mode): drop them, or
287
+ * replace the oldest, which wait frozen or looping until then; see {@link RingBufferMode}.
288
+ * `'disabled'` by default. Takes effect only with {@link maxParticles}.
289
+ */
290
+ ringBufferMode: RingBufferMode;
291
+ /**
292
+ * The part of its life a particle plays again in `'loopUntilReplaced'` (Unity's Loop Range):
293
+ * at the end of its life it goes back to `x` and plays to `y`, as fractions of its life, 0 to
294
+ * 1. (0, 1), the default, replays its whole life. Both ends are read clamped into 0..1 and in
295
+ * order, however the vector was set. A range of no length, such as (0.5, 0.5), holds the
296
+ * particle still at that point of its life; behaviors that act by age stop with it, those
297
+ * that act per second (rotation speed, forces) still run.
298
+ */
299
+ ringBufferLoopRange: Vector2;
239
300
  /**
240
301
  * The system emits nothing of its own and only spawns what an `EmitSubParticleSystem` on
241
302
  * another system sends it (a Unity sub emitter). Default false.
@@ -295,7 +356,7 @@ export class ParticleSystem implements IParticleSystem {
295
356
  private readonly emission = new EmissionController(this, this.spawner);
296
357
  private readonly events = new EventDispatcher();
297
358
  private readonly layerMaskProxy: {mask: number};
298
- private materialRef: any = null;
359
+ private materialRef: ParticleMaterialSource | null = null;
299
360
  private sceneRef: Scene | undefined;
300
361
  private qualityFactor = 1;
301
362
  private destroyed = false;
@@ -336,9 +397,12 @@ export class ParticleSystem implements IParticleSystem {
336
397
  return this.emitEnded;
337
398
  }
338
399
 
339
- /** Whether continuous or queued emission can still spawn particles before the system is done. */
340
- hasPendingEmission(): boolean {
341
- return this.emission.hasPendingEmission();
400
+ /**
401
+ * Whether continuous or queued emission can still spawn particles before the system is done:
402
+ * its own, or with `state` one a sub emitter runs into this system for a parent particle.
403
+ */
404
+ hasPendingEmission(state?: EmissionState): boolean {
405
+ return this.emission.hasPendingEmission(state);
342
406
  }
343
407
 
344
408
  /**
@@ -347,7 +411,7 @@ export class ParticleSystem implements IParticleSystem {
347
411
  * {@link endEmit} and whose last particle has died.
348
412
  */
349
413
  isFinished(): boolean {
350
- if (this.particleNum > 0) {
414
+ if (this.particleNum > 0 || this.hasPendingSubEmission()) {
351
415
  return false;
352
416
  }
353
417
  // A sub emitter target emits nothing of its own: empty is finished,
@@ -395,11 +459,11 @@ export class ParticleSystem implements IParticleSystem {
395
459
  * is not drawn with; particles always use quarks' own shaders. Null leaves the settings as they
396
460
  * are.
397
461
  */
398
- get material(): any {
462
+ get material(): ParticleMaterialSource | null {
399
463
  return this.materialRef;
400
464
  }
401
465
 
402
- set material(material: any) {
466
+ set material(material: ParticleMaterialSource | null) {
403
467
  this.materialRef = material;
404
468
  this.applyMaterialSettings(material);
405
469
  }
@@ -473,6 +537,21 @@ export class ParticleSystem implements IParticleSystem {
473
537
  this.neededToUpdateRender = true;
474
538
  }
475
539
 
540
+ /**
541
+ * Which faces of a mesh particle are drawn (Unity's Render Face): `'both'` by default,
542
+ * `'front'` to cull the back faces of a closed mesh, `'back'` to see only its inside. Applies
543
+ * to the Mesh render mode; the other modes draw both faces. See {@link RenderFace}. Saved in
544
+ * the effect JSON only when not `'both'`.
545
+ */
546
+ get renderFace(): RenderFace {
547
+ return this.rendererSettings.renderFace ?? 'both';
548
+ }
549
+
550
+ set renderFace(face: RenderFace) {
551
+ this.rendererSettings.renderFace = validRenderFace(face);
552
+ this.neededToUpdateRender = true;
553
+ }
554
+
476
555
  /**
477
556
  * Smallest size a billboard or stretched particle is drawn at, as a
478
557
  * fraction of the viewport height (Unity's Min Particle Size); 0 by
@@ -620,26 +699,15 @@ export class ParticleSystem implements IParticleSystem {
620
699
  this.neededToUpdateRender = true;
621
700
  }
622
701
 
623
- /**
624
- * The emitter's world scale per axis, measured with {@link motionScale}
625
- * before every step; what `'hierarchy'` multiplies start speeds by. Taken
626
- * from the system's own emitter rather than the emission matrix, whose
627
- * columns carry a parent particle's velocity for a sub emitter.
628
- * @internal
629
- */
630
- readonly hierarchyScale = new Vector3(1, 1, 1);
631
-
632
- /** The mean world scale of the emitter for a world-space `'hierarchy'` system, else 1. */
702
+ /** The mean scale of the emitter's matrix for a world-space system, else 1. */
633
703
  private measureMotionScale(emitterMatrix: Matrix4): number {
634
- if (this.scalingMode !== 'hierarchy' || !this.worldSpace) {
635
- this.hierarchyScale.set(1, 1, 1);
704
+ if (!this.worldSpace) {
636
705
  return 1;
637
706
  }
638
707
  const e = emitterMatrix.elements;
639
708
  const sx = Math.sqrt(e[0] * e[0] + e[1] * e[1] + e[2] * e[2]);
640
709
  const sy = Math.sqrt(e[4] * e[4] + e[5] * e[5] + e[6] * e[6]);
641
710
  const sz = Math.sqrt(e[8] * e[8] + e[9] * e[9] + e[10] * e[10]);
642
- this.hierarchyScale.set(sx, sy, sz);
643
711
  return (sx + sy + sz) / 3;
644
712
  }
645
713
 
@@ -930,6 +998,34 @@ export class ParticleSystem implements IParticleSystem {
930
998
  this.neededToUpdateRender = true;
931
999
  }
932
1000
 
1001
+ /**
1002
+ * Whether the particles' material applies the scene's image processing (exposure, contrast,
1003
+ * tone mapping, vignette, colour curves, colour grading, dithering of
1004
+ * `scene.imageProcessingConfiguration`), as Babylon's own particle systems and materials do;
1005
+ * true by default. A scene whose configuration does nothing leaves the colour as it is, and
1006
+ * one applied by a post process gets the colour in linear space. Particles that multiply the
1007
+ * frame (`ALPHA_MULTIPLY`) are a factor rather than light and are not tone mapped.
1008
+ *
1009
+ * As in Babylon, the processing runs on each particle before it is blended: tone mapping
1010
+ * clamps an HDR colour to the displayable range, and an additive particle picks up what the
1011
+ * configuration does to black (a contrast below 1, colour curves or an
1012
+ * opaque vignette lift it). The configuration is the scene's;
1013
+ * there is no per-system one.
1014
+ *
1015
+ * Switched off, the material writes its colour as authored, and leaves it in gamma space under
1016
+ * a post process, which still processes the frame the particles are in. To keep particles out
1017
+ * of a post process, draw them with a camera the pipeline is not attached to. Saved in the
1018
+ * effect JSON only when switched off.
1019
+ */
1020
+ get applyImageProcessing(): boolean {
1021
+ return this.rendererSettings.applyImageProcessing !== false;
1022
+ }
1023
+
1024
+ set applyImageProcessing(apply: boolean) {
1025
+ this.rendererSettings.applyImageProcessing = apply;
1026
+ this.neededToUpdateRender = true;
1027
+ }
1028
+
933
1029
  /**
934
1030
  * Multiplier on the time this system is advanced by (Unity's Simulation
935
1031
  * Speed): 2 runs the effect twice as fast, 0 freezes it without pausing the
@@ -946,6 +1042,24 @@ export class ParticleSystem implements IParticleSystem {
946
1042
 
947
1043
  private playbackSpeedValue = 1;
948
1044
 
1045
+ /**
1046
+ * A runtime multiplier on {@link playbackSpeed} that is not part of the effect: what a
1047
+ * timeline, a slow-motion moment or a game's pause menu sets on a system it plays, like
1048
+ * Unreal's Custom Time Dilation or Unity's `VisualEffect.playRate`. The system runs at
1049
+ * `playbackSpeed * timeDilation`. 1 by default; never saved, cloned or loaded. A negative,
1050
+ * NaN or infinite value is read as 0. A `QuarksPrefab` sets it to its `timeScale` on the systems of
1051
+ * its tracks.
1052
+ */
1053
+ get timeDilation(): number {
1054
+ return this.timeDilationValue;
1055
+ }
1056
+
1057
+ set timeDilation(dilation: number) {
1058
+ this.timeDilationValue = dilation > 0 && dilation < Infinity ? dilation : 0;
1059
+ }
1060
+
1061
+ private timeDilationValue = 1;
1062
+
949
1063
  /**
950
1064
  * Runtime multiplier on how many particles the system emits: bursts,
951
1065
  * emission over time and over distance alike. Unlike
@@ -967,13 +1081,14 @@ export class ParticleSystem implements IParticleSystem {
967
1081
  /** What this system does while off screen, see {@link CullingMode}. `'always'` by default. */
968
1082
  cullingMode: CullingMode = 'always';
969
1083
 
970
- /** What the emitter's world scale does to a world-space system, see {@link ScalingMode}. `'shape'` by default. */
971
- scalingMode: ScalingMode = 'shape';
1084
+ /** What the emitter's scale does to the system, see {@link ScalingMode}. `'local'` by default. */
1085
+ scalingMode: ScalingMode = 'local';
972
1086
 
973
1087
  /**
974
- * What world-space forces and speed limits are multiplied by this step:
975
- * the mean of the emitter's world scale axes in `'hierarchy'` mode, 1
976
- * otherwise. Updated before every simulation step.
1088
+ * What world-space forces and speed limits are multiplied by this step: the mean of the axes
1089
+ * of the scale the {@link scalingMode} applies (the emitter's own in `'local'`, its world scale
1090
+ * in `'hierarchy'`, 1 in `'shape'`) in a world-space system, 1 in a local-space one, whose
1091
+ * emitter matrix scales them already. Updated before every simulation step.
977
1092
  */
978
1093
  motionScale = 1;
979
1094
 
@@ -1058,7 +1173,9 @@ export class ParticleSystem implements IParticleSystem {
1058
1173
  },
1059
1174
  });
1060
1175
 
1061
- this.autoDestroy = parameters.autoDestroy ?? false;
1176
+ this.stopAction = validStopAction(
1177
+ parameters.stopAction ?? (parameters.autoDestroy === true ? 'destroy' : undefined)
1178
+ );
1062
1179
  this.duration = parameters.duration ?? 1;
1063
1180
  this.startDelay = parameters.startDelay ?? new ConstantValue(0);
1064
1181
  this.looping = parameters.looping ?? true;
@@ -1073,6 +1190,8 @@ export class ParticleSystem implements IParticleSystem {
1073
1190
  this.emissionOverDistance = parameters.emissionOverDistance ?? new ConstantValue(0);
1074
1191
  this.emissionBursts = normalizeBursts(parameters.emissionBursts);
1075
1192
  this.maxParticles = validMaxParticles(parameters.maxParticles);
1193
+ this.ringBufferMode = validRingBufferMode(parameters.ringBufferMode);
1194
+ this.ringBufferLoopRange = validLoopRange(parameters.ringBufferLoopRange);
1076
1195
  this.onlyUsedByOther = parameters.onlyUsedByOther ?? false;
1077
1196
  this.emitterShape = parameters.shape ?? new SphereEmitter();
1078
1197
  this.behaviors = parameters.behaviors ?? [];
@@ -1125,10 +1244,12 @@ export class ParticleSystem implements IParticleSystem {
1125
1244
  layerMask: parameters.layerMask ?? 0x0fffffff,
1126
1245
  pivot: nonZeroPivot(parameters.pivot),
1127
1246
  sortMode: validSortMode(parameters.sortMode),
1247
+ renderFace: validRenderFace(parameters.renderFace),
1128
1248
  minParticleSize: Math.max(0, parameters.minParticleSize ?? 0),
1129
1249
  maxParticleSize: parameters.maxParticleSize ?? Infinity,
1130
1250
  renderingGroupId: parameters.renderingGroupId ?? 0,
1131
1251
  applyFog: parameters.applyFog ?? true,
1252
+ applyImageProcessing: parameters.applyImageProcessing ?? true,
1132
1253
  lighting: validLighting(parameters.lighting),
1133
1254
  rim: normalizeMeshRim(parameters.rim),
1134
1255
  mapUV: normalizeMapUV(parameters.mapUV),
@@ -1139,7 +1260,7 @@ export class ParticleSystem implements IParticleSystem {
1139
1260
  this.flip = nonZeroFlip(parameters.flip);
1140
1261
  this.playbackSpeed = parameters.playbackSpeed ?? 1;
1141
1262
  this.cullingMode = validCullingMode(parameters.cullingMode);
1142
- this.scalingMode = parameters.scalingMode === 'hierarchy' ? 'hierarchy' : 'shape';
1263
+ this.scalingMode = validScalingMode(parameters.scalingMode);
1143
1264
  this.alignToDirection = parameters.alignToDirection === true;
1144
1265
  this.seed = parameters.seed;
1145
1266
  this.ensureMeshNormals();
@@ -1184,7 +1305,7 @@ export class ParticleSystem implements IParticleSystem {
1184
1305
  * ran) while the system still uses it, drops it and builds a new one if the cube is still alive,
1185
1306
  * so the batch never samples a disposed texture.
1186
1307
  */
1187
- private watchEnvAtlas(atlas: BaseTexture | null, material: any): void {
1308
+ private watchEnvAtlas(atlas: BaseTexture | null, material: ParticleMaterialSource | null): void {
1188
1309
  atlas?.onDisposeObservable.addOnce(() => {
1189
1310
  if (this.destroyed || this.rendererSettings.reflectionAtlas !== atlas) {
1190
1311
  return;
@@ -1211,7 +1332,7 @@ export class ParticleSystem implements IParticleSystem {
1211
1332
  });
1212
1333
  }
1213
1334
 
1214
- private applyMaterialSettings(material: any, overrides: MaterialOverrides = {}) {
1335
+ private applyMaterialSettings(material: ParticleMaterialSource | null, overrides: MaterialOverrides = {}) {
1215
1336
  /* istanbul ignore if -- rendererSettings is always assigned in the constructor before any code path can call this private method */
1216
1337
  if (!this.rendererSettings) {
1217
1338
  return;
@@ -1261,10 +1382,30 @@ export class ParticleSystem implements IParticleSystem {
1261
1382
  }
1262
1383
 
1263
1384
  /**
1264
- * Resumes a paused system. It only resumes: a system that has finished ({@link isFinished})
1265
- * stays finished, since nothing is left to emit; {@link restart} plays it again from the start.
1385
+ * Plays the system, like Unity's `Play`: a paused or stopped system resumes from where it is,
1386
+ * and one that has finished ({@link isFinished}) starts over from the beginning, as
1387
+ * {@link restart} does. A playing system that has not finished is left as it is. To undo a
1388
+ * {@link pause} without replaying a system that had finished, use {@link resume}.
1266
1389
  */
1267
1390
  play(): void {
1391
+ if (this.destroyed) {
1392
+ return;
1393
+ }
1394
+ if (this.isFinished()) {
1395
+ this.restart();
1396
+ }
1397
+ this.resume();
1398
+ }
1399
+
1400
+ /**
1401
+ * Undoes {@link pause}: the system goes on from where it is, and one that has finished stays
1402
+ * finished. For a pause button over a whole scene, where {@link play} would replay the
1403
+ * effects that had already ended.
1404
+ */
1405
+ resume(): void {
1406
+ if (this.destroyed) {
1407
+ return;
1408
+ }
1268
1409
  this.paused = false;
1269
1410
  // A move made while paused is a placement, not motion to spread the next frame over.
1270
1411
  this.previousEmitterMatrix = undefined;
@@ -1327,7 +1468,7 @@ export class ParticleSystem implements IParticleSystem {
1327
1468
  /**
1328
1469
  * Stops emission and lets the particles alive die out (Unity's `Stop` with
1329
1470
  * `StopEmitting`). The system fires `emitEnd`, then `finished` when the last particle has
1330
- * died, and is disposed then if {@link autoDestroy} is set. {@link restart} starts it again.
1471
+ * died, and then does what its {@link stopAction} says. {@link restart} starts it again.
1331
1472
  */
1332
1473
  endEmit(): void {
1333
1474
  this.emitEnded = true;
@@ -1406,16 +1547,12 @@ export class ParticleSystem implements IParticleSystem {
1406
1547
  // Bursts edited in place since the last play are put back in time order and in range.
1407
1548
  this.emissionBursts.forEach((burst) => Object.assign(burst, normalizeBurst(burst)));
1408
1549
  this.emissionBursts.sort((a, b) => a.time - b.time);
1409
- this.behaviors.forEach((behavior) => {
1410
- behavior.reset();
1411
- if (behavior instanceof EmitSubParticleSystem) {
1412
- behavior.clearEmissions();
1413
- }
1414
- });
1550
+ this.behaviors.forEach((behavior) => behavior.reset());
1415
1551
  // A sweeping shape (Loop, PingPong) starts its arc over, so the replay is the same.
1416
1552
  this.emitterShape.reset?.();
1417
1553
  this.emitEnded = false;
1418
1554
  this.finishedEventFired = false;
1555
+ this.stopActionPending = false;
1419
1556
  this.prewarmed = false;
1420
1557
  this.initSystemGenerators();
1421
1558
  this.previousEmitterPos = undefined;
@@ -1439,7 +1576,7 @@ export class ParticleSystem implements IParticleSystem {
1439
1576
  */
1440
1577
  update(delta: number, stepDeadline = Infinity, catchUpDeadline?: number) {
1441
1578
  if (this.paused || this.destroyed) return;
1442
- const speed = this.playbackSpeed;
1579
+ const speed = this.playbackSpeed * this.timeDilationValue;
1443
1580
  delta = SimulationClock.frameSeconds(delta, speed);
1444
1581
 
1445
1582
  if (this.culled) {
@@ -1466,6 +1603,9 @@ export class ParticleSystem implements IParticleSystem {
1466
1603
  }
1467
1604
 
1468
1605
  if (!this.looping && this.finishedEventFired && this.particleNum === 0) {
1606
+ // Finished; the stop action may still be waiting for the sub emitters' particles.
1607
+ this.applyStopAction();
1608
+ if (this.destroyed) return;
1469
1609
  // Still tick the fixed-step clock. A sub-emitter target
1470
1610
  // (onlyUsedByOther) goes empty between parent bursts; skipping the
1471
1611
  // accumulator here desyncs it from the parent, so the next particles
@@ -1647,9 +1787,9 @@ export class ParticleSystem implements IParticleSystem {
1647
1787
  * per-instance data, Unreal's user parameters). With {@link erosion},
1648
1788
  * alpha is the erosion level, so this wipes the shape in and out. The
1649
1789
  * particles' own colours are untouched, so clearing it restores them, and
1650
- * systems with different tints still batch together. Under additive
1651
- * (`ALPHA_ONEONE`) or premultiplied blending, which ignore alpha, fade
1652
- * with the colour instead, or with erosion on. Unset (the default): none.
1790
+ * systems with different tints still batch together. Its alpha fades the
1791
+ * particles in every blend mode that blends by coverage (see
1792
+ * {@link blendCategory}). Unset (the default): none.
1653
1793
  * Not saved in the effect JSON; copied by {@link clone}.
1654
1794
  */
1655
1795
  tint?: Vector4;
@@ -1737,6 +1877,7 @@ export class ParticleSystem implements IParticleSystem {
1737
1877
 
1738
1878
  private stepOnce(delta: number, emitterMatrix: Matrix4): void {
1739
1879
  if (this.destroyed) return;
1880
+ this.spawner.fitRing();
1740
1881
  this.motionScale = this.measureMotionScale(emitterMatrix);
1741
1882
  if (!this.onlyUsedByOther) {
1742
1883
  this.emission.emit(delta, this.emissionState, emitterMatrix);
@@ -1780,7 +1921,8 @@ export class ParticleSystem implements IParticleSystem {
1780
1921
  snapshotColor,
1781
1922
  this.bounds,
1782
1923
  this,
1783
- onDied
1924
+ onDied,
1925
+ this.ringBufferStep()
1784
1926
  );
1785
1927
  } else {
1786
1928
  // Trails: the newborn collapse, then motion, then the trail history,
@@ -1796,23 +1938,122 @@ export class ParticleSystem implements IParticleSystem {
1796
1938
  this.notifyFinished();
1797
1939
  }
1798
1940
 
1941
+ /** Whether a sub emitter of this system still has an emission running into its target. */
1942
+ private hasPendingSubEmission(): boolean {
1943
+ for (const behavior of this.behaviors) {
1944
+ if (behavior instanceof EmitSubParticleSystem && behavior.hasPendingEmission()) {
1945
+ return true;
1946
+ }
1947
+ }
1948
+ return false;
1949
+ }
1950
+
1951
+ /**
1952
+ * Whether the system still has particles or emission to come (Unity's `IsAlive`): not
1953
+ * {@link isFinished}, or, with `withSubEmitters` (the default), any system it emits into
1954
+ * through an `EmitSubParticleSystem`, and theirs, still has particles.
1955
+ */
1956
+ isAlive(withSubEmitters = true): boolean {
1957
+ if (!this.isFinished()) {
1958
+ return true;
1959
+ }
1960
+ return withSubEmitters && this.subEmittersAlive(new Set<ParticleSystem>([this]));
1961
+ }
1962
+
1963
+ /** Whether a system this one emits into has particles, or one of theirs; `seen` stops a cycle. */
1964
+ private subEmittersAlive(seen: Set<ParticleSystem>): boolean {
1965
+ for (const behavior of this.behaviors) {
1966
+ if (!(behavior instanceof EmitSubParticleSystem)) {
1967
+ continue;
1968
+ }
1969
+ const target = behavior.targetSystem;
1970
+ if (!(target instanceof ParticleSystem) || seen.has(target)) {
1971
+ continue;
1972
+ }
1973
+ seen.add(target);
1974
+ if (target.particleNum > 0 || target.hasPendingSubEmission() || target.subEmittersAlive(seen)) {
1975
+ return true;
1976
+ }
1977
+ }
1978
+ return false;
1979
+ }
1980
+
1981
+ /**
1982
+ * Whether particles outlive their lifetime, held or looped until replaced: a {@link ringBufferMode}
1983
+ * on a system with {@link maxParticles}, outside the Trail render mode.
1984
+ */
1985
+ get ringBufferActive(): boolean {
1986
+ return (
1987
+ this.ringBufferMode !== 'disabled' &&
1988
+ this.maxParticles !== undefined &&
1989
+ this.rendererSettings.renderMode !== RenderMode.Trail
1990
+ );
1991
+ }
1992
+
1799
1993
  /** Re-points every particle at the store, for {@link snapshotPreviousState}. */
1800
1994
  private readonly rebindParticles = (): void => this.spawner.rebind();
1801
1995
 
1996
+ /** The one `particleDied` event, filled in for each death so a death allocates nothing. */
1997
+ private readonly particleDiedEvent: ParticleSystemEvent = {type: 'particleDied', particleSystem: this};
1998
+
1802
1999
  /** Fires `particleDied` for a particle the step retired. */
1803
2000
  private readonly fireParticleDied = (particle: Particle): void => {
1804
- this.events.fire({type: 'particleDied', particleSystem: this, particle});
2001
+ const event = this.particleDiedEvent;
2002
+ event.particle = particle;
2003
+ this.events.fire(event);
2004
+ event.particle = undefined;
1805
2005
  };
1806
2006
 
2007
+ /** The ring buffer step for {@link integrateSprites}, or undefined when particles die by age. */
2008
+ private ringBufferStep(): RingBufferStep | undefined {
2009
+ if (!this.ringBufferActive) {
2010
+ return undefined;
2011
+ }
2012
+ const step = this.ringStep;
2013
+ step.pause = this.ringBufferMode === 'pauseUntilReplaced';
2014
+ // Read through the same clamp as the constructor: the field can be assigned anything.
2015
+ const {x, y} = this.ringBufferLoopRange;
2016
+ const start = Number.isFinite(x) ? Math.min(1, Math.max(0, x)) : 0;
2017
+ const end = Number.isFinite(y) ? Math.min(1, Math.max(0, y)) : 1;
2018
+ step.loopStart = Math.min(start, end);
2019
+ step.loopEnd = Math.max(start, end);
2020
+ return step;
2021
+ }
2022
+
2023
+ private readonly ringStep: RingBufferStep = {pause: true, loopStart: 0, loopEnd: 1};
2024
+
2025
+ /** Set on each finish until the {@link stopAction} has run, which waits for the sub emitters. */
2026
+ private stopActionPending = false;
2027
+
1807
2028
  private notifyFinished(): void {
1808
2029
  if (!this.isFinished()) {
1809
2030
  return;
1810
2031
  }
1811
2032
  if (!this.finishedEventFired) {
1812
2033
  this.finishedEventFired = true;
2034
+ // Once per finish, so an emitter enabled again from a listener or later stays enabled.
2035
+ this.stopActionPending = true;
1813
2036
  this.events.fire({type: 'finished', particleSystem: this});
1814
2037
  }
1815
- if (this.autoDestroy) {
2038
+ this.applyStopAction();
2039
+ }
2040
+
2041
+ /** Runs a pending {@link stopAction} once no system this one emits into has particles left. */
2042
+ private applyStopAction(): void {
2043
+ if (!this.stopActionPending) {
2044
+ return;
2045
+ }
2046
+ if (this.stopAction === 'none' || this.ignoreStopAction || this.onlyUsedByOther || this.destroyed) {
2047
+ this.stopActionPending = false;
2048
+ return;
2049
+ }
2050
+ if (this.subEmittersAlive(new Set<ParticleSystem>([this]))) {
2051
+ return;
2052
+ }
2053
+ this.stopActionPending = false;
2054
+ if (this.stopAction === 'disable') {
2055
+ this.emitter.setEnabled(false);
2056
+ } else {
1816
2057
  this.dispose();
1817
2058
  }
1818
2059
  }
@@ -1850,10 +2091,11 @@ export class ParticleSystem implements IParticleSystem {
1850
2091
  ): ParticleSystem {
1851
2092
  const ps = new ParticleSystem(particleSystemParametersFromJSON(json, meta, scene));
1852
2093
  ps.behaviors = (json.behaviors ?? [])
1853
- .map((behaviorJson) => {
1854
- const behavior = BehaviorFromJSON(behaviorJson, ps);
1855
- if (behavior && behavior.type === 'EmitSubParticleSystem') {
1856
- dependencies[(behaviorJson as {subParticleSystem: string}).subParticleSystem] = behavior;
2094
+ .map((behaviorJson, index) => {
2095
+ const behavior = readFormatPart(`behaviors[${index}]`, () => BehaviorFromJSON(behaviorJson, ps));
2096
+ const target = (behaviorJson as {subParticleSystem?: unknown}).subParticleSystem;
2097
+ if (behavior && behavior.type === 'EmitSubParticleSystem' && typeof target === 'string') {
2098
+ dependencies[target] = behavior;
1857
2099
  }
1858
2100
  return behavior;
1859
2101
  })
@@ -1880,13 +2122,21 @@ export class ParticleSystem implements IParticleSystem {
1880
2122
  * Calls `callback` on each `event`: `'emitEnd'` when emission ends, `'finished'` when the last
1881
2123
  * particle of an ended system dies, `'particleDied'` per particle (with `event.particle`), and
1882
2124
  * `'destroy'` on dispose.
2125
+ *
2126
+ * A `'particleDied'` event and the particle on it are reused: read what you need inside the
2127
+ * callback and keep neither, since the particle's slot goes to the next one spawned. In trail
2128
+ * mode a particle dies when its trail is gone, so with a trail lifetime the event comes after
2129
+ * the head stopped, once the last sample has faded.
2130
+ *
2131
+ * A callback added or removed while an event is firing takes effect from the next event; the
2132
+ * callbacks of the one firing are all called.
1883
2133
  */
1884
2134
  addEventListener(event: ParticleSystemEventType, callback: (event: ParticleSystemEvent) => void): void {
1885
2135
  this.events.add(event, callback);
1886
2136
  }
1887
2137
 
1888
- /** Removes every callback registered for `event`. */
1889
- removeAllEventListeners(event: ParticleSystemEventType): void {
2138
+ /** Removes every callback registered for `event`, or every callback of every event when it is left out. */
2139
+ removeAllEventListeners(event?: ParticleSystemEventType): void {
1890
2140
  this.events.removeAll(event);
1891
2141
  }
1892
2142