babylon.quarks 0.19.0 → 0.21.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "babylon.quarks",
3
- "version": "0.19.0",
3
+ "version": "0.21.0",
4
4
  "description": "A High-Performance Particle System for Babylon.js",
5
5
  "type": "module",
6
6
  "types": "./dist/types/index.d.ts",
@@ -2,7 +2,7 @@ import {BaseTexture} from '@babylonjs/core/Materials/Textures/baseTexture';
2
2
  import {Texture} from '@babylonjs/core/Materials/Textures/texture';
3
3
  import {TransformNode} from '@babylonjs/core/Meshes/transformNode';
4
4
  import {Scene} from '@babylonjs/core/scene';
5
- import {EmitSubParticleSystem, IParticleSystem} from 'quarks.core';
5
+ import {EmitSubParticleSystem, IParticleSystem, Vector3} from 'quarks.core';
6
6
  import {ParticleSystem} from './ParticleSystem';
7
7
  import {SpriteBatch} from './SpriteBatch';
8
8
  import {TrailBatch} from './TrailBatch';
@@ -47,6 +47,14 @@ export interface VFXBatchSettings {
47
47
  */
48
48
  reflectionAtlas: BaseTexture | null;
49
49
  layerMask: number;
50
+ /**
51
+ * Offset of billboard quads from their particle, in particle-size units
52
+ * (Unity's renderer Pivot). On billboards x/y move the quad in its own plane
53
+ * before it is rotated and z moves it toward the camera (up for horizontal
54
+ * billboards). On stretched billboards x runs from the head to the tail and
55
+ * y across, and z is unused. Absent or zero: no offset. Meshes ignore it.
56
+ */
57
+ pivot?: Vector3;
50
58
  }
51
59
 
