@vgai/engine 0.5.14 → 0.5.16

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 (177) hide show
  1. package/dist/adapter/adapter-module.d.ts +228 -17
  2. package/dist/adapter/adapter-module.d.ts.map +1 -1
  3. package/dist/adapter/adapter-module.js +224 -42
  4. package/dist/adapter/authoring-seam-contract.d.ts +530 -0
  5. package/dist/adapter/authoring-seam-contract.d.ts.map +1 -0
  6. package/dist/adapter/authoring-seam-contract.js +162 -0
  7. package/dist/adapter/authoring.d.ts +237 -50
  8. package/dist/adapter/authoring.d.ts.map +1 -1
  9. package/dist/adapter/authoring.js +2 -1
  10. package/dist/adapter/entrypoint-selection-source.d.ts +35 -0
  11. package/dist/adapter/entrypoint-selection-source.d.ts.map +1 -0
  12. package/dist/adapter/entrypoint-selection-source.js +151 -0
  13. package/dist/adapter/finders/scenes-from-entrypoint-selection.d.ts +7 -1
  14. package/dist/adapter/finders/scenes-from-entrypoint-selection.d.ts.map +1 -1
  15. package/dist/adapter/finders/scenes-from-entrypoint-selection.js +142 -21
  16. package/dist/adapter/first-party-systems.d.ts +13 -3
  17. package/dist/adapter/first-party-systems.d.ts.map +1 -1
  18. package/dist/adapter/first-party-systems.js +10 -3
  19. package/dist/adapter/index.d.ts +7 -2
  20. package/dist/adapter/index.d.ts.map +1 -1
  21. package/dist/adapter/index.js +4 -0
  22. package/dist/adapter/ingest/contract-system-adapters.d.ts +1 -1
  23. package/dist/adapter/ingest/contract-system-adapters.d.ts.map +1 -1
  24. package/dist/adapter/ingest/contract-system-adapters.js +13 -25
  25. package/dist/adapter/ingest/game-contract-seams.d.ts +214 -0
  26. package/dist/adapter/ingest/game-contract-seams.d.ts.map +1 -0
  27. package/dist/adapter/ingest/game-contract-seams.js +62 -0
  28. package/dist/adapter/ingest/game-contract.d.ts +5 -3
  29. package/dist/adapter/ingest/game-contract.d.ts.map +1 -1
  30. package/dist/adapter/ingest/game-contract.js +4 -1
  31. package/dist/adapter/ingest/merge-debug-adapters.d.ts +52 -0
  32. package/dist/adapter/ingest/merge-debug-adapters.d.ts.map +1 -0
  33. package/dist/adapter/ingest/merge-debug-adapters.js +141 -0
  34. package/dist/adapter/ingest/observation-debug-adapter.d.ts +56 -0
  35. package/dist/adapter/ingest/observation-debug-adapter.d.ts.map +1 -0
  36. package/dist/adapter/ingest/observation-debug-adapter.js +131 -0
  37. package/dist/adapter/ingest/scene-capture.d.ts +38 -13
  38. package/dist/adapter/ingest/scene-capture.d.ts.map +1 -1
  39. package/dist/adapter/ingest/scene-capture.js +101 -24
  40. package/dist/adapter/ingest/visible-capture-window.d.ts +22 -5
  41. package/dist/adapter/ingest/visible-capture-window.d.ts.map +1 -1
  42. package/dist/adapter/ingest/visible-capture-window.js +46 -7
  43. package/dist/adapter/native-debug-module.d.ts +43 -0
  44. package/dist/adapter/native-debug-module.d.ts.map +1 -0
  45. package/dist/adapter/native-debug-module.js +230 -0
  46. package/dist/adapter/object3d-authoring-subject.d.ts +32 -0
  47. package/dist/adapter/object3d-authoring-subject.d.ts.map +1 -0
  48. package/dist/adapter/object3d-authoring-subject.js +25 -0
  49. package/dist/adapter/root-adapter.d.ts +21 -56
  50. package/dist/adapter/root-adapter.d.ts.map +1 -1
  51. package/dist/adapter/root-adapter.js +4 -20
  52. package/dist/adapter/root-seam-contract.d.ts +124 -0
  53. package/dist/adapter/root-seam-contract.d.ts.map +1 -0
  54. package/dist/adapter/root-seam-contract.js +36 -0
  55. package/dist/adapter/seam-evidence.d.ts +118 -0
  56. package/dist/adapter/seam-evidence.d.ts.map +1 -0
  57. package/dist/adapter/seam-evidence.js +231 -0
  58. package/dist/adapter/setup-three-root-adapter.d.ts +2 -3
  59. package/dist/adapter/setup-three-root-adapter.d.ts.map +1 -1
  60. package/dist/adapter/setup-three-root-adapter.js +2 -3
  61. package/dist/adapter/system-adapter.d.ts +6 -1
  62. package/dist/adapter/system-adapter.d.ts.map +1 -1
  63. package/dist/adapter/system-seam-contract.d.ts +354 -0
  64. package/dist/adapter/system-seam-contract.d.ts.map +1 -0
  65. package/dist/adapter/system-seam-contract.js +95 -0
  66. package/dist/animation/xstate-animation-binding.d.ts +6 -0
  67. package/dist/animation/xstate-animation-binding.d.ts.map +1 -1
  68. package/dist/animation/xstate-animation-binding.js +142 -4
  69. package/dist/asset-formats/collider.d.ts +1 -1
  70. package/dist/asset-formats/mesh.d.ts +2 -2
  71. package/dist/asset-formats/particles.d.ts +1 -1
  72. package/dist/canvas-react/engine-bridge.d.ts +6 -6
  73. package/dist/canvas-react/engine-bridge.d.ts.map +1 -1
  74. package/dist/canvas-react/engine-bridge.js +2 -2
  75. package/dist/canvas-react/index.d.ts +2 -2
  76. package/dist/canvas-react/index.d.ts.map +1 -1
  77. package/dist/canvas-react/index.js +1 -1
  78. package/dist/canvas-react/pixi-react-adapter.d.ts +15 -9
  79. package/dist/canvas-react/pixi-react-adapter.d.ts.map +1 -1
  80. package/dist/canvas-react/pixi-react-adapter.js +43 -29
  81. package/dist/canvas-react/pixi-react-root-factory.d.ts.map +1 -1
  82. package/dist/canvas-react/pixi-react-root-factory.js +4 -0
  83. package/dist/canvas-react/world-context.d.ts +22 -12
  84. package/dist/canvas-react/world-context.d.ts.map +1 -1
  85. package/dist/canvas-react/world-context.js +12 -51
  86. package/dist/ecs/user-data.d.ts +5 -0
  87. package/dist/ecs/user-data.d.ts.map +1 -1
  88. package/dist/ecs/user-data.js +3 -0
  89. package/dist/input/input-manager.d.ts +49 -0
  90. package/dist/input/input-manager.d.ts.map +1 -1
  91. package/dist/input/input-manager.js +127 -0
  92. package/dist/manifest/schema.d.ts +9 -9
  93. package/dist/manifest/schema.js +1 -1
  94. package/dist/pixi/authoring.d.ts.map +1 -1
  95. package/dist/pixi/authoring.js +220 -1
  96. package/dist/pixi/ingest.d.ts +2 -1
  97. package/dist/pixi/ingest.d.ts.map +1 -1
  98. package/dist/pixi/ingest.js +4 -2
  99. package/dist/pixi/scene-capture.d.ts +9 -1
  100. package/dist/pixi/scene-capture.d.ts.map +1 -1
  101. package/dist/pixi/scene-capture.js +9 -6
  102. package/dist/render/particles-factory.d.ts +13 -6
  103. package/dist/render/particles-factory.d.ts.map +1 -1
  104. package/dist/render/particles-factory.js +6 -3
  105. package/dist/runtime/create-runtime.d.ts +23 -46
  106. package/dist/runtime/create-runtime.d.ts.map +1 -1
  107. package/dist/runtime/create-runtime.js +22 -67
  108. package/dist/runtime/game.d.ts +29 -71
  109. package/dist/runtime/game.d.ts.map +1 -1
  110. package/dist/runtime/game.js +15 -54
  111. package/dist/runtime/mount-game.d.ts.map +1 -1
  112. package/dist/runtime/mount-game.js +12 -1
  113. package/dist/runtime/mount-manifest.d.ts.map +1 -1
  114. package/dist/runtime/mount-manifest.js +4 -5
  115. package/dist/runtime/types.d.ts +18 -0
  116. package/dist/runtime/types.d.ts.map +1 -1
  117. package/dist/setup/setup-renderer.d.ts +3 -4
  118. package/dist/setup/setup-renderer.d.ts.map +1 -1
  119. package/dist/setup/setup-renderer.js +3 -4
  120. package/dist/world3d-react/engine-bridge.d.ts +1 -1
  121. package/dist/world3d-react/engine-bridge.js +1 -1
  122. package/dist/world3d-react/r3f-adapter.d.ts +7 -0
  123. package/dist/world3d-react/r3f-adapter.d.ts.map +1 -1
  124. package/dist/world3d-react/r3f-adapter.js +50 -21
  125. package/dist/world3d-react/r3f-root-factory.d.ts.map +1 -1
  126. package/dist/world3d-react/r3f-root-factory.js +4 -0
  127. package/dist/world3d-react/world-context.d.ts +9 -3
  128. package/dist/world3d-react/world-context.d.ts.map +1 -1
  129. package/dist/world3d-react/world-context.js +5 -18
  130. package/package.json +1 -1
  131. package/schemas/engine-capabilities.json +8 -8
  132. package/schemas/vgai-project.schema.json +1 -1
  133. package/src/adapter/adapter-module.ts +407 -50
  134. package/src/adapter/authoring-seam-contract.ts +211 -0
  135. package/src/adapter/authoring.ts +242 -50
  136. package/src/adapter/entrypoint-selection-source.ts +159 -0
  137. package/src/adapter/finders/scenes-from-entrypoint-selection.ts +159 -28
  138. package/src/adapter/first-party-systems.ts +17 -3
  139. package/src/adapter/index.ts +51 -1
  140. package/src/adapter/ingest/contract-system-adapters.ts +26 -30
  141. package/src/adapter/ingest/game-contract-seams.ts +85 -0
  142. package/src/adapter/ingest/game-contract.ts +9 -4
  143. package/src/adapter/ingest/merge-debug-adapters.ts +193 -0
  144. package/src/adapter/ingest/observation-debug-adapter.ts +158 -0
  145. package/src/adapter/ingest/scene-capture.ts +126 -23
  146. package/src/adapter/ingest/visible-capture-window.ts +63 -9
  147. package/src/adapter/native-debug-module.ts +307 -0
  148. package/src/adapter/object3d-authoring-subject.ts +50 -0
  149. package/src/adapter/root-adapter.ts +21 -56
  150. package/src/adapter/root-seam-contract.ts +56 -0
  151. package/src/adapter/seam-evidence.ts +336 -0
  152. package/src/adapter/setup-three-root-adapter.ts +4 -6
  153. package/src/adapter/system-adapter.ts +6 -1
  154. package/src/adapter/system-seam-contract.ts +121 -0
  155. package/src/animation/xstate-animation-binding.ts +151 -4
  156. package/src/canvas-react/engine-bridge.ts +6 -6
  157. package/src/canvas-react/index.ts +2 -1
  158. package/src/canvas-react/pixi-react-adapter.tsx +55 -34
  159. package/src/canvas-react/pixi-react-root-factory.tsx +4 -0
  160. package/src/canvas-react/world-context.ts +26 -101
  161. package/src/ecs/user-data.ts +5 -0
  162. package/src/input/input-manager.ts +122 -0
  163. package/src/manifest/schema.ts +1 -1
  164. package/src/pixi/authoring.ts +236 -1
  165. package/src/pixi/ingest.ts +9 -2
  166. package/src/pixi/scene-capture.ts +20 -5
  167. package/src/render/particles-factory.ts +16 -6
  168. package/src/runtime/create-runtime.ts +41 -106
  169. package/src/runtime/game.ts +41 -111
  170. package/src/runtime/mount-game.ts +19 -1
  171. package/src/runtime/mount-manifest.ts +4 -5
  172. package/src/runtime/types.ts +22 -0
  173. package/src/setup/setup-renderer.ts +3 -4
  174. package/src/world3d-react/engine-bridge.ts +1 -1
  175. package/src/world3d-react/r3f-adapter.tsx +64 -24
  176. package/src/world3d-react/r3f-root-factory.tsx +4 -0
  177. package/src/world3d-react/world-context.ts +11 -36
