@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.
Files changed (87) hide show
  1. package/dist/adapter/manifest-interpreter.d.ts +59 -0
  2. package/dist/adapter/manifest-interpreter.d.ts.map +1 -0
  3. package/dist/adapter/manifest-interpreter.js +50 -0
  4. package/dist/adapter/native-debug-module.d.ts +45 -3
  5. package/dist/adapter/native-debug-module.d.ts.map +1 -1
  6. package/dist/adapter/native-debug-module.js +128 -10
  7. package/dist/adapter/renderer-config.d.ts +1 -1
  8. package/dist/adapter/renderer-config.js +1 -1
  9. package/dist/canvas-react/index.d.ts +5 -12
  10. package/dist/canvas-react/index.d.ts.map +1 -1
  11. package/dist/canvas-react/index.js +5 -12
  12. package/dist/canvas-react/pixi-react-root-factory.d.ts +34 -21
  13. package/dist/canvas-react/pixi-react-root-factory.d.ts.map +1 -1
  14. package/dist/canvas-react/pixi-react-root-factory.js +274 -36
  15. package/dist/core/countdown-timer.d.ts +118 -0
  16. package/dist/core/countdown-timer.d.ts.map +1 -0
  17. package/dist/core/countdown-timer.js +137 -0
  18. package/dist/core/deferred-commands.d.ts +127 -0
  19. package/dist/core/deferred-commands.d.ts.map +1 -0
  20. package/dist/core/deferred-commands.js +132 -0
  21. package/dist/core/sim-clock.d.ts +14 -2
  22. package/dist/core/sim-clock.d.ts.map +1 -1
  23. package/dist/core/sim-clock.js +14 -2
  24. package/dist/dev/instruments.d.ts +38 -11
  25. package/dist/dev/instruments.d.ts.map +1 -1
  26. package/dist/dev/instruments.js +81 -11
  27. package/dist/input/input-manager.d.ts +15 -0
  28. package/dist/input/input-manager.d.ts.map +1 -1
  29. package/dist/input/input-manager.js +15 -0
  30. package/dist/render/directional-shadow-fit.d.ts +88 -0
  31. package/dist/render/directional-shadow-fit.d.ts.map +1 -0
  32. package/dist/render/directional-shadow-fit.js +133 -0
  33. package/dist/render/environment-capture.d.ts +68 -0
  34. package/dist/render/environment-capture.d.ts.map +1 -0
  35. package/dist/render/environment-capture.js +90 -0
  36. package/dist/render/ibl-override-material.d.ts +97 -0
  37. package/dist/render/ibl-override-material.d.ts.map +1 -0
  38. package/dist/render/ibl-override-material.js +114 -0
  39. package/dist/runtime/game-input-seams.d.ts +51 -0
  40. package/dist/runtime/game-input-seams.d.ts.map +1 -0
  41. package/dist/runtime/game-input-seams.js +86 -0
  42. package/dist/runtime/game.d.ts +15 -0
  43. package/dist/runtime/game.d.ts.map +1 -1
  44. package/dist/runtime/game.js +40 -8
  45. package/dist/runtime/mount-game.d.ts.map +1 -1
  46. package/dist/runtime/mount-game.js +18 -2
  47. package/dist/runtime/mount-manifest.d.ts.map +1 -1
  48. package/dist/runtime/mount-manifest.js +4 -2
  49. package/dist/runtime/types.d.ts +1 -1
  50. package/dist/world3d-react/index.d.ts +15 -25
  51. package/dist/world3d-react/index.d.ts.map +1 -1
  52. package/dist/world3d-react/index.js +15 -25
  53. package/dist/world3d-react/r3f-root-factory.d.ts +19 -25
  54. package/dist/world3d-react/r3f-root-factory.d.ts.map +1 -1
  55. package/dist/world3d-react/r3f-root-factory.js +396 -50
  56. package/dist/world3d-react/rapier-physics-bridge.d.ts +24 -12
  57. package/dist/world3d-react/rapier-physics-bridge.d.ts.map +1 -1
  58. package/dist/world3d-react/rapier-physics-bridge.js +82 -49
  59. package/package.json +1 -1
  60. package/schemas/engine-capabilities.json +3 -3
  61. package/src/adapter/manifest-interpreter.ts +64 -0
  62. package/src/adapter/native-debug-module.ts +212 -15
  63. package/src/adapter/renderer-config.ts +1 -1
  64. package/src/canvas-react/index.ts +5 -21
  65. package/src/canvas-react/pixi-react-root-factory.tsx +301 -39
  66. package/src/core/countdown-timer.ts +188 -0
  67. package/src/core/deferred-commands.ts +174 -0
  68. package/src/core/sim-clock.ts +14 -2
  69. package/src/dev/instruments.ts +108 -11
  70. package/src/input/input-manager.ts +15 -0
  71. package/src/render/directional-shadow-fit.ts +156 -0
  72. package/src/render/environment-capture.ts +102 -0
  73. package/src/render/ibl-override-material.ts +170 -0
  74. package/src/runtime/game-input-seams.ts +108 -0
  75. package/src/runtime/game.ts +65 -7
  76. package/src/runtime/mount-game.ts +23 -1
  77. package/src/runtime/mount-manifest.ts +4 -2
  78. package/src/runtime/types.ts +1 -1
  79. package/src/world3d-react/index.ts +15 -31
  80. package/src/world3d-react/r3f-root-factory.tsx +444 -53
  81. package/src/world3d-react/rapier-physics-bridge.tsx +88 -49
  82. package/src/canvas-react/engine-bridge.ts +0 -59
  83. package/src/canvas-react/pixi-react-adapter.tsx +0 -356
  84. package/src/canvas-react/world-context.ts +0 -253
  85. package/src/world3d-react/engine-bridge.ts +0 -86
  86. package/src/world3d-react/r3f-adapter.tsx +0 -717
  87. 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