52
60
  export interface AdaptivePerformanceOptions {
@@ -57,6 +65,11 @@ export interface AdaptivePerformanceOptions {
57
65
  increaseStep: number;
58
66
  }
59
67
 
68
+ /** True when both pivots put the quad in the same place; a missing pivot is zero. */
69
+ function pivotEqual(a: Vector3 | undefined, b: Vector3 | undefined): boolean {
70
+ return (a?.x ?? 0) === (b?.x ?? 0) && (a?.y ?? 0) === (b?.y ?? 0) && (a?.z ?? 0) === (b?.z ?? 0);
71
+ }
72
+
60
73
  /** True when both face lists are the same six texture references (or both empty). */
61
74
  function reflectionFacesEqual(a: BaseTexture[] | null | undefined, b: BaseTexture[] | null | undefined): boolean {
62
75
  if (a === b) {
@@ -147,7 +160,8 @@ export class BatchedRenderer extends TransformNode {
147
160
  a.vTileCount === b.vTileCount &&
148
161
  a.instancingGeometry === b.instancingGeometry &&
149
162
  a.renderOrder === b.renderOrder &&
150
- a.layerMask === b.layerMask
163
+ a.layerMask === b.layerMask &&
164
+ pivotEqual(a.pivot, b.pivot)
151
165
  );
152
166
  }
153
167
 
@@ -1,6 +1,6 @@
1
1
  import {VertexBuffer} from '@babylonjs/core/Buffers/buffer';
2
2
  import {Mesh} from '@babylonjs/core/Meshes/mesh';
3
- import {EmitterShape, IParticleSystem, Particle, Plugin, ShapeJSON, Vector3} from 'quarks.core';
3
+ import {EmitterShape, IParticleSystem, Particle, Plugin, ShapeJSON, ShapeTransform, Vector3} from 'quarks.core';
4
4
 
5
5
  export class MeshSurfaceEmitter implements EmitterShape {
6
6
  type = 'mesh_surface';
@@ -10,6 +10,8 @@ export class MeshSurfaceEmitter implements EmitterShape {
10
10
  private _positions?: Float32Array;
11
11
  private _indices?: Uint32Array | Int32Array;
12
12
  private _meshReferenceId?: string;
13
+ /** Offset, rotation and scale of the mesh within the particle system. */
14
+ transform?: ShapeTransform;
13
15
 
14
16
  get mesh() {
15
17
  return this._mesh;
@@ -53,8 +55,9 @@ export class MeshSurfaceEmitter implements EmitterShape {
53
55
  }
54
56
  }
55
57
 
56
- constructor(mesh?: Mesh, meshReferenceId?: string) {
58
+ constructor(mesh?: Mesh, meshReferenceId?: string, transform?: ShapeTransform) {
57
59
  this._meshReferenceId = meshReferenceId;
60
+ this.transform = transform;
58
61
  if (mesh) this.mesh = mesh;
59
62
  }
60
63
 
@@ -62,6 +65,7 @@ export class MeshSurfaceEmitter implements EmitterShape {
62
65
  if (!this._positions || !this._indices) {
63
66
  p.position.set(0, 0, 0);
64
67
  p.velocity.set(0, 0, 1).multiplyScalar(p.startSpeed);
68
+ this.transform?.apply(p);
65
69
  return;
66
70
  }
67
71
 
@@ -107,21 +111,23 @@ export class MeshSurfaceEmitter implements EmitterShape {
107
111
  const nz = bx * cy - by * cx;
108
112
  const len = Math.sqrt(nx * nx + ny * ny + nz * nz) || 1;
109
113
  p.velocity.set(nx / len, ny / len, nz / len).multiplyScalar(p.startSpeed);
114
+ this.transform?.apply(p);
110
115
  }
111
116
 
112
117
  toJSON(): ShapeJSON {
113
118
  return {
114
119
  type: 'mesh_surface',
115
120
  mesh: this._mesh ? (this._mesh as any).uniqueId?.toString() : (this._meshReferenceId ?? ''),
121
+ ...ShapeTransform.toShapeJSON(this.transform),
116
122
  };
117
123
  }
118
124
 
119
125
  static fromJSON(json: any): MeshSurfaceEmitter {
120
- return new MeshSurfaceEmitter(undefined, json?.mesh);
126
+ return new MeshSurfaceEmitter(undefined, json?.mesh, ShapeTransform.fromJSON(json?.transform));
121
127
  }
122
128
 
123
129
  clone(): EmitterShape {
124
- return new MeshSurfaceEmitter(this._mesh, this._meshReferenceId);
130
+ return new MeshSurfaceEmitter(this._mesh, this._meshReferenceId, this.transform?.clone());
125
131
  }
126
132
 
127
133
  update(_system: IParticleSystem, _delta: number): void {}
@@ -38,6 +38,7 @@ import {
38
38
  TrailSettings,
39
39
  ValueGenerator,
40
40
  ValueGeneratorFromJSON,
41
+ Vector2,
41
42
  Vector3,
42
43
  Vector3Generator,
43
44
  Vector4,
@@ -83,6 +84,16 @@ function fallbackUVsForPositions(positions: Float32Array): Float32Array {
83
84
  }
84
85
  const DEFAULT_INDICES = new Uint32Array([0, 1, 2, 0, 2, 3]);
85
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
+
86
97
  export interface ParticleSystemParameters {
87
98
  autoDestroy?: boolean;
88
99
  looping?: boolean;
@@ -99,6 +110,8 @@ export interface ParticleSystemParameters {
99
110
  emissionOverTime?: ValueGenerator | FunctionValueGenerator;
100
111
  emissionOverDistance?: ValueGenerator | FunctionValueGenerator;
101
112
  emissionBursts?: Array<BurstParameters>;
113
+ /** Most particles alive at once; emission past it is dropped. Leave unset for no limit. */
114
+ maxParticles?: number;
102
115
  onlyUsedByOther?: boolean;
103
116
  behaviors?: Array<Behavior>;
104
117
  instancingGeometry?: Float32Array;
@@ -125,6 +138,10 @@ export interface ParticleSystemParameters {
125
138
  depthWrite?: boolean;
126
139
  alphaTest?: number;
127
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;
128
145
  scene?: Scene;
129
146
  }
130
147
 
@@ -152,6 +169,7 @@ export interface ParticleSystemJSONParameters {
152
169
  emissionOverTime: FunctionJSON;
153
170
  emissionOverDistance: FunctionJSON;
154
171
  emissionBursts?: Array<BurstParametersJSON>;
172
+ maxParticles?: number;
155
173
  onlyUsedByOther: boolean;
156
174
  rendererEmitterSettings: RendererEmitterSettings;
157
175
  instancingGeometry?: any;
@@ -175,6 +193,10 @@ export interface ParticleSystemJSONParameters {
175
193
  alphaTest?: number;
176
194
  behaviors: Array<any>;
177
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[];
178
200
  }
179
201
 
180
202
  export interface BabylonMetaData {
@@ -203,9 +225,17 @@ export class ParticleSystem implements IParticleSystem {
203
225
  startColor: ColorGenerator | FunctionColorGenerator;
204
226
  startTileIndex: ValueGenerator;
205
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;
206
234
  emissionOverTime: ValueGenerator | FunctionValueGenerator;
207
235
  emissionOverDistance: ValueGenerator | FunctionValueGenerator;
208
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;
209
239
  onlyUsedByOther: boolean;
210
240
  worldSpace: boolean;
211
241
  particleNum: number;
@@ -356,6 +386,28 @@ export class ParticleSystem implements IParticleSystem {
356
386
  this.neededToUpdateRender = true;
357
387
  }
358
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
+
359
411
  get softNearFade() {
360
412
  return this.rendererSettings.softNearFade;
361
413
  }
@@ -468,6 +520,7 @@ export class ParticleSystem implements IParticleSystem {
468
520
  this.emissionOverTime = parameters.emissionOverTime ?? new ConstantValue(10);
469
521
  this.emissionOverDistance = parameters.emissionOverDistance ?? new ConstantValue(0);
470
522
  this.emissionBursts = parameters.emissionBursts ?? [];
523
+ this.maxParticles = parameters.maxParticles;
471
524
  this.onlyUsedByOther = parameters.onlyUsedByOther ?? false;
472
525
  this.emitterShape = parameters.shape ?? new SphereEmitter();
473
526
  this.behaviors = parameters.behaviors ?? [];
@@ -509,7 +562,9 @@ export class ParticleSystem implements IParticleSystem {
509
562
  reflectionFaces: null,
510
563
  reflectionAtlas: null,
511
564
  layerMask: parameters.layerMask ?? 0x0fffffff,
565
+ pivot: ParticleSystem.nonZeroPivot(parameters.pivot),
512
566
  };
567
+ this.flip = ParticleSystem.nonZeroFlip(parameters.flip);
513
568
  if (this.rendererSettings.renderMode === RenderMode.Mesh && !this.rendererSettings.instancingNormals) {
514
569
  this.rendererSettings.instancingNormals = ParticleSystem.createFallbackNormals(
515
570
  this.rendererSettings.instancingGeometry
@@ -724,6 +779,10 @@ export class ParticleSystem implements IParticleSystem {
724
779
  const timeRatio = emissionState.time / this.duration;
725
780
  const trailSettings = isTrailMode ? (this.rendererEmitterSettings as TrailSettings) : undefined;
726
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
+ }
727
786
 
728
787
  for (let i = 0; i < count; i++) {
729
788
  emissionState.burstParticleIndex = i;
@@ -795,6 +854,9 @@ export class ParticleSystem implements IParticleSystem {
795
854
  // Set until a turning behavior says otherwise, so the
796
855
  // renderer never carries a recycled particle's old spin.
797
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);
798
860
  }
799
861
  } else if (trailSettings) {
800
862
  const trail = particle as TrailParticle;
@@ -1084,6 +1146,33 @@ export class ParticleSystem implements IParticleSystem {
1084
1146
  }
1085
1147
  }
1086
1148
 
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;
1165
+ }
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];
1173
+ }
1174
+ }
1175
+
1087
1176
  const followLocalOrigin = isTrailMode && (this.rendererEmitterSettings as TrailSettings).followLocalOrigin;
1088
1177
  if (followLocalOrigin) {
1089
1178
  const emitterMatrix = this.emitter.matrixWorld;
@@ -1114,8 +1203,30 @@ export class ParticleSystem implements IParticleSystem {
1114
1203
  }
1115
1204
 
1116
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;
1117
1211
  for (let i = 0; i < particleCount; i++) {
1118
- (particles[i] as TrailParticle).update();
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;
1229
+ }
1119
1230
  }
1120
1231
  }
1121
1232
 
@@ -1281,6 +1392,7 @@ export class ParticleSystem implements IParticleSystem {
1281
1392
  interval: burst.interval,
1282
1393
  cycle: burst.cycle,
1283
1394
  })),
1395
+ ...(this.maxParticles !== undefined ? {maxParticles: this.maxParticles} : {}),
1284
1396
  onlyUsedByOther: this.onlyUsedByOther,
1285
1397
  instancingGeometry: geometryUUID,
1286
1398
  renderMode: this.renderMode,
@@ -1302,6 +1414,8 @@ export class ParticleSystem implements IParticleSystem {
1302
1414
  alphaTest: this.rendererSettings.materialAlphaTest,
1303
1415
  behaviors: this.behaviors.map((b) => b.toJSON()),
1304
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]} : {}),
1305
1419
  };
1306
1420
  }
1307
1421
 
@@ -1321,6 +1435,7 @@ export class ParticleSystem implements IParticleSystem {
1321
1435
  ? ValueGeneratorFromJSON(trailSettings.startLength)
1322
1436
  : new ConstantValue(30),
1323
1437
  followLocalOrigin: trailSettings?.followLocalOrigin ?? false,
1438
+ ...trailFlags(trailSettings),
1324
1439
  };
1325
1440
  } else if (json.renderMode === RenderMode.StretchedBillBoard) {
1326
1441
  rendererEmitterSettings = {...(json.rendererEmitterSettings ?? {})};
@@ -1371,6 +1486,7 @@ export class ParticleSystem implements IParticleSystem {
1371
1486
  interval: burst.interval ?? 0.1,
1372
1487
  cycle: burst.cycle ?? burst.cycleCount ?? 1,
1373
1488
  })),
1489
+ maxParticles: typeof json.maxParticles === 'number' ? json.maxParticles : undefined,
1374
1490
  onlyUsedByOther: json.onlyUsedByOther,
1375
1491
  instancingGeometry: resolvedGeometry.positions,
1376
1492
  instancingIndices: resolvedGeometry.indices,
@@ -1399,6 +1515,8 @@ export class ParticleSystem implements IParticleSystem {
1399
1515
  behaviors: [],
1400
1516
  worldSpace: json.worldSpace,
1401
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,
1402
1520
  });