@@ -1,11 +1,11 @@
1
1
  /**
2
2
  * Scene capture — the core primitive of unmodified-game ingestion.
3
3
  *
4
- * An external three.js game owns its own `WebGLRenderer`, `Scene`, camera, and
5
- * render loop. To let the editor inspect/edit that live scene WITHOUT touching
6
- * the game's code, we need a handle to the game's `Scene`+camera the moment it
7
- * first renders. The robust way to obtain it is an **accessor trap** on
8
- * `WebGLRenderer.prototype.render`:
4
+ * An external three.js game owns its own renderer, `Scene`, camera, and render
5
+ * loop. To let the editor inspect/edit that live scene WITHOUT touching the
6
+ * game's code, we need a handle to the game's `Scene`+camera the moment it
7
+ * first renders. For WebGL, the robust way to obtain it is an **accessor trap**
8
+ * on `WebGLRenderer.prototype.render`:
9
9
  *
10
10
  * - `WebGLRenderer` assigns `this.render` as an OWN instance property inside
11
11
  * its constructor (not on the prototype), and `THREE.WebGLRenderer` is a
@@ -20,12 +20,16 @@
20
20
  * (`docs/ingest-study-spike/vgai-ingest-adapter.js`), proven against an
21
21
  * unmodified `three.js/examples/games_fps` game.
22
22
  *
23
- * CRITICAL: the trap must be installed on the SAME `three` module instance the
24
- * game uses. In a bundler/dev-server that dedupes `three` (one `node_modules/
25
- * three`), an external ESM game's `import 'three'` resolves to that one instance,
26
- * so passing the host's `THREE` here traps the game's renderer too. A game that
27
- * bundles its own copy of `three` cannot be captured this way (the module-identity
28
- * gatekeeper).
23
+ * DOM-backed addon renderers use ordinary prototype methods instead. The host
24
+ * supplies those exact shared classes through `additionalRendererCtors`, and
25
+ * the same observer captures their `(scene, camera)` pair without inventing a
26
+ * second world model. CSS3DRenderer is the first implementer.
27
+ *
28
+ * CRITICAL: every trap must be installed on the SAME `three` module/addon
29
+ * instance the game uses. In a bundler/dev-server that dedupes `three` (one
30
+ * `node_modules/three`), an external ESM game's imports resolve to those shared
31
+ * classes. A game that bundles its own copy cannot be captured this way (the
32
+ * module-identity gatekeeper).
29
33
  *
30
34
  * Wave 17 adds an OPTIONAL, ADDITIVE composer capture: a game rendering through
31
35
  * its own three.js addon `EffectComposer`
@@ -51,20 +55,43 @@
51
55
 
52
56
  import type * as THREE from 'three';
53
57
  import {
58
+ documentVisibilityClock,
54
59
  startVisibleCaptureWindow,
55
60
  type VisibilityClock,
56
61
  type VisibleCaptureWindow,
57
62
  } from './visible-capture-window';
58
63
 
64
+ /** The renderer operations the ingest host may lawfully use after capture.
65
+ * WebGLRenderer supplies every member; DOM-backed Three addon renderers such as
66
+ * CSS3DRenderer deliberately omit the WebGL-only lifecycle operations. */
67
+ export interface CapturedThreeRenderer {
68
+ readonly domElement: HTMLElement;
69
+ readonly info?: THREE.WebGLInfo | undefined;
70
+ render(scene: THREE.Scene, camera: THREE.Camera): unknown;
71
+ setSize(width: number, height: number, updateStyle?: boolean): void;
72
+ setAnimationLoop?(callback: ((time: number) => void) | null): void;
73
+ dispose?(): void;
74
+ getPixelRatio?(): number;
75
+ getContext?(): unknown;
76
+ getRenderTarget?(): unknown;
77
+ }
78
+
59
79
  /** A live runtime captured from an external game on its first rendered frame. */
