babylon.quarks 0.27.0 → 0.29.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 (55) hide show
  1. package/CHANGELOG.md +585 -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 +4811 -974
  10. package/dist/types/index.d.ts +4811 -974
  11. package/package.json +7 -2
  12. package/src/BatchedRenderer.ts +106 -79
  13. package/src/EffectPool.ts +96 -16
  14. package/src/MeshSurfaceEmitter.ts +24 -13
  15. package/src/ParticleEmitter.ts +119 -26
  16. package/src/ParticleSystem.ts +305 -55
  17. package/src/QuarksLoader.ts +298 -171
  18. package/src/QuarksPrefab.ts +301 -94
  19. package/src/QuarksSerializer.ts +44 -16
  20. package/src/QuarksUtil.ts +88 -11
  21. package/src/SpriteBatch.ts +24 -6
  22. package/src/TrailBatch.ts +20 -7
  23. package/src/VFXBatch.ts +182 -528
  24. package/src/batchKey.ts +6 -2
  25. package/src/batchSettings.ts +548 -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 +41 -4
  31. package/src/nodeId.ts +25 -2
  32. package/src/shaderProgram.ts +213 -16
  33. package/src/shaderVariants.ts +124 -7
  34. package/src/shaders/chunks/blend.ts +39 -0
  35. package/src/shaders/chunks/fog.ts +8 -3
  36. package/src/shaders/chunks/imageProcessing.ts +143 -0
  37. package/src/shaders/chunks/soft.ts +6 -5
  38. package/src/shaders/chunks/surface.ts +2 -2
  39. package/src/shaders/particle_frag.glsl.ts +5 -1
  40. package/src/shaders/particle_frag.wgsl.ts +4 -0
  41. package/src/shaders/particle_physics_frag.glsl.ts +7 -2
  42. package/src/shaders/particle_physics_frag.wgsl.ts +4 -0
  43. package/src/shaders/trail_frag.glsl.ts +6 -1
  44. package/src/shaders/trail_frag.wgsl.ts +4 -0
  45. package/src/system/EmissionController.ts +23 -20
  46. package/src/system/EventDispatcher.ts +26 -10
  47. package/src/system/ParticleSpawner.ts +96 -19
  48. package/src/system/geometry.ts +22 -29
  49. package/src/system/integrate.ts +61 -4
  50. package/src/system/materialResolve.ts +56 -7
  51. package/src/system/normalize.ts +34 -2
  52. package/src/system/serialization.ts +54 -33
  53. package/src/system/settingsSchema.ts +57 -17
  54. package/src/system/types.ts +150 -42
  55. 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,10 @@ 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};
340
+ /** The file's `quarksFormat`; 0 for a file written before the field existed. */
341
+ format: number;
255
342
  }
256
343
 
257
344
  /**
@@ -292,18 +379,20 @@ export class QuarksLoader {
292
379
 
293
380
  /**
294
381
  * 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
382
+ * the effect JSON is already in hand (File, CDN payload, editor import). `json` is an
383
+ * {@link EffectDocumentJSON} as parsed from its file; anything else throws
384
+ * {@link QuarksFormatError}, and a part of the wrong kind warns. `json` itself is
296
385
  * left as it was, so the same document can be loaded again, into another scene or with
297
386
  * another base url.
298
387
  */