- * It does NOT write `export const adapter = createR3FAdapter({ id, content })`.
14
- * That export is pure vgai ceremony wrapped around a component the author
15
- * already wrote, and it is the second-largest non-idiomatic thing left in a
16
- * world file (the largest is the missing `<Canvas>`, which is a separate
17
- * question about who owns the canvas — see D26).
18
- *
19
- * The `dom` root already settled this convention: its factory reads
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
- * ## Compatibility
29
+ * ## The mount mechanics (fiber v9, host-gated)
34
30
  *
35
- * An `adapter` export still wins, so existing worlds are untouched and a game
36
- * needing full control over its own `RootAdapter` keeps it.
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 { createElement } from 'react';
39
- import { createR3FAdapter } from './r3f-adapter';
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. When only the
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 is neither shape — the callers differ on what
51
- * to do about that (the factory throws, the design session declines the world
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?.adapter)
57
- return mod.adapter;
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() { … }`) — or export ' +
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 exactly ONE `SystemAdapters.physics` registration,
44
- * on the R3F root it is mounted in. It allocates nothing else no timers, no
45
- * subscriptions, no per-frame work (see the phase note below), and no Rapier
46
- * objects: the bodies it edits belong to `<Physics>`.
47
- * SHARERS: every reader of `game.systemAdapters.physics` the
48
- * colliders instrument, the editor's transform coordination. They read; they
49
- * never dispose.
50
- * TEARDOWN: the effect's cleanup, and nothing else — it clears the SAME key it
51
- * set (`registerSystemAdapter('physics', undefined)`, which
52
- * `Game.computeSystemAdapters` skips), so a play stop/start, a hot restart, or
53
- * a world that unmounts `<Physics>` leaves no stale adapter behind. Two
54
- * bridges in one root would fight over that one key; mount exactly one.
43
+ * OWNER: this component owns one physics ADAPTER, published onto the ONE
44
+ * module-scoped slot `rapierPhysicsSystem()` forwards to (GAME-GLOBALthe
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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2DG;AAkBH,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,CA0PrF"}
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"}