babylon.quarks 0.18.1 → 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,6 +56,9 @@ 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();
@@ -58,14 +66,21 @@ const tempV2 = new Vector3();
58
66
  const PREWARM_FPS = 60;
59
67
  const SIMULATION_STEP = 1 / PREWARM_FPS;
60
68
  const MAX_SIMULATION_STEPS_PER_FRAME = 8;
61
-
62
- const DEFAULT_POSITIONS = new Float32Array([
63
- -0.5, -0.5, 0,
64
- 0.5, -0.5, 0,
65
- 0.5, 0.5, 0,
66
- -0.5, 0.5, 0,
67
- ]);
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]);
68
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
+ }
69
84
  const DEFAULT_INDICES = new Uint32Array([0, 1, 2, 0, 2, 3]);
70
85
 
71
86
  export interface ParticleSystemParameters {
@@ -196,6 +211,8 @@ export class ParticleSystem implements IParticleSystem {
196
211
  particleNum: number;
197
212
  paused: boolean;
198
213
  particles: Array<Particle>;
214
+ /** Column storage shared by this system's particles. */
215
+ readonly store: ParticleStore = new ParticleStore(64);
199
216
  emitterShape: EmitterShape;
200
217
  emitter: ParticleEmitter;
201
218
  rendererSettings: VFXBatchSettings;
@@ -217,10 +234,13 @@ export class ParticleSystem implements IParticleSystem {
217
234
  private listeners: {[event: string]: Array<(event: ParticleSystemEvent) => void>} = {};
218
235
  private readonly layerMaskProxy: {mask: number};
219
236
  private materialRef: any = null;
237
+ private sceneRef: Scene | undefined;
220
238
  private qualityFactor = 1;
221
- private useFastTrailHistory = true;
222
239
  private simulationAccumulator = 0;
223
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> = [];
224
244
  /** @internal **/
225
245
  _renderer?: BatchedRenderer;
226
246
 
@@ -424,7 +444,8 @@ export class ParticleSystem implements IParticleSystem {
424
444
  }
425
445
 
426
446
  constructor(parameters: ParticleSystemParameters) {
427
- this.layerMaskProxy = {mask: parameters.layerMask ?? 0x0FFFFFFF};
447
+ this.sceneRef = parameters.scene;
448
+ this.layerMaskProxy = {mask: parameters.layerMask ?? 0x0fffffff};
428
449
  Object.defineProperty(this.layerMaskProxy, 'mask', {
429
450
  enumerable: true,
430
451
  get: () => this.rendererSettings.layerMask,
@@ -465,7 +486,9 @@ export class ParticleSystem implements IParticleSystem {
465
486
  this.rendererSettings = {
466
487
  instancingGeometry: parameters.instancingGeometry ?? DEFAULT_POSITIONS,
467
488
  instancingIndices: parameters.instancingIndices ?? DEFAULT_INDICES,
468
- instancingUVs: parameters.instancingUVs ?? DEFAULT_UVS,
489
+ instancingUVs:
490
+ parameters.instancingUVs ??
491
+ (parameters.instancingGeometry ? fallbackUVsForPositions(parameters.instancingGeometry) : DEFAULT_UVS),
469
492
  instancingNormals: parameters.instancingNormals,
470
493
  renderMode: parameters.renderMode ?? RenderMode.BillBoard,
471
494
  renderOrder: parameters.renderOrder ?? 0,
@@ -481,10 +504,16 @@ export class ParticleSystem implements IParticleSystem {
481
504
  materialDepthWrite: parameters.depthWrite ?? false,
482
505
  materialAlphaTest: parameters.alphaTest ?? 0,
483
506
  texture: parameters.texture ?? null,
484
- layerMask: parameters.layerMask ?? 0x0FFFFFFF,
507
+ reflectionTexture: null,
508
+ reflectionLevel: 1,
509
+ reflectionFaces: null,
510
+ reflectionAtlas: null,
511
+ layerMask: parameters.layerMask ?? 0x0fffffff,
485
512
  };
486
513
  if (this.rendererSettings.renderMode === RenderMode.Mesh && !this.rendererSettings.instancingNormals) {
487
- this.rendererSettings.instancingNormals = ParticleSystem.createFallbackNormals(this.rendererSettings.instancingGeometry);
514
+ this.rendererSettings.instancingNormals = ParticleSystem.createFallbackNormals(
515
+ this.rendererSettings.instancingGeometry
516
+ );
488
517
  }
489
518
 
490
519
  this.materialRef = parameters.material ?? null;
@@ -520,7 +549,6 @@ export class ParticleSystem implements IParticleSystem {
520
549
  this.emissionOverDistance.startGen(this.memory);
521
550
  this.startDelay.startGen(this.memory);
522
551
  this.startDelayTimeLeft = this.startDelay.genValue(this.memory, 0);
523
- this.refreshTrailHistoryMode();
524
552
 
525
553
  this.emitEnded = false;
526
554
  this.markForDestroy = false;
@@ -536,6 +564,7 @@ export class ParticleSystem implements IParticleSystem {
536
564
  depthWrite?: boolean;
537
565
  alphaTest?: number;
538
566
  texture?: Texture | null;
567
+ reflectionAtlas?: BaseTexture | null;
539
568
  layerMask?: number;
540
569
  } = {}
541
570
  ) {
@@ -548,12 +577,18 @@ export class ParticleSystem implements IParticleSystem {
548
577
  overrides.texture !== undefined
549
578
  ? overrides.texture
550
579
  : (material?.albedoTexture ??
551
- material?.diffuseTexture ??
552
- material?.emissiveTexture ??
553
- material?.opacityTexture ??
554
- material?.baseTexture ??
555
- this.rendererSettings.texture ??
556
- 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;
557
592
  const resolvedBlendMode =
558
593
  overrides.blendMode ??
559
594
  (typeof material?.alphaMode === 'number' ? material.alphaMode : this.rendererSettings.materialBlendMode);
@@ -562,8 +597,8 @@ export class ParticleSystem implements IParticleSystem {
562
597
  (typeof material?.needAlphaBlending === 'function'
563
598
  ? material.needAlphaBlending()
564
599
  : typeof material?.alpha === 'number'
565
- ? material.alpha < 1
566
- : this.rendererSettings.materialTransparent);
600
+ ? material.alpha < 1
601
+ : this.rendererSettings.materialTransparent);
567
602
  const resolvedDepthTest =
568
603
  overrides.depthTest ??
569
604
  (typeof material?.disableDepthTest === 'boolean'
@@ -574,17 +609,57 @@ export class ParticleSystem implements IParticleSystem {
574
609
  (typeof material?.disableDepthWrite === 'boolean'
575
610
  ? !material.disableDepthWrite
576
611
  : typeof material?.forceDepthWrite === 'boolean'
577
- ? material.forceDepthWrite
578
- : this.rendererSettings.materialDepthWrite);
612
+ ? material.forceDepthWrite
613
+ : this.rendererSettings.materialDepthWrite);
579
614
  const resolvedAlphaTest =
580
615
  overrides.alphaTest ??
581
- (typeof material?.alphaCutOff === 'number'
582
- ? material.alphaCutOff
583
- : typeof material?.alphaCutOffValue === 'number'
584
- ? material.alphaCutOffValue
585
- : 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
+ })();
586
633
 
587
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;
588
663
  this.rendererSettings.materialBlendMode = resolvedBlendMode;
589
664
  this.rendererSettings.materialTransparent = resolvedTransparent;
590
665
  this.rendererSettings.materialDepthTest = resolvedDepthTest;
@@ -596,88 +671,56 @@ export class ParticleSystem implements IParticleSystem {
596
671
  this.neededToUpdateRender = true;
597
672
  }
598
673
 
599
- pause() { this.paused = true; }
600
- play() { this.paused = false; }
601
- stop() { this.restart(); this.pause(); }
602
-
603
- private refreshTrailHistoryMode() {
604
- this.useFastTrailHistory = !this.behaviors.some((behavior) => (behavior as any).type === 'WidthOverLength');
674
+ pause() {
675
+ this.paused = true;
605
676
  }
606
-
607
- private ensureTrailHistoryCapacity(particle: TrailParticle, capacity: number) {
608
- const trailAny = particle as any;
609
- if (trailAny._trailHistoryCapacity === capacity && trailAny._trailHistoryPositions) {
610
- return;
611
- }
612
- trailAny._trailHistoryCapacity = capacity;
613
- trailAny._trailHistoryPositions = new Float32Array(capacity * 3);
614
- trailAny._trailHistorySizes = new Float32Array(capacity);
615
- trailAny._trailHistoryColors = new Float32Array(capacity * 4);
616
- trailAny._trailHistoryHead = 0;
617
- trailAny._trailHistoryCount = 0;
677
+ play() {
678
+ this.paused = false;
679
+ }
680
+ stop() {
681
+ this.restart();
682
+ this.pause();
618
683
  }
619
684
 
620
- private resetTrailHistory(particle: TrailParticle) {
621
- const trailAny = particle as any;
622
- trailAny._trailHistoryHead = 0;
623
- trailAny._trailHistoryCount = 0;
685
+ setQualityFactor(qualityFactor: number) {
686
+ this.qualityFactor = Math.max(0.1, Math.min(1, qualityFactor));
624
687
  }
625
688
 
626
- private getTrailHistoryCount(particle: TrailParticle): number {
627
- const trailAny = particle as any;
628
- const fastHistoryCount = trailAny._trailHistoryCount;
629
- if (typeof fastHistoryCount === 'number') {
630
- return fastHistoryCount;
631
- }
632
- return particle.previous.length;
633
- }
634
-
635
- private updateFastTrailHistory(particle: TrailParticle) {
636
- const trailAny = particle as any;
637
- const capacity = Math.max(1, Math.ceil(particle.length));
638
- this.ensureTrailHistoryCapacity(particle, capacity);
639
- let head = trailAny._trailHistoryHead as number;
640
- let count = trailAny._trailHistoryCount as number;
641
- const positions = trailAny._trailHistoryPositions as Float32Array;
642
- const sizes = trailAny._trailHistorySizes as Float32Array;
643
- const colors = trailAny._trailHistoryColors as Float32Array;
644
-
645
- if (particle.age <= particle.life) {
646
- const posIndex = head * 3;
647
- positions[posIndex] = particle.position.x;
648
- positions[posIndex + 1] = particle.position.y;
649
- positions[posIndex + 2] = particle.position.z;
650
- sizes[head] = particle.size.x;
651
- const colorIndex = head * 4;
652
- colors[colorIndex] = particle.color.x;
653
- colors[colorIndex + 1] = particle.color.y;
654
- colors[colorIndex + 2] = particle.color.z;
655
- colors[colorIndex + 3] = particle.color.w;
656
- head = (head + 1) % capacity;
657
- if (count < capacity) {
658
- count++;
659
- }
660
- } else if (count > 0) {
661
- 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();
662
700
  }
663
-
664
- trailAny._trailHistoryHead = head;
665
- trailAny._trailHistoryCount = count;
666
701
  }
667
702
 
668
- setQualityFactor(qualityFactor: number) {
669
- 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));
670
709
  }
671
710
 
672
711
  private spawn(count: number, emissionState: EmissionState, matrix: Matrix4) {
673
- tempQ.setFromRotationMatrix(matrix);
674
712
  const translation = tempV;
675
713
  const scale = tempV2;
714
+ // decompose writes tempQ, so no separate setFromRotationMatrix is needed.
676
715
  matrix.decompose(translation, tempQ, scale);
677
716
  const behaviors = this.behaviors;
678
717
  const behaviorCount = behaviors.length;
679
718
  const renderMode = this.rendererSettings.renderMode;
680
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';
681
724
  const timeRatio = emissionState.time / this.duration;
682
725
  const trailSettings = isTrailMode ? (this.rendererEmitterSettings as TrailSettings) : undefined;
683
726
  const followLocalOrigin = Boolean(trailSettings?.followLocalOrigin);
@@ -686,15 +729,15 @@ export class ParticleSystem implements IParticleSystem {
686
729
  emissionState.burstParticleIndex = i;
687
730
  this.particleNum++;
688
731
  if (this.particles.length < this.particleNum) {
689
- if (isTrailMode) {
690
- this.particles.push(new TrailParticle());
691
- } else {
692
- this.particles.push(new SpriteParticle());
693
- }
732
+ this.growParticlePool(isTrailMode);
694
733
  }
695
734
  const particle = this.particles[this.particleNum - 1];
696
735
  particle.reset();
697
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;
698
741
  this.startColor.startGen(particle.memory);
699
742
  this.startColor.genColor(particle.memory, particle.startColor, this.emissionState.time);
700
743
  particle.color.copy(particle.startColor);
@@ -704,7 +747,7 @@ export class ParticleSystem implements IParticleSystem {
704
747
  particle.life = this.startLife.genValue(particle.memory, timeRatio);
705
748
  particle.age = 0;
706
749
  this.startSize.startGen(particle.memory);
707
- if (this.startSize.type === 'vec3function') {
750
+ if (startSizeIsVector3) {
708
751
  (this.startSize as Vector3Generator).genValue(particle.memory, particle.startSize, timeRatio);
709
752
  } else {
710
753
  const size = (this.startSize as FunctionValueGenerator).genValue(particle.memory, timeRatio);
@@ -714,39 +757,51 @@ export class ParticleSystem implements IParticleSystem {
714
757
  particle.uvTile = this.startTileIndex.genValue(particle.memory);
715
758
  particle.size.copy(particle.startSize);
716
759
 
717
- if (
718
- this.rendererSettings.renderMode === RenderMode.Mesh ||
719
- this.rendererSettings.renderMode === RenderMode.BillBoard ||
720
- this.rendererSettings.renderMode === RenderMode.VerticalBillBoard ||
721
- this.rendererSettings.renderMode === RenderMode.HorizontalBillBoard ||
722
- this.rendererSettings.renderMode === RenderMode.StretchedBillBoard
723
- ) {
760
+ if (isSpriteMode) {
724
761
  const sprite = particle as SpriteParticle;
725
762
  this.startRotation.startGen(particle.memory);
726
- if (renderMode === RenderMode.Mesh) {
763
+ if (isMeshMode) {
727
764
  if (!(sprite.rotation instanceof Quaternion)) {
728
765
  sprite.rotation = new Quaternion();
729
766
  }
730
- if (this.startRotation.type === 'rotation') {
731
- 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
+ );
732
780
  } else {
733
- (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
+ );
734
785
  }
735
786
  } else {
736
- if (this.startRotation.type === 'rotation') {
787
+ if (startRotationIsRotation) {
737
788
  sprite.rotation = 0;
738
789
  } else {
739
- sprite.rotation = this.startRotation.genValue(sprite.memory, timeRatio);
790
+ sprite.rotation = (this.startRotation as FunctionValueGenerator).genValue(
791
+ sprite.memory,
792
+ timeRatio
793
+ );
740
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;
741
798
  }
742
- } else if (isTrailMode && trailSettings) {
799
+ } else if (trailSettings) {
743
800
  const trail = particle as TrailParticle;
744
801
  trailSettings.startLength.startGen(trail.memory);
745
802
  trail.length = trailSettings.startLength.genValue(trail.memory, timeRatio);
746
- if (this.useFastTrailHistory) {
747
- this.ensureTrailHistoryCapacity(trail, Math.max(1, Math.ceil(trail.length)));
748
- this.resetTrailHistory(trail);
749
- }
803
+ trail.ensureHistoryCapacity(Math.max(1, Math.ceil(trail.length)));
804
+ trail.resetHistory();
750
805
  }
751
806
 
752
807
  this.emitterShape.initialize(particle, emissionState);
@@ -833,6 +888,20 @@ export class ParticleSystem implements IParticleSystem {
833
888
  }
834
889
 
835
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;
836
905
  return;
837
906
  }
838
907
 
@@ -855,10 +924,7 @@ export class ParticleSystem implements IParticleSystem {
855
924
  const emitterElements = this.emitter.matrixWorld.elements;
856
925
  this.tempEmitterPos.set(emitterElements[12], emitterElements[13], emitterElements[14]);
857
926
  if (this.previousEmitterPos !== undefined && delta > 0) {
858
- this.emitterVelocity
859
- .copy(this.tempEmitterPos)
860
- .sub(this.previousEmitterPos)
861
- .divideScalar(delta);
927
+ this.emitterVelocity.copy(this.tempEmitterPos).sub(this.previousEmitterPos).divideScalar(delta);
862
928
  }
863
929
  (this.previousEmitterPos ??= new Vector3()).copy(this.tempEmitterPos);
864
930
 
@@ -875,7 +941,7 @@ export class ParticleSystem implements IParticleSystem {
875
941
  this.simulationAccumulator += delta;
876
942
  const stepsToRun = Math.min(
877
943
  MAX_SIMULATION_STEPS_PER_FRAME,
878
- Math.floor((this.simulationAccumulator + 1e-9) / SIMULATION_STEP)
944
+ Math.max(0, Math.round(this.simulationAccumulator / SIMULATION_STEP - ROUND_TIE_BIAS))
879
945
  );
880
946
  for (let i = 0; i < stepsToRun; i++) {
881
947
  this.simulateStep(SIMULATION_STEP);
@@ -883,6 +949,77 @@ export class ParticleSystem implements IParticleSystem {
883
949
  this.simulationAccumulator -= stepsToRun * SIMULATION_STEP;
884
950
  }
885
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
+
886
1023
  /** Advances emission, behaviors, motion and culling by one fixed timestep. */
887
1024
  private simulateStep(delta: number): void {
888
1025
  if (!this.onlyUsedByOther) {
@@ -890,62 +1027,125 @@ export class ParticleSystem implements IParticleSystem {
890
1027
  }
891
1028
 
892
1029
  const particles = this.particles;
893
- const behaviorCount = this.behaviors.length;
1030
+ const behaviors = this.behaviors;
1031
+ const behaviorCount = behaviors.length;
894
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
+
895
1055
  this.emitterShape.update(this, delta);
896
- for (let j = 0; j < behaviorCount; j++) {
897
- const behavior = this.behaviors[j];
898
- 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
+ }
899
1078
  for (let i = 0; i < particleCount; i++) {
900
1079
  const particle = particles[i];
901
- if (!particle.died) {
1080
+ // Inlined `died` — this runs once per behavior per particle.
1081
+ if (particle.age < particle.life) {
902
1082
  behavior.update(particle, delta);
903
1083
  }
904
1084
  }
905
1085
  }
906
1086
 
907
- const followLocalOrigin =
908
- this.rendererSettings.renderMode === RenderMode.Trail &&
909
- (this.rendererEmitterSettings as TrailSettings).followLocalOrigin;
910
- const emitterMatrix = this.emitter.matrixWorld;
911
- for (let i = 0; i < particleCount; i++) {
912
- const particle = particles[i];
913
- if (followLocalOrigin && (particle as TrailParticle).localPosition) {
914
- particle.position.copy((particle as TrailParticle).localPosition!);
915
- if (particle.parentMatrix) {
916
- 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);
917
1095
  } else {
918
- particle.position.applyMatrix4(emitterMatrix);
1096
+ particle.position.addScaledVector(particle.velocity, delta * particle.speedModifier);
919
1097
  }
920
- } else {
921
- 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;
922
1113
  }
923
- particle.age += delta;
924
1114
  }
925
1115
 
926
- if (this.rendererSettings.renderMode === RenderMode.Trail) {
1116
+ if (isTrailMode) {
927
1117
  for (let i = 0; i < particleCount; i++) {
928
- const trailParticle = particles[i] as TrailParticle;
929
- if (this.useFastTrailHistory) {
930
- this.updateFastTrailHistory(trailParticle);
931
- } else {
932
- trailParticle.update();
933
- }
1118
+ (particles[i] as TrailParticle).update();
934
1119
  }
935
1120
  }
936
1121
 
1122
+ const notifyDeaths = this.hasListeners('particleDied');
1123
+ const store = this.store;
1124
+ const scalars = store.scalars;
1125
+ const scalarStride = ParticleStore.SCALAR_STRIDE;
937
1126
  let liveParticleCount = this.particleNum;
938
1127
  for (let i = 0; i < liveParticleCount; i++) {
939
1128
  const particle = particles[i];
940
- if (
941
- particle.died &&
942
- (!(particle instanceof TrailParticle) || this.getTrailHistoryCount(particle as TrailParticle) === 0)
943
- ) {
944
- particles[i] = particles[liveParticleCount - 1];
945
- 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
+ }
946
1144
  liveParticleCount--;
947
1145
  i--;
948
- this.fire({type: 'particleDied', particleSystem: this, particle: particle});
1146
+ if (notifyDeaths) {
1147
+ this.fire({type: 'particleDied', particleSystem: this, particle: particle});
1148
+ }
949
1149
  }
950
1150
  }
951
1151
  this.particleNum = liveParticleCount;
@@ -971,7 +1171,9 @@ export class ParticleSystem implements IParticleSystem {
971
1171
  if (this.looping) {
972
1172
  emissionState.time -= this.duration;
973
1173
  emissionState.burstIndex = 0;
974
- this.behaviors.forEach((behavior) => behavior.reset());
1174
+ for (let i = 0; i < this.behaviors.length; i++) {
1175
+ this.behaviors[i].reset();
1176
+ }
975
1177
  } else {
976
1178
  if (!this.emitEnded && !this.onlyUsedByOther) {
977
1179
  this.endEmit();
@@ -979,7 +1181,11 @@ export class ParticleSystem implements IParticleSystem {
979
1181
  }
980
1182
  }
981
1183
 
982
- 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
+ }
983
1189
  const emissionBursts = this.emissionBursts;
984
1190
  const emissionBurstCount = emissionBursts.length;
985
1191
  const qualityFactor = this.qualityFactor;
@@ -1026,7 +1232,11 @@ export class ParticleSystem implements IParticleSystem {
1026
1232
  }
1027
1233
 
1028
1234
  if (emissionState.previousWorldPos === undefined) emissionState.previousWorldPos = new Vector3();
1029
- 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
+ );
1030
1240
  emissionState.time += delta;
1031
1241
  }
1032
1242
 
@@ -1034,15 +1244,15 @@ export class ParticleSystem implements IParticleSystem {
1034
1244
  const isRootObject = metaData === undefined || typeof metaData === 'string';
1035
1245
  const meta: BabylonMetaData = isRootObject
1036
1246
  ? {
1037
- geometries: {},
1038
- materials: {},
1039
- textures: {},
1040
- images: {},
1041
- shapes: {},
1042
- skeletons: {},
1043
- animations: {},
1044
- nodes: {},
1045
- }
1247
+ geometries: {},
1248
+ materials: {},
1249
+ textures: {},
1250
+ images: {},
1251
+ shapes: {},
1252
+ skeletons: {},
1253
+ animations: {},
1254
+ nodes: {},
1255
+ }
1046
1256
  : metaData;
1047
1257
 
1048
1258
  const geometryUUID = this.ensureGeometryMeta(meta);
@@ -1125,8 +1335,8 @@ export class ParticleSystem implements IParticleSystem {
1125
1335
  const materialTextureRef = materialMeta?.texture ?? json.texture;
1126
1336
  const texture =
1127
1337
  typeof materialTextureRef === 'string'
1128
- ? meta.textures?.[materialTextureRef] ?? null
1129
- : materialTextureRef ?? null;
1338
+ ? (meta.textures?.[materialTextureRef] ?? null)
1339
+ : (materialTextureRef ?? null);
1130
1340
  const resolvedGeometryEntry =
1131
1341
  typeof json.instancingGeometry === 'string'
1132
1342
  ? meta.geometries?.[json.instancingGeometry]
@@ -1143,14 +1353,20 @@ export class ParticleSystem implements IParticleSystem {
1143
1353
  shape,
1144
1354
  startLife: ValueGeneratorFromJSON(json.startLife),
1145
1355
  startSpeed: ValueGeneratorFromJSON(json.startSpeed),
1146
- startRotation: GeneratorFromJSON(json.startRotation) as RotationGenerator | ValueGenerator | FunctionValueGenerator,
1356
+ startRotation: GeneratorFromJSON(json.startRotation) as
1357
+ | RotationGenerator
1358
+ | ValueGenerator
1359
+ | FunctionValueGenerator,
1147
1360
  startSize: GeneratorFromJSON(json.startSize) as Vector3Generator | ValueGenerator | FunctionValueGenerator,
1148
1361
  startColor: ColorGeneratorFromJSON(json.startColor) as ColorGenerator,
1149
1362
  emissionOverTime: ValueGeneratorFromJSON(json.emissionOverTime),
1150
1363
  emissionOverDistance: ValueGeneratorFromJSON(json.emissionOverDistance),
1151
1364
  emissionBursts: json.emissionBursts?.map((burst: any) => ({
1152
1365
  time: burst.time,
1153
- 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),
1154
1370
  probability: burst.probability ?? 1,
1155
1371
  interval: burst.interval ?? 0.1,
1156
1372
  cycle: burst.cycle ?? burst.cycleCount ?? 1,
@@ -1235,7 +1451,9 @@ export class ParticleSystem implements IParticleSystem {
1235
1451
  positions: Array.from(this.rendererSettings.instancingGeometry),
1236
1452
  indices: Array.from(this.rendererSettings.instancingIndices),
1237
1453
  uvs: this.rendererSettings.instancingUVs ? Array.from(this.rendererSettings.instancingUVs) : undefined,
1238
- normals: this.rendererSettings.instancingNormals ? Array.from(this.rendererSettings.instancingNormals) : undefined,
1454
+ normals: this.rendererSettings.instancingNormals
1455
+ ? Array.from(this.rendererSettings.instancingNormals)
1456
+ : undefined,
1239
1457
  };
1240
1458
  return geometryUUID;
1241
1459
  }
@@ -1248,6 +1466,13 @@ export class ParticleSystem implements IParticleSystem {
1248
1466
  meta.textures[textureUUID] = texture;
1249
1467
  }
1250
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
+
1251
1476
  const materialUUID = ParticleSystem.nextSerializationId('quarks_material');
1252
1477
  meta.materials[materialUUID] = {
1253
1478
  uuid: materialUUID,
@@ -1258,6 +1483,8 @@ export class ParticleSystem implements IParticleSystem {
1258
1483
  depthWrite: this.rendererSettings.materialDepthWrite,
1259
1484
  alphaTest: this.rendererSettings.materialAlphaTest,
1260
1485
  texture: textureUUID,
1486
+ reflectionAtlas: reflectionAtlasUUID,
1487
+ reflectionLevel: this.rendererSettings.reflectionLevel,
1261
1488
  sourceMaterial: this.materialRef ?? undefined,
1262
1489
  };
1263
1490
  return materialUUID;
@@ -1319,7 +1546,8 @@ export class ParticleSystem implements IParticleSystem {
1319
1546
 
1320
1547
  addBehavior(behavior: Behavior) {
1321
1548
  this.behaviors.push(behavior);
1322
- this.refreshTrailHistoryMode();
1549
+ // A sub emitter added here changes which system has to update first.
1550
+ this._renderer?._invalidateUpdateOrder();
1323
1551
  }
1324
1552
 
1325
1553
  getRendererSettings(): VFXBatchSettings {
@@ -1342,9 +1570,19 @@ export class ParticleSystem implements IParticleSystem {
1342
1570
  }
1343
1571
  }
1344
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
+
1345
1579
  private fire(event: ParticleSystemEvent) {
1346
- if (this.listeners[event.type]) {
1347
- 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);
1348
1586
  }
1349
1587
  }
1350
1588