babylon.quarks 0.23.0 → 0.24.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 (35) hide show
  1. package/CHANGELOG.md +101 -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.ts +529 -20
  9. package/package.json +2 -2
  10. package/src/BatchedRenderer.ts +146 -2
  11. package/src/EffectPool.ts +165 -0
  12. package/src/MeshSurfaceEmitter.ts +4 -4
  13. package/src/ParticleEmitter.ts +2 -1
  14. package/src/ParticleSystem.ts +592 -15
  15. package/src/QuarksUtil.ts +55 -7
  16. package/src/SpriteBatch.ts +127 -3
  17. package/src/TrailBatch.ts +49 -12
  18. package/src/VFXBatch.ts +154 -3
  19. package/src/index.ts +4 -1
  20. package/src/instanceSort.ts +121 -0
  21. package/src/shaders/chunks/fog.ts +127 -0
  22. package/src/shaders/local_particle_physics_vert.glsl.ts +4 -0
  23. package/src/shaders/local_particle_physics_vert.wgsl.ts +4 -0
  24. package/src/shaders/particle_frag.glsl.ts +4 -0
  25. package/src/shaders/particle_frag.wgsl.ts +4 -0
  26. package/src/shaders/particle_physics_frag.glsl.ts +4 -0
  27. package/src/shaders/particle_physics_frag.wgsl.ts +4 -0
  28. package/src/shaders/particle_vert.glsl.ts +23 -4
  29. package/src/shaders/particle_vert.wgsl.ts +23 -4
  30. package/src/shaders/stretched_bb_particle_vert.glsl.ts +17 -0
  31. package/src/shaders/stretched_bb_particle_vert.wgsl.ts +18 -1
  32. package/src/shaders/trail_frag.glsl.ts +4 -0
  33. package/src/shaders/trail_frag.wgsl.ts +4 -0
  34. package/src/shaders/trail_vert.glsl.ts +4 -0
  35. package/src/shaders/trail_vert.wgsl.ts +4 -0
