@volter/editor-threejs 0.5.57

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.
@@ -0,0 +1,865 @@
1
+ /**
2
+ * Scene capture — the core primitive of unmodified-game ingestion.
3
+ *
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
+ *
10
+ * - `WebGLRenderer` assigns `this.render` as an OWN instance property inside
11
+ * its constructor (not on the prototype), and `THREE.WebGLRenderer` is a
12
+ * read-only module export (can't subclass-swap it). A naive "wrap render"
13
+ * hook therefore captures nothing.
14
+ * - Installing a getter/setter for `render` on the PROTOTYPE means the
15
+ * constructor's `this.render = realFn` hits our setter (the instance has no
16
+ * own `render` yet), we stash the real fn, and our getter returns a wrapper
17
+ * that captures `(scene, camera, renderer)` on the first real frame.
18
+ *
19
+ * This is the hardened, typed form of the `ingest-study` spike
20
+ * (`docs/ingest-study-spike/vgai-ingest-adapter.js`), proven against an
21
+ * unmodified `three.js/examples/games_fps` game.
22
+ *
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).
33
+ *
34
+ * There is an OPTIONAL, ADDITIVE composer capture: a game rendering through
35
+ * its own three.js addon `EffectComposer`
36
+ * (`three/examples/jsm/postprocessing/EffectComposer.js`) still trips the `render`
37
+ * trap above (its `RenderPass` calls `renderer.render(scene,camera)` internally),
38
+ * but the renderer holds no reference back to the composer, so the host previously
39
+ * could not resize the composer's own (intentionally non-1:1,
40
+ * progressively-downsampled — see `UnrealBloomPass`) render targets when the host
41
+ * resizes the game's pane. Unlike `WebGLRenderer`, `EffectComposer` is a plain ES
42
+ * class whose methods (including `render`) live on the PROTOTYPE, not assigned as
43
+ * own instance properties in the constructor — so a direct method-wrapper (no
44
+ * getter/ setter indirection) on `EffectComposer.prototype.render` is sufficient:
45
+ * it calls through to the real `render`, then — AFTER that call, so any nested
46
+ * `renderer.render()` the pass makes has already hit the trap above and set
47
+ * `captured` — records `this` (the composer instance) if its `.renderer` is the
48
+ * captured one. This is deduped/shared-trappable for the same reason
49
+ * `WebGLRenderer` is: `EffectComposer.js` is a FILE inside the same `three`
50
+ * package tree Vite's `resolve.dedupe: ['three', …]` already collapses to one
51
+ * instance — not a separate package with its own dedupe question. Proven live
52
+ * against the `bloom-composer` fixture
53
+ * (`docs/f13-bloom-composer-proof/record-fixed.mjs`).
54
+ */
55
+
56
+ import type * as THREE from 'three';
57
+ import {
58
+ type CaptureWaitOptions,
59
+ documentVisibilityClock,
60
+ startVisibleCaptureWindow,
61
+ type VisibilityClock,
62
+ } from './visible-capture-window';
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
+
79
+ /** A live runtime captured from an external game on its first rendered frame. */
80
+ export interface CapturedRuntime {
81
+ scene: THREE.Scene;
82
+ camera: THREE.Camera;
83
+ renderer: CapturedThreeRenderer;
84
+ }
85
+
86
+ /** Options for {@link installSceneCapture}. */
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[];
95
+ /**
96
+ * True when `renderer` is one the HOST constructed for its own drawing.
97
+ *
98
+ * The trap lives on the shared `WebGLRenderer.prototype` — that sharing is
99
+ * the whole mechanism — so the host's renders arrive here too, and the trap
100
+ * captures the first scene it sees. Any host renderer built after the trap
101
+ * installs (the editor bakes model thumbnails and asset previews on demand,
102
+ * each with its own offscreen renderer and its own little light rig) is a
103
+ * candidate to be captured AS THE GAME.
104
+ *
105
+ * That is what made the "a game bundling its own three is DETECTED, never
106
+ * silently mistaken for a capture" spec
107
+ * pass or fail on timing alone: whether a thumbnail happened to bake inside
108
+ * the game's capture window. When it did, the editor adopted its own
109
+ * preview scene as the game and the mount reported success.
110
+ *
111
+ * Identity, not shape, is the discriminator: a host renderer and a game
112
+ * renderer are the same class, both built after install, both drawing real
113
+ * scenes. The host is the only party that knows which is which, so it says
114
+ * so (`@vgai/threejs/viewport/renderer-ownership`).
115
+ */
116
+ isHostRenderer?: (renderer: unknown) => boolean;
117
+ /**
118
+ * The game's OWN declared world, read from its contract
119
+ * (`window.vgaiGame` — the host passes a reader, never a cached value, because
120
+ * the contract is declared by the game's modules and may not exist yet when
121
+ * the trap installs).
122
+ *
123
+ * When it answers non-null, the DECLARATION decides: only a render of that
124
+ * scene is adopted, and first-render-wins never runs. When it answers null —
125
+ * the case for every game that declares nothing — behaviour is unchanged and
126
+ * the adoption is reported as `measured`.
127
+ */
128
+ declaredScene?: () => unknown;
129
+ /**
130
+ * How the world was adopted, and every DISTINCT world seen afterwards.
131
+ *
132
+ * First-non-host-render-wins is a good measured default and a permanent,
133
+ * SILENT commitment: a splash scene, a shadow pre-pass, or a
134
+ * render-to-texture warm-up that happens to draw first is adopted as the game
135
+ * forever, and the real world that renders one frame later reaches no reader
136
+ * at all. This is that reader. It never changes which world is adopted — it
137
+ * makes the ambiguity a recorded fact (`packages/editor/src/world-adoption.ts`
138
+ * publishes it to `vgai status`).
139
+ *
140
+ * Post-processing games legitimately render several (scene, camera) pairs per
141
+ * frame, so alternates are INFORMATION, never an error. Host renders are
142
+ * excluded by the same `isHostRenderer` declaration the adoption itself uses.
143
+ */
144
+ onWorldAdoption?: (event: WorldAdoptionEvent) => void;
145
+ }
146
+
147
+ /**
148
+ * One world-adoption fact. `adopted` fires exactly once, when the trap commits
149
+ * to a (scene, camera, renderer) triple; `alternate` fires for each DISTINCT
150
+ * triple seen afterwards, up to {@link MAX_RECORDED_ALTERNATES}.
151
+ */
152
+ export type WorldAdoptionEvent =
153
+ | {
154
+ readonly phase: 'adopted';
155
+ /** `declared` = the contract named this scene; `measured` = first render won. */
156
+ readonly source: 'declared' | 'measured';
157
+ readonly sceneId: string;
158
+ readonly cameraId: string;
159
+ }
160
+ | {
161
+ readonly phase: 'alternate';
162
+ readonly sceneId: string;
163
+ readonly cameraId: string;
164
+ /** `false` ⇒ a SECOND renderer is drawing, which is the stronger signal. */
165
+ readonly sameRenderer: boolean;
166
+ /** Draws observed when this alternate first appeared — how far past the
167
+ * adoption it is, without a wall clock. */
168
+ readonly drawCount: number;
169
+ };
170
+
171
+ /**
172
+ * How many distinct alternates are recorded before the trap stops looking.
173
+ *
174
+ * The bound is the point: this runs inside the game's own render call, and a
175
+ * post-processing chain can present a new (scene, camera) pair every frame. A
176
+ * handful names the ambiguity; an unbounded set would turn a diagnostic into a
177
+ * leak on the hottest path in the process.
178
+ */
179
+ export const MAX_RECORDED_ALTERNATES = 8;
180
+
181
+ /** A three object's identity, as a string a status facet can carry. `type` is
182
+ * what a reader recognizes ("Scene", "PerspectiveCamera"); `uuid` is what
183
+ * makes two of the same type tellable apart. */
184
+ function objectId(value: unknown): string {
185
+ const obj = value as { type?: unknown; uuid?: unknown } | null | undefined;
186
+ const type = typeof obj?.type === 'string' ? obj.type : 'unknown';
187
+ const uuid = typeof obj?.uuid === 'string' ? obj.uuid : '(no uuid)';
188
+ return `${type}:${uuid}`;
189
+ }
190
+
191
+ /**
192
+ * Brackets ONE `render()` call the captured renderer makes.
193
+ *
194
+ * The trap already stands between an ingested game and its own
195
+ * `WebGLRenderer.render`, which is the only place a host can see the game's
196
+ * render pass begin and end — the game owns its loop, so nothing else in the
197
+ * editor gets a `finally` around it. That is exactly the bracket
198
+ * `dev/render-debug-adapter.ts`'s `beforeRender`/`afterRender` need, so the
199
+ * first-party `RenderDebugAdapter` works for an ingested game with no per-game
200
+ * shimming and no second capture mechanism.
201
+ *
202
+ * Neither hook may throw: they run inside the game's own render call, so an
203
+ * exception here would break the game's frame. The trap calls them in a
204
+ * `try`/`finally` for `after`, but a throwing `before` is the hook author's bug.
205
+ */
206
+ export interface RenderPassHooks {
207
+ before(): void;
208
+ after(): void;
209
+ }
210
+
211
+ /**
212
+ * Options for {@link SceneCaptureHandle.waitForCapture} — declared with the
213
+ * window they configure (`visible-capture-window.ts`), because they configure
214
+ * the WAIT and not this surface, and re-exported here for the callers that
215
+ * look for them beside `waitForCapture`.
216
+ */
217
+ export type { CaptureWaitOptions };
218
+
219
+ /**
220
+ * WHICH interception yielded the captured renderer. Not decoration: every
221
+ * coverage row the host prints names the mechanism it reached the game
222
+ * through, and the two are materially different — a `shared-three` game runs
223
+ * on the host's very `three` instance, while a `devtools-observer` game runs
224
+ * its OWN pinned revision and only its renderer is shared.
225
+ */
226
+ export type CaptureMechanism = 'shared-three' | 'devtools-observer';
227
+
228
+ /** Handle returned by {@link installSceneCapture}. */
229
+ export interface SceneCaptureHandle {
230
+ /** The captured runtime, or null until the game renders its first frame. */
231
+ readonly captured: CapturedRuntime | null;
232
+ /** How {@link captured} was reached; `null` while nothing is captured. */
233
+ readonly capturedVia: CaptureMechanism | null;
234
+ /**
235
+ * Install (or clear, with `null`) hooks bracketing every `render()` the
236
+ * CAPTURED renderer makes. Set AFTER capture, because the consumer
237
+ * (`RenderDebugAdapter`) is built from the captured scene and context. Only
238
+ * the captured renderer's renders are bracketed — the editor's own viewport
239
+ * renders come through the same trap and are not the game's frame.
240
+ */
241
+ setRenderPassHooks(hooks: RenderPassHooks | null): void;
242
+ /**
243
+ * Resolve once a scene+camera is captured.
244
+ *
245
+ * The timeout is a budget of **visible** time, not wall-clock time: a hidden
246
+ * document cannot render (the browser parks rAF), so counting hidden time
247
+ * against the game is counting time it was not allowed to use. The wait
248
+ * therefore PARKS while `document.hidden` and resumes on `visibilitychange`
249
+ * — see `visible-capture-window.ts` for the whole argument. Rejects only
250
+ * when the window is spent with the document VISIBLE.
251
+ */
252
+ waitForCapture(options?: number | CaptureWaitOptions): Promise<CapturedRuntime>;
253
+ /** Total `render()` calls observed through the trap (a liveness signal). */
254
+ getDrawCount(): number;
255
+ /** The game's last (non-null) `setAnimationLoop` callback for a renderer, so the
256
+ * host can pause (set null) and resume (re-set it) the game's own loop. */
257
+ getAnimationLoop(renderer: CapturedThreeRenderer): ((time: number) => void) | null;
258
+ /**
259
+ * Resize every captured `EffectComposer` that renders
260
+ * through the captured renderer to `w`×`h`, matching its pixel ratio to
261
+ * `renderer.getPixelRatio()` — the composer tracks whatever DPR policy the
262
+ * host already applies to the renderer; no separate knob. A no-op when no
263
+ * `effectComposerCtor` was passed to {@link installSceneCapture} (or no
264
+ * composer of that ctor has rendered through the captured renderer yet) —
265
+ * a non-composer game is unaffected.
266
+ */
267
+ resizeComposers(w: number, h: number): void;
268
+ /** Remove the trap and restore the captured renderer's real `render`. */
269
+ uninstall(): void;
270
+ }
271
+
272
+ // Minimal structural type for the bits of the THREE namespace we touch, so this
273
+ // module never imports a concrete `three` (it must trap the CALLER's instance).
274
+ interface ThreeLike {
275
+ WebGLRenderer: { prototype: Record<string, unknown> };
276
+ }
277
+
278
+ /**
279
+ * A three renderer instance reached through three's OWN devtools seam, rather
280
+ * than through a prototype the host shares with the game.
281
+ *
282
+ * Only the two constructor-assigned own methods this file already traps on the
283
+ * shared prototype are named — nothing else about a foreign instance is
284
+ * assumed, because nothing else about it is known.
285
+ */
286
+ interface ForeignRendererLike {
287
+ render?: unknown;
288
+ setAnimationLoop?: unknown;
289
+ domElement?: unknown;
290
+ }
291
+
292
+ /** What {@link observeForeignThreeRenderers} needs from the enclosing capture,
293
+ * passed in rather than closed over so this stays a top-level function. */
294
+ interface ForeignObserverPorts {
295
+ /** True when this renderer's `render` was already intercepted by the shared
296
+ * prototype trap — i.e. it belongs to the host's OWN `three`, which is
297
+ * already captured properly and must not be double-wrapped. */
298
+ alreadyTrapped(renderer: object): boolean;
299
+ isHostRenderer(renderer: unknown): boolean;
300
+ /** The same `(self, scene, camera)` observation the prototype trap makes. */
301
+ observeRender(self: unknown, real: (...args: unknown[]) => unknown, args: unknown[]): unknown;
302
+ /** Record a non-null animation-loop callback (hidden-tab pumping). */
303
+ recordLoop(renderer: object, callback: (time: number) => void): void;
304
+ }
305
+
306
+ /**
307
+ * FOREIGN-INSTANCE CAPTURE — through three's own `__THREE_DEVTOOLS__` seam.
308
+ *
309
+ * The prototype trap above can only reach a game that resolves `three` to the
310
+ * host's instance ("the module-identity gatekeeper" in this file's header). A
311
+ * game vendored as a BUILT BUNDLE routinely does not: a webpack/CRA build
312
+ * inlines three outright, and a Vite build externalized to the game's own
313
+ * pinned `three-rNNN.module.js` loads a second ES module. Those games rendered
314
+ * a real picture the host could not see, and the mount failed by name.
315
+ *
316
+ * The canvas lane already answers this exact question and answers it the same
317
+ * way: `authoring/canvas-runtime-recognition.ts` recognizes Phaser and Babylon
318
+ * through THEIR OWN documented public registries (`Phaser.GAMES`,
319
+ * `BABYLON.Engine.Instances`) rather than by sharing a module instance. three's
320
+ * equivalent public registry is `__THREE_DEVTOOLS__`: every `WebGLRenderer`
321
+ * constructor since r118 ends with
322
+ *
323
+ * if ( typeof __THREE_DEVTOOLS__ !== 'undefined' )
324
+ * __THREE_DEVTOOLS__.dispatchEvent( new CustomEvent( 'observe', { detail: this } ) );
325
+ *
326
+ * — a bare global lookup, so it is not affected by the game-globals lexical
327
+ * shadow, and it is present in every revision this repo vendors (r129, r155,
328
+ * r170) as well as the host's own r180. Setting that global before the game's
329
+ * modules evaluate therefore hands the host the game's renderer INSTANCE, and
330
+ * an own-property wrapper on that instance's `render` yields the identical
331
+ * `(scene, camera, renderer)` triple the prototype trap yields.
332
+ *
333
+ * This never changes what happens for a deduped game: such a renderer's own
334
+ * `render` assignment was already intercepted by the prototype setter, so
335
+ * `alreadyTrapped` skips it and there is exactly one interception per renderer.
336
+ *
337
+ * Returns its own teardown. If something else already installed
338
+ * `__THREE_DEVTOOLS__` (the real three.js devtools extension), we listen on it
339
+ * and leave it in place.
340
+ */
341
+ function observeForeignThreeRenderers(ports: ForeignObserverPorts): () => void {
342
+ const host = globalThis as { __THREE_DEVTOOLS__?: EventTarget };
343
+ if (typeof EventTarget !== 'function') return () => undefined;
344
+ const weInstalled = host.__THREE_DEVTOOLS__ === undefined;
345
+ if (weInstalled) host.__THREE_DEVTOOLS__ = new EventTarget();
346
+ const target = host.__THREE_DEVTOOLS__;
347
+ if (!target) return () => undefined;
348
+ const restores: Array<() => void> = [];
349
+
350
+ function wrapOwn(instance: object, key: 'render' | 'setAnimationLoop', value: unknown): void {
351
+ const prior = (instance as Record<string, unknown>)[key];
352
+ Object.defineProperty(instance, key, {
353
+ configurable: true,
354
+ writable: true,
355
+ value,
356
+ });
357
+ restores.push(() => {
358
+ Object.defineProperty(instance, key, { configurable: true, writable: true, value: prior });
359
+ });
360
+ }
361
+
362
+ const listener = (event: Event): void => {
363
+ const detail = (event as CustomEvent<unknown>).detail as ForeignRendererLike | null;
364
+ // `observe` also carries Scenes (r129/r155/r170 dispatch from the `Scene`
365
+ // constructor too). A renderer is the one that owns a canvas and a render
366
+ // method; anything else is not this observer's subject.
367
+ if (!detail || typeof detail !== 'object') return;
368
+ if (typeof detail.render !== 'function' || !detail.domElement) return;
369
+ if (ports.alreadyTrapped(detail)) return;
370
+ if (ports.isHostRenderer(detail)) return;
371
+ const realRender = detail.render as (...args: unknown[]) => unknown;
372
+ wrapOwn(detail, 'render', function (this: unknown, ...args: unknown[]) {
373
+ return ports.observeRender(detail, realRender, args);
374
+ });
375
+ const realSal = detail.setAnimationLoop;
376
+ if (typeof realSal === 'function') {
377
+ const sal = realSal as (...args: unknown[]) => unknown;
378
+ wrapOwn(detail, 'setAnimationLoop', function (this: unknown, callback: unknown) {
379
+ if (typeof callback === 'function') {
380
+ ports.recordLoop(detail, callback as (time: number) => void);
381
+ }
382
+ return sal.call(detail, callback);
383
+ });
384
+ }
385
+ };
386
+
387
+ target.addEventListener('observe', listener);
388
+ return () => {
389
+ target.removeEventListener('observe', listener);
390
+ for (const restore of restores.splice(0)) restore();
391
+ if (weInstalled) delete host.__THREE_DEVTOOLS__;
392
+ };
393
+ }
394
+
395
+ // Minimal structural type for the bits of an `EffectComposer` instance we
396
+ // touch. This module never imports a concrete `three/examples/jsm/…` addon
397
+ // either — the caller passes the SAME class its ingested game imports, so the
398
+ // trap lands on the instance the game actually constructs.
399
+ interface ComposerLike {
400
+ renderer?: unknown;
401
+ setSize(width: number, height: number): void;
402
+ setPixelRatio(pixelRatio: number): void;
403
+ }
404
+
405
+ interface EffectComposerCtorLike {
406
+ prototype: { render?: (...args: unknown[]) => unknown };
407
+ }
408
+
409
+ interface PrototypeRendererCtorLike {
410
+ prototype: { render?: (...args: unknown[]) => unknown };
411
+ }
412
+
413
+ /**
414
+ * Install the render accessor trap on `threeNamespace.WebGLRenderer.prototype`.
415
+ * Pass the host's `three` module so the game (which shares it) is trapped.
416
+ *
417
+ * `effectComposerCtor` is OPTIONAL: pass the host's
418
+ * `EffectComposer` class (`three/examples/jsm/postprocessing/EffectComposer.js`)
419
+ * to also trap composer construction/rendering on that shared addon, so
420
+ * `resizeComposers()` can reach it. Omitting it (or a game never constructing
421
+ * one) leaves capture byte-identical to before this wave.
422
+ *
423
+ * Idempotent per call site is NOT guaranteed — install once per ingest session
424
+ * and `uninstall()` on teardown.
425
+ */
426
+ export function installSceneCapture(
427
+ threeNamespace: unknown,
428
+ effectComposerCtor?: unknown,
429
+ opts?: SceneCaptureOptions,
430
+ ): SceneCaptureHandle {
431
+ const THREE_NS = threeNamespace as ThreeLike;
432
+ const proto = THREE_NS.WebGLRenderer.prototype;
433
+
434
+ // Stash the real render fn per-instance under a unique symbol so multiple
435
+ // renderers (editor's + game's) never collide.
436
+ const REAL = Symbol('vgai.realRender');
437
+
438
+ let captured: CapturedRuntime | null = null;
439
+ let drawCount = 0;
440
+ /** Memoized answer of `opts.declaredScene` — asked each render until it
441
+ * answers, because a game declares its contract from its own modules and may
442
+ * not have run yet when the trap installs. Once it answers, it is fixed:
443
+ * a declaration that changes mid-boot is not a thing the host chases. */
444
+ let declaredScene: unknown = null;
445
+ /** Distinct alternates already reported, keyed by the same triple identity the
446
+ * event carries — see {@link MAX_RECORDED_ALTERNATES} for why it is bounded. */
447
+ const seenAlternates = new Set<string>();
448
+ /** Set post-capture by the host; see {@link SceneCaptureHandle.setRenderPassHooks}. */
449
+ let renderPassHooks: RenderPassHooks | null = null;
450
+ const waiters: Array<(rt: CapturedRuntime) => void> = [];
451
+
452
+ // Preserve any descriptor already on the prototype so uninstall can restore it.
453
+ const priorDescriptor = Object.getOwnPropertyDescriptor(proto, 'render');
454
+
455
+ // Trap setAnimationLoop to record each renderer's game loop callback, so the host
456
+ // can freeze (set null) and resume (re-set) the game's own rAF for stable editing.
457
+ // CRITICAL: like `render`, WebGLRenderer assigns `this.setAnimationLoop` as an OWN
458
+ // instance property in its constructor — NOT on the prototype. A naive value-wrapper
459
+ // on the prototype is therefore shadowed by the instance's own property and never
460
+ // runs (resume could never recover the callback). So we mirror the `render` trap: a
461
+ // prototype getter/setter. The constructor's `this.setAnimationLoop = realFn` hits
462
+ // our SETTER (the instance has no own property yet) and we stash realFn per-instance;
463
+ // the GETTER returns a wrapper that records each non-null callback before forwarding.
464
+ const REAL_SAL = Symbol('vgai.realSetAnimationLoop');
465
+ const loopCallbacks = new WeakMap<object, (time: number) => void>();
466
+ const loopedRenderers = new Set<object>();
467
+ /** Visibility clock of the in-flight `waitForCapture`, if any. */
468
+ let waitingVisibility: VisibilityClock | null = null;
469
+ const priorSAL = Object.getOwnPropertyDescriptor(proto, 'setAnimationLoop');
470
+
471
+ function pumpHiddenLoops(): void {
472
+ if (
473
+ captured ||
474
+ !waitingVisibility ||
475
+ !(waitingVisibility.suspended?.() ?? waitingVisibility.hidden())
476
+ ) {
477
+ return;
478
+ }
479
+ for (const renderer of loopedRenderers) {
480
+ try {
481
+ loopCallbacks.get(renderer)?.(0);
482
+ } catch {
483
+ /* a throwing game frame must not kill the waiter */
484
+ }
485
+ if (captured) return;
486
+ }
487
+ }
488
+
489
+ Object.defineProperty(proto, 'setAnimationLoop', {
490
+ configurable: true,
491
+ set(this: Record<symbol, unknown>, fn: unknown) {
492
+ this[REAL_SAL] = fn;
493
+ },
494
+ get(this: Record<symbol, unknown>) {
495
+ const self = this;
496
+ return function setAnimationLoop(this: unknown, cb: unknown) {
497
+ if (cb) recordAnimationLoop(self as object, cb as (time: number) => void);
498
+ const real = self[REAL_SAL];
499
+ return typeof real === 'function'
500
+ ? (real as (...a: unknown[]) => unknown).call(self, cb)
501
+ : undefined;
502
+ };
503
+ },
504
+ });
505
+
506
+ /**
507
+ * Call the renderer's own real `render`, bracketed by the render-pass hooks
508
+ * when this IS the captured game's renderer (see `setRenderPassHooks`).
509
+ * `after` runs in a `finally`, so a throwing game frame still restores
510
+ * whatever the hook armed.
511
+ */
512
+ function bracketedRender(
513
+ self: unknown,
514
+ real: (...a: unknown[]) => unknown,
515
+ args: unknown[],
516
+ ): unknown {
517
+ const hooks = renderPassHooks;
518
+ if (hooks === null || !captured || self !== captured.renderer) {
519
+ return real.apply(self, args);
520
+ }
521
+ hooks.before();
522
+ try {
523
+ return real.apply(self, args);
524
+ } finally {
525
+ hooks.after();
526
+ }
527
+ }
528
+
529
+ function forwardRender(self: Record<symbol, unknown>, args: unknown[]): unknown {
530
+ return bracketedRender(self, self[REAL] as (...a: unknown[]) => unknown, args);
531
+ }
532
+
533
+ /** Register a game's animation-loop callback, whichever trap saw it. */
534
+ function recordAnimationLoop(renderer: object, callback: (time: number) => void): void {
535
+ loopCallbacks.set(renderer, callback);
536
+ loopedRenderers.add(renderer);
537
+ // Hidden tabs park rAF. Play/eval still need a first frame, so when a
538
+ // waiter is parked we drive the game's own loop once — the same class of
539
+ // tick `waitSimTime` already uses for a hidden document.
540
+ queueMicrotask(pumpHiddenLoops);
541
+ }
542
+
543
+ /**
544
+ * Commit to a world, DECLARATION FIRST.
545
+ *
546
+ * First-non-host-render-wins is the measured default and a permanent one, so
547
+ * a game that states which scene is its world is not made to race its own
548
+ * splash screen. `declaredScene` is asked until it answers (the game's own
549
+ * modules declare the contract, and may not have run when the trap installed);
550
+ * once it does, only that scene is adopted.
551
+ */
552
+ function adoptWorld(self: unknown, scene: unknown, camera: unknown): void {
553
+ if (declaredScene === null) declaredScene = opts?.declaredScene?.() ?? null;
554
+ if (declaredScene !== null && declaredScene !== scene) return;
555
+ captured = {
556
+ scene: scene as THREE.Scene,
557
+ camera: camera as THREE.Camera,
558
+ renderer: self as CapturedThreeRenderer,
559
+ };
560
+ opts?.onWorldAdoption?.({
561
+ phase: 'adopted',
562
+ source: declaredScene === null ? 'measured' : 'declared',
563
+ sceneId: objectId(scene),
564
+ cameraId: objectId(camera),
565
+ });
566
+ for (const resolve of waiters.splice(0)) resolve(captured);
567
+ }
568
+
569
+ /**
570
+ * A DISTINCT world drew after the adopted one. Recorded, never acted on:
571
+ * which world is adopted does not change (that would break every handle the
572
+ * host already built from it) — but the reader stops being the only party who
573
+ * could have noticed. Deduped and bounded because this is the game's own
574
+ * render call.
575
+ */
576
+ function recordAlternateWorld(self: unknown, scene: unknown, camera: unknown): void {
577
+ if (!captured || seenAlternates.size >= MAX_RECORDED_ALTERNATES) return;
578
+ if (scene === captured.scene && camera === captured.camera) return;
579
+ const sameRenderer = self === captured.renderer;
580
+ const key = `${objectId(scene)}|${objectId(camera)}|${sameRenderer}`;
581
+ if (seenAlternates.has(key)) return;
582
+ seenAlternates.add(key);
583
+ opts?.onWorldAdoption?.({
584
+ phase: 'alternate',
585
+ sceneId: objectId(scene),
586
+ cameraId: objectId(camera),
587
+ sameRenderer,
588
+ drawCount,
589
+ });
590
+ }
591
+
592
+ /** Observe the common `(scene, camera)` render contract once, regardless of
593
+ * whether it came from WebGLRenderer's constructor-assigned method or an
594
+ * addon's ordinary prototype method. */
595
+ function observeRendererRender(self: unknown, scene: unknown, camera: unknown): void {
596
+ if (!captured || self === captured.renderer) drawCount++;
597
+ if (opts?.isHostRenderer?.(self) === true) return;
598
+ if (!(scene as { isScene?: boolean })?.isScene) return;
599
+ if (captured) recordAlternateWorld(self, scene, camera);
600
+ else adoptWorld(self, scene, camera);
601
+ }
602
+
603
+ Object.defineProperty(proto, 'render', {
604
+ configurable: true,
605
+ set(this: Record<symbol, unknown>, fn: unknown) {
606
+ this[REAL] = fn;
607
+ },
608
+ get(this: Record<symbol, unknown>) {
609
+ const self = this;
610
+ return function render(this: unknown, ...args: unknown[]) {
611
+ const [scene, camera] = args;
612
+ // Attribute the draw to the GAME, not to whoever shares this prototype.
613
+ // The trap patches `WebGLRenderer.prototype.render` on the editor's own
614
+ // three, so the EDITOR viewport's renders land here too. Counting them
615
+ // made `getDrawCount()` climb while the ingested game was paused, which
616
+ // is what the pause assertions in `14-ingest-feature-matrix` were
617
+ // measuring — the loop gate was doing its job (both games use
618
+ // `setAnimationLoop`, so they ARE gateable) and the instrument was
619
+ // reporting someone else's frames. Before capture every render still
620
+ // counts: that is how the first game frame is detected at all.
621
+ // Never capture a scene the HOST owns. The trap sits on the shared
622
+ // `WebGLRenderer.prototype`, so the editor's own viewport renders
623
+ // arrive here too — and when a game bundles its own MISMATCHED three,
624
+ // the editor's scene is then the only thing that ever reaches the trap.
625
+ // It was duly "captured" as the game: the editor got a hierarchy of
626
+ // its own GridHelper, BatchedRenderer and viewport lights presented as
627
+ // the ingested game's content, and the mount reported success.
628
+ // Whether that happened at all came down to whether the editor
629
+ // rendered a frame inside the game's capture window, so the same
630
+ // session could pass or fail on timing alone.
631
+ observeRendererRender(self, scene, camera);
632
+ return forwardRender(self, args);
633
+ };
634
+ },
635
+ });
636
+
637
+ // A game that does NOT share the host's `three` never reaches the prototype
638
+ // trap above. three's own devtools seam reaches it anyway — see
639
+ // {@link observeForeignThreeRenderers}.
640
+ const foreignRenderers = new WeakSet<object>();
641
+ const stopForeignObserver = observeForeignThreeRenderers({
642
+ alreadyTrapped: (renderer) => (renderer as Record<symbol, unknown>)[REAL] !== undefined,
643
+ isHostRenderer: (renderer) => opts?.isHostRenderer?.(renderer) === true,
644
+ observeRender: (self, real, args) => {
645
+ foreignRenderers.add(self as object);
646
+ observeRendererRender(self, args[0], args[1]);
647
+ return bracketedRender(self, real, args);
648
+ },
649
+ recordLoop: recordAnimationLoop,
650
+ });
651
+
652
+ // DOM-backed Three renderers (CSS3DRenderer is the first): trap only classes
653
+ // explicitly supplied by the host, and restore each byte-for-byte. Two
654
+ // shapes exist across three revisions and both are met: an ordinary
655
+ // prototype `render` is wrapped in place; a CONSTRUCTOR-ASSIGNED `render`
656
+ // (r180's CSS3DRenderer does `this.render = function …` exactly like
657
+ // WebGLRenderer, so the prototype carries none) gets the same
658
+ // getter/setter trap the WebGLRenderer path uses — the constructor's
659
+ // assignment hits the setter, the getter hands back the observing wrapper.
660
+ // Skipping that shape silently was measured as "the game never rendered"
661
+ // on css3d_periodictable while 118 of its elements sat in the DOM.
662
+ const additionalRendererRestores: Array<() => void> = [];
663
+ for (const candidate of opts?.additionalRendererCtors ?? []) {
664
+ const ctor = candidate as PrototypeRendererCtorLike;
665
+ const rendererProto = ctor?.prototype;
666
+ if (!rendererProto || rendererProto === proto) continue;
667
+ const priorRender = rendererProto.render;
668
+ if (typeof priorRender === 'function') {
669
+ rendererProto.render = function (this: unknown, ...args: unknown[]) {
670
+ observeRendererRender(this, args[0], args[1]);
671
+ const hooks = renderPassHooks;
672
+ if (hooks === null || !captured || this !== captured.renderer) {
673
+ return priorRender.apply(this, args);
674
+ }
675
+ hooks.before();
676
+ try {
677
+ return priorRender.apply(this, args);
678
+ } finally {
679
+ hooks.after();
680
+ }
681
+ };
682
+ additionalRendererRestores.push(() => {
683
+ rendererProto.render = priorRender;
684
+ });
685
+ continue;
686
+ }
687
+ const priorAddonDescriptor = Object.getOwnPropertyDescriptor(rendererProto, 'render');
688
+ Object.defineProperty(rendererProto, 'render', {
689
+ configurable: true,
690
+ set(this: Record<symbol, unknown>, fn: unknown) {
691
+ this[REAL] = fn;
692
+ },
693
+ get(this: Record<symbol, unknown>) {
694
+ const self = this;
695
+ return function render(this: unknown, ...args: unknown[]) {
696
+ observeRendererRender(self, args[0], args[1]);
697
+ return forwardRender(self, args);
698
+ };
699
+ },
700
+ });
701
+ additionalRendererRestores.push(() => {
702
+ if (priorAddonDescriptor)
703
+ Object.defineProperty(rendererProto, 'render', priorAddonDescriptor);
704
+ else delete (rendererProto as { render?: unknown }).render;
705
+ });
706
+ }
707
+
708
+ // ---- (D-C2): additive composer capture, only when a composer ctor
709
+ // was passed. `EffectComposer` methods (including `render`) live on the
710
+ // PROTOTYPE (a plain ES class — the constructor never does `this.render =
711
+ // …`), so a direct method-wrapper suffices — no getter/setter indirection
712
+ // like the `render`/`setAnimationLoop` traps above need. ----
713
+ const composers = new Set<ComposerLike>();
714
+ let composerProto: Record<string, unknown> | undefined;
715
+ let priorComposerRender: ((...a: unknown[]) => unknown) | undefined;
716
+ if (effectComposerCtor) {
717
+ const EC = effectComposerCtor as EffectComposerCtorLike;
718
+ composerProto = EC.prototype as unknown as Record<string, unknown>;
719
+ priorComposerRender = composerProto['render'] as (...a: unknown[]) => unknown;
720
+ composerProto['render'] = function (this: ComposerLike, ...args: unknown[]) {
721
+ // Call the real render FIRST: a composer's RenderPass calls
722
+ // `renderer.render(scene,camera)` internally, which is what actually
723
+ // sets `captured` (above) on the game's first frame. Checking after
724
+ // ensures a composer's very first render is still collected.
725
+ const result = priorComposerRender?.apply(this, args);
726
+ if (captured && this.renderer === captured.renderer) {
727
+ composers.add(this);
728
+ }
729
+ return result;
730
+ };
731
+ }
732
+
733
+ // Extracted so `uninstall()`'s own cognitive complexity doesn't grow with
734
+ // this additive restore step (biome's noExcessiveCognitiveComplexity).
735
+ function restoreComposerTrap() {
736
+ if (!composerProto) return;
737
+ if (priorComposerRender) composerProto['render'] = priorComposerRender;
738
+ else delete composerProto['render'];
739
+ }
740
+
741
+ return {
742
+ get captured() {
743
+ return captured;
744
+ },
745
+ get capturedVia(): CaptureMechanism | null {
746
+ if (!captured) return null;
747
+ return foreignRenderers.has(captured.renderer as unknown as object)
748
+ ? 'devtools-observer'
749
+ : 'shared-three';
750
+ },
751
+ getDrawCount() {
752
+ return drawCount;
753
+ },
754
+ setRenderPassHooks(hooks: RenderPassHooks | null) {
755
+ renderPassHooks = hooks;
756
+ },
757
+ getAnimationLoop(renderer: THREE.WebGLRenderer) {
758
+ return loopCallbacks.get(renderer as unknown as object) ?? null;
759
+ },
760
+ resizeComposers(w: number, h: number) {
761
+ // No composer ctor passed (or none constructed yet) → no-op, and never
762
+ // touches `renderer.getPixelRatio()` — a non-composer game's captured
763
+ // renderer need not even expose that method for this to stay a no-op.
764
+ if (!captured || composers.size === 0) return;
765
+ const pixelRatio = captured.renderer.getPixelRatio?.();
766
+ if (pixelRatio === undefined) return;
767
+ for (const composer of composers) {
768
+ composer.setSize(w, h);
769
+ composer.setPixelRatio(pixelRatio);
770
+ }
771
+ },
772
+ waitForCapture(options) {
773
+ const opts: CaptureWaitOptions =
774
+ typeof options === 'number' ? { timeoutMs: options } : (options ?? {});
775
+ const timeoutMs = opts.timeoutMs ?? 10_000;
776
+ if (captured) return Promise.resolve(captured);
777
+ return new Promise<CapturedRuntime>((resolve, reject) => {
778
+ // A budget of VISIBLE time. The window disarms itself while the
779
+ // document is hidden and resumes when it comes back, so a tab that
780
+ // boots in the background waits instead of dying — and the trap it is
781
+ // waiting on stays installed the whole time, which is what makes the
782
+ // first frame after foregrounding a capture rather than a retry.
783
+ const captureWindow = startVisibleCaptureWindow({
784
+ budgetMs: timeoutMs,
785
+ ...(opts.visibility !== undefined ? { clock: opts.visibility } : {}),
786
+ onExpire: () => {
787
+ const i = waiters.indexOf(wrapped);
788
+ if (i >= 0) waiters.splice(i, 1);
789
+ waitingVisibility = null;
790
+ opts.onWait?.(null);
791
+ reject(
792
+ new Error(
793
+ `Scene capture timed out after ${timeoutMs}ms of VISIBLE time ` +
794
+ `(${Math.round(captureWindow.elapsedHiddenMs())}ms browser-suspended, which is ` +
795
+ 'not counted because no frame can be presented) — no three renderer of ANY ' +
796
+ "instance drew a scene: neither the host's own `three` (the shared prototype " +
797
+ "trap) nor a renderer announcing itself on three's `__THREE_DEVTOOLS__` seam, " +
798
+ 'which is what reaches a game that bundles its own copy. The game never ' +
799
+ 'rendered.',
800
+ ),
801
+ );
802
+ },
803
+ });
804
+ const wrapped = (rt: CapturedRuntime) => {
805
+ captureWindow.cancel();
806
+ waitingVisibility = null;
807
+ opts.onWait?.(null);
808
+ resolve(rt);
809
+ };
810
+ waiters.push(wrapped);
811
+ waitingVisibility = opts.visibility ?? documentVisibilityClock();
812
+ opts.onWait?.(captureWindow);
813
+ // A waiter that starts already-hidden (the normal `vgai play` path
814
+ // against a backgrounded tab) must not wait for a human to foreground
815
+ // it. Pump any loop the game has already registered.
816
+ queueMicrotask(pumpHiddenLoops);
817
+ });
818
+ },
819
+ uninstall() {
820
+ // Restore the captured renderer's real render as an OWN property so its
821
+ // loop keeps working after the prototype trap is removed.
822
+ if (captured) {
823
+ const r = captured.renderer as unknown as Record<symbol, unknown>;
824
+ const real = r[REAL];
825
+ if (typeof real === 'function') {
826
+ Object.defineProperty(captured.renderer, 'render', {
827
+ configurable: true,
828
+ writable: true,
829
+ value: real,
830
+ });
831
+ }
832
+ }
833
+ if (priorDescriptor) {
834
+ Object.defineProperty(proto, 'render', priorDescriptor);
835
+ } else {
836
+ delete proto['render'];
837
+ }
838
+ // Restore the captured renderer's real setAnimationLoop as an OWN property (the
839
+ // prototype getter/setter intercepted the constructor's assignment, so the
840
+ // instance has none) — else removing the prototype trap would leave it without
841
+ // setAnimationLoop and freeze its loop.
842
+ if (captured) {
843
+ const r = captured.renderer as unknown as Record<symbol, unknown>;
844
+ const realSal = r[REAL_SAL];
845
+ if (typeof realSal === 'function') {
846
+ Object.defineProperty(captured.renderer, 'setAnimationLoop', {
847
+ configurable: true,
848
+ writable: true,
849
+ value: realSal,
850
+ });
851
+ }
852
+ }
853
+ if (priorSAL) Object.defineProperty(proto, 'setAnimationLoop', priorSAL);
854
+ else delete proto['setAnimationLoop'];
855
+ // Fully restore the composer addon's `render` (a plain
856
+ // prototype method — no per-instance own-property to restore, unlike
857
+ // the renderer/loop traps above).
858
+ restoreComposerTrap();
859
+ // Stop listening on three's devtools seam and give every foreign
860
+ // renderer instance its own `render`/`setAnimationLoop` back.
861
+ stopForeignObserver();
862
+ for (const restore of additionalRendererRestores) restore();
863
+ },
864
+ };
865
+ }