@vgai/engine 0.5.21 → 0.5.22
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/dist/adapter/manifest-interpreter.d.ts +59 -0
- package/dist/adapter/manifest-interpreter.d.ts.map +1 -0
- package/dist/adapter/manifest-interpreter.js +50 -0
- package/dist/adapter/native-debug-module.d.ts +45 -3
- package/dist/adapter/native-debug-module.d.ts.map +1 -1
- package/dist/adapter/native-debug-module.js +128 -10
- package/dist/adapter/renderer-config.d.ts +1 -1
- package/dist/adapter/renderer-config.js +1 -1
- package/dist/canvas-react/index.d.ts +5 -12
- package/dist/canvas-react/index.d.ts.map +1 -1
- package/dist/canvas-react/index.js +5 -12
- package/dist/canvas-react/pixi-react-root-factory.d.ts +34 -21
- package/dist/canvas-react/pixi-react-root-factory.d.ts.map +1 -1
- package/dist/canvas-react/pixi-react-root-factory.js +274 -36
- package/dist/core/countdown-timer.d.ts +118 -0
- package/dist/core/countdown-timer.d.ts.map +1 -0
- package/dist/core/countdown-timer.js +137 -0
- package/dist/core/deferred-commands.d.ts +127 -0
- package/dist/core/deferred-commands.d.ts.map +1 -0
- package/dist/core/deferred-commands.js +132 -0
- package/dist/core/sim-clock.d.ts +14 -2
- package/dist/core/sim-clock.d.ts.map +1 -1
- package/dist/core/sim-clock.js +14 -2
- package/dist/dev/instruments.d.ts +38 -11
- package/dist/dev/instruments.d.ts.map +1 -1
- package/dist/dev/instruments.js +81 -11
- package/dist/input/input-manager.d.ts +15 -0
- package/dist/input/input-manager.d.ts.map +1 -1
- package/dist/input/input-manager.js +15 -0
- package/dist/render/directional-shadow-fit.d.ts +88 -0
- package/dist/render/directional-shadow-fit.d.ts.map +1 -0
- package/dist/render/directional-shadow-fit.js +133 -0
- package/dist/render/environment-capture.d.ts +68 -0
- package/dist/render/environment-capture.d.ts.map +1 -0
- package/dist/render/environment-capture.js +90 -0
- package/dist/render/ibl-override-material.d.ts +97 -0
- package/dist/render/ibl-override-material.d.ts.map +1 -0
- package/dist/render/ibl-override-material.js +114 -0
- package/dist/runtime/game-input-seams.d.ts +51 -0
- package/dist/runtime/game-input-seams.d.ts.map +1 -0
- package/dist/runtime/game-input-seams.js +86 -0
- package/dist/runtime/game.d.ts +15 -0
- package/dist/runtime/game.d.ts.map +1 -1
- package/dist/runtime/game.js +40 -8
- package/dist/runtime/mount-game.d.ts.map +1 -1
- package/dist/runtime/mount-game.js +18 -2
- package/dist/runtime/mount-manifest.d.ts.map +1 -1
- package/dist/runtime/mount-manifest.js +4 -2
- package/dist/runtime/types.d.ts +1 -1
- package/dist/world3d-react/index.d.ts +15 -25
- package/dist/world3d-react/index.d.ts.map +1 -1
- package/dist/world3d-react/index.js +15 -25
- package/dist/world3d-react/r3f-root-factory.d.ts +19 -25
- package/dist/world3d-react/r3f-root-factory.d.ts.map +1 -1
- package/dist/world3d-react/r3f-root-factory.js +396 -50
- package/dist/world3d-react/rapier-physics-bridge.d.ts +24 -12
- package/dist/world3d-react/rapier-physics-bridge.d.ts.map +1 -1
- package/dist/world3d-react/rapier-physics-bridge.js +82 -49
- package/package.json +1 -1
- package/schemas/engine-capabilities.json +3 -3
- package/src/adapter/manifest-interpreter.ts +64 -0
- package/src/adapter/native-debug-module.ts +212 -15
- package/src/adapter/renderer-config.ts +1 -1
- package/src/canvas-react/index.ts +5 -21
- package/src/canvas-react/pixi-react-root-factory.tsx +301 -39
- package/src/core/countdown-timer.ts +188 -0
- package/src/core/deferred-commands.ts +174 -0
- package/src/core/sim-clock.ts +14 -2
- package/src/dev/instruments.ts +108 -11
- package/src/input/input-manager.ts +15 -0
- package/src/render/directional-shadow-fit.ts +156 -0
- package/src/render/environment-capture.ts +102 -0
- package/src/render/ibl-override-material.ts +170 -0
- package/src/runtime/game-input-seams.ts +108 -0
- package/src/runtime/game.ts +65 -7
- package/src/runtime/mount-game.ts +23 -1
- package/src/runtime/mount-manifest.ts +4 -2
- package/src/runtime/types.ts +1 -1
- package/src/world3d-react/index.ts +15 -31
- package/src/world3d-react/r3f-root-factory.tsx +444 -53
- package/src/world3d-react/rapier-physics-bridge.tsx +88 -49
- package/src/canvas-react/engine-bridge.ts +0 -59
- package/src/canvas-react/pixi-react-adapter.tsx +0 -356
- package/src/canvas-react/world-context.ts +0 -253
- package/src/world3d-react/engine-bridge.ts +0 -86
- package/src/world3d-react/r3f-adapter.tsx +0 -717
- package/src/world3d-react/world-context.ts +0 -358
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `r3fRootFactory` — mount a `three` root whose entry module DEFAULT-EXPORTS a
|
|
3
|
-
* React component.
|
|
4
|
-
*
|
|
5
|
-
* The idiomatic-shape fix (D26). A model asked for an R3F world writes:
|
|
3
|
+
* React component. This is the ONE first-party three mount:
|
|
6
4
|
*
|
|
7
5
|
* ```tsx
|
|
8
6
|
* export default function World() {
|
|
@@ -10,15 +8,13 @@
|
|
|
10
8
|
* }
|
|
11
9
|
* ```
|
|
12
10
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* `entryModule.default` and wraps it. This is the same contract for three
|
|
21
|
-
* roots, so both surfaces answer "what do I export?" the same way.
|
|
11
|
+
* The world is an ordinary R3F app. Everything vgai-shaped stays at the entry
|
|
12
|
+
* module's STATIC surface (`export { debug, systems } from './commands'`) and
|
|
13
|
+
* on the host's side of the seam; no vgai runtime context ever enters the
|
|
14
|
+
* React tree. The host controls Fiber's `frameloop: 'never'` scheduler, wires
|
|
15
|
+
* the game-scoped input seams from outside (`../runtime/game-input-seams.ts`),
|
|
16
|
+
* and installs `WorldProvider` off the Game handle so the lib-legal react
|
|
17
|
+
* doors (`useWorldState`, `useOptionalGame`, `useDebugProvider`) resolve.
|
|
22
18
|
*
|
|
23
19
|
* ## Why this is NOT in `mount-game.ts`
|
|
24
20
|
*
|
|
@@ -30,64 +26,414 @@
|
|
|
30
26
|
* there would make R3F unconditional for every game, including ones with no
|
|
31
27
|
* React at all.
|
|
32
28
|
*
|
|
33
|
-
* ##
|
|
29
|
+
* ## The mount mechanics (fiber v9, host-gated)
|
|
34
30
|
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
31
|
+
* three.js identity: this module never imports `three` itself for scene
|
|
32
|
+
* objects — fiber's internal `import * as THREE from 'three'` must resolve to
|
|
33
|
+
* the SAME instance the engine's `ThreeHostContext.three` points at, which the
|
|
34
|
+
* importing project guarantees via its Vite
|
|
35
|
+
* `resolve.dedupe: ['three', 'react', 'react-dom']`.
|
|
37
36
|
*/
|
|
38
|
-
import {
|
|
39
|
-
import {
|
|
37
|
+
import { advance, createRoot, extend, flushSync, events as pointerEvents, } from '@react-three/fiber';
|
|
38
|
+
import { createElement, Fragment, useEffect } from 'react';
|
|
39
|
+
import { registerRenderVitals } from '../dev/register-render-vitals';
|
|
40
|
+
import { createRenderDebugAdapter, frameCaptureContextFor, } from '../dev/render-debug-adapter';
|
|
41
|
+
import { collectRenderMemory } from '../dev/render-memory';
|
|
42
|
+
import { RENDER_SUBMIT_PHASE } from '../dev/render-vitals';
|
|
43
|
+
import { createWebGLFrameCapture } from '../dev/webgl-frame-capture';
|
|
44
|
+
import { WorldProvider } from '../react/world-state';
|
|
45
|
+
import { getDebugRegistry } from '../runtime/debug-registry';
|
|
46
|
+
import { devBuildEnabled } from '../runtime/dev-build';
|
|
47
|
+
import { DEFAULT_INPUT_MAP_PATH, wireGameInputSeams } from '../runtime/game-input-seams';
|
|
48
|
+
/**
|
|
49
|
+
* Build the `RootAdapter` for one default-exported R3F world. Mounts through
|
|
50
|
+
* react-three-fiber, gated entirely by the host's own loop and rendering
|
|
51
|
+
* through the host's own `WebGLRenderer` — never a second renderer, never a
|
|
52
|
+
* second `requestAnimationFrame` loop.
|
|
53
|
+
*/
|
|
54
|
+
function threeWorldAdapter(id, component) {
|
|
55
|
+
const content = createElement(component);
|
|
56
|
+
return {
|
|
57
|
+
id,
|
|
58
|
+
async mount(host) {
|
|
59
|
+
// Headless honesty — every `mount()` must work headless: the conformance
|
|
60
|
+
// kit runs `mount()` in Node — no canvas, no WebGL, no fiber
|
|
61
|
+
// reconciler. Guard the ENTIRE fiber mount behind `!host.headless` and
|
|
62
|
+
// return a bare, real scene + camera instead (still `instanceof
|
|
63
|
+
// host.three.Scene` / `.Camera` — the identity rule holds even here,
|
|
64
|
+
// since this uses the host's own `three` instance, not a fresh import).
|
|
65
|
+
// Residual: the hierarchy is empty under headless conformance —
|
|
66
|
+
// recorded as design O2, not hidden; a react-nil-style headless
|
|
67
|
+
// reconciler mount is the known fix, out of scope.
|
|
68
|
+
if (host.headless) {
|
|
69
|
+
const scene = new host.three.Scene();
|
|
70
|
+
const camera = new host.three.PerspectiveCamera();
|
|
71
|
+
return {
|
|
72
|
+
kind: 'three',
|
|
73
|
+
scene,
|
|
74
|
+
camera,
|
|
75
|
+
drivesOwnLoop: false,
|
|
76
|
+
dispose() {
|
|
77
|
+
/* nothing was ever mounted */
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
// Fiber v9 made the THREE catalogue tree-shakeable: `<Canvas>` calls
|
|
82
|
+
// `extend(THREE)` for you, a bare `createRoot` does NOT — without this,
|
|
83
|
+
// the FIRST three intrinsic in the tree (`<color>`, `<ambientLight>`,
|
|
84
|
+
// …) throws "X is not part of the THREE namespace! Did you forget to
|
|
85
|
+
// extend?" at reconcile time. Extending with `host.three` (not a fresh
|
|
86
|
+
// `import * as THREE`) keeps the catalogue on the host's deduped three
|
|
87
|
+
// instance — the same identity rule the returned scene/camera rely on.
|
|
88
|
+
// `extend` merges into a module-global catalogue, so calling it once
|
|
89
|
+
// per mount is idempotent.
|
|
90
|
+
extend(host.three);
|
|
91
|
+
// THE HOST WIRES THIS GAME'S INPUT FROM OUTSIDE THE TREE: the project's
|
|
92
|
+
// declared map, and this root's `game.input.*` seams on the debug
|
|
93
|
+
// registry. Skipping those made a fresh scaffold's bot/autoplay/
|
|
94
|
+
// `vgai eval` input doors throw `DEBUG_INPUT_UNAVAILABLE` and left the
|
|
95
|
+
// declared actions unloaded. The conventional path is optional — quiet
|
|
96
|
+
// when the project ships no map, since a brand-new project declares no
|
|
97
|
+
// actions yet. Actions must exist before any component's first frame
|
|
98
|
+
// reads them — wait for the (never-rejecting) load before the first
|
|
99
|
+
// commit.
|
|
100
|
+
const gameDebugRegistry = host.game ? getDebugRegistry(host.game) : null;
|
|
101
|
+
if (gameDebugRegistry) {
|
|
102
|
+
await wireGameInputSeams(host, gameDebugRegistry, {
|
|
103
|
+
id,
|
|
104
|
+
inputMapPath: DEFAULT_INPUT_MAP_PATH,
|
|
105
|
+
optionalInputMap: true,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
const canvas = host.surface.canvas;
|
|
109
|
+
const root = createRoot(canvas);
|
|
110
|
+
// `RootState` (the live scene/camera/gl fiber built) only arrives via
|
|
111
|
+
// the `onCreated` callback — `root.render()`'s return value is
|
|
112
|
+
// TECHNICALLY the same store, but `onCreated` is the supported hook, and
|
|
113
|
+
// waiting for it (rather than assuming the first commit already ran
|
|
114
|
+
// synchronously) is the honest choice under React 19's concurrent
|
|
115
|
+
// renderer, which does not guarantee a synchronous first commit the way
|
|
116
|
+
// legacy ReactDOM.render did.
|
|
117
|
+
let resolveState;
|
|
118
|
+
const statePromise = new Promise((resolve) => {
|
|
119
|
+
resolveState = resolve;
|
|
120
|
+
});
|
|
121
|
+
let resolveEffectsReady;
|
|
122
|
+
const effectsReady = new Promise((resolve) => {
|
|
123
|
+
resolveEffectsReady = resolve;
|
|
124
|
+
});
|
|
125
|
+
// Rendered after the game's content. React runs passive effects in tree
|
|
126
|
+
// order, so when this fires every provider/command effect in the
|
|
127
|
+
// initial tree has registered. A Play acknowledgement is a claim that
|
|
128
|
+
// the mounted game's command surface exists, not merely that Fiber
|
|
129
|
+
// allocated a scene.
|
|
130
|
+
function MountEffectsReady() {
|
|
131
|
+
useEffect(resolveEffectsReady, []);
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
// `root.configure()` is ASYNC in fiber v9 (`Promise<ReconcilerRoot>`) —
|
|
135
|
+
// it must be awaited before `render()`.
|
|
136
|
+
await root.configure({
|
|
137
|
+
// The engine's renderer, not a second one — fiber renders THROUGH
|
|
138
|
+
// `host.renderer` instead of constructing its own `WebGLRenderer`.
|
|
139
|
+
gl: host.renderer,
|
|
140
|
+
// …and the host's shadow map, not fiber's.
|
|
141
|
+
//
|
|
142
|
+
// `configure()` runs `gl.shadowMap.enabled = !!shadows`
|
|
143
|
+
// UNCONDITIONALLY, against its own default of `false`. Passing nothing
|
|
144
|
+
// therefore does not mean "leave it alone" — it means "turn shadows
|
|
145
|
+
// off on the renderer the host just configured" (FX-5: every three
|
|
146
|
+
// root silently read flat, with no error anywhere). Reading it back
|
|
147
|
+
// off the renderer is exactly "don't clobber": a boolean also pins
|
|
148
|
+
// `shadowMap.type` to `PCFSoftShadowMap`, which is what
|
|
149
|
+
// `createHostRenderer` already sets, so the round trip is lossless
|
|
150
|
+
// and the HOST stays the one deciding.
|
|
151
|
+
//
|
|
152
|
+
// Optional-chained even though `WebGLRenderer.shadowMap` is not
|
|
153
|
+
// optional: a headless/stub host renderer is a real shape in this
|
|
154
|
+
// repo's tests, and fiber's own `if (gl.shadowMap)` guard means the
|
|
155
|
+
// value it reads there is ignored anyway.
|
|
156
|
+
shadows: host.renderer.shadowMap?.enabled ?? false,
|
|
157
|
+
// The engine's gated loop is the ONLY loop — fiber must never run its
|
|
158
|
+
// own rAF (that would defeat editor pause; `drivesOwnLoop: false`).
|
|
159
|
+
frameloop: 'never',
|
|
160
|
+
// `RenderProps.size` types as the FULL `Size` (width/height/top/left),
|
|
161
|
+
// so `top`/`left` are pinned to 0 (this bridge always fills its whole
|
|
162
|
+
// canvas; no offset viewport).
|
|
163
|
+
size: { width: host.surface.width, height: host.surface.height, top: 0, left: 0 },
|
|
164
|
+
// Fiber's pointer-event manager, passed EXPLICITLY. There IS no
|
|
165
|
+
// default for a bare `createRoot`: the `events = createPointerEvents`
|
|
166
|
+
// default lives in the `<Canvas>` COMPONENT, which this mount does not
|
|
167
|
+
// use, and `configure` gates on `if (events && !state.events.handlers)`.
|
|
168
|
+
// With `events` undefined the store keeps its initial
|
|
169
|
+
// `{ priority, enabled, connected: false }` — no handlers, nothing
|
|
170
|
+
// bound to the canvas, no error — and every mesh-level pointer prop
|
|
171
|
+
// (`onClick`, `onPointerOver`, `onPointerMissed`) is dead. Proven by a
|
|
172
|
+
// control experiment differing ONLY in this property.
|
|
173
|
+
events: pointerEvents,
|
|
174
|
+
onCreated: (state) => resolveState(state),
|
|
175
|
+
});
|
|
176
|
+
// No `<StrictMode>` — the host mounts once; StrictMode's deliberate
|
|
177
|
+
// double-invoke of effects would double-subscribe `useFrame` callbacks
|
|
178
|
+
// against a host loop that only ticks once per frame.
|
|
179
|
+
//
|
|
180
|
+
// `WorldProvider` rides `host.game` — the React seam every dom root
|
|
181
|
+
// already has (`useWorldState`, `useOptionalGame`, `useDebugProvider` in
|
|
182
|
+
// lib code), and the only way a capability hook can reach the ONE
|
|
183
|
+
// game-scoped registry the editor/`vgai eval` read. `host.game` is
|
|
184
|
+
// genuinely absent in bare/headless hosts, so the provider is
|
|
185
|
+
// conditional and those hooks stay the inert no-ops they already
|
|
186
|
+
// document themselves to be.
|
|
187
|
+
const game = host.game;
|
|
188
|
+
const world = createElement(Fragment, null, content, createElement(MountEffectsReady, { key: 'vgai-mount-effects-ready' }));
|
|
189
|
+
const provided = game ? createElement(WorldProvider, { game }, world) : world;
|
|
190
|
+
root.render(provided);
|
|
191
|
+
// A reconcile-time crash (e.g. a missing `extend` catalogue entry)
|
|
192
|
+
// surfaces as an uncaught window error and `onCreated` never fires —
|
|
193
|
+
// without this guard, `mount()` would await `statePromise` FOREVER and
|
|
194
|
+
// silently wedge every world declared after this one (roots mount
|
|
195
|
+
// sequentially). Convert that class of failure into a loud mount error.
|
|
196
|
+
const state = await new Promise((resolve, reject) => {
|
|
197
|
+
const onError = (event) => {
|
|
198
|
+
cleanup();
|
|
199
|
+
root.unmount();
|
|
200
|
+
gameDebugRegistry?.strip(id);
|
|
201
|
+
reject(new Error(`three world "${id}": fiber crashed before its first commit — ${event.message} ` +
|
|
202
|
+
'(mount() fails loudly instead of hanging on onCreated)'));
|
|
203
|
+
};
|
|
204
|
+
const timer = setTimeout(() => {
|
|
205
|
+
cleanup();
|
|
206
|
+
root.unmount();
|
|
207
|
+
gameDebugRegistry?.strip(id);
|
|
208
|
+
reject(new Error(`three world "${id}": onCreated did not fire within 10s — the R3F tree never ` +
|
|
209
|
+
'reached its first commit (mount() fails loudly instead of hanging)'));
|
|
210
|
+
}, 10_000);
|
|
211
|
+
const cleanup = () => {
|
|
212
|
+
clearTimeout(timer);
|
|
213
|
+
window.removeEventListener('error', onError);
|
|
214
|
+
};
|
|
215
|
+
window.addEventListener('error', onError);
|
|
216
|
+
void Promise.all([statePromise, effectsReady]).then(([s]) => {
|
|
217
|
+
cleanup();
|
|
218
|
+
resolve(s);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
// Clock hardening: fiber's internal `update()` calls
|
|
222
|
+
// `state.clock.getDelta()` BEFORE its `frameloop:'never'` branch, and a
|
|
223
|
+
// RUNNING (or autoStart) `THREE.Clock` accumulates WALL time into
|
|
224
|
+
// `elapsedTime` as a side effect — skewing the deltas the 'never'
|
|
225
|
+
// branch derives from the game timestamps `update(dt)` feeds below.
|
|
226
|
+
// Stopped + autoStart=false makes `getDelta()` a pure no-op, so
|
|
227
|
+
// `useFrame` deltas come from game time alone.
|
|
228
|
+
state.clock.autoStart = false;
|
|
229
|
+
state.clock.stop();
|
|
230
|
+
// THE STORE IS THE TRUTH, NOT THE `onCreated` SNAPSHOT.
|
|
231
|
+
//
|
|
232
|
+
// `state` is one immutable zustand snapshot: every `set(...)` produces a
|
|
233
|
+
// NEW state object. A world that legitimately REPLACES the default
|
|
234
|
+
// camera — `useThree(s => s.set)({ camera })`, which is exactly what
|
|
235
|
+
// drei's `makeDefault` does — never reaches anything read off `state`
|
|
236
|
+
// directly, and fiber's own `update()` renders
|
|
237
|
+
// `state.gl.render(state.scene, state.camera)` off the state object it
|
|
238
|
+
// is HANDED. Measured: an authored `OrthographicCamera` world rendered
|
|
239
|
+
// from fiber's default (0, 0, 5) forever, culling every ground polygon.
|
|
240
|
+
// `state.get()` is fiber's own `StoreApi.getState`, carried on
|
|
241
|
+
// `RootState` for precisely this reason; it is a plain map read.
|
|
242
|
+
const live = () => state.get();
|
|
243
|
+
// --- Live render vitals, host-seeded ---------------------------------
|
|
244
|
+
// A running game must be able to explain its own frame cost through the
|
|
245
|
+
// debug registry, with no capability to install and nothing for a game
|
|
246
|
+
// to write. Gate: `devBuildEnabled()` (the ONE owner of "is this a dev
|
|
247
|
+
// context" — a ship build registers nothing and pays nothing) and a
|
|
248
|
+
// `Game` shell (the readings are folded out of that game's profiler
|
|
249
|
+
// frames). "Not headless" needs no term here: a headless mount returned
|
|
250
|
+
// above, before fiber was ever configured.
|
|
251
|
+
let renderVitals = null;
|
|
252
|
+
if (host.game && devBuildEnabled()) {
|
|
253
|
+
// The readings are folded out of profiler frames, and the profiler is
|
|
254
|
+
// a flag the editor already sets on play — under the dev gate this is
|
|
255
|
+
// the same cost arriving a little earlier, not a new one.
|
|
256
|
+
host.game.profiler.enabled = true;
|
|
257
|
+
if (!gameDebugRegistry) {
|
|
258
|
+
throw new Error(`three world "${id}": mounted Game has no debug registry.`);
|
|
259
|
+
}
|
|
260
|
+
renderVitals = registerRenderVitals({
|
|
261
|
+
registry: gameDebugRegistry,
|
|
262
|
+
worldId: id,
|
|
263
|
+
profiler: host.game.profiler,
|
|
264
|
+
scene: live().scene,
|
|
265
|
+
renderer: host.renderer,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
// Engine-owned render introspection, built over the SAME renderer and
|
|
269
|
+
// scene Fiber uses. A design-time stand-in has no context and a
|
|
270
|
+
// headless mount returned above, so both honestly omit the capability.
|
|
271
|
+
const rendererContext = typeof host.renderer.getContext === 'function' ? host.renderer.getContext() : undefined;
|
|
272
|
+
const frameCaptureContext = frameCaptureContextFor(false, rendererContext);
|
|
273
|
+
const frameCapture = frameCaptureContext
|
|
274
|
+
? createWebGLFrameCapture(frameCaptureContext)
|
|
275
|
+
: null;
|
|
276
|
+
const renderDebugWiring = frameCapture
|
|
277
|
+
? createRenderDebugAdapter({
|
|
278
|
+
capture: frameCapture,
|
|
279
|
+
scene: state.scene,
|
|
280
|
+
memory: () => collectRenderMemory(live().scene, host.renderer.info),
|
|
281
|
+
})
|
|
282
|
+
: null;
|
|
283
|
+
// The session debug registry is host instrumentation, not component
|
|
284
|
+
// context. Publishing the existing adapter here lets coverage and
|
|
285
|
+
// editor panels see the same door without wrapping the R3F tree. The
|
|
286
|
+
// world's own declared systems merge in from the entry module at the
|
|
287
|
+
// Game layer (`installDeclaredSystemAdapters`).
|
|
288
|
+
const systemAdapters = gameDebugRegistry
|
|
289
|
+
? { debug: gameDebugRegistry.adapter }
|
|
290
|
+
: {};
|
|
291
|
+
if (renderDebugWiring)
|
|
292
|
+
systemAdapters.renderDebug = renderDebugWiring.adapter;
|
|
293
|
+
// The engine drives every `useFrame` through the mounted world's
|
|
294
|
+
// `update(dt)` hook, never off a raw host-loop callback. That is the
|
|
295
|
+
// whole pause story: `runFrameImpl` (`runtime/game.ts`) calls
|
|
296
|
+
// `mounted.update?.(dt)` per substep for a host-driven world and SKIPS
|
|
297
|
+
// it while that world is frozen, and `Game.play.step()` ticks it exactly
|
|
298
|
+
// once.
|
|
299
|
+
//
|
|
300
|
+
// `advance(timestamp, runGlobalEffects, state)`'s `timestamp` is
|
|
301
|
+
// consumed as `THREE.Clock.elapsedTime` DIRECTLY when
|
|
302
|
+
// `frameloop:'never'` (verified against fiber's `update()`
|
|
303
|
+
// implementation): `delta = timestamp - clock.elapsedTime;
|
|
304
|
+
// clock.elapsedTime = timestamp`. So `timestamp` must be a
|
|
305
|
+
// monotonically increasing SECONDS value — GAME time, not wall time:
|
|
306
|
+
// `update` simply isn't called while this world is frozen, so
|
|
307
|
+
// accumulating its `dt` means fiber's clock does not advance across a
|
|
308
|
+
// pause. Wall clock would leak the pause duration into the first
|
|
309
|
+
// resumed frame as one giant `useFrame` delta.
|
|
310
|
+
let elapsed = 0;
|
|
311
|
+
return {
|
|
312
|
+
kind: 'three',
|
|
313
|
+
// Fiber's REAL `THREE.Scene`/`THREE.Camera`. Checkout development
|
|
314
|
+
// dedupes `three`; a registry-installed project and the prebundled
|
|
315
|
+
// editor can legitimately have distinct constructor identities, so
|
|
316
|
+
// editor boundaries recognize Three's stable structural brands
|
|
317
|
+
// (`isScene`, `isCamera`) rather than relying only on `instanceof`.
|
|
318
|
+
//
|
|
319
|
+
// Both are GETTERS over the live store, not snapshot fields: the host
|
|
320
|
+
// and the editor must see the camera the world actually renders
|
|
321
|
+
// through, including one the world swapped in after the first commit
|
|
322
|
+
// (see `live` above).
|
|
323
|
+
get scene() {
|
|
324
|
+
return live().scene;
|
|
325
|
+
},
|
|
326
|
+
get camera() {
|
|
327
|
+
return live().camera;
|
|
328
|
+
},
|
|
329
|
+
drivesOwnLoop: false,
|
|
330
|
+
// Adapter surface: `debug` (the shared game registry) and, when a real
|
|
331
|
+
// WebGL2 context exists, `renderDebug` are engine-seeded. The game's
|
|
332
|
+
// own capabilities arrive as the entry module's declared `systems`.
|
|
333
|
+
systems: systemAdapters,
|
|
334
|
+
update(dt) {
|
|
335
|
+
elapsed += dt;
|
|
336
|
+
const current = live();
|
|
337
|
+
renderDebugWiring?.beforeRender();
|
|
338
|
+
try {
|
|
339
|
+
const profiler = host.game?.profiler;
|
|
340
|
+
if (!profiler?.enabled) {
|
|
341
|
+
advance(elapsed, true, current);
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
// The frame's CPU submission cost, produced where the draw
|
|
345
|
+
// actually happens: `advance()` under `frameloop: 'never'` is what
|
|
346
|
+
// calls `gl.render(scene, camera)`, and this bracket is also what
|
|
347
|
+
// makes this frame a PRESENTATION as far as the vitals fold is
|
|
348
|
+
// concerned (`../dev/render-vitals.ts`: no bracket, no display
|
|
349
|
+
// frame). The profiler's phase clock is a stack, so bracketing
|
|
350
|
+
// here cannot truncate an enclosing phase.
|
|
351
|
+
const info = host.renderer.info;
|
|
352
|
+
const passesBefore = info?.render.frame ?? 0;
|
|
353
|
+
profiler.beginPhase();
|
|
354
|
+
try {
|
|
355
|
+
advance(elapsed, true, current);
|
|
356
|
+
}
|
|
357
|
+
finally {
|
|
358
|
+
profiler.endPhase(RENDER_SUBMIT_PHASE);
|
|
359
|
+
}
|
|
360
|
+
// Checked, not assumed — a duck-typed/design-time renderer
|
|
361
|
+
// supplies only what a mount needs, and a mount with no counters
|
|
362
|
+
// must report NOTHING rather than throw once per frame. `gpuMs` is
|
|
363
|
+
// null: this path runs no GPU timer, and the profiler keeps the
|
|
364
|
+
// previous reading rather than inventing a zero.
|
|
365
|
+
if (info) {
|
|
366
|
+
profiler.reportRender({
|
|
367
|
+
gpuMs: null,
|
|
368
|
+
drawCalls: info.render.calls,
|
|
369
|
+
triangles: info.render.triangles,
|
|
370
|
+
geometries: info.memory.geometries,
|
|
371
|
+
textures: info.memory.textures,
|
|
372
|
+
renderPasses: info.render.frame - passesBefore,
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
finally {
|
|
377
|
+
renderDebugWiring?.afterRender();
|
|
378
|
+
}
|
|
379
|
+
},
|
|
380
|
+
resize(width, height) {
|
|
381
|
+
live().setSize(width, height);
|
|
382
|
+
},
|
|
383
|
+
dispose() {
|
|
384
|
+
// Reject/restore a pending capture while its renderables still
|
|
385
|
+
// exist, then unmount component effects and strip this world's
|
|
386
|
+
// registrations.
|
|
387
|
+
renderDebugWiring?.dispose();
|
|
388
|
+
// The editor audits and disposes the mounted game's window/document
|
|
389
|
+
// realm immediately after `session.stop()`. Fiber's ordinary
|
|
390
|
+
// `unmount()` submits a concurrent reconciler update, so component
|
|
391
|
+
// effect cleanups could still be pending at that boundary and the
|
|
392
|
+
// audit would truthfully report/reclaim listeners the world was
|
|
393
|
+
// about to remove itself. Force the unmount COMMIT (not fiber's
|
|
394
|
+
// deliberately delayed renderer/context disposal) to finish here: a
|
|
395
|
+
// stopped root owns no live component lifecycle beyond this method.
|
|
396
|
+
flushSync(() => root.unmount());
|
|
397
|
+
// The vitals fold owns two `profiler.subscribe` registrations —
|
|
398
|
+
// ended here, before the strip, so a subscription can never outlive
|
|
399
|
+
// the door it feeds.
|
|
400
|
+
renderVitals?.dispose();
|
|
401
|
+
renderVitals = null;
|
|
402
|
+
// `strip(id)` ends this world's PROVIDERS AND COMMANDS (the
|
|
403
|
+
// render-vitals door above is the one this mount registers under
|
|
404
|
+
// `id`); sibling roots are untouched. It does NOT end the input
|
|
405
|
+
// seams `wireGameInputSeams` set — see that module's LIFETIME note.
|
|
406
|
+
gameDebugRegistry?.strip(id);
|
|
407
|
+
},
|
|
408
|
+
};
|
|
409
|
+
},
|
|
410
|
+
};
|
|
411
|
+
}
|
|
40
412
|
/**
|
|
41
413
|
* What a three entry module MEANS, in one place.
|
|
42
414
|
*
|
|
43
415
|
* Two callers need this answer and must never disagree about it: the runtime
|
|
44
416
|
* mount ({@link r3fRootFactory}, below) and the EDITOR's design session
|
|
45
417
|
* (`packages/editor/src/authoring/r3f-design-session.ts`), which design-mounts
|
|
46
|
-
* the same entry so edit mode authors the live fiber scene.
|
|
47
|
-
* runtime knew about the default-export shape, the converted starter played
|
|
48
|
-
* fine under `npm run game` and edit mode showed "(no scene loaded)".
|
|
418
|
+
* the same entry so edit mode authors the live fiber scene.
|
|
49
419
|
*
|
|
50
|
-
* Returns `null` when the module
|
|
51
|
-
* to do about that (the factory throws, the design
|
|
52
|
-
* and leaves existing paths alone).
|
|
420
|
+
* Returns `null` when the module has no default-exported component — the
|
|
421
|
+
* callers differ on what to do about that (the factory throws, the design
|
|
422
|
+
* session declines the world and leaves existing paths alone).
|
|
53
423
|
*/
|
|
54
424
|
export function resolveR3FEntryAdapter(entryModule, rootId) {
|
|
55
425
|
const mod = entryModule;
|
|
56
|
-
if (mod?.
|
|
57
|
-
return mod.
|
|
58
|
-
if (typeof mod?.default === 'function') {
|
|
59
|
-
return createR3FAdapter({
|
|
60
|
-
id: rootId,
|
|
61
|
-
content: createElement(mod.default),
|
|
62
|
-
// A default-exported R3F component is an ordinary R3F app. The host
|
|
63
|
-
// controls Fiber's native `frameloop: 'never'` scheduler; the vgai
|
|
64
|
-
// runtime CONTEXT (`useGameContext`, `ctx.systems`) never enters its
|
|
65
|
-
// React tree.
|
|
66
|
-
//
|
|
67
|
-
// This flag governs that context and nothing else. It does NOT govern
|
|
68
|
-
// `WorldProvider`: `useDebugProvider`/`useDebugCommand`/`useWorldState`
|
|
69
|
-
// are the documented door a game declares its own state/commands
|
|
70
|
-
// through, and they must work in the idiomatic shape — which is the
|
|
71
|
-
// ONLY shape `vgai create` scaffolds. `r3f-adapter.tsx` therefore keys
|
|
72
|
-
// that provider off `host.game`, not off this flag; see the comment
|
|
73
|
-
// beside `const game = host.game` there for the measured symptom
|
|
74
|
-
// (`vgai eval 'await game.providers()'` listing only built-ins).
|
|
75
|
-
engineRuntime: false,
|
|
76
|
-
});
|
|
77
|
-
}
|
|
426
|
+
if (typeof mod?.default === 'function')
|
|
427
|
+
return threeWorldAdapter(rootId, mod.default);
|
|
78
428
|
return null;
|
|
79
429
|
}
|
|
80
430
|
/**
|
|
81
431
|
* Register with `registerAdapter('three', r3fRootFactory)`.
|
|
82
|
-
*
|
|
83
|
-
* Resolution order mirrors `defaultThreeAdapterFactory`'s (explicit adapter
|
|
84
|
-
* wins) and then accepts the default-exported component.
|
|
85
432
|
*/
|
|
86
433
|
export const r3fRootFactory = (root, ctx) => {
|
|
87
434
|
const adapter = resolveR3FEntryAdapter(ctx.entryModule, root.id);
|
|
88
435
|
if (adapter)
|
|
89
436
|
return { kind: 'three', adapter };
|
|
90
437
|
throw new Error(`r3fRootFactory: entry module "${root.entry ?? '(none)'}" for root "${root.id}" must ` +
|
|
91
|
-
'default-export a React component (`export default function World() { … }`)
|
|
92
|
-
'an `adapter` for full control.');
|
|
438
|
+
'default-export a React component (`export default function World() { … }`).');
|
|
93
439
|
};
|
|
@@ -40,24 +40,27 @@
|
|
|
40
40
|
* is how a `SystemAdapters` member says "not supported".
|
|
41
41
|
*
|
|
42
42
|
* ── RESOURCE OWNERSHIP, STATED ONCE ─────────────────────────────────────────
|
|
43
|
-
* OWNER: this component owns
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
* a world that unmounts `<Physics>` leaves no stale adapter
|
|
54
|
-
*
|
|
43
|
+
* OWNER: this component owns one physics ADAPTER, published onto the ONE
|
|
44
|
+
* module-scoped slot `rapierPhysicsSystem()` forwards to (GAME-GLOBAL — the
|
|
45
|
+
* entry's static `systems.physics` declaration cannot be per-root). It allocates nothing
|
|
46
|
+
* else — no timers, no subscriptions, no per-frame work (see the phase note
|
|
47
|
+
* below), and no Rapier objects: the bodies it edits belong to `<Physics>`.
|
|
48
|
+
* SHARERS: every reader of `game.systemAdapters.physics` — the colliders
|
|
49
|
+
* instrument, the editor's transform coordination. They read; they never
|
|
50
|
+
* dispose.
|
|
51
|
+
* TEARDOWN: the effect's cleanup, and nothing else — it clears the SAME key
|
|
52
|
+
* and the slot it set (identity-guarded), so a play stop/start, a hot
|
|
53
|
+
* restart, or a world that unmounts `<Physics>` leaves no stale adapter
|
|
54
|
+
* behind. Because the slot is game-global, mount exactly ONE bridge per
|
|
55
|
+
* GAME: with two, the later mount wins the slot and the later unmount clears
|
|
56
|
+
* it while the earlier bridge is still live.
|
|
55
57
|
*
|
|
56
58
|
* PHASE ORDERING: none to respect — this component registers pull-only
|
|
57
59
|
* callbacks and runs no per-frame work. Every method it exposes is invoked
|
|
58
60
|
* BETWEEN frames, by an editor gesture or an instrument command, never from a
|
|
59
61
|
* phase.
|
|
60
62
|
*/
|
|
63
|
+
import type { PhysicsAdapter } from '../adapter/system-adapter';
|
|
61
64
|
export interface RapierPhysicsBridgeProps {
|
|
62
65
|
/**
|
|
63
66
|
* Set the `debug` prop of the SAME `<Physics>` this bridge is mounted in.
|
|
@@ -73,4 +76,13 @@ export interface RapierPhysicsBridgeProps {
|
|
|
73
76
|
* `useRapier()`). Renders nothing.
|
|
74
77
|
*/
|
|
75
78
|
export declare function RapierPhysicsBridge({ onDebugChange }: RapierPhysicsBridgeProps): null;
|
|
79
|
+
/**
|
|
80
|
+
* The `systems.physics` slot for a default-exported world — declare it from
|
|
81
|
+
* the entry (`export const systems = { physics: rapierPhysicsSystem() }`) and
|
|
82
|
+
* keep `<RapierPhysicsBridge />` mounted inside `<Physics>`. Every call
|
|
83
|
+
* forwards to the LIVE bridge adapter, so the static declaration answers with
|
|
84
|
+
* the running world's physics; while none is mounted, every call refuses by
|
|
85
|
+
* name rather than fabricating.
|
|
86
|
+
*/
|
|
87
|
+
export declare function rapierPhysicsSystem(): PhysicsAdapter;
|
|
76
88
|
//# sourceMappingURL=rapier-physics-bridge.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"rapier-physics-bridge.d.ts","sourceRoot":"","sources":["../../src/world3d-react/rapier-physics-bridge.tsx"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"rapier-physics-bridge.d.ts","sourceRoot":"","sources":["../../src/world3d-react/rapier-physics-bridge.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6DG;AASH,OAAO,KAAK,EACV,cAAc,EAKf,MAAM,2BAA2B,CAAC;AAEnC,MAAM,WAAW,wBAAwB;IACvC;;;;;OAKG;IACH,QAAQ,CAAC,aAAa,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;CAClD;AAkCD;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,EAAE,aAAa,EAAE,EAAE,wBAAwB,GAAG,IAAI,CA0OrF;AAqBD;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,IAAI,cAAc,CAyBpD"}
|