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