babylon.quarks 0.24.0 → 0.25.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.
@@ -53,7 +53,16 @@ import {BatchedRenderer, VFXBatchSettings} from './BatchedRenderer';
53
53
  import {ensureEnvAtlasFromCube, getCachedEnvAtlas} from './envAtlas';
54
54
  import {ensureTriangleIndices} from './geometryUtil';
55
55
  import {ParticleEmitter} from './ParticleEmitter';
56
- import {MeshLighting, MeshRim, normalizeMeshRim, RenderMode, SortMode} from './VFXBatch';
56
+ import {
57
+ MeshLighting,
58
+ MeshRim,
59
+ normalizeErosion,
60
+ normalizeMeshRim,
61
+ normalizeUVScroll,
62
+ ParticleErosion,
63
+ RenderMode,
64
+ SortMode,
65
+ } from './VFXBatch';
57
66
 
58
67
  export interface BurstParameters {
59
68
  time: number;
@@ -81,6 +90,27 @@ export interface BurstParameters {
81
90
  */
82
91
  export type CullingMode = 'always' | 'pause' | 'pauseAndCatchUp';
83
92
 
93
+ /**
94
+ * What the emitter's world scale does to a world-space system (Unity's
95
+ * Scaling Mode, as far as it differs here). A local-space system lives in its
96
+ * emitter's space, so its scale reaches everything either way.
97
+ *
98
+ * - `'shape'` (default): the scale sizes the emission shape and the
99
+ * particles; speeds, forces and speed limits stay in world units, so a
100
+ * scaled-down effect moves as far as the full-size one.
101
+ * - `'hierarchy'`: the scale also multiplies start speeds (per axis),
102
+ * `ForceOverLife` and `LimitSpeedOverLife` (by the mean of the axes), so the
103
+ * whole effect, motion included, scales like a model would.
104
+ */
105
+ export type ScalingMode = 'shape' | 'hierarchy';
106
+
107
+ /**
108
+ * Where a {@link RenderMode.Beam} system's quads end: a point in world space,
109
+ * or anything with a world position, such as a Babylon `TransformNode`, read
110
+ * every frame.
111
+ */
112
+ export type BeamEnd = {x: number; y: number; z: number} | {getAbsolutePosition(): {x: number; y: number; z: number}};
113
+
84
114
  /** Particle backed by a {@link ParticleStore} row, as this system always creates. */
85
115
  type StoreBackedParticle = Particle & {storeIndex: number; setStoreIndex(index: number): void};
86
116
 
@@ -199,10 +229,18 @@ export interface ParticleSystemParameters {
199
229
  lighting?: MeshLighting;
200
230
  /** Rim (Fresnel) term on mesh particles, see {@link ParticleSystem.rim}. Default none. */
201
231
  rim?: Partial<MeshRim>;
232
+ /** Map UV scroll in UV units per second, see {@link ParticleSystem.uvScroll}. Default none. */
233
+ uvScroll?: [number, number];
234
+ /** Colour ramp for the map, see {@link ParticleSystem.gradientMap}. Default none. */
235
+ gradientMap?: BaseTexture | null;
236
+ /** Alpha erosion, see {@link ParticleSystem.erosion}. Default none. */
237
+ erosion?: Partial<ParticleErosion>;
202
238
  /** Multiplier on the time the system is advanced by, see {@link ParticleSystem.playbackSpeed}. Default 1. */
203
239
  playbackSpeed?: number;
204
240
  /** What the system does while off screen, see {@link CullingMode}. Default `'always'`. */
205
241
  cullingMode?: CullingMode;
242
+ /** What the emitter's scale does to a world-space system, see {@link ScalingMode}. Default `'shape'`. */
243
+ scalingMode?: ScalingMode;
206
244
  /** Seed for this system's own random generator, see {@link ParticleSystem.seed}. Default none. */
207
245
  seed?: number;
208
246
  /** Draw order within the batch, see {@link ParticleSystem.sortMode}. Default `'none'`. */
@@ -258,6 +296,8 @@ export interface ParticleSystemJSONParameters {
258
296
  playbackSpeed?: number;
259
297
  /** Written only when not `'always'`. */
260
298
  cullingMode?: CullingMode;
299
+ /** Written only when not `'shape'`. */
300
+ scalingMode?: ScalingMode;
261
301
  /** Written only when set. */
262
302
  seed?: number;
263
303
  /** Written only when not `'none'`. */
@@ -587,6 +627,77 @@ export class ParticleSystem implements IParticleSystem {
587
627
  this.neededToUpdateRender = true;
588
628
  }
589
629
 
630
+ /**
631
+ * Scrolls the map's UV by `[u, v]` UV units per second on the renderer's
632
+ * clock: flowing energy, lava, a smear running along a mesh. The texture
633
+ * should wrap. Undefined (the default) or zero does not scroll. Assign a
634
+ * new array to change it. Saved with the material in the effect JSON.
635
+ */
636
+ get uvScroll(): [number, number] | undefined {
637
+ return this.rendererSettings.uvScroll;
638
+ }
639
+
640
+ set uvScroll(scroll: [number, number] | undefined) {
641
+ this.rendererSettings.uvScroll = normalizeUVScroll(scroll);
642
+ this.neededToUpdateRender = true;
643
+ }
644
+
645
+ /**
646
+ * Colour ramp the map is recoloured through (a gradient map): the map's
647
+ * red channel picks a colour along U of this texture, whose alpha
648
+ * multiplies the map's; the particle colour still tints the result. One
649
+ * greyscale texture then serves every palette. Null (the default): none.
650
+ * Saved with the material in the effect JSON.
651
+ */
652
+ get gradientMap(): BaseTexture | null {
653
+ return this.rendererSettings.gradientMap ?? null;
654
+ }
655
+
656
+ set gradientMap(texture: BaseTexture | null) {
657
+ this.rendererSettings.gradientMap = texture ?? null;
658
+ this.neededToUpdateRender = true;
659
+ }
660
+
661
+ /**
662
+ * Alpha erosion, see {@link ParticleErosion}: the particle's alpha decides
663
+ * how much of the shape is left rather than how transparent it is, so a
664
+ * `ColorOverLife` alpha curve dissolves the shape through a noise mask or
665
+ * wipes it through a gradient one — and an effect told to stop can fade
666
+ * out that way. Undefined (the default): alpha is plain transparency.
667
+ * Fields left out take their defaults; assign a new object to change it.
668
+ * Saved with the material in the effect JSON.
669
+ */
670
+ get erosion(): ParticleErosion | undefined {
671
+ return this.rendererSettings.erosion;
672
+ }
673
+
674
+ set erosion(erosion: Partial<ParticleErosion> | undefined) {
675
+ this.rendererSettings.erosion = normalizeErosion(erosion);
676
+ this.neededToUpdateRender = true;
677
+ }
678
+
679
+ /**
680
+ * The emitter's world scale per axis, measured with {@link motionScale}
681
+ * before every step; what `'hierarchy'` multiplies start speeds by. Taken
682
+ * from the system's own emitter rather than the emission matrix, whose
683
+ * columns carry a parent particle's velocity for a sub emitter.
684
+ */
685
+ private readonly hierarchyScale = new Vector3(1, 1, 1);
686
+
687
+ /** The mean world scale of the emitter for a world-space `'hierarchy'` system, else 1. */
688
+ private measureMotionScale(): number {
689
+ if (this.scalingMode !== 'hierarchy' || !this.worldSpace) {
690
+ this.hierarchyScale.set(1, 1, 1);
691
+ return 1;
692
+ }
693
+ const e = this.emitter.matrixWorld.elements;
694
+ const sx = Math.sqrt(e[0] * e[0] + e[1] * e[1] + e[2] * e[2]);
695
+ const sy = Math.sqrt(e[4] * e[4] + e[5] * e[5] + e[6] * e[6]);
696
+ const sz = Math.sqrt(e[8] * e[8] + e[9] * e[9] + e[10] * e[10]);
697
+ this.hierarchyScale.set(sx, sy, sz);
698
+ return (sx + sy + sz) / 3;
699
+ }
700
+
590
701
  /** `lighting` if it is one of the modes, else the default. */
591
702
  private static validLighting(lighting: unknown): MeshLighting {
592
703
  return lighting === 'unlit' || lighting === 'scene' ? lighting : 'fixed';
@@ -674,6 +785,7 @@ export class ParticleSystem implements IParticleSystem {
674
785
  case RenderMode.BillBoard:
675
786
  case RenderMode.VerticalBillBoard:
676
787
  case RenderMode.HorizontalBillBoard:
788
+ case RenderMode.Beam:
677
789
  this.rendererEmitterSettings = {};
678
790
  break;
679
791
  }
@@ -730,9 +842,28 @@ export class ParticleSystem implements IParticleSystem {
730
842
  */
731
843
  playbackSpeed = 1;
732
844
 
845
+ /**
846
+ * Runtime multiplier on how many particles the system emits: bursts,
847
+ * emission over time and over distance alike. Unlike
848
+ * {@link setQualityFactor}, which only ever thins an effect, it may go
849
+ * above 1 to make a stronger version of the same effect. The two multiply.
850
+ * 1 by default; not saved in the effect JSON.
851
+ */
852
+ emissionMultiplier = 1;
853
+
733
854
  /** What this system does while off screen, see {@link CullingMode}. `'always'` by default. */
734
855
  cullingMode: CullingMode = 'always';
735
856
 
857
+ /** What the emitter's world scale does to a world-space system, see {@link ScalingMode}. `'shape'` by default. */
858
+ scalingMode: ScalingMode = 'shape';
859
+
860
+ /**
861
+ * What world-space forces and speed limits are multiplied by this step:
862
+ * the mean of the emitter's world scale axes in `'hierarchy'` mode, 1
863
+ * otherwise. Updated before every simulation step.
864
+ */
865
+ motionScale = 1;
866
+
736
867
  /**
737
868
  * Whether each particle starts turned along the direction its shape sends
738
869
  * it (Unity's Align To Direction): a mesh particle's +Z is pointed along
@@ -880,10 +1011,14 @@ export class ParticleSystem implements IParticleSystem {
880
1011
  applyFog: parameters.applyFog ?? true,
881
1012
  lighting: ParticleSystem.validLighting(parameters.lighting),
882
1013
  rim: normalizeMeshRim(parameters.rim),
1014
+ uvScroll: normalizeUVScroll(parameters.uvScroll),
1015
+ gradientMap: parameters.gradientMap ?? null,
1016
+ erosion: normalizeErosion(parameters.erosion),
883
1017
  };
884
1018
  this.flip = ParticleSystem.nonZeroFlip(parameters.flip);
885
1019
  this.playbackSpeed = parameters.playbackSpeed ?? 1;
886
1020
  this.cullingMode = parameters.cullingMode ?? 'always';
1021
+ this.scalingMode = parameters.scalingMode === 'hierarchy' ? 'hierarchy' : 'shape';
887
1022
  this.alignToDirection = parameters.alignToDirection === true;
888
1023
  this.seed = parameters.seed;
889
1024
  if (this.rendererSettings.renderMode === RenderMode.Mesh && !this.rendererSettings.instancingNormals) {
@@ -1062,6 +1197,12 @@ export class ParticleSystem implements IParticleSystem {
1062
1197
  this.qualityFactor = Math.max(0.1, Math.min(1, qualityFactor));
1063
1198
  }
1064
1199
 
1200
+ /** Quality factor times {@link emissionMultiplier}: what emission counts are scaled by. */
1201
+ private emissionFactor(): number {
1202
+ const multiplier = this.emissionMultiplier;
1203
+ return multiplier >= 0 ? this.qualityFactor * multiplier : this.qualityFactor;
1204
+ }
1205
+
1065
1206
  /**
1066
1207
  * Appends one particle to the pool, giving it the next row of the store.
1067
1208
  *
@@ -1212,7 +1353,12 @@ export class ParticleSystem implements IParticleSystem {
1212
1353
  particle.position.applyMatrix4(matrix);
1213
1354
  particle.startSize.multiply(scale).abs();
1214
1355
  particle.size.copy(particle.startSize);
1215
- particle.velocity.multiply(scale).applyMatrix3(this.normalMatrix);
1356
+ if (this.scalingMode === 'hierarchy') {
1357
+ // Scaled per axis by the emitter's world scale, then turned: the speed scales with it.
1358
+ particle.velocity.multiply(this.hierarchyScale).applyQuaternion(tempQ);
1359
+ } else {
1360
+ particle.velocity.multiply(scale).applyMatrix3(this.normalMatrix);
1361
+ }
1216
1362
  if (particle.rotation && particle.rotation instanceof Quaternion) {
1217
1363
  particle.rotation.multiplyQuaternions(tempQ, particle.rotation);
1218
1364
  }
@@ -1514,6 +1660,63 @@ export class ParticleSystem implements IParticleSystem {
1514
1660
  * frame behind the display.
1515
1661
  */
1516
1662
  getWorldBounds(min: Vector3, max: Vector3): boolean {
1663
+ if (!this.measureWorldBounds(min, max)) {
1664
+ return false;
1665
+ }
1666
+ if (this.rendererSettings.renderMode === RenderMode.Beam) {
1667
+ // The quads reach the beam's end, which moves on its own. Without
1668
+ // one a beam draws nothing beyond its particles.
1669
+ const end = this.beamEndScratch;
1670
+ if (!this.getBeamEnd(end)) {
1671
+ return true;
1672
+ }
1673
+ const pad = this.boundsInWorld ? this.boundsPadding : this.boundsPadding * this.emitterMaxScale();
1674
+ min.set(Math.min(min.x, end.x - pad), Math.min(min.y, end.y - pad), Math.min(min.z, end.z - pad));
1675
+ max.set(Math.max(max.x, end.x + pad), Math.max(max.y, end.y + pad), Math.max(max.z, end.z + pad));
1676
+ }
1677
+ return true;
1678
+ }
1679
+
1680
+ /**
1681
+ * Where a {@link RenderMode.Beam} system's quads end, see {@link BeamEnd}.
1682
+ * Unset (the default), a beam has no length and draws nothing. Not saved
1683
+ * in the effect JSON: it is where this instance is aimed at runtime.
1684
+ */
1685
+ beamEnd?: BeamEnd;
1686
+
1687
+ private readonly beamEndScratch = new Vector3();
1688
+
1689
+ /** The world position of {@link beamEnd} into `out`; false when it is unset. */
1690
+ getBeamEnd(out: Vector3): boolean {
1691
+ const end = this.beamEnd;
1692
+ if (end === undefined) {
1693
+ return false;
1694
+ }
1695
+ const node = end as {getAbsolutePosition?: () => {x: number; y: number; z: number}};
1696
+ if (typeof node.getAbsolutePosition === 'function') {
1697
+ const position = node.getAbsolutePosition();
1698
+ out.set(position.x, position.y, position.z);
1699
+ } else {
1700
+ // Any point: quarks' Vector3, or a Babylon one passed from plain JavaScript.
1701
+ const point = end as {x: number; y: number; z: number};
1702
+ out.set(point.x, point.y, point.z);
1703
+ }
1704
+ return true;
1705
+ }
1706
+
1707
+ /** The largest axis of the emitter's world scale. */
1708
+ private emitterMaxScale(): number {
1709
+ const e = this.emitter.matrixWorld.elements;
1710
+ return Math.sqrt(
1711
+ Math.max(
1712
+ e[0] * e[0] + e[1] * e[1] + e[2] * e[2],
1713
+ e[4] * e[4] + e[5] * e[5] + e[6] * e[6],
1714
+ e[8] * e[8] + e[9] * e[9] + e[10] * e[10]
1715
+ )
1716
+ );
1717
+ }
1718
+
1719
+ private measureWorldBounds(min: Vector3, max: Vector3): boolean {
1517
1720
  if (!this.boundsValid) {
1518
1721
  return false;
1519
1722
  }
@@ -1689,6 +1892,7 @@ export class ParticleSystem implements IParticleSystem {
1689
1892
  }
1690
1893
 
1691
1894
  private stepOnce(delta: number): void {
1895
+ this.motionScale = this.measureMotionScale();
1692
1896
  if (!this.onlyUsedByOther) {
1693
1897
  this.emit(delta, this.emissionState, this.emitter.matrixWorld);
1694
1898
  }
@@ -2060,8 +2264,9 @@ export class ParticleSystem implements IParticleSystem {
2060
2264
  return;
2061
2265
  }
2062
2266
  const rawCount = burst.count.genValue(this.memory, this.time);
2063
- const qualityFactor = this.qualityFactor;
2064
- const count = qualityFactor >= 0.999 ? rawCount : Math.floor(rawCount * qualityFactor);
2267
+ const factor = this.emissionFactor();
2268
+ // Rounded at random, so a fraction is not always lost: three at 1.5 is four or five, on average 4.5.
2269
+ const count = Math.abs(factor - 1) < 0.001 ? rawCount : Math.floor(rawCount * factor + random());
2065
2270
  emissionState.isBursting = true;
2066
2271
  emissionState.burstParticleCount = count;
2067
2272
  this.spawn(count, emissionState, emitterMatrix);
@@ -2126,6 +2331,12 @@ export class ParticleSystem implements IParticleSystem {
2126
2331
  }
2127
2332
 
2128
2333
  emit(delta: number, emissionState: EmissionState, emitterMatrix: Matrix4) {
2334
+ // Ended before this step (endEmit, or a past step's end of duration):
2335
+ // no more bursts either. A looping system would otherwise keep firing
2336
+ // them every loop after endEmit and never finish. Only the system's
2337
+ // own emission: a sub emitter's bursts belong to its parent particles,
2338
+ // which still go off as they die after the effect was told to end.
2339
+ const endedBefore = this.emitEnded && emissionState === this.emissionState;
2129
2340
  if (emissionState.time > this.duration) {
2130
2341
  if (this.looping) {
2131
2342
  emissionState.time -= this.duration;
@@ -2149,9 +2360,9 @@ export class ParticleSystem implements IParticleSystem {
2149
2360
  this.normalMatrix.getNormalMatrix(emitterMatrix);
2150
2361
  }
2151
2362
  const emissionBurstCount = this.emissionBursts.length;
2152
- const qualityFactor = this.qualityFactor;
2363
+ const qualityFactor = this.emissionFactor();
2153
2364
 
2154
- if (emissionBurstCount > 0) {
2365
+ if (emissionBurstCount > 0 && !endedBefore) {
2155
2366
  this.emitBursts(emissionState, emitterMatrix);
2156
2367
  }
2157
2368
 
@@ -2265,6 +2476,7 @@ export class ParticleSystem implements IParticleSystem {
2265
2476
  ...(this.applyFog ? {} : {applyFog: false}),
2266
2477
  ...(this.playbackSpeed !== 1 ? {playbackSpeed: this.playbackSpeed} : {}),
2267
2478
  ...(this.cullingMode !== 'always' ? {cullingMode: this.cullingMode} : {}),
2479
+ ...(this.scalingMode !== 'shape' ? {scalingMode: this.scalingMode} : {}),
2268
2480
  ...(this.seedValue !== undefined ? {seed: this.seedValue} : {}),
2269
2481
  ...(this.sortMode !== 'none' ? {sortMode: this.sortMode} : {}),
2270
2482
  ...(this.alignToDirection ? {alignToDirection: true} : {}),
@@ -2325,6 +2537,10 @@ export class ParticleSystem implements IParticleSystem {
2325
2537
  typeof materialTextureRef === 'string'
2326
2538
  ? (meta.textures?.[materialTextureRef] ?? null)
2327
2539
  : (materialTextureRef ?? null);
2540
+ // Texture effects reference textures by uuid in a serialised meta, or hold them once a loader resolved them.
2541
+ const resolveTexture = (ref: unknown): BaseTexture | null =>
2542
+ typeof ref === 'string' ? (meta.textures?.[ref] ?? null) : ((ref as BaseTexture | null) ?? null);
2543
+ const erosionMeta = materialMeta?.erosion;
2328
2544
  const resolvedGeometryEntry =
2329
2545
  typeof json.instancingGeometry === 'string'
2330
2546
  ? meta.geometries?.[json.instancingGeometry]
@@ -2372,6 +2588,7 @@ export class ParticleSystem implements IParticleSystem {
2372
2588
  applyFog: json.applyFog !== false,
2373
2589
  playbackSpeed: typeof json.playbackSpeed === 'number' ? json.playbackSpeed : undefined,
2374
2590
  cullingMode: ParticleSystem.validCullingMode(json.cullingMode),
2591
+ scalingMode: json.scalingMode === 'hierarchy' ? 'hierarchy' : 'shape',
2375
2592
  seed: typeof json.seed === 'number' ? json.seed : undefined,
2376
2593
  sortMode: ParticleSystem.validSortMode(json.sortMode),
2377
2594
  alignToDirection: json.alignToDirection === true,
@@ -2401,6 +2618,12 @@ export class ParticleSystem implements IParticleSystem {
2401
2618
  flip: json.flip ? new Vector2(json.flip[0] ?? 0, json.flip[1] ?? 0) : undefined,
2402
2619
  lighting: materialMeta?.lighting,
2403
2620
  rim: materialMeta?.rim,
2621
+ uvScroll: materialMeta?.uvScroll,
2622
+ gradientMap: resolveTexture(materialMeta?.gradientMap),
2623
+ erosion:
2624
+ erosionMeta && typeof erosionMeta === 'object'
2625
+ ? {...erosionMeta, mask: resolveTexture(erosionMeta.mask)}
2626
+ : undefined,
2404
2627
  });
2405
2628
  ps.behaviors = (json.behaviors ?? [])
2406
2629
  .map((behaviorJson) => {
@@ -2476,6 +2699,16 @@ export class ParticleSystem implements IParticleSystem {
2476
2699
  meta.textures[reflectionAtlasUUID] = reflectionAtlas;
2477
2700
  }
2478
2701
 
2702
+ const textureRef = (value: BaseTexture | null | undefined): string | undefined => {
2703
+ if (!(value instanceof Texture)) {
2704
+ return undefined;
2705
+ }
2706
+ const uuid = ParticleSystem.nextSerializationId('quarks_texture');
2707
+ meta.textures[uuid] = value;
2708
+ return uuid;
2709
+ };
2710
+ const erosion = this.erosion;
2711
+
2479
2712
  const materialUUID = ParticleSystem.nextSerializationId('quarks_material');
2480
2713
  meta.materials[materialUUID] = {
2481
2714
  uuid: materialUUID,
@@ -2491,6 +2724,19 @@ export class ParticleSystem implements IParticleSystem {
2491
2724
  // Written only when not the default, so existing effects serialise as before.
2492
2725
  ...(this.lighting !== 'fixed' ? {lighting: this.lighting} : {}),
2493
2726
  ...(this.rim ? {rim: {...this.rim, color: [...this.rim.color]}} : {}),
2727
+ ...(this.uvScroll ? {uvScroll: [...this.uvScroll]} : {}),
2728
+ ...(this.gradientMap ? {gradientMap: textureRef(this.gradientMap)} : {}),
2729
+ ...(erosion
2730
+ ? {
2731
+ erosion: {
2732
+ mask: textureRef(erosion.mask),
2733
+ softness: erosion.softness,
2734
+ edgeWidth: erosion.edgeWidth,
2735
+ edgeColor: [...erosion.edgeColor],
2736
+ maskScroll: [...erosion.maskScroll],
2737
+ },
2738
+ }
2739
+ : {}),
2494
2740
  sourceMaterial: this.materialRef ?? undefined,
2495
2741
  };
2496
2742
  return materialUUID;
@@ -2638,6 +2884,7 @@ export class ParticleSystem implements IParticleSystem {
2638
2884
  applyFog: this.applyFog,
2639
2885
  playbackSpeed: this.playbackSpeed,
2640
2886
  cullingMode: this.cullingMode,
2887
+ scalingMode: this.scalingMode,
2641
2888
  seed: this.seedValue,
2642
2889
  sortMode: this.sortMode,
2643
2890
  alignToDirection: this.alignToDirection,
@@ -2665,6 +2912,9 @@ export class ParticleSystem implements IParticleSystem {
2665
2912
  flip: this.flip,
2666
2913
  lighting: this.lighting,
2667
2914
  rim: this.rim,
2915
+ uvScroll: this.uvScroll,
2916
+ gradientMap: this.gradientMap,
2917
+ erosion: this.erosion,
2668
2918
  scene: this.emitter.getScene(),
2669
2919
  });
2670
2920
  // A sub emitter behavior transforms by its parent system's emitter;
@@ -824,6 +824,20 @@ export class QuarksLoader {
824
824
  if (matDef.rim && typeof matDef.rim === 'object') {
825
825
  matInfo.rim = matDef.rim;
826
826
  }
827
+ // Texture effects (QuarksMaterial): uvScroll [u, v], gradientMap texture uuid,
828
+ // erosion {mask (texture uuid), softness, edgeWidth, edgeColor, maskScroll}.
829
+ if (Array.isArray(matDef.uvScroll)) {
830
+ matInfo.uvScroll = matDef.uvScroll;
831
+ }
832
+ if (matDef.gradientMap) {
833
+ matInfo.gradientMap = meta.textures[matDef.gradientMap] || null;
834
+ }
835
+ if (matDef.erosion && typeof matDef.erosion === 'object') {
836
+ matInfo.erosion = {
837
+ ...matDef.erosion,
838
+ mask: matDef.erosion.mask ? meta.textures[matDef.erosion.mask] || null : null,
839
+ };
840
+ }
827
841
 
828
842
  // QuarksMaterial (what ParticleSystem.ensureMaterialMeta emits) writes `alphaMode`
829
843
  // directly as a Babylon alpha-mode constant; three.js materials (MeshBasicMaterial
package/src/QuarksUtil.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import {TransformNode} from '@babylonjs/core/Meshes/transformNode';
2
2
  import {Node} from '@babylonjs/core/node';
3
- import {EmitSubParticleSystem} from 'quarks.core';
3
+ import {ConstantValue, EmitSubParticleSystem, FunctionValueGenerator, IntervalValue, ValueGenerator} from 'quarks.core';
4
4
  import {BatchedRenderer} from './BatchedRenderer';
5
5
  import {ParticleEmitter} from './ParticleEmitter';
6
6
  import {ParticleSystem} from './ParticleSystem';
@@ -120,6 +120,102 @@ export class QuarksUtil {
120
120
  return copy;
121
121
  }
122
122
 
123
+ /**
124
+ * Seconds until the effect under `root` is over when it plays from the
125
+ * start: for each system its start delay, its duration and its longest
126
+ * particle life, divided by its playback speed; a sub emitter target goes
127
+ * on emitting for its own duration after the last of its parents' particles
128
+ * and ends a particle life after that, wherever it sits in the scene.
129
+ * Infinity when a system loops, since such an effect only ends on
130
+ * {@link endEmit}, or when one is frozen at playback speed 0.
131
+ *
132
+ * Random values count at their maximum, so this is an upper bound; curves
133
+ * are sampled. Particles cut short by collisions or culling only make the
134
+ * real effect shorter.
135
+ */
136
+ static getDuration(root: Node): number {
137
+ const systems: ParticleSystem[] = [];
138
+ QuarksUtil.runOnAllParticleEmitters(root, (emitter) => {
139
+ systems.push(emitter.system as unknown as ParticleSystem);
140
+ });
141
+ const parents = new Map<ParticleSystem, ParticleSystem[]>();
142
+ // Grows while it is walked: a target outside `root` is part of the effect too.
143
+ for (let i = 0; i < systems.length; i++) {
144
+ const system = systems[i];
145
+ for (const behavior of system.behaviors) {
146
+ if (!(behavior instanceof EmitSubParticleSystem)) {
147
+ continue;
148
+ }
149
+ const target = (behavior.subParticleSystem as unknown as ParticleEmitter | undefined)?.system as
150
+ | ParticleSystem
151
+ | undefined;
152
+ if (target !== undefined && typeof target === 'object') {
153
+ if (!systems.includes(target)) {
154
+ systems.push(target);
155
+ }
156
+ const list = parents.get(target) ?? [];
157
+ list.push(system);
158
+ parents.set(target, list);
159
+ }
160
+ }
161
+ }
162
+ const ends = new Map<ParticleSystem, number>();
163
+ const endOf = (system: ParticleSystem): number => {
164
+ const known = ends.get(system);
165
+ if (known !== undefined) {
166
+ return known;
167
+ }
168
+ // Marks the system while it is resolved, so a sub emitter cycle loops forever rather than the stack.
169
+ ends.set(system, Infinity);
170
+ const speed = system.playbackSpeed;
171
+ const life = QuarksUtil.maxOf(system.startLife) / speed;
172
+ let end: number;
173
+ if (!(speed > 0)) {
174
+ end = Infinity;
175
+ } else if (system.onlyUsedByOther) {
176
+ end = 0;
177
+ for (const parent of parents.get(system) ?? []) {
178
+ end = Math.max(end, endOf(parent));
179
+ }
180
+ end += system.duration / speed + life;
181
+ } else if (system.looping) {
182
+ end = Infinity;
183
+ } else {
184
+ // The delay is drawn once, at the start.
185
+ end = (QuarksUtil.maxOf(system.startDelay, 0) + system.duration) / speed + life;
186
+ }
187
+ ends.set(system, end);
188
+ return end;
189
+ };
190
+ let duration = 0;
191
+ for (const system of systems) {
192
+ duration = Math.max(duration, endOf(system));
193
+ }
194
+ return duration;
195
+ }
196
+
197
+ /** The largest value `generator` gives: exact for constants and intervals, sampled otherwise. */
198
+ private static maxOf(generator: ValueGenerator | FunctionValueGenerator, at?: number): number {
199
+ if (generator instanceof ConstantValue) {
200
+ return generator.value;
201
+ }
202
+ if (generator instanceof IntervalValue) {
203
+ return Math.max(generator.a, generator.b);
204
+ }
205
+ let max = -Infinity;
206
+ const samples = 32;
207
+ for (let i = 0; i <= samples; i++) {
208
+ const memory: number[] = [];
209
+ generator.startGen(memory);
210
+ const value =
211
+ generator.type === 'function'
212
+ ? generator.genValue(memory, at ?? i / samples)
213
+ : generator.genValue(memory);
214
+ max = Math.max(max, value);
215
+ }
216
+ return max;
217
+ }
218
+
123
219
  private static traverseNode(node: Node, callback: (node: Node) => void): void {
124
220
  callback(node);
125
221
  const children = node.getChildren();