babylon.quarks 0.18.1 → 0.20.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 +57 -0
  2. package/README.md +38 -16
  3. package/dist/babylon.quarks.cjs +1 -1
  4. package/dist/babylon.quarks.cjs.map +1 -1
  5. package/dist/babylon.quarks.esm.js +1 -1
  6. package/dist/babylon.quarks.esm.js.map +1 -1
  7. package/dist/babylon.quarks.umd.min.js +1 -1
  8. package/dist/babylon.quarks.umd.min.js.map +1 -1
  9. package/dist/types/index.d.ts +2121 -1157
  10. package/package.json +67 -67
  11. package/src/BatchedRenderer.ts +228 -15
  12. package/src/MeshSurfaceEmitter.ts +34 -23
  13. package/src/ParticleEmitter.ts +34 -10
  14. package/src/ParticleSystem.ts +558 -197
  15. package/src/QuarksLoader.ts +204 -18
  16. package/src/QuarksPrefab.ts +8 -11
  17. package/src/QuarksUtil.ts +1 -3
  18. package/src/SpriteBatch.ts +520 -98
  19. package/src/TrailBatch.ts +374 -166
  20. package/src/VFXBatch.ts +24 -5
  21. package/src/envAtlas.ts +136 -0
  22. package/src/index.ts +34 -29
  23. package/src/materials/ParticleMaterials.ts +1 -1
  24. package/src/shaders/index.ts +4 -4
  25. package/src/shaders/local_particle_physics_vert.wgsl.ts +81 -0
  26. package/src/shaders/particle_frag.wgsl.ts +64 -0
  27. package/src/shaders/particle_physics_frag.glsl.ts +56 -1
  28. package/src/shaders/particle_physics_frag.wgsl.ts +125 -0
  29. package/src/shaders/particle_vert.glsl.ts +20 -0
  30. package/src/shaders/particle_vert.wgsl.ts +107 -0
  31. package/src/shaders/shaderLanguageSupport.ts +54 -0
  32. package/src/shaders/stretched_bb_particle_vert.glsl.ts +18 -5
  33. package/src/shaders/stretched_bb_particle_vert.wgsl.ts +101 -0
  34. package/src/shaders/trail_frag.wgsl.ts +23 -0
  35. package/src/shaders/trail_vert.wgsl.ts +63 -0
@@ -1,47 +1,53 @@
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,
41
+ Vector2,
32
42
  Vector3,
33
- Vector4,
34
- Matrix3,
35
- Matrix4,
36
- Quaternion,
37
43
  Vector3Generator,
38
- ParticleSystemEvent,
39
- ParticleSystemEventType,
44
+ Vector4,
40
45
  } from 'quarks.core';
41
- import {ParticleEmitter} from './ParticleEmitter';
42
- import {RenderMode} from './VFXBatch';
43
46
  import {BatchedRenderer, VFXBatchSettings} from './BatchedRenderer';
47
+ import {ensureEnvAtlasFromCube, getCachedEnvAtlas} from './envAtlas';
44
48
  import {ensureTriangleIndices} from './geometryUtil';
49
+ import {ParticleEmitter} from './ParticleEmitter';
50
+ import {RenderMode} from './VFXBatch';
45
51
 
46
52
  export interface BurstParameters {
47
53
  time: number;
@@ -51,6 +57,9 @@ export interface BurstParameters {
51
57
  probability: number;
52
58
  }
53
59
 
60
+ /** Particle backed by a {@link ParticleStore} row, as this system always creates. */
61
+ type StoreBackedParticle = Particle & {storeIndex: number; setStoreIndex(index: number): void};
62
+
54
63
  const UP = new Vector3(0, 0, 1);
55
64
  const tempQ = new Quaternion();
56
65
  const tempV = new Vector3();
@@ -58,16 +67,33 @@ const tempV2 = new Vector3();
58
67
  const PREWARM_FPS = 60;
59
68
  const SIMULATION_STEP = 1 / PREWARM_FPS;
60
69
  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
- ]);
70
+ // Math.round() breaks an exact tie (accumulator sitting at precisely half a
71
+ // step) by rounding up. At exactly 120Hz that tie is not a corner case — it is
72
+ // every single frame — and rounding it up would run a step on every one of
73
+ // them instead of every other one. Nudging the tie down costs nothing on a
74
+ // real display (real frame times essentially never land on the tie exactly)
75
+ // and keeps the well-tested "half a step is no step yet" behavior.
76
+ const ROUND_TIE_BIAS = 1e-9;
77
+
78
+ 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
79
  const DEFAULT_UVS = new Float32Array([0, 0, 1, 0, 1, 1, 0, 1]);
80
+
81
+ /** Zero UVs sized to a position buffer — missing/mismatched uv attrs → GL_INVALID_OPERATION on iOS. */
82
+ function fallbackUVsForPositions(positions: Float32Array): Float32Array {
83
+ return new Float32Array((positions.length / 3) * 2);
84
+ }
69
85
  const DEFAULT_INDICES = new Uint32Array([0, 1, 2, 0, 2, 3]);
70
86
 
