@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.
- package/README.md +18 -11
- package/package.json +10 -6
- package/schemas/engine-api.json +1 -68
- package/schemas/engine-api.md +1 -32
- package/schemas/engine-capabilities.json +30 -42
- package/schemas/{vgai-game.schema.json → vgai-project.schema.json} +52 -34
- package/src/adapter/adapter-surface.ts +5 -5
- package/src/adapter/authoring.ts +168 -226
- package/src/adapter/colyseus-networking-adapter.ts +44 -5
- package/src/adapter/first-party-systems.ts +156 -42
- package/src/adapter/host-context.ts +177 -46
- package/src/adapter/index.ts +47 -51
- package/src/adapter/ingest/game-contract.ts +2 -2
- package/src/adapter/ingest/scene-capture.ts +18 -19
- package/src/adapter/ingest/structural-ids.ts +127 -0
- package/src/adapter/ingest/upstream-pin.ts +9 -12
- package/src/adapter/loop-gate-report.ts +11 -11
- package/src/adapter/rapier-physics-adapter.ts +27 -9
- package/src/adapter/root-adapter.ts +217 -0
- package/src/adapter/{vgai-scene-game-adapter.ts → setup-three-root-adapter.ts} +173 -351
- package/src/adapter/system-adapter.ts +80 -63
- package/src/ai/navigation.ts +1 -1
- package/src/animation/animation-clock.ts +1 -1
- package/src/animation/camera-ownership.ts +1 -2
- package/src/animation/cubic-spline-interpolant.ts +132 -0
- package/src/animation/theatre-clock-binding.ts +2 -2
- package/src/animation/theatre-object-binding.ts +4 -4
- package/src/animation/xstate-animation-binding.ts +75 -5
- package/src/{scene/schema → asset-formats}/camera.ts +2 -5
- package/src/{scene/schema → asset-formats}/collider.ts +2 -5
- package/src/asset-formats/index.ts +54 -0
- package/src/{scene/schema → asset-formats}/instances.ts +10 -6
- package/src/{scene/schema → asset-formats}/light.ts +3 -6
- package/src/{scene/schema → asset-formats}/material.ts +4 -7
- package/src/{scene/schema → asset-formats}/mesh.ts +4 -7
- package/src/asset-formats/parse.ts +39 -0
- package/src/{scene/schema → asset-formats}/particles.ts +3 -6
- package/src/{scene/schema/environment.ts → asset-formats/render-env.ts} +23 -86
- package/src/{scene/schema → asset-formats}/tuples.ts +1 -1
- package/src/{scene/asset-loaders.ts → asset-loaders.ts} +8 -9
- package/src/asset-parse-error.ts +33 -0
- package/src/{scene/asset-registry.ts → asset-registry.ts} +1 -1
- package/src/assets.ts +1 -1
- package/src/audio/wav-encode.ts +9 -9
- package/src/canvas-react/engine-bridge.ts +59 -0
- package/src/canvas-react/index.ts +50 -0
- package/src/canvas-react/pixi-primitive.tsx +202 -0
- package/src/canvas-react/pixi-react-adapter.tsx +290 -0
- package/src/canvas-react/pixi-react-root-factory.tsx +88 -0
- package/src/canvas-react/world-context.ts +328 -0
- package/src/core/frame-pacing.ts +100 -0
- package/src/core/game-loop.ts +50 -28
- package/src/core/seeded-random.ts +7 -7
- package/src/core/sim-clock.ts +388 -0
- package/src/core/system-runner.ts +17 -63
- package/src/core/types.ts +34 -15
- package/src/data/data-asset.ts +3 -3
- package/src/data/data-check-core.ts +6 -7
- package/src/data/data-ref.ts +11 -11
- package/src/data/vite-plugin-data.ts +10 -10
- package/src/{scene/defaults.ts → defaults.ts} +18 -40
- package/src/dev/render-debug-adapter.ts +1 -1
- package/src/dev/webgl-frame-capture.ts +1 -1
- package/src/ecs/scene-index.ts +439 -0
- package/src/ecs/scene-query.ts +43 -0
- package/src/{scene → ecs}/user-data.ts +17 -36
- package/src/index.ts +7 -9
- package/src/input/input-manager.ts +29 -32
- package/src/input/input-types.ts +2 -2
- package/src/input/schema.ts +5 -5
- package/src/loader.ts +57 -0
- package/src/manifest/editor-port.ts +69 -0
- package/src/manifest/filename.ts +49 -0
- package/src/manifest/index.ts +8 -2
- package/src/manifest/load-file.ts +11 -0
- package/src/manifest/load.ts +65 -77
- package/src/manifest/locate.ts +55 -0
- package/src/manifest/schema.ts +400 -233
- package/src/{scene → physics}/collider-dimensions.ts +3 -3
- package/src/physics/physics-registry.ts +1 -1
- package/src/{world2d/authoring-2d.ts → pixi/authoring.ts} +24 -11
- package/src/pixi/index.ts +43 -0
- package/src/{world2d/ingest-iframe-2d.ts → pixi/ingest-iframe.ts} +9 -9
- package/src/{world2d/ingest2d.ts → pixi/ingest.ts} +28 -28
- package/src/{world2d/physics2d-registry.ts → pixi/physics-registry.ts} +1 -1
- package/src/{world2d/scene-capture-2d.ts → pixi/scene-capture.ts} +3 -3
- package/src/{world2d/system-adapters-2d.ts → pixi/system-adapters.ts} +2 -2
- package/src/react/unmanaged-root-detector.ts +26 -1
- package/src/react/use-data.ts +5 -5
- package/src/react/use-selection.tsx +15 -42
- package/src/react/{game-state.tsx → world-state.tsx} +44 -46
- package/src/render/auto-batcher.ts +1 -2
- package/src/{scene → render}/instance-mesh.ts +1 -1
- package/src/{scene → render}/light-camera-factory.ts +14 -13
- package/src/render/lod.ts +17 -0
- package/src/{scene → render}/material-factory.ts +8 -7
- package/src/{scene → render}/particles-factory.ts +62 -12
- package/src/render/render-batch-system.ts +14 -41
- package/src/render/render-features.ts +1 -1
- package/src/render/render-settings.ts +1 -2
- package/src/render/spark-renderer-lifecycle.ts +1 -1
- package/src/runtime/create-runtime.ts +352 -463
- package/src/runtime/debug-bridge.ts +148 -93
- package/src/runtime/debug-registry.ts +79 -54
- package/src/runtime/dev-layers.ts +40 -0
- package/src/runtime/frame-selector-cache.ts +4 -4
- package/src/runtime/game.ts +618 -369
- package/src/runtime/gameplay-rng-trap.ts +6 -7
- package/src/runtime/input-router.ts +11 -11
- package/src/runtime/mount-game.ts +54 -55
- package/src/runtime/mount-manifest.ts +154 -150
- package/src/runtime/presentation.ts +141 -0
- package/src/runtime/render-audio-control.ts +64 -53
- package/src/runtime/render-control.ts +45 -51
- package/src/runtime/render-seed.ts +3 -4
- package/src/runtime/state-bridge.ts +17 -18
- package/src/runtime/types.ts +94 -61
- package/src/setup/setup-renderer.ts +11 -6
- package/src/world3d-react/engine-bridge.ts +46 -33
- package/src/world3d-react/index.ts +31 -26
- package/src/world3d-react/r3f-adapter.tsx +211 -96
- package/src/world3d-react/r3f-root-factory.tsx +91 -0
- package/src/world3d-react/renderer-config.ts +137 -0
- package/src/world3d-react/world-context.ts +141 -111
- package/schemas/entity2d.schema.json +0 -468
- package/schemas/prefab.schema.json +0 -9992
- package/schemas/scn2d.schema.json +0 -494
- package/schemas/vscn.schema.json +0 -10851
- package/src/adapter/game-adapter.ts +0 -164
- package/src/adapter/ingest/overlay-applier.ts +0 -207
- package/src/adapter/ingest/overlay-apply.ts +0 -168
- package/src/adapter/ingest/overlay-file.ts +0 -126
- package/src/adapter/ingest/overlay-report.ts +0 -176
- package/src/animation/gsap-registration.ts +0 -184
- package/src/audio/audio-introspection.ts +0 -290
- package/src/audio/index.ts +0 -39
- package/src/audio/tone-clock-binding.ts +0 -98
- package/src/audio/tone-context.ts +0 -175
- package/src/audio/tone-offline-render.ts +0 -167
- package/src/ecs/component-manager.ts +0 -814
- package/src/ecs/game-component.ts +0 -260
- package/src/ecs/hmr-swap-report.ts +0 -65
- package/src/physics/trigger-dispatch.ts +0 -97
- package/src/react/root-adapter.tsx +0 -49
- package/src/scene/asset-paths.ts +0 -121
- package/src/scene/asset-ref-check.ts +0 -248
- package/src/scene/component-registry.ts +0 -51
- package/src/scene/parse.ts +0 -204
- package/src/scene/scene-apply.ts +0 -407
- package/src/scene/scene-diff-schema.ts +0 -115
- package/src/scene/scene-diff-types.ts +0 -29
- package/src/scene/scene-loader.ts +0 -1526
- package/src/scene/scene-query.ts +0 -63
- package/src/scene/scene-types.ts +0 -33
- package/src/scene/scene-version.ts +0 -40
- package/src/scene/schema/animation.ts +0 -46
- package/src/scene/schema/audio.ts +0 -25
- package/src/scene/schema/entity-ref.ts +0 -78
- package/src/scene/schema/entity.ts +0 -189
- package/src/scene/schema/index.ts +0 -51
- package/src/scene/schema/joint.ts +0 -26
- package/src/scene/schema/physics.ts +0 -49
- package/src/scene/schema/scene-file.ts +0 -292
- package/src/scene/schema/shadow.ts +0 -24
- package/src/scene/schema/spline.ts +0 -21
- package/src/world2d/asset-paths2d.ts +0 -44
- package/src/world2d/capture-to-scene2d.ts +0 -52
- package/src/world2d/collision-2d.ts +0 -99
- package/src/world2d/entity2d-asset.ts +0 -22
- package/src/world2d/index.ts +0 -91
- package/src/world2d/physics2d-transform.ts +0 -173
- package/src/world2d/physics2d-units.ts +0 -10
- package/src/world2d/pixi-game-adapter.ts +0 -439
- package/src/world2d/pixi-surface.ts +0 -78
- package/src/world2d/scene2d-identity.ts +0 -49
- package/src/world2d/scene2d-loader.ts +0 -433
- package/src/world2d/schema/entity2d.ts +0 -163
- package/src/world2d/schema/physics2d.ts +0 -64
- package/src/world2d/schema/sprite.ts +0 -99
- package/src/world2d/schema/tilemap.ts +0 -39
- package/src/world2d/schema/tuples2d.ts +0 -25
- package/src/world2d/transform-writer-2d.ts +0 -42
- package/src/world2d/types.ts +0 -74
- package/src/world3d-react/behavior.tsx +0 -146
- /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
|
-
}
|