@vgai/engine 0.5.2 → 0.5.3

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