299
- async loadFromJSON(json: any, baseUrl?: string, options: QuarksLoadOptions = {}): Promise<TransformNode> {
388
+ async loadFromJSON(json: unknown, baseUrl?: string, options: QuarksLoadOptions = {}): Promise<TransformNode> {
300
389
  const signal = options.signal;
301
390
  checkLoad(this.scene, signal);
302
391
  const resolvedBase = baseUrl ?? this.options.baseUrl ?? '';
303
- json = detachDocument(json);
304
- await untilStopped(this.resolveResources(json, resolvedBase), this.scene, signal);
392
+ const document = detachDocument(json);
393
+ await untilStopped(this.resolveResources(document, resolvedBase), this.scene, signal);
305
394
  // The urls are resolved now; joining them with the base again would load `fx/fx/...`.
306
- const loaded = this.parseDocument(json, '');
395
+ const loaded = this.parseDocument(document, '');
307
396
  await this.finishLoad(loaded, signal);
308
397
  return loaded.root;
309
398
  }
@@ -402,7 +491,7 @@ export class QuarksLoader {
402
491
  entries: Array<QuarksJSONLoadEntry | unknown>,
403
492
  options: QuarksBatchLoadOptions = {}
404
493
  ): Promise<(TransformNode | null)[]> {
405
- const docs: Array<{json: any; baseUrl: string} | null> = entries.map((entry) => {
494
+ const docs: Array<{json: unknown; baseUrl: string} | null> = entries.map((entry) => {
406
495
  // Copied, as loadFromJSON does, so the caller's documents stay as they were.
407
496
  if (entry !== null && typeof entry === 'object' && 'json' in (entry as object)) {
408
497
  const typed = entry as QuarksJSONLoadEntry;
@@ -420,7 +509,7 @@ export class QuarksLoader {
420
509
  * loading the same document again does not join its base url twice. Failures warn and leave
421
510
  * the entry as is.
422
511
  */
423
- async resolveResources(json: any, baseUrl: string = ''): Promise<void> {
512
+ async resolveResources(json: unknown, baseUrl: string = ''): Promise<void> {
424
513
  await QuarksLoader.resolveLinkedResources(json, {
425
514
  baseUrl,
426
515
  resolver: this.options.resolver,
@@ -435,15 +524,7 @@ export class QuarksLoader {
435
524
  * Like it, resolves each document once. A geometry file that fails to load goes to
436
525
  * `onResourceError` (default: a console warning) and keeps its link.
437
526
  */
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> {
527
+ static async resolveLinkedResources(json: unknown, options: QuarksResolveOptions = {}): Promise<void> {
447
528
  const baseUrl = options.baseUrl ?? '';
448
529
  const resolver = options.resolver;
449
530
  const scene = options.scene;
@@ -457,10 +538,9 @@ export class QuarksLoader {
457
538
  options.onResourceError
458
539
  );
459
540
 
460
- const images: any[] = Array.isArray(json?.images) ? json.images : [];
461
541
  await Promise.all(
462
- images.map(async (img) => {
463
- if (typeof img?.url !== 'string' || !img.url) {
542
+ documentList(json, 'images').map(async (img) => {
543
+ if (!isRecord(img) || typeof img.url !== 'string' || !img.url) {
464
544
  return;
465
545
  }
466
546
  // An entry this loader already rewrote keeps its url: resolving the same document
@@ -468,12 +548,13 @@ export class QuarksLoader {
468
548
  if (resolvedImageUrls.get(img) === img.url) {
469
549
  return;
470
550
  }
471
- const source: string = img.url;
472
- img.url = await QuarksLoader.resolveWith(resolver, img.url, 'image', baseUrl);
473
- resolvedImageUrls.set(img, img.url);
551
+ const source = img.url;
552
+ const url = await QuarksLoader.resolveWith(resolver, source, 'image', baseUrl);
553
+ img.url = url;
554
+ resolvedImageUrls.set(img, url);
474
555
  // A resolver that inlined the file (`data:`, `blob:`) made the url the image itself;
475
556
  // 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)) {
557
+ if (!/^(data|blob):/i.test(url)) {
477
558
  sourceImageUrls.set(img, source);
478
559
  }
479
560
  })
@@ -486,7 +567,10 @@ export class QuarksLoader {
486
567
  * the loader's resolver and fetch cache. Kept for callers that already have a custom
487
568
  * `loadGeometry` and no `QuarksLoader` instance.
488
569
  */
489
- static async resolveExternalGeometries(json: any, loadGeometry: (url: string) => Promise<any>): Promise<void> {
570
+ static async resolveExternalGeometries(
571
+ json: unknown,
572
+ loadGeometry: (url: string) => Promise<unknown>
573
+ ): Promise<void> {
490
574
  await QuarksLoader.inlineGeometryEntries(json, loadGeometry);
491
575
  }
492
576
 
@@ -496,19 +580,41 @@ export class QuarksLoader {
496
580
  * read warn and take their defaults; a document with no `object` throws
497
581
  * {@link QuarksFormatError}. Free the effect with {@link disposeEffect}.
498
582
  */
499
- parse(json: any, baseUrl: string = ''): TransformNode {
583
+ parse(json: unknown, baseUrl: string = ''): TransformNode {
500
584
  return this.parseDocument(json, baseUrl).root;
501
585
  }
502
586
 
503
587
  /** {@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') {
588
+ private parseDocument(json: unknown, baseUrl: string): {root: TransformNode; textures: Texture[]} {
589
+ const {strict, onFormatWarning} = this.options;
590
+ const problems: string[] = [];
591
+ const onWarning = (message: string, path: string): void => {
592
+ const located = path ? `${message} (at ${path})` : message;
593
+ if (strict) problems.push(located);
594
+ if (onFormatWarning) onFormatWarning(message, path);
595
+ else if (!strict) console.warn(`[quarks] ${located}`);
596
+ };
597
+ const loaded = readFormat({onWarning}, () => this.readDocument(json, baseUrl));
598
+ if (problems.length > 0) {
599
+ // The whole effect is read first, so a strict load frees what it built the same way any
600
+ // other effect is freed, and reports how many parts failed.
601
+ QuarksLoader.disposeEffect(loaded.root);
602
+ const more = problems.length > 1 ? ` (and ${problems.length - 1} more)` : '';
603
+ throw new QuarksFormatError(`${problems[0]}${more}`, problems);
604
+ }
605
+ return loaded;
606
+ }
607
+
608
+ /** {@link parseDocument} inside its format read. */
609
+ private readDocument(document: unknown, baseUrl: string): {root: TransformNode; textures: Texture[]} {
610
+ if (!isEffectDocument(document)) {
506
611
  throw new QuarksFormatError('[QuarksLoader] Not an effect document: it has no "object" to load.');
507
612
  }
613
+ const json = document;
508
614
  const format = json.metadata?.quarksFormat;
509
615
  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} ` +
616
+ warnFormat(
617
+ `This effect was written in format ${format}, newer than the ${QUARKS_FORMAT_VERSION} ` +
512
618
  'this version of babylon.quarks reads; settings it does not know are left out.'
513
619
  );
514
620
  }
@@ -519,29 +625,31 @@ export class QuarksLoader {
519
625
  textures: {},
520
626
  geometries: {},
521
627
  materials: {},
628
+ format: typeof format === 'number' ? format : 0,
522
629
  };
523
630
 
524
- if (json.geometries) {
525
- this.parseGeometries(json.geometries, meta);
631
+ const {geometries, images, textures: textureList, materials} = json;
632
+ if (geometries) {
633
+ readFormatPart('geometries', () => this.parseGeometries(documentEntries(geometries), meta));
526
634
  }
527
- if (json.images) {
528
- this.parseImages(json.images, baseUrl, meta);
635
+ if (images) {
636
+ readFormatPart('images', () => this.parseImages(documentEntries(images), baseUrl, meta));
529
637
  }
530
- if (json.textures) {
531
- this.parseTextures(json.textures, meta);
638
+ if (textureList) {
639
+ readFormatPart('textures', () => this.parseTextures(documentEntries(textureList), meta));
532
640
  }
533
- if (json.materials) {
534
- this.parseMaterials(json.materials, meta);
641
+ if (materials) {
642
+ readFormatPart('materials', () => this.parseMaterials(documentEntries(materials), meta));
535
643
  }
536
644
 
537
- const root = this.parseObject(json.object, meta);
645
+ const root = readFormatPart('object', () => this.parseObject(json.object, meta));
538
646
  this.linkReferences(root);
539
647
  const textures = [...new Set(Object.values(meta.textures).filter((t): t is Texture => t !== null))];
540
648
  retainEffectResources(root, textures, meta.ownedMaterials);
541
649
  return {root, textures};
542
650
  }
543
651
 
544
- private parseGeometries(geometries: any[], meta: LoadedMeta): void {
652
+ private parseGeometries(geometries: EffectGeometryJSON[], meta: LoadedMeta): void {
545
653
  for (const geom of geometries) {
546
654
  if (geom.type === 'PlaneGeometry') {
547
655
  const w = geom.width || 1;
@@ -561,8 +669,8 @@ export class QuarksLoader {
561
669
  meta.geometries[geom.uuid] = parsed;
562
670
  } else if (geom.type === 'QuarksGeometry') {
563
671
  if (typeof geom.url === 'string' && !geom.positions) {
564
- console.warn(
565
- `[QuarksLoader] Geometry "${geom.url}" is an external file; use load(), ` +
672
+ warnFormat(
673
+ `Geometry "${geom.url}" is an external file; use load(), ` +
566
674
  'loadFromJSON(), or resolveResources() before parse().'
567
675
  );
568
676
  }
@@ -578,7 +686,7 @@ export class QuarksLoader {
578
686
  }
579
687
 
580
688
  /** The scene's copy of an identical geometry when resources are shared, else `geometry` itself. */
581
- private share(geometry: ParsedGeometry): ParsedGeometry {
689
+ private share(geometry: GeometryArrays): GeometryArrays {
582
690
  if (this.options.shareResources === false) {
583
691
  return geometry;
584
692
  }
@@ -611,7 +719,7 @@ export class QuarksLoader {
611
719
  return baseUrl + url;
612
720
  }
613
721
 
614
- private parseSphereGeometry(geom: any): ParsedGeometry {
722
+ private parseSphereGeometry(geom: SphereGeometryJSON): GeometryArrays {
615
723
  const radius = geom.radius ?? 0.5;
616
724
  const widthSegments = Math.max(3, Math.floor(geom.widthSegments ?? 16));
617
725
  const heightSegments = Math.max(2, Math.floor(geom.heightSegments ?? 8));
@@ -681,8 +789,8 @@ export class QuarksLoader {
681
789
  };
682
790
  }
683
791
 
684
- private parseBufferGeometry(data: any): ParsedGeometry {
685
- let positions = new Float32Array(0);
792
+ private parseBufferGeometry(data: BufferGeometryDataJSON): GeometryArrays {
793
+ let positions: Float32Array = new Float32Array(0);
686
794
  let indices: Uint32Array | Uint16Array = new Uint32Array(0);
687
795
  let uvs: Float32Array | undefined;
688
796
  let uvs2: Float32Array | undefined;
@@ -692,7 +800,7 @@ export class QuarksLoader {
692
800
 
693
801
  if (data.interleavedBuffers && data.arrayBuffers) {
694
802
  const buffers: {[uuid: string]: {data: Float32Array; stride: number}} = {};
695
- for (const [uuid, ibDef] of Object.entries(data.interleavedBuffers) as any[]) {
803
+ for (const [uuid, ibDef] of Object.entries(data.interleavedBuffers)) {
696
804
  const arrayBuffer = data.arrayBuffers[ibDef.buffer];
697
805
  if (arrayBuffer) {
698
806
  const floatArray = new Float32Array(
@@ -702,35 +810,17 @@ export class QuarksLoader {
702
810
  }
703
811
  }
704
812
 
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
- }
813
+ const readInterleaved = (attr: BufferAttributeJSON | undefined): Float32Array | undefined => {
814
+ const buf =
815
+ attr?.isInterleavedBufferAttribute && attr.data !== undefined ? buffers[attr.data] : undefined;
816
+ return buf
817
+ ? this.extractInterleavedAttribute(buf.data, buf.stride, attr?.offset, attr?.itemSize)
818
+ : undefined;
819
+ };
820
+ positions = readInterleaved(data.attributes?.position) ?? positions;
821
+ uvs = readInterleaved(data.attributes?.uv);
822
+ uvs2 = readInterleaved(uv2Attribute);
823
+ normals = readInterleaved(data.attributes?.normal);
734
824
  } else if (data.attributes) {
735
825
  if (data.attributes.position?.array) {
736
826
  positions = new Float32Array(data.attributes.position.array);
@@ -759,7 +849,12 @@ export class QuarksLoader {
759
849
  return {positions, indices, uvs, uvs2, normals};
760
850
  }
761
851
 
762
- private extractInterleavedAttribute(buffer: Float32Array, stride: number, offset: number, itemSize: number): any {
852
+ private extractInterleavedAttribute(
853
+ buffer: Float32Array,
854
+ stride: number,
855
+ offset: number = 0,
856
+ itemSize: number = 0
857
+ ): Float32Array {
763
858
  const count = Math.floor(buffer.length / stride);
764
859
  const result = new Float32Array(count * itemSize);
765
860
  for (let i = 0; i < count; i++) {
@@ -776,7 +871,7 @@ export class QuarksLoader {
776
871
  for (let i = 0; i < intArray.length; i++) {
777
872
  int32View[i] = intArray[i];
778
873
  }
779
- return new Float32Array(buffer) as any;
874
+ return new Float32Array(buffer);
780
875
  }
781
876
 
782
877
  private async resolveResourceUrl(url: string, kind: QuarksResourceKind, baseUrl: string): Promise<string> {
@@ -784,7 +879,7 @@ export class QuarksLoader {
784
879
  }
785
880
 
786
881
  /** 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}> {
882
+ private async fetchEffectDocument(url: string, signal?: AbortSignal): Promise<{json: unknown; baseUrl: string}> {
788
883
  const baseUrl = this.options.baseUrl || url.substring(0, url.lastIndexOf('/') + 1);
789
884
  const resolvedEffect = await this.resolveResourceUrl(url, 'effect', baseUrl);
790
885
  const json = await QuarksLoader.fetchJsonWith(this.options.resolver, resolvedEffect, undefined, signal);
@@ -796,7 +891,7 @@ export class QuarksLoader {
796
891
  * scene fetch cache), then parse. Each index settles exactly once for `onProgress`.
797
892
  */
798
893
  private async loadDocuments(
799
- docs: Array<{json: any; baseUrl: string} | null>,
894
+ docs: Array<{json: unknown; baseUrl: string} | null>,
800
895
  options: QuarksBatchLoadOptions
801
896
  ): Promise<(TransformNode | null)[]> {
802
897
  const total = docs.length;
@@ -816,7 +911,7 @@ export class QuarksLoader {
816
911
  }
817
912
 
818
913
  private async loadResolvedDocuments(
819
- docs: Array<{json: any; baseUrl: string} | null>,
914
+ docs: Array<{json: unknown; baseUrl: string} | null>,
820
915
  options: QuarksBatchLoadOptions,
821
916
  stopAll: AbortController
822
917
  ): Promise<(TransformNode | null)[]> {
@@ -976,31 +1071,32 @@ export class QuarksLoader {
976
1071
  }
977
1072
 
978
1073
  private static async inlineGeometryEntries(
979
- json: any,
980
- loadGeometry: (url: string) => Promise<any>,
1074
+ json: unknown,
1075
+ loadGeometry: (url: string) => Promise<unknown>,
981
1076
  onResourceError: QuarksLoaderOptions['onResourceError'] = QuarksLoader.warnResourceError
982
1077
  ): Promise<void> {
983
- const geometries: any[] = Array.isArray(json?.geometries) ? json.geometries : [];
984
1078
  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;
1079
+ documentList(json, 'geometries').map(async (geom) => {
1080
+ if (!isRecord(geom) || typeof geom.url !== 'string' || geom.positions) {
1081
+ return;
1082
+ }
1083
+ const url = geom.url;
1084
+ try {
1085
+ const data = await loadGeometry(url);
1086
+ // Copy arrays so a shared fetch-cache document is not aliased across
1087
+ // effects — mutating one effect's inlined geometry must not corrupt others.
1088
+ for (const key of ['positions', 'indices', 'uvs', 'uvs2', 'normals']) {
1089
+ const value = isRecord(data) ? data[key] : undefined;
1090
+ if (value === undefined) {
1091
+ continue;
998
1092
  }
999
- delete geom.url;
1000
- } catch (err) {
1001
- onResourceError('geometry', geom.url, err);
1093
+ geom[key] = Array.isArray(value) ? value.slice() : value;
1002
1094
  }
1003
- })
1095
+ delete geom.url;
1096
+ } catch (err) {
1097
+ onResourceError('geometry', url, err);
1098
+ }
1099
+ })
1004
1100
  );
1005
1101
  }
1006
1102
 
@@ -1012,17 +1108,18 @@ export class QuarksLoader {
1012
1108
  (this.options.onResourceError ?? QuarksLoader.warnResourceError)(kind, url, error);
1013
1109
  }
1014
1110
 
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);
1111
+ private parseImages(images: EffectImageJSON[], baseUrl: string, meta: LoadedMeta): void {
1112
+ for (const [index, img] of images.entries()) {
1113
+ const url = img.url;
1114
+ if (url) {
1115
+ readFormatPart(`[${index}]`, () => QuarksLoader.warnIfNotAnImageUrl(url));
1019
1116
  }
1020
1117
  // Urls are usually already absolute after resolveResources; joinUrl is a no-op for
1021
1118
  // 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;
1119
+ meta.images[img.uuid] = url ? QuarksLoader.joinUrl(baseUrl, url) : null;
1023
1120
  // The url the file wrote, for serializeEffect to write back. An inlined image
1024
1121
  // (`data:`, `blob:`) has no path to write; its texture writes its url.
1025
- const source: string | undefined = sourceImageUrls.get(img) ?? img.url;
1122
+ const source = sourceImageUrls.get(img) ?? url;
1026
1123
  if (source && !/^(data|blob):/i.test(source)) {
1027
1124
  meta.imageSources[img.uuid] = source;
1028
1125
  }
@@ -1042,15 +1139,15 @@ export class QuarksLoader {
1042
1139
  if (/^(data|blob):/i.test(url) || /\.(png|jpe?g|webp|gif|bmp|ktx2?|dds|env|basis)(\?|#|$)/i.test(url)) {
1043
1140
  return;
1044
1141
  }
1045
- console.warn(
1046
- `[QuarksLoader] Image "${url}" does not look like an image; its texture will fail to load. ` +
1142
+ warnFormat(
1143
+ `Image "${url}" does not look like an image; its texture will fail to load. ` +
1047
1144
  'This usually means the effect was exported without embedding that texture.'
1048
1145
  );
1049
1146
  }
1050
1147
 
1051
- private parseTextures(textures: any[], meta: LoadedMeta): void {
1148
+ private parseTextures(textures: QuarksTextureJSON[], meta: LoadedMeta): void {
1052
1149
  for (const texDef of textures) {
1053
- const imageUrl = meta.images[texDef.image];
1150
+ const imageUrl = texDef.image !== undefined ? meta.images[texDef.image] : undefined;
1054
1151
  if (imageUrl) {
1055
1152
  const invertY = texDef.invertY !== undefined ? !!texDef.invertY : true;
1056
1153
  const noMipmap = !!texDef.noMipmap;
@@ -1105,7 +1202,7 @@ export class QuarksLoader {
1105
1202
  continue;
1106
1203
  }
1107
1204
  textureSettled.set(texture, settled);
1108
- const source = meta.imageSources[texDef.image];
1205
+ const source = texDef.image !== undefined ? meta.imageSources[texDef.image] : undefined;
1109
1206
  if (source !== undefined) {
1110
1207
  setTextureSourceUrl(texture, source, imageUrl);
1111
1208
  }
@@ -1154,9 +1251,12 @@ export class QuarksLoader {
1154
1251
  }
1155
1252
  }
1156
1253
 
1157
- private parseMaterials(materials: any[], meta: LoadedMeta): void {
1254
+ private parseMaterials(materials: QuarksMaterialJSON[], meta: LoadedMeta): void {
1255
+ // A file names a texture by uuid; anything else in a slot is no texture.
1256
+ const texture = (ref: MaterialTextureRef | undefined): Texture | null =>
1257
+ typeof ref === 'string' ? meta.textures[ref] || null : null;
1158
1258
  for (const matDef of materials) {
1159
- const matInfo: any = {
1259
+ const matInfo: QuarksMaterialJSON = {
1160
1260
  uuid: matDef.uuid,
1161
1261
  transparent: matDef.transparent ?? true,
1162
1262
  blending: matDef.blending ?? 1,
@@ -1171,12 +1271,12 @@ export class QuarksLoader {
1171
1271
  // emits) uses `texture`. Accept both so library-exported effects round-trip.
1172
1272
  const textureRef = matDef.map ?? matDef.texture;
1173
1273
  if (textureRef) {
1174
- matInfo.texture = meta.textures[textureRef] || null;
1274
+ matInfo.texture = texture(textureRef);
1175
1275
  }
1176
1276
 
1177
1277
  const reflectionAtlasRef = matDef.reflectionAtlas;
1178
1278
  if (reflectionAtlasRef) {
1179
- matInfo.reflectionAtlas = meta.textures[reflectionAtlasRef] || null;
1279
+ matInfo.reflectionAtlas = texture(reflectionAtlasRef);
1180
1280
  }
1181
1281
  if (typeof matDef.reflectionLevel === 'number') {
1182
1282
  matInfo.reflectionLevel = matDef.reflectionLevel;
@@ -1199,20 +1299,20 @@ export class QuarksLoader {
1199
1299
  if (matDef.gradientMap && typeof matDef.gradientMap === 'object') {
1200
1300
  matInfo.gradientMap = {
1201
1301
  ...matDef.gradientMap,
1202
- ramp: matDef.gradientMap.ramp ? meta.textures[matDef.gradientMap.ramp] || null : null,
1302
+ ramp: texture(matDef.gradientMap.ramp),
1203
1303
  };
1204
1304
  }
1205
1305
  if (matDef.erosion && typeof matDef.erosion === 'object') {
1206
1306
  matInfo.erosion = {
1207
1307
  ...matDef.erosion,
1208
- mask: matDef.erosion.mask ? meta.textures[matDef.erosion.mask] || null : null,
1308
+ mask: texture(matDef.erosion.mask),
1209
1309
  };
1210
1310
  }
1211
1311
  // uvDistortion {map (texture uuid), mapUV {channel, tiling, offset, scroll}, strength}.
1212
1312
  if (matDef.uvDistortion && typeof matDef.uvDistortion === 'object') {
1213
1313
  matInfo.uvDistortion = {
1214
1314
  ...matDef.uvDistortion,
1215
- map: matDef.uvDistortion.map ? meta.textures[matDef.uvDistortion.map] || null : null,
1315
+ map: texture(matDef.uvDistortion.map),
1216
1316
  };
1217
1317
  }
1218
1318
 
@@ -1230,19 +1330,19 @@ export class QuarksLoader {
1230
1330
  3: Constants.ALPHA_SUBTRACT,
1231
1331
  4: Constants.ALPHA_MULTIPLY,
1232
1332
  };
1233
- matInfo.alphaMode = blendingToAlphaMode[matDef.blending] || Constants.ALPHA_COMBINE;
1333
+ matInfo.alphaMode = blendingToAlphaMode[matDef.blending ?? 1] || Constants.ALPHA_COMBINE;
1234
1334
  }
1235
1335
 
1236
1336
  meta.materials[matDef.uuid] = matInfo;
1237
1337
  }
1238
1338
  }
1239
1339
 
1240
- private getGeometry(meta: LoadedMeta, geometryId: string | undefined): ParsedGeometry | undefined {
1340
+ private getGeometry(meta: LoadedMeta, geometryId: string | undefined): GeometryArrays | undefined {
1241
1341
  if (!geometryId) return undefined;
1242
1342
  return meta.geometries[geometryId];
1243
1343
  }
1244
1344
 
1245
- private getMaterial(meta: LoadedMeta, materialId: string | string[] | undefined): any | undefined {
1345
+ private getMaterial(meta: LoadedMeta, materialId: string | string[] | undefined): QuarksMaterialJSON | undefined {
1246
1346
  if (!materialId) return undefined;
1247
1347
  const selectedId = Array.isArray(materialId) ? materialId[0] : materialId;
1248
1348
  return meta.materials[selectedId];
@@ -1250,11 +1350,12 @@ export class QuarksLoader {
1250
1350
 
1251
1351
  private createPlaceholderNode(type: string, name?: string): TransformNode {
1252
1352
  const node = new TransformNode(name || type || 'node', this.scene);
1253
- (node as any).quarksOriginalType = type;
1353
+ // The three.js type the node stood for, which babylon.quarks has no node for.
1354
+ Object.assign(node, {quarksOriginalType: type});
1254
1355
  return node;
1255
1356
  }
1256
1357
 
1257
- private createMeshNode(data: any, meta: LoadedMeta): Mesh {
1358
+ private createMeshNode(data: QuarksNodeJSON, meta: LoadedMeta): Mesh {
1258
1359
  const mesh = new Mesh(data.name || data.type || 'mesh', this.scene);
1259
1360
  const geometry = this.getGeometry(meta, data.geometry);
1260
1361
  if (geometry && geometry.positions.length > 0 && geometry.indices.length > 0) {
@@ -1281,16 +1382,17 @@ export class QuarksLoader {
1281
1382
  material.backFaceCulling = materialInfo.side !== 2;
1282
1383
  material.alphaCutOff = materialInfo.alphaTest ?? 0;
1283
1384
  material.disableDepthWrite = !(materialInfo.depthWrite ?? false);
1284
- if (materialInfo.texture) {
1285
- material.diffuseTexture = materialInfo.texture;
1286
- material.opacityTexture = materialInfo.texture;
1385
+ const texture = materialInfo.texture;
1386
+ if (texture && typeof texture !== 'string') {
1387
+ material.diffuseTexture = texture;
1388
+ material.opacityTexture = texture;
1287
1389
  }
1288
1390
  mesh.material = material;
1289
1391
  }
1290
1392
  return mesh;
1291
1393
  }
1292
1394
 
1293
- private parseObject(data: any, meta: LoadedMeta): TransformNode {
1395
+ private parseObject(data: QuarksNodeJSON, meta: LoadedMeta): TransformNode {
1294
1396
  let node: TransformNode;
1295
1397
  switch (data.type) {
1296
1398
  case 'QuarksPrefab':
@@ -1298,7 +1400,8 @@ export class QuarksLoader {
1298
1400
  break;
1299
1401
  case 'ParticleEmitter':
1300
1402
  if (data.ps) {
1301
- const ps = this.parseParticleSystem(data.ps, meta);
1403
+ const psJSON = data.ps;
1404
+ const ps = readFormatPart('ps', () => this.parseParticleSystem(psJSON, meta));
1302
1405
  node = ps.emitter;
1303
1406
  break;
1304
1407
  }
@@ -1336,7 +1439,7 @@ export class QuarksLoader {
1336
1439
  node = new TransformNode(data.name || data.type || 'node', this.scene);
1337
1440
  }
1338
1441
 
1339
- if (data.uuid) (node as any)._quarksUUID = data.uuid;
1442
+ if (data.uuid) setQuarksNodeId(node, data.uuid);
1340
1443
  if (data.name) node.name = data.name;
1341
1444
 
1342
1445
  if (data.matrix) {
@@ -1367,19 +1470,37 @@ export class QuarksLoader {
1367
1470
  if (data.isVisible === false && node instanceof Mesh) node.isVisible = false;
1368
1471
 
1369
1472
  if (data.children) {
1370
- for (const childData of data.children) {
1371
- const child = this.parseObject(childData, meta);
1372
- child.parent = node;
1473
+ const children: unknown = data.children;
1474
+ if (!Array.isArray(children)) {
1475
+ readFormatPart('children', () => warnFormat('"children" is not a list; the node loads without them.'));
1476
+ } else {
1477
+ children.forEach((childData: unknown, index) => {
1478
+ const child = readFormatPart(`children[${index}]`, () =>
1479
+ isNodeJSON(childData)
1480
+ ? this.parseObject(childData, meta)
1481
+ : warnFormat('A child is not an object; it is skipped.')
1482
+ );
1483
+ if (child) child.parent = node;
1484
+ });
1373
1485
  }
1374
1486
  }
1375
1487
 
1376
1488
  return node;
1377
1489
  }
1378
1490
 
1379
- private parseParticleSystem(json: any, meta: LoadedMeta): ParticleSystem {
1491
+ private parseParticleSystem(json: ParticleSystemJSONParameters, meta: LoadedMeta): ParticleSystem {
1492
+ if (meta.format < 2 && json !== null && typeof json === 'object' && json.scalingMode !== 'hierarchy') {
1493
+ // Before format 2 the emitter's whole world scale reached every system's shape and
1494
+ // particle size, and a local-space system's motion too: 'hierarchy' is the mode that
1495
+ // keeps such an effect's look. 'local' became the default with Unity's meaning.
1496
+ json = {...json, scalingMode: 'hierarchy'};
1497
+ }
1380
1498
  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;
1499
+ const ps = ParticleSystem.fromJSON(json, meta, dependencies, this.scene);
1500
+ if (json?.shape?.type === 'mesh_surface' && typeof json.shape.mesh === 'string') {
1501
+ meshSurfaceLinks.set(ps, json.shape.mesh);
1502
+ }
1503
+ systemPaths.set(ps, currentFormatPath());
1383
1504
  return ps;
1384
1505
  }
1385
1506
 
@@ -1387,8 +1508,9 @@ export class QuarksLoader {
1387
1508
  const nodesMap: {[uuid: string]: TransformNode} = {};
1388
1509
 
1389
1510
  const traverse = (node: TransformNode) => {
1390
- if ((node as any)._quarksUUID) {
1391
- nodesMap[(node as any)._quarksUUID] = node;
1511
+ const fileId = fileNodeId(node);
1512
+ if (fileId) {
1513
+ nodesMap[fileId] = node;
1392
1514
  }
1393
1515
  for (const child of node.getChildren()) {
1394
1516
  if (child instanceof TransformNode) {
@@ -1404,14 +1526,19 @@ export class QuarksLoader {
1404
1526
  }
1405
1527
  if (node instanceof ParticleEmitter) {
1406
1528
  const system = node.system as ParticleSystem;
1407
- const meshSurfaceUUID = (system as any)._meshSurfaceReferenceUUID;
1408
- if (meshSurfaceUUID && (system.emitterShape as any).type === 'mesh_surface') {
1529
+ // Links are made once the whole effect is read; a warning names the system's path.
1530
+ const path = systemPaths.get(system);
1531
+ const warnAtSystem = (message: string) =>
1532
+ path ? readFormatPart(path, () => warnFormat(message)) : warnFormat(message);
1533
+ const meshSurfaceUUID = meshSurfaceLinks.get(system);
1534
+ const shape = system.emitterShape;
1535
+ if (meshSurfaceUUID && shape instanceof MeshSurfaceEmitter) {
1409
1536
  const targetNode = nodesMap[meshSurfaceUUID];
1410
1537
  if (targetNode instanceof Mesh) {
1411
- (system.emitterShape as any).mesh = targetNode;
1538
+ shape.mesh = targetNode;
1412
1539
  } else {
1413
- console.warn(
1414
- `[QuarksLoader] Emitter "${node.name}" has a mesh surface shape on "${meshSurfaceUUID}", ` +
1540
+ warnAtSystem(
1541
+ `Emitter "${node.name}" has a mesh surface shape on "${meshSurfaceUUID}", ` +
1415
1542
  (targetNode ? 'which is not a mesh' : 'which is not in the effect') +
1416
1543
  '; it emits nothing until a mesh is set.'
1417
1544
  );
@@ -1420,13 +1547,13 @@ export class QuarksLoader {
1420
1547
  for (let i = 0; i < system.behaviors.length; i++) {
1421
1548
  if (system.behaviors[i] instanceof EmitSubParticleSystem) {
1422
1549
  const subEmitter = system.behaviors[i] as EmitSubParticleSystem;
1423
- const targetUUID = subEmitter.subParticleSystem as any;
1550
+ const targetUUID = subEmitter.subParticleSystem;
1424
1551
  if (typeof targetUUID !== 'string') {
1425
1552
  continue;
1426
1553
  }
1427
1554
  const resolved = nodesMap[targetUUID];
1428
1555
  if (resolved instanceof ParticleEmitter) {
1429
- (subEmitter as any).subParticleSystem = resolved;
1556
+ subEmitter.subParticleSystem = resolved;
1430
1557
  (resolved.system as ParticleSystem).onlyUsedByOther = true;
1431
1558
  } else {
1432
1559
  // An export that dropped the target emitter, or kept only
@@ -1434,8 +1561,8 @@ export class QuarksLoader {
1434
1561
  // a string the behavior would treat as an emitter.
1435
1562
  subEmitter.subParticleSystem = undefined;
1436
1563
  if (targetUUID !== '') {
1437
- console.warn(
1438
- `[QuarksLoader] Emitter "${node.name}" has a sub emitter targeting "${targetUUID}", ` +
1564
+ warnAtSystem(
1565
+ `Emitter "${node.name}" has a sub emitter targeting "${targetUUID}", ` +
1439
1566
  (resolved ? 'which is not a particle emitter' : 'which is not in the effect') +
1440
1567
  '; that sub emitter is skipped.'
1441
1568
  );