babylon.quarks 0.28.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 (39) hide show
  1. package/CHANGELOG.md +160 -0
  2. package/dist/babylon.quarks.cjs +1 -1
  3. package/dist/babylon.quarks.cjs.map +1 -1
  4. package/dist/babylon.quarks.esm.js +1 -1
  5. package/dist/babylon.quarks.esm.js.map +1 -1
  6. package/dist/babylon.quarks.umd.min.js +1 -1
  7. package/dist/babylon.quarks.umd.min.js.map +1 -1
  8. package/dist/types/index.d.cts +602 -190
  9. package/dist/types/index.d.ts +602 -190
  10. package/package.json +1 -1
  11. package/src/BatchedRenderer.ts +13 -1
  12. package/src/EffectPool.ts +5 -4
  13. package/src/ParticleEmitter.ts +89 -24
  14. package/src/ParticleSystem.ts +213 -30
  15. package/src/QuarksLoader.ts +9 -0
  16. package/src/QuarksSerializer.ts +1 -1
  17. package/src/QuarksUtil.ts +24 -2
  18. package/src/SpriteBatch.ts +3 -0
  19. package/src/TrailBatch.ts +8 -1
  20. package/src/VFXBatch.ts +112 -0
  21. package/src/batchKey.ts +2 -0
  22. package/src/batchSettings.ts +2 -0
  23. package/src/index.ts +2 -0
  24. package/src/shaderProgram.ts +103 -0
  25. package/src/shaderVariants.ts +102 -5
  26. package/src/shaders/chunks/blend.ts +15 -7
  27. package/src/shaders/chunks/imageProcessing.ts +143 -0
  28. package/src/shaders/particle_frag.glsl.ts +2 -0
  29. package/src/shaders/particle_frag.wgsl.ts +2 -0
  30. package/src/shaders/particle_physics_frag.glsl.ts +2 -0
  31. package/src/shaders/particle_physics_frag.wgsl.ts +2 -0
  32. package/src/shaders/trail_frag.glsl.ts +2 -0
  33. package/src/shaders/trail_frag.wgsl.ts +2 -0
  34. package/src/system/EmissionController.ts +21 -16
  35. package/src/system/ParticleSpawner.ts +96 -19
  36. package/src/system/integrate.ts +61 -4
  37. package/src/system/normalize.ts +28 -1
  38. package/src/system/settingsSchema.ts +25 -7
  39. package/src/system/types.ts +83 -14
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "babylon.quarks",
3
- "version": "0.28.0",
3
+ "version": "0.29.0",
4
4
  "description": "A High-Performance Particle System for Babylon.js",
5
5
  "type": "module",
6
6
  "types": "./dist/types/index.d.ts",
