babylon.quarks 0.26.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/CHANGELOG.md +1194 -8
  2. package/README.md +13 -9
  3. package/dist/babylon.quarks.cjs +1 -1
  4. package/dist/babylon.quarks.cjs.map +1 -1
  5. package/dist/babylon.quarks.esm.js +1 -1
  6. package/dist/babylon.quarks.esm.js.map +1 -1
  7. package/dist/babylon.quarks.umd.min.js +1 -1
  8. package/dist/babylon.quarks.umd.min.js.map +1 -1
  9. package/dist/types/index.d.cts +10108 -0
  10. package/dist/types/index.d.ts +5794 -998
  11. package/package.json +18 -7
  12. package/src/BatchedRenderer.ts +274 -156
  13. package/src/EffectPool.ts +94 -12
  14. package/src/MeshSurfaceEmitter.ts +50 -14
  15. package/src/ParticleEmitter.ts +75 -17
  16. package/src/ParticleSystem.ts +981 -2066
  17. package/src/QuarksLoader.ts +666 -194
  18. package/src/QuarksPrefab.ts +367 -73
  19. package/src/QuarksSerializer.ts +512 -0
  20. package/src/QuarksUtil.ts +77 -7
  21. package/src/SpriteBatch.ts +127 -252
  22. package/src/TrailBatch.ts +545 -348
  23. package/src/VFXBatch.ts +238 -596
  24. package/src/batchKey.ts +239 -0
  25. package/src/batchSettings.ts +546 -0
  26. package/src/effectDocumentJSON.ts +261 -0
  27. package/src/effectResources.ts +76 -0
  28. package/src/envAtlas.ts +126 -18
  29. package/src/geometryUtil.ts +1 -1
  30. package/src/index.ts +69 -35
  31. package/src/nodeId.ts +36 -0
  32. package/src/shaderProgram.ts +419 -0
  33. package/src/shaderVariants.ts +245 -0
  34. package/src/shaders/chunks/blend.ts +31 -0
  35. package/src/shaders/chunks/fog.ts +12 -5
  36. package/src/shaders/chunks/sheet.ts +111 -0
  37. package/src/shaders/chunks/soft.ts +85 -0
  38. package/src/shaders/chunks/surface.ts +12 -2
  39. package/src/shaders/local_particle_physics_vert.glsl.ts +14 -28
  40. package/src/shaders/local_particle_physics_vert.wgsl.ts +16 -29
  41. package/src/shaders/particle_frag.glsl.ts +5 -22
  42. package/src/shaders/particle_frag.wgsl.ts +5 -23
  43. package/src/shaders/particle_physics_frag.glsl.ts +16 -26
  44. package/src/shaders/particle_physics_frag.wgsl.ts +14 -26
  45. package/src/shaders/particle_vert.glsl.ts +5 -27
  46. package/src/shaders/particle_vert.wgsl.ts +5 -26
  47. package/src/shaders/stretched_bb_particle_vert.glsl.ts +7 -33
  48. package/src/shaders/stretched_bb_particle_vert.wgsl.ts +7 -33
  49. package/src/shaders/trail_frag.glsl.ts +13 -0
  50. package/src/shaders/trail_frag.wgsl.ts +13 -0
  51. package/src/shaders/trail_vert.glsl.ts +45 -36
  52. package/src/shaders/trail_vert.wgsl.ts +46 -40
  53. package/src/softDepth.ts +136 -0
  54. package/src/system/BehaviorPlan.ts +85 -0
  55. package/src/system/BoundsTracker.ts +181 -0
  56. package/src/system/EmissionController.ts +358 -0
  57. package/src/system/EventDispatcher.ts +60 -0
  58. package/src/system/ParticleSpawner.ts +274 -0
  59. package/src/system/SimulationClock.ts +130 -0
  60. package/src/system/geometry.ts +104 -0
  61. package/src/system/integrate.ts +428 -0
  62. package/src/system/materialResolve.ts +165 -0
  63. package/src/system/normalize.ts +100 -0
  64. package/src/system/serialization.ts +422 -0
  65. package/src/system/settingsSchema.ts +339 -0
  66. package/src/system/types.ts +449 -0
  67. package/src/textureSource.ts +30 -0
  68. package/src/trailSettings.ts +54 -0
  69. package/src/BatchedParticleRenderer.ts +0 -3
  70. package/src/materials/ParticleMaterials.ts +0 -23
  71. package/src/materials/index.ts +0 -1
  72. package/src/shaders/chunks/index.ts +0 -1
  73. package/src/shaders/chunks/register-shader-chunks.ts +0 -32
  74. package/src/shaders/chunks/soft_fragment.glsl.ts +0 -13
  75. package/src/shaders/chunks/soft_pars_fragment.glsl.ts +0 -20
  76. package/src/shaders/chunks/soft_pars_vertex.glsl.ts +0 -6
  77. package/src/shaders/chunks/soft_vertex.glsl.ts +0 -6
  78. package/src/shaders/chunks/tile_fragment.glsl.ts +0 -9
  79. package/src/shaders/chunks/tile_pars_fragment.glsl.ts +0 -9
  80. package/src/shaders/chunks/tile_pars_vertex.glsl.ts +0 -28
  81. package/src/shaders/chunks/tile_vertex.glsl.ts +0 -18
  82. package/src/shaders/index.ts +0 -9
  83. package/src/shaders/local_particle_vert.glsl.ts +0 -50
@@ -1,16 +1,44 @@
1
1
  import {Constants} from '@babylonjs/core/Engines/constants';
2
+ import type {ITextureCreationOptions} from '@babylonjs/core/Materials/Textures/texture';
2
3
  import {Texture} from '@babylonjs/core/Materials/Textures/texture';
4
+ import type {Material} from '@babylonjs/core/Materials/material';
3
5
  import {StandardMaterial} from '@babylonjs/core/Materials/standardMaterial';
4
6
  import {Matrix, Quaternion, Vector3} from '@babylonjs/core/Maths/math.vector';
5
7
  import {Mesh} from '@babylonjs/core/Meshes/mesh';
6
8
  import {VertexData} from '@babylonjs/core/Meshes/mesh.vertexData';
7
9
  import {TransformNode} from '@babylonjs/core/Meshes/transformNode';
8
10
  import {Scene} from '@babylonjs/core/scene';
9
- import {Behavior, EmitSubParticleSystem} from 'quarks.core';
11
+ import {
12
+ Behavior,
13
+ currentFormatPath,
14
+ EmitSubParticleSystem,
15
+ QuarksFormatError,
16
+ readFormat,
17
+ readFormatPart,
18
+ warnFormat,
19
+ } from 'quarks.core';
20
+ import {MeshSurfaceEmitter} from './MeshSurfaceEmitter';
10
21
  import {ParticleEmitter} from './ParticleEmitter';
11
22
  import {ParticleSystem} from './ParticleSystem';
12
23
  import {QuarksPrefab} from './QuarksPrefab';
24
+ import type {QuarksNodeJSON, QuarksTextureJSON} from './QuarksSerializer';
25
+ import {QUARKS_FORMAT_VERSION} from './QuarksSerializer';
26
+ import type {
27
+ BufferAttributeJSON,
28
+ BufferGeometryDataJSON,
29
+ EffectDocumentJSON,
30
+ EffectGeometryJSON,
31
+ EffectImageJSON,
32
+ GeometryArrays,
33
+ MaterialTextureRef,
34
+ QuarksMaterialJSON,
35
+ SphereGeometryJSON,
36
+ } from './effectDocumentJSON';
37
+ import {disposeEffect, retainEffectResources} from './effectResources';
13
38
  import {ensureTriangleIndices} from './geometryUtil';
39
+ import {fileNodeId, setQuarksNodeId} from './nodeId';
40
+ import type {ParticleSystemJSONParameters} from './system/types';
41
+ import {setTextureSourceUrl} from './textureSource';
14
42
 
15
43
  /** Kind of linked asset the loader asks a {@link QuarksResourceResolver} to handle. */
16
44
  export type QuarksResourceKind = 'effect' | 'geometry' | 'image';
@@ -22,11 +50,32 @@ export type QuarksResourceKind = 'effect' | 'geometry' | 'image';
22
50
  export interface QuarksResourceResolver {
23
51
  /** Resolve a relative url to an absolute / blob / data url. Default: {@link QuarksLoader.joinUrl}. */
24
52
  resolve?(url: string, kind: QuarksResourceKind, baseUrl: string): string | Promise<string>;
25
- /** Load a JSON asset (geometry files). Default: `fetch(resolvedUrl).json()`. */
26
- fetchJson?(resolvedUrl: string): Promise<unknown>;
53
+ /**
54
+ * Load a JSON asset (effect and geometry files). Default: `fetch(resolvedUrl).json()`.
55
+ * `options.signal` is passed for an effect document, whose fetch belongs to one load; geometry
56
+ * fetches are shared between loads and get none.
57
+ */
58
+ fetchJson?(resolvedUrl: string, options?: {signal?: AbortSignal}): Promise<unknown>;
59
+ }
60
+
61
+ /** Options for {@link QuarksLoader.resolveLinkedResources}. */
62
+ export interface QuarksResolveOptions {
63
+ /** Joined to relative urls. Default `''`. */
64
+ baseUrl?: string;
65
+ /** Resolves and fetches the linked files; default: join with `baseUrl` and `fetch`. */
66
+ resolver?: QuarksResourceResolver;
67
+ /** Shares the per-scene fetch cache when set. */
68
+ scene?: Scene;
69
+ /** Called for a geometry file that fails to load; default: a console warning. */
70
+ onResourceError?: QuarksLoaderOptions['onResourceError'];
27
71
  }
