babylon.quarks 0.17.9 → 0.18.1

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.
@@ -823,7 +823,7 @@ interface IEmitter {
823
823
  visible: boolean;
824
824
  matrixWorld: any;
825
825
  }
826
- type ParticleSystemEventType = "emitEnd" | "destroy" | "particleDied";
826
+ type ParticleSystemEventType = "emitEnd" | "finished" | "destroy" | "particleDied";
827
827
  interface ParticleSystemEvent {
828
828
  type: ParticleSystemEventType;
829
829
  particleSystem: IParticleSystem;
@@ -877,6 +877,12 @@ interface IParticleSystem {
877
877
  rendererEmitterSettings: RendererEmitterSettings;
878
878
  emissionState: EmissionState;
879
879
  paused: boolean;
880
+ /** True after `endEmit()` — emission window closed (non-looping duration elapsed). */
881
+ readonly isEmitEnded: boolean;
882
+ /** Whether more particles can still spawn (bursts, rate, distance). */
883
+ hasPendingEmission(): boolean;
884
+ /** Non-looping system with no live particles and no pending emission. */
885
+ isFinished(): boolean;
880
886
  pause(): void;
881
887
  stop(): void;
882
888
  play(): void;
@@ -2382,6 +2388,16 @@ declare class BatchedRenderer extends TransformNode {
2382
2388
  getAdaptivePerformanceState(): AdaptivePerformanceState;
2383
2389
  private applyAdaptiveQuality;
2384
2390
  update(delta: number): void;
2391
+ /** Rebuild instance buffers from current particle state without advancing simulation. */
2392
+ refreshBatches(): void;
2393
+ /** Per-batch particle vs GPU instance counts — for diagnosing stale render state. */
2394
+ getBatchRenderStats(): Array<{
2395
+ batchIndex: number;
2396
+ renderMode: RenderMode;
2397
+ instanceCount: number;
2398
+ particleCount: number;
2399
+ systemNames: string[];
2400
+ }>;
2385
2401
  dispose(): void;
2386
2402
  }
2387
2403
 
@@ -2415,6 +2431,8 @@ declare class SpriteBatch extends VFXBatch {
2415
2431
  private rotationMat2_;
2416
2432
  private lastStretchedSpeedFactor;
2417
2433
  update(): void;
2434
+ /** Pushes a zero-alpha instance so stale instancing data cannot linger on screen. */
2435
+ private uploadClearedInstance;
2418
2436
  dispose(): void;
2419
2437
  }
2420
2438
 
@@ -2615,6 +2633,7 @@ declare class ParticleSystem implements IParticleSystem {
2615
2633
  private prewarmed;
2616
2634
  private emitEnded;
2617
2635
  private markForDestroy;
2636
+ private finishedEventFired;
2618
2637
  private startDelayTimeLeft;
2619
2638
  private previousEmitterPos?;
2620
2639
  private tempEmitterPos;
@@ -2626,10 +2645,17 @@ declare class ParticleSystem implements IParticleSystem {
2626
2645
  private materialRef;
2627
2646
  private qualityFactor;
2628
2647
  private useFastTrailHistory;
2648
+ private simulationAccumulator;
2649
+ private destroyed;
2629
2650
  /** @internal **/
2630
2651
  _renderer?: BatchedRenderer;
2631
2652
  set time(time: number);
2632
2653
  get time(): number;
2654
+ get isEmitEnded(): boolean;
2655
+ /** Whether continuous or queued emission can still spawn particles before the system is done. */
2656
+ hasPendingEmission(): boolean;
2657
+ /** Non-looping system with no particles left and nothing left to emit. */
2658
+ isFinished(): boolean;
2633
2659
  get layers(): {
2634
2660
  mask: number;
2635
2661
  };
@@ -2675,6 +2701,9 @@ declare class ParticleSystem implements IParticleSystem {
2675
2701
  private firstTimeUpdate;
2676
2702
  /** @internal */
2677
2703
  update(delta: number): void;
2704
+ /** Advances emission, behaviors, motion and culling by one fixed timestep. */
2705
+ private simulateStep;
2706
+ private notifyFinished;
2678
2707
  emit(delta: number, emissionState: EmissionState, emitterMatrix: Matrix4): void;
2679
2708
  toJSON(metaData?: BabylonMetaData | string, _options?: SerializationOptions): ParticleSystemJSONParameters;
2680
2709
  static fromJSON(json: ParticleSystemJSONParameters, meta: BabylonMetaData, dependencies?: {
@@ -2704,6 +2733,8 @@ declare class QuarksUtil {
2704
2733
  static restart(root: Node): void;
2705
2734
  static setAutoDestroy(root: Node, autoDestroy: boolean): void;
2706
2735
  static endEmit(root: Node): void;
2736
+ /** True when every emitter under `root` is a finished non-looping system (or looping). */
2737
+ static isEffectFinished(root: Node): boolean;
2707
2738
  private static traverseNode;
2708
2739
  }
2709
2740
 
@@ -2722,6 +2753,13 @@ declare class QuarksLoader {
2722
2753
  private extractInterleavedAttribute;
2723
2754
  private int32ArrayToFloat32;
2724
2755
  private parseImages;
2756
+ /**
2757
+ * `img.url` is already absolute for data/blob URIs and full http(s) URLs — common for
2758
+ * "portable" exports that embed textures as base64 (see ensurePortableTextureUrl in
2759
+ * babylon.quarks-editor). Prepending baseUrl to those turns e.g.
2760
+ * "data:image/png;base64,AAAA" into an invalid path like "/some/dir/data:image/png;base64,AAAA".
2761
+ */
2762
+ private resolveImageUrl;
2725
2763
  private parseTextures;
2726
2764
  private mapWrapMode;
2727
2765
  private parseMaterials;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "babylon.quarks",
3
- "version": "0.17.9",
3
+ "version": "0.18.1",
4
4
  "description": "A High-Performance Particle System for Babylon.js",
5
5
  "type": "module",
6
6
  "types": "./dist/types/index.d.ts",
@@ -194,9 +194,7 @@ export class BatchedRenderer extends TransformNode {
194
194
  for (const ps of this.systemToBatchIndex.keys()) {
195
195
  (ps as ParticleSystem).update(delta);
196
196
  }
197
- for (let i = 0; i < this.batches.length; i++) {
198
- this.batches[i].update();
199
- }
197
+ this.refreshBatches();
200
198
  if (!adaptiveState.enabled) {
201
199
  return;
202
200
  }
@@ -217,6 +215,39 @@ export class BatchedRenderer extends TransformNode {
217
215
  }
218
216
  }
219
217
 
218
+ /** Rebuild instance buffers from current particle state without advancing simulation. */
219
+ refreshBatches(): void {
220
+ for (let i = 0; i < this.batches.length; i++) {
221
+ this.batches[i].update();
222
+ }
223
+ }
224
+
225
+ /** Per-batch particle vs GPU instance counts — for diagnosing stale render state. */
226
+ getBatchRenderStats(): Array<{
227
+ batchIndex: number;
228
+ renderMode: RenderMode;
229
+ instanceCount: number;
230
+ particleCount: number;
231
+ systemNames: string[];
232
+ }> {
233
+ return this.batches.map((batch, batchIndex) => {
234
+ let particleCount = 0;
235
+ const systemNames: string[] = [];
236
+ for (const system of batch.systems) {
237
+ const ps = system as ParticleSystem;
238
+ particleCount += ps.particleNum;
239
+ systemNames.push(ps.emitter.name);
240
+ }
241
+ return {
242
+ batchIndex,
243
+ renderMode: batch.settings.renderMode,
244
+ instanceCount: batch.mesh.forcedInstanceCount ?? 0,
245
+ particleCount,
246
+ systemNames,
247
+ };
248
+ });
249
+ }
250
+
220
251
  dispose(): void {
221
252
  for (const batch of this.batches) {
222
253
  batch.dispose();
@@ -56,6 +56,8 @@ const tempQ = new Quaternion();
56
56
  const tempV = new Vector3();
57
57
  const tempV2 = new Vector3();
58
58
  const PREWARM_FPS = 60;
59
+ const SIMULATION_STEP = 1 / PREWARM_FPS;
60
+ const MAX_SIMULATION_STEPS_PER_FRAME = 8;
59
61
 
60
62
  const DEFAULT_POSITIONS = new Float32Array([
61
63
  -0.5, -0.5, 0,
@@ -205,6 +207,7 @@ export class ParticleSystem implements IParticleSystem {
205
207
  private prewarmed: boolean;
206
208
  private emitEnded: boolean;
207
209
  private markForDestroy: boolean;
210
+ private finishedEventFired = false;
208
211
  private startDelayTimeLeft = 0;
209
212
  private previousEmitterPos?: Vector3;
210
213
  private tempEmitterPos: Vector3 = new Vector3();
@@ -216,6 +219,8 @@ export class ParticleSystem implements IParticleSystem {
216
219
  private materialRef: any = null;
217
220
  private qualityFactor = 1;
218
221
  private useFastTrailHistory = true;
222
+ private simulationAccumulator = 0;
223
+ private destroyed = false;
219
224
  /** @internal **/
220
225
  _renderer?: BatchedRenderer;
221
226
 
@@ -227,6 +232,52 @@ export class ParticleSystem implements IParticleSystem {
227
232
  return this.emissionState.time;
228
233
  }
229
234
 
235
+ get isEmitEnded(): boolean {
236
+ return this.emitEnded;
237
+ }
238
+
239
+ /** Whether continuous or queued emission can still spawn particles before the system is done. */
240
+ hasPendingEmission(): boolean {
241
+ if (this.looping) {
242
+ return true;
243
+ }
244
+ if (this.emitEnded) {
245
+ return false;
246
+ }
247
+ if (this.onlyUsedByOther) {
248
+ return false;
249
+ }
250
+ const state = this.emissionState;
251
+ if (state.waitEmiting > 0) {
252
+ return true;
253
+ }
254
+ if (state.burstIndex < this.emissionBursts.length) {
255
+ return true;
256
+ }
257
+ if (state.time >= this.duration) {
258
+ return false;
259
+ }
260
+ const timeRatio = state.time / this.duration;
261
+ if (this.emissionOverTime.genValue(this.memory, timeRatio) > 0) {
262
+ return true;
263
+ }
264
+ return this.emissionOverDistance.genValue(this.memory, timeRatio) > 0;
265
+ }
266
+
267
+ /** Non-looping system with no particles left and nothing left to emit. */
268
+ isFinished(): boolean {
269
+ if (this.looping) {
270
+ return false;
271
+ }
272
+ if (this.particleNum > 0) {
273
+ return false;
274
+ }
275
+ if (this.onlyUsedByOther) {
276
+ return true;
277
+ }
278
+ return !this.hasPendingEmission();
279
+ }
280
+
230
281
  get layers() {
231
282
  return this.layerMaskProxy;
232
283
  }
@@ -737,6 +788,10 @@ export class ParticleSystem implements IParticleSystem {
737
788
  }
738
789
 
739
790
  dispose() {
791
+ if (this.destroyed) {
792
+ return;
793
+ }
794
+ this.destroyed = true;
740
795
  if (this._renderer) this._renderer.deleteSystem(this);
741
796
  this.emitter.dispose();
742
797
  this.fire({type: 'destroy', particleSystem: this});
@@ -754,6 +809,7 @@ export class ParticleSystem implements IParticleSystem {
754
809
  this.behaviors.forEach((behavior) => behavior.reset());
755
810
  this.emitEnded = false;
756
811
  this.markForDestroy = false;
812
+ this.finishedEventFired = false;
757
813
  this.prewarmed = false;
758
814
  this.emissionBursts.forEach((burst) => burst.count.startGen(this.memory));
759
815
  this.emissionOverDistance.startGen(this.memory);
@@ -761,6 +817,8 @@ export class ParticleSystem implements IParticleSystem {
761
817
  this.startDelayTimeLeft = this.startDelay.genValue(this.memory, 0);
762
818
  this.previousEmitterPos = undefined;
763
819
  this.emitterVelocity.set(0, 0, 0);
820
+ this.simulationAccumulator = 0;
821
+ this.destroyed = false;
764
822
  }
765
823
 
766
824
  private firstTimeUpdate = true;
@@ -774,8 +832,7 @@ export class ParticleSystem implements IParticleSystem {
774
832
  this.emitter.computeWorldMatrix(true);
775
833
  }
776
834
 
777
- if (this.emitEnded && this.particleNum === 0) {
778
- if (this.markForDestroy) this.dispose();
835
+ if (!this.looping && this.finishedEventFired && this.particleNum === 0) {
779
836
  return;
780
837
  }
781
838
 
@@ -815,6 +872,19 @@ export class ParticleSystem implements IParticleSystem {
815
872
  this.startDelayTimeLeft = 0;
816
873
  }
817
874
 
875
+ this.simulationAccumulator += delta;
876
+ const stepsToRun = Math.min(
877
+ MAX_SIMULATION_STEPS_PER_FRAME,
878
+ Math.floor((this.simulationAccumulator + 1e-9) / SIMULATION_STEP)
879
+ );
880
+ for (let i = 0; i < stepsToRun; i++) {
881
+ this.simulateStep(SIMULATION_STEP);
882
+ }
883
+ this.simulationAccumulator -= stepsToRun * SIMULATION_STEP;
884
+ }
885
+
886
+ /** Advances emission, behaviors, motion and culling by one fixed timestep. */
887
+ private simulateStep(delta: number): void {
818
888
  if (!this.onlyUsedByOther) {
819
889
  this.emit(delta, this.emissionState, this.emitter.matrixWorld);
820
890
  }
@@ -879,6 +949,21 @@ export class ParticleSystem implements IParticleSystem {
879
949
  }
880
950
  }
881
951
  this.particleNum = liveParticleCount;
952
+ this.notifyFinished();
953
+ }
954
+
955
+ private notifyFinished(): void {
956
+ if (!this.isFinished()) {
957
+ return;
958
+ }
959
+ if (!this.finishedEventFired) {
960
+ this.finishedEventFired = true;
961
+ this.fire({type: 'finished', particleSystem: this});
962
+ }
963
+ if (this.autoDestroy) {
964
+ this.markForDestroy = true;
965
+ this.dispose();
966
+ }
882
967
  }
883
968
 
884
969
  emit(delta: number, emissionState: EmissionState, emitterMatrix: Matrix4) {
@@ -899,12 +984,6 @@ export class ParticleSystem implements IParticleSystem {
899
984
  const emissionBurstCount = emissionBursts.length;
900
985
  const qualityFactor = this.qualityFactor;
901
986
 
902
- const totalSpawn = Math.floor(emissionState.waitEmiting);
903
- if (totalSpawn > 0) {
904
- this.spawn(totalSpawn, emissionState, emitterMatrix);
905
- emissionState.waitEmiting -= totalSpawn;
906
- }
907
-
908
987
  while (
909
988
  emissionState.burstIndex < emissionBurstCount &&
910
989
  emissionBursts[emissionState.burstIndex].time <= emissionState.time
@@ -937,6 +1016,15 @@ export class ParticleSystem implements IParticleSystem {
937
1016
  }
938
1017
  }
939
1018
  }
1019
+
1020
+ // Accrue before spawning so replacement particles can appear on the same
1021
+ // frame an old one dies (rate * lifetime ≈ 1 would otherwise flicker).
1022
+ const totalSpawn = Math.floor(emissionState.waitEmiting);
1023
+ if (totalSpawn > 0) {
1024
+ this.spawn(totalSpawn, emissionState, emitterMatrix);
1025
+ emissionState.waitEmiting -= totalSpawn;
1026
+ }
1027
+
940
1028
  if (emissionState.previousWorldPos === undefined) emissionState.previousWorldPos = new Vector3();
941
1029
  emissionState.previousWorldPos.set(emitterMatrix.elements[12], emitterMatrix.elements[13], emitterMatrix.elements[14]);
942
1030
  emissionState.time += delta;
@@ -255,10 +255,23 @@ export class QuarksLoader {
255
255
  private parseImages(images: any[], baseUrl: string, _meta: LoadedMeta): void {
256
256
  (this as any)._images = {};
257
257
  for (const img of images) {
258
- (this as any)._images[img.uuid] = img.url ? baseUrl + img.url : null;
258
+ (this as any)._images[img.uuid] = img.url ? this.resolveImageUrl(baseUrl, img.url) : null;
259
259
  }
260
260
  }
261
261
 
262
+ /**
263
+ * `img.url` is already absolute for data/blob URIs and full http(s) URLs — common for
264
+ * "portable" exports that embed textures as base64 (see ensurePortableTextureUrl in
265
+ * babylon.quarks-editor). Prepending baseUrl to those turns e.g.
266
+ * "data:image/png;base64,AAAA" into an invalid path like "/some/dir/data:image/png;base64,AAAA".
267
+ */
268
+ private resolveImageUrl(baseUrl: string, url: string): string {
269
+ if (/^(data|blob):/i.test(url) || /^[a-z][a-z0-9+.-]*:\/\//i.test(url) || url.startsWith('/')) {
270
+ return url;
271
+ }
272
+ return baseUrl + url;
273
+ }
274
+
262
275
  private parseTextures(textures: any[], meta: LoadedMeta): void {
263
276
  const images = (this as any)._images || {};
264
277
  for (const texDef of textures) {
package/src/QuarksUtil.ts CHANGED
@@ -56,6 +56,24 @@ export class QuarksUtil {
56
56
  });
57
57
  }
58
58
 
59
+ /** True when every emitter under `root` is a finished non-looping system (or looping). */
60
+ static isEffectFinished(root: Node): boolean {
61
+ let found = false;
62
+ let finished = true;
63
+ QuarksUtil.runOnAllParticleEmitters(root, (emitter) => {
64
+ found = true;
65
+ const system = emitter.system as unknown as ParticleSystem;
66
+ if (system.looping) {
67
+ finished = false;
68
+ return;
69
+ }
70
+ if (!system.isFinished()) {
71
+ finished = false;
72
+ }
73
+ });
74
+ return found && finished;
75
+ }
76
+
59
77
  private static traverseNode(node: Node, callback: (node: Node) => void): void {
60
78
  callback(node);
61
79
  const children = node.getChildren();
@@ -383,15 +383,38 @@ export class SpriteBatch extends VFXBatch {
383
383
  // single frame (common with short lifetimes + low emission overlap) causes flicker.
384
384
  this.mesh.forcedInstanceCount = index;
385
385
 
386
- if (index > 0) {
387
- this.offsetVB.update(this.offsetBuffer);
388
- this.sizeVB.update(this.sizeBuffer);
389
- this.colorVB.update(this.colorBuffer);
390
- this.uvTileVB.update(this.uvTileBuffer);
391
- this.rotationVB.update(this.rotationBuffer);
392
- if (isStretchedRender && this.velocityVB && this.velocityBuffer) {
393
- this.velocityVB.update(this.velocityBuffer);
394
- }
386
+ if (index === 0) {
387
+ this.uploadClearedInstance(isMeshRender, isStretchedRender);
388
+ return;
389
+ }
390
+
391
+ this.offsetVB.update(this.offsetBuffer);
392
+ this.sizeVB.update(this.sizeBuffer);
393
+ this.colorVB.update(this.colorBuffer);
394
+ this.uvTileVB.update(this.uvTileBuffer);
395
+ this.rotationVB.update(this.rotationBuffer);
396
+ if (isStretchedRender && this.velocityVB && this.velocityBuffer) {
397
+ this.velocityVB.update(this.velocityBuffer);
398
+ }
399
+ }
400
+
401
+ /** Pushes a zero-alpha instance so stale instancing data cannot linger on screen. */
402
+ private uploadClearedInstance(isMeshRender: boolean, isStretchedRender: boolean): void {
403
+ this.colorBuffer[3] = 0;
404
+ this.sizeBuffer[0] = 0;
405
+ this.sizeBuffer[1] = 0;
406
+ this.sizeBuffer[2] = 0;
407
+ this.colorVB.update(this.colorBuffer.subarray(0, 4));
408
+ this.sizeVB.update(this.sizeBuffer.subarray(0, 3));
409
+ this.offsetVB.update(this.offsetBuffer.subarray(0, 3));
410
+ this.uvTileVB.update(this.uvTileBuffer.subarray(0, 1));
411
+ if (isMeshRender) {
412
+ this.rotationVB.update(this.rotationBuffer.subarray(0, 4));
413
+ } else {
414
+ this.rotationVB.update(this.rotationBuffer.subarray(0, 1));
415
+ }
416
+ if (isStretchedRender && this.velocityVB && this.velocityBuffer) {
417
+ this.velocityVB.update(this.velocityBuffer.subarray(0, 4));
395
418
  }
396
419
  }
397
420