1403
1521
  ps.behaviors = (json.behaviors ?? [])
1404
1522
  .map((behaviorJson) => {
@@ -1432,6 +1550,7 @@ export class ParticleSystem implements IParticleSystem {
1432
1550
  return {
1433
1551
  startLength: (this.rendererEmitterSettings as TrailSettings).startLength.toJSON(),
1434
1552
  followLocalOrigin: (this.rendererEmitterSettings as TrailSettings).followLocalOrigin,
1553
+ ...trailFlags(this.rendererEmitterSettings as TrailSettings),
1435
1554
  };
1436
1555
  }
1437
1556
  if (this.renderMode === RenderMode.StretchedBillBoard) {
@@ -1594,6 +1713,7 @@ export class ParticleSystem implements IParticleSystem {
1594
1713
  rendererEmitterSettings = {
1595
1714
  startLength: (this.rendererEmitterSettings as TrailSettings).startLength.clone(),
1596
1715
  followLocalOrigin: (this.rendererEmitterSettings as TrailSettings).followLocalOrigin,
1716
+ ...trailFlags(this.rendererEmitterSettings as TrailSettings),
1597
1717
  };
1598
1718
  } else if (this.renderMode === RenderMode.StretchedBillBoard) {
1599
1719
  rendererEmitterSettings = {
@@ -1619,6 +1739,7 @@ export class ParticleSystem implements IParticleSystem {
1619
1739
  emissionOverTime: this.emissionOverTime.clone(),
1620
1740
  emissionOverDistance: this.emissionOverDistance.clone(),
1621
1741
  emissionBursts: this.emissionBursts.map((b) => ({...b})),
1742
+ maxParticles: this.maxParticles,
1622
1743
  onlyUsedByOther: this.onlyUsedByOther,
1623
1744
  instancingGeometry: this.rendererSettings.instancingGeometry,
1624
1745
  instancingIndices: this.rendererSettings.instancingIndices,
@@ -1644,6 +1765,8 @@ export class ParticleSystem implements IParticleSystem {
1644
1765
  depthWrite: this.rendererSettings.materialDepthWrite,
1645
1766
  alphaTest: this.rendererSettings.materialAlphaTest,
1646
1767
  layerMask: this.rendererSettings.layerMask,
1768
+ pivot: this.pivot,
1769
+ flip: this.flip,
1647
1770
  scene: this.emitter.getScene(),
1648
1771
  });
1649
1772
  }