babylon.quarks 0.18.0 → 0.19.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.
@@ -1,47 +1,52 @@
1
- import {Scene} from '@babylonjs/core/scene';
2
- import {Texture} from '@babylonjs/core/Materials/Textures/texture';
3
1
  import {Constants} from '@babylonjs/core/Engines/constants';
2
+ import {BaseTexture} from '@babylonjs/core/Materials/Textures/baseTexture';
3
+ import {Texture} from '@babylonjs/core/Materials/Textures/texture';
4
+ import {Scene} from '@babylonjs/core/scene';
4
5
  import {
5
6
  AxisAngleGenerator,
7
+ Behavior,
8
+ BehaviorFromJSON,
6
9
  ColorGenerator,
7
10
  ColorGeneratorFromJSON,
8
11
  ConstantColor,
9
12
  ConstantValue,
13
+ EmissionState,
14
+ EmitterFromJSON,
15
+ EmitterShape,
10
16
  FunctionColorGenerator,
11
17
  FunctionJSON,
12
18
  FunctionValueGenerator,
19
+ FusionStep,
13
20
  GeneratorFromJSON,
14
- ValueGenerator,
15
- ValueGeneratorFromJSON,
16
- Behavior,
17
- BehaviorFromJSON,
21
+ GeneratorMemory,
22
+ IParticleSystem,
23
+ Matrix3,
24
+ Matrix4,
18
25
  Particle,
19
- SpriteParticle,
20
- TrailParticle,
21
- EmitterFromJSON,
22
- EmitterShape,
23
- SphereEmitter,
26
+ ParticleStore,
27
+ ParticleSystemEvent,
28
+ ParticleSystemEventType,
29
+ planBehaviorFusion,
30
+ Quaternion,
24
31
  RendererEmitterSettings,
25
32
  RotationGenerator,
26
- IParticleSystem,
27
- EmissionState,
28
- GeneratorMemory,
29
- TrailSettings,
30
- StretchedBillBoardSettings,
31
33
  SerializationOptions,
34
+ SphereEmitter,
35
+ SpriteParticle,
36
+ StretchedBillBoardSettings,
37
+ TrailParticle,
38
+ TrailSettings,
39
+ ValueGenerator,
40
+ ValueGeneratorFromJSON,
32
41
  Vector3,
33
- Vector4,
34
- Matrix3,
35
- Matrix4,
36
- Quaternion,
37
42
  Vector3Generator,
38
- ParticleSystemEvent,
39
- ParticleSystemEventType,
43
+ Vector4,
40
44
  } from 'quarks.core';
41
- import {ParticleEmitter} from './ParticleEmitter';
42
- import {RenderMode} from './VFXBatch';
43
45
  import {BatchedRenderer, VFXBatchSettings} from './BatchedRenderer';
46
+ import {ensureEnvAtlasFromCube, getCachedEnvAtlas} from './envAtlas';
44
47
  import {ensureTriangleIndices} from './geometryUtil';
48
+ import {ParticleEmitter} from './ParticleEmitter';
49
+ import {RenderMode} from './VFXBatch';
45
50
 