28
72
 
73
+ /** Options for a {@link QuarksLoader}, shared by every load it does. */
29
74
  export interface QuarksLoaderOptions {
75
+ /**
76
+ * Prefix joined to relative geometry and image urls (see {@link QuarksLoader.joinUrl}).
77
+ * Unset, {@link QuarksLoader.load} uses the effect file's own folder and the other loads `''`.
78
+ */
30
79
  baseUrl?: string;
31
80
  /**
32
81
  * Reuse one Texture per scene for every effect that references the same image with the same
@@ -39,10 +88,50 @@ export interface QuarksLoaderOptions {
39
88
  shareResources?: boolean;
40
89
  /** Custom url / fetch backend (File picker, CDN, IndexedDB). */
41
90
  resolver?: QuarksResourceResolver;
91
+ /**
92
+ * Makes `load`, `loadFromJSON`, `loadAll` and `loadFromJSONAll` resolve only once every texture
93
+ * the effect uses has loaded or failed, so it does not draw its first frames without them.
94
+ * A texture a scene with `useDelayedTextureLoading` would load only once drawn is loaded at
95
+ * once. `parse` never waits. Default false: they resolve when the nodes are built.
96
+ */
97
+ waitForTextures?: boolean;
98
+ /**
99
+ * Called for a geometry file or image that fails to load. The effect still loads: a geometry
100
+ * keeps its link unresolved, and a texture draws nothing. Default: a console warning.
101
+ */
102
+ onResourceError?: (kind: QuarksResourceKind, url: string, error: unknown) => void;
103
+ /**
104
+ * Called for each part of an effect file that cannot be read as written (an unknown behavior,
105
+ * shape or curve type, a sub emitter or mesh surface shape whose target is not in the effect, a
106
+ * newer format), with what was used instead and the part's JSON path, such as
107
+ * `object.children[2].ps.behaviors[3]`. The effect still loads, with that part at its default.
108
+ * Default: a console warning that names the path.
109
+ */
110
+ onFormatWarning?: (message: string, path: string) => void;
111
+ /**
112
+ * Makes a load of a file with a part that cannot be read as written throw a
113
+ * {@link QuarksFormatError} instead of loading on with a warning: for checks that a set of
114
+ * assets loads cleanly, such as in CI. The whole effect is read first, so the error's message
115
+ * names the first such part and its `problems` list them all; what the load built is freed
116
+ * before it throws. `onFormatWarning` still hears each part. Default false.
117
+ */
118
+ strict?: boolean;
119
+ }
120
+
121
+ /** Options for one {@link QuarksLoader.load} or {@link QuarksLoader.loadFromJSON}. */
122
+ export interface QuarksLoadOptions {
123
+ /**
124
+ * Cancels the load, e.g. on a level change. The promise rejects with the signal's reason as
125
+ * soon as it is aborted, and no node of the effect is left in the scene. The effect document's
126
+ * own fetch is cancelled; a geometry fetch shared with other loads runs on, and its result is
127
+ * dropped. A scene disposed during the load rejects it the same way.
128
+ */
129
+ signal?: AbortSignal;
42
130
  }
43
131
 
44
132
  /** One in-memory effect for {@link QuarksLoader.loadFromJSONAll}. */
