@vgai/engine 0.2.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 (147) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +35 -0
  3. package/package.json +55 -0
  4. package/src/adapter/authoring.ts +402 -0
  5. package/src/adapter/colyseus-networking-adapter.ts +72 -0
  6. package/src/adapter/first-party-systems.ts +103 -0
  7. package/src/adapter/game-adapter.ts +151 -0
  8. package/src/adapter/host-context.ts +77 -0
  9. package/src/adapter/index.ts +85 -0
  10. package/src/adapter/ingest/game-contract.ts +59 -0
  11. package/src/adapter/ingest/overlay-applier.ts +207 -0
  12. package/src/adapter/ingest/overlay-apply.ts +124 -0
  13. package/src/adapter/ingest/overlay-file.ts +126 -0
  14. package/src/adapter/ingest/overlay-report.ts +176 -0
  15. package/src/adapter/ingest/scene-capture.ts +307 -0
  16. package/src/adapter/ingest/upstream-pin.ts +52 -0
  17. package/src/adapter/loop-gate-report.ts +54 -0
  18. package/src/adapter/rapier-physics-adapter.ts +56 -0
  19. package/src/adapter/system-adapter.ts +154 -0
  20. package/src/adapter/transform.ts +18 -0
  21. package/src/adapter/vgai-scene-game-adapter.ts +886 -0
  22. package/src/adapter/world-kind.ts +34 -0
  23. package/src/ai/navigation.ts +164 -0
  24. package/src/animation/anim-graph-types.ts +56 -0
  25. package/src/animation/anim-graph.ts +406 -0
  26. package/src/animation/anim-system.ts +28 -0
  27. package/src/animation/blend-node.ts +119 -0
  28. package/src/animation/property-track.ts +178 -0
  29. package/src/animation/schema.ts +204 -0
  30. package/src/assets.ts +80 -0
  31. package/src/audio/ambient.ts +300 -0
  32. package/src/audio/impacts.ts +212 -0
  33. package/src/audio/index.ts +7 -0
  34. package/src/audio/movement.ts +140 -0
  35. package/src/audio/musical.ts +200 -0
  36. package/src/audio/ui-sounds.ts +171 -0
  37. package/src/audio/vehicle.ts +235 -0
  38. package/src/audio/weapons.ts +152 -0
  39. package/src/core/game-loop.ts +127 -0
  40. package/src/core/system-runner.ts +298 -0
  41. package/src/core/types.ts +58 -0
  42. package/src/dev/console-bridge.ts +83 -0
  43. package/src/dev/debug-draw.ts +80 -0
  44. package/src/dev/logger.ts +119 -0
  45. package/src/ecs/component-manager.ts +748 -0
  46. package/src/ecs/game-component.ts +147 -0
  47. package/src/ecs/hmr-swap-report.ts +65 -0
  48. package/src/input/input-manager.ts +439 -0
  49. package/src/input/input-types.ts +19 -0
  50. package/src/input/schema.ts +129 -0
  51. package/src/loader.ts +70 -0
  52. package/src/manifest/index.ts +24 -0
  53. package/src/manifest/load-file.ts +16 -0
  54. package/src/manifest/load.ts +378 -0
  55. package/src/manifest/schema.ts +375 -0
  56. package/src/physics/collision-system.ts +76 -0
  57. package/src/physics/physics-registry.ts +83 -0
  58. package/src/physics/transform-writer.ts +41 -0
  59. package/src/physics/trigger-dispatch.ts +97 -0
  60. package/src/react/game-state.tsx +172 -0
  61. package/src/render/auto-batcher.ts +169 -0
  62. package/src/render/render-batch-system.ts +268 -0
  63. package/src/render/render-features.ts +146 -0
  64. package/src/render/render-settings.ts +72 -0
  65. package/src/runtime/create-runtime.ts +1152 -0
  66. package/src/runtime/frame-selector-cache.ts +81 -0
  67. package/src/runtime/game.ts +1003 -0
  68. package/src/runtime/input-router.ts +213 -0
  69. package/src/runtime/mount-game.ts +269 -0
  70. package/src/runtime/mount-manifest.ts +361 -0
  71. package/src/runtime/scene-ui-bridge.ts +86 -0
  72. package/src/runtime/scene-ui-data.ts +119 -0
  73. package/src/runtime/state-bridge.ts +79 -0
  74. package/src/runtime/types.ts +196 -0
  75. package/src/scene/asset-loaders.ts +195 -0
  76. package/src/scene/asset-paths.ts +123 -0
  77. package/src/scene/asset-registry.ts +67 -0
  78. package/src/scene/collider-dimensions.ts +125 -0
  79. package/src/scene/component-registry.ts +40 -0
  80. package/src/scene/defaults.ts +164 -0
  81. package/src/scene/geometries/index.ts +7 -0
  82. package/src/scene/geometries/terrain.ts +42 -0
  83. package/src/scene/geometry-registry.ts +42 -0
  84. package/src/scene/instance-registry.ts +84 -0
  85. package/src/scene/instancers/grid.ts +38 -0
  86. package/src/scene/instancers/index.ts +7 -0
  87. package/src/scene/light-camera-factory.ts +97 -0
  88. package/src/scene/material-factory.ts +211 -0
  89. package/src/scene/material-registry.ts +73 -0
  90. package/src/scene/materials/index.ts +7 -0
  91. package/src/scene/materials/water.ts +56 -0
  92. package/src/scene/parse.ts +71 -0
  93. package/src/scene/particles-factory.ts +383 -0
  94. package/src/scene/scene-apply.ts +356 -0
  95. package/src/scene/scene-diff-schema.ts +115 -0
  96. package/src/scene/scene-diff-types.ts +29 -0
  97. package/src/scene/scene-loader.ts +1533 -0
  98. package/src/scene/scene-query.ts +63 -0
  99. package/src/scene/scene-types.ts +34 -0
  100. package/src/scene/scene-version.ts +40 -0
  101. package/src/scene/schema/animation.ts +95 -0
  102. package/src/scene/schema/audio.ts +25 -0
  103. package/src/scene/schema/camera.ts +21 -0
  104. package/src/scene/schema/collider.ts +69 -0
  105. package/src/scene/schema/entity-ref.ts +78 -0
  106. package/src/scene/schema/entity.ts +169 -0
  107. package/src/scene/schema/environment.ts +384 -0
  108. package/src/scene/schema/index.ts +95 -0
  109. package/src/scene/schema/instances.ts +35 -0
  110. package/src/scene/schema/joint.ts +26 -0
  111. package/src/scene/schema/light.ts +38 -0
  112. package/src/scene/schema/material.ts +113 -0
  113. package/src/scene/schema/mesh.ts +108 -0
  114. package/src/scene/schema/particles.ts +398 -0
  115. package/src/scene/schema/physics.ts +49 -0
  116. package/src/scene/schema/scene-file.ts +299 -0
  117. package/src/scene/schema/shadow.ts +24 -0
  118. package/src/scene/schema/spline.ts +21 -0
  119. package/src/scene/schema/tuples.ts +21 -0
  120. package/src/scene/schema/ui.ts +602 -0
  121. package/src/scene/user-data.ts +203 -0
  122. package/src/setup/setup-audio.ts +60 -0
  123. package/src/setup/setup-particles.ts +23 -0
  124. package/src/setup/setup-physics.ts +67 -0
  125. package/src/setup/setup-renderer.ts +529 -0
  126. package/src/types-n8ao.d.ts +37 -0
  127. package/src/types-realism-effects.d.ts +61 -0
  128. package/src/world2d/authoring-2d.ts +208 -0
  129. package/src/world2d/capture-to-scene2d.ts +52 -0
  130. package/src/world2d/collision-2d.ts +106 -0
  131. package/src/world2d/components-2d.ts +86 -0
  132. package/src/world2d/index.ts +66 -0
  133. package/src/world2d/ingest-iframe-2d.ts +255 -0
  134. package/src/world2d/ingest2d.ts +131 -0
  135. package/src/world2d/physics2d-registry.ts +49 -0
  136. package/src/world2d/pixi-game-adapter.ts +325 -0
  137. package/src/world2d/pixi-surface.ts +78 -0
  138. package/src/world2d/scene-capture-2d.ts +117 -0
  139. package/src/world2d/scene2d-loader.ts +308 -0
  140. package/src/world2d/schema/entity2d.ts +145 -0
  141. package/src/world2d/schema/physics2d.ts +53 -0
  142. package/src/world2d/schema/sprite.ts +71 -0
  143. package/src/world2d/schema/tilemap.ts +22 -0
  144. package/src/world2d/schema/tuples2d.ts +25 -0
  145. package/src/world2d/system-adapters-2d.ts +49 -0
  146. package/src/world2d/transform-writer-2d.ts +24 -0
  147. package/src/world2d/types.ts +55 -0
