babylon.quarks 0.27.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 (52) hide show
  1. package/CHANGELOG.md +425 -0
  2. package/README.md +3 -1
  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 +4256 -831
  10. package/dist/types/index.d.ts +4256 -831
  11. package/package.json +7 -2
  12. package/src/BatchedRenderer.ts +93 -78
  13. package/src/EffectPool.ts +91 -12
  14. package/src/MeshSurfaceEmitter.ts +24 -13
  15. package/src/ParticleEmitter.ts +31 -3
  16. package/src/ParticleSystem.ts +92 -25
  17. package/src/QuarksLoader.ts +289 -171
  18. package/src/QuarksPrefab.ts +301 -94
  19. package/src/QuarksSerializer.ts +43 -15
  20. package/src/QuarksUtil.ts +64 -9
  21. package/src/SpriteBatch.ts +21 -6
  22. package/src/TrailBatch.ts +12 -6
  23. package/src/VFXBatch.ts +70 -528
  24. package/src/batchKey.ts +4 -2
  25. package/src/batchSettings.ts +546 -0
  26. package/src/effectDocumentJSON.ts +261 -0
  27. package/src/effectResources.ts +21 -24
  28. package/src/envAtlas.ts +8 -2
  29. package/src/geometryUtil.ts +1 -1
  30. package/src/index.ts +39 -4
  31. package/src/nodeId.ts +25 -2
  32. package/src/shaderProgram.ts +110 -16
  33. package/src/shaderVariants.ts +24 -4
  34. package/src/shaders/chunks/blend.ts +31 -0
  35. package/src/shaders/chunks/fog.ts +8 -3
  36. package/src/shaders/chunks/soft.ts +6 -5
  37. package/src/shaders/chunks/surface.ts +2 -2
  38. package/src/shaders/particle_frag.glsl.ts +3 -1
  39. package/src/shaders/particle_frag.wgsl.ts +2 -0
  40. package/src/shaders/particle_physics_frag.glsl.ts +5 -2
  41. package/src/shaders/particle_physics_frag.wgsl.ts +2 -0
  42. package/src/shaders/trail_frag.glsl.ts +4 -1
  43. package/src/shaders/trail_frag.wgsl.ts +2 -0
  44. package/src/system/EmissionController.ts +2 -4
  45. package/src/system/EventDispatcher.ts +26 -10
  46. package/src/system/geometry.ts +22 -29
  47. package/src/system/materialResolve.ts +56 -7
  48. package/src/system/normalize.ts +6 -1
  49. package/src/system/serialization.ts +54 -33
  50. package/src/system/settingsSchema.ts +32 -10
  51. package/src/system/types.ts +67 -28
  52. package/src/trailSettings.ts +9 -6
@@ -8,13 +8,36 @@ import {Mesh} from '@babylonjs/core/Meshes/mesh';
8
8
  import {VertexData} from '@babylonjs/core/Meshes/mesh.vertexData';
9
9
  import {TransformNode} from '@babylonjs/core/Meshes/transformNode';
10
10
  import {Scene} from '@babylonjs/core/scene';
11
- 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';
12
21
  import {ParticleEmitter} from './ParticleEmitter';
13
22
  import {ParticleSystem} from './ParticleSystem';
14
23
  import {QuarksPrefab} from './QuarksPrefab';
24
+ import type {QuarksNodeJSON, QuarksTextureJSON} from './QuarksSerializer';
15
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';
16
37
  import {disposeEffect, retainEffectResources} from './effectResources';
17
38
  import {ensureTriangleIndices} from './geometryUtil';
39
+ import {fileNodeId, setQuarksNodeId} from './nodeId';
40
+ import type {ParticleSystemJSONParameters} from './system/types';
18
41
  import {setTextureSourceUrl} from './textureSource';
19
42
 
20
43
  /** Kind of linked asset the loader asks a {@link QuarksResourceResolver} to handle. */
@@ -35,6 +58,18 @@ export interface QuarksResourceResolver {
35
58
  fetchJson?(resolvedUrl: string, options?: {signal?: AbortSignal}): Promise<unknown>;
36
59
  }
37
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'];
71
+ }
72
+
38
73
  /** Options for a {@link QuarksLoader}, shared by every load it does. */
39
74
  export interface QuarksLoaderOptions {
40
75
  /**
@@ -65,6 +100,22 @@ export interface QuarksLoaderOptions {
65
100
  * keeps its link unresolved, and a texture draws nothing. Default: a console warning.
66
101
  */
67
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;
68
119
  }
69
120
 
70
121
  /** Options for one {@link QuarksLoader.load} or {@link QuarksLoader.loadFromJSON}. */
@@ -109,17 +160,60 @@ export interface QuarksBatchLoadOptions extends QuarksLoadOptions {
109
160
  continueOnError?: boolean;
110
161
  }
111
162
 
