babylon.quarks 0.25.0 → 0.27.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 (79) hide show
  1. package/CHANGELOG.md +854 -8
  2. package/README.md +10 -8
  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 +6683 -0
  10. package/dist/types/index.d.ts +2210 -613
  11. package/package.json +12 -6
  12. package/src/BatchedRenderer.ts +215 -106
  13. package/src/EffectPool.ts +4 -1
  14. package/src/MeshSurfaceEmitter.ts +29 -4
  15. package/src/ParticleEmitter.ts +45 -15
  16. package/src/ParticleSystem.ts +1012 -1962
  17. package/src/QuarksLoader.ts +463 -75
  18. package/src/QuarksPrefab.ts +91 -4
  19. package/src/QuarksSerializer.ts +484 -0
  20. package/src/QuarksUtil.ts +15 -0
  21. package/src/SpriteBatch.ts +149 -238
  22. package/src/TrailBatch.ts +586 -286
  23. package/src/VFXBatch.ts +517 -153
  24. package/src/batchKey.ts +237 -0
  25. package/src/effectResources.ts +79 -0
  26. package/src/envAtlas.ts +119 -17
  27. package/src/index.ts +51 -35
  28. package/src/nodeId.ts +13 -0
  29. package/src/shaderProgram.ts +325 -0
  30. package/src/shaderVariants.ts +225 -0
  31. package/src/shaders/chunks/fog.ts +4 -2
  32. package/src/shaders/chunks/sheet.ts +111 -0
  33. package/src/shaders/chunks/soft.ts +84 -0
  34. package/src/shaders/chunks/surface.ts +132 -42
  35. package/src/shaders/local_particle_physics_vert.glsl.ts +28 -27
  36. package/src/shaders/local_particle_physics_vert.wgsl.ts +30 -28
  37. package/src/shaders/particle_frag.glsl.ts +6 -22
  38. package/src/shaders/particle_frag.wgsl.ts +6 -23
  39. package/src/shaders/particle_physics_frag.glsl.ts +22 -29
  40. package/src/shaders/particle_physics_frag.wgsl.ts +22 -30
  41. package/src/shaders/particle_vert.glsl.ts +11 -26
  42. package/src/shaders/particle_vert.wgsl.ts +11 -25
  43. package/src/shaders/stretched_bb_particle_vert.glsl.ts +21 -39
  44. package/src/shaders/stretched_bb_particle_vert.wgsl.ts +22 -40
  45. package/src/shaders/trail_frag.glsl.ts +14 -1
  46. package/src/shaders/trail_frag.wgsl.ts +14 -0
  47. package/src/shaders/trail_vert.glsl.ts +53 -36
  48. package/src/shaders/trail_vert.wgsl.ts +54 -40
  49. package/src/softDepth.ts +136 -0
  50. package/src/system/BehaviorPlan.ts +85 -0
  51. package/src/system/BoundsTracker.ts +181 -0
  52. package/src/system/EmissionController.ts +360 -0
  53. package/src/system/EventDispatcher.ts +44 -0
  54. package/src/system/ParticleSpawner.ts +274 -0
  55. package/src/system/SimulationClock.ts +130 -0
  56. package/src/system/geometry.ts +111 -0
  57. package/src/system/integrate.ts +428 -0
  58. package/src/system/materialResolve.ts +116 -0
  59. package/src/system/normalize.ts +95 -0
  60. package/src/system/serialization.ts +401 -0
  61. package/src/system/settingsSchema.ts +317 -0
  62. package/src/system/types.ts +410 -0
  63. package/src/textureSource.ts +30 -0
  64. package/src/trailSettings.ts +51 -0
  65. package/src/BatchedParticleRenderer.ts +0 -3
  66. package/src/materials/ParticleMaterials.ts +0 -23
  67. package/src/materials/index.ts +0 -1
  68. package/src/shaders/chunks/index.ts +0 -1
  69. package/src/shaders/chunks/register-shader-chunks.ts +0 -32
  70. package/src/shaders/chunks/soft_fragment.glsl.ts +0 -13
  71. package/src/shaders/chunks/soft_pars_fragment.glsl.ts +0 -20
  72. package/src/shaders/chunks/soft_pars_vertex.glsl.ts +0 -6
  73. package/src/shaders/chunks/soft_vertex.glsl.ts +0 -6
  74. package/src/shaders/chunks/tile_fragment.glsl.ts +0 -9
  75. package/src/shaders/chunks/tile_pars_fragment.glsl.ts +0 -9
  76. package/src/shaders/chunks/tile_pars_vertex.glsl.ts +0 -28
  77. package/src/shaders/chunks/tile_vertex.glsl.ts +0 -18
  78. package/src/shaders/index.ts +0 -9
  79. package/src/shaders/local_particle_vert.glsl.ts +0 -50
@@ -1,5 +1,7 @@
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';
@@ -10,7 +12,10 @@ import {Behavior, EmitSubParticleSystem} from 'quarks.core';
10
12
  import {ParticleEmitter} from './ParticleEmitter';
11
13
  import {ParticleSystem} from './ParticleSystem';
12
14
  import {QuarksPrefab} from './QuarksPrefab';
15
+ import {QUARKS_FORMAT_VERSION} from './QuarksSerializer';
16
+ import {disposeEffect, retainEffectResources} from './effectResources';
13
17
  import {ensureTriangleIndices} from './geometryUtil';
18
+ import {setTextureSourceUrl} from './textureSource';
14
19
 
15
20
  /** Kind of linked asset the loader asks a {@link QuarksResourceResolver} to handle. */
16
21
  export type QuarksResourceKind = 'effect' | 'geometry' | 'image';
@@ -22,11 +27,20 @@ export type QuarksResourceKind = 'effect' | 'geometry' | 'image';
22
27
  export interface QuarksResourceResolver {
23
28
  /** Resolve a relative url to an absolute / blob / data url. Default: {@link QuarksLoader.joinUrl}. */
24
29
  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>;
30
+ /**
31
+ * Load a JSON asset (effect and geometry files). Default: `fetch(resolvedUrl).json()`.
32
+ * `options.signal` is passed for an effect document, whose fetch belongs to one load; geometry
33
+ * fetches are shared between loads and get none.
34
+ */
35
+ fetchJson?(resolvedUrl: string, options?: {signal?: AbortSignal}): Promise<unknown>;
27
36
  }