46
51
  export interface BurstParameters {
47
52
  time: number;
@@ -51,19 +56,31 @@ export interface BurstParameters {
51
56
  probability: number;
52
57
  }
53
58
 
59
+ /** Particle backed by a {@link ParticleStore} row, as this system always creates. */
60
+ type StoreBackedParticle = Particle & {storeIndex: number; setStoreIndex(index: number): void};
61
+
54
62
  const UP = new Vector3(0, 0, 1);
55
63
  const tempQ = new Quaternion();
56
64
  const tempV = new Vector3();
57
65
  const tempV2 = new Vector3();
58
66
  const PREWARM_FPS = 60;
59
-
60
- const DEFAULT_POSITIONS = new Float32Array([
61
- -0.5, -0.5, 0,
62
- 0.5, -0.5, 0,
63
- 0.5, 0.5, 0,
64
- -0.5, 0.5, 0,
65
- ]);
67
+ const SIMULATION_STEP = 1 / PREWARM_FPS;
68
+ const MAX_SIMULATION_STEPS_PER_FRAME = 8;
69
+ // Math.round() breaks an exact tie (accumulator sitting at precisely half a
70
+ // step) by rounding up. At exactly 120Hz that tie is not a corner case — it is
71
+ // every single frame — and rounding it up would run a step on every one of
72
+ // them instead of every other one. Nudging the tie down costs nothing on a
73
+ // real display (real frame times essentially never land on the tie exactly)
74
+ // and keeps the well-tested "half a step is no step yet" behavior.
75
+ const ROUND_TIE_BIAS = 1e-9;
76
+
77
+ const DEFAULT_POSITIONS = new Float32Array([-0.5, -0.5, 0, 0.5, -0.5, 0, 0.5, 0.5, 0, -0.5, 0.5, 0]);
66
78
  const DEFAULT_UVS = new Float32Array([0, 0, 1, 0, 1, 1, 0, 1]);
79
+
80
+ /** Zero UVs sized to a position buffer — missing/mismatched uv attrs → GL_INVALID_OPERATION on iOS. */
81
+ function fallbackUVsForPositions(positions: Float32Array): Float32Array {
82
+ return new Float32Array((positions.length / 3) * 2);
83
+ }
67
84
  const DEFAULT_INDICES = new Uint32Array([0, 1, 2, 0, 2, 3]);
68
85
 
69
86
  export interface ParticleSystemParameters {
@@ -194,6 +211,8 @@ export class ParticleSystem implements IParticleSystem {
194
211
  particleNum: number;
195
212
  paused: boolean;
196
213
  particles: Array<Particle>;
214
+ /** Column storage shared by this system's particles. */
215
+ readonly store: ParticleStore = new ParticleStore(64);
197
216
  emitterShape: EmitterShape;
198
217
  emitter: ParticleEmitter;
199
218
  rendererSettings: VFXBatchSettings;
@@ -215,8 +234,13 @@ export class ParticleSystem implements IParticleSystem {
215
234
  private listeners: {[event: string]: Array<(event: ParticleSystemEvent) => void>} = {};
216
235
  private readonly layerMaskProxy: {mask: number};
217
236
  private materialRef: any = null;
237
+ private sceneRef: Scene | undefined;
218
238
  private qualityFactor = 1;
219
- private useFastTrailHistory = true;
239
+ private simulationAccumulator = 0;
240
+ private destroyed = false;
241
+ /** Behaviors the cached plan was built from, compared by identity. */
242
+ private fusionPlan?: Array<Behavior>;
243
+ private fusionSteps: Array<FusionStep> = [];
220
244
  /** @internal **/
221
245
  _renderer?: BatchedRenderer;
222
246
 
@@ -420,7 +444,8 @@ export class ParticleSystem implements IParticleSystem {
420
444
  }
421
445
 
422
446
  constructor(parameters: ParticleSystemParameters) {
423
- this.layerMaskProxy = {mask: parameters.layerMask ?? 0x0FFFFFFF};
447
+ this.sceneRef = parameters.scene;
448
+ this.layerMaskProxy = {mask: parameters.layerMask ?? 0x0fffffff};
424
449
  Object.defineProperty(this.layerMaskProxy, 'mask', {
425
450
  enumerable: true,
426
451
  get: () => this.rendererSettings.layerMask,
@@ -461,7 +486,9 @@ export class ParticleSystem implements IParticleSystem {
461
486
  this.rendererSettings = {
462
487
  instancingGeometry: parameters.instancingGeometry ?? DEFAULT_POSITIONS,
463
488
  instancingIndices: parameters.instancingIndices ?? DEFAULT_INDICES,
464
- instancingUVs: parameters.instancingUVs ?? DEFAULT_UVS,
489
+ instancingUVs:
490
+ parameters.instancingUVs ??
491
+ (parameters.instancingGeometry ? fallbackUVsForPositions(parameters.instancingGeometry) : DEFAULT_UVS),
465
492
  instancingNormals: parameters.instancingNormals,
466
493
  renderMode: parameters.renderMode ?? RenderMode.BillBoard,
467
494
  renderOrder: parameters.renderOrder ?? 0,
@@ -477,10 +504,16 @@ export class ParticleSystem implements IParticleSystem {
477
504
  materialDepthWrite: parameters.depthWrite ?? false,
478
505
  materialAlphaTest: parameters.alphaTest ?? 0,
479
506
  texture: parameters.texture ?? null,
480
- layerMask: parameters.layerMask ?? 0x0FFFFFFF,
507
+ reflectionTexture: null,
508
+ reflectionLevel: 1,
509
+ reflectionFaces: null,
510
+ reflectionAtlas: null,
511
+ layerMask: parameters.layerMask ?? 0x0fffffff,
481
512
  };
482
513
  if (this.rendererSettings.renderMode === RenderMode.Mesh && !this.rendererSettings.instancingNormals) {
483
- this.rendererSettings.instancingNormals = ParticleSystem.createFallbackNormals(this.rendererSettings.instancingGeometry);
514
+ this.rendererSettings.instancingNormals = ParticleSystem.createFallbackNormals(
515
+ this.rendererSettings.instancingGeometry
516
+ );
484
517
  }
485
518
 
486
519
  this.materialRef = parameters.material ?? null;
@@ -516,7 +549,6 @@ export class ParticleSystem implements IParticleSystem {
516
549
  this.emissionOverDistance.startGen(this.memory);
517
550
  this.startDelay.startGen(this.memory);
518
551
  this.startDelayTimeLeft = this.startDelay.genValue(this.memory, 0);
519
- this.refreshTrailHistoryMode();
520
552
 
521
553
  this.emitEnded = false;
522
554
  this.markForDestroy = false;
@@ -532,6 +564,7 @@ export class ParticleSystem implements IParticleSystem {
532
564
  depthWrite?: boolean;
533
565
  alphaTest?: number;
534
566
  texture?: Texture | null;
567
+ reflectionAtlas?: BaseTexture | null;
535
568
  layerMask?: number;
536
569
  } = {}
537
570
  ) {
@@ -544,12 +577,18 @@ export class ParticleSystem implements IParticleSystem {
544
577
  overrides.texture !== undefined
545
578
  ? overrides.texture
546
579
  : (material?.albedoTexture ??
547
- material?.diffuseTexture ??
548
- material?.emissiveTexture ??
549
- material?.opacityTexture ??
550
- material?.baseTexture ??
551
- this.rendererSettings.texture ??
552
- null);
580
+ material?.diffuseTexture ??
581
+ material?.emissiveTexture ??
582
+ material?.opacityTexture ??
583
+ material?.baseTexture ??
584
+ this.rendererSettings.texture ??
585
+ null);
586
+ const resolvedReflection =
587
+ material?.reflectionTexture ??
588
+ material?.environmentTexture ??
589
+ this.rendererSettings.reflectionTexture ??
590
+ null;
591
+ const resolvedReflectionLevel = typeof resolvedReflection?.level === 'number' ? resolvedReflection.level : 1;
553
592
  const resolvedBlendMode =
554
593
  overrides.blendMode ??
555
594
  (typeof material?.alphaMode === 'number' ? material.alphaMode : this.rendererSettings.materialBlendMode);
@@ -558,8 +597,8 @@ export class ParticleSystem implements IParticleSystem {
558
597
  (typeof material?.needAlphaBlending === 'function'
559
598
  ? material.needAlphaBlending()
560
599
  : typeof material?.alpha === 'number'
561
- ? material.alpha < 1
562
- : this.rendererSettings.materialTransparent);
600
+ ? material.alpha < 1
601
+ : this.rendererSettings.materialTransparent);
563
602
  const resolvedDepthTest =
564
603
  overrides.depthTest ??
565
604
  (typeof material?.disableDepthTest === 'boolean'
@@ -570,17 +609,57 @@ export class ParticleSystem implements IParticleSystem {
570
609
  (typeof material?.disableDepthWrite === 'boolean'
571
610
  ? !material.disableDepthWrite
572
611
  : typeof material?.forceDepthWrite === 'boolean'
573
- ? material.forceDepthWrite
574
- : this.rendererSettings.materialDepthWrite);
612
+ ? material.forceDepthWrite
613
+ : this.rendererSettings.materialDepthWrite);
575
614
  const resolvedAlphaTest =
576
615
  overrides.alphaTest ??
577
- (typeof material?.alphaCutOff === 'number'
578
- ? material.alphaCutOff
579
- : typeof material?.alphaCutOffValue === 'number'
580
- ? material.alphaCutOffValue
581
- : this.rendererSettings.materialAlphaTest);
616
+ (() => {
617
+ // StandardMaterial always has alphaCutOff (default 0.4). Only treat it as
618
+ // particle alpha-test when the material is actually in an alpha-test mode —
619
+ // otherwise Mesh Material demos wrongly enable USE_ALPHATEST.
620
+ const mode = material?.transparencyMode;
621
+ const alphaTestMode = mode === 1 || mode === 3;
622
+ if (alphaTestMode && typeof material?.alphaCutOff === 'number') {
623
+ return material.alphaCutOff;
624
+ }
625
+ if (alphaTestMode && typeof material?.alphaCutOffValue === 'number') {
626
+ return material.alphaCutOffValue;
627
+ }
628
+ if (material) {
629
+ return 0;
630
+ }
631
+ return this.rendererSettings.materialAlphaTest;
632
+ })();
582
633
 
583
634
  this.rendererSettings.texture = resolvedTexture;
635
+ this.rendererSettings.reflectionTexture = resolvedReflection;
636
+ this.rendererSettings.reflectionLevel =
637
+ typeof material?.reflectionLevel === 'number' ? material.reflectionLevel : resolvedReflectionLevel;
638
+ // Prefer an explicit atlas; otherwise build a 3×2 atlas from CubeTexture
639
+ // face URLs (iOS-safe single sampler2D — not samplerCube / six faces).
640
+ let resolvedAtlas =
641
+ material?.reflectionAtlas ?? overrides.reflectionAtlas ?? this.rendererSettings.reflectionAtlas ?? null;
642
+ if (!resolvedAtlas && resolvedReflection?.isCube) {
643
+ resolvedAtlas = getCachedEnvAtlas(resolvedReflection);
644
+ if (!resolvedAtlas && this.sceneRef) {
645
+ ensureEnvAtlasFromCube(resolvedReflection, this.sceneRef, (atlas) => {
646
+ if (this.rendererSettings.reflectionAtlas === atlas) {
647
+ return;
648
+ }
649
+ this.rendererSettings.reflectionAtlas = atlas;
650
+ if (material && material.reflectionAtlas == null) {
651
+ material.reflectionAtlas = atlas;
652
+ }
653
+ this.neededToUpdateRender = true;
654
+ });
655
+ resolvedAtlas = getCachedEnvAtlas(resolvedReflection);
656
+ }
657
+ }
658
+ this.rendererSettings.reflectionAtlas = resolvedAtlas;
659
+ // Do not expand cube files into six face textures anymore — that path
660
+ // still trips GL_INVALID_OPERATION on iOS. Prefer a single atlas.
661
+ this.rendererSettings.reflectionFaces =
662
+ material?.reflectionFaces?.length === 6 ? material.reflectionFaces : null;
584
663
  this.rendererSettings.materialBlendMode = resolvedBlendMode;
585
664
  this.rendererSettings.materialTransparent = resolvedTransparent;
586
665
  this.rendererSettings.materialDepthTest = resolvedDepthTest;
@@ -592,88 +671,56 @@ export class ParticleSystem implements IParticleSystem {
592
671
  this.neededToUpdateRender = true;
593
672
  }
594
673
 
595
- pause() { this.paused = true; }
596
- play() { this.paused = false; }
597
- stop() { this.restart(); this.pause(); }
598
-
599
- private refreshTrailHistoryMode() {
600
- this.useFastTrailHistory = !this.behaviors.some((behavior) => (behavior as any).type === 'WidthOverLength');
674
+ pause() {
675
+ this.paused = true;
601
676
  }
602
-
603
- private ensureTrailHistoryCapacity(particle: TrailParticle, capacity: number) {
604
- const trailAny = particle as any;
605
- if (trailAny._trailHistoryCapacity === capacity && trailAny._trailHistoryPositions) {
606
- return;
607
- }
608
- trailAny._trailHistoryCapacity = capacity;
609
- trailAny._trailHistoryPositions = new Float32Array(capacity * 3);
610
- trailAny._trailHistorySizes = new Float32Array(capacity);
611
- trailAny._trailHistoryColors = new Float32Array(capacity * 4);
612
- trailAny._trailHistoryHead = 0;
613
- trailAny._trailHistoryCount = 0;
677
+ play() {
678
+ this.paused = false;
679
+ }
680
+ stop() {
681
+ this.restart();
682
+ this.pause();
614
683
  }
615
684
 
616
- private resetTrailHistory(particle: TrailParticle) {
617
- const trailAny = particle as any;
618
- trailAny._trailHistoryHead = 0;
619
- trailAny._trailHistoryCount = 0;
685
+ setQualityFactor(qualityFactor: number) {
686
+ this.qualityFactor = Math.max(0.1, Math.min(1, qualityFactor));
620
687
  }
621
688
 
622
- private getTrailHistoryCount(particle: TrailParticle): number {
623
- const trailAny = particle as any;
624
- const fastHistoryCount = trailAny._trailHistoryCount;
625
- if (typeof fastHistoryCount === 'number') {
626
- return fastHistoryCount;
627
- }
628
- return particle.previous.length;
629
- }
630
-
631
- private updateFastTrailHistory(particle: TrailParticle) {
632
- const trailAny = particle as any;
633
- const capacity = Math.max(1, Math.ceil(particle.length));
634
- this.ensureTrailHistoryCapacity(particle, capacity);
635
- let head = trailAny._trailHistoryHead as number;
636
- let count = trailAny._trailHistoryCount as number;
637
- const positions = trailAny._trailHistoryPositions as Float32Array;
638
- const sizes = trailAny._trailHistorySizes as Float32Array;
639
- const colors = trailAny._trailHistoryColors as Float32Array;
640
-
641
- if (particle.age <= particle.life) {
642
- const posIndex = head * 3;
643
- positions[posIndex] = particle.position.x;
644
- positions[posIndex + 1] = particle.position.y;
645
- positions[posIndex + 2] = particle.position.z;
646
- sizes[head] = particle.size.x;
647
- const colorIndex = head * 4;
648
- colors[colorIndex] = particle.color.x;
649
- colors[colorIndex + 1] = particle.color.y;
650
- colors[colorIndex + 2] = particle.color.z;
651
- colors[colorIndex + 3] = particle.color.w;
652
- head = (head + 1) % capacity;
653
- if (count < capacity) {
654
- count++;
655
- }
656
- } else if (count > 0) {
657
- count--;
689
+ /**
690
+ * Appends one particle to the pool, giving it the next row of the store.
691
+ *
692
+ * Growing the store replaces its arrays, so every particle already bound to
693
+ * it has to be re-pointed at the new ones.
694
+ */
695
+ /** Points every particle at the store's columns again, after any were replaced. */
696
+ private rebindParticles(): void {
697
+ const particles = this.particles;
698
+ for (let i = 0; i < particles.length; i++) {
699
+ (particles[i] as SpriteParticle | TrailParticle).rebind();
658
700
  }
659
-
660
- trailAny._trailHistoryHead = head;
661
- trailAny._trailHistoryCount = count;
662
701
  }
663
702
 
664
- setQualityFactor(qualityFactor: number) {
665
- this.qualityFactor = Math.max(0.1, Math.min(1, qualityFactor));
703
+ private growParticlePool(isTrailMode: boolean): void {
704
+ const index = this.particles.length;
705
+ if (this.store.ensureCapacity(index + 1)) {
706
+ this.rebindParticles();
707
+ }
708
+ this.particles.push(isTrailMode ? new TrailParticle(this.store, index) : new SpriteParticle(this.store, index));
666
709
  }
667
710
 
668
711
  private spawn(count: number, emissionState: EmissionState, matrix: Matrix4) {
669
- tempQ.setFromRotationMatrix(matrix);
670
712
  const translation = tempV;
671
713
  const scale = tempV2;
714
+ // decompose writes tempQ, so no separate setFromRotationMatrix is needed.
672
715
  matrix.decompose(translation, tempQ, scale);
673
716
  const behaviors = this.behaviors;
674
717
  const behaviorCount = behaviors.length;
675
718
  const renderMode = this.rendererSettings.renderMode;
676
719
  const isTrailMode = renderMode === RenderMode.Trail;
720
+ const isSpriteMode = !isTrailMode;
721
+ const isMeshMode = renderMode === RenderMode.Mesh;
722
+ const startRotationIsRotation = this.startRotation.type === 'rotation';
723
+ const startSizeIsVector3 = this.startSize.type === 'vec3function';
677
724
  const timeRatio = emissionState.time / this.duration;
678
725
  const trailSettings = isTrailMode ? (this.rendererEmitterSettings as TrailSettings) : undefined;
679
726
  const followLocalOrigin = Boolean(trailSettings?.followLocalOrigin);
@@ -682,15 +729,15 @@ export class ParticleSystem implements IParticleSystem {
682
729
  emissionState.burstParticleIndex = i;
683
730
  this.particleNum++;
684
731
  if (this.particles.length < this.particleNum) {
685
- if (isTrailMode) {
686
- this.particles.push(new TrailParticle());
687
- } else {
688
- this.particles.push(new SpriteParticle());
689
- }
732
+ this.growParticlePool(isTrailMode);
690
733
  }
691
734
  const particle = this.particles[this.particleNum - 1];
692
735
  particle.reset();
693
736
  particle.speedModifier = 1;
737
+ // Parent EmitSub can refill an onlyUsedByOther system after it had
738
+ // already finished empty; clear the flag so update does not treat it
739
+ // as done while it has live particles again.
740
+ this.finishedEventFired = false;
694
741
  this.startColor.startGen(particle.memory);
695
742
  this.startColor.genColor(particle.memory, particle.startColor, this.emissionState.time);
696
743
  particle.color.copy(particle.startColor);
@@ -700,7 +747,7 @@ export class ParticleSystem implements IParticleSystem {
700
747
  particle.life = this.startLife.genValue(particle.memory, timeRatio);
701
748
  particle.age = 0;
702
749
  this.startSize.startGen(particle.memory);
703
- if (this.startSize.type === 'vec3function') {
750
+ if (startSizeIsVector3) {
704
751
  (this.startSize as Vector3Generator).genValue(particle.memory, particle.startSize, timeRatio);
705
752
  } else {
706
753
  const size = (this.startSize as FunctionValueGenerator).genValue(particle.memory, timeRatio);
@@ -710,39 +757,51 @@ export class ParticleSystem implements IParticleSystem {
710
757
  particle.uvTile = this.startTileIndex.genValue(particle.memory);
711
758
  particle.size.copy(particle.startSize);
712
759
 
713
- if (
714
- this.rendererSettings.renderMode === RenderMode.Mesh ||
715
- this.rendererSettings.renderMode === RenderMode.BillBoard ||
716
- this.rendererSettings.renderMode === RenderMode.VerticalBillBoard ||
717
- this.rendererSettings.renderMode === RenderMode.HorizontalBillBoard ||
718
- this.rendererSettings.renderMode === RenderMode.StretchedBillBoard
719
- ) {
760
+ if (isSpriteMode) {
720
761
  const sprite = particle as SpriteParticle;
721
762
  this.startRotation.startGen(particle.memory);
722
- if (renderMode === RenderMode.Mesh) {
763
+ if (isMeshMode) {
723
764
  if (!(sprite.rotation instanceof Quaternion)) {
724
765
  sprite.rotation = new Quaternion();
725
766
  }
726
- if (this.startRotation.type === 'rotation') {
727
- this.startRotation.genValue(particle.memory, sprite.rotation as Quaternion, 1, timeRatio);
767
+ // A recycled particle can still carry the turn of whatever
768
+ // it was last time; the renderer would draw a fraction of it
769
+ // before any behavior has had a chance to say otherwise.
770
+ if (sprite.angularVelocity instanceof Quaternion) {
771
+ sprite.angularVelocity.set(0, 0, 0, 1);
772
+ }
773
+ if (startRotationIsRotation) {
774
+ (this.startRotation as RotationGenerator).genValue(
775
+ particle.memory,
776
+ sprite.rotation as Quaternion,
777
+ 1,
778
+ timeRatio
779
+ );
728
780
  } else {
729
- (sprite.rotation as Quaternion).setFromAxisAngle(UP, this.startRotation.genValue(sprite.memory, timeRatio));
781
+ (sprite.rotation as Quaternion).setFromAxisAngle(
782
+ UP,
783
+ (this.startRotation as FunctionValueGenerator).genValue(sprite.memory, timeRatio)
784
+ );
730
785
  }
731
786
  } else {
732
- if (this.startRotation.type === 'rotation') {
787
+ if (startRotationIsRotation) {
733
788
  sprite.rotation = 0;
734
789
  } else {
735
- sprite.rotation = this.startRotation.genValue(sprite.memory, timeRatio);
790
+ sprite.rotation = (this.startRotation as FunctionValueGenerator).genValue(
791
+ sprite.memory,
792
+ timeRatio
793
+ );
736
794
  }
795
+ // Set until a turning behavior says otherwise, so the
796
+ // renderer never carries a recycled particle's old spin.
797
+ sprite.angularVelocity = 0;
737
798
  }
738
- } else if (isTrailMode && trailSettings) {
799
+ } else if (trailSettings) {
739
800
  const trail = particle as TrailParticle;
740
801
  trailSettings.startLength.startGen(trail.memory);
741
802
  trail.length = trailSettings.startLength.genValue(trail.memory, timeRatio);
742
- if (this.useFastTrailHistory) {
743
- this.ensureTrailHistoryCapacity(trail, Math.max(1, Math.ceil(trail.length)));
744
- this.resetTrailHistory(trail);
745
- }
803
+ trail.ensureHistoryCapacity(Math.max(1, Math.ceil(trail.length)));
804
+ trail.resetHistory();
746
805
  }
747
806
 
748
807
  this.emitterShape.initialize(particle, emissionState);
@@ -784,6 +843,10 @@ export class ParticleSystem implements IParticleSystem {
784
843
  }
785
844
 
786
845
  dispose() {
846
+ if (this.destroyed) {
847
+ return;
848
+ }
849
+ this.destroyed = true;
787
850
  if (this._renderer) this._renderer.deleteSystem(this);
788
851
  this.emitter.dispose();
789
852
  this.fire({type: 'destroy', particleSystem: this});
@@ -809,6 +872,8 @@ export class ParticleSystem implements IParticleSystem {
809
872
  this.startDelayTimeLeft = this.startDelay.genValue(this.memory, 0);
810
873
  this.previousEmitterPos = undefined;
811
874
  this.emitterVelocity.set(0, 0, 0);
875
+ this.simulationAccumulator = 0;
876
+ this.destroyed = false;
812
877
  }
813
878
 
814
879
  private firstTimeUpdate = true;
@@ -823,6 +888,20 @@ export class ParticleSystem implements IParticleSystem {
823
888
  }
824
889
 
825
890
  if (!this.looping && this.finishedEventFired && this.particleNum === 0) {
891
+ // Still tick the fixed-step clock. A sub-emitter target
892
+ // (onlyUsedByOther) goes empty between parent bursts; skipping the
893
+ // accumulator here desyncs it from the parent, so the next particles
894
+ // the parent emits into it sit at age 0 / startColor until the phase
895
+ // catches up — white flashes scattered wherever the parent was.
896
+ if (delta > 0.1) {
897
+ delta = 0.1;
898
+ }
899
+ this.simulationAccumulator += delta;
900
+ const stepsToRun = Math.min(
901
+ MAX_SIMULATION_STEPS_PER_FRAME,
902
+ Math.max(0, Math.round(this.simulationAccumulator / SIMULATION_STEP - ROUND_TIE_BIAS))
903
+ );
904
+ this.simulationAccumulator -= stepsToRun * SIMULATION_STEP;
826
905
  return;
827
906
  }
828
907
 
@@ -845,10 +924,7 @@ export class ParticleSystem implements IParticleSystem {
845
924
  const emitterElements = this.emitter.matrixWorld.elements;
846
925
  this.tempEmitterPos.set(emitterElements[12], emitterElements[13], emitterElements[14]);
847
926
  if (this.previousEmitterPos !== undefined && delta > 0) {
848
- this.emitterVelocity
849
- .copy(this.tempEmitterPos)
850
- .sub(this.previousEmitterPos)
851
- .divideScalar(delta);
927
+ this.emitterVelocity.copy(this.tempEmitterPos).sub(this.previousEmitterPos).divideScalar(delta);
852
928
  }
853
929
  (this.previousEmitterPos ??= new Vector3()).copy(this.tempEmitterPos);
854
930
 
@@ -862,67 +938,214 @@ export class ParticleSystem implements IParticleSystem {
862
938
  this.startDelayTimeLeft = 0;
863
939
  }
864
940
 
941
+ this.simulationAccumulator += delta;
942
+ const stepsToRun = Math.min(
943
+ MAX_SIMULATION_STEPS_PER_FRAME,
944
+ Math.max(0, Math.round(this.simulationAccumulator / SIMULATION_STEP - ROUND_TIE_BIAS))
945
+ );
946
+ for (let i = 0; i < stepsToRun; i++) {
947
+ this.simulateStep(SIMULATION_STEP);
948
+ }
949
+ this.simulationAccumulator -= stepsToRun * SIMULATION_STEP;
950
+ }
951
+
952
+ /**
953
+ * Time the display is ahead of (positive) or still ahead of the simulation
954
+ * (negative — the simulation ran a hair early), in seconds. Magnitude never
955
+ * exceeds one step.
956
+ *
957
+ * The simulation runs on a fixed 1/60 step so that emission and lifetimes
958
+ * stay in phase — without it a slow emitter flickers when the frame rate
959
+ * wobbles. But frames do not arrive on that grid: a display running at
960
+ * anything other than exactly 60Hz leaves some frames with no step at all
961
+ * (half of them at 120Hz, three in five at 144Hz) and others with two, so
962
+ * the particles are drawn stuttering even while the frame rate is perfect.
963
+ *
964
+ * The steps-per-frame count below is chosen by *rounding* the accumulated
965
+ * time to the nearest step, not flooring it. Flooring always leaves the
966
+ * accumulator positive, and when the real frame rate sits very close to
967
+ * 60Hz (as most displays do) that leftover creeps toward a full step for
968
+ * many consecutive frames before tipping over into a frame that runs two
969
+ * steps at once and resets it to near zero — a visible lurch, every particle
970
+ * in the system at once, with a perfectly steady frame rate the whole time.
971
+ * Rounding lets the leftover go negative instead, which keeps it centered
972
+ * near zero and stops it from ever building up toward that reset.
973
+ *
974
+ * Renderers close the gap by advancing what they draw along each particle's
975
+ * velocity by this much. That reproduces the straight-line part of the
976
+ * motion exactly — it is the same term the next step would integrate — so
977
+ * the simulation stays fixed-step while the picture moves continuously.
978
+ */
979
+ /** Length of one simulation step, in seconds. See {@link simulationResidual}. */
980
+ get simulationStep(): number {
981
+ return SIMULATION_STEP;
982
+ }
983
+
984
+ get simulationResidual(): number {
985
+ if (this.paused) {
986
+ return 0;
987
+ }
988
+ // Magnitude never more than a step, even if a frame long enough to hit
989
+ // the per-frame step cap left the accumulator with more than that.
990
+ if (this.simulationAccumulator > SIMULATION_STEP) return SIMULATION_STEP;
991
+ if (this.simulationAccumulator < -SIMULATION_STEP) return -SIMULATION_STEP;
992
+ return this.simulationAccumulator;
993
+ }
994
+
995
+ /**
996
+ * Execution plan for this system's behaviors, rebuilt when the list changes.
997
+ *
998
+ * Consecutive behaviors that can be compiled into one loop are merged, so
999
+ * the particles are walked once instead of once per behavior. The plan is
1000
+ * keyed on the behavior instances, not their types: swapping one out has to
1001
+ * change the generated code's indices.
1002
+ */
1003
+ private behaviorSteps(): Array<FusionStep> {
1004
+ const behaviors = this.behaviors;
1005
+ const cached = this.fusionPlan;
1006
+ if (cached !== undefined && cached.length === behaviors.length) {
1007
+ let same = true;
1008
+ for (let i = 0; i < behaviors.length; i++) {
1009
+ if (cached[i] !== behaviors[i]) {
1010
+ same = false;
1011
+ break;
1012
+ }
1013
+ }
1014
+ if (same) {
1015
+ return this.fusionSteps;
1016
+ }
1017
+ }
1018
+ this.fusionPlan = behaviors.slice();
1019
+ this.fusionSteps = planBehaviorFusion(behaviors);
1020
+ return this.fusionSteps;
1021
+ }
1022
+
1023
+ /** Advances emission, behaviors, motion and culling by one fixed timestep. */
1024
+ private simulateStep(delta: number): void {
865
1025
  if (!this.onlyUsedByOther) {
866
1026
  this.emit(delta, this.emissionState, this.emitter.matrixWorld);
867
1027
  }
868
1028
 
869
1029
  const particles = this.particles;
870
- const behaviorCount = this.behaviors.length;
1030
+ const behaviors = this.behaviors;
1031
+ const behaviorCount = behaviors.length;
871
1032
  const particleCount = this.particleNum;
1033
+ const isTrailMode = this.rendererSettings.renderMode === RenderMode.Trail;
1034
+ // Only stretched billboards draw from velocity, and only they pay for
1035
+ // keeping the previous one.
1036
+ const stretched = this.rendererSettings.renderMode === RenderMode.StretchedBillBoard;
1037
+
1038
+ // Remember where everything starts this step, so the renderer can carry
1039
+ // the motion the step produces on into the part of the frame that comes
1040
+ // after it. Taken after emission on purpose: a particle born this step
1041
+ // then starts from where it was born rather than from whatever the row
1042
+ // held before, and so is drawn at the emitter instead of jumping.
1043
+ const columns = this.store;
1044
+ const vector3Rows = particleCount * 3;
1045
+ columns.previousPosition.set(columns.position.subarray(0, vector3Rows));
1046
+ columns.previousSize.set(columns.size.subarray(0, vector3Rows));
1047
+ if (stretched) {
1048
+ if (columns.keepPreviousVelocity()) {
1049
+ this.rebindParticles();
1050
+ }
1051
+ columns.previousVelocity.set(columns.velocity.subarray(0, vector3Rows));
1052
+ }
1053
+ columns.previousColor.set(columns.color.subarray(0, particleCount * 4));
1054
+
872
1055
  this.emitterShape.update(this, delta);
873
- for (let j = 0; j < behaviorCount; j++) {
874
- const behavior = this.behaviors[j];
875
- behavior.frameUpdate(delta);
1056
+ const steps = this.behaviorSteps();
1057
+ for (let s = 0; s < steps.length; s++) {
1058
+ const step = steps[s];
1059
+ const stepBehaviors = step.behaviors;
1060
+ for (let j = 0; j < stepBehaviors.length; j++) {
1061
+ stepBehaviors[j].frameUpdate(delta);
1062
+ }
1063
+ // Nothing to update: skip, matching what the per-particle loop did.
1064
+ // A behavior may set up state before its loop that is only valid
1065
+ // once it has seen a particle.
1066
+ if (particleCount === 0) {
1067
+ continue;
1068
+ }
1069
+ if (step.run !== undefined) {
1070
+ step.run(behaviors, particles, particleCount, delta);
1071
+ continue;
1072
+ }
1073
+ const behavior = stepBehaviors[0];
1074
+ if (behavior.updateAll !== undefined) {
1075
+ behavior.updateAll(particles, particleCount, delta);
1076
+ continue;
1077
+ }
876
1078
  for (let i = 0; i < particleCount; i++) {
877
1079
  const particle = particles[i];
878
- if (!particle.died) {
1080
+ // Inlined `died` — this runs once per behavior per particle.
1081
+ if (particle.age < particle.life) {
879
1082
  behavior.update(particle, delta);
880
1083
  }
881
1084
  }
882
1085
  }
883
1086
 
884
- const followLocalOrigin =
885
- this.rendererSettings.renderMode === RenderMode.Trail &&
886
- (this.rendererEmitterSettings as TrailSettings).followLocalOrigin;
887
- const emitterMatrix = this.emitter.matrixWorld;
888
- for (let i = 0; i < particleCount; i++) {
889
- const particle = particles[i];
890
- if (followLocalOrigin && (particle as TrailParticle).localPosition) {
891
- particle.position.copy((particle as TrailParticle).localPosition!);
892
- if (particle.parentMatrix) {
893
- particle.position.applyMatrix4(particle.parentMatrix);
1087
+ const followLocalOrigin = isTrailMode && (this.rendererEmitterSettings as TrailSettings).followLocalOrigin;
1088
+ if (followLocalOrigin) {
1089
+ const emitterMatrix = this.emitter.matrixWorld;
1090
+ for (let i = 0; i < particleCount; i++) {
1091
+ const particle = particles[i];
1092
+ if ((particle as TrailParticle).localPosition) {
1093
+ particle.position.copy((particle as TrailParticle).localPosition!);
1094
+ particle.position.applyMatrix4(particle.parentMatrix ?? emitterMatrix);
894
1095
  } else {
895
- particle.position.applyMatrix4(emitterMatrix);
1096
+ particle.position.addScaledVector(particle.velocity, delta * particle.speedModifier);
896
1097
  }
897
- } else {
898
- particle.position.addScaledVector(particle.velocity, delta * particle.speedModifier);
1098
+ particle.age += delta;
1099
+ }
1100
+ } else {
1101
+ // Live particles own rows [0, particleNum), so integrate straight
1102
+ // over the columns. Nothing in here touches a particle object.
1103
+ const positions = this.store.position;
1104
+ const velocities = this.store.velocity;
1105
+ const scalars = this.store.scalars;
1106
+ const stride = ParticleStore.SCALAR_STRIDE;
1107
+ for (let i = 0, offset = 0, scalar = 0; i < particleCount; i++, offset += 3, scalar += stride) {
1108
+ const step = delta * scalars[scalar + ParticleStore.SPEED_MODIFIER];
1109
+ positions[offset] += velocities[offset] * step;
1110
+ positions[offset + 1] += velocities[offset + 1] * step;
1111
+ positions[offset + 2] += velocities[offset + 2] * step;
1112
+ scalars[scalar + ParticleStore.AGE] += delta;
899
1113
  }
900
- particle.age += delta;
901
1114
  }
902
1115
 
903
- if (this.rendererSettings.renderMode === RenderMode.Trail) {
1116
+ if (isTrailMode) {
904
1117
  for (let i = 0; i < particleCount; i++) {
905
- const trailParticle = particles[i] as TrailParticle;
906
- if (this.useFastTrailHistory) {
907
- this.updateFastTrailHistory(trailParticle);
908
- } else {
909
- trailParticle.update();
910
- }
1118
+ (particles[i] as TrailParticle).update();
911
1119
  }
912
1120
  }
913
1121
 
1122
+ const notifyDeaths = this.hasListeners('particleDied');
1123
+ const store = this.store;
1124
+ const scalars = store.scalars;
1125
+ const scalarStride = ParticleStore.SCALAR_STRIDE;
914
1126
  let liveParticleCount = this.particleNum;
915
1127
  for (let i = 0; i < liveParticleCount; i++) {
916
1128
  const particle = particles[i];
917
- if (
918
- particle.died &&
919
- (!(particle instanceof TrailParticle) || this.getTrailHistoryCount(particle as TrailParticle) === 0)
920
- ) {
921
- particles[i] = particles[liveParticleCount - 1];
922
- particles[liveParticleCount - 1] = particle;
1129
+ const scalar = i * scalarStride;
1130
+ const dead = scalars[scalar + ParticleStore.AGE] >= scalars[scalar + ParticleStore.LIFE];
1131
+ if (dead && (!isTrailMode || (particle as TrailParticle).historyCount === 0)) {
1132
+ const last = liveParticleCount - 1;
1133
+ const survivor = particles[last] as StoreBackedParticle;
1134
+ particles[i] = survivor;
1135
+ particles[last] = particle;
1136
+ // Move the rows along with the particles so slot k always owns
1137
+ // row k. Live particles then stay one contiguous range, which is
1138
+ // what lets the renderer copy them without gathering.
1139
+ if (i !== last) {
1140
+ store.swapRows(i, last);
1141
+ survivor.setStoreIndex(i);
1142
+ (particle as StoreBackedParticle).setStoreIndex(last);
1143
+ }
923
1144
  liveParticleCount--;
924
1145
  i--;
925
- this.fire({type: 'particleDied', particleSystem: this, particle: particle});
1146
+ if (notifyDeaths) {
1147
+ this.fire({type: 'particleDied', particleSystem: this, particle: particle});
1148
+ }
926
1149
  }
927
1150
  }
928
1151
  this.particleNum = liveParticleCount;
@@ -948,7 +1171,9 @@ export class ParticleSystem implements IParticleSystem {
948
1171
  if (this.looping) {
949
1172
  emissionState.time -= this.duration;
950
1173
  emissionState.burstIndex = 0;
951
- this.behaviors.forEach((behavior) => behavior.reset());
1174
+ for (let i = 0; i < this.behaviors.length; i++) {
1175
+ this.behaviors[i].reset();
1176
+ }
952
1177
  } else {
953
1178
  if (!this.emitEnded && !this.onlyUsedByOther) {
954
1179
  this.endEmit();
@@ -956,17 +1181,15 @@ export class ParticleSystem implements IParticleSystem {
956
1181
  }
957
1182
  }
958
1183
 
959
- this.normalMatrix.getNormalMatrix(emitterMatrix);
1184
+ // Only spawns in world space consume the normal matrix, and computing it
1185
+ // inverts a 4x4 — skip it for local-space systems.
1186
+ if (this.worldSpace) {
1187
+ this.normalMatrix.getNormalMatrix(emitterMatrix);
1188
+ }
960
1189
  const emissionBursts = this.emissionBursts;
961
1190
  const emissionBurstCount = emissionBursts.length;
962
1191
  const qualityFactor = this.qualityFactor;
963
1192
 
964
- const totalSpawn = Math.floor(emissionState.waitEmiting);
965
- if (totalSpawn > 0) {
966
- this.spawn(totalSpawn, emissionState, emitterMatrix);
967
- emissionState.waitEmiting -= totalSpawn;
968
- }
969
-
970
1193
  while (
971
1194
  emissionState.burstIndex < emissionBurstCount &&
972
1195
  emissionBursts[emissionState.burstIndex].time <= emissionState.time
@@ -999,8 +1222,21 @@ export class ParticleSystem implements IParticleSystem {
999
1222
  }
1000
1223
  }
1001
1224
  }
1225
+
1226
+ // Accrue before spawning so replacement particles can appear on the same
1227
+ // frame an old one dies (rate * lifetime ≈ 1 would otherwise flicker).
1228
+ const totalSpawn = Math.floor(emissionState.waitEmiting);
1229
+ if (totalSpawn > 0) {
1230
+ this.spawn(totalSpawn, emissionState, emitterMatrix);
1231
+ emissionState.waitEmiting -= totalSpawn;
1232
+ }
1233
+
1002
1234
  if (emissionState.previousWorldPos === undefined) emissionState.previousWorldPos = new Vector3();
1003
- emissionState.previousWorldPos.set(emitterMatrix.elements[12], emitterMatrix.elements[13], emitterMatrix.elements[14]);
1235
+ emissionState.previousWorldPos.set(
1236
+ emitterMatrix.elements[12],
1237
+ emitterMatrix.elements[13],
1238
+ emitterMatrix.elements[14]
1239
+ );
1004
1240
  emissionState.time += delta;
1005
1241
  }
1006
1242
 
@@ -1008,15 +1244,15 @@ export class ParticleSystem implements IParticleSystem {
1008
1244
  const isRootObject = metaData === undefined || typeof metaData === 'string';
1009
1245
  const meta: BabylonMetaData = isRootObject
1010
1246
  ? {
1011
- geometries: {},
1012
- materials: {},
1013
- textures: {},
1014
- images: {},
1015
- shapes: {},
1016
- skeletons: {},
1017
- animations: {},
1018
- nodes: {},
1019
- }
1247
+ geometries: {},
1248
+ materials: {},
1249
+ textures: {},
1250
+ images: {},
1251
+ shapes: {},
1252
+ skeletons: {},
1253
+ animations: {},
1254
+ nodes: {},
1255
+ }
1020
1256
  : metaData;
1021
1257
 
1022
1258
  const geometryUUID = this.ensureGeometryMeta(meta);
@@ -1099,8 +1335,8 @@ export class ParticleSystem implements IParticleSystem {
1099
1335
  const materialTextureRef = materialMeta?.texture ?? json.texture;
1100
1336
  const texture =
1101
1337
  typeof materialTextureRef === 'string'
1102
- ? meta.textures?.[materialTextureRef] ?? null
1103
- : materialTextureRef ?? null;
1338
+ ? (meta.textures?.[materialTextureRef] ?? null)
1339
+ : (materialTextureRef ?? null);
1104
1340
  const resolvedGeometryEntry =
1105
1341
  typeof json.instancingGeometry === 'string'
1106
1342
  ? meta.geometries?.[json.instancingGeometry]
@@ -1117,14 +1353,20 @@ export class ParticleSystem implements IParticleSystem {
1117
1353
  shape,
1118
1354
  startLife: ValueGeneratorFromJSON(json.startLife),
1119
1355
  startSpeed: ValueGeneratorFromJSON(json.startSpeed),
1120
- startRotation: GeneratorFromJSON(json.startRotation) as RotationGenerator | ValueGenerator | FunctionValueGenerator,
1356
+ startRotation: GeneratorFromJSON(json.startRotation) as
1357
+ | RotationGenerator
1358
+ | ValueGenerator
1359
+ | FunctionValueGenerator,
1121
1360
  startSize: GeneratorFromJSON(json.startSize) as Vector3Generator | ValueGenerator | FunctionValueGenerator,
1122
1361
  startColor: ColorGeneratorFromJSON(json.startColor) as ColorGenerator,
1123
1362
  emissionOverTime: ValueGeneratorFromJSON(json.emissionOverTime),
1124
1363
  emissionOverDistance: ValueGeneratorFromJSON(json.emissionOverDistance),
1125
1364
  emissionBursts: json.emissionBursts?.map((burst: any) => ({
1126
1365
  time: burst.time,
1127
- count: typeof burst.count === 'number' ? new ConstantValue(burst.count) : ValueGeneratorFromJSON(burst.count),
1366
+ count:
1367
+ typeof burst.count === 'number'
1368
+ ? new ConstantValue(burst.count)
1369
+ : ValueGeneratorFromJSON(burst.count),
1128
1370
  probability: burst.probability ?? 1,
1129
1371
  interval: burst.interval ?? 0.1,
1130
1372
  cycle: burst.cycle ?? burst.cycleCount ?? 1,
@@ -1209,7 +1451,9 @@ export class ParticleSystem implements IParticleSystem {
1209
1451
  positions: Array.from(this.rendererSettings.instancingGeometry),
1210
1452
  indices: Array.from(this.rendererSettings.instancingIndices),
1211
1453
  uvs: this.rendererSettings.instancingUVs ? Array.from(this.rendererSettings.instancingUVs) : undefined,
1212
- normals: this.rendererSettings.instancingNormals ? Array.from(this.rendererSettings.instancingNormals) : undefined,
1454
+ normals: this.rendererSettings.instancingNormals
1455
+ ? Array.from(this.rendererSettings.instancingNormals)
1456
+ : undefined,
1213
1457
  };
1214
1458
  return geometryUUID;
1215
1459
  }
@@ -1222,6 +1466,13 @@ export class ParticleSystem implements IParticleSystem {
1222
1466
  meta.textures[textureUUID] = texture;
1223
1467
  }
1224
1468
 
1469
+ const reflectionAtlas = this.rendererSettings.reflectionAtlas;
1470
+ let reflectionAtlasUUID: string | undefined;
1471
+ if (reflectionAtlas instanceof Texture) {
1472
+ reflectionAtlasUUID = ParticleSystem.nextSerializationId('quarks_texture');
1473
+ meta.textures[reflectionAtlasUUID] = reflectionAtlas;
1474
+ }
1475
+
1225
1476
  const materialUUID = ParticleSystem.nextSerializationId('quarks_material');
1226
1477
  meta.materials[materialUUID] = {
1227
1478
  uuid: materialUUID,
@@ -1232,6 +1483,8 @@ export class ParticleSystem implements IParticleSystem {
1232
1483
  depthWrite: this.rendererSettings.materialDepthWrite,
1233
1484
  alphaTest: this.rendererSettings.materialAlphaTest,
1234
1485
  texture: textureUUID,
1486
+ reflectionAtlas: reflectionAtlasUUID,
1487
+ reflectionLevel: this.rendererSettings.reflectionLevel,
1235
1488
  sourceMaterial: this.materialRef ?? undefined,
1236
1489
  };
1237
1490
  return materialUUID;
@@ -1293,7 +1546,8 @@ export class ParticleSystem implements IParticleSystem {
1293
1546
 
1294
1547
  addBehavior(behavior: Behavior) {
1295
1548
  this.behaviors.push(behavior);
1296
- this.refreshTrailHistoryMode();
1549
+ // A sub emitter added here changes which system has to update first.
1550
+ this._renderer?._invalidateUpdateOrder();
1297
1551
  }
1298
1552
 
1299
1553
  getRendererSettings(): VFXBatchSettings {
@@ -1316,9 +1570,19 @@ export class ParticleSystem implements IParticleSystem {
1316
1570
  }
1317
1571
  }
1318
1572
 
1573
+ /** True when at least one callback is registered for the event type. */
1574
+ private hasListeners(event: ParticleSystemEventType): boolean {
1575
+ const callbacks = this.listeners[event];
1576
+ return callbacks !== undefined && callbacks.length > 0;
1577
+ }
1578
+
1319
1579
  private fire(event: ParticleSystemEvent) {
1320
- if (this.listeners[event.type]) {
1321
- this.listeners[event.type].forEach((callback) => callback(event));
1580
+ const callbacks = this.listeners[event.type];
1581
+ if (callbacks === undefined) {
1582
+ return;
1583
+ }
1584
+ for (let i = 0; i < callbacks.length; i++) {
1585
+ callbacks[i](event);
1322
1586
  }
1323
1587
  }
1324
1588