babylon.quarks 0.19.0 → 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.
@@ -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
  }
@@ -14,6 +14,15 @@ import {ensureTriangleIndices} from './geometryUtil';
14
14
 
15
15
  export interface QuarksLoaderOptions {
16
16
  baseUrl?: string;
17
+ /**
18
+ * Reuse one Texture per scene for every effect that references the same image with the same
19
+ * sampling, and one copy of the data for identical mesh geometry. The renderer only batches
20
+ * systems that share the texture (and geometry) object, so without this every loaded effect
21
+ * gets batches — draw calls and buffer uploads — of its own even when its images are the same
22
+ * as another effect's. Default true; turn off to give each effect private textures, e.g. to
23
+ * change one effect's texture settings without touching the others.
24
+ */
25
+ shareResources?: boolean;
17
26
  }
18
27
 
19
28
  interface ParsedGeometry {
@@ -23,6 +32,45 @@ interface ParsedGeometry {
23
32
  normals?: Float32Array;
24
33
  }
25
34
 
35
+ /** Textures and geometries reused across loads, per scene. See QuarksLoaderOptions.shareResources. */
36
+ const sharedTextures = new WeakMap<Scene, Map<string, Texture>>();
37
+ const sharedGeometries = new WeakMap<Scene, Map<string, ParsedGeometry>>();
38
+
39
+ function sceneMap<T>(maps: WeakMap<Scene, Map<string, T>>, scene: Scene): Map<string, T> {
40
+ let map = maps.get(scene);
41
+ if (map === undefined) {
42
+ map = new Map();
43
+ maps.set(scene, map);
44
+ }
45
+ return map;
46
+ }
47
+
48
+ /** Content key for a geometry: two independent 32-bit hashes over every array's bytes, plus lengths. */
49
+ function geometryKey(geometry: ParsedGeometry): string {
50
+ let h1 = 0x811c9dc5;
51
+ let h2 = 0x9e3779b9;
52
+ const parts: Array<ArrayBufferView | undefined> = [
53
+ geometry.positions,
54
+ geometry.indices,
55
+ geometry.uvs,
56
+ geometry.normals,
57
+ ];
58
+ let lengths = '';
59
+ for (const part of parts) {
60
+ if (part === undefined) {
61
+ lengths += '-,';
62
+ continue;
63
+ }
64
+ const bytes = new Uint8Array(part.buffer, part.byteOffset, part.byteLength);
65
+ lengths += bytes.length + ',';
66
+ for (let i = 0; i < bytes.length; i++) {
67
+ h1 = Math.imul(h1 ^ bytes[i], 0x01000193);
68
+ h2 = Math.imul(h2 ^ bytes[i], 0x5bd1e995) ^ (h2 >>> 15);
69
+ }
70
+ }
71
+ return `${lengths}${(h1 >>> 0).toString(16)}${(h2 >>> 0).toString(16)}`;
72
+ }
73
+
26
74
  interface LoadedMeta {
27
75
  textures: {[uuid: string]: Texture | null};
28
76
  geometries: {[uuid: string]: ParsedGeometry};
@@ -42,9 +90,45 @@ export class QuarksLoader {
42
90
  const baseUrl = this.options.baseUrl || url.substring(0, url.lastIndexOf('/') + 1);
43
91
  const response = await fetch(url);
44
92
  const json = await response.json();
93
+ await QuarksLoader.resolveExternalGeometries(json, async (geometryUrl) => {
94
+ const geometryResponse = await fetch(QuarksLoader.resolveUrl(baseUrl, geometryUrl));
95
+ if (!geometryResponse.ok) {
96
+ throw new Error(`HTTP ${geometryResponse.status}`);
97
+ }
98
+ return geometryResponse.json();
99
+ });
45
100
  return this.parse(json, baseUrl);
46
101
  }
47
102
 
103
+ /**
104
+ * Inlines geometries that an export wrote to their own file (`{uuid, type: 'QuarksGeometry',
105
+ * url}` — e.g. the Unity exporter's "geometries as files"): each `url` is passed to
106
+ * `loadGeometry`, and the `positions / indices / uvs / normals` it returns replace the url in
107
+ * place, so `parse()` (which is synchronous) sees an ordinary inline geometry. `load()` does
108
+ * this with `fetch`; call it yourself before `parse()` when the files come from elsewhere.
109
+ * A geometry that fails to load is left as is and warned about.
110
+ */
111
+ static async resolveExternalGeometries(json: any, loadGeometry: (url: string) => Promise<any>): Promise<void> {
112
+ const geometries: any[] = Array.isArray(json?.geometries) ? json.geometries : [];
113
+ await Promise.all(
114
+ geometries
115
+ .filter((geom) => typeof geom?.url === 'string' && !geom.positions)
116
+ .map(async (geom) => {
117
+ try {
118
+ const data = await loadGeometry(geom.url);
119
+ for (const key of ['positions', 'indices', 'uvs', 'normals']) {
120
+ if (data?.[key] !== undefined) {
121
+ geom[key] = data[key];
122
+ }
123
+ }
124
+ delete geom.url;
125
+ } catch (err) {
126
+ console.warn(`[QuarksLoader] Could not load geometry "${geom.url}":`, err);
127
+ }
128
+ })
129
+ );
130
+ }
131
+
48
132
  parse(json: any, baseUrl: string = ''): TransformNode {
49
133
  const meta: LoadedMeta = {
50
134
  textures: {},
@@ -89,16 +173,47 @@ export class QuarksLoader {
89
173
  const parsed = this.parseBufferGeometry(geom.data);
90
174
  meta.geometries[geom.uuid] = parsed;
91
175
  } else if (geom.type === 'QuarksGeometry') {
92
- meta.geometries[geom.uuid] = {
176
+ if (typeof geom.url === 'string' && !geom.positions) {
177
+ console.warn(
178
+ `[QuarksLoader] Geometry "${geom.url}" is an external file; use load() or ` +
179
+ 'QuarksLoader.resolveExternalGeometries() before parse().'
180
+ );
181
+ }
182
+ meta.geometries[geom.uuid] = this.share({
93
183
  positions: new Float32Array(geom.positions ?? []),
94
184
  indices: geom.indices ? new Uint32Array(geom.indices) : new Uint32Array(0),
95
185
  uvs: geom.uvs ? new Float32Array(geom.uvs) : undefined,
96
186
  normals: geom.normals ? new Float32Array(geom.normals) : undefined,
97
- };
187
+ });
98
188
  }
99
189
  }
100
190
  }
101
191
 
192
+ /** The scene's copy of an identical geometry when resources are shared, else `geometry` itself. */
193
+ private share(geometry: ParsedGeometry): ParsedGeometry {
194
+ if (this.options.shareResources === false) {
195
+ return geometry;
196
+ }
197
+ const geometries = sceneMap(sharedGeometries, this.scene);
198
+ const key = geometryKey(geometry);
199
+ const existing = geometries.get(key);
200
+ if (existing !== undefined) {
201
+ return existing;
202
+ }
203
+ geometries.set(key, geometry);
204
+ return geometry;
205
+ }
206
+
207
+ /**
208
+ * Forgets the textures and geometries shared between loads into `scene` (see
209
+ * {@link QuarksLoaderOptions.shareResources}). Effects already loaded keep theirs; the next
210
+ * load creates new ones. Textures are not disposed — they may still be in use.
211
+ */
212
+ static clearSharedResources(scene: Scene): void {
213
+ sharedTextures.delete(scene);
214
+ sharedGeometries.delete(scene);
215
+ }
216
+
102
217
  private parseSphereGeometry(geom: any): ParsedGeometry {
103
218
  const radius = geom.radius ?? 0.5;
104
219
  const widthSegments = Math.max(3, Math.floor(geom.widthSegments ?? 16));
@@ -257,7 +372,7 @@ export class QuarksLoader {
257
372
  if (img.url) {
258
373
  QuarksLoader.warnIfNotAnImageUrl(img.url);
259
374
  }
260
- (this as any)._images[img.uuid] = img.url ? this.resolveImageUrl(baseUrl, img.url) : null;
375
+ (this as any)._images[img.uuid] = img.url ? QuarksLoader.resolveUrl(baseUrl, img.url) : null;
261
376
  }
262
377
  }
263
378
 
@@ -286,7 +401,7 @@ export class QuarksLoader {
286
401
  * babylon.quarks-editor). Prepending baseUrl to those turns e.g.
287
402
  * "data:image/png;base64,AAAA" into an invalid path like "/some/dir/data:image/png;base64,AAAA".
288
403
  */
289
- private resolveImageUrl(baseUrl: string, url: string): string {
404
+ private static resolveUrl(baseUrl: string, url: string): string {
290
405
  if (/^(data|blob):/i.test(url) || /^[a-z][a-z0-9+.-]*:\/\//i.test(url) || url.startsWith('/')) {
291
406
  return url;
292
407
  }
@@ -300,6 +415,28 @@ export class QuarksLoader {
300
415
  if (imageUrl) {
301
416
  const invertY = texDef.invertY !== undefined ? !!texDef.invertY : true;
302
417
  const noMipmap = !!texDef.noMipmap;
418
+ // Everything below that ends up on the Texture object is part of the key, so a
419
+ // shared texture always looks the way each of its effects asked for.
420
+ const share = this.options.shareResources !== false;
421
+ const key = share
422
+ ? [
423
+ invertY,
424
+ noMipmap,
425
+ texDef.samplingMode,
426
+ texDef.wrap,
427
+ texDef.repeat,
428
+ texDef.offset,
429
+ texDef.rotation,
430
+ ].join('|') +
431
+ '|' +
432
+ imageUrl
433
+ : '';
434
+ const cache = share ? sceneMap(sharedTextures, this.scene) : undefined;
435
+ const cached = cache?.get(key);
436
+ if (cached !== undefined) {
437
+ meta.textures[texDef.uuid] = cached;
438
+ continue;
439
+ }
303
440
  const texture = new Texture(imageUrl, this.scene, {
304
441
  noMipmap,
305
442
  invertY,
@@ -320,6 +457,15 @@ export class QuarksLoader {
320
457
  if (typeof texDef.rotation === 'number') {
321
458
  texture.wAng = texDef.rotation;
322
459
  }
460
+ if (cache !== undefined) {
461
+ cache.set(key, texture);
462
+ // Something outside the library may dispose it; never hand out a dead texture.
463
+ texture.onDisposeObservable.addOnce(() => {
464
+ if (cache.get(key) === texture) {
465
+ cache.delete(key);
466
+ }
467
+ });
468
+ }
323
469
  meta.textures[texDef.uuid] = texture;
324
470
  } else {
325
471
  meta.textures[texDef.uuid] = null;