28
37
 
38
+ /** Options for a {@link QuarksLoader}, shared by every load it does. */
29
39
  export interface QuarksLoaderOptions {
40
+ /**
41
+ * Prefix joined to relative geometry and image urls (see {@link QuarksLoader.joinUrl}).
42
+ * Unset, {@link QuarksLoader.load} uses the effect file's own folder and the other loads `''`.
43
+ */
30
44
  baseUrl?: string;
31
45
  /**
32
46
  * Reuse one Texture per scene for every effect that references the same image with the same
@@ -39,10 +53,34 @@ export interface QuarksLoaderOptions {
39
53
  shareResources?: boolean;
40
54
  /** Custom url / fetch backend (File picker, CDN, IndexedDB). */
41
55
  resolver?: QuarksResourceResolver;
56
+ /**
57
+ * Makes `load`, `loadFromJSON`, `loadAll` and `loadFromJSONAll` resolve only once every texture
58
+ * the effect uses has loaded or failed, so it does not draw its first frames without them.
59
+ * A texture a scene with `useDelayedTextureLoading` would load only once drawn is loaded at
60
+ * once. `parse` never waits. Default false: they resolve when the nodes are built.
61
+ */
62
+ waitForTextures?: boolean;
63
+ /**
64
+ * Called for a geometry file or image that fails to load. The effect still loads: a geometry
65
+ * keeps its link unresolved, and a texture draws nothing. Default: a console warning.
66
+ */
67
+ onResourceError?: (kind: QuarksResourceKind, url: string, error: unknown) => void;
68
+ }
69
+
70
+ /** Options for one {@link QuarksLoader.load} or {@link QuarksLoader.loadFromJSON}. */
71
+ export interface QuarksLoadOptions {
72
+ /**
73
+ * Cancels the load, e.g. on a level change. The promise rejects with the signal's reason as
74
+ * soon as it is aborted, and no node of the effect is left in the scene. The effect document's
75
+ * own fetch is cancelled; a geometry fetch shared with other loads runs on, and its result is
76
+ * dropped. A scene disposed during the load rejects it the same way.
77
+ */
78
+ signal?: AbortSignal;
42
79
  }
43
80
 
44
81
  /** One in-memory effect for {@link QuarksLoader.loadFromJSONAll}. */
