@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.
- package/LICENSE +661 -0
- package/LICENSE-APACHE +202 -0
- package/NOTICE +12 -0
- package/README.md +16 -0
- package/package.json +43 -0
- package/src/adapter/constraint.ts +78 -0
- package/src/adapter/hierarchy-marks.ts +156 -0
- package/src/adapter/ingest/scene-capture.ts +865 -0
- package/src/adapter/ingest/structural-ids.ts +139 -0
- package/src/adapter/ingest/visible-capture-window.ts +302 -0
- package/src/adapter/object3d-authoring-subject.ts +50 -0
- package/src/adapter/reflection-probe.ts +75 -0
- package/src/adapter/renderer-config.ts +116 -0
- package/src/adapter/trigger-volume.ts +29 -0
- package/src/animation/animation-clock.ts +479 -0
- package/src/animation/runtime-inspection.ts +45 -0
- package/src/asset-loaders.ts +242 -0
- package/src/asset-parse-error.ts +29 -0
- package/src/capture/output-pass.ts +36 -0
- package/src/capture/scene.ts +146 -0
- package/src/ecs/object-marks.ts +75 -0
- package/src/ecs/user-data.ts +251 -0
- package/src/loader.ts +134 -0
- package/src/render/matcap-texture.ts +92 -0
- package/src/render/spark-renderer-lifecycle.ts +64 -0
- package/src/render/viewport-shading.ts +163 -0
- package/src/viewport/clip-planes.ts +63 -0
- package/src/viewport/content-bounds.ts +355 -0
- package/src/viewport/editor-layers.ts +62 -0
- package/src/viewport/environment.ts +26 -0
- package/src/viewport/preview-renderer.ts +179 -0
- package/src/viewport/renderer-ownership.ts +86 -0
|
@@ -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
|
+
}
|