@@ -118,6 +118,18 @@ export interface VFXBatchSettings {
118
118
  * that differ are batched apart.
119
119
  */
120
120
  applyFog: boolean;
121
+ /**
122
+ * Whether the material applies the scene's image processing (`scene.imageProcessingConfiguration`:
123
+ * exposure, contrast, tone mapping, vignette, colour curves, colour grading, dithering), as
124
+ * Babylon's own `ParticleSystem` and its standard and PBR materials do; unset reads as true.
125
+ * When the configuration is applied by a post process (a `DefaultRenderingPipeline` with image
126
+ * processing on), the material leaves its colour in linear space for it, as those materials do.
127
+ * Off, the material does neither: its colour is written as authored. That does not keep the
128
+ * particles out of a post process, which processes the whole frame; particles that must reach
129
+ * the screen untouched are drawn by a camera the pipeline is not attached to. Systems that
130
+ * differ are batched apart. See {@link ParticleSystem.applyImageProcessing}.
131
+ */
132
+ applyImageProcessing?: boolean;
121
133
  /**
122
134
  * Offset of billboard quads from their particle, in particle-size units
123
135
  * (Unity's renderer Pivot). On billboards x/y move the quad in its own plane
@@ -704,7 +716,7 @@ export class BatchedRenderer extends TransformNode {
704
716
  !this.isSystemInView(system);
705
717
  system.update(delta, stepDeadline, catchUpDeadline);
706
718
  }
707
- // An autoDestroy system disposes itself from inside update and drops
719
+ // A system whose stop action is destroy disposes itself from inside update and drops
708
720
  // out of the list; stay on the slot it vacated.
709
721
  if (systems[i] !== system) {
710
722
  i--;
package/src/EffectPool.ts CHANGED
@@ -49,7 +49,8 @@ interface RestTransform {
49
49
  * it; a copy whose every system has finished goes back to the pool by itself,
50
50
  * so a one-shot effect needs nothing more. A looping effect is ended with
51
51
  * {@link stop}, which lets its particles die out before the copy returns, or
52
- * dropped at once with {@link release}.
52
+ * dropped at once with {@link release}. A ring buffer effect (`ringBufferMode`)
53
+ * never finishes, since its particles never die: release it.
53
54
  *
54
55
  * Idle copies are parked disabled, which costs the renderer nothing, and keep
55
56
  * their place in its batches, so acquiring one allocates nothing.
@@ -168,9 +169,9 @@ export class EffectPool {
168
169
  const systems: ParticleSystem[] = [];
169
170
  QuarksUtil.runOnAllParticleEmitters(copy, (emitter) => {
170
171
  const system = emitter.system as unknown as ParticleSystem;
171
- // The pool owns the copy's life; a system disposing itself would
172
- // pull one emitter out of an effect that is meant to play again.
173
- system.autoDestroy = false;
172
+ // The pool owns the copy's life; a system disposing or disabling itself
173
+ // would pull one emitter out of an effect that is meant to play again.
174
+ system.ignoreStopAction = true;
174
175
  system.addEventListener('finished', this.onSystemFinished);
175
176
  systems.push(system);
176
177
  });
@@ -1,8 +1,46 @@
1
1
  import {TransformNode} from '@babylonjs/core/Meshes/transformNode';
2
2
  import {Scene} from '@babylonjs/core/scene';
3
- import {IParticleSystem, Matrix4, SerializationOptions} from 'quarks.core';
3
+ import {IParticleSystem, Matrix4, SerializationOptions, Vector3} from 'quarks.core';
4
4
  import {quarksNodeId} from './nodeId';
5
- import type {BabylonMetaData} from './system/types';
5
+ import type {BabylonMetaData, ScalingMode} from './system/types';
6
+
7
+ const axisLengths = [0, 0, 0];
8
+ const crossA = new Vector3();
9
+ const crossB = new Vector3();
10
+
11
+ /** Below this an axis of the world matrix counts as scaled to 0. */
12
+ const ZERO_AXIS = 1e-12;
13
+
14
+ /**
15
+ * Sets the length of basis column `axis` of the 4×4 `e` to `target`, keeping its direction and
16
+ * sign. A column of length 0 is rebuilt as the cross product of the other two, in the order that
17
+ * keeps the basis right-handed, or as the bare axis when they are degenerate too.
18
+ */
19
+ function setAxisLength(e: number[], axis: number, lengths: number[], target: number): void {
20
+ const o = axis * 4;
21
+ const length = lengths[axis];
22
+ if (length > ZERO_AXIS) {
23
+ const k = target / length;
24
+ e[o] *= k;
25
+ e[o + 1] *= k;
26
+ e[o + 2] *= k;
27
+ return;
28
+ }
29
+ const a = (axis + 1) % 3;
30
+ const b = (axis + 2) % 3;
31
+ if (lengths[a] > ZERO_AXIS && lengths[b] > ZERO_AXIS) {
32
+ crossA.set(e[a * 4], e[a * 4 + 1], e[a * 4 + 2]);
33
+ crossB.set(e[b * 4], e[b * 4 + 1], e[b * 4 + 2]);
34
+ crossA.cross(crossB).normalize().multiplyScalar(target);
35
+ e[o] = crossA.x;
36
+ e[o + 1] = crossA.y;
37
+ e[o + 2] = crossA.z;
38
+ } else {
39
+ e[o] = axis === 0 ? target : 0;
40
+ e[o + 1] = axis === 1 ? target : 0;
41
+ e[o + 2] = axis === 2 ? target : 0;
42
+ }
43
+ }
6
44
 
7
45
  /** A {@link ParticleEmitter} as its `toJSON` writes it: the node, its transform and its system. */
8
46
  export interface ParticleEmitterJSON {
@@ -28,7 +66,7 @@ export interface ParticleEmitterJSON {
28
66
 
29
67
  /**
30
68
  * Above 0 while a particle system disposes its own emitter (`ParticleSystem.dispose()`, which
31
- * `autoDestroy` calls when the system finishes). Babylon then disposes the child nodes too, and
69
+ * a `destroy` stop action calls when the system finishes). Babylon then disposes the child nodes too, and
32
70
  * child emitters keep their systems playing, so an effect's longer-lived children are not cut off
33
71
  * when a short parent finishes. Disposing a node directly (`root.dispose()`) disposes its systems.
34
72
  */
@@ -54,40 +92,67 @@ export class ParticleEmitter extends TransformNode {
54
92
  system: IParticleSystem;
55
93
  private _matrixWorld: Matrix4 = new Matrix4();
56
94
  private _matrixWorldFlag = -1;
95
+ private _matrixWorldMode: ScalingMode | undefined;
96
+ private readonly _shapeScale = new Vector3(1, 1, 1);
57
97
 
58
98
  /**
59
- * The node's world matrix in quarks.core form. Mirrored from Babylon's
60
- * matrix only when that matrix actually changed (tracked by its
61
- * `updateFlag`), so repeated reads inside a frame are free.
99
+ * The matrix the system simulates and draws in, in quarks.core form: the node's world matrix,
100
+ * with the length of each axis set by the system's {@link ScalingMode}. `'hierarchy'` keeps the
101
+ * whole world scale; `'local'` sets each axis to the node's own `scaling` (times
102
+ * `scalingDeterminant`), leaving the parents' scale out; `'shape'` sets each to 1, and
103
+ * {@link shapeScale} applies the world scale to the emission shape alone. Only the axes'
104
+ * lengths change, so position, rotation and any mirroring from the hierarchy are kept. An axis
105
+ * that a parent scales to 0 has no direction left: it is rebuilt square to the other two. A
106
+ * node whose system has no scaling mode reads as `'hierarchy'`. Recomputed only when Babylon's
107
+ * matrix changed (tracked by its `updateFlag`) or the mode did, so repeated reads inside a frame
108
+ * are free.
62
109
  */
63
110
  get matrixWorld(): Matrix4 {
64
111
  const m = this.getWorldMatrix();
65
112
  const flag = m.updateFlag;
66
- if (flag === this._matrixWorldFlag) {
113
+ const mode = (this.system as {scalingMode?: ScalingMode} | undefined)?.scalingMode ?? 'hierarchy';
114
+ if (flag === this._matrixWorldFlag && mode === this._matrixWorldMode) {
67
115
  return this._matrixWorld;
68
116
  }
69
117
  this._matrixWorldFlag = flag;
118
+ this._matrixWorldMode = mode;
70
119
  const e = this._matrixWorld.elements;
71
120
  const s = m.m;
72
- e[0] = s[0];
73
- e[1] = s[1];
74
- e[2] = s[2];
75
- e[3] = s[3];
76
- e[4] = s[4];
77
- e[5] = s[5];
78
- e[6] = s[6];
79
- e[7] = s[7];
80
- e[8] = s[8];
81
- e[9] = s[9];
82
- e[10] = s[10];
83
- e[11] = s[11];
84
- e[12] = s[12];
85
- e[13] = s[13];
86
- e[14] = s[14];
87
- e[15] = s[15];
121
+ for (let i = 0; i < 16; i++) {
122
+ e[i] = s[i];
123
+ }
124
+ this._shapeScale.set(1, 1, 1);
125
+ // Without a parent the world scale is the node's own, so 'local' is 'hierarchy'.
126
+ if (mode === 'hierarchy' || (mode === 'local' && !this.parent)) {
127
+ return this._matrixWorld;
128
+ }
129
+ const own = this.scaling;
130
+ const determinant = this.scalingDeterminant;
131
+ const lengths = axisLengths;
132
+ for (let axis = 0; axis < 3; axis++) {
133
+ const o = axis * 4;
134
+ lengths[axis] = Math.sqrt(e[o] * e[o] + e[o + 1] * e[o + 1] + e[o + 2] * e[o + 2]);
135
+ }
136
+ for (let axis = 0; axis < 3; axis++) {
137
+ const target =
138
+ mode === 'local' ? Math.abs((axis === 0 ? own.x : axis === 1 ? own.y : own.z) * determinant) : 1;
139
+ if (mode === 'shape') {
140
+ this._shapeScale.setComponent(axis, lengths[axis]);
141
+ }
142
+ setAxisLength(e, axis, lengths, target);
143
+ }
88
144
  return this._matrixWorld;
89
145
  }
90
146
 
147
+ /**
148
+ * What the system's emission shape is scaled by before {@link matrixWorld} places it: the
149
+ * node's world scale in `'shape'` mode, which leaves the particles and their motion unscaled,
150
+ * and 1 otherwise. Read it after `matrixWorld`, which refreshes it.
151
+ */
152
+ get shapeScale(): Vector3 {
153
+ return this._shapeScale;
154
+ }
155
+
91
156
  /** Whether the node and its parents are enabled; setting it is `setEnabled`. */
92
157
  get visible(): boolean {
93
158
  return this.isEnabled();
@@ -110,7 +175,7 @@ export class ParticleEmitter extends TransformNode {
110
175
  /**
111
176
  * Disposes the node and the particle system it carries, so `root.dispose()` on an effect tree
112
177
  * also removes its systems from their renderer. Safe to call more than once. When a system
113
- * disposes its own emitter (`ParticleSystem.dispose()`, `autoDestroy`), the systems of child
178
+ * disposes its own emitter (`ParticleSystem.dispose()`, a `destroy` stop action), the systems of child
114
179
  * emitters keep playing until they finish or are disposed themselves.
115
180
  */
116
181
  dispose(doNotRecurse?: boolean, disposeMaterialAndTextures = false): void {
@@ -61,6 +61,7 @@ import {
61
61
  moveTrails,
62
62
  recordTrailHistory,
63
63
  removeDeadTrails,
64
+ RingBufferStep,
64
65
  runBehaviorSteps,
65
66
  snapshotPreviousState,
66
67
  } from './system/integrate';
@@ -74,10 +75,14 @@ import {
74
75
  validAlphaTest,
75
76
  validCullingMode,
76
77
  validLighting,
78
+ validLoopRange,
77
79
  validMaxParticles,
78
80
  validRenderFace,
81
+ validRingBufferMode,
82
+ validScalingMode,
79
83
  validSoftFade,
80
84
  validSortMode,
85
+ validStopAction,
81
86
  validTileCount,
82
87
  } from './system/normalize';
83
88
  import {ParticleSpawner} from './system/ParticleSpawner';
@@ -116,7 +121,9 @@ import type {
116
121
  CullingMode,
117
122
  ParticleSystemJSONParameters,
118
123
  ParticleSystemParameters,
124
+ RingBufferMode,
119
125
  ScalingMode,
126
+ StopAction,
120
127
  } from './system/types';
121
128
 
122
129
  export type {
@@ -127,7 +134,9 @@ export type {
127
134
  CullingMode,
128
135
  ParticleSystemJSONParameters,
129
136
  ParticleSystemParameters,
137
+ RingBufferMode,
130
138
  ScalingMode,
139
+ StopAction,
131
140
  } from './system/types';
132
141
 
133
142
  // Scratch for the per-step emitter matrix of a frame that interpolates, see `update`.
@@ -155,8 +164,42 @@ const stepScale = new Vector3();
155
164
  * start values and emission, and the particle's age over its life inside behaviors.
156
165
  */
157
166
  export class ParticleSystem implements IParticleSystem {
158
- /** Dispose the system, its emitter and its batch slot when it finishes. Default false. */
159
- 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
+ }
160
203
  /**
161
204
  * Start a looping system as if it had already run one full cycle (Unity's Prewarm), so a fire
162
205
  * or a mist is full from its first frame. Default false.
@@ -236,9 +279,24 @@ export class ParticleSystem implements IParticleSystem {
236
279
  /**
237
280
  * Most particles alive at once (Unity's Max Particles); emission past it is dropped. Undefined
238
281
  * means no limit, as does a negative or NaN count given to the constructor; a fraction is
239
- * rounded down.
282
+ * rounded down. With a {@link ringBufferMode}, new particles replace the oldest instead.
240
283
  */
241
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;
242
300
  /**
243
301
  * The system emits nothing of its own and only spawns what an `EmitSubParticleSystem` on
244
302
  * another system sends it (a Unity sub emitter). Default false.
@@ -339,9 +397,12 @@ export class ParticleSystem implements IParticleSystem {
339
397
  return this.emitEnded;
340
398
  }
341
399
 
342
- /** Whether continuous or queued emission can still spawn particles before the system is done. */
343
- hasPendingEmission(): boolean {
344
- 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);
345
406
  }
346
407
 
347
408
  /**
@@ -350,7 +411,7 @@ export class ParticleSystem implements IParticleSystem {
350
411
  * {@link endEmit} and whose last particle has died.
351
412
  */
352
413
  isFinished(): boolean {
353
- if (this.particleNum > 0) {
414
+ if (this.particleNum > 0 || this.hasPendingSubEmission()) {
354
415
  return false;
355
416
  }
356
417
  // A sub emitter target emits nothing of its own: empty is finished,
@@ -638,26 +699,15 @@ export class ParticleSystem implements IParticleSystem {
638
699
  this.neededToUpdateRender = true;
639
700
  }
640
701
 
641
- /**
642
- * The emitter's world scale per axis, measured with {@link motionScale}
643
- * before every step; what `'hierarchy'` multiplies start speeds by. Taken
644
- * from the system's own emitter rather than the emission matrix, whose
645
- * columns carry a parent particle's velocity for a sub emitter.
646
- * @internal
647
- */
648
- readonly hierarchyScale = new Vector3(1, 1, 1);
649
-
650
- /** 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. */
651
703
  private measureMotionScale(emitterMatrix: Matrix4): number {
652
- if (this.scalingMode !== 'hierarchy' || !this.worldSpace) {
653
- this.hierarchyScale.set(1, 1, 1);
704
+ if (!this.worldSpace) {
654
705
  return 1;
655
706
  }
656
707
  const e = emitterMatrix.elements;
657
708
  const sx = Math.sqrt(e[0] * e[0] + e[1] * e[1] + e[2] * e[2]);
658
709
  const sy = Math.sqrt(e[4] * e[4] + e[5] * e[5] + e[6] * e[6]);
659
710
  const sz = Math.sqrt(e[8] * e[8] + e[9] * e[9] + e[10] * e[10]);
660
- this.hierarchyScale.set(sx, sy, sz);
661
711
  return (sx + sy + sz) / 3;
662
712
  }
663
713
 
@@ -948,6 +998,34 @@ export class ParticleSystem implements IParticleSystem {
948
998
  this.neededToUpdateRender = true;
949
999
  }
950
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
+
951
1029
  /**
952
1030
  * Multiplier on the time this system is advanced by (Unity's Simulation
953
1031
  * Speed): 2 runs the effect twice as fast, 0 freezes it without pausing the
@@ -1003,13 +1081,14 @@ export class ParticleSystem implements IParticleSystem {
1003
1081
  /** What this system does while off screen, see {@link CullingMode}. `'always'` by default. */
1004
1082
  cullingMode: CullingMode = 'always';
1005
1083
 
1006
- /** What the emitter's world scale does to a world-space system, see {@link ScalingMode}. `'shape'` by default. */
1007
- scalingMode: ScalingMode = 'shape';
1084
+ /** What the emitter's scale does to the system, see {@link ScalingMode}. `'local'` by default. */
1085
+ scalingMode: ScalingMode = 'local';
1008
1086
 
1009
1087
  /**
1010
- * What world-space forces and speed limits are multiplied by this step:
1011
- * the mean of the emitter's world scale axes in `'hierarchy'` mode, 1
1012
- * 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.
1013
1092
  */
1014
1093
  motionScale = 1;
1015
1094
 
@@ -1094,7 +1173,9 @@ export class ParticleSystem implements IParticleSystem {
1094
1173
  },
1095
1174
  });
1096
1175
 
1097
- this.autoDestroy = parameters.autoDestroy ?? false;
1176
+ this.stopAction = validStopAction(
1177
+ parameters.stopAction ?? (parameters.autoDestroy === true ? 'destroy' : undefined)
1178
+ );
1098
1179
  this.duration = parameters.duration ?? 1;
1099
1180
  this.startDelay = parameters.startDelay ?? new ConstantValue(0);
1100
1181
  this.looping = parameters.looping ?? true;
@@ -1109,6 +1190,8 @@ export class ParticleSystem implements IParticleSystem {
1109
1190
  this.emissionOverDistance = parameters.emissionOverDistance ?? new ConstantValue(0);
1110
1191
  this.emissionBursts = normalizeBursts(parameters.emissionBursts);
1111
1192
  this.maxParticles = validMaxParticles(parameters.maxParticles);
1193
+ this.ringBufferMode = validRingBufferMode(parameters.ringBufferMode);
1194
+ this.ringBufferLoopRange = validLoopRange(parameters.ringBufferLoopRange);
1112
1195
  this.onlyUsedByOther = parameters.onlyUsedByOther ?? false;
1113
1196
  this.emitterShape = parameters.shape ?? new SphereEmitter();
1114
1197
  this.behaviors = parameters.behaviors ?? [];
@@ -1166,6 +1249,7 @@ export class ParticleSystem implements IParticleSystem {
1166
1249
  maxParticleSize: parameters.maxParticleSize ?? Infinity,
1167
1250
  renderingGroupId: parameters.renderingGroupId ?? 0,
1168
1251
  applyFog: parameters.applyFog ?? true,
1252
+ applyImageProcessing: parameters.applyImageProcessing ?? true,
1169
1253
  lighting: validLighting(parameters.lighting),
1170
1254
  rim: normalizeMeshRim(parameters.rim),
1171
1255
  mapUV: normalizeMapUV(parameters.mapUV),
@@ -1176,7 +1260,7 @@ export class ParticleSystem implements IParticleSystem {
1176
1260
  this.flip = nonZeroFlip(parameters.flip);
1177
1261
  this.playbackSpeed = parameters.playbackSpeed ?? 1;
1178
1262
  this.cullingMode = validCullingMode(parameters.cullingMode);
1179
- this.scalingMode = parameters.scalingMode === 'hierarchy' ? 'hierarchy' : 'shape';
1263
+ this.scalingMode = validScalingMode(parameters.scalingMode);
1180
1264
  this.alignToDirection = parameters.alignToDirection === true;
1181
1265
  this.seed = parameters.seed;
1182
1266
  this.ensureMeshNormals();
@@ -1384,7 +1468,7 @@ export class ParticleSystem implements IParticleSystem {
1384
1468
  /**
1385
1469
  * Stops emission and lets the particles alive die out (Unity's `Stop` with
1386
1470
  * `StopEmitting`). The system fires `emitEnd`, then `finished` when the last particle has
1387
- * 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.
1388
1472
  */
1389
1473
  endEmit(): void {
1390
1474
  this.emitEnded = true;
@@ -1468,6 +1552,7 @@ export class ParticleSystem implements IParticleSystem {
1468
1552
  this.emitterShape.reset?.();
1469
1553
  this.emitEnded = false;
1470
1554
  this.finishedEventFired = false;
1555
+ this.stopActionPending = false;
1471
1556
  this.prewarmed = false;
1472
1557
  this.initSystemGenerators();
1473
1558
  this.previousEmitterPos = undefined;
@@ -1518,6 +1603,9 @@ export class ParticleSystem implements IParticleSystem {
1518
1603
  }
1519
1604
 
1520
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;
1521
1609
  // Still tick the fixed-step clock. A sub-emitter target
1522
1610
  // (onlyUsedByOther) goes empty between parent bursts; skipping the
1523
1611
  // accumulator here desyncs it from the parent, so the next particles
@@ -1789,6 +1877,7 @@ export class ParticleSystem implements IParticleSystem {
1789
1877
 
1790
1878
  private stepOnce(delta: number, emitterMatrix: Matrix4): void {
1791
1879
  if (this.destroyed) return;
1880
+ this.spawner.fitRing();
1792
1881
  this.motionScale = this.measureMotionScale(emitterMatrix);
1793
1882
  if (!this.onlyUsedByOther) {
1794
1883
  this.emission.emit(delta, this.emissionState, emitterMatrix);
@@ -1832,7 +1921,8 @@ export class ParticleSystem implements IParticleSystem {
1832
1921
  snapshotColor,
1833
1922
  this.bounds,
1834
1923
  this,
1835
- onDied
1924
+ onDied,
1925
+ this.ringBufferStep()
1836
1926
  );
1837
1927
  } else {
1838
1928
  // Trails: the newborn collapse, then motion, then the trail history,
@@ -1848,6 +1938,58 @@ export class ParticleSystem implements IParticleSystem {
1848
1938
  this.notifyFinished();
1849
1939
  }
1850
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
+
1851
1993
  /** Re-points every particle at the store, for {@link snapshotPreviousState}. */
1852
1994
  private readonly rebindParticles = (): void => this.spawner.rebind();
1853
1995
 
@@ -1862,15 +2004,56 @@ export class ParticleSystem implements IParticleSystem {
1862
2004
  event.particle = undefined;
1863
2005
  };
1864
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
+
1865
2028
  private notifyFinished(): void {
1866
2029
  if (!this.isFinished()) {
1867
2030
  return;
1868
2031
  }
1869
2032
  if (!this.finishedEventFired) {
1870
2033
  this.finishedEventFired = true;
2034
+ // Once per finish, so an emitter enabled again from a listener or later stays enabled.
2035
+ this.stopActionPending = true;
1871
2036
  this.events.fire({type: 'finished', particleSystem: this});
1872
2037
  }
1873
- 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 {
1874
2057
  this.dispose();
1875
2058
  }
1876
2059
  }
@@ -337,6 +337,8 @@ interface LoadedMeta {
337
337
  textures: {[uuid: string]: Texture | null};
338
338
  geometries: {[uuid: string]: GeometryArrays};
339
339
  materials: {[uuid: string]: QuarksMaterialJSON};
340
+ /** The file's `quarksFormat`; 0 for a file written before the field existed. */
341
+ format: number;
340
342
  }
341
343
 
342
344
  /**
@@ -623,6 +625,7 @@ export class QuarksLoader {
623
625
  textures: {},
624
626
  geometries: {},
625
627
  materials: {},
628
+ format: typeof format === 'number' ? format : 0,
626
629
  };
627
630
 
628
631
  const {geometries, images, textures: textureList, materials} = json;
@@ -1486,6 +1489,12 @@ export class QuarksLoader {
1486
1489
  }
1487
1490
 
1488
1491
  private parseParticleSystem(json: ParticleSystemJSONParameters, meta: LoadedMeta): ParticleSystem {
1492
+ if (meta.format < 2 && json !== null && typeof json === 'object' && json.scalingMode !== 'hierarchy') {
1493
+ // Before format 2 the emitter's whole world scale reached every system's shape and
1494
+ // particle size, and a local-space system's motion too: 'hierarchy' is the mode that
1495
+ // keeps such an effect's look. 'local' became the default with Unity's meaning.
1496
+ json = {...json, scalingMode: 'hierarchy'};
1497
+ }
1489
1498
  const dependencies: {[uuid: string]: Behavior} = {};
1490
1499
  const ps = ParticleSystem.fromJSON(json, meta, dependencies, this.scene);
1491
1500
  if (json?.shape?.type === 'mesh_surface' && typeof json.shape.mesh === 'string') {
@@ -23,7 +23,7 @@ import {textureSourceUrl} from './textureSource';
23
23
  * reads every earlier version, and files written before the field existed, and warns about a
24
24
  * file from a newer version.
25
25
  */
26
- export const QUARKS_FORMAT_VERSION = 1;
26
+ export const QUARKS_FORMAT_VERSION = 2;
27
27
 
28
28
  /** An image a texture reads: a url, a root-relative path, or a `data:` URI. */
29
29
  export interface QuarksImageJSON {