45
82
  export interface QuarksJSONLoadEntry {
83
+ /** The effect document, as {@link serializeEffect} or the editor writes it. Left unchanged. */
46
84
  json: unknown;
47
85
  /** Overrides loader `baseUrl` for relative texture / geometry links in this effect. */
48
86
  baseUrl?: string;
@@ -53,7 +91,7 @@ export interface QuarksJSONLoadEntry {
53
91
  * Batch loads share the per-scene fetch cache and `shareResources`, so N effects that point at
54
92
  * the same `assets/geometries/*.json` or texture only pay for that asset once.
55
93
  */
56
- export interface QuarksBatchLoadOptions {
94
+ export interface QuarksBatchLoadOptions extends QuarksLoadOptions {
57
95
  /**
58
96
  * Max concurrent effect-document fetches (phase 1). Linked geometry/image resolve still runs
59
97
  * across the whole pack in parallel so shared urls coalesce. Default: all at once.
@@ -75,6 +113,7 @@ interface ParsedGeometry {
75
113
  positions: Float32Array;
76
114
  indices: Uint32Array | Uint16Array;
77
115
  uvs?: Float32Array;
116
+ uvs2?: Float32Array;
78
117
  normals?: Float32Array;
79
118
  }
80
119
 
@@ -93,6 +132,12 @@ function sceneMap<T>(maps: WeakMap<Scene, Map<string, T>>, scene: Scene): Map<st
93
132
  return map;
94
133
  }
95
134
 
135
+ /** Image entries {@link QuarksLoader.resolveLinkedResources} rewrote, with the url it wrote. */
136
+ const resolvedImageUrls = new WeakMap<object, string>();
137
+
138
+ /** Image entries {@link QuarksLoader.resolveLinkedResources} rewrote, with the url the file had. */
139
+ const sourceImageUrls = new WeakMap<object, string>();
140
+
96
141
  /** Content key for a geometry: two independent 32-bit hashes over every array's bytes, plus lengths. */
97
142
  function geometryKey(geometry: ParsedGeometry): string {
98
143
  let h1 = 0x811c9dc5;
@@ -101,6 +146,7 @@ function geometryKey(geometry: ParsedGeometry): string {
101
146
  geometry.positions,
102
147
  geometry.indices,
103
148
  geometry.uvs,
149
+ geometry.uvs2,
104
150
  geometry.normals,
105
151
  ];
106
152
  let lengths = '';
@@ -119,12 +165,109 @@ function geometryKey(geometry: ParsedGeometry): string {
119
165
  return `${lengths}${(h1 >>> 0).toString(16)}${(h2 >>> 0).toString(16)}`;
120
166
  }
121
167
 
168
+ /**
169
+ * Thrown for a document the loader cannot read at all, such as one with no `object`. A part it
170
+ * cannot read (an unknown behavior, shape or curve type, a missing setting) only warns, and that
171
+ * part takes its default.
172
+ */
173
+ export class QuarksFormatError extends Error {
174
+ constructor(message: string) {
175
+ super(message);
176
+ this.name = 'QuarksFormatError';
177
+ }
178
+ }
179
+
180
+ /**
181
+ * A copy of an effect document that loading can rewrite (resolved image urls, inlined geometry
182
+ * files) without touching the caller's. Only the entries that change are copied.
183
+ */
184
+ function detachDocument(json: any): any {
185
+ if (json === null || typeof json !== 'object') {
186
+ return json;
187
+ }
188
+ const copy = {...json};
189
+ if (Array.isArray(json.images)) {
190
+ copy.images = json.images.map((image: unknown) => {
191
+ if (!image || typeof image !== 'object') {
192
+ return image;
193
+ }
194
+ const imageCopy: {url?: unknown} = {...(image as object)};
195
+ // An entry resolveResources() already rewrote stays resolved in its copy, so loading the
196
+ // document it prepared, with the same base, does not join the base a second time.
197
+ if (resolvedImageUrls.get(image) === imageCopy.url && typeof imageCopy.url === 'string') {
198
+ resolvedImageUrls.set(imageCopy, imageCopy.url);
199
+ const source = sourceImageUrls.get(image);
200
+ if (source !== undefined) {
201
+ sourceImageUrls.set(imageCopy, source);
202
+ }
203
+ }
204
+ return imageCopy;
205
+ });
206
+ }
207
+ if (Array.isArray(json.geometries)) {
208
+ copy.geometries = json.geometries.map((geometry: unknown) =>
209
+ geometry && typeof geometry === 'object' ? {...(geometry as object)} : geometry
210
+ );
211
+ }
212
+ return copy;
213
+ }
214
+
215
+ /** Settles when a texture the loader made has loaded or failed. */
216
+ const textureSettled = new WeakMap<Texture, Promise<void>>();
217
+
218
+ const abortReason = (signal: AbortSignal) => signal.reason ?? new Error('[QuarksLoader] The load was aborted.');
219
+ const sceneDisposed = () => new Error('[QuarksLoader] The scene was disposed while the effect loaded.');
220
+
221
+ /** Throws the reason a load stops early: its signal was aborted, or its scene disposed. */
222
+ function checkLoad(scene: Scene, signal: AbortSignal | undefined): void {
223
+ if (signal?.aborted) {
224
+ throw abortReason(signal);
225
+ }
226
+ if (scene.isDisposed) {
227
+ throw sceneDisposed();
228
+ }
229
+ }
230
+
231
+ /** Follows `promise`, or rejects as {@link checkLoad} would as soon as the load is stopped. */
232
+ function untilStopped<T>(promise: Promise<T>, scene: Scene, signal: AbortSignal | undefined): Promise<T> {
233
+ checkLoad(scene, signal);
234
+ return new Promise<T>((resolve, reject) => {
235
+ const aborted = () => reject(abortReason(signal!));
236
+ signal?.addEventListener('abort', aborted, {once: true});
237
+ const disposed = scene.onDisposeObservable.addOnce(() => reject(sceneDisposed()));
238
+ promise.then(resolve, reject).finally(() => {
239
+ signal?.removeEventListener('abort', aborted);
240
+ scene.onDisposeObservable.remove(disposed);
241
+ });
242
+ });
243
+ }
244
+
122
245
  interface LoadedMeta {
246
+ /** The materials this load made, which the effect owns. */
247
+ ownedMaterials: Material[];
248
+ /** Image uuid to the url its textures load. */
249
+ images: {[uuid: string]: string | null};
250
+ /** Image uuid to the url the file wrote, before any base url was joined. */
251
+ imageSources: {[uuid: string]: string};
123
252
  textures: {[uuid: string]: Texture | null};
124
253
  geometries: {[uuid: string]: ParsedGeometry};
125
254
  materials: {[uuid: string]: any};
126
255
  }
127
256
 
257
+ /**
258
+ * Loads effects saved as JSON into a scene: fetches the document and the geometry and image files
259
+ * it links, builds the nodes, particle systems, textures and materials, and returns the root.
260
+ * Add the systems to a {@link BatchedRenderer} with {@link QuarksUtil.addToBatchRenderer} to draw
261
+ * them, and free the effect with {@link QuarksLoader.disposeEffect}.
262
+ *
263
+ * @example
264
+ * ```ts
265
+ * const loader = new QuarksLoader(scene, {waitForTextures: true});
266
+ * const effect = await loader.load('effects/fireball.json');
267
+ * QuarksUtil.addToBatchRenderer(effect, renderer);
268
+ * QuarksUtil.play(effect);
269
+ * ```
270
+ */
128
271
  export class QuarksLoader {
129
272
  private scene: Scene;
130
273
  private options: QuarksLoaderOptions;
@@ -134,20 +277,77 @@ export class QuarksLoader {
134
277
  this.options = options;
135
278
  }
136
279
 
137
- /** Fetches an effect JSON from `url`, resolves linked sources, and parses it. */
138
- async load(url: string): Promise<TransformNode> {
139
- const {json, baseUrl} = await this.fetchEffectDocument(url);
140
- return this.loadFromJSON(json, baseUrl);
280
+ /**
281
+ * Fetches an effect JSON from `url`, resolves linked sources, and parses it. Free the effect
282
+ * with {@link disposeEffect}.
283
+ */
284
+ async load(url: string, options: QuarksLoadOptions = {}): Promise<TransformNode> {
285
+ const {json, baseUrl} = await untilStopped(
286
+ this.fetchEffectDocument(url, options.signal),
287
+ this.scene,
288
+ options.signal
289
+ );
290
+ return this.loadFromJSON(json, baseUrl, options);
141
291
  }
142
292
 
143
293
  /**
144
- * Resolves linked geometry / image urls on `json` (via {@link resolveResources}) then parses.
145
- * Use when the effect JSON is already in hand (File, CDN payload, editor import).
294
+ * Resolves linked geometry / image urls (via {@link resolveResources}) then parses. Use when
295
+ * the effect JSON is already in hand (File, CDN payload, editor import). `json` itself is
296
+ * left as it was, so the same document can be loaded again, into another scene or with
297
+ * another base url.
146
298
  */
147
- async loadFromJSON(json: any, baseUrl?: string): Promise<TransformNode> {
299
+ async loadFromJSON(json: any, baseUrl?: string, options: QuarksLoadOptions = {}): Promise<TransformNode> {
300
+ const signal = options.signal;
301
+ checkLoad(this.scene, signal);
148
302
  const resolvedBase = baseUrl ?? this.options.baseUrl ?? '';
149
- await this.resolveResources(json, resolvedBase);
150
- return this.parse(json, resolvedBase);
303
+ json = detachDocument(json);
304
+ await untilStopped(this.resolveResources(json, resolvedBase), this.scene, signal);
305
+ // The urls are resolved now; joining them with the base again would load `fx/fx/...`.
306
+ const loaded = this.parseDocument(json, '');
307
+ await this.finishLoad(loaded, signal);
308
+ return loaded.root;
309
+ }
310
+
311
+ /**
312
+ * Disposes an effect a loader built: its nodes and systems, the materials made for it, and
313
+ * each texture it uses once no other loaded effect uses it (textures are shared per scene,
314
+ * see {@link QuarksLoaderOptions.shareResources}). Copies made with
315
+ * `QuarksUtil.instantiate` hold the textures too, so disposing the source effect leaves them
316
+ * drawing. The same happens when the root is disposed any other way (`root.dispose()`, its
317
+ * parent's dispose, the scene's). The materials and textures are freed even when other code
318
+ * was handed them; give that code its own. For a root the loader did not build, this is
319
+ * `root.dispose()`.
320
+ */
321
+ static disposeEffect(root: TransformNode): void {
322
+ disposeEffect(root);
323
+ }
324
+
325
+ /**
326
+ * Waits for the effect's textures when the loader is asked to, and frees the effect when the
327
+ * load is stopped meanwhile.
328
+ */
329
+ private async finishLoad(loaded: {root: TransformNode; textures: Texture[]}, signal?: AbortSignal): Promise<void> {
330
+ try {
331
+ if (this.options.waitForTextures) {
332
+ for (const texture of loaded.textures) {
333
+ // A delayed texture would load only once drawn, which it is not until the load resolves.
334
+ if (texture.delayLoadState === Constants.DELAYLOADSTATE_NOTLOADED) {
335
+ texture.delayLoad();
336
+ }
337
+ }
338
+ await untilStopped(
339
+ Promise.all(loaded.textures.map((texture) => textureSettled.get(texture))),
340
+ this.scene,
341
+ signal
342
+ );
343
+ }
344
+ checkLoad(this.scene, signal);
345
+ } catch (err) {
346
+ if (!this.scene.isDisposed) {
347
+ disposeEffect(loaded.root);
348
+ }
349
+ throw err;
350
+ }
151
351
  }
152
352
 
153
353
  /**
@@ -168,17 +368,21 @@ export class QuarksLoader {
168
368
  options: QuarksBatchLoadOptions & {continueOnError: true}
169
369
  ): Promise<(TransformNode | null)[]>;
170
370
  async loadAll(urls: string[], options: QuarksBatchLoadOptions = {}): Promise<(TransformNode | null)[]> {
171
- const docs = await QuarksLoader.mapPool(urls, options.concurrency, async (url, index) => {
371
+ checkLoad(this.scene, options.signal);
372
+ const fetching = QuarksLoader.mapPool(urls, options.concurrency, async (url, index) => {
373
+ checkLoad(this.scene, options.signal);
172
374
  try {
173
- return await this.fetchEffectDocument(url);
375
+ return await this.fetchEffectDocument(url, options.signal);
174
376
  } catch (err) {
175
- if (!options.continueOnError) {
377
+ // A stopped load is never one effect's failure.
378
+ if (!options.continueOnError || options.signal?.aborted || this.scene.isDisposed) {
176
379
  throw err;
177
380
  }
178
381
  console.warn(`[QuarksLoader] Failed to fetch effect "${url}" (${index + 1}/${urls.length}):`, err);
179
382
  return null;
180
383
  }
181
384
  });
385
+ const docs = await untilStopped(fetching, this.scene, options.signal);
182
386
  return this.loadDocuments(docs, options);
183
387
  }
184
388
 
@@ -199,44 +403,59 @@ export class QuarksLoader {
199
403
  options: QuarksBatchLoadOptions = {}
200
404
  ): Promise<(TransformNode | null)[]> {
201
405
  const docs: Array<{json: any; baseUrl: string} | null> = entries.map((entry) => {
406
+ // Copied, as loadFromJSON does, so the caller's documents stay as they were.
202
407
  if (entry !== null && typeof entry === 'object' && 'json' in (entry as object)) {
203
408
  const typed = entry as QuarksJSONLoadEntry;
204
- return {json: typed.json as any, baseUrl: typed.baseUrl ?? this.options.baseUrl ?? ''};
409
+ return {json: detachDocument(typed.json), baseUrl: typed.baseUrl ?? this.options.baseUrl ?? ''};
205
410
  }
206
- return {json: entry as any, baseUrl: this.options.baseUrl ?? ''};
411
+ return {json: detachDocument(entry), baseUrl: this.options.baseUrl ?? ''};
207
412
  });
208
413
  return this.loadDocuments(docs, options);
209
414
  }
210
415
 
211
416
  /**
212
417
  * Inlines external geometry files and rewrites relative image urls through the loader's
213
- * {@link QuarksResourceResolver}. Idempotent for already-inlined geometries. Failures warn
214
- * and leave the entry as is.
418
+ * {@link QuarksResourceResolver}, in place on `json`. Each document is resolved once:
419
+ * geometries already inlined and image urls this method already rewrote are left alone, so
420
+ * loading the same document again does not join its base url twice. Failures warn and leave
421
+ * the entry as is.
215
422
  */
216
423
  async resolveResources(json: any, baseUrl: string = ''): Promise<void> {
217
424
  await QuarksLoader.resolveLinkedResources(json, {
218
425
  baseUrl,
219
426
  resolver: this.options.resolver,
220
427
  scene: this.scene,
428
+ onResourceError: this.options.onResourceError,
221
429
  });
222
430
  }
223
431
 
224
432
  /**
225
433
  * Same as instance {@link resolveResources}, but usable without constructing a loader (e.g.
226
434
  * editor import that only prepares JSON). Pass `scene` to share the per-scene fetch cache.
435
+ * Like it, resolves each document once. A geometry file that fails to load goes to
436
+ * `onResourceError` (default: a console warning) and keeps its link.
227
437
  */
228
438
  static async resolveLinkedResources(
229
439
  json: any,
230
- options: {baseUrl?: string; resolver?: QuarksResourceResolver; scene?: Scene} = {}
440
+ options: {
441
+ baseUrl?: string;
442
+ resolver?: QuarksResourceResolver;
443
+ scene?: Scene;
444
+ onResourceError?: QuarksLoaderOptions['onResourceError'];
445
+ } = {}
231
446
  ): Promise<void> {
232
447
  const baseUrl = options.baseUrl ?? '';
233
448
  const resolver = options.resolver;
234
449
  const scene = options.scene;
235
450
 
236
- await QuarksLoader.inlineGeometryEntries(json, async (geometryUrl) => {
237
- const resolved = await QuarksLoader.resolveWith(resolver, geometryUrl, 'geometry', baseUrl);
238
- return QuarksLoader.fetchJsonWith(resolver, resolved, scene);
239
- });
451
+ await QuarksLoader.inlineGeometryEntries(
452
+ json,
453
+ async (geometryUrl) => {
454
+ const resolved = await QuarksLoader.resolveWith(resolver, geometryUrl, 'geometry', baseUrl);
455
+ return QuarksLoader.fetchJsonWith(resolver, resolved, scene);
456
+ },
457
+ options.onResourceError
458
+ );
240
459
 
241
460
  const images: any[] = Array.isArray(json?.images) ? json.images : [];
242
461
  await Promise.all(
@@ -244,7 +463,19 @@ export class QuarksLoader {
244
463
  if (typeof img?.url !== 'string' || !img.url) {
245
464
  return;
246
465
  }
466
+ // An entry this loader already rewrote keeps its url: resolving the same document
467
+ // again (a second load, or a fetch cached for several loads) would join it twice.
468
+ if (resolvedImageUrls.get(img) === img.url) {
469
+ return;
470
+ }
471
+ const source: string = img.url;
247
472
  img.url = await QuarksLoader.resolveWith(resolver, img.url, 'image', baseUrl);
473
+ resolvedImageUrls.set(img, img.url);
474
+ // A resolver that inlined the file (`data:`, `blob:`) made the url the image itself;
475
+ // the file's path no longer names what the texture holds, so a save writes the url.
476
+ if (!/^(data|blob):/i.test(img.url)) {
477
+ sourceImageUrls.set(img, source);
478
+ }
248
479
  })
249
480
  );
250
481
  }
@@ -259,8 +490,32 @@ export class QuarksLoader {
259
490
  await QuarksLoader.inlineGeometryEntries(json, loadGeometry);
260
491
  }
261
492
 
493
+ /**
494
+ * Builds the node tree of an effect document whose linked files are already inline (see
495
+ * {@link loadFromJSON}). `baseUrl` is joined to relative image urls. Parts the loader cannot
496
+ * read warn and take their defaults; a document with no `object` throws
497
+ * {@link QuarksFormatError}. Free the effect with {@link disposeEffect}.
498
+ */
262
499
  parse(json: any, baseUrl: string = ''): TransformNode {
500
+ return this.parseDocument(json, baseUrl).root;
501
+ }
502
+
503
+ /** {@link parse}, and the textures the effect uses. */
504
+ private parseDocument(json: any, baseUrl: string): {root: TransformNode; textures: Texture[]} {
505
+ if (json === null || typeof json !== 'object' || json.object === null || typeof json.object !== 'object') {
506
+ throw new QuarksFormatError('[QuarksLoader] Not an effect document: it has no "object" to load.');
507
+ }
508
+ const format = json.metadata?.quarksFormat;
509
+ if (typeof format === 'number' && format > QUARKS_FORMAT_VERSION) {
510
+ console.warn(
511
+ `[QuarksLoader] This effect was written in format ${format}, newer than the ${QUARKS_FORMAT_VERSION} ` +
512
+ 'this version of babylon.quarks reads; settings it does not know are left out.'
513
+ );
514
+ }
263
515
  const meta: LoadedMeta = {
516
+ ownedMaterials: [],
517
+ images: {},
518
+ imageSources: {},
264
519
  textures: {},
265
520
  geometries: {},
266
521
  materials: {},
@@ -281,7 +536,9 @@ export class QuarksLoader {
281
536
 
282
537
  const root = this.parseObject(json.object, meta);
283
538
  this.linkReferences(root);
284
- return root;
539
+ const textures = [...new Set(Object.values(meta.textures).filter((t): t is Texture => t !== null))];
540
+ retainEffectResources(root, textures, meta.ownedMaterials);
541
+ return {root, textures};
285
542
  }
286
543
 
287
544
  private parseGeometries(geometries: any[], meta: LoadedMeta): void {
@@ -313,6 +570,7 @@ export class QuarksLoader {
313
570
  positions: new Float32Array(geom.positions ?? []),
314
571
  indices: geom.indices ? new Uint32Array(geom.indices) : new Uint32Array(0),
315
572
  uvs: geom.uvs ? new Float32Array(geom.uvs) : undefined,
573
+ uvs2: geom.uvs2 ? new Float32Array(geom.uvs2) : undefined,
316
574
  normals: geom.normals ? new Float32Array(geom.normals) : undefined,
317
575
  });
318
576
  }
@@ -427,7 +685,10 @@ export class QuarksLoader {
427
685
  let positions = new Float32Array(0);
428
686
  let indices: Uint32Array | Uint16Array = new Uint32Array(0);
429
687
  let uvs: Float32Array | undefined;
688
+ let uvs2: Float32Array | undefined;
430
689
  let normals: Float32Array | undefined;
690
+ // three.js names the second UV set `uv1` (r151 on), `uv2` before.
691
+ const uv2Attribute = data.attributes?.uv1 ?? data.attributes?.uv2;
431
692
 
432
693
  if (data.interleavedBuffers && data.arrayBuffers) {
433
694
  const buffers: {[uuid: string]: {data: Float32Array; stride: number}} = {};
@@ -453,6 +714,16 @@ export class QuarksLoader {
453
714
  const buf = buffers[attr.data];
454
715
  if (buf) uvs = this.extractInterleavedAttribute(buf.data, buf.stride, attr.offset, attr.itemSize);
455
716
  }
717
+ if (uv2Attribute?.isInterleavedBufferAttribute) {
718
+ const buf = buffers[uv2Attribute.data];
719
+ if (buf)
720
+ uvs2 = this.extractInterleavedAttribute(
721
+ buf.data,
722
+ buf.stride,
723
+ uv2Attribute.offset,
724
+ uv2Attribute.itemSize
725
+ );
726
+ }
456
727
  if (data.attributes.normal?.isInterleavedBufferAttribute) {
457
728
  const attr = data.attributes.normal;
458
729
  const buf = buffers[attr.data];
@@ -467,6 +738,9 @@ export class QuarksLoader {
467
738
  if (data.attributes.uv?.array) {
468
739
  uvs = new Float32Array(data.attributes.uv.array);
469
740
  }
741
+ if (uv2Attribute?.array) {
742
+ uvs2 = new Float32Array(uv2Attribute.array);
743
+ }
470
744
  if (data.attributes.normal?.array) {
471
745
  normals = new Float32Array(data.attributes.normal.array);
472
746
  }
@@ -482,7 +756,7 @@ export class QuarksLoader {
482
756
 
483
757
  indices = ensureTriangleIndices(positions, indices);
484
758
 
485
- return {positions, indices, uvs, normals};
759
+ return {positions, indices, uvs, uvs2, normals};
486
760
  }
487
761
 
488
762
  private extractInterleavedAttribute(buffer: Float32Array, stride: number, offset: number, itemSize: number): any {
@@ -510,10 +784,10 @@ export class QuarksLoader {
510
784
  }
511
785
 
512
786
  /** Fetches one effect JSON; documents are not fetch-cached because resolve mutates them. */
513
- private async fetchEffectDocument(url: string): Promise<{json: any; baseUrl: string}> {
787
+ private async fetchEffectDocument(url: string, signal?: AbortSignal): Promise<{json: any; baseUrl: string}> {
514
788
  const baseUrl = this.options.baseUrl || url.substring(0, url.lastIndexOf('/') + 1);
515
789
  const resolvedEffect = await this.resolveResourceUrl(url, 'effect', baseUrl);
516
- const json = await QuarksLoader.fetchJsonWith(this.options.resolver, resolvedEffect, undefined);
790
+ const json = await QuarksLoader.fetchJsonWith(this.options.resolver, resolvedEffect, undefined, signal);
517
791
  return {json, baseUrl};
518
792
  }
519
793
 
@@ -529,12 +803,30 @@ export class QuarksLoader {
529
803
  if (total === 0) {
530
804
  return [];
531
805
  }
806
+ checkLoad(this.scene, options.signal);
807
+ // Stops the whole batch on abort, and the effects still waiting for textures once one fails.
808
+ const stopAll = new AbortController();
809
+ const abort = () => stopAll.abort(abortReason(options.signal!));
810
+ options.signal?.addEventListener('abort', abort, {once: true});
811
+ try {
812
+ return await this.loadResolvedDocuments(docs, options, stopAll);
813
+ } finally {
814
+ options.signal?.removeEventListener('abort', abort);
815
+ }
816
+ }
532
817
 
533
- const results: (TransformNode | null)[] = new Array(total);
818
+ private async loadResolvedDocuments(
819
+ docs: Array<{json: any; baseUrl: string} | null>,
820
+ options: QuarksBatchLoadOptions,
821
+ stopAll: AbortController
822
+ ): Promise<(TransformNode | null)[]> {
823
+ const total = docs.length;
824
+ const signal = stopAll.signal;
825
+ const results: (TransformNode | null)[] = new Array(total).fill(null);
534
826
  const settled = new Array<boolean>(total).fill(false);
535
827
  let done = 0;
536
828
  const settle = (index: number, value: TransformNode | null) => {
537
- if (settled[index]) {
829
+ if (settled[index] || signal.aborted) {
538
830
  return;
539
831
  }
540
832
  settled[index] = true;
@@ -543,42 +835,67 @@ export class QuarksLoader {
543
835
  options.onProgress?.(done, total);
544
836
  };
545
837
 
546
- await Promise.all(
547
- docs.map(async (doc, i) => {
838
+ await untilStopped(
839
+ Promise.all(
840
+ docs.map(async (doc, i) => {
841
+ if (!doc) {
842
+ return;
843
+ }
844
+ try {
845
+ await this.resolveResources(doc.json, doc.baseUrl);
846
+ } catch (err) {
847
+ if (!options.continueOnError) {
848
+ throw err;
849
+ }
850
+ console.warn(`[QuarksLoader] Failed to resolve resources for effect ${i + 1}/${total}:`, err);
851
+ docs[i] = null;
852
+ settle(i, null);
853
+ }
854
+ })
855
+ ),
856
+ this.scene,
857
+ signal
858
+ );
859
+
860
+ const loaded: Array<{root: TransformNode; textures: Texture[]}> = [];
861
+ const finishing: Promise<void>[] = [];
862
+ try {
863
+ for (let i = 0; i < total; i++) {
864
+ if (settled[i]) {
865
+ continue;
866
+ }
867
+ const doc = docs[i];
548
868
  if (!doc) {
549
- return;
869
+ settle(i, null);
870
+ continue;
550
871
  }
872
+ let effect: {root: TransformNode; textures: Texture[]};
551
873
  try {
552
- await this.resolveResources(doc.json, doc.baseUrl);
874
+ // Resolved above, so the base must not be joined a second time.
875
+ effect = this.parseDocument(doc.json, '');
553
876
  } catch (err) {
554
877
  if (!options.continueOnError) {
555
878
  throw err;
556
879
  }
557
- console.warn(`[QuarksLoader] Failed to resolve resources for effect ${i + 1}/${total}:`, err);
558
- docs[i] = null;
880
+ console.warn(`[QuarksLoader] Failed to parse effect ${i + 1}/${total}:`, err);
559
881
  settle(i, null);
882
+ continue;
560
883
  }
561
- })
562
- );
563
-
564
- for (let i = 0; i < total; i++) {
565
- if (settled[i]) {
566
- continue;
884
+ loaded.push(effect);
885
+ // An effect counts as loaded once its textures have too, when the loader waits for them.
886
+ finishing.push(this.finishLoad(effect, signal).then(() => settle(i, effect.root)));
567
887
  }
568
- const doc = docs[i];
569
- if (!doc) {
570
- settle(i, null);
571
- continue;
888
+ await Promise.all(finishing);
889
+ } catch (err) {
890
+ // One failure, or the load stopped: stop the rest and free every effect already built.
891
+ if (!signal.aborted) {
892
+ stopAll.abort(err);
572
893
  }
573
- try {
574
- settle(i, this.parse(doc.json, doc.baseUrl));
575
- } catch (err) {
576
- if (!options.continueOnError) {
577
- throw err;
578
- }
579
- console.warn(`[QuarksLoader] Failed to parse effect ${i + 1}/${total}:`, err);
580
- settle(i, null);
894
+ void Promise.allSettled(finishing);
895
+ if (!this.scene.isDisposed) {
896
+ loaded.forEach((effect) => disposeEffect(effect.root));
581
897
  }
898
+ throw err;
582
899
  }
583
900
  return results;
584
901
  }
@@ -624,13 +941,15 @@ export class QuarksLoader {
624
941
  private static fetchJsonWith(
625
942
  resolver: QuarksResourceResolver | undefined,
626
943
  resolvedUrl: string,
627
- scene: Scene | undefined
944
+ scene: Scene | undefined,
945
+ signal?: AbortSignal
628
946
  ): Promise<unknown> {
629
947
  const load = async () => {
630
948
  if (resolver?.fetchJson) {
631
- return resolver.fetchJson(resolvedUrl);
949
+ return signal ? resolver.fetchJson(resolvedUrl, {signal}) : resolver.fetchJson(resolvedUrl);
632
950
  }
633
- const response = await fetch(resolvedUrl);
951
+ // Only an uncached fetch (an effect document) can be cancelled: a cached one is shared.
952
+ const response = await (signal ? fetch(resolvedUrl, {signal}) : fetch(resolvedUrl));
634
953
  if (!response.ok) {
635
954
  throw new Error(`HTTP ${response.status}`);
636
955
  }
@@ -656,7 +975,11 @@ export class QuarksLoader {
656
975
  return pending;
657
976
  }
658
977
 
659
- private static async inlineGeometryEntries(json: any, loadGeometry: (url: string) => Promise<any>): Promise<void> {
978
+ private static async inlineGeometryEntries(
979
+ json: any,
980
+ loadGeometry: (url: string) => Promise<any>,
981
+ onResourceError: QuarksLoaderOptions['onResourceError'] = QuarksLoader.warnResourceError
982
+ ): Promise<void> {
660
983
  const geometries: any[] = Array.isArray(json?.geometries) ? json.geometries : [];
661
984
  await Promise.all(
662
985
  geometries
@@ -666,7 +989,7 @@ export class QuarksLoader {
666
989
  const data = await loadGeometry(geom.url);
667
990
  // Copy arrays so a shared fetch-cache document is not aliased across
668
991
  // effects — mutating one effect's inlined geometry must not corrupt others.
669
- for (const key of ['positions', 'indices', 'uvs', 'normals']) {
992
+ for (const key of ['positions', 'indices', 'uvs', 'uvs2', 'normals']) {
670
993
  const value = data?.[key];
671
994
  if (value === undefined) {
672
995
  continue;
@@ -675,21 +998,34 @@ export class QuarksLoader {
675
998
  }
676
999
  delete geom.url;
677
1000
  } catch (err) {
678
- console.warn(`[QuarksLoader] Could not load geometry "${geom.url}":`, err);
1001
+ onResourceError('geometry', geom.url, err);
679
1002
  }
680
1003
  })
681
1004
  );
682
1005
  }
683
1006
 
684
- private parseImages(images: any[], baseUrl: string, _meta: LoadedMeta): void {
685
- (this as any)._images = {};
1007
+ private static warnResourceError(kind: QuarksResourceKind, url: string, error: unknown): void {
1008
+ console.warn(`[QuarksLoader] Could not load ${kind} "${url}":`, error);
1009
+ }
1010
+
1011
+ private reportResourceError(kind: QuarksResourceKind, url: string, error: unknown): void {
1012
+ (this.options.onResourceError ?? QuarksLoader.warnResourceError)(kind, url, error);
1013
+ }
1014
+
1015
+ private parseImages(images: any[], baseUrl: string, meta: LoadedMeta): void {
686
1016
  for (const img of images) {
687
1017
  if (img.url) {
688
1018
  QuarksLoader.warnIfNotAnImageUrl(img.url);
689
1019
  }
690
1020
  // Urls are usually already absolute after resolveResources; joinUrl is a no-op for
691
1021
  // data/blob/http(s) and still supports callers that use parse() with a baseUrl only.
692
- (this as any)._images[img.uuid] = img.url ? QuarksLoader.joinUrl(baseUrl, img.url) : null;
1022
+ meta.images[img.uuid] = img.url ? QuarksLoader.joinUrl(baseUrl, img.url) : null;
1023
+ // The url the file wrote, for serializeEffect to write back. An inlined image
1024
+ // (`data:`, `blob:`) has no path to write; its texture writes its url.
1025
+ const source: string | undefined = sourceImageUrls.get(img) ?? img.url;
1026
+ if (source && !/^(data|blob):/i.test(source)) {
1027
+ meta.imageSources[img.uuid] = source;
1028
+ }
693
1029
  }
694
1030
  }
695
1031
 
@@ -713,9 +1049,8 @@ export class QuarksLoader {
713
1049
  }
714
1050
 
715
1051
  private parseTextures(textures: any[], meta: LoadedMeta): void {
716
- const images = (this as any)._images || {};
717
1052
  for (const texDef of textures) {
718
- const imageUrl = images[texDef.image];
1053
+ const imageUrl = meta.images[texDef.image];
719
1054
  if (imageUrl) {
720
1055
  const invertY = texDef.invertY !== undefined ? !!texDef.invertY : true;
721
1056
  const noMipmap = !!texDef.noMipmap;
@@ -741,11 +1076,41 @@ export class QuarksLoader {
741
1076
  meta.textures[texDef.uuid] = cached;
742
1077
  continue;
743
1078
  }
744
- const texture = new Texture(imageUrl, this.scene, {
1079
+ let settle!: () => void;
1080
+ const settled = new Promise<void>((resolve) => (settle = resolve));
1081
+ let failed = false;
1082
+ // Babylon calls onError from inside the constructor, before `texture` is assigned, and
1083
+ // then throws, when the engine cannot make the texture at all.
1084
+ let texture: Texture | undefined = undefined;
1085
+ const options: ITextureCreationOptions = {
745
1086
  noMipmap,
746
1087
  invertY,
747
1088
  samplingMode: typeof texDef.samplingMode === 'number' ? texDef.samplingMode : undefined,
748
- });
1089
+ onLoad: () => settle(),
1090
+ onError: (message, exception) => {
1091
+ failed = true;
1092
+ // A later load tries the image again rather than reuse the failed texture.
1093
+ if (cache !== undefined && cache.get(key) === texture) {
1094
+ cache.delete(key);
1095
+ }
1096
+ this.reportResourceError('image', imageUrl, exception ?? message);
1097
+ settle();
1098
+ },
1099
+ };
1100
+ try {
1101
+ texture = new Texture(imageUrl, this.scene, options);
1102
+ } catch {
1103
+ // Reported through onError: the effect draws without this texture.
1104
+ meta.textures[texDef.uuid] = null;
1105
+ continue;
1106
+ }
1107
+ textureSettled.set(texture, settled);
1108
+ const source = meta.imageSources[texDef.image];
1109
+ if (source !== undefined) {
1110
+ setTextureSourceUrl(texture, source, imageUrl);
1111
+ }
1112
+ // A texture disposed before it loads never will.
1113
+ texture.onDisposeObservable.addOnce(() => settle());
749
1114
  if (Array.isArray(texDef.wrap)) {
750
1115
  texture.wrapU = this.mapWrapMode(texDef.wrap[0]);
751
1116
  texture.wrapV = this.mapWrapMode(texDef.wrap[1] ?? texDef.wrap[0]);
@@ -761,7 +1126,7 @@ export class QuarksLoader {
761
1126
  if (typeof texDef.rotation === 'number') {
762
1127
  texture.wAng = texDef.rotation;
763
1128
  }
764
- if (cache !== undefined) {
1129
+ if (cache !== undefined && !failed) {
765
1130
  cache.set(key, texture);
766
1131
  // Something outside the library may dispose it; never hand out a dead texture.
767
1132
  texture.onDisposeObservable.addOnce(() => {
@@ -824,13 +1189,18 @@ export class QuarksLoader {
824
1189
  if (matDef.rim && typeof matDef.rim === 'object') {
825
1190
  matInfo.rim = matDef.rim;
826
1191
  }
827
- // Texture effects (QuarksMaterial): uvScroll [u, v], gradientMap texture uuid,
828
- // erosion {mask (texture uuid), softness, edgeWidth, edgeColor, maskScroll}.
829
- if (Array.isArray(matDef.uvScroll)) {
830
- matInfo.uvScroll = matDef.uvScroll;
1192
+ // Texture effects (QuarksMaterial): mapUV {channel, tiling, offset, scroll},
1193
+ // gradientMap {ramp (texture uuid), channel},
1194
+ // erosion {mask (texture uuid), maskUV {channel, tiling, offset, scroll}, softness, edgeWidth,
1195
+ // edgeColor}.
1196
+ if (matDef.mapUV && typeof matDef.mapUV === 'object') {
1197
+ matInfo.mapUV = matDef.mapUV;
831
1198
  }
832
- if (matDef.gradientMap) {
833
- matInfo.gradientMap = meta.textures[matDef.gradientMap] || null;
1199
+ if (matDef.gradientMap && typeof matDef.gradientMap === 'object') {
1200
+ matInfo.gradientMap = {
1201
+ ...matDef.gradientMap,
1202
+ ramp: matDef.gradientMap.ramp ? meta.textures[matDef.gradientMap.ramp] || null : null,
1203
+ };
834
1204
  }
835
1205
  if (matDef.erosion && typeof matDef.erosion === 'object') {
836
1206
  matInfo.erosion = {
@@ -838,8 +1208,15 @@ export class QuarksLoader {
838
1208
  mask: matDef.erosion.mask ? meta.textures[matDef.erosion.mask] || null : null,
839
1209
  };
840
1210
  }
1211
+ // uvDistortion {map (texture uuid), mapUV {channel, tiling, offset, scroll}, strength}.
1212
+ if (matDef.uvDistortion && typeof matDef.uvDistortion === 'object') {
1213
+ matInfo.uvDistortion = {
1214
+ ...matDef.uvDistortion,
1215
+ map: matDef.uvDistortion.map ? meta.textures[matDef.uvDistortion.map] || null : null,
1216
+ };
1217
+ }
841
1218
 
842
- // QuarksMaterial (what ParticleSystem.ensureMaterialMeta emits) writes `alphaMode`
1219
+ // QuarksMaterial (what `materialMeta` in system/serialization.ts writes) writes `alphaMode`
843
1220
  // directly as a Babylon alpha-mode constant; three.js materials (MeshBasicMaterial
844
1221
  // etc.) instead use `blending` with three.js's own 1..4 numbering, which does NOT
845
1222
  // line up with Babylon's constants (e.g. three.js Additive=2 vs Babylon ALPHA_ADD=1)
@@ -887,6 +1264,9 @@ export class QuarksLoader {
887
1264
  if (geometry.uvs && geometry.uvs.length > 0) {
888
1265
  vertexData.uvs = geometry.uvs;
889
1266
  }
1267
+ if (geometry.uvs2 && geometry.uvs2.length > 0) {
1268
+ vertexData.uvs2 = geometry.uvs2;
1269
+ }
890
1270
  if (geometry.normals && geometry.normals.length > 0) {
891
1271
  vertexData.normals = geometry.normals;
892
1272
  }
@@ -896,6 +1276,7 @@ export class QuarksLoader {
896
1276
  const materialInfo = this.getMaterial(meta, data.material);
897
1277
  if (materialInfo) {
898
1278
  const material = new StandardMaterial(`quarks_loader_material_${materialInfo.uuid}`, this.scene);
1279
+ meta.ownedMaterials.push(material);
899
1280
  material.alphaMode = materialInfo.alphaMode ?? Constants.ALPHA_COMBINE;
900
1281
  material.backFaceCulling = materialInfo.side !== 2;
901
1282
  material.alphaCutOff = materialInfo.alphaTest ?? 0;
@@ -983,6 +1364,7 @@ export class QuarksLoader {
983
1364
  }
984
1365
 
985
1366
  if (data.visible !== undefined) node.setEnabled(data.visible);
1367
+ if (data.isVisible === false && node instanceof Mesh) node.isVisible = false;
986
1368
 
987
1369
  if (data.children) {
988
1370
  for (const childData of data.children) {
@@ -1027,6 +1409,12 @@ export class QuarksLoader {
1027
1409
  const targetNode = nodesMap[meshSurfaceUUID];
1028
1410
  if (targetNode instanceof Mesh) {
1029
1411
  (system.emitterShape as any).mesh = targetNode;
1412
+ } else {
1413
+ console.warn(
1414
+ `[QuarksLoader] Emitter "${node.name}" has a mesh surface shape on "${meshSurfaceUUID}", ` +
1415
+ (targetNode ? 'which is not a mesh' : 'which is not in the effect') +
1416
+ '; it emits nothing until a mesh is set.'
1417
+ );
1030
1418
  }
1031
1419
  }
1032
1420
  for (let i = 0; i < system.behaviors.length; i++) {