45
133
  export interface QuarksJSONLoadEntry {
134
+ /** The effect document, as {@link serializeEffect} or the editor writes it. Left unchanged. */
46
135
  json: unknown;
47
136
  /** Overrides loader `baseUrl` for relative texture / geometry links in this effect. */
48
137
  baseUrl?: string;
@@ -53,7 +142,7 @@ export interface QuarksJSONLoadEntry {
53
142
  * Batch loads share the per-scene fetch cache and `shareResources`, so N effects that point at
54
143
  * the same `assets/geometries/*.json` or texture only pay for that asset once.
55
144
  */
56
- export interface QuarksBatchLoadOptions {
145
+ export interface QuarksBatchLoadOptions extends QuarksLoadOptions {
57
146
  /**
58
147
  * Max concurrent effect-document fetches (phase 1). Linked geometry/image resolve still runs
59
148
  * across the whole pack in parallel so shared urls coalesce. Default: all at once.
@@ -71,17 +160,60 @@ export interface QuarksBatchLoadOptions {
71
160
  continueOnError?: boolean;
72
161
  }
73
162
 
74
- interface ParsedGeometry {
75
- positions: Float32Array;
76
- indices: Uint32Array | Uint16Array;
77
- uvs?: Float32Array;
78
- uvs2?: Float32Array;
79
- normals?: Float32Array;
163
+ /** An object with fields of unknown types, as a file's JSON may hold anywhere. */
164
+ type JSONRecord = {[key: string]: unknown};
165
+
166
+ function isRecord(value: unknown): value is JSONRecord {
167
+ return value !== null && typeof value === 'object';
168
+ }
169
+
170
+ /**
171
+ * Whether `value` has the root node an effect document needs. The other parts are checked as they
172
+ * are read, so a part of the wrong kind warns instead of failing the load.
173
+ */
174
+ function isEffectDocument(value: unknown): value is EffectDocumentJSON {
175
+ return isRecord(value) && isRecord(value.object);
176
+ }
177
+
178
+ /** Whether `value` can be read as a node; its fields are checked as they are read. */
179
+ function isNodeJSON(value: unknown): value is QuarksNodeJSON {
180
+ return isRecord(value);
181
+ }
182
+
183
+ /**
184
+ * The entries of a list in an effect document. A list that is not an array, or an entry that is
185
+ * not an object, warns and is left out; the fields of each entry are checked as they are read.
186
+ */
187
+ function documentEntries<T>(list: unknown): T[] {
188
+ if (!Array.isArray(list)) {
189
+ warnFormat('This part is not a list; it is skipped.');
190
+ return [];
191
+ }
192
+ const entries: T[] = [];
193
+ list.forEach((entry: unknown, index) => {
194
+ if (isRecord(entry)) {
195
+ entries.push(entry as T);
196
+ } else {
197
+ readFormatPart(`[${index}]`, () => warnFormat('This entry is not an object; it is skipped.'));
198
+ }
199
+ });
200
+ return entries;
201
+ }
202
+
203
+ /** `json[key]` when it is an array, else an empty one. */
204
+ function documentList(json: unknown, key: 'images' | 'geometries'): unknown[] {
205
+ const list = isRecord(json) ? json[key] : undefined;
206
+ return Array.isArray(list) ? list : [];
80
207
  }
81
208
 
209
+ /** The uuid of the mesh each loaded system's mesh surface shape names, until the effect links it. */
210
+ const meshSurfaceLinks = new WeakMap<ParticleSystem, string>();
211
+ /** The JSON path each loaded system was read from, for the warnings of linking it. */
212
+ const systemPaths = new WeakMap<ParticleSystem, string>();
213
+
82
214
  /** Textures and geometries reused across loads, per scene. See QuarksLoaderOptions.shareResources. */
83
215
  const sharedTextures = new WeakMap<Scene, Map<string, Texture>>();
84
- const sharedGeometries = new WeakMap<Scene, Map<string, ParsedGeometry>>();
216
+ const sharedGeometries = new WeakMap<Scene, Map<string, GeometryArrays>>();
85
217
  /** In-flight / completed JSON fetches keyed by `json|resolvedUrl`, per scene. */
86
218
  const sharedFetches = new WeakMap<Scene, Map<string, Promise<unknown>>>();
87
219
 
@@ -94,8 +226,14 @@ function sceneMap<T>(maps: WeakMap<Scene, Map<string, T>>, scene: Scene): Map<st
94
226
  return map;
95
227
  }
96
228
 
229
+ /** Image entries {@link QuarksLoader.resolveLinkedResources} rewrote, with the url it wrote. */
230
+ const resolvedImageUrls = new WeakMap<object, string>();
231
+
232
+ /** Image entries {@link QuarksLoader.resolveLinkedResources} rewrote, with the url the file had. */
233
+ const sourceImageUrls = new WeakMap<object, string>();
234
+
97
235
  /** Content key for a geometry: two independent 32-bit hashes over every array's bytes, plus lengths. */
98
- function geometryKey(geometry: ParsedGeometry): string {
236
+ function geometryKey(geometry: GeometryArrays): string {
99
237
  let h1 = 0x811c9dc5;
100
238
  let h2 = 0x9e3779b9;
101
239
  const parts: Array<ArrayBufferView | undefined> = [
@@ -121,12 +259,100 @@ function geometryKey(geometry: ParsedGeometry): string {
121
259
  return `${lengths}${(h1 >>> 0).toString(16)}${(h2 >>> 0).toString(16)}`;
122
260
  }
123
261
 
262
+ // Defined in quarks.core, whose readers throw it in a strict read; exported here as before.
263
+ export {QuarksFormatError};
264
+
265
+ /**
266
+ * A copy of an effect document that loading can rewrite (resolved image urls, inlined geometry
267
+ * files) without touching the caller's. Only the entries that change are copied.
268
+ */
269
+ function detachDocument(json: unknown): unknown {
270
+ if (!isRecord(json)) {
271
+ return json;
272
+ }
273
+ const copy: JSONRecord = {...json};
274
+ if (Array.isArray(json.images)) {
275
+ copy.images = json.images.map((image: unknown) => {
276
+ if (!image || typeof image !== 'object') {
277
+ return image;
278
+ }
279
+ const imageCopy: {url?: unknown} = {...(image as object)};
280
+ // An entry resolveResources() already rewrote stays resolved in its copy, so loading the
281
+ // document it prepared, with the same base, does not join the base a second time.
282
+ if (resolvedImageUrls.get(image) === imageCopy.url && typeof imageCopy.url === 'string') {
283
+ resolvedImageUrls.set(imageCopy, imageCopy.url);
284
+ const source = sourceImageUrls.get(image);
285
+ if (source !== undefined) {
286
+ sourceImageUrls.set(imageCopy, source);
287
+ }
288
+ }
289
+ return imageCopy;
290
+ });
291
+ }
292
+ if (Array.isArray(json.geometries)) {
293
+ copy.geometries = json.geometries.map((geometry: unknown) =>
294
+ geometry && typeof geometry === 'object' ? {...(geometry as object)} : geometry
295
+ );
296
+ }
297
+ return copy;
298
+ }
299
+
300
+ /** Settles when a texture the loader made has loaded or failed. */
301
+ const textureSettled = new WeakMap<Texture, Promise<void>>();
302
+
303
+ const abortReason = (signal: AbortSignal) => signal.reason ?? new Error('[QuarksLoader] The load was aborted.');
304
+ const sceneDisposed = () => new Error('[QuarksLoader] The scene was disposed while the effect loaded.');
305
+
306
+ /** Throws the reason a load stops early: its signal was aborted, or its scene disposed. */
307
+ function checkLoad(scene: Scene, signal: AbortSignal | undefined): void {
308
+ if (signal?.aborted) {
309
+ throw abortReason(signal);
310
+ }
311
+ if (scene.isDisposed) {
312
+ throw sceneDisposed();
313
+ }
314
+ }
315
+
316
+ /** Follows `promise`, or rejects as {@link checkLoad} would as soon as the load is stopped. */
317
+ function untilStopped<T>(promise: Promise<T>, scene: Scene, signal: AbortSignal | undefined): Promise<T> {
318
+ checkLoad(scene, signal);
319
+ return new Promise<T>((resolve, reject) => {
320
+ const aborted = () => reject(abortReason(signal!));
321
+ signal?.addEventListener('abort', aborted, {once: true});
322
+ const disposed = scene.onDisposeObservable.addOnce(() => reject(sceneDisposed()));
323
+ promise.then(resolve, reject).finally(() => {
324
+ signal?.removeEventListener('abort', aborted);
325
+ scene.onDisposeObservable.remove(disposed);
326
+ });
327
+ });
328
+ }
329
+
124
330
  interface LoadedMeta {
331
+ /** The materials this load made, which the effect owns. */
332
+ ownedMaterials: Material[];
333
+ /** Image uuid to the url its textures load. */
334
+ images: {[uuid: string]: string | null};
335
+ /** Image uuid to the url the file wrote, before any base url was joined. */
336
+ imageSources: {[uuid: string]: string};
125
337
  textures: {[uuid: string]: Texture | null};
126
- geometries: {[uuid: string]: ParsedGeometry};
127
- materials: {[uuid: string]: any};
338
+ geometries: {[uuid: string]: GeometryArrays};
339
+ materials: {[uuid: string]: QuarksMaterialJSON};
128
340
  }
129
341
 
342
+ /**
343
+ * Loads effects saved as JSON into a scene: fetches the document and the geometry and image files
344
+ * it links, builds the nodes, particle systems, textures and materials, and returns the root.
345
+ * Add the systems to a {@link BatchedRenderer} with {@link QuarksUtil.addToBatchRenderer} to draw
346
+ * them, and free the effect with {@link QuarksLoader.disposeEffect}.
347
+ *
348
+ * @example
349
+ * ```ts
350
+ * const loader = new QuarksLoader(scene, {waitForTextures: true});
351
+ * const effect = await loader.load('effects/fireball.json');
352
+ * QuarksUtil.addToBatchRenderer(effect, renderer);
353
+ * QuarksUtil.play(effect);
354
+ * ```
355
+ */
130
356
  export class QuarksLoader {
131
357
  private scene: Scene;
132
358
  private options: QuarksLoaderOptions;
@@ -136,20 +362,79 @@ export class QuarksLoader {
136
362
  this.options = options;
137
363
  }
138
364
 
139
- /** Fetches an effect JSON from `url`, resolves linked sources, and parses it. */
140
- async load(url: string): Promise<TransformNode> {
141
- const {json, baseUrl} = await this.fetchEffectDocument(url);
142
- return this.loadFromJSON(json, baseUrl);
365
+ /**
366
+ * Fetches an effect JSON from `url`, resolves linked sources, and parses it. Free the effect
367
+ * with {@link disposeEffect}.
368
+ */
369
+ async load(url: string, options: QuarksLoadOptions = {}): Promise<TransformNode> {
370
+ const {json, baseUrl} = await untilStopped(
371
+ this.fetchEffectDocument(url, options.signal),
372
+ this.scene,
373
+ options.signal
374
+ );
375
+ return this.loadFromJSON(json, baseUrl, options);
143
376
  }
144
377
 
145
378
  /**
146
- * Resolves linked geometry / image urls on `json` (via {@link resolveResources}) then parses.
147
- * Use when the effect JSON is already in hand (File, CDN payload, editor import).
379
+ * Resolves linked geometry / image urls (via {@link resolveResources}) then parses. Use when
380
+ * the effect JSON is already in hand (File, CDN payload, editor import). `json` is an
381
+ * {@link EffectDocumentJSON} as parsed from its file; anything else throws
382
+ * {@link QuarksFormatError}, and a part of the wrong kind warns. `json` itself is
383
+ * left as it was, so the same document can be loaded again, into another scene or with
384
+ * another base url.
148
385
  */
149
- async loadFromJSON(json: any, baseUrl?: string): Promise<TransformNode> {
386
+ async loadFromJSON(json: unknown, baseUrl?: string, options: QuarksLoadOptions = {}): Promise<TransformNode> {
387
+ const signal = options.signal;
388
+ checkLoad(this.scene, signal);
150
389
  const resolvedBase = baseUrl ?? this.options.baseUrl ?? '';
151
- await this.resolveResources(json, resolvedBase);
152
- return this.parse(json, resolvedBase);
390
+ const document = detachDocument(json);
391
+ await untilStopped(this.resolveResources(document, resolvedBase), this.scene, signal);
392
+ // The urls are resolved now; joining them with the base again would load `fx/fx/...`.
393
+ const loaded = this.parseDocument(document, '');
394
+ await this.finishLoad(loaded, signal);
395
+ return loaded.root;
396
+ }
397
+
398
+ /**
399
+ * Disposes an effect a loader built: its nodes and systems, the materials made for it, and
400
+ * each texture it uses once no other loaded effect uses it (textures are shared per scene,
401
+ * see {@link QuarksLoaderOptions.shareResources}). Copies made with
402
+ * `QuarksUtil.instantiate` hold the textures too, so disposing the source effect leaves them
403
+ * drawing. The same happens when the root is disposed any other way (`root.dispose()`, its
404
+ * parent's dispose, the scene's). The materials and textures are freed even when other code
405
+ * was handed them; give that code its own. For a root the loader did not build, this is
406
+ * `root.dispose()`.
407
+ */
408
+ static disposeEffect(root: TransformNode): void {
409
+ disposeEffect(root);
410
+ }
411
+
412
+ /**
413
+ * Waits for the effect's textures when the loader is asked to, and frees the effect when the
414
+ * load is stopped meanwhile.
415
+ */
416
+ private async finishLoad(loaded: {root: TransformNode; textures: Texture[]}, signal?: AbortSignal): Promise<void> {
417
+ try {
418
+ if (this.options.waitForTextures) {
419
+ for (const texture of loaded.textures) {
420
+ // A delayed texture would load only once drawn, which it is not until the load resolves.
421
+ if (texture.delayLoadState === Constants.DELAYLOADSTATE_NOTLOADED) {
422
+ texture.delayLoad();
423
+ }
424
+ }
425
+ await untilStopped(
426
+ Promise.all(loaded.textures.map((texture) => textureSettled.get(texture))),
427
+ this.scene,
428
+ signal
429
+ );
430
+ }
431
+ checkLoad(this.scene, signal);
432
+ } catch (err) {
433
+ if (!this.scene.isDisposed) {
434
+ disposeEffect(loaded.root);
435
+ }
436
+ throw err;
437
+ }
153
438
  }
154
439
 
155
440
  /**
@@ -170,17 +455,21 @@ export class QuarksLoader {
170
455
  options: QuarksBatchLoadOptions & {continueOnError: true}
171
456
  ): Promise<(TransformNode | null)[]>;
172
457
  async loadAll(urls: string[], options: QuarksBatchLoadOptions = {}): Promise<(TransformNode | null)[]> {
173
- const docs = await QuarksLoader.mapPool(urls, options.concurrency, async (url, index) => {
458
+ checkLoad(this.scene, options.signal);
459
+ const fetching = QuarksLoader.mapPool(urls, options.concurrency, async (url, index) => {
460
+ checkLoad(this.scene, options.signal);
174
461
  try {
175
- return await this.fetchEffectDocument(url);
462
+ return await this.fetchEffectDocument(url, options.signal);
176
463
  } catch (err) {
177
- if (!options.continueOnError) {
464
+ // A stopped load is never one effect's failure.
465
+ if (!options.continueOnError || options.signal?.aborted || this.scene.isDisposed) {
178
466
  throw err;
179
467
  }
180
468
  console.warn(`[QuarksLoader] Failed to fetch effect "${url}" (${index + 1}/${urls.length}):`, err);
181
469
  return null;
182
470
  }
183
471
  });
472
+ const docs = await untilStopped(fetching, this.scene, options.signal);
184
473
  return this.loadDocuments(docs, options);
185
474
  }
186
475
 
@@ -200,53 +489,72 @@ export class QuarksLoader {
200
489
  entries: Array<QuarksJSONLoadEntry | unknown>,
201
490
  options: QuarksBatchLoadOptions = {}
202
491
  ): Promise<(TransformNode | null)[]> {
203
- const docs: Array<{json: any; baseUrl: string} | null> = entries.map((entry) => {
492
+ const docs: Array<{json: unknown; baseUrl: string} | null> = entries.map((entry) => {
493
+ // Copied, as loadFromJSON does, so the caller's documents stay as they were.
204
494
  if (entry !== null && typeof entry === 'object' && 'json' in (entry as object)) {
205
495
  const typed = entry as QuarksJSONLoadEntry;
206
- return {json: typed.json as any, baseUrl: typed.baseUrl ?? this.options.baseUrl ?? ''};
496
+ return {json: detachDocument(typed.json), baseUrl: typed.baseUrl ?? this.options.baseUrl ?? ''};
207
497
  }
208
- return {json: entry as any, baseUrl: this.options.baseUrl ?? ''};
498
+ return {json: detachDocument(entry), baseUrl: this.options.baseUrl ?? ''};
209
499
  });
210
500
  return this.loadDocuments(docs, options);
211
501
  }
212
502
 
213
503
  /**
214
504
  * Inlines external geometry files and rewrites relative image urls through the loader's
215
- * {@link QuarksResourceResolver}. Idempotent for already-inlined geometries. Failures warn
216
- * and leave the entry as is.
505
+ * {@link QuarksResourceResolver}, in place on `json`. Each document is resolved once:
506
+ * geometries already inlined and image urls this method already rewrote are left alone, so
507
+ * loading the same document again does not join its base url twice. Failures warn and leave
508
+ * the entry as is.
217
509
  */
218
- async resolveResources(json: any, baseUrl: string = ''): Promise<void> {
510
+ async resolveResources(json: unknown, baseUrl: string = ''): Promise<void> {
219
511
  await QuarksLoader.resolveLinkedResources(json, {
220
512
  baseUrl,
221
513
  resolver: this.options.resolver,
222
514
  scene: this.scene,
515
+ onResourceError: this.options.onResourceError,
223
516
  });
224
517
  }
225
518
 
226
519
  /**
227
520
  * Same as instance {@link resolveResources}, but usable without constructing a loader (e.g.
228
521
  * editor import that only prepares JSON). Pass `scene` to share the per-scene fetch cache.
522
+ * Like it, resolves each document once. A geometry file that fails to load goes to
523
+ * `onResourceError` (default: a console warning) and keeps its link.
229
524
  */
230
- static async resolveLinkedResources(
231
- json: any,
232
- options: {baseUrl?: string; resolver?: QuarksResourceResolver; scene?: Scene} = {}
233
- ): Promise<void> {
525
+ static async resolveLinkedResources(json: unknown, options: QuarksResolveOptions = {}): Promise<void> {
234
526
  const baseUrl = options.baseUrl ?? '';
235
527
  const resolver = options.resolver;
236
528
  const scene = options.scene;
237
529
 
238
- await QuarksLoader.inlineGeometryEntries(json, async (geometryUrl) => {
239
- const resolved = await QuarksLoader.resolveWith(resolver, geometryUrl, 'geometry', baseUrl);
240
- return QuarksLoader.fetchJsonWith(resolver, resolved, scene);
241
- });
530
+ await QuarksLoader.inlineGeometryEntries(
531
+ json,
532
+ async (geometryUrl) => {
533
+ const resolved = await QuarksLoader.resolveWith(resolver, geometryUrl, 'geometry', baseUrl);
534
+ return QuarksLoader.fetchJsonWith(resolver, resolved, scene);
535
+ },
536
+ options.onResourceError
537
+ );
242
538
 
243
- const images: any[] = Array.isArray(json?.images) ? json.images : [];
244
539
  await Promise.all(
245
- images.map(async (img) => {
246
- if (typeof img?.url !== 'string' || !img.url) {
540
+ documentList(json, 'images').map(async (img) => {
541
+ if (!isRecord(img) || typeof img.url !== 'string' || !img.url) {
542
+ return;
543
+ }
544
+ // An entry this loader already rewrote keeps its url: resolving the same document
545
+ // again (a second load, or a fetch cached for several loads) would join it twice.
546
+ if (resolvedImageUrls.get(img) === img.url) {
247
547
  return;
248
548
  }
249
- img.url = await QuarksLoader.resolveWith(resolver, img.url, 'image', baseUrl);
549
+ const source = img.url;
550
+ const url = await QuarksLoader.resolveWith(resolver, source, 'image', baseUrl);
551
+ img.url = url;
552
+ resolvedImageUrls.set(img, url);
553
+ // A resolver that inlined the file (`data:`, `blob:`) made the url the image itself;
554
+ // the file's path no longer names what the texture holds, so a save writes the url.
555
+ if (!/^(data|blob):/i.test(url)) {
556
+ sourceImageUrls.set(img, source);
557
+ }
250
558
  })
251
559
  );
252
560
  }
@@ -257,36 +565,88 @@ export class QuarksLoader {
257
565
  * the loader's resolver and fetch cache. Kept for callers that already have a custom
258
566
  * `loadGeometry` and no `QuarksLoader` instance.
259
567
  */
260
- static async resolveExternalGeometries(json: any, loadGeometry: (url: string) => Promise<any>): Promise<void> {
568
+ static async resolveExternalGeometries(
569
+ json: unknown,
570
+ loadGeometry: (url: string) => Promise<unknown>
571
+ ): Promise<void> {
261
572
  await QuarksLoader.inlineGeometryEntries(json, loadGeometry);
262
573
  }
263
574
 
264
- parse(json: any, baseUrl: string = ''): TransformNode {
575
+ /**
576
+ * Builds the node tree of an effect document whose linked files are already inline (see
577
+ * {@link loadFromJSON}). `baseUrl` is joined to relative image urls. Parts the loader cannot
578
+ * read warn and take their defaults; a document with no `object` throws
579
+ * {@link QuarksFormatError}. Free the effect with {@link disposeEffect}.
580
+ */
581
+ parse(json: unknown, baseUrl: string = ''): TransformNode {
582
+ return this.parseDocument(json, baseUrl).root;
583
+ }
584
+
585
+ /** {@link parse}, and the textures the effect uses. */
586
+ private parseDocument(json: unknown, baseUrl: string): {root: TransformNode; textures: Texture[]} {
587
+ const {strict, onFormatWarning} = this.options;
588
+ const problems: string[] = [];
589
+ const onWarning = (message: string, path: string): void => {
590
+ const located = path ? `${message} (at ${path})` : message;
591
+ if (strict) problems.push(located);
592
+ if (onFormatWarning) onFormatWarning(message, path);
593
+ else if (!strict) console.warn(`[quarks] ${located}`);
594
+ };
595
+ const loaded = readFormat({onWarning}, () => this.readDocument(json, baseUrl));
596
+ if (problems.length > 0) {
597
+ // The whole effect is read first, so a strict load frees what it built the same way any
598
+ // other effect is freed, and reports how many parts failed.
599
+ QuarksLoader.disposeEffect(loaded.root);
600
+ const more = problems.length > 1 ? ` (and ${problems.length - 1} more)` : '';
601
+ throw new QuarksFormatError(`${problems[0]}${more}`, problems);
602
+ }
603
+ return loaded;
604
+ }
605
+
606
+ /** {@link parseDocument} inside its format read. */
607
+ private readDocument(document: unknown, baseUrl: string): {root: TransformNode; textures: Texture[]} {
608
+ if (!isEffectDocument(document)) {
609
+ throw new QuarksFormatError('[QuarksLoader] Not an effect document: it has no "object" to load.');
610
+ }
611
+ const json = document;
612
+ const format = json.metadata?.quarksFormat;
613
+ if (typeof format === 'number' && format > QUARKS_FORMAT_VERSION) {
614
+ warnFormat(
615
+ `This effect was written in format ${format}, newer than the ${QUARKS_FORMAT_VERSION} ` +
616
+ 'this version of babylon.quarks reads; settings it does not know are left out.'
617
+ );
618
+ }
265
619
  const meta: LoadedMeta = {
620
+ ownedMaterials: [],
621
+ images: {},
622
+ imageSources: {},
266
623
  textures: {},
267
624
  geometries: {},
268
625
  materials: {},
269
626
  };
270
627
 
271
- if (json.geometries) {
272
- this.parseGeometries(json.geometries, meta);
628
+ const {geometries, images, textures: textureList, materials} = json;
629
+ if (geometries) {
630
+ readFormatPart('geometries', () => this.parseGeometries(documentEntries(geometries), meta));
273
631
  }
274
- if (json.images) {
275
- this.parseImages(json.images, baseUrl, meta);
632
+ if (images) {
633
+ readFormatPart('images', () => this.parseImages(documentEntries(images), baseUrl, meta));
276
634
  }
277
- if (json.textures) {
278
- this.parseTextures(json.textures, meta);
635
+ if (textureList) {
636
+ readFormatPart('textures', () => this.parseTextures(documentEntries(textureList), meta));
279
637
  }
280
- if (json.materials) {
281
- this.parseMaterials(json.materials, meta);
638
+ if (materials) {
639
+ readFormatPart('materials', () => this.parseMaterials(documentEntries(materials), meta));
282
640
  }
283
641
 
284
- const root = this.parseObject(json.object, meta);
642
+ const root = readFormatPart('object', () => this.parseObject(json.object, meta));
285
643
  this.linkReferences(root);
286
- return root;
644
+ const textures = [...new Set(Object.values(meta.textures).filter((t): t is Texture => t !== null))];
645
+ retainEffectResources(root, textures, meta.ownedMaterials);
646
+ return {root, textures};
287
647
  }
288
648
 
289
- private parseGeometries(geometries: any[], meta: LoadedMeta): void {
649
+ private parseGeometries(geometries: EffectGeometryJSON[], meta: LoadedMeta): void {
290
650
  for (const geom of geometries) {
291
651
  if (geom.type === 'PlaneGeometry') {
292
652
  const w = geom.width || 1;
@@ -306,8 +666,8 @@ export class QuarksLoader {
306
666
  meta.geometries[geom.uuid] = parsed;
307
667
  } else if (geom.type === 'QuarksGeometry') {
308
668
  if (typeof geom.url === 'string' && !geom.positions) {
309
- console.warn(
310
- `[QuarksLoader] Geometry "${geom.url}" is an external file; use load(), ` +
669
+ warnFormat(
670
+ `Geometry "${geom.url}" is an external file; use load(), ` +
311
671
  'loadFromJSON(), or resolveResources() before parse().'
312
672
  );
313
673
  }
@@ -323,7 +683,7 @@ export class QuarksLoader {
323
683
  }
324
684
 
325
685
  /** The scene's copy of an identical geometry when resources are shared, else `geometry` itself. */
326
- private share(geometry: ParsedGeometry): ParsedGeometry {
686
+ private share(geometry: GeometryArrays): GeometryArrays {
327
687
  if (this.options.shareResources === false) {
328
688
  return geometry;
329
689
  }
@@ -356,7 +716,7 @@ export class QuarksLoader {
356
716
  return baseUrl + url;
357
717
  }
358
718
 
359
- private parseSphereGeometry(geom: any): ParsedGeometry {
719
+ private parseSphereGeometry(geom: SphereGeometryJSON): GeometryArrays {
360
720
  const radius = geom.radius ?? 0.5;
361
721
  const widthSegments = Math.max(3, Math.floor(geom.widthSegments ?? 16));
362
722
  const heightSegments = Math.max(2, Math.floor(geom.heightSegments ?? 8));
@@ -426,8 +786,8 @@ export class QuarksLoader {
426
786
  };
427
787
  }
428
788
 
429
- private parseBufferGeometry(data: any): ParsedGeometry {
430
- let positions = new Float32Array(0);
789
+ private parseBufferGeometry(data: BufferGeometryDataJSON): GeometryArrays {
790
+ let positions: Float32Array = new Float32Array(0);
431
791
  let indices: Uint32Array | Uint16Array = new Uint32Array(0);
432
792
  let uvs: Float32Array | undefined;
433
793
  let uvs2: Float32Array | undefined;
@@ -437,7 +797,7 @@ export class QuarksLoader {
437
797
 
438
798
  if (data.interleavedBuffers && data.arrayBuffers) {
439
799
  const buffers: {[uuid: string]: {data: Float32Array; stride: number}} = {};
440
- for (const [uuid, ibDef] of Object.entries(data.interleavedBuffers) as any[]) {
800
+ for (const [uuid, ibDef] of Object.entries(data.interleavedBuffers)) {
441
801
  const arrayBuffer = data.arrayBuffers[ibDef.buffer];
442
802
  if (arrayBuffer) {
443
803
  const floatArray = new Float32Array(
@@ -447,35 +807,17 @@ export class QuarksLoader {
447
807
  }
448
808
  }
449
809
 
450
- if (data.attributes) {
451
- if (data.attributes.position?.isInterleavedBufferAttribute) {
452
- const attr = data.attributes.position;
453
- const buf = buffers[attr.data];
454
- if (buf)
455
- positions = this.extractInterleavedAttribute(buf.data, buf.stride, attr.offset, attr.itemSize);
456
- }
457
- if (data.attributes.uv?.isInterleavedBufferAttribute) {
458
- const attr = data.attributes.uv;
459
- const buf = buffers[attr.data];
460
- if (buf) uvs = this.extractInterleavedAttribute(buf.data, buf.stride, attr.offset, attr.itemSize);
461
- }
462
- if (uv2Attribute?.isInterleavedBufferAttribute) {
463
- const buf = buffers[uv2Attribute.data];
464
- if (buf)
465
- uvs2 = this.extractInterleavedAttribute(
466
- buf.data,
467
- buf.stride,
468
- uv2Attribute.offset,
469
- uv2Attribute.itemSize
470
- );
471
- }
472
- if (data.attributes.normal?.isInterleavedBufferAttribute) {
473
- const attr = data.attributes.normal;
474
- const buf = buffers[attr.data];
475
- if (buf)
476
- normals = this.extractInterleavedAttribute(buf.data, buf.stride, attr.offset, attr.itemSize);
477
- }
478
- }
810
+ const readInterleaved = (attr: BufferAttributeJSON | undefined): Float32Array | undefined => {
811
+ const buf =
812
+ attr?.isInterleavedBufferAttribute && attr.data !== undefined ? buffers[attr.data] : undefined;
813
+ return buf
814
+ ? this.extractInterleavedAttribute(buf.data, buf.stride, attr?.offset, attr?.itemSize)
815
+ : undefined;
816
+ };
817
+ positions = readInterleaved(data.attributes?.position) ?? positions;
818
+ uvs = readInterleaved(data.attributes?.uv);
819
+ uvs2 = readInterleaved(uv2Attribute);
820
+ normals = readInterleaved(data.attributes?.normal);
479
821
  } else if (data.attributes) {
480
822
  if (data.attributes.position?.array) {
481
823
  positions = new Float32Array(data.attributes.position.array);
@@ -504,7 +846,12 @@ export class QuarksLoader {
504
846
  return {positions, indices, uvs, uvs2, normals};
505
847
  }
506
848
 
507
- private extractInterleavedAttribute(buffer: Float32Array, stride: number, offset: number, itemSize: number): any {
849
+ private extractInterleavedAttribute(
850
+ buffer: Float32Array,
851
+ stride: number,
852
+ offset: number = 0,
853
+ itemSize: number = 0
854
+ ): Float32Array {
508
855
  const count = Math.floor(buffer.length / stride);
509
856
  const result = new Float32Array(count * itemSize);
510
857
  for (let i = 0; i < count; i++) {
@@ -521,7 +868,7 @@ export class QuarksLoader {
521
868
  for (let i = 0; i < intArray.length; i++) {
522
869
  int32View[i] = intArray[i];
523
870
  }
524
- return new Float32Array(buffer) as any;
871
+ return new Float32Array(buffer);
525
872
  }
526
873
 
527
874
  private async resolveResourceUrl(url: string, kind: QuarksResourceKind, baseUrl: string): Promise<string> {
@@ -529,10 +876,10 @@ export class QuarksLoader {
529
876
  }
530
877
 
531
878
  /** Fetches one effect JSON; documents are not fetch-cached because resolve mutates them. */
532
- private async fetchEffectDocument(url: string): Promise<{json: any; baseUrl: string}> {
879
+ private async fetchEffectDocument(url: string, signal?: AbortSignal): Promise<{json: unknown; baseUrl: string}> {
533
880
  const baseUrl = this.options.baseUrl || url.substring(0, url.lastIndexOf('/') + 1);
534
881
  const resolvedEffect = await this.resolveResourceUrl(url, 'effect', baseUrl);
535
- const json = await QuarksLoader.fetchJsonWith(this.options.resolver, resolvedEffect, undefined);
882
+ const json = await QuarksLoader.fetchJsonWith(this.options.resolver, resolvedEffect, undefined, signal);
536
883
  return {json, baseUrl};
537
884
  }
538
885
 
@@ -541,19 +888,37 @@ export class QuarksLoader {
541
888
  * scene fetch cache), then parse. Each index settles exactly once for `onProgress`.
542
889
  */
543
890
  private async loadDocuments(
544
- docs: Array<{json: any; baseUrl: string} | null>,
891
+ docs: Array<{json: unknown; baseUrl: string} | null>,
545
892
  options: QuarksBatchLoadOptions
546
893
  ): Promise<(TransformNode | null)[]> {
547
894
  const total = docs.length;
548
895
  if (total === 0) {
549
896
  return [];
550
897
  }
898
+ checkLoad(this.scene, options.signal);
899
+ // Stops the whole batch on abort, and the effects still waiting for textures once one fails.
900
+ const stopAll = new AbortController();
901
+ const abort = () => stopAll.abort(abortReason(options.signal!));
902
+ options.signal?.addEventListener('abort', abort, {once: true});
903
+ try {
904
+ return await this.loadResolvedDocuments(docs, options, stopAll);
905
+ } finally {
906
+ options.signal?.removeEventListener('abort', abort);
907
+ }
908
+ }
551
909
 
552
- const results: (TransformNode | null)[] = new Array(total);
910
+ private async loadResolvedDocuments(
911
+ docs: Array<{json: unknown; baseUrl: string} | null>,
912
+ options: QuarksBatchLoadOptions,
913
+ stopAll: AbortController
914
+ ): Promise<(TransformNode | null)[]> {
915
+ const total = docs.length;
916
+ const signal = stopAll.signal;
917
+ const results: (TransformNode | null)[] = new Array(total).fill(null);
553
918
  const settled = new Array<boolean>(total).fill(false);
554
919
  let done = 0;
555
920
  const settle = (index: number, value: TransformNode | null) => {
556
- if (settled[index]) {
921
+ if (settled[index] || signal.aborted) {
557
922
  return;
558
923
  }
559
924
  settled[index] = true;
@@ -562,42 +927,67 @@ export class QuarksLoader {
562
927
  options.onProgress?.(done, total);
563
928
  };
564
929
 
565
- await Promise.all(
566
- docs.map(async (doc, i) => {
930
+ await untilStopped(
931
+ Promise.all(
932
+ docs.map(async (doc, i) => {
933
+ if (!doc) {
934
+ return;
935
+ }
936
+ try {
937
+ await this.resolveResources(doc.json, doc.baseUrl);
938
+ } catch (err) {
939
+ if (!options.continueOnError) {
940
+ throw err;
941
+ }
942
+ console.warn(`[QuarksLoader] Failed to resolve resources for effect ${i + 1}/${total}:`, err);
943
+ docs[i] = null;
944
+ settle(i, null);
945
+ }
946
+ })
947
+ ),
948
+ this.scene,
949
+ signal
950
+ );
951
+
952
+ const loaded: Array<{root: TransformNode; textures: Texture[]}> = [];
953
+ const finishing: Promise<void>[] = [];
954
+ try {
955
+ for (let i = 0; i < total; i++) {
956
+ if (settled[i]) {
957
+ continue;
958
+ }
959
+ const doc = docs[i];
567
960
  if (!doc) {
568
- return;
961
+ settle(i, null);
962
+ continue;
569
963
  }
964
+ let effect: {root: TransformNode; textures: Texture[]};
570
965
  try {
571
- await this.resolveResources(doc.json, doc.baseUrl);
966
+ // Resolved above, so the base must not be joined a second time.
967
+ effect = this.parseDocument(doc.json, '');
572
968
  } catch (err) {
573
969
  if (!options.continueOnError) {
574
970
  throw err;
575
971
  }
576
- console.warn(`[QuarksLoader] Failed to resolve resources for effect ${i + 1}/${total}:`, err);
577
- docs[i] = null;
972
+ console.warn(`[QuarksLoader] Failed to parse effect ${i + 1}/${total}:`, err);
578
973
  settle(i, null);
974
+ continue;
579
975
  }
580
- })
581
- );
582
-
583
- for (let i = 0; i < total; i++) {
584
- if (settled[i]) {
585
- continue;
976
+ loaded.push(effect);
977
+ // An effect counts as loaded once its textures have too, when the loader waits for them.
978
+ finishing.push(this.finishLoad(effect, signal).then(() => settle(i, effect.root)));
586
979
  }
587
- const doc = docs[i];
588
- if (!doc) {
589
- settle(i, null);
590
- continue;
980
+ await Promise.all(finishing);
981
+ } catch (err) {
982
+ // One failure, or the load stopped: stop the rest and free every effect already built.
983
+ if (!signal.aborted) {
984
+ stopAll.abort(err);
591
985
  }
592
- try {
593
- settle(i, this.parse(doc.json, doc.baseUrl));
594
- } catch (err) {
595
- if (!options.continueOnError) {
596
- throw err;
597
- }
598
- console.warn(`[QuarksLoader] Failed to parse effect ${i + 1}/${total}:`, err);
599
- settle(i, null);
986
+ void Promise.allSettled(finishing);
987
+ if (!this.scene.isDisposed) {
988
+ loaded.forEach((effect) => disposeEffect(effect.root));
600
989
  }
990
+ throw err;
601
991
  }
602
992
  return results;
603
993
  }
@@ -643,13 +1033,15 @@ export class QuarksLoader {
643
1033
  private static fetchJsonWith(
644
1034
  resolver: QuarksResourceResolver | undefined,
645
1035
  resolvedUrl: string,
646
- scene: Scene | undefined
1036
+ scene: Scene | undefined,
1037
+ signal?: AbortSignal
647
1038
  ): Promise<unknown> {
648
1039
  const load = async () => {
649
1040
  if (resolver?.fetchJson) {
650
- return resolver.fetchJson(resolvedUrl);
1041
+ return signal ? resolver.fetchJson(resolvedUrl, {signal}) : resolver.fetchJson(resolvedUrl);
651
1042
  }
652
- const response = await fetch(resolvedUrl);
1043
+ // Only an uncached fetch (an effect document) can be cancelled: a cached one is shared.
1044
+ const response = await (signal ? fetch(resolvedUrl, {signal}) : fetch(resolvedUrl));
653
1045
  if (!response.ok) {
654
1046
  throw new Error(`HTTP ${response.status}`);
655
1047
  }
@@ -675,40 +1067,59 @@ export class QuarksLoader {
675
1067
  return pending;
676
1068
  }
677
1069
 
678
- private static async inlineGeometryEntries(json: any, loadGeometry: (url: string) => Promise<any>): Promise<void> {
679
- const geometries: any[] = Array.isArray(json?.geometries) ? json.geometries : [];
1070
+ private static async inlineGeometryEntries(
1071
+ json: unknown,
1072
+ loadGeometry: (url: string) => Promise<unknown>,
1073
+ onResourceError: QuarksLoaderOptions['onResourceError'] = QuarksLoader.warnResourceError
1074
+ ): Promise<void> {
680
1075
  await Promise.all(
681
- geometries
682
- .filter((geom) => typeof geom?.url === 'string' && !geom.positions)
683
- .map(async (geom) => {
684
- try {
685
- const data = await loadGeometry(geom.url);
686
- // Copy arrays so a shared fetch-cache document is not aliased across
687
- // effects — mutating one effect's inlined geometry must not corrupt others.
688
- for (const key of ['positions', 'indices', 'uvs', 'uvs2', 'normals']) {
689
- const value = data?.[key];
690
- if (value === undefined) {
691
- continue;
692
- }
693
- geom[key] = Array.isArray(value) ? value.slice() : value;
1076
+ documentList(json, 'geometries').map(async (geom) => {
1077
+ if (!isRecord(geom) || typeof geom.url !== 'string' || geom.positions) {
1078
+ return;
1079
+ }
1080
+ const url = geom.url;
1081
+ try {
1082
+ const data = await loadGeometry(url);
1083
+ // Copy arrays so a shared fetch-cache document is not aliased across
1084
+ // effects — mutating one effect's inlined geometry must not corrupt others.
1085
+ for (const key of ['positions', 'indices', 'uvs', 'uvs2', 'normals']) {
1086
+ const value = isRecord(data) ? data[key] : undefined;
1087
+ if (value === undefined) {
1088
+ continue;
694
1089
  }
695
- delete geom.url;
696
- } catch (err) {
697
- console.warn(`[QuarksLoader] Could not load geometry "${geom.url}":`, err);
1090
+ geom[key] = Array.isArray(value) ? value.slice() : value;
698
1091
  }
699
- })
1092
+ delete geom.url;
1093
+ } catch (err) {
1094
+ onResourceError('geometry', url, err);
1095
+ }
1096
+ })
700
1097
  );
701
1098
  }
702
1099
 
703
- private parseImages(images: any[], baseUrl: string, _meta: LoadedMeta): void {
704
- (this as any)._images = {};
705
- for (const img of images) {
706
- if (img.url) {
707
- QuarksLoader.warnIfNotAnImageUrl(img.url);
1100
+ private static warnResourceError(kind: QuarksResourceKind, url: string, error: unknown): void {
1101
+ console.warn(`[QuarksLoader] Could not load ${kind} "${url}":`, error);
1102
+ }
1103
+
1104
+ private reportResourceError(kind: QuarksResourceKind, url: string, error: unknown): void {
1105
+ (this.options.onResourceError ?? QuarksLoader.warnResourceError)(kind, url, error);
1106
+ }
1107
+
1108
+ private parseImages(images: EffectImageJSON[], baseUrl: string, meta: LoadedMeta): void {
1109
+ for (const [index, img] of images.entries()) {
1110
+ const url = img.url;
1111
+ if (url) {
1112
+ readFormatPart(`[${index}]`, () => QuarksLoader.warnIfNotAnImageUrl(url));
708
1113
  }
709
1114
  // Urls are usually already absolute after resolveResources; joinUrl is a no-op for
710
1115
  // data/blob/http(s) and still supports callers that use parse() with a baseUrl only.
711
- (this as any)._images[img.uuid] = img.url ? QuarksLoader.joinUrl(baseUrl, img.url) : null;
1116
+ meta.images[img.uuid] = url ? QuarksLoader.joinUrl(baseUrl, url) : null;
1117
+ // The url the file wrote, for serializeEffect to write back. An inlined image
1118
+ // (`data:`, `blob:`) has no path to write; its texture writes its url.
1119
+ const source = sourceImageUrls.get(img) ?? url;
1120
+ if (source && !/^(data|blob):/i.test(source)) {
1121
+ meta.imageSources[img.uuid] = source;
1122
+ }
712
1123
  }
713
1124
  }
714
1125
 
@@ -725,16 +1136,15 @@ export class QuarksLoader {
725
1136
  if (/^(data|blob):/i.test(url) || /\.(png|jpe?g|webp|gif|bmp|ktx2?|dds|env|basis)(\?|#|$)/i.test(url)) {
726
1137
  return;
727
1138
  }
728
- console.warn(
729
- `[QuarksLoader] Image "${url}" does not look like an image; its texture will fail to load. ` +
1139
+ warnFormat(
1140
+ `Image "${url}" does not look like an image; its texture will fail to load. ` +
730
1141
  'This usually means the effect was exported without embedding that texture.'
731
1142
  );
732
1143
  }
733
1144
 
734
- private parseTextures(textures: any[], meta: LoadedMeta): void {
735
- const images = (this as any)._images || {};
1145
+ private parseTextures(textures: QuarksTextureJSON[], meta: LoadedMeta): void {
736
1146
  for (const texDef of textures) {
737
- const imageUrl = images[texDef.image];
1147
+ const imageUrl = texDef.image !== undefined ? meta.images[texDef.image] : undefined;
738
1148
  if (imageUrl) {
739
1149
  const invertY = texDef.invertY !== undefined ? !!texDef.invertY : true;
740
1150
  const noMipmap = !!texDef.noMipmap;
@@ -760,11 +1170,41 @@ export class QuarksLoader {
760
1170
  meta.textures[texDef.uuid] = cached;
761
1171
  continue;
762
1172
  }
763
- const texture = new Texture(imageUrl, this.scene, {
1173
+ let settle!: () => void;
1174
+ const settled = new Promise<void>((resolve) => (settle = resolve));
1175
+ let failed = false;
1176
+ // Babylon calls onError from inside the constructor, before `texture` is assigned, and
1177
+ // then throws, when the engine cannot make the texture at all.
1178
+ let texture: Texture | undefined = undefined;
1179
+ const options: ITextureCreationOptions = {
764
1180
  noMipmap,
765
1181
  invertY,
766
1182
  samplingMode: typeof texDef.samplingMode === 'number' ? texDef.samplingMode : undefined,
767
- });
1183
+ onLoad: () => settle(),
1184
+ onError: (message, exception) => {
1185
+ failed = true;
1186
+ // A later load tries the image again rather than reuse the failed texture.
1187
+ if (cache !== undefined && cache.get(key) === texture) {
1188
+ cache.delete(key);
1189
+ }
1190
+ this.reportResourceError('image', imageUrl, exception ?? message);
1191
+ settle();
1192
+ },
1193
+ };
1194
+ try {
1195
+ texture = new Texture(imageUrl, this.scene, options);
1196
+ } catch {
1197
+ // Reported through onError: the effect draws without this texture.
1198
+ meta.textures[texDef.uuid] = null;
1199
+ continue;
1200
+ }
1201
+ textureSettled.set(texture, settled);
1202
+ const source = texDef.image !== undefined ? meta.imageSources[texDef.image] : undefined;
1203
+ if (source !== undefined) {
1204
+ setTextureSourceUrl(texture, source, imageUrl);
1205
+ }
1206
+ // A texture disposed before it loads never will.
1207
+ texture.onDisposeObservable.addOnce(() => settle());
768
1208
  if (Array.isArray(texDef.wrap)) {
769
1209
  texture.wrapU = this.mapWrapMode(texDef.wrap[0]);
770
1210
  texture.wrapV = this.mapWrapMode(texDef.wrap[1] ?? texDef.wrap[0]);
@@ -780,7 +1220,7 @@ export class QuarksLoader {
780
1220
  if (typeof texDef.rotation === 'number') {
781
1221
  texture.wAng = texDef.rotation;
782
1222
  }
783
- if (cache !== undefined) {
1223
+ if (cache !== undefined && !failed) {
784
1224
  cache.set(key, texture);
785
1225
  // Something outside the library may dispose it; never hand out a dead texture.
786
1226
  texture.onDisposeObservable.addOnce(() => {
@@ -808,9 +1248,12 @@ export class QuarksLoader {
808
1248
  }
809
1249
  }
810
1250
 
811
- private parseMaterials(materials: any[], meta: LoadedMeta): void {
1251
+ private parseMaterials(materials: QuarksMaterialJSON[], meta: LoadedMeta): void {
1252
+ // A file names a texture by uuid; anything else in a slot is no texture.
1253
+ const texture = (ref: MaterialTextureRef | undefined): Texture | null =>
1254
+ typeof ref === 'string' ? meta.textures[ref] || null : null;
812
1255
  for (const matDef of materials) {
813
- const matInfo: any = {
1256
+ const matInfo: QuarksMaterialJSON = {
814
1257
  uuid: matDef.uuid,
815
1258
  transparent: matDef.transparent ?? true,
816
1259
  blending: matDef.blending ?? 1,
@@ -825,12 +1268,12 @@ export class QuarksLoader {
825
1268
  // emits) uses `texture`. Accept both so library-exported effects round-trip.
826
1269
  const textureRef = matDef.map ?? matDef.texture;
827
1270
  if (textureRef) {
828
- matInfo.texture = meta.textures[textureRef] || null;
1271
+ matInfo.texture = texture(textureRef);
829
1272
  }
830
1273
 
831
1274
  const reflectionAtlasRef = matDef.reflectionAtlas;
832
1275
  if (reflectionAtlasRef) {
833
- matInfo.reflectionAtlas = meta.textures[reflectionAtlasRef] || null;
1276
+ matInfo.reflectionAtlas = texture(reflectionAtlasRef);
834
1277
  }
835
1278
  if (typeof matDef.reflectionLevel === 'number') {
836
1279
  matInfo.reflectionLevel = matDef.reflectionLevel;
@@ -853,24 +1296,24 @@ export class QuarksLoader {
853
1296
  if (matDef.gradientMap && typeof matDef.gradientMap === 'object') {
854
1297
  matInfo.gradientMap = {
855
1298
  ...matDef.gradientMap,
856
- ramp: matDef.gradientMap.ramp ? meta.textures[matDef.gradientMap.ramp] || null : null,
1299
+ ramp: texture(matDef.gradientMap.ramp),
857
1300
  };
858
1301
  }
859
1302
  if (matDef.erosion && typeof matDef.erosion === 'object') {
860
1303
  matInfo.erosion = {
861
1304
  ...matDef.erosion,
862
- mask: matDef.erosion.mask ? meta.textures[matDef.erosion.mask] || null : null,
1305
+ mask: texture(matDef.erosion.mask),
863
1306
  };
864
1307
  }
865
1308
  // uvDistortion {map (texture uuid), mapUV {channel, tiling, offset, scroll}, strength}.
866
1309
  if (matDef.uvDistortion && typeof matDef.uvDistortion === 'object') {
867
1310
  matInfo.uvDistortion = {
868
1311
  ...matDef.uvDistortion,
869
- map: matDef.uvDistortion.map ? meta.textures[matDef.uvDistortion.map] || null : null,
1312
+ map: texture(matDef.uvDistortion.map),
870
1313
  };
871
1314
  }
872
1315
 
873
- // QuarksMaterial (what ParticleSystem.ensureMaterialMeta emits) writes `alphaMode`
1316
+ // QuarksMaterial (what `materialMeta` in system/serialization.ts writes) writes `alphaMode`
874
1317
  // directly as a Babylon alpha-mode constant; three.js materials (MeshBasicMaterial
875
1318
  // etc.) instead use `blending` with three.js's own 1..4 numbering, which does NOT
876
1319
  // line up with Babylon's constants (e.g. three.js Additive=2 vs Babylon ALPHA_ADD=1)
@@ -884,19 +1327,19 @@ export class QuarksLoader {
884
1327
  3: Constants.ALPHA_SUBTRACT,
885
1328
  4: Constants.ALPHA_MULTIPLY,
886
1329
  };
887
- matInfo.alphaMode = blendingToAlphaMode[matDef.blending] || Constants.ALPHA_COMBINE;
1330
+ matInfo.alphaMode = blendingToAlphaMode[matDef.blending ?? 1] || Constants.ALPHA_COMBINE;
888
1331
  }
889
1332
 
890
1333
  meta.materials[matDef.uuid] = matInfo;
891
1334
  }
892
1335
  }
893
1336
 
894
- private getGeometry(meta: LoadedMeta, geometryId: string | undefined): ParsedGeometry | undefined {
1337
+ private getGeometry(meta: LoadedMeta, geometryId: string | undefined): GeometryArrays | undefined {
895
1338
  if (!geometryId) return undefined;
896
1339
  return meta.geometries[geometryId];
897
1340
  }
898
1341
 
899
- private getMaterial(meta: LoadedMeta, materialId: string | string[] | undefined): any | undefined {
1342
+ private getMaterial(meta: LoadedMeta, materialId: string | string[] | undefined): QuarksMaterialJSON | undefined {
900
1343
  if (!materialId) return undefined;
901
1344
  const selectedId = Array.isArray(materialId) ? materialId[0] : materialId;
902
1345
  return meta.materials[selectedId];
@@ -904,11 +1347,12 @@ export class QuarksLoader {
904
1347
 
905
1348
  private createPlaceholderNode(type: string, name?: string): TransformNode {
906
1349
  const node = new TransformNode(name || type || 'node', this.scene);
907
- (node as any).quarksOriginalType = type;
1350
+ // The three.js type the node stood for, which babylon.quarks has no node for.
1351
+ Object.assign(node, {quarksOriginalType: type});
908
1352
  return node;
909
1353
  }
910
1354
 
911
- private createMeshNode(data: any, meta: LoadedMeta): Mesh {
1355
+ private createMeshNode(data: QuarksNodeJSON, meta: LoadedMeta): Mesh {
912
1356
  const mesh = new Mesh(data.name || data.type || 'mesh', this.scene);
913
1357
  const geometry = this.getGeometry(meta, data.geometry);
914
1358
  if (geometry && geometry.positions.length > 0 && geometry.indices.length > 0) {
@@ -930,20 +1374,22 @@ export class QuarksLoader {
930
1374
  const materialInfo = this.getMaterial(meta, data.material);
931
1375
  if (materialInfo) {
932
1376
  const material = new StandardMaterial(`quarks_loader_material_${materialInfo.uuid}`, this.scene);
1377
+ meta.ownedMaterials.push(material);
933
1378
  material.alphaMode = materialInfo.alphaMode ?? Constants.ALPHA_COMBINE;
934
1379
  material.backFaceCulling = materialInfo.side !== 2;
935
1380
  material.alphaCutOff = materialInfo.alphaTest ?? 0;
936
1381
  material.disableDepthWrite = !(materialInfo.depthWrite ?? false);
937
- if (materialInfo.texture) {
938
- material.diffuseTexture = materialInfo.texture;
939
- material.opacityTexture = materialInfo.texture;
1382
+ const texture = materialInfo.texture;
1383
+ if (texture && typeof texture !== 'string') {
1384
+ material.diffuseTexture = texture;
1385
+ material.opacityTexture = texture;
940
1386
  }
941
1387
  mesh.material = material;
942
1388
  }
943
1389
  return mesh;
944
1390
  }
945
1391
 
946
- private parseObject(data: any, meta: LoadedMeta): TransformNode {
1392
+ private parseObject(data: QuarksNodeJSON, meta: LoadedMeta): TransformNode {
947
1393
  let node: TransformNode;
948
1394
  switch (data.type) {
949
1395
  case 'QuarksPrefab':
@@ -951,7 +1397,8 @@ export class QuarksLoader {
951
1397
  break;
952
1398
  case 'ParticleEmitter':
953
1399
  if (data.ps) {
954
- const ps = this.parseParticleSystem(data.ps, meta);
1400
+ const psJSON = data.ps;
1401
+ const ps = readFormatPart('ps', () => this.parseParticleSystem(psJSON, meta));
955
1402
  node = ps.emitter;
956
1403
  break;
957
1404
  }
@@ -989,7 +1436,7 @@ export class QuarksLoader {
989
1436
  node = new TransformNode(data.name || data.type || 'node', this.scene);
990
1437
  }
991
1438
 
992
- if (data.uuid) (node as any)._quarksUUID = data.uuid;
1439
+ if (data.uuid) setQuarksNodeId(node, data.uuid);
993
1440
  if (data.name) node.name = data.name;
994
1441
 
995
1442
  if (data.matrix) {
@@ -1017,21 +1464,34 @@ export class QuarksLoader {
1017
1464
  }
1018
1465
 
1019
1466
  if (data.visible !== undefined) node.setEnabled(data.visible);
1467
+ if (data.isVisible === false && node instanceof Mesh) node.isVisible = false;
1020
1468
 
1021
1469
  if (data.children) {
1022
- for (const childData of data.children) {
1023
- const child = this.parseObject(childData, meta);
1024
- child.parent = node;
1470
+ const children: unknown = data.children;
1471
+ if (!Array.isArray(children)) {
1472
+ readFormatPart('children', () => warnFormat('"children" is not a list; the node loads without them.'));
1473
+ } else {
1474
+ children.forEach((childData: unknown, index) => {
1475
+ const child = readFormatPart(`children[${index}]`, () =>
1476
+ isNodeJSON(childData)
1477
+ ? this.parseObject(childData, meta)
1478
+ : warnFormat('A child is not an object; it is skipped.')
1479
+ );
1480
+ if (child) child.parent = node;
1481
+ });
1025
1482
  }
1026
1483
  }
1027
1484
 
1028
1485
  return node;
1029
1486
  }
1030
1487
 
1031
- private parseParticleSystem(json: any, meta: LoadedMeta): ParticleSystem {
1488
+ private parseParticleSystem(json: ParticleSystemJSONParameters, meta: LoadedMeta): ParticleSystem {
1032
1489
  const dependencies: {[uuid: string]: Behavior} = {};
1033
- const ps = ParticleSystem.fromJSON(json, meta as any, dependencies, this.scene);
1034
- (ps as any)._meshSurfaceReferenceUUID = json?.shape?.type === 'mesh_surface' ? json?.shape?.mesh : undefined;
1490
+ const ps = ParticleSystem.fromJSON(json, meta, dependencies, this.scene);
1491
+ if (json?.shape?.type === 'mesh_surface' && typeof json.shape.mesh === 'string') {
1492
+ meshSurfaceLinks.set(ps, json.shape.mesh);
1493
+ }
1494
+ systemPaths.set(ps, currentFormatPath());
1035
1495
  return ps;
1036
1496
  }
1037
1497
 
@@ -1039,8 +1499,9 @@ export class QuarksLoader {
1039
1499
  const nodesMap: {[uuid: string]: TransformNode} = {};
1040
1500
 
1041
1501
  const traverse = (node: TransformNode) => {
1042
- if ((node as any)._quarksUUID) {
1043
- nodesMap[(node as any)._quarksUUID] = node;
1502
+ const fileId = fileNodeId(node);
1503
+ if (fileId) {
1504
+ nodesMap[fileId] = node;
1044
1505
  }
1045
1506
  for (const child of node.getChildren()) {
1046
1507
  if (child instanceof TransformNode) {
@@ -1056,23 +1517,34 @@ export class QuarksLoader {
1056
1517
  }
1057
1518
  if (node instanceof ParticleEmitter) {
1058
1519
  const system = node.system as ParticleSystem;
1059
- const meshSurfaceUUID = (system as any)._meshSurfaceReferenceUUID;
1060
- if (meshSurfaceUUID && (system.emitterShape as any).type === 'mesh_surface') {
1520
+ // Links are made once the whole effect is read; a warning names the system's path.
1521
+ const path = systemPaths.get(system);
1522
+ const warnAtSystem = (message: string) =>
1523
+ path ? readFormatPart(path, () => warnFormat(message)) : warnFormat(message);
1524
+ const meshSurfaceUUID = meshSurfaceLinks.get(system);
1525
+ const shape = system.emitterShape;
1526
+ if (meshSurfaceUUID && shape instanceof MeshSurfaceEmitter) {
1061
1527
  const targetNode = nodesMap[meshSurfaceUUID];
1062
1528
  if (targetNode instanceof Mesh) {
1063
- (system.emitterShape as any).mesh = targetNode;
1529
+ shape.mesh = targetNode;
1530
+ } else {
1531
+ warnAtSystem(
1532
+ `Emitter "${node.name}" has a mesh surface shape on "${meshSurfaceUUID}", ` +
1533
+ (targetNode ? 'which is not a mesh' : 'which is not in the effect') +
1534
+ '; it emits nothing until a mesh is set.'
1535
+ );
1064
1536
  }
1065
1537
  }
1066
1538
  for (let i = 0; i < system.behaviors.length; i++) {
1067
1539
  if (system.behaviors[i] instanceof EmitSubParticleSystem) {
1068
1540
  const subEmitter = system.behaviors[i] as EmitSubParticleSystem;
1069
- const targetUUID = subEmitter.subParticleSystem as any;
1541
+ const targetUUID = subEmitter.subParticleSystem;
1070
1542
  if (typeof targetUUID !== 'string') {
1071
1543
  continue;
1072
1544
  }
1073
1545
  const resolved = nodesMap[targetUUID];
1074
1546
  if (resolved instanceof ParticleEmitter) {
1075
- (subEmitter as any).subParticleSystem = resolved;
1547
+ subEmitter.subParticleSystem = resolved;
1076
1548
  (resolved.system as ParticleSystem).onlyUsedByOther = true;
1077
1549
  } else {
1078
1550
  // An export that dropped the target emitter, or kept only
@@ -1080,8 +1552,8 @@ export class QuarksLoader {
1080
1552
  // a string the behavior would treat as an emitter.
1081
1553
  subEmitter.subParticleSystem = undefined;
1082
1554
  if (targetUUID !== '') {
1083
- console.warn(
1084
- `[QuarksLoader] Emitter "${node.name}" has a sub emitter targeting "${targetUUID}", ` +
1555
+ warnAtSystem(
1556
+ `Emitter "${node.name}" has a sub emitter targeting "${targetUUID}", ` +
1085
1557
  (resolved ? 'which is not a particle emitter' : 'which is not in the effect') +
1086
1558
  '; that sub emitter is skipped.'
1087
1559
  );