60
80
  export interface CapturedRuntime {
61
81
  scene: THREE.Scene;
62
82
  camera: THREE.Camera;
63
- renderer: THREE.WebGLRenderer;
83
+ renderer: CapturedThreeRenderer;
64
84
  }
65
85
 
66
86
  /** Options for {@link installSceneCapture}. */
67
87
  export interface SceneCaptureOptions {
88
+ /**
89
+ * Additional shared Three addon renderer classes whose prototype `render`
90
+ * method carries the same `(scene, camera)` pair as WebGLRenderer. CSS3DRenderer
91
+ * is the first implementer. The caller must pass the exact class its game
92
+ * imports; a bundled private copy remains deliberately uncapturable.
93
+ */
94
+ additionalRendererCtors?: readonly unknown[];
68
95
  /**
69
96
  * True when `renderer` is one the HOST constructed for its own drawing.
70
97
  *
@@ -230,7 +257,7 @@ export interface SceneCaptureHandle {
230
257
  getDrawCount(): number;
231
258
  /** The game's last (non-null) `setAnimationLoop` callback for a renderer, so the
232
259
  * host can pause (set null) and resume (re-set it) the game's own loop. */
233
- getAnimationLoop(renderer: THREE.WebGLRenderer): ((time: number) => void) | null;
260
+ getAnimationLoop(renderer: CapturedThreeRenderer): ((time: number) => void) | null;
234
261
  /**
235
262
  * Wave 17 (D-C3): resize every captured `EffectComposer` that renders
236
263
  * through the captured renderer to `w`×`h`, matching its pixel ratio to
@@ -265,6 +292,10 @@ interface EffectComposerCtorLike {
265
292
  prototype: { render?: (...args: unknown[]) => unknown };
266
293
  }
267
294
 
295
+ interface PrototypeRendererCtorLike {
296
+ prototype: { render?: (...args: unknown[]) => unknown };
297
+ }
298
+
268
299
  /**
269
300
  * Install the render accessor trap on `threeNamespace.WebGLRenderer.prototype`.
270
301
  * Pass the host's `three` module so the game (which shares it) is trapped.
@@ -318,7 +349,29 @@ export function installSceneCapture(
318
349
  // the GETTER returns a wrapper that records each non-null callback before forwarding.
319
350
  const REAL_SAL = Symbol('vgai.realSetAnimationLoop');
320
351
  const loopCallbacks = new WeakMap<object, (time: number) => void>();
352
+ const loopedRenderers = new Set<object>();
353
+ /** Visibility clock of the in-flight `waitForCapture`, if any. */
354
+ let waitingVisibility: VisibilityClock | null = null;
321
355
  const priorSAL = Object.getOwnPropertyDescriptor(proto, 'setAnimationLoop');
356
+
357
+ function pumpHiddenLoops(): void {
358
+ if (
359
+ captured ||
360
+ !waitingVisibility ||
361
+ !(waitingVisibility.suspended?.() ?? waitingVisibility.hidden())
362
+ ) {
363
+ return;
364
+ }
365
+ for (const renderer of loopedRenderers) {
366
+ try {
367
+ loopCallbacks.get(renderer)?.(0);
368
+ } catch {
369
+ /* a throwing game frame must not kill the waiter */
370
+ }
371
+ if (captured) return;
372
+ }
373
+ }
374
+
322
375
  Object.defineProperty(proto, 'setAnimationLoop', {
323
376
  configurable: true,
324
377
  set(this: Record<symbol, unknown>, fn: unknown) {
@@ -327,7 +380,14 @@ export function installSceneCapture(
327
380
  get(this: Record<symbol, unknown>) {
328
381
  const self = this;
329
382
  return function setAnimationLoop(this: unknown, cb: unknown) {
330
- if (cb) loopCallbacks.set(self as object, cb as (time: number) => void);
383
+ if (cb) {
384
+ loopCallbacks.set(self as object, cb as (time: number) => void);
385
+ loopedRenderers.add(self as object);
386
+ // Hidden tabs park rAF. Play/eval still need a first frame, so when
387
+ // a waiter is parked we drive the game's own loop once — the same
388
+ // class of tick `waitSimTime` already uses for a hidden document.
389
+ queueMicrotask(pumpHiddenLoops);
390
+ }
331
391
  const real = self[REAL_SAL];
332
392
  return typeof real === 'function'
333
393
  ? (real as (...a: unknown[]) => unknown).call(self, cb)
@@ -371,7 +431,7 @@ export function installSceneCapture(
371
431
  captured = {
372
432
  scene: scene as THREE.Scene,
373
433
  camera: camera as THREE.Camera,
374
- renderer: self as THREE.WebGLRenderer,
434
+ renderer: self as CapturedThreeRenderer,
375
435
  };
376
436
  opts?.onWorldAdoption?.({
377
437
  phase: 'adopted',
@@ -405,6 +465,17 @@ export function installSceneCapture(
405
465
  });
406
466
  }
407
467
 
468
+ /** Observe the common `(scene, camera)` render contract once, regardless of
469
+ * whether it came from WebGLRenderer's constructor-assigned method or an
470
+ * addon's ordinary prototype method. */
471
+ function observeRendererRender(self: unknown, scene: unknown, camera: unknown): void {
472
+ if (!captured || self === captured.renderer) drawCount++;
473
+ if (opts?.isHostRenderer?.(self) === true) return;
474
+ if (!(scene as { isScene?: boolean })?.isScene) return;
475
+ if (captured) recordAlternateWorld(self, scene, camera);
476
+ else adoptWorld(self, scene, camera);
477
+ }
478
+
408
479
  Object.defineProperty(proto, 'render', {
409
480
  configurable: true,
410
481
  set(this: Record<symbol, unknown>, fn: unknown) {
@@ -423,7 +494,6 @@ export function installSceneCapture(
423
494
  // `setAnimationLoop`, so they ARE gateable) and the instrument was
424
495
  // reporting someone else's frames. Before capture every render still
425
496
  // counts: that is how the first game frame is detected at all.
426
- if (!captured || (self as unknown) === captured.renderer) drawCount++;
427
497
  // Never capture a scene the HOST owns. The trap sits on the shared
428
498
  // `WebGLRenderer.prototype`, so the editor's own viewport renders
429
499
  // arrive here too — and when a game bundles its own MISMATCHED three,
@@ -434,15 +504,39 @@ export function installSceneCapture(
434
504
  // Whether that happened at all came down to whether the editor
435
505
  // rendered a frame inside the game's capture window, so the same
436
506
  // session could pass or fail on timing alone.
437
- if (opts?.isHostRenderer?.(self) !== true && (scene as { isScene?: boolean })?.isScene) {
438
- if (captured) recordAlternateWorld(self, scene, camera);
439
- else adoptWorld(self, scene, camera);
440
- }
507
+ observeRendererRender(self, scene, camera);
441
508
  return forwardRender(self, args);
442
509
  };
443
510
  },
444
511
  });
445
512
 
513
+ // DOM-backed Three renderers (CSS3DRenderer is the first) use a normal
514
+ // prototype method rather than WebGLRenderer's constructor assignment. Trap
515
+ // only classes explicitly supplied by the host, and restore each byte-for-byte.
516
+ const additionalRendererRestores: Array<() => void> = [];
517
+ for (const candidate of opts?.additionalRendererCtors ?? []) {
518
+ const ctor = candidate as PrototypeRendererCtorLike;
519
+ const rendererProto = ctor?.prototype;
520
+ const priorRender = rendererProto?.render;
521
+ if (!rendererProto || typeof priorRender !== 'function' || rendererProto === proto) continue;
522
+ rendererProto.render = function (this: unknown, ...args: unknown[]) {
523
+ observeRendererRender(this, args[0], args[1]);
524
+ const hooks = renderPassHooks;
525
+ if (hooks === null || !captured || this !== captured.renderer) {
526
+ return priorRender.apply(this, args);
527
+ }
528
+ hooks.before();
529
+ try {
530
+ return priorRender.apply(this, args);
531
+ } finally {
532
+ hooks.after();
533
+ }
534
+ };
535
+ additionalRendererRestores.push(() => {
536
+ rendererProto.render = priorRender;
537
+ });
538
+ }
539
+
446
540
  // ---- Wave 17 (D-C2): additive composer capture, only when a composer ctor
447
541
  // was passed. `EffectComposer` methods (including `render`) live on the
448
542
  // PROTOTYPE (a plain ES class — the constructor never does `this.render =
@@ -494,7 +588,8 @@ export function installSceneCapture(
494
588
  // touches `renderer.getPixelRatio()` — a non-composer game's captured
495
589
  // renderer need not even expose that method for this to stay a no-op.
496
590
  if (!captured || composers.size === 0) return;
497
- const pixelRatio = captured.renderer.getPixelRatio();
591
+ const pixelRatio = captured.renderer.getPixelRatio?.();
592
+ if (pixelRatio === undefined) return;
498
593
  for (const composer of composers) {
499
594
  composer.setSize(w, h);
500
595
  composer.setPixelRatio(pixelRatio);
@@ -517,12 +612,13 @@ export function installSceneCapture(
517
612
  onExpire: () => {
518
613
  const i = waiters.indexOf(wrapped);
519
614
  if (i >= 0) waiters.splice(i, 1);
615
+ waitingVisibility = null;
520
616
  opts.onWait?.(null);
521
617
  reject(
522
618
  new Error(
523
619
  `Scene capture timed out after ${timeoutMs}ms of VISIBLE time ` +
524
- `(${Math.round(captureWindow.elapsedHiddenMs())}ms hidden, which is not counted — ` +
525
- 'a hidden tab cannot render) — the game never rendered, ' +
620
+ `(${Math.round(captureWindow.elapsedHiddenMs())}ms browser-suspended, which is ` +
621
+ 'not counted because no frame can be presented) — the game never rendered, ' +
526
622
  'or it bundles its own (un-shared) copy of three.',
527
623
  ),
528
624
  );
@@ -530,11 +626,17 @@ export function installSceneCapture(
530
626
  });
531
627
  const wrapped = (rt: CapturedRuntime) => {
532
628
  captureWindow.cancel();
629
+ waitingVisibility = null;
533
630
  opts.onWait?.(null);
534
631
  resolve(rt);
535
632
  };
536
633
  waiters.push(wrapped);
634
+ waitingVisibility = opts.visibility ?? documentVisibilityClock();
537
635
  opts.onWait?.(captureWindow);
636
+ // A waiter that starts already-hidden (the normal `vgai play` path
637
+ // against a backgrounded tab) must not wait for a human to foreground
638
+ // it. Pump any loop the game has already registered.
639
+ queueMicrotask(pumpHiddenLoops);
538
640
  });
539
641
  },
540
642
  uninstall() {
@@ -577,6 +679,7 @@ export function installSceneCapture(
577
679
  // prototype method — no per-instance own-property to restore, unlike
578
680
  // the renderer/loop traps above).
579
681
  restoreComposerTrap();
682
+ for (const restore of additionalRendererRestores) restore();
580
683
  },
581
684
  };
582
685
  }
@@ -46,6 +46,16 @@ export interface VisibilityClock {
46
46
  now(): number;
47
47
  /** True while the document is hidden (no rAF, so no frame can be captured). */
48
48
  hidden(): boolean;
49
+ /**
50
+ * True while the browser is not presenting frames. This is wider than
51
+ * `hidden()`: WebKit also stops rAF for an unfocused window (and can suspend
52
+ * the page entirely) while `document.hidden` still reads false.
53
+ *
54
+ * Optional for compatibility with injected clocks; absent means `hidden()`.
55
+ */
56
+ suspended?(): boolean;
57
+ /** Why {@link suspended} is true, when the clock can say. */
58
+ suspensionReason?(): 'hidden' | 'unfocused' | 'page-suspended' | null;
49
59
  /** Subscribe to visibility transitions; returns the unsubscribe. */
50
60
  subscribe(onChange: () => void): () => void;
51
61
  }
@@ -55,13 +65,46 @@ export interface VisibilityClock {
55
65
  * honest answer for a headless caller — it has no tab to background. */
56
66
  export function documentVisibilityClock(): VisibilityClock {
57
67
  const doc = typeof document === 'undefined' ? null : document;
68
+ const win = doc?.defaultView ?? null;
69
+ let pageSuspended = false;
70
+ const reason = (): 'hidden' | 'unfocused' | 'page-suspended' | null => {
71
+ if (pageSuspended) return 'page-suspended';
72
+ if (doc?.hidden === true) return 'hidden';
73
+ // WebKit stops rAF when the browser window becomes inactive, including
74
+ // when another app is in front. Page Visibility can still say `visible`
75
+ // in that state. Focus is a platform fact, not a Safari/UA sniff, and in
76
+ // browsers that keep rendering while unfocused this merely parks the
77
+ // expiry clock until either a frame arrives or focus returns.
78
+ if (doc && typeof doc.hasFocus === 'function' && !doc.hasFocus()) return 'unfocused';
79
+ return null;
80
+ };
58
81
  return {
59
82
  now: () => (typeof performance === 'undefined' ? Date.now() : performance.now()),
60
83
  hidden: () => doc?.hidden === true,
84
+ suspended: () => reason() !== null,
85
+ suspensionReason: reason,
61
86
  subscribe(onChange) {
62
87
  if (!doc) return () => {};
88
+ const onPageHide = () => {
89
+ pageSuspended = true;
90
+ onChange();
91
+ };
92
+ const onPageShow = () => {
93
+ pageSuspended = false;
94
+ onChange();
95
+ };
63
96
  doc.addEventListener('visibilitychange', onChange);
64
- return () => doc.removeEventListener('visibilitychange', onChange);
97
+ win?.addEventListener('blur', onChange);
98
+ win?.addEventListener('focus', onChange);
99
+ win?.addEventListener('pagehide', onPageHide);
100
+ win?.addEventListener('pageshow', onPageShow);
101
+ return () => {
102
+ doc.removeEventListener('visibilitychange', onChange);
103
+ win?.removeEventListener('blur', onChange);
104
+ win?.removeEventListener('focus', onChange);
105
+ win?.removeEventListener('pagehide', onPageHide);
106
+ win?.removeEventListener('pageshow', onPageShow);
107
+ };
65
108
  },
66
109
  };
67
110
  }
@@ -137,19 +180,26 @@ export interface VisibleCaptureWindow {
137
180
  readonly budgetMs: number;
138
181
  /** Visible milliseconds spent so far. */
139
182
  elapsedVisibleMs(): number;
140
- /** Milliseconds this window has spent parked while hidden (not counted). */
183
+ /** Milliseconds this window has spent browser-suspended (not counted). */
141
184
  elapsedHiddenMs(): number;
142
- /** Whether the wait is parked right now because the document is hidden. */
185
+ /**
186
+ * Whether the document itself is hidden. Kept distinct from browser
187
+ * suspension so status never calls an unfocused, still-visible window hidden.
188
+ */
143
189
  isHidden(): boolean;
190
+ /** Whether the expiry budget is parked because the browser is not presenting frames. */
191
+ isSuspended(): boolean;
192
+ /** The browser condition parking the budget, when observable. */
193
+ suspensionReason(): 'hidden' | 'unfocused' | 'page-suspended' | null;
144
194
  /** Stop the timer and drop the visibility listener. Idempotent. */
145
195
  cancel(): void;
146
196
  }
147
197
 
148
198
  /**
149
199
  * Start a window that calls `onExpire` after `budgetMs` of VISIBLE time.
150
- * While the document is hidden the timer is disarmed entirely (so a throttled
151
- * background timer cannot fire it late either) and re-armed with the remaining
152
- * budget when the document comes back.
200
+ * While browser frame presentation is suspended the timer is disarmed entirely
201
+ * (so a throttled background timer cannot fire it late either) and re-armed
202
+ * with the remaining budget when frame presentation resumes.
153
203
  */
154
204
  export function startVisibleCaptureWindow(opts: {
155
205
  budgetMs: number;
@@ -164,7 +214,8 @@ export function startVisibleCaptureWindow(opts: {
164
214
  const setTimer = opts.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
165
215
  const clearTimer = opts.clearTimer ?? ((h) => clearTimeout(h as ReturnType<typeof setTimeout>));
166
216
 
167
- let state = beginVisibleBudget(opts.budgetMs, clock.now(), clock.hidden());
217
+ const suspended = () => clock.suspended?.() ?? clock.hidden();
218
+ let state = beginVisibleBudget(opts.budgetMs, clock.now(), suspended());
168
219
  let timer: unknown = null;
169
220
  let done = false;
170
221
 
@@ -192,7 +243,7 @@ export function startVisibleCaptureWindow(opts: {
192
243
 
193
244
  const unsubscribe = clock.subscribe(() => {
194
245
  if (done) return;
195
- state = applyVisibility(state, clock.now(), clock.hidden());
246
+ state = applyVisibility(state, clock.now(), suspended());
196
247
  arm();
197
248
  });
198
249
 
@@ -202,7 +253,10 @@ export function startVisibleCaptureWindow(opts: {
202
253
  budgetMs: opts.budgetMs,
203
254
  elapsedVisibleMs: () => visibleElapsedMs(state, clock.now()),
204
255
  elapsedHiddenMs: () => hiddenElapsedMs(state, clock.now()),
205
- isHidden: () => state.hidden,
256
+ isHidden: () => clock.hidden(),
257
+ isSuspended: () => state.hidden,
258
+ suspensionReason: () =>
259
+ state.hidden ? (clock.suspensionReason?.() ?? (clock.hidden() ? 'hidden' : null)) : null,
206
260
  cancel() {
207
261
  if (done) return;
208
262
  done = true;