@@ -10,7 +10,9 @@ import {
10
10
  ColorGeneratorFromJSON,
11
11
  ConstantColor,
12
12
  ConstantValue,
13
+ createSeededRandom,
13
14
  EmissionState,
15
+ EmitSubParticleSystem,
14
16
  EmitterFromJSON,
15
17
  EmitterShape,
16
18
  FunctionColorGenerator,
@@ -28,12 +30,16 @@ import {
28
30
  ParticleSystemEventType,
29
31
  planBehaviorFusion,
30
32
  Quaternion,
33
+ random,
34
+ RandomSource,
31
35
  RendererEmitterSettings,
32
36
  RotationGenerator,
33
37
  SerializationOptions,
38
+ setRandomSource,
34
39
  SphereEmitter,
35
40
  SpriteParticle,
36
41
  StretchedBillBoardSettings,
42
+ SubEmitterInheritMask,
37
43
  TrailParticle,
38
44
  TrailSettings,
39
45
  ValueGenerator,
@@ -47,7 +53,7 @@ import {BatchedRenderer, VFXBatchSettings} from './BatchedRenderer';
47
53
  import {ensureEnvAtlasFromCube, getCachedEnvAtlas} from './envAtlas';
48
54
  import {ensureTriangleIndices} from './geometryUtil';
49
55
  import {ParticleEmitter} from './ParticleEmitter';
50
- import {MeshLighting, MeshRim, normalizeMeshRim, RenderMode} from './VFXBatch';
56
+ import {MeshLighting, MeshRim, normalizeMeshRim, RenderMode, SortMode} from './VFXBatch';
51
57
 
52
58
  export interface BurstParameters {
53
59
  time: number;
@@ -57,6 +63,24 @@ export interface BurstParameters {
57
63
  probability: number;
58
64
  }
59
65
 
66
+ /**
67
+ * What a system does while no camera can see its particles (Unity's Culling
68
+ * Mode). The {@link BatchedRenderer} tests each system's bounds against the
69
+ * active cameras' frusta every frame.
70
+ *
71
+ * - `'always'` keeps simulating off screen, so an effect is exactly where it
72
+ * would have been when it comes back into view. The default, and the only
73
+ * safe choice for an emitter whose particles reach the screen from off it.
74
+ * - `'pause'` freezes the system off screen and resumes it where it left off.
75
+ * - `'pauseAndCatchUp'` freezes it too, then runs the missed time (up to a few
76
+ * seconds) within the renderer's catch-up budget when it is seen again.
77
+ *
78
+ * An empty system is never culled, so it can start emitting and grow the
79
+ * bounds that decide its visibility. A system another one emits into is never
80
+ * culled either; its parent decides.
81
+ */
82
+ export type CullingMode = 'always' | 'pause' | 'pauseAndCatchUp';
83
+
60
84
  /** Particle backed by a {@link ParticleStore} row, as this system always creates. */
61
85
  type StoreBackedParticle = Particle & {storeIndex: number; setStoreIndex(index: number): void};
62
86
 
@@ -68,6 +92,10 @@ const tempV2 = new Vector3();
68
92
  const PREWARM_FPS = 60;
69
93
  const SIMULATION_STEP = 1 / PREWARM_FPS;
70
94
  const MAX_SIMULATION_STEPS_PER_FRAME = 8;
95
+ /** Most off-screen time a `'pauseAndCatchUp'` system replays on coming back into view. */
96
+ const MAX_CATCH_UP_SECONDS = 2;
97
+ /** Steps between full measurements of a system's bounds; see simulateStep. */
98
+ const BOUNDS_REFRESH_STEPS = 4;
71
99
  // Math.round() breaks an exact tie (accumulator sitting at precisely half a
72
100
  // step) by rounding up. At exactly 120Hz that tie is not a corner case — it is
73
101
  // every single frame — and rounding it up would run a step on every one of
@@ -171,6 +199,24 @@ export interface ParticleSystemParameters {
171
199
  lighting?: MeshLighting;
172
200
  /** Rim (Fresnel) term on mesh particles, see {@link ParticleSystem.rim}. Default none. */
173
201
  rim?: Partial<MeshRim>;
202
+ /** Multiplier on the time the system is advanced by, see {@link ParticleSystem.playbackSpeed}. Default 1. */
203
+ playbackSpeed?: number;
204
+ /** What the system does while off screen, see {@link CullingMode}. Default `'always'`. */
205
+ cullingMode?: CullingMode;
206
+ /** Seed for this system's own random generator, see {@link ParticleSystem.seed}. Default none. */
207
+ seed?: number;
208
+ /** Draw order within the batch, see {@link ParticleSystem.sortMode}. Default `'none'`. */
209
+ sortMode?: SortMode;
210
+ /** Whether particles start turned along their direction of travel, see {@link ParticleSystem.alignToDirection}. Default false. */
211
+ alignToDirection?: boolean;
212
+ /** Smallest on-screen size, as a fraction of the viewport height, see {@link ParticleSystem.minParticleSize}. Default 0. */
213
+ minParticleSize?: number;
214
+ /** Largest on-screen size, as a fraction of the viewport height, see {@link ParticleSystem.maxParticleSize}. Default unlimited. */
215
+ maxParticleSize?: number;
216
+ /** Babylon rendering group of the batch, see {@link ParticleSystem.renderingGroupId}. Default 0. */
217
+ renderingGroupId?: number;
218
+ /** Whether the scene's fog applies, see {@link ParticleSystem.applyFog}. Default true. */
219
+ applyFog?: boolean;
174
220
  scene?: Scene;
175
221
  }
176
222
 
@@ -204,6 +250,24 @@ export interface ParticleSystemJSONParameters {
204
250
  instancingGeometry?: any;
205
251
  renderMode: number;
206
252
  renderOrder?: number;
253
+ /** Written only when not 0. */
254
+ renderingGroupId?: number;
255
+ /** Written only when false. */
256
+ applyFog?: boolean;
257
+ /** Written only when not 1. */
258
+ playbackSpeed?: number;
259
+ /** Written only when not `'always'`. */
260
+ cullingMode?: CullingMode;
261
+ /** Written only when set. */
262
+ seed?: number;
263
+ /** Written only when not `'none'`. */
264
+ sortMode?: SortMode;
265
+ /** Written only when true. */
266
+ alignToDirection?: boolean;
267
+ /** Written only when not 0. */
268
+ minParticleSize?: number;
269
+ /** Written only when limited. */
270
+ maxParticleSize?: number;
207
271
  speedFactor?: number;
208
272
  texture?: string;
209
273
  material?: string;
@@ -354,17 +418,23 @@ export class ParticleSystem implements IParticleSystem {
354
418
  return this.emissionOverDistance.genValue(this.memory, timeRatio) > 0;
355
419
  }
356
420
 
357
- /** Non-looping system with no particles left and nothing left to emit. */
421
+ /**
422
+ * No particles left and nothing left to emit: a non-looping system that
423
+ * ran its course, or a looping one whose emission was ended with
424
+ * {@link endEmit} and whose last particle has died.
425
+ */
358
426
  isFinished(): boolean {
359
- if (this.looping) {
360
- return false;
361
- }
362
427
  if (this.particleNum > 0) {
363
428
  return false;
364
429
  }
430
+ // A sub emitter target emits nothing of its own: empty is finished,
431
+ // looping or not. Its parent decides when the effect is done.
365
432
  if (this.onlyUsedByOther) {
366
433
  return true;
367
434
  }
435
+ if (this.looping) {
436
+ return this.emitEnded;
437
+ }
368
438
  return !this.hasPendingEmission();
369
439
  }
370
440
 
@@ -431,6 +501,50 @@ export class ParticleSystem implements IParticleSystem {
431
501
  * {@link VFXBatchSettings.pivot}. Assign a new vector to change it: editing
432
502
  * the returned one in place does not reach the renderer.
433
503
  */
504
+ /**
505
+ * The order this system's batch draws its particles in (Unity's Sort
506
+ * Mode), `'none'` by default. Alpha blended smoke wants `'distance'`;
507
+ * additive effects need nothing. Sorting covers the whole batch, so systems
508
+ * with different modes are batched apart. See {@link SortMode}.
509
+ */
510
+ get sortMode(): SortMode {
511
+ return this.rendererSettings.sortMode;
512
+ }
513
+
514
+ set sortMode(mode: SortMode) {
515
+ this.rendererSettings.sortMode = ParticleSystem.validSortMode(mode);
516
+ this.neededToUpdateRender = true;
517
+ }
518
+
519
+ /**
520
+ * Smallest size a billboard or stretched particle is drawn at, as a
521
+ * fraction of the viewport height (Unity's Min Particle Size); 0 by
522
+ * default. Keeps distant sparks from vanishing. Meshes and trails ignore it.
523
+ */
524
+ get minParticleSize(): number {
525
+ return this.rendererSettings.minParticleSize;
526
+ }
527
+
528
+ set minParticleSize(size: number) {
529
+ this.rendererSettings.minParticleSize = Math.max(0, size);
530
+ this.neededToUpdateRender = true;
531
+ }
532
+
533
+ /**
534
+ * Largest size a billboard or stretched particle is drawn at, as a fraction
535
+ * of the viewport height (Unity's Max Particle Size, 0.5 there); unlimited
536
+ * by default. Keeps a close particle from filling the screen. Meshes and
537
+ * trails ignore it.
538
+ */
539
+ get maxParticleSize(): number {
540
+ return this.rendererSettings.maxParticleSize;
541
+ }
542
+
543
+ set maxParticleSize(size: number) {
544
+ this.rendererSettings.maxParticleSize = size > 0 ? size : Infinity;
545
+ this.neededToUpdateRender = true;
546
+ }
547
+
434
548
  get pivot(): Vector3 | undefined {
435
549
  return this.rendererSettings.pivot;
436
550
  }
@@ -478,6 +592,14 @@ export class ParticleSystem implements IParticleSystem {
478
592
  return lighting === 'unlit' || lighting === 'scene' ? lighting : 'fixed';
479
593
  }
480
594
 
595
+ private static validSortMode(mode: unknown): SortMode {
596
+ return mode === 'distance' || mode === 'oldestInFront' || mode === 'youngestInFront' ? mode : 'none';
597
+ }
598
+
599
+ private static validCullingMode(mode: unknown): CullingMode {
600
+ return mode === 'pause' || mode === 'pauseAndCatchUp' ? mode : 'always';
601
+ }
602
+
481
603
  private static nonZeroPivot(v: Vector3 | undefined): Vector3 | undefined {
482
604
  return v && (v.x !== 0 || v.y !== 0 || v.z !== 0) ? v.clone() : undefined;
483
605
  }
@@ -573,6 +695,116 @@ export class ParticleSystem implements IParticleSystem {
573
695
  this.neededToUpdateRender = true;
574
696
  }
575
697
 
698
+ /**
699
+ * Babylon rendering group the particles draw in (`mesh.renderingGroupId`),
700
+ * 0 by default. Use it to draw an effect after the scene's other transparent
701
+ * meshes, or before an overlay group.
702
+ */
703
+ get renderingGroupId(): number {
704
+ return this.rendererSettings.renderingGroupId;
705
+ }
706
+
707
+ set renderingGroupId(group: number) {
708
+ this.rendererSettings.renderingGroupId = group;
709
+ this.neededToUpdateRender = true;
710
+ }
711
+
712
+ /**
713
+ * Whether the scene's fog applies to the particles, as it does to any other
714
+ * mesh; true by default. Additive particles fade toward black rather than
715
+ * the fog colour. Saved in the effect JSON only when switched off.
716
+ */
717
+ get applyFog(): boolean {
718
+ return this.rendererSettings.applyFog;
719
+ }
720
+
721
+ set applyFog(apply: boolean) {
722
+ this.rendererSettings.applyFog = apply;
723
+ this.neededToUpdateRender = true;
724
+ }
725
+
726
+ /**
727
+ * Multiplier on the time this system is advanced by (Unity's Simulation
728
+ * Speed): 2 runs the effect twice as fast, 0 freezes it without pausing the
729
+ * renderer. 1 by default; saved in the effect JSON only when it differs.
730
+ */
731
+ playbackSpeed = 1;
732
+
733
+ /** What this system does while off screen, see {@link CullingMode}. `'always'` by default. */
734
+ cullingMode: CullingMode = 'always';
735
+
736
+ /**
737
+ * Whether each particle starts turned along the direction its shape sends
738
+ * it (Unity's Align To Direction): a mesh particle's +Z is pointed along
739
+ * the velocity, a billboard's top is turned toward it in the emitter's XY
740
+ * plane. The start rotation is applied on top. A particle with no speed is
741
+ * left as it is. False by default.
742
+ */
743
+ alignToDirection = false;
744
+
745
+ private seedValue?: number;
746
+ /** The system's own generator while it steps, when seeded; see {@link seed}. */
747
+ private rng?: RandomSource;
748
+
749
+ /**
750
+ * Seed for this system's own random generator. Set, every roll the system
751
+ * makes while it steps (spawn positions, interval values, random colours
752
+ * and rotations, burst probability) comes from a generator started from
753
+ * this seed, and restarted with it on {@link restart}: the effect plays out
754
+ * identically every time, on every machine — for replays, lock-step
755
+ * multiplayer and reproducible tests. Systems this one emits into roll on
756
+ * its generator too. Unset (the default), rolls come from the global source
757
+ * (`setRandomSource` in quarks.core). Saved in the effect JSON when set.
758
+ *
759
+ * Not covered: randomness outside the step, such as a `TurbulenceField`'s
760
+ * offsets, which are rolled when the behavior is created.
761
+ */
762
+ get seed(): number | undefined {
763
+ return this.seedValue;
764
+ }
765
+
766
+ set seed(seed: number | undefined) {
767
+ this.seedValue = seed;
768
+ this.rng = seed === undefined ? undefined : createSeededRandom(seed);
769
+ }
770
+
771
+ /**
772
+ * Set by the renderer each frame: true while no active camera sees the
773
+ * system's bounds and its {@link cullingMode} is not `'always'`.
774
+ * @internal
775
+ */
776
+ culled = false;
777
+
778
+ /**
779
+ * Time a `'pauseAndCatchUp'` system has spent off screen and not yet
780
+ * simulated, in seconds. Capped, so an effect hidden for a minute does not
781
+ * replay the minute.
782
+ */
783
+ private pendingCatchUp = 0;
784
+
785
+ /**
786
+ * Time per frame a system coming back into view may spend on the steps it
787
+ * missed, in milliseconds, when it is advanced without a renderer. The
788
+ * {@link BatchedRenderer} shares its own budget among all its systems
789
+ * instead. Steps that do not fit are dropped, not deferred.
790
+ */
791
+ static catchUpBudgetMs = 2;
792
+
793
+ /** Axis-aligned bounds of the live particles, in simulation space. See {@link getWorldBounds}. */
794
+ private readonly boundsMin = new Vector3();
795
+ private readonly boundsMax = new Vector3();
796
+ /** How far a particle's quad, mesh or stretch can reach past its position, in simulation units. */
797
+ private boundsPadding = 0;
798
+ private boundsValid = false;
799
+ /** Steps since the last full measurement; between them the bounds are carried and padded. */
800
+ private stepsSinceBounds = 0;
801
+ private boundsMaxSize = 0;
802
+ private boundsMaxSpeedSq = 0;
803
+ /** True when the bounds are already in world space (a world-space system, or bounds the renderer measured). */
804
+ private boundsInWorld = false;
805
+ private geometryRadiusFor?: Float32Array;
806
+ private geometryRadius = 1;
807
+
576
808
  constructor(parameters: ParticleSystemParameters) {
577
809
  this.sceneRef = parameters.scene;
578
810
  this.layerMaskProxy = {mask: parameters.layerMask ?? 0x0fffffff};
@@ -641,10 +873,19 @@ export class ParticleSystem implements IParticleSystem {
641
873
  reflectionAtlas: null,
642
874
  layerMask: parameters.layerMask ?? 0x0fffffff,
643
875
  pivot: ParticleSystem.nonZeroPivot(parameters.pivot),
876
+ sortMode: ParticleSystem.validSortMode(parameters.sortMode),
877
+ minParticleSize: Math.max(0, parameters.minParticleSize ?? 0),
878
+ maxParticleSize: parameters.maxParticleSize ?? Infinity,
879
+ renderingGroupId: parameters.renderingGroupId ?? 0,
880
+ applyFog: parameters.applyFog ?? true,
644
881
  lighting: ParticleSystem.validLighting(parameters.lighting),
645
882
  rim: normalizeMeshRim(parameters.rim),
646
883
  };
647
884
  this.flip = ParticleSystem.nonZeroFlip(parameters.flip);
885
+ this.playbackSpeed = parameters.playbackSpeed ?? 1;
886
+ this.cullingMode = parameters.cullingMode ?? 'always';
887
+ this.alignToDirection = parameters.alignToDirection === true;
888
+ this.seed = parameters.seed;
648
889
  if (this.rendererSettings.renderMode === RenderMode.Mesh && !this.rendererSettings.instancingNormals) {
649
890
  this.rendererSettings.instancingNormals = ParticleSystem.createFallbackNormals(
650
891
  this.rendererSettings.instancingGeometry
@@ -937,8 +1178,7 @@ export class ParticleSystem implements IParticleSystem {
937
1178
  // renderer never carries a recycled particle's old spin.
938
1179
  sprite.angularVelocity = 0;
939
1180
  const flip = this.flip;
940
- sprite.flip =
941
- flip === undefined ? 0 : (Math.random() < flip.x ? 1 : 0) | (Math.random() < flip.y ? 2 : 0);
1181
+ sprite.flip = flip === undefined ? 0 : (random() < flip.x ? 1 : 0) | (random() < flip.y ? 2 : 0);
942
1182
  }
943
1183
  } else if (trailSettings) {
944
1184
  const trail = particle as TrailParticle;
@@ -950,6 +1190,16 @@ export class ParticleSystem implements IParticleSystem {
950
1190
 
951
1191
  this.emitterShape.initialize(particle, emissionState);
952
1192
 
1193
+ if (this.alignToDirection && isSpriteMode) {
1194
+ this.alignParticleToDirection(particle as SpriteParticle, isMeshMode);
1195
+ }
1196
+
1197
+ const parent = emissionState.particle;
1198
+ const inherit = emissionState.inherit;
1199
+ if (parent !== undefined && inherit !== undefined && inherit !== 0) {
1200
+ this.inheritFromParent(particle, parent, inherit, isSpriteMode);
1201
+ }
1202
+
953
1203
  if (isTrailMode && followLocalOrigin) {
954
1204
  const trail = particle as TrailParticle;
955
1205
  if (!trail.localPosition) {
@@ -981,6 +1231,53 @@ export class ParticleSystem implements IParticleSystem {
981
1231
  }
982
1232
  }
983
1233
 
1234
+ /** Turns a newborn so that it faces the way its shape sent it; see {@link alignToDirection}. */
1235
+ private alignParticleToDirection(sprite: SpriteParticle, isMeshMode: boolean): void {
1236
+ const velocity = sprite.velocity;
1237
+ const speedSq = velocity.x * velocity.x + velocity.y * velocity.y + velocity.z * velocity.z;
1238
+ if (speedSq === 0) {
1239
+ return;
1240
+ }
1241
+ if (isMeshMode) {
1242
+ // +Z along the velocity, with the start rotation applied first.
1243
+ const rotation = sprite.rotation as Quaternion;
1244
+ tempV.copy(velocity).divideScalar(Math.sqrt(speedSq));
1245
+ tempQ.setFromUnitVectors(UP, tempV);
1246
+ rotation.multiplyQuaternions(tempQ, rotation);
1247
+ } else {
1248
+ // The quad's top (its +Y) toward the velocity in the emitter's XY plane.
1249
+ if (velocity.x !== 0 || velocity.y !== 0) {
1250
+ sprite.rotation = (sprite.rotation as number) + Math.atan2(-velocity.x, velocity.y);
1251
+ }
1252
+ }
1253
+ }
1254
+
1255
+ /** Applies the parent particle's inherited properties to a sub emitted newborn; see `EmitSubParticleSystem.inherit`. */
1256
+ private inheritFromParent(particle: Particle, parent: Particle, inherit: number, isSpriteMode: boolean): void {
1257
+ if (inherit & SubEmitterInheritMask.color) {
1258
+ particle.startColor.multiply(parent.color);
1259
+ particle.color.copy(particle.startColor);
1260
+ }
1261
+ if (inherit & SubEmitterInheritMask.size) {
1262
+ particle.startSize.multiply(parent.size);
1263
+ particle.size.copy(particle.startSize);
1264
+ }
1265
+ if (inherit & SubEmitterInheritMask.rotation && isSpriteMode) {
1266
+ const sprite = particle as SpriteParticle;
1267
+ const parentRotation = parent.rotation;
1268
+ if (typeof sprite.rotation === 'number') {
1269
+ if (typeof parentRotation === 'number') {
1270
+ sprite.rotation += parentRotation;
1271
+ }
1272
+ } else if (parentRotation instanceof Quaternion) {
1273
+ (sprite.rotation as Quaternion).multiplyQuaternions(parentRotation, sprite.rotation as Quaternion);
1274
+ }
1275
+ }
1276
+ if (inherit & SubEmitterInheritMask.lifetime && parent.life > 0) {
1277
+ particle.life *= Math.max(0, 1 - parent.age / parent.life);
1278
+ }
1279
+ }
1280
+
984
1281
  endEmit() {
985
1282
  this.emitEnded = true;
986
1283
  if (this.autoDestroy) {
@@ -1003,6 +1300,12 @@ export class ParticleSystem implements IParticleSystem {
1003
1300
  this.memory.length = 0;
1004
1301
  this.paused = false;
1005
1302
  this.particleNum = 0;
1303
+ this.boundsValid = false;
1304
+ this.pendingCatchUp = 0;
1305
+ if (this.seedValue !== undefined) {
1306
+ // The same seed again, so a restart replays the same effect.
1307
+ this.rng = createSeededRandom(this.seedValue);
1308
+ }
1006
1309
  this.emissionState.isBursting = false;
1007
1310
  this.emissionState.burstIndex = 0;
1008
1311
  this.emissionState.burstWaveIndex = 0;
@@ -1026,9 +1329,33 @@ export class ParticleSystem implements IParticleSystem {
1026
1329
 
1027
1330
  private firstTimeUpdate = true;
1028
1331
 
1029
- /** @internal */
1030
- update(delta: number) {
1332
+ /**
1333
+ * Advances the system by `delta` seconds of host time.
1334
+ *
1335
+ * `stepDeadline` is a `performance.now()` time after which no further
1336
+ * catch-up step of this frame is run: a frame long enough to need several
1337
+ * steps gets at least one, and the time the rest would have covered is
1338
+ * dropped rather than carried, so one slow frame does not grow the next.
1339
+ * `catchUpDeadline` bounds the steps a `'pauseAndCatchUp'` system runs on
1340
+ * coming back into view; unset, the system allows itself
1341
+ * {@link catchUpBudgetMs}. The renderer supplies both from its budgets.
1342
+ * @internal
1343
+ */
1344
+ update(delta: number, stepDeadline = Infinity, catchUpDeadline?: number) {
1031
1345
  if (this.paused) return;
1346
+ delta *= this.playbackSpeed;
1347
+
1348
+ if (this.culled) {
1349
+ // Out of every camera's view. A paused system holds its particles
1350
+ // where they are; a catching-up one also remembers the time it missed.
1351
+ if (this.cullingMode === 'pauseAndCatchUp') {
1352
+ this.pendingCatchUp = Math.min(MAX_CATCH_UP_SECONDS, this.pendingCatchUp + delta);
1353
+ }
1354
+ return;
1355
+ }
1356
+ if (this.pendingCatchUp > 0) {
1357
+ this.catchUp(catchUpDeadline ?? performance.now() + ParticleSystem.catchUpBudgetMs);
1358
+ }
1032
1359
 
1033
1360
  if (this.firstTimeUpdate) {
1034
1361
  this.firstTimeUpdate = false;
@@ -1093,10 +1420,169 @@ export class ParticleSystem implements IParticleSystem {
1093
1420
  );
1094
1421
  for (let i = 0; i < stepsToRun; i++) {
1095
1422
  this.simulateStep(SIMULATION_STEP);
1423
+ if (stepDeadline !== Infinity && i + 1 < stepsToRun && performance.now() >= stepDeadline) {
1424
+ // Over the frame's simulation budget: the remaining steps' time
1425
+ // is consumed below without being simulated.
1426
+ break;
1427
+ }
1096
1428
  }
1097
1429
  this.simulationAccumulator -= stepsToRun * SIMULATION_STEP;
1098
1430
  }
1099
1431
 
1432
+ /** Runs the steps a system missed while culled, as many as fit before `deadline`. */
1433
+ private catchUp(deadline: number): void {
1434
+ let steps = Math.floor(this.pendingCatchUp / SIMULATION_STEP);
1435
+ this.pendingCatchUp = 0;
1436
+ while (steps-- > 0) {
1437
+ this.simulateStep(SIMULATION_STEP);
1438
+ if (steps > 0 && performance.now() >= deadline) {
1439
+ break;
1440
+ }
1441
+ }
1442
+ }
1443
+
1444
+ /**
1445
+ * Records the bounds the sprite step measured over the live particles.
1446
+ * Trails are measured by their renderer instead, since their ribbons live
1447
+ * outside the store.
1448
+ */
1449
+ private storeBounds(
1450
+ count: number,
1451
+ measured: boolean,
1452
+ minX: number,
1453
+ minY: number,
1454
+ minZ: number,
1455
+ maxX: number,
1456
+ maxY: number,
1457
+ maxZ: number,
1458
+ maxSize: number,
1459
+ maxSpeedSq: number
1460
+ ): void {
1461
+ if (count === 0) {
1462
+ this.boundsValid = false;
1463
+ return;
1464
+ }
1465
+ this.stepsSinceBounds = measured ? 0 : this.stepsSinceBounds + 1;
1466
+ this.boundsMaxSize = maxSize;
1467
+ this.boundsMaxSpeedSq = maxSpeedSq;
1468
+ const maxSpeed = Math.sqrt(maxSpeedSq);
1469
+ const renderMode = this.rendererSettings.renderMode;
1470
+ // How far past its position a particle can be drawn: half a quad's
1471
+ // diagonal plus its pivot, a mesh's radius, or a stretched quad's tail.
1472
+ // Plus the motion since the last measurement, and the step the renderer
1473
+ // moves particles on along their velocity.
1474
+ let padding = maxSpeed * SIMULATION_STEP * (this.stepsSinceBounds + 1);
1475
+ if (renderMode === RenderMode.Mesh) {
1476
+ padding += maxSize * this.meshGeometryRadius();
1477
+ } else if (renderMode === RenderMode.StretchedBillBoard) {
1478
+ const settings = this.rendererEmitterSettings as StretchedBillBoardSettings;
1479
+ padding += maxSize * (1 + (settings.lengthFactor ?? 0) + maxSpeed * (settings.speedFactor ?? 0));
1480
+ } else {
1481
+ const pivot = this.rendererSettings.pivot;
1482
+ const pivotReach = pivot === undefined ? 0 : Math.abs(pivot.x) + Math.abs(pivot.y) + Math.abs(pivot.z);
1483
+ padding += maxSize * (0.75 + pivotReach);
1484
+ }
1485
+ this.boundsMin.set(minX, minY, minZ);
1486
+ this.boundsMax.set(maxX, maxY, maxZ);
1487
+ this.boundsPadding = padding;
1488
+ this.boundsInWorld = this.worldSpace;
1489
+ this.boundsValid = true;
1490
+ }
1491
+
1492
+ /** Distance of the farthest vertex of the mesh particles' geometry from its origin. */
1493
+ private meshGeometryRadius(): number {
1494
+ const geometry = this.rendererSettings.instancingGeometry;
1495
+ if (geometry !== this.geometryRadiusFor) {
1496
+ let radiusSq = 0;
1497
+ for (let o = 0; o + 2 < geometry.length; o += 3) {
1498
+ const d =
1499
+ geometry[o] * geometry[o] + geometry[o + 1] * geometry[o + 1] + geometry[o + 2] * geometry[o + 2];
1500
+ if (d > radiusSq) radiusSq = d;
1501
+ }
1502
+ this.geometryRadius = Math.sqrt(radiusSq);
1503
+ this.geometryRadiusFor = geometry;
1504
+ }
1505
+ return this.geometryRadius;
1506
+ }
1507
+
1508
+ /**
1509
+ * World-space axis-aligned bounds of everything this system draws, padded
1510
+ * for particle size, into `min` and `max`. False when the system has no
1511
+ * particles, or none have been measured yet; the caller should then treat
1512
+ * it as visible. Measured in full every few simulated steps and carried,
1513
+ * padded for the motion since, in between: conservative, and at most a
1514
+ * frame behind the display.
1515
+ */
1516
+ getWorldBounds(min: Vector3, max: Vector3): boolean {
1517
+ if (!this.boundsValid) {
1518
+ return false;
1519
+ }
1520
+ const bmin = this.boundsMin;
1521
+ const bmax = this.boundsMax;
1522
+ if (this.boundsInWorld) {
1523
+ const pad = this.boundsPadding;
1524
+ min.set(bmin.x - pad, bmin.y - pad, bmin.z - pad);
1525
+ max.set(bmax.x + pad, bmax.y + pad, bmax.z + pad);
1526
+ return true;
1527
+ }
1528
+ // Local bounds: the box of the eight transformed corners, padded by the
1529
+ // reach scaled by the emitter's largest axis.
1530
+ const e = this.emitter.matrixWorld.elements;
1531
+ const scale = Math.sqrt(
1532
+ Math.max(
1533
+ e[0] * e[0] + e[1] * e[1] + e[2] * e[2],
1534
+ e[4] * e[4] + e[5] * e[5] + e[6] * e[6],
1535
+ e[8] * e[8] + e[9] * e[9] + e[10] * e[10]
1536
+ )
1537
+ );
1538
+ const pad = this.boundsPadding * scale;
1539
+ let minX = Infinity;
1540
+ let minY = Infinity;
1541
+ let minZ = Infinity;
1542
+ let maxX = -Infinity;
1543
+ let maxY = -Infinity;
1544
+ let maxZ = -Infinity;
1545
+ for (let corner = 0; corner < 8; corner++) {
1546
+ const x = corner & 1 ? bmax.x : bmin.x;
1547
+ const y = corner & 2 ? bmax.y : bmin.y;
1548
+ const z = corner & 4 ? bmax.z : bmin.z;
1549
+ const w = 1 / (e[3] * x + e[7] * y + e[11] * z + e[15]);
1550
+ const wx = (e[0] * x + e[4] * y + e[8] * z + e[12]) * w;
1551
+ const wy = (e[1] * x + e[5] * y + e[9] * z + e[13]) * w;
1552
+ const wz = (e[2] * x + e[6] * y + e[10] * z + e[14]) * w;
1553
+ if (wx < minX) minX = wx;
1554
+ if (wx > maxX) maxX = wx;
1555
+ if (wy < minY) minY = wy;
1556
+ if (wy > maxY) maxY = wy;
1557
+ if (wz < minZ) minZ = wz;
1558
+ if (wz > maxZ) maxZ = wz;
1559
+ }
1560
+ min.set(minX - pad, minY - pad, minZ - pad);
1561
+ max.set(maxX + pad, maxY + pad, maxZ + pad);
1562
+ return true;
1563
+ }
1564
+
1565
+ /**
1566
+ * Records bounds a renderer measured in world space, for systems whose
1567
+ * drawn extent the store does not hold (trail ribbons).
1568
+ * @internal
1569
+ */
1570
+ setWorldBounds(
1571
+ minX: number,
1572
+ minY: number,
1573
+ minZ: number,
1574
+ maxX: number,
1575
+ maxY: number,
1576
+ maxZ: number,
1577
+ padding: number
1578
+ ) {
1579
+ this.boundsMin.set(minX, minY, minZ);
1580
+ this.boundsMax.set(maxX, maxY, maxZ);
1581
+ this.boundsPadding = padding;
1582
+ this.boundsInWorld = true;
1583
+ this.boundsValid = true;
1584
+ }
1585
+
1100
1586
  /**
1101
1587
  * Time the display is ahead of (positive) or still ahead of the simulation
1102
1588
  * (negative — the simulation ran a hair early), in seconds. Magnitude never
@@ -1184,8 +1670,25 @@ export class ParticleSystem implements IParticleSystem {
1184
1670
  return this.fusionSteps;
1185
1671
  }
1186
1672
 
1187
- /** Advances emission, behaviors, motion and culling by one fixed timestep. */
1673
+ /**
1674
+ * Advances emission, behaviors, motion and culling by one fixed timestep,
1675
+ * on the system's own random generator when it has a {@link seed}.
1676
+ */
1188
1677
  private simulateStep(delta: number): void {
1678
+ const rng = this.rng;
1679
+ if (rng === undefined) {
1680
+ this.stepOnce(delta);
1681
+ return;
1682
+ }
1683
+ const previous = setRandomSource(rng);
1684
+ try {
1685
+ this.stepOnce(delta);
1686
+ } finally {
1687
+ setRandomSource(previous);
1688
+ }
1689
+ }
1690
+
1691
+ private stepOnce(delta: number): void {
1189
1692
  if (!this.onlyUsedByOther) {
1190
1693
  this.emit(delta, this.emissionState, this.emitter.matrixWorld);
1191
1694
  }
@@ -1291,10 +1794,27 @@ export class ParticleSystem implements IParticleSystem {
1291
1794
  const curColor = store.color;
1292
1795
  const collapseSize = snapshotSize;
1293
1796
  const collapseColor = snapshotColor;
1797
+ // The bounds fall out of the same pass. Measuring every particle on
1798
+ // every step costs about as much as the integration itself, so the
1799
+ // full measurement runs every few steps; in between, newborns extend
1800
+ // the last bounds and the fastest particle seen since pads them for
1801
+ // the motion, which keeps them conservative.
1802
+ const measure = this.stepsSinceBounds >= BOUNDS_REFRESH_STEPS || !this.boundsValid;
1803
+ const carriedMin = this.boundsMin;
1804
+ const carriedMax = this.boundsMax;
1805
+ let minX = measure ? Infinity : carriedMin.x;
1806
+ let minY = measure ? Infinity : carriedMin.y;
1807
+ let minZ = measure ? Infinity : carriedMin.z;
1808
+ let maxX = measure ? -Infinity : carriedMax.x;
1809
+ let maxY = measure ? -Infinity : carriedMax.y;
1810
+ let maxZ = measure ? -Infinity : carriedMax.z;
1811
+ let maxSize = measure ? 0 : this.boundsMaxSize;
1812
+ let maxSpeedSq = measure ? 0 : this.boundsMaxSpeedSq;
1294
1813
  for (let i = 0; i < liveParticleCount; i++) {
1295
1814
  const scalar = i * stride;
1296
1815
  let age = scalars[scalar + ParticleStore.AGE];
1297
1816
  const offset = i * 3;
1817
+ const track = measure || age === 0;
1298
1818
  if (age === 0) {
1299
1819
  if (collapseSize) {
1300
1820
  prevSize[offset] = curSize[offset];
@@ -1310,9 +1830,30 @@ export class ParticleSystem implements IParticleSystem {
1310
1830
  }
1311
1831
  }
1312
1832
  const step = delta * scalars[scalar + ParticleStore.SPEED_MODIFIER];
1313
- positions[offset] += velocities[offset] * step;
1314
- positions[offset + 1] += velocities[offset + 1] * step;
1315
- positions[offset + 2] += velocities[offset + 2] * step;
1833
+ const vx = velocities[offset];
1834
+ const vy = velocities[offset + 1];
1835
+ const vz = velocities[offset + 2];
1836
+ const x = positions[offset] + vx * step;
1837
+ const y = positions[offset + 1] + vy * step;
1838
+ const z = positions[offset + 2] + vz * step;
1839
+ positions[offset] = x;
1840
+ positions[offset + 1] = y;
1841
+ positions[offset + 2] = z;
1842
+ if (track) {
1843
+ minX = Math.min(minX, x);
1844
+ maxX = Math.max(maxX, x);
1845
+ minY = Math.min(minY, y);
1846
+ maxY = Math.max(maxY, y);
1847
+ minZ = Math.min(minZ, z);
1848
+ maxZ = Math.max(maxZ, z);
1849
+ maxSize = Math.max(
1850
+ maxSize,
1851
+ Math.max(curSize[offset], Math.max(curSize[offset + 1], curSize[offset + 2]))
1852
+ );
1853
+ }
1854
+ // Speed is tracked every step: it bounds how far the particles
1855
+ // can have moved since the last measurement.
1856
+ maxSpeedSq = Math.max(maxSpeedSq, vx * vx + vy * vy + vz * vz);
1316
1857
  age += delta;
1317
1858
  scalars[scalar + ParticleStore.AGE] = age;
1318
1859
  if (age >= scalars[scalar + ParticleStore.LIFE]) {
@@ -1334,6 +1875,7 @@ export class ParticleSystem implements IParticleSystem {
1334
1875
  }
1335
1876
  }
1336
1877
  this.particleNum = liveParticleCount;
1878
+ this.storeBounds(liveParticleCount, measure, minX, minY, minZ, maxX, maxY, maxZ, maxSize, maxSpeedSq);
1337
1879
  this.notifyFinished();
1338
1880
  return;
1339
1881
  }
@@ -1514,7 +2056,7 @@ export class ParticleSystem implements IParticleSystem {
1514
2056
 
1515
2057
  /** Spawns one wave of `burst`, if its probability roll passes. */
1516
2058
  private fireBurstWave(burst: BurstParameters, emissionState: EmissionState, emitterMatrix: Matrix4): void {
1517
- if (Math.random() >= burst.probability) {
2059
+ if (random() >= burst.probability) {
1518
2060
  return;
1519
2061
  }
1520
2062
  const rawCount = burst.count.genValue(this.memory, this.time);
@@ -1719,6 +2261,15 @@ export class ParticleSystem implements IParticleSystem {
1719
2261
  instancingGeometry: geometryUUID,
1720
2262
  renderMode: this.renderMode,
1721
2263
  renderOrder: this.renderOrder,
2264
+ ...(this.renderingGroupId !== 0 ? {renderingGroupId: this.renderingGroupId} : {}),
2265
+ ...(this.applyFog ? {} : {applyFog: false}),
2266
+ ...(this.playbackSpeed !== 1 ? {playbackSpeed: this.playbackSpeed} : {}),
2267
+ ...(this.cullingMode !== 'always' ? {cullingMode: this.cullingMode} : {}),
2268
+ ...(this.seedValue !== undefined ? {seed: this.seedValue} : {}),
2269
+ ...(this.sortMode !== 'none' ? {sortMode: this.sortMode} : {}),
2270
+ ...(this.alignToDirection ? {alignToDirection: true} : {}),
2271
+ ...(this.minParticleSize > 0 ? {minParticleSize: this.minParticleSize} : {}),
2272
+ ...(this.maxParticleSize !== Infinity ? {maxParticleSize: this.maxParticleSize} : {}),
1722
2273
  rendererEmitterSettings: rendererEmitterSettingsJSON,
1723
2274
  material: materialUUID,
1724
2275
  layers: this.rendererSettings.layerMask,
@@ -1817,6 +2368,15 @@ export class ParticleSystem implements IParticleSystem {
1817
2368
  renderMode: json.renderMode,
1818
2369
  rendererEmitterSettings,
1819
2370
  renderOrder: json.renderOrder,
2371
+ renderingGroupId: typeof json.renderingGroupId === 'number' ? json.renderingGroupId : undefined,
2372
+ applyFog: json.applyFog !== false,
2373
+ playbackSpeed: typeof json.playbackSpeed === 'number' ? json.playbackSpeed : undefined,
2374
+ cullingMode: ParticleSystem.validCullingMode(json.cullingMode),
2375
+ seed: typeof json.seed === 'number' ? json.seed : undefined,
2376
+ sortMode: ParticleSystem.validSortMode(json.sortMode),
2377
+ alignToDirection: json.alignToDirection === true,
2378
+ minParticleSize: typeof json.minParticleSize === 'number' ? json.minParticleSize : undefined,
2379
+ maxParticleSize: typeof json.maxParticleSize === 'number' ? json.maxParticleSize : undefined,
1820
2380
  texture,
1821
2381
  material: materialMeta?.sourceMaterial ?? materialMeta,
1822
2382
  blendMode: materialMeta?.alphaMode ?? json.blending ?? Constants.ALPHA_ADD,
@@ -2051,7 +2611,7 @@ export class ParticleSystem implements IParticleSystem {
2051
2611
  rendererEmitterSettings = {};
2052
2612
  }
2053
2613
 
2054
- return new ParticleSystem({
2614
+ const copy = new ParticleSystem({
2055
2615
  autoDestroy: this.autoDestroy,
2056
2616
  looping: this.looping,
2057
2617
  prewarm: this.prewarm,
@@ -2074,6 +2634,15 @@ export class ParticleSystem implements IParticleSystem {
2074
2634
  instancingNormals: this.rendererSettings.instancingNormals,
2075
2635
  renderMode: this.renderMode,
2076
2636
  renderOrder: this.renderOrder,
2637
+ renderingGroupId: this.renderingGroupId,
2638
+ applyFog: this.applyFog,
2639
+ playbackSpeed: this.playbackSpeed,
2640
+ cullingMode: this.cullingMode,
2641
+ seed: this.seedValue,
2642
+ sortMode: this.sortMode,
2643
+ alignToDirection: this.alignToDirection,
2644
+ minParticleSize: this.minParticleSize,
2645
+ maxParticleSize: this.maxParticleSize,
2077
2646
  rendererEmitterSettings,
2078
2647
  material: this.materialRef,
2079
2648
  texture: this.rendererSettings.texture,
@@ -2098,5 +2667,13 @@ export class ParticleSystem implements IParticleSystem {
2098
2667
  rim: this.rim,
2099
2668
  scene: this.emitter.getScene(),
2100
2669
  });
2670
+ // A sub emitter behavior transforms by its parent system's emitter;
2671
+ // the cloned behaviors still pointed at this one.
2672
+ for (const behavior of newBehaviors) {
2673
+ if (behavior instanceof EmitSubParticleSystem) {
2674
+ behavior.particleSystem = copy;
2675
+ }
2676
+ }
2677
+ return copy;
2101
2678
  }
2102
2679
  }