112
- interface ParsedGeometry {
113
- positions: Float32Array;
114
- indices: Uint32Array | Uint16Array;
115
- uvs?: Float32Array;
116
- uvs2?: Float32Array;
117
- 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);
118
181
  }
119
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 : [];
207
+ }
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
+
120
214
  /** Textures and geometries reused across loads, per scene. See QuarksLoaderOptions.shareResources. */
121
215
  const sharedTextures = new WeakMap<Scene, Map<string, Texture>>();
122
- const sharedGeometries = new WeakMap<Scene, Map<string, ParsedGeometry>>();
216
+ const sharedGeometries = new WeakMap<Scene, Map<string, GeometryArrays>>();
123
217
  /** In-flight / completed JSON fetches keyed by `json|resolvedUrl`, per scene. */
124
218
  const sharedFetches = new WeakMap<Scene, Map<string, Promise<unknown>>>();
125
219
 
@@ -139,7 +233,7 @@ const resolvedImageUrls = new WeakMap<object, string>();
139
233
  const sourceImageUrls = new WeakMap<object, string>();
140
234
 
141
235
  /** Content key for a geometry: two independent 32-bit hashes over every array's bytes, plus lengths. */
142
- function geometryKey(geometry: ParsedGeometry): string {
236
+ function geometryKey(geometry: GeometryArrays): string {
143
237
  let h1 = 0x811c9dc5;
144
238
  let h2 = 0x9e3779b9;
145
239
  const parts: Array<ArrayBufferView | undefined> = [
@@ -165,27 +259,18 @@ function geometryKey(geometry: ParsedGeometry): string {
165
259
  return `${lengths}${(h1 >>> 0).toString(16)}${(h2 >>> 0).toString(16)}`;
166
260
  }
167
261
 
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
- }
262
+ // Defined in quarks.core, whose readers throw it in a strict read; exported here as before.
263
+ export {QuarksFormatError};
179
264
 
180
265
  /**
181
266
  * A copy of an effect document that loading can rewrite (resolved image urls, inlined geometry
182
267
  * files) without touching the caller's. Only the entries that change are copied.
183
268
  */
184
- function detachDocument(json: any): any {
185
- if (json === null || typeof json !== 'object') {
269
+ function detachDocument(json: unknown): unknown {
270
+ if (!isRecord(json)) {
186
271
  return json;
187
272
  }
188
- const copy = {...json};
273
+ const copy: JSONRecord = {...json};
189
274
  if (Array.isArray(json.images)) {
190
275
  copy.images = json.images.map((image: unknown) => {
191
276
  if (!image || typeof image !== 'object') {
@@ -250,8 +335,8 @@ interface LoadedMeta {
250
335
  /** Image uuid to the url the file wrote, before any base url was joined. */
251
336
  imageSources: {[uuid: string]: string};
252
337
  textures: {[uuid: string]: Texture | null};
253
- geometries: {[uuid: string]: ParsedGeometry};
254
- materials: {[uuid: string]: any};
338
+ geometries: {[uuid: string]: GeometryArrays};
339
+ materials: {[uuid: string]: QuarksMaterialJSON};
255
340
  }
256
341
 
257
342
  /**
@@ -292,18 +377,20 @@ export class QuarksLoader {
292
377
 
293
378
  /**
294
379
  * 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
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
296
383
  * left as it was, so the same document can be loaded again, into another scene or with
297
384
  * another base url.
298
385
  */
299
- async loadFromJSON(json: any, baseUrl?: string, options: QuarksLoadOptions = {}): Promise<TransformNode> {
386
+ async loadFromJSON(json: unknown, baseUrl?: string, options: QuarksLoadOptions = {}): Promise<TransformNode> {
300
387
  const signal = options.signal;
301
388
  checkLoad(this.scene, signal);
302
389
  const resolvedBase = baseUrl ?? this.options.baseUrl ?? '';
303
- json = detachDocument(json);
304
- await untilStopped(this.resolveResources(json, resolvedBase), this.scene, signal);
390
+ const document = detachDocument(json);
391
+ await untilStopped(this.resolveResources(document, resolvedBase), this.scene, signal);
305
392
  // The urls are resolved now; joining them with the base again would load `fx/fx/...`.
306
- const loaded = this.parseDocument(json, '');
393
+ const loaded = this.parseDocument(document, '');
307
394
  await this.finishLoad(loaded, signal);
308
395
  return loaded.root;
309
396
  }
@@ -402,7 +489,7 @@ export class QuarksLoader {
402
489
  entries: Array<QuarksJSONLoadEntry | unknown>,
403
490
  options: QuarksBatchLoadOptions = {}
404
491
  ): Promise<(TransformNode | null)[]> {
405
- const docs: Array<{json: any; baseUrl: string} | null> = entries.map((entry) => {
492
+ const docs: Array<{json: unknown; baseUrl: string} | null> = entries.map((entry) => {
406
493
  // Copied, as loadFromJSON does, so the caller's documents stay as they were.
407
494
  if (entry !== null && typeof entry === 'object' && 'json' in (entry as object)) {
408
495
  const typed = entry as QuarksJSONLoadEntry;
@@ -420,7 +507,7 @@ export class QuarksLoader {
420
507
  * loading the same document again does not join its base url twice. Failures warn and leave
421
508
  * the entry as is.
422
509
  */
423
- async resolveResources(json: any, baseUrl: string = ''): Promise<void> {
510
+ async resolveResources(json: unknown, baseUrl: string = ''): Promise<void> {
424
511
  await QuarksLoader.resolveLinkedResources(json, {
425
512
  baseUrl,
426
513
  resolver: this.options.resolver,
@@ -435,15 +522,7 @@ export class QuarksLoader {
435
522
  * Like it, resolves each document once. A geometry file that fails to load goes to
436
523
  * `onResourceError` (default: a console warning) and keeps its link.
437
524
  */
438
- static async resolveLinkedResources(
439
- json: any,
440
- options: {
441
- baseUrl?: string;
442
- resolver?: QuarksResourceResolver;
443
- scene?: Scene;
444
- onResourceError?: QuarksLoaderOptions['onResourceError'];
445
- } = {}
446
- ): Promise<void> {
525
+ static async resolveLinkedResources(json: unknown, options: QuarksResolveOptions = {}): Promise<void> {
447
526
  const baseUrl = options.baseUrl ?? '';
448
527
  const resolver = options.resolver;
449
528
  const scene = options.scene;
@@ -457,10 +536,9 @@ export class QuarksLoader {
457
536
  options.onResourceError
458
537
  );
459
538
 
460
- const images: any[] = Array.isArray(json?.images) ? json.images : [];
461
539
  await Promise.all(
462
- images.map(async (img) => {
463
- if (typeof img?.url !== 'string' || !img.url) {
540
+ documentList(json, 'images').map(async (img) => {
541
+ if (!isRecord(img) || typeof img.url !== 'string' || !img.url) {
464
542
  return;
465
543
  }
466
544
  // An entry this loader already rewrote keeps its url: resolving the same document
@@ -468,12 +546,13 @@ export class QuarksLoader {
468
546
  if (resolvedImageUrls.get(img) === img.url) {
469
547
  return;
470
548
  }
471
- const source: string = img.url;
472
- img.url = await QuarksLoader.resolveWith(resolver, img.url, 'image', baseUrl);
473
- resolvedImageUrls.set(img, img.url);
549
+ const source = img.url;
550
+ const url = await QuarksLoader.resolveWith(resolver, source, 'image', baseUrl);
551
+ img.url = url;
552
+ resolvedImageUrls.set(img, url);
474
553
  // A resolver that inlined the file (`data:`, `blob:`) made the url the image itself;
475
554
  // 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)) {
555
+ if (!/^(data|blob):/i.test(url)) {
477
556
  sourceImageUrls.set(img, source);
478
557
  }
479
558
  })
@@ -486,7 +565,10 @@ export class QuarksLoader {
486
565
  * the loader's resolver and fetch cache. Kept for callers that already have a custom
487
566
  * `loadGeometry` and no `QuarksLoader` instance.
488
567
  */
489
- 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> {
490
572
  await QuarksLoader.inlineGeometryEntries(json, loadGeometry);
491
573
  }
492
574
 
@@ -496,19 +578,41 @@ export class QuarksLoader {
496
578
  * read warn and take their defaults; a document with no `object` throws
497
579
  * {@link QuarksFormatError}. Free the effect with {@link disposeEffect}.
498
580
  */
499
- parse(json: any, baseUrl: string = ''): TransformNode {
581
+ parse(json: unknown, baseUrl: string = ''): TransformNode {
500
582
  return this.parseDocument(json, baseUrl).root;
501
583
  }
502
584
 
503
585
  /** {@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') {
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)) {
506
609
  throw new QuarksFormatError('[QuarksLoader] Not an effect document: it has no "object" to load.');
507
610
  }
611
+ const json = document;
508
612
  const format = json.metadata?.quarksFormat;
509
613
  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} ` +
614
+ warnFormat(
615
+ `This effect was written in format ${format}, newer than the ${QUARKS_FORMAT_VERSION} ` +
512
616
  'this version of babylon.quarks reads; settings it does not know are left out.'
513
617
  );
514
618
  }
@@ -521,27 +625,28 @@ export class QuarksLoader {
521
625
  materials: {},
522
626
  };
523
627
 
524
- if (json.geometries) {
525
- this.parseGeometries(json.geometries, meta);
628
+ const {geometries, images, textures: textureList, materials} = json;
629
+ if (geometries) {
630
+ readFormatPart('geometries', () => this.parseGeometries(documentEntries(geometries), meta));
526
631
  }
527
- if (json.images) {
528
- this.parseImages(json.images, baseUrl, meta);
632
+ if (images) {
633
+ readFormatPart('images', () => this.parseImages(documentEntries(images), baseUrl, meta));
529
634
  }
530
- if (json.textures) {
531
- this.parseTextures(json.textures, meta);
635
+ if (textureList) {
636
+ readFormatPart('textures', () => this.parseTextures(documentEntries(textureList), meta));
532
637
  }
533
- if (json.materials) {
534
- this.parseMaterials(json.materials, meta);
638
+ if (materials) {
639
+ readFormatPart('materials', () => this.parseMaterials(documentEntries(materials), meta));
535
640
  }
536
641
 
537
- const root = this.parseObject(json.object, meta);
642
+ const root = readFormatPart('object', () => this.parseObject(json.object, meta));
538
643
  this.linkReferences(root);
539
644
  const textures = [...new Set(Object.values(meta.textures).filter((t): t is Texture => t !== null))];
540
645
  retainEffectResources(root, textures, meta.ownedMaterials);
541
646
  return {root, textures};
542
647
  }
543
648
 
544
- private parseGeometries(geometries: any[], meta: LoadedMeta): void {
649
+ private parseGeometries(geometries: EffectGeometryJSON[], meta: LoadedMeta): void {
545
650
  for (const geom of geometries) {
546
651
  if (geom.type === 'PlaneGeometry') {
547
652
  const w = geom.width || 1;
@@ -561,8 +666,8 @@ export class QuarksLoader {
561
666
  meta.geometries[geom.uuid] = parsed;
562
667
  } else if (geom.type === 'QuarksGeometry') {
563
668
  if (typeof geom.url === 'string' && !geom.positions) {
564
- console.warn(
565
- `[QuarksLoader] Geometry "${geom.url}" is an external file; use load(), ` +
669
+ warnFormat(
670
+ `Geometry "${geom.url}" is an external file; use load(), ` +
566
671
  'loadFromJSON(), or resolveResources() before parse().'
567
672
  );
568
673
  }
@@ -578,7 +683,7 @@ export class QuarksLoader {
578
683
  }
579
684
 
580
685
  /** The scene's copy of an identical geometry when resources are shared, else `geometry` itself. */
581
- private share(geometry: ParsedGeometry): ParsedGeometry {
686
+ private share(geometry: GeometryArrays): GeometryArrays {
582
687
  if (this.options.shareResources === false) {
583
688
  return geometry;
584
689
  }
@@ -611,7 +716,7 @@ export class QuarksLoader {
611
716
  return baseUrl + url;
612
717
  }
613
718
 
614
- private parseSphereGeometry(geom: any): ParsedGeometry {
719
+ private parseSphereGeometry(geom: SphereGeometryJSON): GeometryArrays {
615
720
  const radius = geom.radius ?? 0.5;
616
721
  const widthSegments = Math.max(3, Math.floor(geom.widthSegments ?? 16));
617
722
  const heightSegments = Math.max(2, Math.floor(geom.heightSegments ?? 8));
@@ -681,8 +786,8 @@ export class QuarksLoader {
681
786
  };
682
787
  }
683
788
 
684
- private parseBufferGeometry(data: any): ParsedGeometry {
685
- let positions = new Float32Array(0);
789
+ private parseBufferGeometry(data: BufferGeometryDataJSON): GeometryArrays {
790
+ let positions: Float32Array = new Float32Array(0);
686
791
  let indices: Uint32Array | Uint16Array = new Uint32Array(0);
687
792
  let uvs: Float32Array | undefined;
688
793
  let uvs2: Float32Array | undefined;
@@ -692,7 +797,7 @@ export class QuarksLoader {
692
797
 
693
798
  if (data.interleavedBuffers && data.arrayBuffers) {
694
799
  const buffers: {[uuid: string]: {data: Float32Array; stride: number}} = {};
695
- for (const [uuid, ibDef] of Object.entries(data.interleavedBuffers) as any[]) {
800
+ for (const [uuid, ibDef] of Object.entries(data.interleavedBuffers)) {
696
801
  const arrayBuffer = data.arrayBuffers[ibDef.buffer];
697
802
  if (arrayBuffer) {
698
803
  const floatArray = new Float32Array(
@@ -702,35 +807,17 @@ export class QuarksLoader {
702
807
  }
703
808
  }
704
809
 
705
- if (data.attributes) {
706
- if (data.attributes.position?.isInterleavedBufferAttribute) {
707
- const attr = data.attributes.position;
708
- const buf = buffers[attr.data];
709
- if (buf)
710
- positions = this.extractInterleavedAttribute(buf.data, buf.stride, attr.offset, attr.itemSize);
711
- }
712
- if (data.attributes.uv?.isInterleavedBufferAttribute) {
713
- const attr = data.attributes.uv;
714
- const buf = buffers[attr.data];
715
- if (buf) uvs = this.extractInterleavedAttribute(buf.data, buf.stride, attr.offset, attr.itemSize);
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
- }
727
- if (data.attributes.normal?.isInterleavedBufferAttribute) {
728
- const attr = data.attributes.normal;
729
- const buf = buffers[attr.data];
730
- if (buf)
731
- normals = this.extractInterleavedAttribute(buf.data, buf.stride, attr.offset, attr.itemSize);
732
- }
733
- }
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);
734
821
  } else if (data.attributes) {
735
822
  if (data.attributes.position?.array) {
736
823
  positions = new Float32Array(data.attributes.position.array);
@@ -759,7 +846,12 @@ export class QuarksLoader {
759
846
  return {positions, indices, uvs, uvs2, normals};
760
847
  }
761
848
 
762
- 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 {
763
855
  const count = Math.floor(buffer.length / stride);
764
856
  const result = new Float32Array(count * itemSize);
765
857
  for (let i = 0; i < count; i++) {
@@ -776,7 +868,7 @@ export class QuarksLoader {
776
868
  for (let i = 0; i < intArray.length; i++) {
777
869
  int32View[i] = intArray[i];
778
870
  }
779
- return new Float32Array(buffer) as any;
871
+ return new Float32Array(buffer);
780
872
  }
781
873
 
782
874
  private async resolveResourceUrl(url: string, kind: QuarksResourceKind, baseUrl: string): Promise<string> {
@@ -784,7 +876,7 @@ export class QuarksLoader {
784
876
  }
785
877
 
786
878
  /** Fetches one effect JSON; documents are not fetch-cached because resolve mutates them. */
787
- private async fetchEffectDocument(url: string, signal?: AbortSignal): Promise<{json: any; baseUrl: string}> {
879
+ private async fetchEffectDocument(url: string, signal?: AbortSignal): Promise<{json: unknown; baseUrl: string}> {
788
880
  const baseUrl = this.options.baseUrl || url.substring(0, url.lastIndexOf('/') + 1);
789
881
  const resolvedEffect = await this.resolveResourceUrl(url, 'effect', baseUrl);
790
882
  const json = await QuarksLoader.fetchJsonWith(this.options.resolver, resolvedEffect, undefined, signal);
@@ -796,7 +888,7 @@ export class QuarksLoader {
796
888
  * scene fetch cache), then parse. Each index settles exactly once for `onProgress`.
797
889
  */
798
890
  private async loadDocuments(
799
- docs: Array<{json: any; baseUrl: string} | null>,
891
+ docs: Array<{json: unknown; baseUrl: string} | null>,
800
892
  options: QuarksBatchLoadOptions
801
893
  ): Promise<(TransformNode | null)[]> {
802
894
  const total = docs.length;
@@ -816,7 +908,7 @@ export class QuarksLoader {
816
908
  }
817
909
 
818
910
  private async loadResolvedDocuments(
819
- docs: Array<{json: any; baseUrl: string} | null>,
911
+ docs: Array<{json: unknown; baseUrl: string} | null>,
820
912
  options: QuarksBatchLoadOptions,
821
913
  stopAll: AbortController
822
914
  ): Promise<(TransformNode | null)[]> {
@@ -976,31 +1068,32 @@ export class QuarksLoader {
976
1068
  }
977
1069
 
978
1070
  private static async inlineGeometryEntries(
979
- json: any,
980
- loadGeometry: (url: string) => Promise<any>,
1071
+ json: unknown,
1072
+ loadGeometry: (url: string) => Promise<unknown>,
981
1073
  onResourceError: QuarksLoaderOptions['onResourceError'] = QuarksLoader.warnResourceError
982
1074
  ): Promise<void> {
983
- const geometries: any[] = Array.isArray(json?.geometries) ? json.geometries : [];
984
1075
  await Promise.all(
985
- geometries
986
- .filter((geom) => typeof geom?.url === 'string' && !geom.positions)
987
- .map(async (geom) => {
988
- try {
989
- const data = await loadGeometry(geom.url);
990
- // Copy arrays so a shared fetch-cache document is not aliased across
991
- // effects — mutating one effect's inlined geometry must not corrupt others.
992
- for (const key of ['positions', 'indices', 'uvs', 'uvs2', 'normals']) {
993
- const value = data?.[key];
994
- if (value === undefined) {
995
- continue;
996
- }
997
- 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;
998
1089
  }
999
- delete geom.url;
1000
- } catch (err) {
1001
- onResourceError('geometry', geom.url, err);
1090
+ geom[key] = Array.isArray(value) ? value.slice() : value;
1002
1091
  }
1003
- })
1092
+ delete geom.url;
1093
+ } catch (err) {
1094
+ onResourceError('geometry', url, err);
1095
+ }
1096
+ })
1004
1097
  );
1005
1098
  }
1006
1099
 
@@ -1012,17 +1105,18 @@ export class QuarksLoader {
1012
1105
  (this.options.onResourceError ?? QuarksLoader.warnResourceError)(kind, url, error);
1013
1106
  }
1014
1107
 
1015
- private parseImages(images: any[], baseUrl: string, meta: LoadedMeta): void {
1016
- for (const img of images) {
1017
- if (img.url) {
1018
- QuarksLoader.warnIfNotAnImageUrl(img.url);
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));
1019
1113
  }
1020
1114
  // Urls are usually already absolute after resolveResources; joinUrl is a no-op for
1021
1115
  // data/blob/http(s) and still supports callers that use parse() with a baseUrl only.
1022
- meta.images[img.uuid] = img.url ? QuarksLoader.joinUrl(baseUrl, img.url) : null;
1116
+ meta.images[img.uuid] = url ? QuarksLoader.joinUrl(baseUrl, url) : null;
1023
1117
  // The url the file wrote, for serializeEffect to write back. An inlined image
1024
1118
  // (`data:`, `blob:`) has no path to write; its texture writes its url.
1025
- const source: string | undefined = sourceImageUrls.get(img) ?? img.url;
1119
+ const source = sourceImageUrls.get(img) ?? url;
1026
1120
  if (source && !/^(data|blob):/i.test(source)) {
1027
1121
  meta.imageSources[img.uuid] = source;
1028
1122
  }
@@ -1042,15 +1136,15 @@ export class QuarksLoader {
1042
1136
  if (/^(data|blob):/i.test(url) || /\.(png|jpe?g|webp|gif|bmp|ktx2?|dds|env|basis)(\?|#|$)/i.test(url)) {
1043
1137
  return;
1044
1138
  }
1045
- console.warn(
1046
- `[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. ` +
1047
1141
  'This usually means the effect was exported without embedding that texture.'
1048
1142
  );
1049
1143
  }
1050
1144
 
1051
- private parseTextures(textures: any[], meta: LoadedMeta): void {
1145
+ private parseTextures(textures: QuarksTextureJSON[], meta: LoadedMeta): void {
1052
1146
  for (const texDef of textures) {
1053
- const imageUrl = meta.images[texDef.image];
1147
+ const imageUrl = texDef.image !== undefined ? meta.images[texDef.image] : undefined;
1054
1148
  if (imageUrl) {
1055
1149
  const invertY = texDef.invertY !== undefined ? !!texDef.invertY : true;
1056
1150
  const noMipmap = !!texDef.noMipmap;
@@ -1105,7 +1199,7 @@ export class QuarksLoader {
1105
1199
  continue;
1106
1200
  }
1107
1201
  textureSettled.set(texture, settled);
1108
- const source = meta.imageSources[texDef.image];
1202
+ const source = texDef.image !== undefined ? meta.imageSources[texDef.image] : undefined;
1109
1203
  if (source !== undefined) {
1110
1204
  setTextureSourceUrl(texture, source, imageUrl);
1111
1205
  }
@@ -1154,9 +1248,12 @@ export class QuarksLoader {
1154
1248
  }
1155
1249
  }
1156
1250
 
1157
- 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;
1158
1255
  for (const matDef of materials) {
1159
- const matInfo: any = {
1256
+ const matInfo: QuarksMaterialJSON = {
1160
1257
  uuid: matDef.uuid,
1161
1258
  transparent: matDef.transparent ?? true,
1162
1259
  blending: matDef.blending ?? 1,
@@ -1171,12 +1268,12 @@ export class QuarksLoader {
1171
1268
  // emits) uses `texture`. Accept both so library-exported effects round-trip.
1172
1269
  const textureRef = matDef.map ?? matDef.texture;
1173
1270
  if (textureRef) {
1174
- matInfo.texture = meta.textures[textureRef] || null;
1271
+ matInfo.texture = texture(textureRef);
1175
1272
  }
1176
1273
 
1177
1274
  const reflectionAtlasRef = matDef.reflectionAtlas;
1178
1275
  if (reflectionAtlasRef) {
1179
- matInfo.reflectionAtlas = meta.textures[reflectionAtlasRef] || null;
1276
+ matInfo.reflectionAtlas = texture(reflectionAtlasRef);
1180
1277
  }
1181
1278
  if (typeof matDef.reflectionLevel === 'number') {
1182
1279
  matInfo.reflectionLevel = matDef.reflectionLevel;
@@ -1199,20 +1296,20 @@ export class QuarksLoader {
1199
1296
  if (matDef.gradientMap && typeof matDef.gradientMap === 'object') {
1200
1297
  matInfo.gradientMap = {
1201
1298
  ...matDef.gradientMap,
1202
- ramp: matDef.gradientMap.ramp ? meta.textures[matDef.gradientMap.ramp] || null : null,
1299
+ ramp: texture(matDef.gradientMap.ramp),
1203
1300
  };
1204
1301
  }
1205
1302
  if (matDef.erosion && typeof matDef.erosion === 'object') {
1206
1303
  matInfo.erosion = {
1207
1304
  ...matDef.erosion,
1208
- mask: matDef.erosion.mask ? meta.textures[matDef.erosion.mask] || null : null,
1305
+ mask: texture(matDef.erosion.mask),
1209
1306
  };
1210
1307
  }
1211
1308
  // uvDistortion {map (texture uuid), mapUV {channel, tiling, offset, scroll}, strength}.
1212
1309
  if (matDef.uvDistortion && typeof matDef.uvDistortion === 'object') {
1213
1310
  matInfo.uvDistortion = {
1214
1311
  ...matDef.uvDistortion,
1215
- map: matDef.uvDistortion.map ? meta.textures[matDef.uvDistortion.map] || null : null,
1312
+ map: texture(matDef.uvDistortion.map),
1216
1313
  };
1217
1314
  }
1218
1315
 
@@ -1230,19 +1327,19 @@ export class QuarksLoader {
1230
1327
  3: Constants.ALPHA_SUBTRACT,
1231
1328
  4: Constants.ALPHA_MULTIPLY,
1232
1329
  };
1233
- matInfo.alphaMode = blendingToAlphaMode[matDef.blending] || Constants.ALPHA_COMBINE;
1330
+ matInfo.alphaMode = blendingToAlphaMode[matDef.blending ?? 1] || Constants.ALPHA_COMBINE;
1234
1331
  }
1235
1332
 
1236
1333
  meta.materials[matDef.uuid] = matInfo;
1237
1334
  }
1238
1335
  }
1239
1336
 
1240
- private getGeometry(meta: LoadedMeta, geometryId: string | undefined): ParsedGeometry | undefined {
1337
+ private getGeometry(meta: LoadedMeta, geometryId: string | undefined): GeometryArrays | undefined {
1241
1338
  if (!geometryId) return undefined;
1242
1339
  return meta.geometries[geometryId];
1243
1340
  }
1244
1341
 
1245
- private getMaterial(meta: LoadedMeta, materialId: string | string[] | undefined): any | undefined {
1342
+ private getMaterial(meta: LoadedMeta, materialId: string | string[] | undefined): QuarksMaterialJSON | undefined {
1246
1343
  if (!materialId) return undefined;
1247
1344
  const selectedId = Array.isArray(materialId) ? materialId[0] : materialId;
1248
1345
  return meta.materials[selectedId];
@@ -1250,11 +1347,12 @@ export class QuarksLoader {
1250
1347
 
1251
1348
  private createPlaceholderNode(type: string, name?: string): TransformNode {
1252
1349
  const node = new TransformNode(name || type || 'node', this.scene);
1253
- (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});
1254
1352
  return node;
1255
1353
  }
1256
1354
 
1257
- private createMeshNode(data: any, meta: LoadedMeta): Mesh {
1355
+ private createMeshNode(data: QuarksNodeJSON, meta: LoadedMeta): Mesh {
1258
1356
  const mesh = new Mesh(data.name || data.type || 'mesh', this.scene);
1259
1357
  const geometry = this.getGeometry(meta, data.geometry);
1260
1358
  if (geometry && geometry.positions.length > 0 && geometry.indices.length > 0) {
@@ -1281,16 +1379,17 @@ export class QuarksLoader {
1281
1379
  material.backFaceCulling = materialInfo.side !== 2;
1282
1380
  material.alphaCutOff = materialInfo.alphaTest ?? 0;
1283
1381
  material.disableDepthWrite = !(materialInfo.depthWrite ?? false);
1284
- if (materialInfo.texture) {
1285
- material.diffuseTexture = materialInfo.texture;
1286
- material.opacityTexture = materialInfo.texture;
1382
+ const texture = materialInfo.texture;
1383
+ if (texture && typeof texture !== 'string') {
1384
+ material.diffuseTexture = texture;
1385
+ material.opacityTexture = texture;
1287
1386
  }
1288
1387
  mesh.material = material;
1289
1388
  }
1290
1389
  return mesh;
1291
1390
  }
1292
1391
 
1293
- private parseObject(data: any, meta: LoadedMeta): TransformNode {
1392
+ private parseObject(data: QuarksNodeJSON, meta: LoadedMeta): TransformNode {
1294
1393
  let node: TransformNode;
1295
1394
  switch (data.type) {
1296
1395
  case 'QuarksPrefab':
@@ -1298,7 +1397,8 @@ export class QuarksLoader {
1298
1397
  break;
1299
1398
  case 'ParticleEmitter':
1300
1399
  if (data.ps) {
1301
- const ps = this.parseParticleSystem(data.ps, meta);
1400
+ const psJSON = data.ps;
1401
+ const ps = readFormatPart('ps', () => this.parseParticleSystem(psJSON, meta));
1302
1402
  node = ps.emitter;
1303
1403
  break;
1304
1404
  }
@@ -1336,7 +1436,7 @@ export class QuarksLoader {
1336
1436
  node = new TransformNode(data.name || data.type || 'node', this.scene);
1337
1437
  }
1338
1438
 
1339
- if (data.uuid) (node as any)._quarksUUID = data.uuid;
1439
+ if (data.uuid) setQuarksNodeId(node, data.uuid);
1340
1440
  if (data.name) node.name = data.name;
1341
1441
 
1342
1442
  if (data.matrix) {
@@ -1367,19 +1467,31 @@ export class QuarksLoader {
1367
1467
  if (data.isVisible === false && node instanceof Mesh) node.isVisible = false;
1368
1468
 
1369
1469
  if (data.children) {
1370
- for (const childData of data.children) {
1371
- const child = this.parseObject(childData, meta);
1372
- 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
+ });
1373
1482
  }
1374
1483
  }
1375
1484
 
1376
1485
  return node;
1377
1486
  }
1378
1487
 
1379
- private parseParticleSystem(json: any, meta: LoadedMeta): ParticleSystem {
1488
+ private parseParticleSystem(json: ParticleSystemJSONParameters, meta: LoadedMeta): ParticleSystem {
1380
1489
  const dependencies: {[uuid: string]: Behavior} = {};
1381
- const ps = ParticleSystem.fromJSON(json, meta as any, dependencies, this.scene);
1382
- (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());
1383
1495
  return ps;
1384
1496
  }
1385
1497
 
@@ -1387,8 +1499,9 @@ export class QuarksLoader {
1387
1499
  const nodesMap: {[uuid: string]: TransformNode} = {};
1388
1500
 
1389
1501
  const traverse = (node: TransformNode) => {
1390
- if ((node as any)._quarksUUID) {
1391
- nodesMap[(node as any)._quarksUUID] = node;
1502
+ const fileId = fileNodeId(node);
1503
+ if (fileId) {
1504
+ nodesMap[fileId] = node;
1392
1505
  }
1393
1506
  for (const child of node.getChildren()) {
1394
1507
  if (child instanceof TransformNode) {
@@ -1404,14 +1517,19 @@ export class QuarksLoader {
1404
1517
  }
1405
1518
  if (node instanceof ParticleEmitter) {
1406
1519
  const system = node.system as ParticleSystem;
1407
- const meshSurfaceUUID = (system as any)._meshSurfaceReferenceUUID;
1408
- 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) {
1409
1527
  const targetNode = nodesMap[meshSurfaceUUID];
1410
1528
  if (targetNode instanceof Mesh) {
1411
- (system.emitterShape as any).mesh = targetNode;
1529
+ shape.mesh = targetNode;
1412
1530
  } else {
1413
- console.warn(
1414
- `[QuarksLoader] Emitter "${node.name}" has a mesh surface shape on "${meshSurfaceUUID}", ` +
1531
+ warnAtSystem(
1532
+ `Emitter "${node.name}" has a mesh surface shape on "${meshSurfaceUUID}", ` +
1415
1533
  (targetNode ? 'which is not a mesh' : 'which is not in the effect') +
1416
1534
  '; it emits nothing until a mesh is set.'
1417
1535
  );
@@ -1420,13 +1538,13 @@ export class QuarksLoader {
1420
1538
  for (let i = 0; i < system.behaviors.length; i++) {
1421
1539
  if (system.behaviors[i] instanceof EmitSubParticleSystem) {
1422
1540
  const subEmitter = system.behaviors[i] as EmitSubParticleSystem;
1423
- const targetUUID = subEmitter.subParticleSystem as any;
1541
+ const targetUUID = subEmitter.subParticleSystem;
1424
1542
  if (typeof targetUUID !== 'string') {
1425
1543
  continue;
1426
1544
  }
1427
1545
  const resolved = nodesMap[targetUUID];
1428
1546
  if (resolved instanceof ParticleEmitter) {
1429
- (subEmitter as any).subParticleSystem = resolved;
1547
+ subEmitter.subParticleSystem = resolved;
1430
1548
  (resolved.system as ParticleSystem).onlyUsedByOther = true;
1431
1549
  } else {
1432
1550
  // An export that dropped the target emitter, or kept only
@@ -1434,8 +1552,8 @@ export class QuarksLoader {
1434
1552
  // a string the behavior would treat as an emitter.
1435
1553
  subEmitter.subParticleSystem = undefined;
1436
1554
  if (targetUUID !== '') {
1437
- console.warn(
1438
- `[QuarksLoader] Emitter "${node.name}" has a sub emitter targeting "${targetUUID}", ` +
1555
+ warnAtSystem(
1556
+ `Emitter "${node.name}" has a sub emitter targeting "${targetUUID}", ` +
1439
1557
  (resolved ? 'which is not a particle emitter' : 'which is not in the effect') +
1440
1558
  '; that sub emitter is skipped.'
1441
1559
  );