@@ -0,0 +1,1533 @@
1
+ import type RAPIER from '@dimforge/rapier3d-compat';
2
+ import * as THREE from 'three';
3
+ import { RGBELoader } from 'three/addons/loaders/RGBELoader.js';
4
+ import type { BatchedRenderer } from 'three.quarks';
5
+ import { initNavigation, NavMeshManager } from '../ai/navigation';
6
+ import { AnimGraph } from '../animation/anim-graph';
7
+ import { PropertyTrackRunner } from '../animation/property-track';
8
+ import { log } from '../dev/logger';
9
+ import type { ComponentManager } from '../ecs/component-manager';
10
+ import type { GameComponentClass } from '../ecs/game-component';
11
+ import { loadingManager, resolveUrl } from '../loader';
12
+ import type { PhysicsRegistry } from '../physics/physics-registry';
13
+ import { type RenderScope, resolveRenderSettings } from '../render/render-settings';
14
+ import {
15
+ assetCacheSizes,
16
+ clearAssetCaches as clearSharedAssetCaches,
17
+ loadAnimGraphData,
18
+ loadGLTF,
19
+ resolveGltfNode,
20
+ } from './asset-loaders';
21
+ import { getAssetMeta, loadRegistry } from './asset-registry';
22
+ import { computeColliderWorldDimensions, computeColliderWorldOffset } from './collider-dimensions';
23
+ import { applyComponents, type ComponentRegistry } from './component-registry';
24
+ import { DEFAULTS } from './defaults';
25
+ import './geometries';
26
+ import './instancers';
27
+ import { buildInstancedMesh, buildInstancedMeshFromTuples } from './instance-registry';
28
+ import { createCamera, createLight } from './light-camera-factory';
29
+ import { createGeometry, createMaterial } from './material-factory';
30
+ import './materials';
31
+ import { parseInstancesFile, parseMaterialFile, parsePrefabFile, parseSceneFile } from './parse';
32
+ import { createParticleSystemFromData } from './particles-factory';
33
+ import {
34
+ type InstancesFile,
35
+ type MaterialFile,
36
+ mergePrefabInstance,
37
+ type PrefabFile,
38
+ type SceneEntity,
39
+ type SceneEnvironment,
40
+ type SceneFile,
41
+ type SceneMaterial,
42
+ type ScenePhysics,
43
+ type SceneSpline,
44
+ type UIRoot,
45
+ } from './scene-types';
46
+ import { isEntityRefSchema } from './schema/entity-ref';
47
+ import { getUserData, setUserData } from './user-data';
48
+
49
+ // GLTF / texture / animgraph load+cache logic lives in the shared ./asset-loaders
50
+ // module (A2) so the editor and runtime share one cache + one clone-safety
51
+ // contract. Only the IBL/skybox env-map cache stays here — it needs a
52
+ // WebGLRenderer (PMREM), which the shared module deliberately doesn't depend on.
53
+ const envTextureCache = new Map<string, THREE.Texture>();
54
+ let pmremGenerator: THREE.PMREMGenerator | null = null;
55
+
56
+ /**
57
+ * Clear module-level asset caches (textures, GLTF scenes, anim graphs, IBL env
58
+ * maps). Call on full runtime teardown to release cached GPU resources and avoid
59
+ * leaking across editor Play sessions. Delegates the GLTF/texture/animgraph
60
+ * caches to {@link clearSharedAssetCaches} and additionally frees the local
61
+ * IBL env-map cache + PMREM generator.
62
+ *
63
+ * Lifetime / P1.8: these caches are intentionally module-level and persist
64
+ * ACROSS hot-reloads / scene switches (they are NOT cleared on every
65
+ * loadSceneFromData). This is deliberate: (1) it makes repeated Play→Stop→Play
66
+ * cheap, and (2) it underpins the `__sharedGeometry` contract from P0.2 —
67
+ * GLTF clones share the cached source geometry, so clearing mid-session would
68
+ * orphan live clones. The caches are keyed by URL, so loading the SAME scene N
69
+ * times is bounded (one entry per distinct asset URL, not per load). They are
70
+ * only released here, on full runtime teardown (`fullCleanup`).
71
+ */
72
+ export function clearAssetCaches(): void {
73
+ clearSharedAssetCaches();
74
+ envTextureCache.clear();
75
+ if (pmremGenerator) {
76
+ pmremGenerator.dispose();
77
+ pmremGenerator = null;
78
+ }
79
+ }
80
+
81
+ /**
82
+ * F7 — per-frame LOD tick. THREE.LOD only swaps its active level when
83
+ * `.update(camera)` runs, so every `THREE.LOD` in the scene graph (built by
84
+ * the `mesh.lod` spawn branch above) needs this called once a frame. Mirrors
85
+ * `tickCustomMaterials`'s traverse-and-tick shape; wired into the runtime's
86
+ * `preRender` system in `vgai-scene-game-adapter.ts`.
87
+ */
88
+ export function updateSceneLODs(scene: THREE.Scene, camera: THREE.Camera): void {
89
+ scene.traverse((o) => {
90
+ if ((o as THREE.LOD).isLOD) (o as THREE.LOD).update(camera);
91
+ });
92
+ }
93
+
94
+ /**
95
+ * Test-only: current sizes of the module-level asset caches. Used by the P1.8
96
+ * headless test to assert caches stay bounded (one entry per distinct asset URL)
97
+ * across repeated loads of the same scene, rather than growing per load.
98
+ */
99
+ export function __assetCacheSizesForTest(): {
100
+ textures: number;
101
+ gltf: number;
102
+ animGraphs: number;
103
+ env: number;
104
+ } {
105
+ const sizes = assetCacheSizes();
106
+ return {
107
+ textures: sizes.textures,
108
+ gltf: sizes.gltf,
109
+ animGraphs: sizes.animGraphs,
110
+ env: envTextureCache.size,
111
+ };
112
+ }
113
+
114
+ /**
115
+ * Load an equirectangular HDR/EXR/image as a PMREM-processed IBL environment
116
+ * map and assign it to `scene.background` + `scene.environment`. Mirrors the
117
+ * editor's scene-sync `applyEnvironment`/`loadEnvTexture` so the runtime renders
118
+ * the same skybox the editor previews. Requires a WebGLRenderer for PMREM; in
119
+ * headless (no renderer) this is skipped and no placeholder is set.
120
+ */
121
+ function applySkybox(skybox: string, env: SceneEnvironment, ctx: SceneLoadContext): void {
122
+ const renderer = ctx.renderer;
123
+ if (!renderer) return;
124
+ const cached = envTextureCache.get(skybox);
125
+ if (cached) {
126
+ ctx.scene.background = cached;
127
+ ctx.scene.environment = cached;
128
+ ctx.scene.environmentIntensity = env.envMapIntensity ?? DEFAULTS.environment.envMapIntensity;
129
+ return;
130
+ }
131
+ loadEnvTexture(skybox, renderer)
132
+ .then((texture) => {
133
+ if (!texture) return;
134
+ envTextureCache.set(skybox, texture);
135
+ ctx.scene.background = texture;
136
+ ctx.scene.environment = texture;
137
+ ctx.scene.environmentIntensity = env.envMapIntensity ?? DEFAULTS.environment.envMapIntensity;
138
+ })
139
+ .catch(() => {
140
+ // HDRI load failed — leave background unset rather than show a wrong sky.
141
+ });
142
+ }
143
+
144
+ async function loadEnvTexture(
145
+ path: string,
146
+ renderer: THREE.WebGLRenderer,
147
+ ): Promise<THREE.Texture | null> {
148
+ try {
149
+ if (!pmremGenerator) {
150
+ pmremGenerator = new THREE.PMREMGenerator(renderer);
151
+ pmremGenerator.compileEquirectangularShader();
152
+ }
153
+ const ext = path.split('.').pop()?.toLowerCase();
154
+ const rawTexture =
155
+ ext === 'hdr'
156
+ ? await new RGBELoader(loadingManager).loadAsync(resolveUrl(path))
157
+ : await new THREE.TextureLoader(loadingManager).loadAsync(resolveUrl(path));
158
+ rawTexture.mapping = THREE.EquirectangularReflectionMapping;
159
+ const envMap = pmremGenerator.fromEquirectangular(rawTexture).texture;
160
+ rawTexture.dispose();
161
+ return envMap;
162
+ } catch {
163
+ return null;
164
+ }
165
+ }
166
+
167
+ /** Build a Three.js CatmullRomCurve3 from a spline descriptor (≥2 points). */
168
+ function createCurveFromSpline(spline: SceneSpline): THREE.CatmullRomCurve3 | null {
169
+ if (spline.points.length < 2) return null;
170
+ const points = spline.points.map((p) => new THREE.Vector3(p[0], p[1], p[2]));
171
+ return new THREE.CatmullRomCurve3(
172
+ points,
173
+ spline.closed ?? false,
174
+ spline.curveType,
175
+ spline.tension ?? 0.5,
176
+ );
177
+ }
178
+
179
+ function buildClipMap(
180
+ animations: THREE.AnimationClip[],
181
+ aliases?: Record<string, string>,
182
+ ): Map<string, THREE.AnimationClip> {
183
+ const clips = new Map<string, THREE.AnimationClip>();
184
+ for (const clip of animations) {
185
+ const raw = clip.name.split('|').pop()?.toLowerCase() ?? clip.name;
186
+ const name = aliases?.[raw] ?? raw;
187
+ clips.set(name, clip);
188
+ }
189
+ return clips;
190
+ }
191
+
192
+ export interface SceneLoadContext {
193
+ scene: THREE.Scene;
194
+ rapierWorld: RAPIER.World;
195
+ rapier: typeof RAPIER;
196
+ /** Object3D ↔ Rapier body/collider registry (keys physics by the Object3D). */
197
+ physics: PhysicsRegistry;
198
+ prefabs?: Map<string, (ctx: SceneLoadContext, entity: SceneEntity) => THREE.Object3D>;
199
+ componentRegistry?: ComponentRegistry;
200
+ /** Cache for loaded .prefab.json files. Populated on first reference, cleared per scene load. */
201
+ prefabFileCache?: Map<string, PrefabFile>;
202
+ /** Cache for loaded .mat.json files. Populated on first reference. */
203
+ materialFileCache?: Map<string, MaterialFile>;
204
+ /** Cache for loaded .instances.json files. Populated on first reference (F3). */
205
+ instancesFileCache?: Map<string, InstancesFile>;
206
+ /** Supply an AudioListener (attached to camera) to enable audio entity spawning. */
207
+ audioListener?: THREE.AudioListener;
208
+ /** AnimGraph registry keyed by Object3D. If provided, loader registers created
209
+ * AnimGraphs here (and skips tickFns to avoid double-ticking with the global
210
+ * animationSystem). */
211
+ animGraphs?: Map<THREE.Object3D, AnimGraph>;
212
+ /** ComponentManager for instantiating GameComponent classes from scene data. */
213
+ componentManager?: ComponentManager;
214
+ /** three.quarks BatchedRenderer. If provided, particle systems declared on
215
+ * entities are registered here so they render (mirrors the editor). */
216
+ particleRenderer?: BatchedRenderer;
217
+ /** WebGLRenderer. Required for skybox/IBL (PMREM). Absent in headless tests,
218
+ * where the skybox load is skipped rather than substituting a placeholder. */
219
+ renderer?: THREE.WebGLRenderer;
220
+ /** Scene-level render-feature overrides (`environment.rendering`), populated by
221
+ * `instantiateScene` once `data.environment` is known. Read by the F7 mesh.lod
222
+ * master-gate check (`resolveRenderSettings(ctx.sceneRendering, def.render)`). */
223
+ sceneRendering?: RenderScope;
224
+ }
225
+
226
+ /** Handle returned from loadScene — call update() each frame to tick animations. */
227
+ export interface SceneInstance {
228
+ update(dt: number): void;
229
+ dispose(): void;
230
+ /** Pre-baked navmesh loaded from sibling .navmesh file, if present. */
231
+ navMesh?: NavMeshManager | undefined;
232
+ /** Cameras defined in the scene file. First entry is typically the "main" camera. */
233
+ cameras: THREE.Camera[];
234
+ /** Parsed scene environment, if any — lets the runtime apply post-processing
235
+ * (which needs the composer/camera the loader doesn't have). */
236
+ environment?: SceneEnvironment | undefined;
237
+ /**
238
+ * The scene file's top-level `ui` tree (React UI roots/canvases, A1),
239
+ * carried through verbatim from the parsed `SceneFile` by BOTH `loadScene`
240
+ * and `loadSceneFromData` (single source of truth — R8 item 7 / B). Opaque
241
+ * to the loader itself; the adapter passes it to `renderSceneUI` (the
242
+ * injected React renderer) so scenePath and sceneData mounts render the
243
+ * SAME scene-authored UI instead of only the sceneData branch special-
244
+ * casing it. `undefined` when the scene file has no `ui` field.
245
+ */
246
+ ui?: UIRoot[] | undefined;
247
+ /**
248
+ * Authored entity id -> spawned Object3D (design/24-scene-ui.md D5). The
249
+ * SAME map `spawnEntity`/`createSceneJoints` build internally (id-less
250
+ * entities are auto-UUID'd before insertion, so every spawned entity has an
251
+ * entry) -- exposed so the scene adapter's default `worldProjector` can
252
+ * resolve a `trackEntity` id to a live Object3D without a second id
253
+ * registry. Both `loadScene` and `loadSceneFromData` funnel through the one
254
+ * `instantiateScene` return site, so this is populated identically for both.
255
+ */
256
+ entities: ReadonlyMap<string, THREE.Object3D>;
257
+ }
258
+
259
+ /**
260
+ * Loads a .vscn.json file and instantiates all entities.
261
+ *
262
+ * Creates Three.js objects and Rapier bodies. The Object3D IS the entity —
263
+ * there is no separate ECS id. Uses raw Three.js/Rapier calls — no abstraction.
264
+ *
265
+ * Returns a SceneInstance that must be ticked each frame (for GLTF animations).
266
+ */
267
+ export async function loadScene(url: string, ctx: SceneLoadContext): Promise<SceneInstance> {
268
+ const res = await fetch(resolveUrl(url));
269
+ // Checklist item 9 (audit residual — the editor-side equivalent was fixed
270
+ // under T1.12): a 404/500 response body is typically HTML, not JSON — feed
271
+ // it straight to `res.json()` and the failure surfaces as an opaque
272
+ // "Unexpected token '<'" JSON parse error instead of naming the URL/status
273
+ // that actually failed. Throw loudly here instead.
274
+ if (!res.ok) {
275
+ throw new Error(`Failed to load scene: ${url} (${res.status} ${res.statusText})`);
276
+ }
277
+ const data = parseSceneFile(await res.json(), url);
278
+ return instantiateScene(data, ctx, url);
279
+ }
280
+
281
+ /**
282
+ * Instantiate a scene from an already-parsed SceneFile.
283
+ *
284
+ * Same as loadScene but skips the fetch — useful when the scene data is
285
+ * already in memory (e.g. serialized from the editor for play-mode preview).
286
+ */
287
+ export async function loadSceneFromData(
288
+ data: SceneFile,
289
+ ctx: SceneLoadContext,
290
+ ): Promise<SceneInstance> {
291
+ return instantiateScene(data, ctx);
292
+ }
293
+
294
+ /**
295
+ * World-transform context threaded down through `spawnEntity` recursion (T1.1).
296
+ *
297
+ * `spawnEntity` builds Object3Ds top-down but only parents a child under its
298
+ * Object3D AFTER the child is fully constructed (see the `children` loop
299
+ * below) — so a nested entity's `object3d.parent` chain doesn't exist yet at
300
+ * the moment its own Rapier body would be created, and Three's normal
301
+ * `getWorldPosition`/`matrixWorld` machinery isn't available. This context
302
+ * carries the composed ancestor world matrix (position/rotation/scale, built
303
+ * the same way any correct implementation must: parent world matrix × this
304
+ * entity's own local matrix) so `createPhysicsBody` can place bodies at the
305
+ * true world transform instead of the raw authored local transform.
306
+ */
307
+ interface AncestorPhysicsContext {
308
+ /** Composed world matrix of the parent chain (identity at the scene root). */
309
+ worldMatrix: THREE.Matrix4;
310
+ /** Nearest ancestor with a dynamic/kinematic physics body, if any — used for
311
+ * the "moving parent" loud-error check (a dynamic body nested under one of
312
+ * these is unsupported: Rapier has no parent/child body relationship, so a
313
+ * moving ancestor cannot carry a nested dynamic body with it). */
314
+ movingAncestor?: { name: string; bodyType: string };
315
+ }
316
+
317
+ const ROOT_ANCESTOR_CONTEXT: AncestorPhysicsContext = { worldMatrix: new THREE.Matrix4() };
318
+
319
+ /**
320
+ * Minimal shape needed to stop a spawned `THREE.Audio`/`THREE.PositionalAudio`
321
+ * on dispose (R1c). Kept as a tiny duck-typed interface rather than
322
+ * `THREE.Audio` itself — `PositionalAudio extends Audio<PannerNode>` while
323
+ * plain `Audio` defaults its generic to `GainNode`, so the two concrete
324
+ * classes aren't mutually assignable as `THREE.Audio[]`; `stop()` doesn't
325
+ * depend on that generic at all, so a duck type sidesteps the mismatch.
326
+ */
327
+ interface DisposableAudio {
328
+ stop(delay?: number): unknown;
329
+ }
330
+
331
+ /**
332
+ * Threaded through `spawnEntity`'s recursion (R1c, `spawnPrefab().dispose()`
333
+ * completeness — see the CLASS INVENTORY comment above `spawnPrefab`) so a
334
+ * runtime-spawned prefab's dispose() can stop every `THREE.Audio` it created,
335
+ * and so the audio-loading network callback (which may still be in flight
336
+ * when dispose() runs) can check `disposed` before touching an audio object
337
+ * that's already been torn down. `undefined` for `loadScene`/`loadSceneFromData`
338
+ * call sites — full SceneInstance teardown doesn't dispose individual audio
339
+ * objects (out of scope here; see SceneInstance.dispose in `instantiateScene`).
340
+ */
341
+ interface AudioTracker {
342
+ audios: DisposableAudio[];
343
+ disposed: { value: boolean };
344
+ }
345
+
346
+ /** Human-readable entity identifier for error messages (loud-failure naming). */
347
+ function entityLabel(def: SceneEntity): string {
348
+ return def.id ? `${def.name} (id: "${def.id}")` : def.name;
349
+ }
350
+
351
+ // ---------------------------------------------------------------------------
352
+ // T3.9 — bounded-concurrency asset prefetch.
353
+ //
354
+ // Child/asset fetches (prefab files, materials, GLTF models, animgraphs) used
355
+ // to only ever happen inline, one at a time, inside the fully-sequential
356
+ // `spawnEntity` recursion — fine at ~20 assets, product-breaking at hundreds
357
+ // (every fetch's network round-trip is paid serially). Rather than restructure
358
+ // the deterministic sequential spawn walk itself (which authored array order,
359
+ // tickFns/mixers order, and GameComponent registration order all depend on),
360
+ // this prefetch pass walks the WHOLE entity tree up front and warms the
361
+ // existing URL-keyed caches (asset-loaders.ts's module-level GLTF/animgraph
362
+ // caches, and this module's ctx-level prefab/material file caches) with a
363
+ // small concurrency limiter. The sequential spawn pass below is completely
364
+ // unchanged — its `await`s just resolve instantly against a warm cache, so
365
+ // wall-clock time is bounded by ceil(assetCount / limit) round-trips instead
366
+ // of assetCount. Prefetching is a pure optimization: any error here is
367
+ // swallowed and re-surfaced normally (with a real error) by the sequential
368
+ // pass that actually consumes the data.
369
+ // ---------------------------------------------------------------------------
370
+
371
+ const PREFETCH_CONCURRENCY = 8;
372
+
373
+ /**
374
+ * Tiny concurrency-limited task runner — no new dependency. Runs `tasks` with
375
+ * at most `limit` in flight at once. Tasks may push MORE tasks onto the same
376
+ * array while running (each worker re-checks `tasks.length` every loop), which
377
+ * is how nested prefab expansion gets picked up without a second pass.
378
+ */
379
+ async function runLimited(tasks: Array<() => Promise<unknown>>, limit: number): Promise<void> {
380
+ let next = 0;
381
+ async function worker(): Promise<void> {
382
+ for (;;) {
383
+ const i = next++;
384
+ if (i >= tasks.length) return;
385
+ try {
386
+ await tasks[i]!();
387
+ } catch {
388
+ // Swallow — prefetching is an optimization; the real (sequential)
389
+ // spawn pass re-fetches and surfaces genuine load errors normally.
390
+ }
391
+ }
392
+ }
393
+ const workerCount = Math.min(limit, tasks.length);
394
+ await Promise.all(Array.from({ length: workerCount }, () => worker()));
395
+ }
396
+
397
+ /**
398
+ * Recursively collect asset-prefetch tasks for an entity subtree (T3.9).
399
+ * Best-effort: walks JSON-prefab expansion (fetch the `.prefab.json`, merge,
400
+ * recurse into the merged root) so a prefab's OWN nested asset refs get
401
+ * warmed too. Any inaccuracy here (e.g. a merge that would actually throw)
402
+ * only costs a bit of parallelism, never correctness — errors are swallowed
403
+ * by `runLimited` and the real spawn pass is unaffected.
404
+ */
405
+ function collectPrefetchTasks(
406
+ def: SceneEntity,
407
+ ctx: SceneLoadContext,
408
+ tasks: Array<() => Promise<unknown>>,
409
+ seenPrefabUrls: Set<string>,
410
+ ): void {
411
+ if (def.mesh?.type === 'gltf' && def.mesh.src) {
412
+ const src = def.mesh.src;
413
+ tasks.push(() => loadGLTF(src));
414
+ }
415
+ if (def.materialRef) {
416
+ const ref = def.materialRef;
417
+ tasks.push(() => loadMaterialFile(ref, ctx));
418
+ }
419
+ if (def.animation?.animGraph) {
420
+ const graph = def.animation.animGraph;
421
+ tasks.push(() => loadAnimGraphData(graph));
422
+ }
423
+ if (def.prefab && !ctx.prefabs?.has(def.prefab) && !seenPrefabUrls.has(def.prefab)) {
424
+ const prefabUrl = def.prefab;
425
+ seenPrefabUrls.add(prefabUrl);
426
+ tasks.push(async () => {
427
+ const prefabDef = await loadPrefabFile(prefabUrl, ctx);
428
+ const merged = mergePrefabInstance(prefabDef.root, def);
429
+ collectPrefetchTasks(merged, ctx, tasks, seenPrefabUrls);
430
+ });
431
+ }
432
+ if (def.children) {
433
+ for (const child of def.children) collectPrefetchTasks(child, ctx, tasks, seenPrefabUrls);
434
+ }
435
+ }
436
+
437
+ async function instantiateScene(
438
+ data: SceneFile,
439
+ ctx: SceneLoadContext,
440
+ url?: string,
441
+ ): Promise<SceneInstance> {
442
+ const tickFns: ((dt: number) => void)[] = [];
443
+ const mixers: THREE.AnimationMixer[] = [];
444
+
445
+ // Ensure asset registry is loaded before spawning entities
446
+ await loadRegistry();
447
+
448
+ // T3.9 — warm asset caches for the whole tree with bounded concurrency
449
+ // before the deterministic sequential spawn pass below.
450
+ const prefetchTasks: Array<() => Promise<unknown>> = [];
451
+ const seenPrefabUrls = new Set<string>();
452
+ for (const entityDef of data.entities) {
453
+ collectPrefetchTasks(entityDef, ctx, prefetchTasks, seenPrefabUrls);
454
+ }
455
+ await runLimited(prefetchTasks, PREFETCH_CONCURRENCY);
456
+
457
+ // F7 — populate the scene-level render-feature scope once, before any entity
458
+ // spawns, so the mesh.lod master-gate check (resolveRenderSettings) below has
459
+ // it available regardless of whether `data.environment` is set.
460
+ if (data.environment?.rendering) {
461
+ ctx.sceneRendering = data.environment.rendering as RenderScope;
462
+ }
463
+
464
+ // Environment
465
+ if (data.environment) {
466
+ const env = data.environment;
467
+ if (env.ambient) {
468
+ const light = new THREE.AmbientLight(env.ambient.color, env.ambient.intensity);
469
+ ctx.scene.add(light);
470
+ }
471
+ if (env.fog) {
472
+ if (env.fog.type === 'exponential') {
473
+ ctx.scene.fog = new THREE.FogExp2(
474
+ env.fog.color,
475
+ env.fog.density ?? DEFAULTS.fog.exponential.density,
476
+ );
477
+ } else {
478
+ ctx.scene.fog = new THREE.Fog(
479
+ env.fog.color,
480
+ env.fog.near ?? DEFAULTS.fog.linear.near,
481
+ env.fog.far ?? DEFAULTS.fog.linear.far,
482
+ );
483
+ }
484
+ }
485
+ if (env.background) {
486
+ ctx.scene.background = new THREE.Color(env.background);
487
+ }
488
+ if (env.skybox) {
489
+ // Load the real HDRI as a PMREM IBL env map (background + environment),
490
+ // mirroring the editor. Async + fire-and-forget; no placeholder color.
491
+ applySkybox(env.skybox, env, ctx);
492
+ }
493
+ }
494
+
495
+ // Entities — pass 1: spawn all, build entity ID → Object3D mapping.
496
+ // spawnEntity recurses into children itself and registers every id
497
+ // (including nested children) in idMap via the idMap parameter.
498
+ const idMap = new Map<string, THREE.Object3D>();
499
+
500
+ for (const entityDef of data.entities) {
501
+ const obj = await spawnEntity(entityDef, ctx, tickFns, mixers, idMap);
502
+ ctx.scene.add(obj);
503
+ }
504
+
505
+ // Pass 2: create joints between physics bodies
506
+ createSceneJoints(data.entities, idMap, ctx);
507
+
508
+ // Pass 2.5 (F6): resolve EntityRefSchema component fields (string id ->
509
+ // live Object3D) — must run after every entity id is in idMap, and before
510
+ // initAll() so components observe the resolved Object3D in init().
511
+ if (ctx.componentManager) {
512
+ resolveEntityRefs(data.entities, idMap, ctx.componentManager);
513
+ }
514
+
515
+ // Pass 3: initialize GameComponent instances (now that all entities + joints exist)
516
+ if (ctx.componentManager) {
517
+ await ctx.componentManager.initAll();
518
+ }
519
+
520
+ // Try to load pre-baked navmesh from sibling .navmesh file
521
+ let navMesh: NavMeshManager | undefined;
522
+ if (url) {
523
+ const navmeshUrl = url.replace('.vscn.json', '.navmesh');
524
+ try {
525
+ const navRes = await fetch(resolveUrl(navmeshUrl));
526
+ if (navRes.ok) {
527
+ await initNavigation();
528
+ const buffer = await navRes.arrayBuffer();
529
+ navMesh = new NavMeshManager();
530
+ navMesh.loadFromData(new Uint8Array(buffer));
531
+ }
532
+ } catch {
533
+ // No pre-baked navmesh — continue without one
534
+ }
535
+ }
536
+
537
+ // Collect cameras defined in scene entities
538
+ const cameras: THREE.Camera[] = [];
539
+ ctx.scene.traverse((obj) => {
540
+ const cam = getUserData(obj, '_camera');
541
+ if (cam) cameras.push(cam);
542
+ });
543
+
544
+ return {
545
+ navMesh,
546
+ cameras,
547
+ environment: data.environment,
548
+ ui: data.ui,
549
+ entities: idMap,
550
+ update(dt: number) {
551
+ for (const fn of tickFns) fn(dt);
552
+ },
553
+ dispose() {
554
+ for (const mixer of mixers) mixer.stopAllAction();
555
+ tickFns.length = 0;
556
+ mixers.length = 0;
557
+ },
558
+ };
559
+ }
560
+
561
+ async function spawnEntity(
562
+ def: SceneEntity,
563
+ ctx: SceneLoadContext,
564
+ tickFns: ((dt: number) => void)[],
565
+ mixers: THREE.AnimationMixer[],
566
+ idMap?: Map<string, THREE.Object3D>,
567
+ ancestorCtx: AncestorPhysicsContext = ROOT_ANCESTOR_CONTEXT,
568
+ audioTracker?: AudioTracker,
569
+ ): Promise<THREE.Object3D> {
570
+ // Check for factory-function prefabs (code prefabs registered at runtime)
571
+ if (def.prefab && ctx.prefabs?.has(def.prefab)) {
572
+ // R1c — the factory function receives NO ancestor context (no world
573
+ // matrix, no moving-ancestor info), unlike the JSON-prefab/primitive path
574
+ // below which composes it for physics spawn. If this code-prefab is
575
+ // nested under a transformed ancestor, its own transform/physics are
576
+ // built by the factory as if it were at the scene root — they will NOT
577
+ // reflect the ancestor's position/rotation/scale. Warn loudly (once per
578
+ // entity, naturally once per scene load since each entity is visited once)
579
+ // rather than silently producing a wrong-looking result.
580
+ if (!ancestorCtx.worldMatrix.equals(ROOT_ANCESTOR_CONTEXT.worldMatrix)) {
581
+ log.scene.warn(
582
+ `Code-prefab entity "${entityLabel(def)}" (factory "${def.prefab}") is nested under an ` +
583
+ 'ancestor with a non-identity world transform. The runtime ctx.prefabs factory API ' +
584
+ "doesn't yet receive ancestor context, so this entity's own transform/physics are " +
585
+ "built as if it were at the scene root — they will NOT compose the ancestor's " +
586
+ 'position/rotation/scale. Move this entity to the scene root, or wait for ancestor-' +
587
+ 'context support in the code-prefab factory API.',
588
+ { entityId: def.id },
589
+ );
590
+ }
591
+ const object3d = ctx.prefabs.get(def.prefab)!(ctx, def);
592
+ // Same entity-metadata contract as the main path below (see the comment
593
+ // there): `entity` userData is written unconditionally so queries find
594
+ // code-prefab instances too; only `entityId`/idMap stay id-gated.
595
+ object3d.name = def.name;
596
+ setUserData(object3d, 'entity', def);
597
+ if (def.id) {
598
+ setUserData(object3d, 'entityId', def.id);
599
+ if (idMap) idMap.set(def.id, object3d);
600
+ }
601
+ // Apply JSON components on top of prefab defaults
602
+ if (def.components && ctx.componentRegistry && ctx.componentManager) {
603
+ applyComponents(ctx.componentRegistry, object3d, def.components, ctx.componentManager);
604
+ }
605
+ return object3d;
606
+ }
607
+
608
+ // Check for JSON prefab files (.prefab.json)
609
+ if (def.prefab) {
610
+ const prefabDef = await loadPrefabFile(def.prefab, ctx);
611
+ const merged = mergePrefabInstance(prefabDef.root, def);
612
+ return spawnEntity(merged, ctx, tickFns, mixers, idMap, ancestorCtx, audioTracker);
613
+ }
614
+
615
+ // Transform
616
+ const pos = def.transform?.position ?? DEFAULTS.transform.position;
617
+ const rot = def.transform?.rotation ?? DEFAULTS.transform.rotation;
618
+ const scl = def.transform?.scale ?? DEFAULTS.transform.scale;
619
+
620
+ // T1.1 — compose this entity's WORLD transform (parent chain × own local
621
+ // transform) for physics spawn purposes. Cheap (one 4x4 multiply) and done
622
+ // unconditionally so children can thread it further down regardless of
623
+ // whether THIS entity has physics.
624
+ const localMatrix = new THREE.Matrix4().compose(
625
+ new THREE.Vector3(pos[0], pos[1], pos[2]),
626
+ new THREE.Quaternion(rot[0], rot[1], rot[2], rot[3]),
627
+ new THREE.Vector3(scl[0], scl[1], scl[2]),
628
+ );
629
+ const worldMatrix = ancestorCtx.worldMatrix.clone().multiply(localMatrix);
630
+
631
+ // Create Three.js object — the Object3D IS the entity.
632
+ let object3d: THREE.Object3D = new THREE.Group();
633
+
634
+ if (def.mesh) {
635
+ if (def.mesh.type === 'gltf' && def.mesh.src) {
636
+ // Load GLTF model
637
+ const { scene: gltfScene, animations } = await loadGLTF(def.mesh.src);
638
+ if (def.shadow?.enabled) {
639
+ gltfScene.traverse((child) => {
640
+ if (child instanceof THREE.Mesh) {
641
+ child.castShadow = true;
642
+ child.receiveShadow = true;
643
+ }
644
+ });
645
+ }
646
+
647
+ // F4 — mesh.node: resolve a single sub-node to instantiate as this
648
+ // entity's geometry instead of the whole file (shared geometry, no
649
+ // copy — see resolveGltfNode). Must run before import correction below
650
+ // so the correction applies to whatever we actually wrap.
651
+ let content: THREE.Object3D = gltfScene;
652
+ if (def.mesh.node) {
653
+ content = resolveGltfNode(gltfScene, def.mesh.node, def.mesh.src);
654
+ // wrapper.add() below detaches `content` from the glTF hierarchy.
655
+ // Reset its local transform to identity — the entity's own transform
656
+ // (applied to `wrapper`) is the sole positioner; the node's offset
657
+ // within the source file's hierarchy is not carried over (this
658
+ // instantiates the node "as this entity's geometry", not as a
659
+ // placed sub-scene).
660
+ content.position.set(0, 0, 0);
661
+ content.rotation.set(0, 0, 0);
662
+ content.scale.set(1, 1, 1);
663
+ } else if (def.mesh.lod?.length) {
664
+ // F7 — mesh.lod: multi-level distance-keyed glTF sub-node swap, gated
665
+ // by the environment.rendering.lod master toggle (per-entity cascade
666
+ // via resolveRenderSettings — the first real reader of that toggle).
667
+ // Mutually exclusive with `node`/`instances`/`instancer` (schema refine).
668
+ const lodOn =
669
+ resolveRenderSettings(ctx.sceneRendering, def.render as RenderScope | undefined)[
670
+ 'lod'
671
+ ] !== false;
672
+ const levels = [...def.mesh.lod].sort((a, b) => a.distance - b.distance);
673
+ if (lodOn) {
674
+ const lod = new THREE.LOD();
675
+ for (const level of levels) {
676
+ const n = resolveGltfNode(gltfScene, level.node, def.mesh.src);
677
+ n.position.set(0, 0, 0);
678
+ n.rotation.set(0, 0, 0);
679
+ n.scale.set(1, 1, 1);
680
+ lod.addLevel(n, level.distance);
681
+ }
682
+ content = lod;
683
+ } else {
684
+ // Master gate off — degrade to the closest (highest-detail) level
685
+ // only, no THREE.LOD.
686
+ const closest = levels[0]!;
687
+ const n = resolveGltfNode(gltfScene, closest.node, def.mesh.src);
688
+ n.position.set(0, 0, 0);
689
+ n.rotation.set(0, 0, 0);
690
+ n.scale.set(1, 1, 1);
691
+ content = n;
692
+ }
693
+ }
694
+
695
+ // Apply import correction from asset registry
696
+ const meta = getAssetMeta(def.mesh.src);
697
+ if (meta?.importCorrection) {
698
+ const ic = meta.importCorrection;
699
+ if (ic.scale !== undefined) {
700
+ content.scale.multiplyScalar(ic.scale);
701
+ }
702
+ if (ic.rotation) {
703
+ content.rotation.set(
704
+ THREE.MathUtils.degToRad(ic.rotation[0]),
705
+ THREE.MathUtils.degToRad(ic.rotation[1]),
706
+ THREE.MathUtils.degToRad(ic.rotation[2]),
707
+ );
708
+ }
709
+ }
710
+
711
+ // Wrap in a group so entity transform and import correction are separated.
712
+ // `content` carries import correction; wrapper carries entity transform.
713
+ const wrapper = new THREE.Group();
714
+ wrapper.add(content);
715
+ object3d = wrapper;
716
+
717
+ // Set up animation if defined
718
+ if (def.animation && animations.length > 0) {
719
+ const clipMap = buildClipMap(animations, def.animation.clipAliases);
720
+
721
+ if (def.animation.animGraph) {
722
+ // Full AnimGraph state machine
723
+ const graphData = await loadAnimGraphData(def.animation.animGraph);
724
+ const mixer = new THREE.AnimationMixer(gltfScene);
725
+ const graph = new AnimGraph(mixer, graphData, clipMap);
726
+ if (def.animation.parameters) {
727
+ for (const [key, value] of Object.entries(def.animation.parameters)) {
728
+ graph.setParameter(key, value);
729
+ }
730
+ }
731
+ if (ctx.animGraphs) {
732
+ // Register in global map (keyed by Object3D); animationSystem ticks it.
733
+ ctx.animGraphs.set(object3d, graph);
734
+ } else {
735
+ // Standalone: tick via scene instance update
736
+ tickFns.push((dt) => graph.update(dt));
737
+ }
738
+ mixers.push(mixer);
739
+ } else if (def.animation.autoplay) {
740
+ // Simple clip autoplay
741
+ const clip = clipMap.get(def.animation.autoplay);
742
+ if (clip) {
743
+ const mixer = new THREE.AnimationMixer(gltfScene);
744
+ const action = mixer.clipAction(clip);
745
+ const loop = def.animation.loop ?? DEFAULTS.animation.loop;
746
+ action.setLoop(loop ? THREE.LoopRepeat : THREE.LoopOnce, Infinity);
747
+ action.clampWhenFinished = !loop;
748
+ action.play();
749
+ tickFns.push((dt) => mixer.update(dt));
750
+ mixers.push(mixer);
751
+ }
752
+ }
753
+ }
754
+ } else {
755
+ const geometry = createGeometry(def.mesh);
756
+ const resolvedMat = await resolveMaterial(def, ctx);
757
+ const material = createMaterial(resolvedMat);
758
+ let mesh: THREE.Mesh | THREE.InstancedMesh;
759
+ if (def.mesh.instances) {
760
+ // F3 — declarative/authored instancing: fetch+parse the referenced
761
+ // .instances.json and build one InstancedMesh from its tuples.
762
+ const tuples = await loadInstancesFile(def.mesh.instances, ctx);
763
+ mesh = buildInstancedMeshFromTuples(geometry, material, tuples);
764
+ } else if (def.mesh.instancer) {
765
+ mesh = buildInstancedMesh(
766
+ geometry,
767
+ material,
768
+ def.mesh.instancer,
769
+ def.mesh.instancerParams ?? {},
770
+ );
771
+ } else {
772
+ mesh = new THREE.Mesh(geometry, material);
773
+ }
774
+ if (def.shadow?.enabled) {
775
+ mesh.castShadow = true;
776
+ mesh.receiveShadow = true;
777
+ }
778
+ object3d = mesh;
779
+ }
780
+ }
781
+
782
+ if (def.light) {
783
+ const light = createLight(def.light);
784
+ if (def.shadow?.enabled && 'shadow' in light) {
785
+ const shadowLight = light as THREE.DirectionalLight;
786
+ shadowLight.castShadow = true;
787
+ // Default to DEFAULTS.shadow.mapSize (1024) rather than Three's 512 so the
788
+ // runtime matches the documented single-source default.
789
+ shadowLight.shadow.mapSize.setScalar(def.shadow.mapSize ?? DEFAULTS.shadow.mapSize);
790
+ if (def.shadow.camera) {
791
+ const cam = def.shadow.camera;
792
+ if (cam.left !== undefined) shadowLight.shadow.camera.left = cam.left;
793
+ if (cam.right !== undefined) shadowLight.shadow.camera.right = cam.right;
794
+ if (cam.top !== undefined) shadowLight.shadow.camera.top = cam.top;
795
+ if (cam.bottom !== undefined) shadowLight.shadow.camera.bottom = cam.bottom;
796
+ }
797
+ if (def.shadow.radius !== undefined) {
798
+ shadowLight.shadow.radius = def.shadow.radius;
799
+ }
800
+ if (def.shadow.bias !== undefined) {
801
+ shadowLight.shadow.bias = def.shadow.bias;
802
+ }
803
+ }
804
+ object3d = light;
805
+ }
806
+
807
+ if (def.camera) {
808
+ const camera = createCamera(def.camera);
809
+ // Wrap in a group so entity transform and camera are separated (same pattern as editor)
810
+ const wrapper = new THREE.Group();
811
+ wrapper.add(camera);
812
+ setUserData(wrapper, '_camera', camera);
813
+ object3d = wrapper;
814
+ }
815
+
816
+ // Audio source
817
+ if (def.audio && ctx.audioListener) {
818
+ const audioDef = def.audio;
819
+ const dAudio = DEFAULTS.audio;
820
+ const spatial = audioDef.spatial ?? dAudio.spatial;
821
+ const audioObj = spatial
822
+ ? new THREE.PositionalAudio(ctx.audioListener)
823
+ : new THREE.Audio(ctx.audioListener);
824
+
825
+ audioObj.setVolume(audioDef.volume ?? dAudio.volume);
826
+ audioObj.setLoop(audioDef.loop ?? dAudio.loop);
827
+
828
+ if (spatial && audioObj instanceof THREE.PositionalAudio) {
829
+ audioObj.setRefDistance(audioDef.refDistance ?? dAudio.refDistance);
830
+ audioObj.setRolloffFactor(audioDef.rolloffFactor ?? dAudio.rolloffFactor);
831
+ audioObj.setMaxDistance(audioDef.maxDistance ?? dAudio.maxDistance);
832
+ }
833
+
834
+ // R1c — track every THREE.Audio this spawn creates (when the caller
835
+ // supplied a tracker; only spawnPrefab's dispose() does) so dispose() can
836
+ // stop() it, and guard the async load callback so a still-in-flight
837
+ // AudioLoader request can't setBuffer/play on an already-disposed audio
838
+ // object (the callback captures `audioObj`, which outlives dispose() —
839
+ // Three.js gives no other hook to cancel an in-flight AudioLoader request).
840
+ audioTracker?.audios.push(audioObj);
841
+
842
+ if (audioDef.src) {
843
+ const loader = new THREE.AudioLoader(loadingManager);
844
+ loader.load(audioDef.src, (buffer) => {
845
+ if (audioTracker?.disposed.value) return;
846
+ audioObj.setBuffer(buffer);
847
+ if (audioDef.autoplay ?? dAudio.autoplay) audioObj.play();
848
+ });
849
+ }
850
+
851
+ object3d.add(audioObj);
852
+ }
853
+
854
+ // Particles — instantiate the three.quarks system and register it with the
855
+ // runtime BatchedRenderer (mirrors the editor entity-factory + scene-sync).
856
+ if (def.particles) {
857
+ try {
858
+ const { emitter, system } = createParticleSystemFromData(def.particles);
859
+ object3d.add(emitter);
860
+ setUserData(object3d, '_particleSystem', system);
861
+ if (ctx.particleRenderer) ctx.particleRenderer.addSystem(system);
862
+ } catch (err) {
863
+ log.scene.warn(`Failed to create particle system for entity "${def.name}"`, {
864
+ entityId: def.id,
865
+ error: String(err),
866
+ });
867
+ }
868
+ }
869
+
870
+ // Spline — build a CatmullRomCurve3 and stash it for gameplay code (path
871
+ // following, etc.). The editor renders an interactive gizmo for splines; at
872
+ // runtime the curve itself is the load-bearing artifact.
873
+ if (def.spline) {
874
+ const curve = createCurveFromSpline(def.spline);
875
+ if (curve) setUserData(object3d, 'splineCurve', curve);
876
+ }
877
+
878
+ // Tag navigation role for runtime navmesh collection
879
+ if (def.navigation?.role) {
880
+ setUserData(object3d, 'navRole', def.navigation.role);
881
+ }
882
+
883
+ // Tag pivot for runtime code that needs to rotate/scale around it
884
+ if (def.pivot) {
885
+ setUserData(object3d, 'pivot', def.pivot);
886
+ }
887
+
888
+ // Tag entity metadata (used by editor hierarchy/inspector when viewing game
889
+ // scene, and by gameplay queries like queryByComponent/queryByTag which read
890
+ // component/tag names off `userData.entity`). Write `entity` unconditionally
891
+ // — NOT only when `def.id` is set: id-less prefab instances (e.g. the RTS
892
+ // units) still attach components (below), so gating entity metadata on `id`
893
+ // made those components undiscoverable by name.
894
+ //
895
+ // The scene schema declares `id` optional with "auto-generated if omitted",
896
+ // so `entityId` is unconditional too (auto-generating when absent) rather
897
+ // than skipped: without this, hand-authored scenes (which rarely carry ids)
898
+ // load into an EMPTY play-mode hierarchy even though they render fine. This
899
+ // mirrors the editor store, which fills missing ids on load
900
+ // (`if (!e.id) e.id = crypto.randomUUID()`). Populating idMap for every
901
+ // entity is safe — it's only read by joint-target resolution keyed on an
902
+ // authored `joint.target` id, which can't collide with a random UUID.
903
+ object3d.name = def.name;
904
+ if (!def.id) def.id = crypto.randomUUID();
905
+ setUserData(object3d, 'entity', def);
906
+ setUserData(object3d, 'entityId', def.id);
907
+ if (idMap) idMap.set(def.id, object3d);
908
+
909
+ // Apply transform
910
+ object3d.position.set(pos[0], pos[1], pos[2]);
911
+ object3d.quaternion.set(rot[0], rot[1], rot[2], rot[3]);
912
+ object3d.scale.set(scl[0], scl[1], scl[2]);
913
+
914
+ // Visibility (mirrors editor: visible unless explicitly false)
915
+ object3d.visible = def.visible ?? DEFAULTS.entity.visible;
916
+
917
+ // F5 — declarative property tracks (docs/VSCN-STRUCTURAL-GAPS-DESIGN.md).
918
+ // Unlike gltf autoplay/animGraph above (clip-driven, gltf-only), tracks work
919
+ // on ANY entity — a box, a light, no gltf/clips required — so this lives
920
+ // OUTSIDE the gltf branch. Registered in the same tickFns list the
921
+ // AnimationMixers use, so it ticks on the same deterministic dt (honors
922
+ // pause/timeScale for free — no new clock).
923
+ if (def.animation?.tracks?.length) {
924
+ const runner = new PropertyTrackRunner(object3d, def.animation.tracks);
925
+ tickFns.push((dt) => runner.update(dt));
926
+ }
927
+
928
+ // Physics — stores {body, collider} in the physics registry keyed by object3d.
929
+ // T1.1: the body is created at the COMPOSED WORLD transform (ancestorCtx's
930
+ // world matrix × this entity's own local transform), not the raw authored
931
+ // local transform — Rapier knows nothing about the Three.js parent chain.
932
+ if (def.physics) {
933
+ // R1c: this used to only throw for a "dynamic" child body — but a
934
+ // "fixed"/"kinematic" child nested under a moving (dynamic/kinematic)
935
+ // ancestor is equally unsupported: Rapier has no parent/child body
936
+ // relationship at all, so ANY nested body (whatever its own type) gets its
937
+ // world transform baked in ONCE at spawn time and then never follows the
938
+ // ancestor's body again — the transform writer only pins the child's
939
+ // VISUAL Object3D to its OWN (stale, non-moving) body pose, so both the
940
+ // mesh and collider silently fall behind the moving ancestor.
941
+ if (ancestorCtx.movingAncestor) {
942
+ throw new Error(
943
+ `Entity "${entityLabel(def)}" has a "${def.physics.bodyType}" physics body nested under ` +
944
+ `"${ancestorCtx.movingAncestor.name}", which itself has a "${ancestorCtx.movingAncestor.bodyType}" ` +
945
+ `physics body. Rapier has no parent/child body relationship, so a moving ` +
946
+ `(dynamic/kinematic) ancestor cannot carry ANY nested physics body with it, regardless of ` +
947
+ `the nested body's own type — the child body's world position would immediately drift out ` +
948
+ `of sync with its visual parent. This configuration is unsupported: move ` +
949
+ `"${entityLabel(def)}" out from under "${ancestorCtx.movingAncestor.name}", or make the ` +
950
+ `ancestor's body "fixed" (a fixed/static ancestor's transform is fine to compose).`,
951
+ );
952
+ }
953
+ const worldPos = new THREE.Vector3();
954
+ const worldQuat = new THREE.Quaternion();
955
+ const worldScale = new THREE.Vector3();
956
+ worldMatrix.decompose(worldPos, worldQuat, worldScale);
957
+ createPhysicsBody(
958
+ def.physics,
959
+ [worldPos.x, worldPos.y, worldPos.z],
960
+ [worldQuat.x, worldQuat.y, worldQuat.z, worldQuat.w],
961
+ ctx,
962
+ object3d,
963
+ [worldScale.x, worldScale.y, worldScale.z],
964
+ entityLabel(def),
965
+ );
966
+ }
967
+
968
+ // Apply custom GameComponents from scene data
969
+ if (def.components && ctx.componentRegistry && ctx.componentManager) {
970
+ applyComponents(ctx.componentRegistry, object3d, def.components, ctx.componentManager);
971
+ }
972
+
973
+ // Children — parent each child Object3D directly under this one. Threads the
974
+ // composed world matrix + nearest moving-ancestor (T1.1) down: a
975
+ // dynamic/kinematic body ON THIS entity becomes the nearest moving ancestor
976
+ // for its own children (overriding any further-up ancestor, since it's the
977
+ // closer/more relevant one to report); otherwise the existing ancestor
978
+ // context is passed through unchanged.
979
+ if (def.children) {
980
+ const childAncestorCtx: AncestorPhysicsContext = {
981
+ worldMatrix,
982
+ ...(def.physics &&
983
+ (def.physics.bodyType === 'dynamic' || def.physics.bodyType === 'kinematic')
984
+ ? { movingAncestor: { name: entityLabel(def), bodyType: def.physics.bodyType } }
985
+ : ancestorCtx.movingAncestor
986
+ ? { movingAncestor: ancestorCtx.movingAncestor }
987
+ : {}),
988
+ };
989
+ for (const childDef of def.children) {
990
+ const childObj = await spawnEntity(
991
+ childDef,
992
+ ctx,
993
+ tickFns,
994
+ mixers,
995
+ idMap,
996
+ childAncestorCtx,
997
+ audioTracker,
998
+ );
999
+ object3d.add(childObj);
1000
+ }
1001
+ }
1002
+
1003
+ return object3d;
1004
+ }
1005
+
1006
+ /** Find the first mesh geometry in an Object3D subtree (for trimesh colliders). */
1007
+ function findMeshGeometry(object3d: THREE.Object3D): THREE.BufferGeometry | null {
1008
+ let geometry: THREE.BufferGeometry | null = null;
1009
+ object3d.traverse((child) => {
1010
+ if (!geometry && child instanceof THREE.Mesh && child.geometry) {
1011
+ geometry = child.geometry;
1012
+ }
1013
+ });
1014
+ return geometry;
1015
+ }
1016
+
1017
+ function createPhysicsBody(
1018
+ physics: ScenePhysics,
1019
+ worldPos: [number, number, number] | number[],
1020
+ worldRot: [number, number, number, number] | number[],
1021
+ ctx: SceneLoadContext,
1022
+ object3d: THREE.Object3D,
1023
+ worldScale: readonly [number, number, number],
1024
+ label: string,
1025
+ ): void {
1026
+ const rapier = ctx.rapier;
1027
+
1028
+ // Create rigid body
1029
+ let bodyDesc: RAPIER.RigidBodyDesc;
1030
+ switch (physics.bodyType) {
1031
+ case 'fixed':
1032
+ bodyDesc = rapier.RigidBodyDesc.fixed();
1033
+ break;
1034
+ case 'kinematic':
1035
+ bodyDesc = rapier.RigidBodyDesc.kinematicPositionBased();
1036
+ break;
1037
+ default:
1038
+ bodyDesc = rapier.RigidBodyDesc.dynamic();
1039
+ break;
1040
+ }
1041
+
1042
+ bodyDesc.setTranslation(worldPos[0]!, worldPos[1]!, worldPos[2]!);
1043
+ bodyDesc.setRotation({ x: worldRot[0]!, y: worldRot[1]!, z: worldRot[2]!, w: worldRot[3]! });
1044
+
1045
+ const body = ctx.rapierWorld.createRigidBody(bodyDesc);
1046
+
1047
+ // Create collider. T1.2: dimensions are baked from the entity's composed
1048
+ // WORLD scale (self × ancestors) — Rapier colliders have no notion of a
1049
+ // parent Object3D's scale, so this is what keeps the physical shape
1050
+ // matching whatever's visually on screen (which scales for free via the
1051
+ // normal Three.js hierarchy). See collider-dimensions.ts (shared with the
1052
+ // editor's collider gizmo) for the exact per-type math + round-collider
1053
+ // non-uniform-scale error.
1054
+ let colliderDesc: RAPIER.ColliderDesc;
1055
+ const col = physics.collider;
1056
+ switch (col.type) {
1057
+ case 'cuboid': {
1058
+ const dims = computeColliderWorldDimensions(col, worldScale, label);
1059
+ const he = dims.halfExtents!;
1060
+ colliderDesc = rapier.ColliderDesc.cuboid(he[0], he[1], he[2]);
1061
+ break;
1062
+ }
1063
+ case 'ball': {
1064
+ const dims = computeColliderWorldDimensions(col, worldScale, label);
1065
+ colliderDesc = rapier.ColliderDesc.ball(dims.radius!);
1066
+ break;
1067
+ }
1068
+ case 'capsule': {
1069
+ const dims = computeColliderWorldDimensions(col, worldScale, label);
1070
+ colliderDesc = rapier.ColliderDesc.capsule(dims.halfHeight!, dims.radius!);
1071
+ break;
1072
+ }
1073
+ case 'trimesh': {
1074
+ // Build a triangle-mesh collider from the entity's own mesh geometry.
1075
+ // NEVER silently fall back to a cuboid — wrong physics with no warning is
1076
+ // a classic AI trap. If geometry isn't available we throw loudly.
1077
+ const geometry = findMeshGeometry(object3d);
1078
+ if (!geometry) {
1079
+ throw new Error(
1080
+ `Entity "${object3d.name}" declares a "trimesh" collider but has no mesh geometry to build it from. ` +
1081
+ `Trimesh colliders require a mesh (primitive or GLTF) on the same entity.`,
1082
+ );
1083
+ }
1084
+ const posAttr = geometry.getAttribute('position');
1085
+ if (!posAttr) {
1086
+ throw new Error(
1087
+ `Entity "${object3d.name}" declares a "trimesh" collider but its mesh geometry has no position attribute.`,
1088
+ );
1089
+ }
1090
+ const vertices = new Float32Array(posAttr.array);
1091
+ // T1.2: pre-scale vertices per-axis by the composed world scale — the
1092
+ // geometry's raw vertex data is in local (unscaled) space.
1093
+ for (let i = 0; i < vertices.length; i += 3) {
1094
+ vertices[i]! *= worldScale[0];
1095
+ vertices[i + 1]! *= worldScale[1];
1096
+ vertices[i + 2]! *= worldScale[2];
1097
+ }
1098
+ let indices: Uint32Array;
1099
+ if (geometry.index) {
1100
+ indices = new Uint32Array(geometry.index.array);
1101
+ } else {
1102
+ // Non-indexed geometry: every 3 consecutive vertices form a triangle.
1103
+ indices = new Uint32Array(posAttr.count);
1104
+ for (let i = 0; i < posAttr.count; i++) indices[i] = i;
1105
+ }
1106
+ colliderDesc = rapier.ColliderDesc.trimesh(vertices, indices);
1107
+ break;
1108
+ }
1109
+ default:
1110
+ colliderDesc = rapier.ColliderDesc.cuboid(0.5, 0.5, 0.5);
1111
+ }
1112
+
1113
+ // Mass precedence: an explicit `physics.mass` always wins. When mass is set we
1114
+ // skip setDensity entirely, otherwise Rapier's later setDensity would recompute
1115
+ // (and silently defeat) the requested mass.
1116
+ if (physics.mass !== undefined) {
1117
+ colliderDesc.setMass(physics.mass);
1118
+ }
1119
+ if (col.friction !== undefined) {
1120
+ colliderDesc.setFriction(col.friction);
1121
+ }
1122
+ if (col.restitution !== undefined) {
1123
+ colliderDesc.setRestitution(col.restitution);
1124
+ }
1125
+ if (col.density !== undefined && physics.mass === undefined) {
1126
+ colliderDesc.setDensity(col.density);
1127
+ }
1128
+ // T1.2/R1c: the offset is a physics-engine translation with no notion of the
1129
+ // parent Object3D's scale (same reasoning as the dimensions above) — scale it
1130
+ // by the entity's composed world scale, or it silently drifts off its visual
1131
+ // anchor point under any non-1 scale.
1132
+ const worldOffset = computeColliderWorldOffset(col.offset, worldScale);
1133
+ if (worldOffset) {
1134
+ colliderDesc.setTranslation(worldOffset[0], worldOffset[1], worldOffset[2]);
1135
+ }
1136
+ if (col.isSensor) {
1137
+ colliderDesc.setSensor(true);
1138
+ }
1139
+ // All scene-loaded colliders opt into collision events — sensors need them for
1140
+ // onTriggerEnter/onTriggerExit, and solid colliders need them for onCollision
1141
+ // (matches the world2d loader's unconditional setActiveEvents; see T1.3).
1142
+ colliderDesc.setActiveEvents(rapier.ActiveEvents.COLLISION_EVENTS);
1143
+ if (col.collisionGroups !== undefined || col.collisionFilter !== undefined) {
1144
+ const groups = col.collisionGroups ?? 0xffff;
1145
+ const filter = col.collisionFilter ?? 0xffff;
1146
+ colliderDesc.setCollisionGroups((groups << 16) | filter);
1147
+ }
1148
+
1149
+ const collider = ctx.rapierWorld.createCollider(colliderDesc, body);
1150
+
1151
+ // Register in the physics registry: object3d → {body, collider} and the
1152
+ // colliderHandle → object3d reverse index (used for collision/trigger dispatch).
1153
+ ctx.physics.add(object3d, body, collider);
1154
+ }
1155
+
1156
+ /** Walk all entities in a scene tree recursively. */
1157
+ function walkEntities(entities: SceneEntity[], fn: (e: SceneEntity) => void): void {
1158
+ for (const e of entities) {
1159
+ fn(e);
1160
+ if (e.children) walkEntities(e.children, fn);
1161
+ }
1162
+ }
1163
+
1164
+ /** Create Rapier joints for all entities that define them. */
1165
+ function createSceneJoints(
1166
+ entities: SceneEntity[],
1167
+ idMap: Map<string, THREE.Object3D>,
1168
+ ctx: SceneLoadContext,
1169
+ ): void {
1170
+ const rapier = ctx.rapier;
1171
+
1172
+ walkEntities(entities, (def) => {
1173
+ if (!def.joints || !def.id) return;
1174
+ const objA = idMap.get(def.id);
1175
+ if (!objA) return;
1176
+ const bodyA = ctx.physics.get(objA)?.body;
1177
+ if (!bodyA) return;
1178
+
1179
+ for (const joint of def.joints) {
1180
+ const targetObj = idMap.get(joint.target);
1181
+ if (!targetObj) {
1182
+ log.scene.warn(`Joint target "${joint.target}" not found for entity "${def.name}"`, {
1183
+ entityId: def.id,
1184
+ });
1185
+ continue;
1186
+ }
1187
+ const bodyB = ctx.physics.get(targetObj)?.body;
1188
+ if (!bodyB) continue;
1189
+
1190
+ const a1 = joint.anchor ?? [0, 0, 0];
1191
+ const a2 = joint.targetAnchor ?? [0, 0, 0];
1192
+ const anchor1 = { x: a1[0], y: a1[1], z: a1[2] };
1193
+ const anchor2 = { x: a2[0], y: a2[1], z: a2[2] };
1194
+
1195
+ let params: RAPIER.JointData;
1196
+ switch (joint.type) {
1197
+ case 'fixed': {
1198
+ const frame1 = { x: 0, y: 0, z: 0, w: 1 };
1199
+ const frame2 = { x: 0, y: 0, z: 0, w: 1 };
1200
+ params = rapier.JointData.fixed(anchor1, frame1, anchor2, frame2);
1201
+ break;
1202
+ }
1203
+ case 'revolute': {
1204
+ const ax = joint.axis ?? [0, 1, 0];
1205
+ params = rapier.JointData.revolute(anchor1, anchor2, { x: ax[0], y: ax[1], z: ax[2] });
1206
+ if (joint.limits) {
1207
+ const deg2rad = Math.PI / 180;
1208
+ const lp = params as typeof params & {
1209
+ limitsEnabled: boolean;
1210
+ limits: [number, number];
1211
+ };
1212
+ lp.limitsEnabled = true;
1213
+ lp.limits = [joint.limits.min * deg2rad, joint.limits.max * deg2rad];
1214
+ }
1215
+ break;
1216
+ }
1217
+ case 'prismatic': {
1218
+ const ax = joint.axis ?? [0, 1, 0];
1219
+ params = rapier.JointData.prismatic(anchor1, anchor2, { x: ax[0], y: ax[1], z: ax[2] });
1220
+ if (joint.limits) {
1221
+ const lp = params as typeof params & {
1222
+ limitsEnabled: boolean;
1223
+ limits: [number, number];
1224
+ };
1225
+ lp.limitsEnabled = true;
1226
+ lp.limits = [joint.limits.min, joint.limits.max];
1227
+ }
1228
+ break;
1229
+ }
1230
+ case 'spherical':
1231
+ params = rapier.JointData.spherical(anchor1, anchor2);
1232
+ break;
1233
+ case 'spring': {
1234
+ const rest = joint.restLength ?? 1;
1235
+ const stiff = joint.stiffness ?? 10;
1236
+ const damp = joint.damping ?? 1;
1237
+ params = rapier.JointData.spring(rest, stiff, damp, anchor1, anchor2);
1238
+ break;
1239
+ }
1240
+ default:
1241
+ continue;
1242
+ }
1243
+
1244
+ ctx.rapierWorld.createImpulseJoint(params, bodyA, bodyB, true);
1245
+ }
1246
+ });
1247
+ }
1248
+
1249
+ /**
1250
+ * F6 (`docs/VSCN-STRUCTURAL-GAPS-DESIGN.md`) — resolve `EntityRefSchema`
1251
+ * component fields from the authored string id to the live `Object3D`.
1252
+ *
1253
+ * Modeled directly on `createSceneJoints` above (same walk, same `idMap`,
1254
+ * same "missing target" shape) — the difference is WHAT gets resolved
1255
+ * (a component field, discovered via the component's own static schema)
1256
+ * and HOW a miss is handled: a joint with a missing target logs a warning
1257
+ * and skips it; a missing entity-ref id is a loud load error (throw) per
1258
+ * the design doc's AC, because a component that expects a live `Object3D`
1259
+ * at `init()` cannot safely run with a dangling string in that field.
1260
+ *
1261
+ * Must run AFTER every entity is spawned (idMap is complete — components are
1262
+ * attached during spawn too, so by this point every instance's ref field
1263
+ * still holds its raw authored string id) and BEFORE
1264
+ * `componentManager.initAll()`, so `init()` observes the resolved
1265
+ * `Object3D`, not the string. Resolves through the SAME `idMap`
1266
+ * `createSceneJoints` uses — no new registry (G3).
1267
+ */
1268
+ export function resolveEntityRefs(
1269
+ entities: SceneEntity[],
1270
+ idMap: Map<string, THREE.Object3D>,
1271
+ manager: ComponentManager,
1272
+ ): void {
1273
+ walkEntities(entities, (def) => {
1274
+ if (!def.id || !def.components) return;
1275
+ const node = idMap.get(def.id);
1276
+ if (!node) return;
1277
+
1278
+ for (const inst of manager.getComponents(node)) {
1279
+ const schema = (inst.constructor as GameComponentClass).schema;
1280
+ if (!schema) continue;
1281
+
1282
+ for (const [field, fieldSchema] of Object.entries(schema.shape)) {
1283
+ if (!isEntityRefSchema(fieldSchema)) continue;
1284
+
1285
+ const record = inst as unknown as Record<string, unknown>;
1286
+ const value = record[field];
1287
+ // Optional ref, not authored on this instance — nothing to resolve.
1288
+ if (value === undefined) continue;
1289
+ if (typeof value !== 'string' || value.length === 0) continue;
1290
+
1291
+ const resolved = idMap.get(value);
1292
+ if (!resolved) {
1293
+ throw new Error(
1294
+ `Entity ref "${field}" on component "${inst.constructor.name}" (entity ` +
1295
+ `"${entityLabel(def)}") points at missing entity id "${value}".`,
1296
+ );
1297
+ }
1298
+ record[field] = resolved;
1299
+ }
1300
+ }
1301
+ });
1302
+ }
1303
+
1304
+ /** Fetch and cache a .mat.json file. Validated via Zod (T4.6) — a malformed material asset
1305
+ * throws a `SceneParseError` naming the file, not a deep TypeError once the bad data reaches
1306
+ * material-factory.ts. */
1307
+ async function loadMaterialFile(url: string, ctx: SceneLoadContext): Promise<MaterialFile> {
1308
+ if (!ctx.materialFileCache) ctx.materialFileCache = new Map();
1309
+ const cached = ctx.materialFileCache.get(url);
1310
+ if (cached) return cached;
1311
+
1312
+ const res = await fetch(resolveUrl(url));
1313
+ if (!res.ok) throw new Error(`Failed to load material: ${url} (${res.status})`);
1314
+ const data = parseMaterialFile(await res.json(), url);
1315
+ ctx.materialFileCache.set(url, data);
1316
+ return data;
1317
+ }
1318
+
1319
+ /** Fetch and cache a .instances.json file (F3). Validated via Zod — a malformed instance-
1320
+ * transform asset throws a `SceneParseError` naming the file. */
1321
+ async function loadInstancesFile(url: string, ctx: SceneLoadContext): Promise<InstancesFile> {
1322
+ if (!ctx.instancesFileCache) ctx.instancesFileCache = new Map();
1323
+ const cached = ctx.instancesFileCache.get(url);
1324
+ if (cached) return cached;
1325
+
1326
+ const res = await fetch(resolveUrl(url));
1327
+ if (!res.ok) throw new Error(`Failed to load instances: ${url} (${res.status})`);
1328
+ const data = parseInstancesFile(await res.json(), url);
1329
+ ctx.instancesFileCache.set(url, data);
1330
+ return data;
1331
+ }
1332
+
1333
+ /** Resolve material for an entity — load from .mat.json ref or use inline material. */
1334
+ async function resolveMaterial(
1335
+ def: SceneEntity,
1336
+ ctx: SceneLoadContext,
1337
+ ): Promise<SceneMaterial | undefined> {
1338
+ if (def.materialRef) {
1339
+ try {
1340
+ const file = await loadMaterialFile(def.materialRef, ctx);
1341
+ return file.material;
1342
+ } catch (err) {
1343
+ log.scene.warn(`Failed to load material ref: ${def.materialRef}`, {
1344
+ entityId: def.id,
1345
+ error: String(err),
1346
+ });
1347
+ return def.material;
1348
+ }
1349
+ }
1350
+ return def.material;
1351
+ }
1352
+
1353
+ /** Fetch and cache a .prefab.json file. Validated via Zod (T4.6) — a malformed prefab file
1354
+ * throws a `SceneParseError` naming the file, not a deep TypeError once the bad data reaches
1355
+ * spawnEntity. */
1356
+ async function loadPrefabFile(url: string, ctx: SceneLoadContext): Promise<PrefabFile> {
1357
+ if (!ctx.prefabFileCache) ctx.prefabFileCache = new Map();
1358
+ const cached = ctx.prefabFileCache.get(url);
1359
+ if (cached) return cached;
1360
+
1361
+ const res = await fetch(resolveUrl(url));
1362
+ if (!res.ok) throw new Error(`Failed to load prefab: ${url} (${res.status})`);
1363
+ const data = parsePrefabFile(await res.json(), url);
1364
+ ctx.prefabFileCache.set(url, data);
1365
+ return data;
1366
+ }
1367
+
1368
+ /** Handle returned from spawnPrefab — call update() each frame to tick animations. */
1369
+ export interface SpawnedPrefab {
1370
+ /** The Object3D of the spawned prefab root (added to the scene). */
1371
+ object3D: THREE.Object3D;
1372
+ /**
1373
+ * Tick standalone (non-animGraph) animations for this prefab — e.g. a simple
1374
+ * `animation.autoplay` clip. AnimGraphs are registered into `ctx.animGraphs`
1375
+ * (when provided) and ticked by the global animationSystem instead, so this
1376
+ * is a no-op for those. Call once per frame, mirroring SceneInstance.update.
1377
+ */
1378
+ update(dt: number): void;
1379
+ /** Stop any mixers this prefab created. */
1380
+ dispose(): void;
1381
+ }
1382
+
1383
+ /**
1384
+ * Spawn a prefab instance at runtime from gameplay code.
1385
+ *
1386
+ * Returns a {@link SpawnedPrefab}. The previous version passed throwaway
1387
+ * tickFns/mixers arrays, so a runtime-spawned prefab's `animation.autoplay`
1388
+ * (or an animGraph with no `ctx.animGraphs`) never advanced. Now the tick
1389
+ * functions are captured and exposed via `update()`, consistent with how
1390
+ * loadScene's SceneInstance.update drives animations.
1391
+ *
1392
+ * Usage:
1393
+ * const crate = await spawnPrefab('/data/prefabs/crate.prefab.json', ctx, {
1394
+ * transform: { position: [x, y, z] },
1395
+ * components: { Health: { current: 50 } },
1396
+ * });
1397
+ * // each frame (only needed for autoplay clips; animGraphs tick globally):
1398
+ * crate.update(dt);
1399
+ */
1400
+ export async function spawnPrefab(
1401
+ prefabUrl: string,
1402
+ ctx: SceneLoadContext,
1403
+ overrides?: Partial<SceneEntity>,
1404
+ ): Promise<SpawnedPrefab> {
1405
+ const instance: SceneEntity = {
1406
+ name: 'Instance',
1407
+ prefab: prefabUrl,
1408
+ ...overrides,
1409
+ };
1410
+ const tickFns: ((dt: number) => void)[] = [];
1411
+ const mixers: THREE.AnimationMixer[] = [];
1412
+ const audioTracker: AudioTracker = { audios: [], disposed: { value: false } };
1413
+ const object3D = await spawnEntity(
1414
+ instance,
1415
+ ctx,
1416
+ tickFns,
1417
+ mixers,
1418
+ undefined,
1419
+ ROOT_ANCESTOR_CONTEXT,
1420
+ audioTracker,
1421
+ );
1422
+ ctx.scene.add(object3D);
1423
+ // Run init() on any GameComponents the prefab attached. attach() only queues
1424
+ // them in pendingInit + the per-phase tick list; without this, update() fires
1425
+ // every frame while init() never runs (mirrors loadScene's pass-3 initAll).
1426
+ if (ctx.componentManager) await ctx.componentManager.initAll();
1427
+ return {
1428
+ object3D,
1429
+ update(dt: number) {
1430
+ for (const fn of tickFns) fn(dt);
1431
+ },
1432
+ // ---------------------------------------------------------------------
1433
+ // R1c CLASS INVENTORY — everything spawnEntity can register into a
1434
+ // `ctx` map/system/registry for a spawned subtree, and what dispose()
1435
+ // below does about each one. (This is the acceptance criterion for the
1436
+ // "spawnPrefab().dispose() completeness" task — keep it in sync with
1437
+ // spawnEntity whenever a new registration point is added.)
1438
+ //
1439
+ // - ctx.physics (Object3D -> {body, collider}) DISPOSED — root AND
1440
+ // every nested descendant's Rapier body is removed from the world
1441
+ // AND the registry (pre-existing, T1.4).
1442
+ // - ctx.componentManager (GameComponent instances) DISPOSED —
1443
+ // detach() runs on root + every descendant, firing each component's
1444
+ // own dispose() and stopping its tick (pre-existing, T1.4).
1445
+ // - mixers (THREE.AnimationMixer, simple `animation.autoplay` clips)
1446
+ // DISPOSED — stopAllAction() on every mixer this spawn created
1447
+ // (pre-existing).
1448
+ // - tickFns (standalone animGraph/mixer tick closures, only used when
1449
+ // `ctx.animGraphs` is NOT provided) DISPOSED — array cleared so
1450
+ // update() becomes a no-op (pre-existing).
1451
+ // - ctx.animGraphs (Object3D -> AnimGraph, when provided)
1452
+ // NEWLY-DISPOSED — every entry whose key is inside the spawned
1453
+ // subtree is deleted, so the global animationSystem stops ticking a
1454
+ // despawned graph (probe1: previously leaked forever).
1455
+ // - ctx.particleRenderer (three.quarks BatchedRenderer, when provided)
1456
+ // NEWLY-DISPOSED — every particle system tagged via the
1457
+ // `_particleSystem` userData key (mirrors editor scene-sync's own
1458
+ // registration convention) anywhere in the subtree is removed with
1459
+ // `deleteSystem` (probe1: previously kept rendering/simulating after
1460
+ // the visual object was gone).
1461
+ // - THREE.Audio/PositionalAudio objects (added as children of entities
1462
+ // with `def.audio`) NEWLY-DISPOSED — every audio object this spawn
1463
+ // created is `stop()`-ed, AND the `disposed` flag is set so a
1464
+ // still-in-flight `AudioLoader.load()` callback (which captures the
1465
+ // audio object and can resolve after dispose()) skips setBuffer/play
1466
+ // instead of reviving already-torn-down audio.
1467
+ // - `entity`/`entityId`/`navRole`/`pivot`/`splineCurve`/`_camera`
1468
+ // userData tags DELIBERATELY NOT DISPOSED — these are plain data
1469
+ // tags on Object3Ds that get garbage-collected with the subtree once
1470
+ // `ctx.scene.remove(object3D)` drops the last reference; there is no
1471
+ // separate ctx-level registry entry to leak.
1472
+ // - GLTF/texture/animgraph asset caches (asset-loaders.ts, module-level)
1473
+ // DELIBERATELY NOT DISPOSED — these are shared, URL-keyed caches
1474
+ // across the whole runtime session (P1.8 contract: repeated
1475
+ // spawns/loads of the same asset must NOT re-fetch or re-parse), not
1476
+ // per-spawn state; they're only released by `clearAssetCaches()` on
1477
+ // full runtime teardown.
1478
+ // ---------------------------------------------------------------------
1479
+ dispose() {
1480
+ // Detach components on the root AND every descendant (a prefab tree can
1481
+ // attach components anywhere, not just at the root) — fires each
1482
+ // instance's dispose() + stops it ticking.
1483
+ object3D.traverse((node) => {
1484
+ ctx.componentManager?.detach(node);
1485
+ });
1486
+ // Remove any Rapier bodies this prefab tree created (root or nested) from
1487
+ // both the physics world and the physics registry — otherwise the body
1488
+ // keeps simulating/colliding after the visual object is gone (orphan body).
1489
+ object3D.traverse((node) => {
1490
+ const refs = ctx.physics.get(node);
1491
+ if (!refs) return;
1492
+ ctx.rapierWorld.removeRigidBody(refs.body);
1493
+ ctx.physics.remove(node);
1494
+ });
1495
+ // Unregister any AnimGraphs this subtree registered into the shared,
1496
+ // Object3D-keyed ctx.animGraphs map — otherwise the global
1497
+ // animationSystem keeps ticking a despawned graph forever.
1498
+ if (ctx.animGraphs) {
1499
+ const animGraphs = ctx.animGraphs;
1500
+ object3D.traverse((node) => {
1501
+ animGraphs.delete(node);
1502
+ });
1503
+ }
1504
+ // Unregister any particle systems this subtree registered with the
1505
+ // shared BatchedRenderer — otherwise they keep simulating/rendering
1506
+ // after the visual object is gone. `_particleSystem` is the same
1507
+ // userData tag the editor's scene-sync uses for the same purpose.
1508
+ if (ctx.particleRenderer) {
1509
+ const particleRenderer = ctx.particleRenderer;
1510
+ object3D.traverse((node) => {
1511
+ const system = getUserData(node, '_particleSystem');
1512
+ if (!system) return;
1513
+ try {
1514
+ particleRenderer.deleteSystem(system);
1515
+ } catch {
1516
+ // Already removed — nothing left to do.
1517
+ }
1518
+ });
1519
+ }
1520
+ // Stop every THREE.Audio/PositionalAudio object this spawn created, and
1521
+ // flag `disposed` so an AudioLoader request still in flight (its
1522
+ // callback captured `audioObj`, which outlives this dispose() call)
1523
+ // skips setBuffer/play on audio that's already torn down.
1524
+ audioTracker.disposed.value = true;
1525
+ for (const audio of audioTracker.audios) audio.stop();
1526
+
1527
+ ctx.scene.remove(object3D);
1528
+ for (const mixer of mixers) mixer.stopAllAction();
1529
+ tickFns.length = 0;
1530
+ mixers.length = 0;
1531
+ },
1532
+ };
1533
+ }