87
+ /** The optional trail flags that are switched on, so JSON and clones only carry the ones in use. */
88
+ function trailFlags(settings: TrailSettings | undefined): Partial<TrailSettings> {
89
+ const flags: Partial<TrailSettings> = {};
90
+ if (settings?.uvFromHead) flags.uvFromHead = true;
91
+ if (settings?.colorFromParticle) flags.colorFromParticle = true;
92
+ if (settings?.dieWithParticle) flags.dieWithParticle = true;
93
+ if (settings?.recordWorldSpace) flags.recordWorldSpace = true;
94
+ return flags;
95
+ }
96
+
71
97
  export interface ParticleSystemParameters {
72
98
  autoDestroy?: boolean;
73
99
  looping?: boolean;
@@ -84,6 +110,8 @@ export interface ParticleSystemParameters {
84
110
  emissionOverTime?: ValueGenerator | FunctionValueGenerator;
85
111
  emissionOverDistance?: ValueGenerator | FunctionValueGenerator;
86
112
  emissionBursts?: Array<BurstParameters>;
113
+ /** Most particles alive at once; emission past it is dropped. Leave unset for no limit. */
114
+ maxParticles?: number;
87
115
  onlyUsedByOther?: boolean;
88
116
  behaviors?: Array<Behavior>;
89
117
  instancingGeometry?: Float32Array;
@@ -110,6 +138,10 @@ export interface ParticleSystemParameters {
110
138
  depthWrite?: boolean;
111
139
  alphaTest?: number;
112
140
  layerMask?: number;
141
+ /** Billboard pivot in particle-size units, see {@link VFXBatchSettings.pivot}. */
142
+ pivot?: Vector3;
143
+ /** Fraction of billboard particles mirrored along U (x) and V (y), see {@link ParticleSystem.flip}. */
144
+ flip?: Vector2;
113
145
  scene?: Scene;
114
146
  }
115
147
 
@@ -137,6 +169,7 @@ export interface ParticleSystemJSONParameters {
137
169
  emissionOverTime: FunctionJSON;
138
170
  emissionOverDistance: FunctionJSON;
139
171
  emissionBursts?: Array<BurstParametersJSON>;
172
+ maxParticles?: number;
140
173
  onlyUsedByOther: boolean;
141
174
  rendererEmitterSettings: RendererEmitterSettings;
142
175
  instancingGeometry?: any;
@@ -160,6 +193,10 @@ export interface ParticleSystemJSONParameters {
160
193
  alphaTest?: number;
161
194
  behaviors: Array<any>;
162
195
  worldSpace: boolean;
196
+ /** `[x, y, z]`; written only when non-zero. */
197
+ pivot?: number[];
198
+ /** `[u, v]`; written only when non-zero. */
199
+ flip?: number[];
163
200
  }
164
201
 
165
202
  export interface BabylonMetaData {
@@ -188,14 +225,24 @@ export class ParticleSystem implements IParticleSystem {
188
225
  startColor: ColorGenerator | FunctionColorGenerator;
189
226
  startTileIndex: ValueGenerator;
190
227
  rendererEmitterSettings: RendererEmitterSettings;
228
+ /**
229
+ * Fraction of billboard particles mirrored along U (x) and V (y), each
230
+ * picked at random when the particle is born — Unity's renderer Flip.
231
+ * Undefined: nothing is mirrored. Meshes ignore it.
232
+ */
233
+ flip?: Vector2;
191
234
  emissionOverTime: ValueGenerator | FunctionValueGenerator;
192
235
  emissionOverDistance: ValueGenerator | FunctionValueGenerator;
193
236
  emissionBursts: Array<BurstParameters>;
237
+ /** Most particles alive at once (Unity's Max Particles); emission past it is dropped. Undefined means no limit. */
238
+ maxParticles?: number;
194
239
  onlyUsedByOther: boolean;
195
240
  worldSpace: boolean;
196
241
  particleNum: number;
197
242
  paused: boolean;
198
243
  particles: Array<Particle>;
244
+ /** Column storage shared by this system's particles. */
245
+ readonly store: ParticleStore = new ParticleStore(64);
199
246
  emitterShape: EmitterShape;
200
247
  emitter: ParticleEmitter;
201
248
  rendererSettings: VFXBatchSettings;
@@ -217,10 +264,13 @@ export class ParticleSystem implements IParticleSystem {
217
264
  private listeners: {[event: string]: Array<(event: ParticleSystemEvent) => void>} = {};
218
265
  private readonly layerMaskProxy: {mask: number};
219
266
  private materialRef: any = null;
267
+ private sceneRef: Scene | undefined;
220
268
  private qualityFactor = 1;
221
- private useFastTrailHistory = true;
222
269
  private simulationAccumulator = 0;
223
270
  private destroyed = false;
271
+ /** Behaviors the cached plan was built from, compared by identity. */
272
+ private fusionPlan?: Array<Behavior>;
273
+ private fusionSteps: Array<FusionStep> = [];
224
274
  /** @internal **/
225
275
  _renderer?: BatchedRenderer;
226
276
 
@@ -336,6 +386,28 @@ export class ParticleSystem implements IParticleSystem {
336
386
  this.neededToUpdateRender = true;
337
387
  }
338
388
 
389
+ /**
390
+ * Billboard pivot in particle-size units (Unity's renderer Pivot), see
391
+ * {@link VFXBatchSettings.pivot}. Assign a new vector to change it: editing
392
+ * the returned one in place does not reach the renderer.
393
+ */
394
+ get pivot(): Vector3 | undefined {
395
+ return this.rendererSettings.pivot;
396
+ }
397
+
398
+ set pivot(v: Vector3 | undefined) {
399
+ this.rendererSettings.pivot = ParticleSystem.nonZeroPivot(v);
400
+ this.neededToUpdateRender = true;
401
+ }
402
+
403
+ private static nonZeroPivot(v: Vector3 | undefined): Vector3 | undefined {
404
+ return v && (v.x !== 0 || v.y !== 0 || v.z !== 0) ? v.clone() : undefined;
405
+ }
406
+
407
+ private static nonZeroFlip(v: Vector2 | undefined): Vector2 | undefined {
408
+ return v && (v.x > 0 || v.y > 0) ? v.clone() : undefined;
409
+ }
410
+
339
411
  get softNearFade() {
340
412
  return this.rendererSettings.softNearFade;
341
413
  }
@@ -424,7 +496,8 @@ export class ParticleSystem implements IParticleSystem {
424
496
  }
425
497
 
426
498
  constructor(parameters: ParticleSystemParameters) {
427
- this.layerMaskProxy = {mask: parameters.layerMask ?? 0x0FFFFFFF};
499
+ this.sceneRef = parameters.scene;
500
+ this.layerMaskProxy = {mask: parameters.layerMask ?? 0x0fffffff};
428
501
  Object.defineProperty(this.layerMaskProxy, 'mask', {
429
502
  enumerable: true,
430
503
  get: () => this.rendererSettings.layerMask,
@@ -447,6 +520,7 @@ export class ParticleSystem implements IParticleSystem {
447
520
  this.emissionOverTime = parameters.emissionOverTime ?? new ConstantValue(10);
448
521
  this.emissionOverDistance = parameters.emissionOverDistance ?? new ConstantValue(0);
449
522
  this.emissionBursts = parameters.emissionBursts ?? [];
523
+ this.maxParticles = parameters.maxParticles;
450
524
  this.onlyUsedByOther = parameters.onlyUsedByOther ?? false;
451
525
  this.emitterShape = parameters.shape ?? new SphereEmitter();
452
526
  this.behaviors = parameters.behaviors ?? [];
@@ -465,7 +539,9 @@ export class ParticleSystem implements IParticleSystem {
465
539
  this.rendererSettings = {
466
540
  instancingGeometry: parameters.instancingGeometry ?? DEFAULT_POSITIONS,
467
541
  instancingIndices: parameters.instancingIndices ?? DEFAULT_INDICES,
468
- instancingUVs: parameters.instancingUVs ?? DEFAULT_UVS,
542
+ instancingUVs:
543
+ parameters.instancingUVs ??
544
+ (parameters.instancingGeometry ? fallbackUVsForPositions(parameters.instancingGeometry) : DEFAULT_UVS),
469
545
  instancingNormals: parameters.instancingNormals,
470
546
  renderMode: parameters.renderMode ?? RenderMode.BillBoard,
471
547
  renderOrder: parameters.renderOrder ?? 0,
@@ -481,10 +557,18 @@ export class ParticleSystem implements IParticleSystem {
481
557
  materialDepthWrite: parameters.depthWrite ?? false,
482
558
  materialAlphaTest: parameters.alphaTest ?? 0,
483
559
  texture: parameters.texture ?? null,
484
- layerMask: parameters.layerMask ?? 0x0FFFFFFF,
560
+ reflectionTexture: null,
561
+ reflectionLevel: 1,
562
+ reflectionFaces: null,
563
+ reflectionAtlas: null,
564
+ layerMask: parameters.layerMask ?? 0x0fffffff,
565
+ pivot: ParticleSystem.nonZeroPivot(parameters.pivot),
485
566
  };
567
+ this.flip = ParticleSystem.nonZeroFlip(parameters.flip);
486
568
  if (this.rendererSettings.renderMode === RenderMode.Mesh && !this.rendererSettings.instancingNormals) {
487
- this.rendererSettings.instancingNormals = ParticleSystem.createFallbackNormals(this.rendererSettings.instancingGeometry);
569
+ this.rendererSettings.instancingNormals = ParticleSystem.createFallbackNormals(
570
+ this.rendererSettings.instancingGeometry
571
+ );
488
572
  }
489
573
 
490
574
  this.materialRef = parameters.material ?? null;
@@ -520,7 +604,6 @@ export class ParticleSystem implements IParticleSystem {
520
604
  this.emissionOverDistance.startGen(this.memory);
521
605
  this.startDelay.startGen(this.memory);
522
606
  this.startDelayTimeLeft = this.startDelay.genValue(this.memory, 0);
523
- this.refreshTrailHistoryMode();
524
607
 
525
608
  this.emitEnded = false;
526
609
  this.markForDestroy = false;
@@ -536,6 +619,7 @@ export class ParticleSystem implements IParticleSystem {
536
619
  depthWrite?: boolean;
537
620
  alphaTest?: number;
538
621
  texture?: Texture | null;
622
+ reflectionAtlas?: BaseTexture | null;
539
623
  layerMask?: number;
540
624
  } = {}
541
625
  ) {
@@ -548,12 +632,18 @@ export class ParticleSystem implements IParticleSystem {
548
632
  overrides.texture !== undefined
549
633
  ? overrides.texture
550
634
  : (material?.albedoTexture ??
551
- material?.diffuseTexture ??
552
- material?.emissiveTexture ??
553
- material?.opacityTexture ??
554
- material?.baseTexture ??
555
- this.rendererSettings.texture ??
556
- null);
635
+ material?.diffuseTexture ??
636
+ material?.emissiveTexture ??
637
+ material?.opacityTexture ??
638
+ material?.baseTexture ??
639
+ this.rendererSettings.texture ??
640
+ null);
641
+ const resolvedReflection =
642
+ material?.reflectionTexture ??
643
+ material?.environmentTexture ??
644
+ this.rendererSettings.reflectionTexture ??
645
+ null;
646
+ const resolvedReflectionLevel = typeof resolvedReflection?.level === 'number' ? resolvedReflection.level : 1;
557
647
  const resolvedBlendMode =
558
648
  overrides.blendMode ??
559
649
  (typeof material?.alphaMode === 'number' ? material.alphaMode : this.rendererSettings.materialBlendMode);
@@ -562,8 +652,8 @@ export class ParticleSystem implements IParticleSystem {
562
652
  (typeof material?.needAlphaBlending === 'function'
563
653
  ? material.needAlphaBlending()
564
654
  : typeof material?.alpha === 'number'
565
- ? material.alpha < 1
566
- : this.rendererSettings.materialTransparent);
655
+ ? material.alpha < 1
656
+ : this.rendererSettings.materialTransparent);
567
657
  const resolvedDepthTest =
568
658
  overrides.depthTest ??
569
659
  (typeof material?.disableDepthTest === 'boolean'
@@ -574,17 +664,57 @@ export class ParticleSystem implements IParticleSystem {
574
664
  (typeof material?.disableDepthWrite === 'boolean'
575
665
  ? !material.disableDepthWrite
576
666
  : typeof material?.forceDepthWrite === 'boolean'
577
- ? material.forceDepthWrite
578
- : this.rendererSettings.materialDepthWrite);
667
+ ? material.forceDepthWrite
668
+ : this.rendererSettings.materialDepthWrite);
579
669
  const resolvedAlphaTest =
580
670
  overrides.alphaTest ??
581
- (typeof material?.alphaCutOff === 'number'
582
- ? material.alphaCutOff
583
- : typeof material?.alphaCutOffValue === 'number'
584
- ? material.alphaCutOffValue
585
- : this.rendererSettings.materialAlphaTest);
671
+ (() => {
672
+ // StandardMaterial always has alphaCutOff (default 0.4). Only treat it as
673
+ // particle alpha-test when the material is actually in an alpha-test mode —
674
+ // otherwise Mesh Material demos wrongly enable USE_ALPHATEST.
675
+ const mode = material?.transparencyMode;
676
+ const alphaTestMode = mode === 1 || mode === 3;
677
+ if (alphaTestMode && typeof material?.alphaCutOff === 'number') {
678
+ return material.alphaCutOff;
679
+ }
680
+ if (alphaTestMode && typeof material?.alphaCutOffValue === 'number') {
681
+ return material.alphaCutOffValue;
682
+ }
683
+ if (material) {
684
+ return 0;
685
+ }
686
+ return this.rendererSettings.materialAlphaTest;
687
+ })();
586
688
 
587
689
  this.rendererSettings.texture = resolvedTexture;
690
+ this.rendererSettings.reflectionTexture = resolvedReflection;
691
+ this.rendererSettings.reflectionLevel =
692
+ typeof material?.reflectionLevel === 'number' ? material.reflectionLevel : resolvedReflectionLevel;
693
+ // Prefer an explicit atlas; otherwise build a 3×2 atlas from CubeTexture
694
+ // face URLs (iOS-safe single sampler2D — not samplerCube / six faces).
695
+ let resolvedAtlas =
696
+ material?.reflectionAtlas ?? overrides.reflectionAtlas ?? this.rendererSettings.reflectionAtlas ?? null;
697
+ if (!resolvedAtlas && resolvedReflection?.isCube) {
698
+ resolvedAtlas = getCachedEnvAtlas(resolvedReflection);
699
+ if (!resolvedAtlas && this.sceneRef) {
700
+ ensureEnvAtlasFromCube(resolvedReflection, this.sceneRef, (atlas) => {
701
+ if (this.rendererSettings.reflectionAtlas === atlas) {
702
+ return;
703
+ }
704
+ this.rendererSettings.reflectionAtlas = atlas;
705
+ if (material && material.reflectionAtlas == null) {
706
+ material.reflectionAtlas = atlas;
707
+ }
708
+ this.neededToUpdateRender = true;
709
+ });
710
+ resolvedAtlas = getCachedEnvAtlas(resolvedReflection);
711
+ }
712
+ }
713
+ this.rendererSettings.reflectionAtlas = resolvedAtlas;
714
+ // Do not expand cube files into six face textures anymore — that path
715
+ // still trips GL_INVALID_OPERATION on iOS. Prefer a single atlas.
716
+ this.rendererSettings.reflectionFaces =
717
+ material?.reflectionFaces?.length === 6 ? material.reflectionFaces : null;
588
718
  this.rendererSettings.materialBlendMode = resolvedBlendMode;
589
719
  this.rendererSettings.materialTransparent = resolvedTransparent;
590
720
  this.rendererSettings.materialDepthTest = resolvedDepthTest;
@@ -596,105 +726,77 @@ export class ParticleSystem implements IParticleSystem {
596
726
  this.neededToUpdateRender = true;
597
727
  }
598
728
 
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');
729
+ pause() {
730
+ this.paused = true;
605
731
  }
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;
732
+ play() {
733
+ this.paused = false;
734
+ }
735
+ stop() {
736
+ this.restart();
737
+ this.pause();
618
738
  }
619
739
 
620
- private resetTrailHistory(particle: TrailParticle) {
621
- const trailAny = particle as any;
622
- trailAny._trailHistoryHead = 0;
623
- trailAny._trailHistoryCount = 0;
740
+ setQualityFactor(qualityFactor: number) {
741
+ this.qualityFactor = Math.max(0.1, Math.min(1, qualityFactor));
624
742
  }
625
743
 
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--;
744
+ /**
745
+ * Appends one particle to the pool, giving it the next row of the store.
746
+ *
747
+ * Growing the store replaces its arrays, so every particle already bound to
748
+ * it has to be re-pointed at the new ones.
749
+ */
750
+ /** Points every particle at the store's columns again, after any were replaced. */
751
+ private rebindParticles(): void {
752
+ const particles = this.particles;
753
+ for (let i = 0; i < particles.length; i++) {
754
+ (particles[i] as SpriteParticle | TrailParticle).rebind();
662
755
  }
663
-
664
- trailAny._trailHistoryHead = head;
665
- trailAny._trailHistoryCount = count;
666
756
  }
667
757
 
668
- setQualityFactor(qualityFactor: number) {
669
- this.qualityFactor = Math.max(0.1, Math.min(1, qualityFactor));
758
+ private growParticlePool(isTrailMode: boolean): void {
759
+ const index = this.particles.length;
760
+ if (this.store.ensureCapacity(index + 1)) {
761
+ this.rebindParticles();
762
+ }
763
+ this.particles.push(isTrailMode ? new TrailParticle(this.store, index) : new SpriteParticle(this.store, index));
670
764
  }
671
765
 
672
766
  private spawn(count: number, emissionState: EmissionState, matrix: Matrix4) {
673
- tempQ.setFromRotationMatrix(matrix);
674
767
  const translation = tempV;
675
768
  const scale = tempV2;
769
+ // decompose writes tempQ, so no separate setFromRotationMatrix is needed.
676
770
  matrix.decompose(translation, tempQ, scale);
677
771
  const behaviors = this.behaviors;
678
772
  const behaviorCount = behaviors.length;
679
773
  const renderMode = this.rendererSettings.renderMode;
680
774
  const isTrailMode = renderMode === RenderMode.Trail;
775
+ const isSpriteMode = !isTrailMode;
776
+ const isMeshMode = renderMode === RenderMode.Mesh;
777
+ const startRotationIsRotation = this.startRotation.type === 'rotation';
778
+ const startSizeIsVector3 = this.startSize.type === 'vec3function';
681
779
  const timeRatio = emissionState.time / this.duration;
682
780
  const trailSettings = isTrailMode ? (this.rendererEmitterSettings as TrailSettings) : undefined;
683
781
  const followLocalOrigin = Boolean(trailSettings?.followLocalOrigin);
782
+ if (this.maxParticles !== undefined) {
783
+ // Like Unity, a full system skips the particles it has no room for rather than queueing them.
784
+ count = Math.min(count, Math.max(0, this.maxParticles - this.particleNum));
785
+ }
684
786
 
685
787
  for (let i = 0; i < count; i++) {
686
788
  emissionState.burstParticleIndex = i;
687
789
  this.particleNum++;
688
790
  if (this.particles.length < this.particleNum) {
689
- if (isTrailMode) {
690
- this.particles.push(new TrailParticle());
691
- } else {
692
- this.particles.push(new SpriteParticle());
693
- }
791
+ this.growParticlePool(isTrailMode);
694
792
  }
695
793
  const particle = this.particles[this.particleNum - 1];
696
794
  particle.reset();
697
795
  particle.speedModifier = 1;
796
+ // Parent EmitSub can refill an onlyUsedByOther system after it had
797
+ // already finished empty; clear the flag so update does not treat it
798
+ // as done while it has live particles again.
799
+ this.finishedEventFired = false;
698
800
  this.startColor.startGen(particle.memory);
699
801
  this.startColor.genColor(particle.memory, particle.startColor, this.emissionState.time);
700
802
  particle.color.copy(particle.startColor);
@@ -704,7 +806,7 @@ export class ParticleSystem implements IParticleSystem {
704
806
  particle.life = this.startLife.genValue(particle.memory, timeRatio);
705
807
  particle.age = 0;
706
808
  this.startSize.startGen(particle.memory);
707
- if (this.startSize.type === 'vec3function') {
809
+ if (startSizeIsVector3) {
708
810
  (this.startSize as Vector3Generator).genValue(particle.memory, particle.startSize, timeRatio);
709
811
  } else {
710
812
  const size = (this.startSize as FunctionValueGenerator).genValue(particle.memory, timeRatio);
@@ -714,39 +816,54 @@ export class ParticleSystem implements IParticleSystem {
714
816
  particle.uvTile = this.startTileIndex.genValue(particle.memory);
715
817
  particle.size.copy(particle.startSize);
716
818
 
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
- ) {
819
+ if (isSpriteMode) {
724
820
  const sprite = particle as SpriteParticle;
725
821
  this.startRotation.startGen(particle.memory);
726
- if (renderMode === RenderMode.Mesh) {
822
+ if (isMeshMode) {
727
823
  if (!(sprite.rotation instanceof Quaternion)) {
728
824
  sprite.rotation = new Quaternion();
729
825
  }
730
- if (this.startRotation.type === 'rotation') {
731
- this.startRotation.genValue(particle.memory, sprite.rotation as Quaternion, 1, timeRatio);
826
+ // A recycled particle can still carry the turn of whatever
827
+ // it was last time; the renderer would draw a fraction of it
828
+ // before any behavior has had a chance to say otherwise.
829
+ if (sprite.angularVelocity instanceof Quaternion) {
830
+ sprite.angularVelocity.set(0, 0, 0, 1);
831
+ }
832
+ if (startRotationIsRotation) {
833
+ (this.startRotation as RotationGenerator).genValue(
834
+ particle.memory,
835
+ sprite.rotation as Quaternion,
836
+ 1,
837
+ timeRatio
838
+ );
732
839
  } else {
733
- (sprite.rotation as Quaternion).setFromAxisAngle(UP, this.startRotation.genValue(sprite.memory, timeRatio));
840
+ (sprite.rotation as Quaternion).setFromAxisAngle(
841
+ UP,
842
+ (this.startRotation as FunctionValueGenerator).genValue(sprite.memory, timeRatio)
843
+ );
734
844
  }
735
845
  } else {
736
- if (this.startRotation.type === 'rotation') {
846
+ if (startRotationIsRotation) {
737
847
  sprite.rotation = 0;
738
848
  } else {
739
- sprite.rotation = this.startRotation.genValue(sprite.memory, timeRatio);
849
+ sprite.rotation = (this.startRotation as FunctionValueGenerator).genValue(
850
+ sprite.memory,
851
+ timeRatio
852
+ );
740
853
  }
854
+ // Set until a turning behavior says otherwise, so the
855
+ // renderer never carries a recycled particle's old spin.
856
+ sprite.angularVelocity = 0;
857
+ const flip = this.flip;
858
+ sprite.flip =
859
+ flip === undefined ? 0 : (Math.random() < flip.x ? 1 : 0) | (Math.random() < flip.y ? 2 : 0);
741
860
  }
742
- } else if (isTrailMode && trailSettings) {
861
+ } else if (trailSettings) {
743
862
  const trail = particle as TrailParticle;
744
863
  trailSettings.startLength.startGen(trail.memory);
745
864
  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
- }
865
+ trail.ensureHistoryCapacity(Math.max(1, Math.ceil(trail.length)));
866
+ trail.resetHistory();
750
867
  }
751
868
 
752
869
  this.emitterShape.initialize(particle, emissionState);
@@ -833,6 +950,20 @@ export class ParticleSystem implements IParticleSystem {
833
950
  }
834
951
 
835
952
  if (!this.looping && this.finishedEventFired && this.particleNum === 0) {
953
+ // Still tick the fixed-step clock. A sub-emitter target
954
+ // (onlyUsedByOther) goes empty between parent bursts; skipping the
955
+ // accumulator here desyncs it from the parent, so the next particles
956
+ // the parent emits into it sit at age 0 / startColor until the phase
957
+ // catches up — white flashes scattered wherever the parent was.
958
+ if (delta > 0.1) {
959
+ delta = 0.1;
960
+ }
961
+ this.simulationAccumulator += delta;
962
+ const stepsToRun = Math.min(
963
+ MAX_SIMULATION_STEPS_PER_FRAME,
964
+ Math.max(0, Math.round(this.simulationAccumulator / SIMULATION_STEP - ROUND_TIE_BIAS))
965
+ );
966
+ this.simulationAccumulator -= stepsToRun * SIMULATION_STEP;
836
967
  return;
837
968
  }
838
969
 
@@ -855,10 +986,7 @@ export class ParticleSystem implements IParticleSystem {
855
986
  const emitterElements = this.emitter.matrixWorld.elements;
856
987
  this.tempEmitterPos.set(emitterElements[12], emitterElements[13], emitterElements[14]);
857
988
  if (this.previousEmitterPos !== undefined && delta > 0) {
858
- this.emitterVelocity
859
- .copy(this.tempEmitterPos)
860
- .sub(this.previousEmitterPos)
861
- .divideScalar(delta);
989
+ this.emitterVelocity.copy(this.tempEmitterPos).sub(this.previousEmitterPos).divideScalar(delta);
862
990
  }
863
991
  (this.previousEmitterPos ??= new Vector3()).copy(this.tempEmitterPos);
864
992
 
@@ -875,7 +1003,7 @@ export class ParticleSystem implements IParticleSystem {
875
1003
  this.simulationAccumulator += delta;
876
1004
  const stepsToRun = Math.min(
877
1005
  MAX_SIMULATION_STEPS_PER_FRAME,
878
- Math.floor((this.simulationAccumulator + 1e-9) / SIMULATION_STEP)
1006
+ Math.max(0, Math.round(this.simulationAccumulator / SIMULATION_STEP - ROUND_TIE_BIAS))
879
1007
  );
880
1008
  for (let i = 0; i < stepsToRun; i++) {
881
1009
  this.simulateStep(SIMULATION_STEP);
@@ -883,6 +1011,77 @@ export class ParticleSystem implements IParticleSystem {
883
1011
  this.simulationAccumulator -= stepsToRun * SIMULATION_STEP;
884
1012
  }
885
1013
 
1014
+ /**
1015
+ * Time the display is ahead of (positive) or still ahead of the simulation
1016
+ * (negative — the simulation ran a hair early), in seconds. Magnitude never
1017
+ * exceeds one step.
1018
+ *
1019
+ * The simulation runs on a fixed 1/60 step so that emission and lifetimes
1020
+ * stay in phase — without it a slow emitter flickers when the frame rate
1021
+ * wobbles. But frames do not arrive on that grid: a display running at
1022
+ * anything other than exactly 60Hz leaves some frames with no step at all
1023
+ * (half of them at 120Hz, three in five at 144Hz) and others with two, so
1024
+ * the particles are drawn stuttering even while the frame rate is perfect.
1025
+ *
1026
+ * The steps-per-frame count below is chosen by *rounding* the accumulated
1027
+ * time to the nearest step, not flooring it. Flooring always leaves the
1028
+ * accumulator positive, and when the real frame rate sits very close to
1029
+ * 60Hz (as most displays do) that leftover creeps toward a full step for
1030
+ * many consecutive frames before tipping over into a frame that runs two
1031
+ * steps at once and resets it to near zero — a visible lurch, every particle
1032
+ * in the system at once, with a perfectly steady frame rate the whole time.
1033
+ * Rounding lets the leftover go negative instead, which keeps it centered
1034
+ * near zero and stops it from ever building up toward that reset.
1035
+ *
1036
+ * Renderers close the gap by advancing what they draw along each particle's
1037
+ * velocity by this much. That reproduces the straight-line part of the
1038
+ * motion exactly — it is the same term the next step would integrate — so
1039
+ * the simulation stays fixed-step while the picture moves continuously.
1040
+ */
1041
+ /** Length of one simulation step, in seconds. See {@link simulationResidual}. */
1042
+ get simulationStep(): number {
1043
+ return SIMULATION_STEP;
1044
+ }
1045
+
1046
+ get simulationResidual(): number {
1047
+ if (this.paused) {
1048
+ return 0;
1049
+ }
1050
+ // Magnitude never more than a step, even if a frame long enough to hit
1051
+ // the per-frame step cap left the accumulator with more than that.
1052
+ if (this.simulationAccumulator > SIMULATION_STEP) return SIMULATION_STEP;
1053
+ if (this.simulationAccumulator < -SIMULATION_STEP) return -SIMULATION_STEP;
1054
+ return this.simulationAccumulator;
1055
+ }
1056
+
1057
+ /**
1058
+ * Execution plan for this system's behaviors, rebuilt when the list changes.
1059
+ *
1060
+ * Consecutive behaviors that can be compiled into one loop are merged, so
1061
+ * the particles are walked once instead of once per behavior. The plan is
1062
+ * keyed on the behavior instances, not their types: swapping one out has to
1063
+ * change the generated code's indices.
1064
+ */
1065
+ private behaviorSteps(): Array<FusionStep> {
1066
+ const behaviors = this.behaviors;
1067
+ const cached = this.fusionPlan;
1068
+ if (cached !== undefined && cached.length === behaviors.length) {
1069
+ let same = true;
1070
+ for (let i = 0; i < behaviors.length; i++) {
1071
+ if (cached[i] !== behaviors[i]) {
1072
+ same = false;
1073
+ break;
1074
+ }
1075
+ }
1076
+ if (same) {
1077
+ return this.fusionSteps;
1078
+ }
1079
+ }
1080
+ this.fusionPlan = behaviors.slice();
1081
+ this.fusionSteps = planBehaviorFusion(behaviors);
1082
+ return this.fusionSteps;
1083
+ }
1084
+
886
1085
  /** Advances emission, behaviors, motion and culling by one fixed timestep. */
887
1086
  private simulateStep(delta: number): void {
888
1087
  if (!this.onlyUsedByOther) {
@@ -890,62 +1089,174 @@ export class ParticleSystem implements IParticleSystem {
890
1089
  }
891
1090
 
892
1091
  const particles = this.particles;
893
- const behaviorCount = this.behaviors.length;
1092
+ const behaviors = this.behaviors;
1093
+ const behaviorCount = behaviors.length;
894
1094
  const particleCount = this.particleNum;
1095
+ const isTrailMode = this.rendererSettings.renderMode === RenderMode.Trail;
1096
+ // Only stretched billboards draw from velocity, and only they pay for
1097
+ // keeping the previous one.
1098
+ const stretched = this.rendererSettings.renderMode === RenderMode.StretchedBillBoard;
1099
+
1100
+ // Remember where everything starts this step, so the renderer can carry
1101
+ // the motion the step produces on into the part of the frame that comes
1102
+ // after it. Taken after emission on purpose: a particle born this step
1103
+ // then starts from where it was born rather than from whatever the row
1104
+ // held before, and so is drawn at the emitter instead of jumping.
1105
+ const columns = this.store;
1106
+ const vector3Rows = particleCount * 3;
1107
+ columns.previousPosition.set(columns.position.subarray(0, vector3Rows));
1108
+ columns.previousSize.set(columns.size.subarray(0, vector3Rows));
1109
+ if (stretched) {
1110
+ if (columns.keepPreviousVelocity()) {
1111
+ this.rebindParticles();
1112
+ }
1113
+ columns.previousVelocity.set(columns.velocity.subarray(0, vector3Rows));
1114
+ }
1115
+ columns.previousColor.set(columns.color.subarray(0, particleCount * 4));
1116
+
895
1117
  this.emitterShape.update(this, delta);
896
- for (let j = 0; j < behaviorCount; j++) {
897
- const behavior = this.behaviors[j];
898
- behavior.frameUpdate(delta);
1118
+ const steps = this.behaviorSteps();
1119
+ for (let s = 0; s < steps.length; s++) {
1120
+ const step = steps[s];
1121
+ const stepBehaviors = step.behaviors;
1122
+ for (let j = 0; j < stepBehaviors.length; j++) {
1123
+ stepBehaviors[j].frameUpdate(delta);
1124
+ }
1125
+ // Nothing to update: skip, matching what the per-particle loop did.
1126
+ // A behavior may set up state before its loop that is only valid
1127
+ // once it has seen a particle.
1128
+ if (particleCount === 0) {
1129
+ continue;
1130
+ }
1131
+ if (step.run !== undefined) {
1132
+ step.run(behaviors, particles, particleCount, delta);
1133
+ continue;
1134
+ }
1135
+ const behavior = stepBehaviors[0];
1136
+ if (behavior.updateAll !== undefined) {
1137
+ behavior.updateAll(particles, particleCount, delta);
1138
+ continue;
1139
+ }
899
1140
  for (let i = 0; i < particleCount; i++) {
900
1141
  const particle = particles[i];
901
- if (!particle.died) {
1142
+ // Inlined `died` — this runs once per behavior per particle.
1143
+ if (particle.age < particle.life) {
902
1144
  behavior.update(particle, delta);
903
1145
  }
904
1146
  }
905
1147
  }
906
1148
 
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);
917
- } else {
918
- particle.position.applyMatrix4(emitterMatrix);
1149
+ // Newborns were snapshotted above with spawn size/color (startSize /
1150
+ // startColor) before SizeOverLife / ColorOverLife ran. That false jump
1151
+ // is then extrapolated by the renderer when simulationResidual is
1152
+ // negative (common with rounded fixed-step) and flashes the start
1153
+ // appearance at the emitter. Collapse it so birth matches t=0 on the
1154
+ // curves. Age is still 0 here — integration below ages them.
1155
+ if (particleCount > 0) {
1156
+ const prevSize = columns.previousSize;
1157
+ const curSize = columns.size;
1158
+ const prevColor = columns.previousColor;
1159
+ const curColor = columns.color;
1160
+ const scalars = columns.scalars;
1161
+ const stride = ParticleStore.SCALAR_STRIDE;
1162
+ for (let i = 0, o3 = 0, o4 = 0, s = 0; i < particleCount; i++, o3 += 3, o4 += 4, s += stride) {
1163
+ if (scalars[s + ParticleStore.AGE] !== 0) {
1164
+ continue;
919
1165
  }
920
- } else {
921
- particle.position.addScaledVector(particle.velocity, delta * particle.speedModifier);
1166
+ prevSize[o3] = curSize[o3];
1167
+ prevSize[o3 + 1] = curSize[o3 + 1];
1168
+ prevSize[o3 + 2] = curSize[o3 + 2];
1169
+ prevColor[o4] = curColor[o4];
1170
+ prevColor[o4 + 1] = curColor[o4 + 1];
1171
+ prevColor[o4 + 2] = curColor[o4 + 2];
1172
+ prevColor[o4 + 3] = curColor[o4 + 3];
922
1173
  }
923
- particle.age += delta;
924
1174
  }
925
1175
 
926
- if (this.rendererSettings.renderMode === RenderMode.Trail) {
1176
+ const followLocalOrigin = isTrailMode && (this.rendererEmitterSettings as TrailSettings).followLocalOrigin;
1177
+ if (followLocalOrigin) {
1178
+ const emitterMatrix = this.emitter.matrixWorld;
927
1179
  for (let i = 0; i < particleCount; i++) {
928
- const trailParticle = particles[i] as TrailParticle;
929
- if (this.useFastTrailHistory) {
930
- this.updateFastTrailHistory(trailParticle);
1180
+ const particle = particles[i];
1181
+ if ((particle as TrailParticle).localPosition) {
1182
+ particle.position.copy((particle as TrailParticle).localPosition!);
1183
+ particle.position.applyMatrix4(particle.parentMatrix ?? emitterMatrix);
931
1184
  } else {
932
- trailParticle.update();
1185
+ particle.position.addScaledVector(particle.velocity, delta * particle.speedModifier);
1186
+ }
1187
+ particle.age += delta;
1188
+ }
1189
+ } else {
1190
+ // Live particles own rows [0, particleNum), so integrate straight
1191
+ // over the columns. Nothing in here touches a particle object.
1192
+ const positions = this.store.position;
1193
+ const velocities = this.store.velocity;
1194
+ const scalars = this.store.scalars;
1195
+ const stride = ParticleStore.SCALAR_STRIDE;
1196
+ for (let i = 0, offset = 0, scalar = 0; i < particleCount; i++, offset += 3, scalar += stride) {
1197
+ const step = delta * scalars[scalar + ParticleStore.SPEED_MODIFIER];
1198
+ positions[offset] += velocities[offset] * step;
1199
+ positions[offset + 1] += velocities[offset + 1] * step;
1200
+ positions[offset + 2] += velocities[offset + 2] * step;
1201
+ scalars[scalar + ParticleStore.AGE] += delta;
1202
+ }
1203
+ }
1204
+
1205
+ if (isTrailMode) {
1206
+ const trailSettings = this.rendererEmitterSettings as TrailSettings;
1207
+ const dieWithParticle = trailSettings.dieWithParticle;
1208
+ // A world-space system records world positions already.
1209
+ const recordWorldSpace = trailSettings.recordWorldSpace && !this.worldSpace;
1210
+ const emitterMatrix = this.emitter.matrixWorld;
1211
+ for (let i = 0; i < particleCount; i++) {
1212
+ const trail = particles[i] as TrailParticle;
1213
+ // Emptying the history lets the removal pass below take the
1214
+ // particle this same step.
1215
+ if (dieWithParticle && trail.age >= trail.life) {
1216
+ trail.resetHistory();
1217
+ continue;
1218
+ }
1219
+ trail.update();
1220
+ // update() wrote a sample only while the particle is alive.
1221
+ if (recordWorldSpace && trail.age <= trail.life) {
1222
+ const offset = ((trail.historyHead + trail.historyCapacity - 1) % trail.historyCapacity) * 3;
1223
+ const positions = trail.historyPositions;
1224
+ tempV.set(positions[offset], positions[offset + 1], positions[offset + 2]);
1225
+ tempV.applyMatrix4(trail.parentMatrix ?? emitterMatrix);
1226
+ positions[offset] = tempV.x;
1227
+ positions[offset + 1] = tempV.y;
1228
+ positions[offset + 2] = tempV.z;
933
1229
  }
934
1230
  }
935
1231
  }
936
1232
 
1233
+ const notifyDeaths = this.hasListeners('particleDied');
1234
+ const store = this.store;
1235
+ const scalars = store.scalars;
1236
+ const scalarStride = ParticleStore.SCALAR_STRIDE;
937
1237
  let liveParticleCount = this.particleNum;
938
1238
  for (let i = 0; i < liveParticleCount; i++) {
939
1239
  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;
1240
+ const scalar = i * scalarStride;
1241
+ const dead = scalars[scalar + ParticleStore.AGE] >= scalars[scalar + ParticleStore.LIFE];
1242
+ if (dead && (!isTrailMode || (particle as TrailParticle).historyCount === 0)) {
1243
+ const last = liveParticleCount - 1;
1244
+ const survivor = particles[last] as StoreBackedParticle;
1245
+ particles[i] = survivor;
1246
+ particles[last] = particle;
1247
+ // Move the rows along with the particles so slot k always owns
1248
+ // row k. Live particles then stay one contiguous range, which is
1249
+ // what lets the renderer copy them without gathering.
1250
+ if (i !== last) {
1251
+ store.swapRows(i, last);
1252
+ survivor.setStoreIndex(i);
1253
+ (particle as StoreBackedParticle).setStoreIndex(last);
1254
+ }
946
1255
  liveParticleCount--;
947
1256
  i--;
948
- this.fire({type: 'particleDied', particleSystem: this, particle: particle});
1257
+ if (notifyDeaths) {
1258
+ this.fire({type: 'particleDied', particleSystem: this, particle: particle});
1259
+ }
949
1260
  }
950
1261
  }
951
1262
  this.particleNum = liveParticleCount;
@@ -971,7 +1282,9 @@ export class ParticleSystem implements IParticleSystem {
971
1282
  if (this.looping) {
972
1283
  emissionState.time -= this.duration;
973
1284
  emissionState.burstIndex = 0;
974
- this.behaviors.forEach((behavior) => behavior.reset());
1285
+ for (let i = 0; i < this.behaviors.length; i++) {
1286
+ this.behaviors[i].reset();
1287
+ }
975
1288
  } else {
976
1289
  if (!this.emitEnded && !this.onlyUsedByOther) {
977
1290
  this.endEmit();
@@ -979,7 +1292,11 @@ export class ParticleSystem implements IParticleSystem {
979
1292
  }
980
1293
  }
981
1294
 
982
- this.normalMatrix.getNormalMatrix(emitterMatrix);
1295
+ // Only spawns in world space consume the normal matrix, and computing it
1296
+ // inverts a 4x4 — skip it for local-space systems.
1297
+ if (this.worldSpace) {
1298
+ this.normalMatrix.getNormalMatrix(emitterMatrix);
1299
+ }
983
1300
  const emissionBursts = this.emissionBursts;
984
1301
  const emissionBurstCount = emissionBursts.length;
985
1302
  const qualityFactor = this.qualityFactor;
@@ -1026,7 +1343,11 @@ export class ParticleSystem implements IParticleSystem {
1026
1343
  }
1027
1344
 
1028
1345
  if (emissionState.previousWorldPos === undefined) emissionState.previousWorldPos = new Vector3();
1029
- emissionState.previousWorldPos.set(emitterMatrix.elements[12], emitterMatrix.elements[13], emitterMatrix.elements[14]);
1346
+ emissionState.previousWorldPos.set(
1347
+ emitterMatrix.elements[12],
1348
+ emitterMatrix.elements[13],
1349
+ emitterMatrix.elements[14]
1350
+ );
1030
1351
  emissionState.time += delta;
1031
1352
  }
1032
1353
 
@@ -1034,15 +1355,15 @@ export class ParticleSystem implements IParticleSystem {
1034
1355
  const isRootObject = metaData === undefined || typeof metaData === 'string';
1035
1356
  const meta: BabylonMetaData = isRootObject
1036
1357
  ? {
1037
- geometries: {},
1038
- materials: {},
1039
- textures: {},
1040
- images: {},
1041
- shapes: {},
1042
- skeletons: {},
1043
- animations: {},
1044
- nodes: {},
1045
- }
1358
+ geometries: {},
1359
+ materials: {},
1360
+ textures: {},
1361
+ images: {},
1362
+ shapes: {},
1363
+ skeletons: {},
1364
+ animations: {},
1365
+ nodes: {},
1366
+ }
1046
1367
  : metaData;
1047
1368
 
1048
1369
  const geometryUUID = this.ensureGeometryMeta(meta);
@@ -1071,6 +1392,7 @@ export class ParticleSystem implements IParticleSystem {
1071
1392
  interval: burst.interval,
1072
1393
  cycle: burst.cycle,
1073
1394
  })),
1395
+ ...(this.maxParticles !== undefined ? {maxParticles: this.maxParticles} : {}),
1074
1396
  onlyUsedByOther: this.onlyUsedByOther,
1075
1397
  instancingGeometry: geometryUUID,
1076
1398
  renderMode: this.renderMode,
@@ -1092,6 +1414,8 @@ export class ParticleSystem implements IParticleSystem {
1092
1414
  alphaTest: this.rendererSettings.materialAlphaTest,
1093
1415
  behaviors: this.behaviors.map((b) => b.toJSON()),
1094
1416
  worldSpace: this.worldSpace,
1417
+ ...(this.pivot ? {pivot: [this.pivot.x, this.pivot.y, this.pivot.z]} : {}),
1418
+ ...(this.flip ? {flip: [this.flip.x, this.flip.y]} : {}),
1095
1419
  };
1096
1420
  }
1097
1421
 
@@ -1111,6 +1435,7 @@ export class ParticleSystem implements IParticleSystem {
1111
1435
  ? ValueGeneratorFromJSON(trailSettings.startLength)
1112
1436
  : new ConstantValue(30),
1113
1437
  followLocalOrigin: trailSettings?.followLocalOrigin ?? false,
1438
+ ...trailFlags(trailSettings),
1114
1439
  };
1115
1440
  } else if (json.renderMode === RenderMode.StretchedBillBoard) {
1116
1441
  rendererEmitterSettings = {...(json.rendererEmitterSettings ?? {})};
@@ -1125,8 +1450,8 @@ export class ParticleSystem implements IParticleSystem {
1125
1450
  const materialTextureRef = materialMeta?.texture ?? json.texture;
1126
1451
  const texture =
1127
1452
  typeof materialTextureRef === 'string'
1128
- ? meta.textures?.[materialTextureRef] ?? null
1129
- : materialTextureRef ?? null;
1453
+ ? (meta.textures?.[materialTextureRef] ?? null)
1454
+ : (materialTextureRef ?? null);
1130
1455
  const resolvedGeometryEntry =
1131
1456
  typeof json.instancingGeometry === 'string'
1132
1457
  ? meta.geometries?.[json.instancingGeometry]
@@ -1143,18 +1468,25 @@ export class ParticleSystem implements IParticleSystem {
1143
1468
  shape,
1144
1469
  startLife: ValueGeneratorFromJSON(json.startLife),
1145
1470
  startSpeed: ValueGeneratorFromJSON(json.startSpeed),
1146
- startRotation: GeneratorFromJSON(json.startRotation) as RotationGenerator | ValueGenerator | FunctionValueGenerator,
1471
+ startRotation: GeneratorFromJSON(json.startRotation) as
1472
+ | RotationGenerator
1473
+ | ValueGenerator
1474
+ | FunctionValueGenerator,
1147
1475
  startSize: GeneratorFromJSON(json.startSize) as Vector3Generator | ValueGenerator | FunctionValueGenerator,
1148
1476
  startColor: ColorGeneratorFromJSON(json.startColor) as ColorGenerator,
1149
1477
  emissionOverTime: ValueGeneratorFromJSON(json.emissionOverTime),
1150
1478
  emissionOverDistance: ValueGeneratorFromJSON(json.emissionOverDistance),
1151
1479
  emissionBursts: json.emissionBursts?.map((burst: any) => ({
1152
1480
  time: burst.time,
1153
- count: typeof burst.count === 'number' ? new ConstantValue(burst.count) : ValueGeneratorFromJSON(burst.count),
1481
+ count:
1482
+ typeof burst.count === 'number'
1483
+ ? new ConstantValue(burst.count)
1484
+ : ValueGeneratorFromJSON(burst.count),
1154
1485
  probability: burst.probability ?? 1,
1155
1486
  interval: burst.interval ?? 0.1,
1156
1487
  cycle: burst.cycle ?? burst.cycleCount ?? 1,
1157
1488
  })),
1489
+ maxParticles: typeof json.maxParticles === 'number' ? json.maxParticles : undefined,
1158
1490
  onlyUsedByOther: json.onlyUsedByOther,
1159
1491
  instancingGeometry: resolvedGeometry.positions,
1160
1492
  instancingIndices: resolvedGeometry.indices,
@@ -1183,6 +1515,8 @@ export class ParticleSystem implements IParticleSystem {
1183
1515
  behaviors: [],
1184
1516
  worldSpace: json.worldSpace,
1185
1517
  layerMask: json.layers,
1518
+ pivot: json.pivot ? new Vector3(json.pivot[0] ?? 0, json.pivot[1] ?? 0, json.pivot[2] ?? 0) : undefined,
1519
+ flip: json.flip ? new Vector2(json.flip[0] ?? 0, json.flip[1] ?? 0) : undefined,
1186
1520
  });
1187
1521
  ps.behaviors = (json.behaviors ?? [])
1188
1522
  .map((behaviorJson) => {
@@ -1216,6 +1550,7 @@ export class ParticleSystem implements IParticleSystem {
1216
1550
  return {
1217
1551
  startLength: (this.rendererEmitterSettings as TrailSettings).startLength.toJSON(),
1218
1552
  followLocalOrigin: (this.rendererEmitterSettings as TrailSettings).followLocalOrigin,
1553
+ ...trailFlags(this.rendererEmitterSettings as TrailSettings),
1219
1554
  };
1220
1555
  }
1221
1556
  if (this.renderMode === RenderMode.StretchedBillBoard) {
@@ -1235,7 +1570,9 @@ export class ParticleSystem implements IParticleSystem {
1235
1570
  positions: Array.from(this.rendererSettings.instancingGeometry),
1236
1571
  indices: Array.from(this.rendererSettings.instancingIndices),
1237
1572
  uvs: this.rendererSettings.instancingUVs ? Array.from(this.rendererSettings.instancingUVs) : undefined,
1238
- normals: this.rendererSettings.instancingNormals ? Array.from(this.rendererSettings.instancingNormals) : undefined,
1573
+ normals: this.rendererSettings.instancingNormals
1574
+ ? Array.from(this.rendererSettings.instancingNormals)
1575
+ : undefined,
1239
1576
  };
1240
1577
  return geometryUUID;
1241
1578
  }
@@ -1248,6 +1585,13 @@ export class ParticleSystem implements IParticleSystem {
1248
1585
  meta.textures[textureUUID] = texture;
1249
1586
  }
1250
1587
 
1588
+ const reflectionAtlas = this.rendererSettings.reflectionAtlas;
1589
+ let reflectionAtlasUUID: string | undefined;
1590
+ if (reflectionAtlas instanceof Texture) {
1591
+ reflectionAtlasUUID = ParticleSystem.nextSerializationId('quarks_texture');
1592
+ meta.textures[reflectionAtlasUUID] = reflectionAtlas;
1593
+ }
1594
+
1251
1595
  const materialUUID = ParticleSystem.nextSerializationId('quarks_material');
1252
1596
  meta.materials[materialUUID] = {
1253
1597
  uuid: materialUUID,
@@ -1258,6 +1602,8 @@ export class ParticleSystem implements IParticleSystem {
1258
1602
  depthWrite: this.rendererSettings.materialDepthWrite,
1259
1603
  alphaTest: this.rendererSettings.materialAlphaTest,
1260
1604
  texture: textureUUID,
1605
+ reflectionAtlas: reflectionAtlasUUID,
1606
+ reflectionLevel: this.rendererSettings.reflectionLevel,
1261
1607
  sourceMaterial: this.materialRef ?? undefined,
1262
1608
  };
1263
1609
  return materialUUID;
@@ -1319,7 +1665,8 @@ export class ParticleSystem implements IParticleSystem {
1319
1665
 
1320
1666
  addBehavior(behavior: Behavior) {
1321
1667
  this.behaviors.push(behavior);
1322
- this.refreshTrailHistoryMode();
1668
+ // A sub emitter added here changes which system has to update first.
1669
+ this._renderer?._invalidateUpdateOrder();
1323
1670
  }
1324
1671
 
1325
1672
  getRendererSettings(): VFXBatchSettings {
@@ -1342,9 +1689,19 @@ export class ParticleSystem implements IParticleSystem {
1342
1689
  }
1343
1690
  }
1344
1691
 
1692
+ /** True when at least one callback is registered for the event type. */
1693
+ private hasListeners(event: ParticleSystemEventType): boolean {
1694
+ const callbacks = this.listeners[event];
1695
+ return callbacks !== undefined && callbacks.length > 0;
1696
+ }
1697
+
1345
1698
  private fire(event: ParticleSystemEvent) {
1346
- if (this.listeners[event.type]) {
1347
- this.listeners[event.type].forEach((callback) => callback(event));
1699
+ const callbacks = this.listeners[event.type];
1700
+ if (callbacks === undefined) {
1701
+ return;
1702
+ }
1703
+ for (let i = 0; i < callbacks.length; i++) {
1704
+ callbacks[i](event);
1348
1705
  }
1349
1706
  }
1350
1707
 
@@ -1356,6 +1713,7 @@ export class ParticleSystem implements IParticleSystem {
1356
1713
  rendererEmitterSettings = {
1357
1714
  startLength: (this.rendererEmitterSettings as TrailSettings).startLength.clone(),
1358
1715
  followLocalOrigin: (this.rendererEmitterSettings as TrailSettings).followLocalOrigin,
1716
+ ...trailFlags(this.rendererEmitterSettings as TrailSettings),
1359
1717
  };
1360
1718
  } else if (this.renderMode === RenderMode.StretchedBillBoard) {
1361
1719
  rendererEmitterSettings = {
@@ -1381,6 +1739,7 @@ export class ParticleSystem implements IParticleSystem {
1381
1739
  emissionOverTime: this.emissionOverTime.clone(),
1382
1740
  emissionOverDistance: this.emissionOverDistance.clone(),
1383
1741
  emissionBursts: this.emissionBursts.map((b) => ({...b})),
1742
+ maxParticles: this.maxParticles,
1384
1743
  onlyUsedByOther: this.onlyUsedByOther,
1385
1744
  instancingGeometry: this.rendererSettings.instancingGeometry,
1386
1745
  instancingIndices: this.rendererSettings.instancingIndices,
@@ -1406,6 +1765,8 @@ export class ParticleSystem implements IParticleSystem {
1406
1765
  depthWrite: this.rendererSettings.materialDepthWrite,
1407
1766
  alphaTest: this.rendererSettings.materialAlphaTest,
1408
1767
  layerMask: this.rendererSettings.layerMask,
1768
+ pivot: this.pivot,
1769
+ flip: this.flip,
1409
1770
  scene: this.emitter.getScene(),
1410
1771
  });
1411
1772
  }