@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,179 @@
1
+ /**
2
+ * The WebGL renderer the INSPECTOR-PREVIEW lane draws through — one per editor
3
+ * session, not one per selection.
4
+ *
5
+ * Why this module exists (measured, 2026-08-07): every chromeless preview
6
+ * (`components/InspectorObjectPreview.tsx` → `Object3DSnapshotDocument` →
7
+ * `Object3DDocumentViewport`) used to construct its own
8
+ * `THREE.WebGLRenderer` on mount, so SELECTING A THING built a whole GL stack.
9
+ * A renderer's constructor is a long chain of synchronous driver round trips
10
+ * (`WebGLCapabilities`/`getExtension`/`getParameter`), and its program cache
11
+ * starts empty, so every selection also recompiled the subject's shaders and
12
+ * re-baked the PMREM environment. A click profiled at ~1.9 s of frozen main
13
+ * thread, 3.5 s of self time in `gl.getParameter` alone. Sharing one renderer
14
+ * removes the construction entirely and lets the program cache and the IBL
15
+ * bake survive across selections.
16
+ *
17
+ * OWNERSHIP — the one place it is stated:
18
+ * - This module OWNS every canvas, `WebGLRenderer` and baked
19
+ * {@link StandardEnvironment} it creates. Nothing else may dispose them.
20
+ * - SHARERS are lease holders: {@link acquireInspectorPreviewRenderer} hands
21
+ * out a lease, the holder mounts `lease.canvas` in its own container and
22
+ * draws through `lease.renderer` for as long as it is mounted.
23
+ * - The ONE teardown path a holder has is {@link InspectorPreviewLease.release}
24
+ * — it returns the renderer to the pool (or disposes it, if it was an
25
+ * overflow lease that the pool does not keep). A holder never calls
26
+ * `renderer.dispose()`, and never disposes `lease.environment()`, whose
27
+ * handle is deliberately non-owning.
28
+ * - Per-selection SCENE resources (the snapshot's skeletons, the dressing's
29
+ * lights/grid/backdrop) stay the holder's, exactly as before: three's
30
+ * program cache lives on the renderer and is not touched by disposing the
31
+ * materials that populated it.
32
+ *
33
+ * Capacity is deliberately small. The inspector shows ONE live preview at a
34
+ * time by construction (`InspectionProjection.tsx` drops the disc to its glyph
35
+ * while the preview panel is open), so a second entry is slack for a handover
36
+ * frame, and anything beyond that is a transient renderer disposed on release
37
+ * rather than a growing pool.
38
+ */
39
+
40
+ import * as THREE from 'three';
41
+ import { createStandardEnvironment, type StandardEnvironment } from './environment';
42
+ import { markHostRenderer } from './renderer-ownership';
43
+
44
+ /** How many renderers the lane keeps alive between selections. */
45
+ const POOL_CAPACITY = 2;
46
+
47
+ interface PreviewRendererEntry {
48
+ readonly canvas: HTMLCanvasElement;
49
+ readonly renderer: THREE.WebGLRenderer;
50
+ environment: StandardEnvironment | null;
51
+ /** In the pool (kept between leases) rather than an overflow renderer. */
52
+ pooled: boolean;
53
+ leased: boolean;
54
+ /** The GL context went away; the entry is dead and is never leased again. */
55
+ lost: boolean;
56
+ }
57
+
58
+ const entries: PreviewRendererEntry[] = [];
59
+ const liveEntries = new Set<PreviewRendererEntry>();
60
+
61
+ export interface InspectorPreviewLease {
62
+ /** The canvas to mount. It is the renderer's own `domElement`. */
63
+ readonly canvas: HTMLCanvasElement;
64
+ readonly renderer: THREE.WebGLRenderer;
65
+ /**
66
+ * The lane's shared IBL bake, as a NON-OWNING handle: its `dispose()` is a
67
+ * no-op, so the standard dressing (which disposes the environment it is
68
+ * given) can take it under its usual contract without freeing a render
69
+ * target the next selection needs.
70
+ */
71
+ environment(): StandardEnvironment;
72
+ /** The one teardown path. Idempotent; unmounts the canvas. Discard after a
73
+ * render exception: Three may have skipped its internal render-stack cleanup. */
74
+ release(options?: { discard?: boolean }): void;
75
+ }
76
+
77
+ function createEntry(): PreviewRendererEntry {
78
+ const canvas = document.createElement('canvas');
79
+ canvas.style.width = '100%';
80
+ canvas.style.height = '100%';
81
+ canvas.style.display = 'block';
82
+ const renderer = markHostRenderer(
83
+ new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true }),
84
+ );
85
+ const entry: PreviewRendererEntry = {
86
+ canvas,
87
+ renderer,
88
+ environment: null,
89
+ pooled: false,
90
+ leased: true,
91
+ lost: false,
92
+ };
93
+ liveEntries.add(entry);
94
+ // A lost context cannot be drawn through again, and three's renderer does not
95
+ // rebuild itself. Drop the entry so the next acquisition builds a fresh one.
96
+ canvas.addEventListener('webglcontextlost', () => {
97
+ entry.lost = true;
98
+ const index = entries.indexOf(entry);
99
+ if (index >= 0) entries.splice(index, 1);
100
+ if (!entry.leased) disposeEntry(entry);
101
+ });
102
+ return entry;
103
+ }
104
+
105
+ function disposeEntry(entry: PreviewRendererEntry): void {
106
+ // forceContextLoss can deliver the loss event after an explicit discard.
107
+ if (!liveEntries.delete(entry)) return;
108
+ entry.environment?.dispose();
109
+ entry.environment = null;
110
+ entry.renderer.dispose();
111
+ // `dispose()` frees GPU RESOURCES; it does not give the CONTEXT back. The
112
+ // canvas holds it until garbage collection decides otherwise, and WebGL
113
+ // contexts are a hard ~16-per-page budget the browser enforces by killing
114
+ // the OLDEST — which is the main viewport, so the symptom is a white
115
+ // viewport and "too many active WebGL contexts" far from the cause
116
+ // (runhuman pass 104). Every other renderer owner in this editor already
117
+ // pairs the two (`asset-preview.ts`, `components/world-root-stage.ts`); this
118
+ // shared pool did not, and it backs both the Inspector preview and the
119
+ // Object3D tool's viewport, so its transients leaked a context each.
120
+ entry.renderer.forceContextLoss();
121
+ entry.canvas.remove();
122
+ }
123
+
124
+ /** Diagnostic readback kept separate from the interactive-document pool. */
125
+ export function inspectorPreviewRendererCounts(): {
126
+ readonly active: number;
127
+ readonly idle: number;
128
+ } {
129
+ return {
130
+ active: [...liveEntries].filter((entry) => entry.leased && !entry.lost).length,
131
+ idle: [...liveEntries].filter((entry) => !entry.leased && !entry.lost).length,
132
+ };
133
+ }
134
+
135
+ /** Take the lane's renderer for one mounted preview. Never fails to a caller:
136
+ * when the pool is busy it hands back a transient renderer instead. */
137
+ export function acquireInspectorPreviewRenderer(): InspectorPreviewLease {
138
+ let entry = entries.find((candidate) => !candidate.leased && !candidate.lost);
139
+ if (!entry) {
140
+ entry = createEntry();
141
+ if (entries.length < POOL_CAPACITY) {
142
+ entry.pooled = true;
143
+ entries.push(entry);
144
+ }
145
+ }
146
+ entry.leased = true;
147
+ const held = entry;
148
+ let released = false;
149
+ return {
150
+ canvas: held.canvas,
151
+ renderer: held.renderer,
152
+ environment(): StandardEnvironment {
153
+ held.environment ??= createStandardEnvironment(held.renderer);
154
+ const texture = held.environment.texture;
155
+ return { texture, dispose: () => {} };
156
+ },
157
+ release(options): void {
158
+ if (released) return;
159
+ released = true;
160
+ held.leased = false;
161
+ held.canvas.remove();
162
+ if (options?.discard) {
163
+ held.lost = true;
164
+ const index = entries.indexOf(held);
165
+ if (index >= 0) entries.splice(index, 1);
166
+ }
167
+ if (!held.pooled || held.lost) {
168
+ disposeEntry(held);
169
+ return;
170
+ }
171
+ // Leave nothing of this subject on the surface: the canvas is re-mounted
172
+ // for the NEXT subject and would otherwise show the last frame of the
173
+ // previous one until its first draw lands.
174
+ held.renderer.setScissorTest(false);
175
+ held.renderer.autoClear = true;
176
+ held.renderer.clear();
177
+ },
178
+ };
179
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * The set of `WebGLRenderer`s the editor-side Three integration owns — its viewport, and every
3
+ * offscreen renderer it spins up for thumbnails, asset previews and asset
4
+ * compare.
5
+ *
6
+ * Why this exists: ingest's scene-capture trap
7
+ * (`@volter/editor-threejs-runtime/adapter/ingest/scene-capture`) is installed on the SHARED
8
+ * `three.WebGLRenderer.prototype` — that sharing is the whole mechanism, it is
9
+ * how an unmodified game's renderer gets instrumented without touching the
10
+ * game's source. But it means the editor's own renders arrive at the trap too,
11
+ * and the trap captures the first scene it sees. Any host renderer constructed
12
+ * after the trap installs (a thumbnail bake, an asset preview) could therefore
13
+ * be captured AS THE INGESTED GAME.
14
+ *
15
+ * That is not hypothetical: it is what made the "a game bundling its own three
16
+ * is DETECTED, never silently mistaken for a capture" spec pass or fail on
17
+ * timing alone. When it failed, the editor
18
+ * had adopted its own thumbnail scene as the game: a hierarchy of the editor's
19
+ * own lights, presented as the game's content.
20
+ *
21
+ * Marking is explicit at each construction site rather than inferred, because
22
+ * the honest question ("did the HOST make this renderer?") has no reliable
23
+ * signal at the trap: a host renderer and a game renderer are the same class,
24
+ * both constructed after install, and both render real scenes.
25
+ */
26
+
27
+ const _hostRenderers = new WeakSet<object>();
28
+
29
+ /** Record `renderer` as the editor's own. Returns it, so it can wrap a `new`. */
30
+ /**
31
+ * WebGL contexts are a HARD, SMALL browser budget (order of sixteen per page),
32
+ * and this is the one chokepoint every editor-constructed renderer passes
33
+ * through — so it is where an editor that is quietly accumulating them can be
34
+ * caught naming its own culprit.
35
+ *
36
+ * A tester adding and deleting prefabs hit "too many active WebGL contexts"
37
+ * after five rounds and the whole viewport turned white, because the browser
38
+ * evicts the OLDEST context when the limit is reached and the oldest is the
39
+ * main viewport (runhuman pass 104). Three's warning comes from inside
40
+ * `three.module.js` and names nothing about who asked, which is what made it
41
+ * undiagnosable from the report.
42
+ *
43
+ * The count is LIVE: `dispose()` is wrapped so a renderer that is properly
44
+ * torn down stops counting. Past the budget the warning carries the creating
45
+ * stack, so the next sighting names the leak instead of the victim.
46
+ */
47
+ const LIVE_RENDERER_WARN_AT = 8;
48
+ let liveHostRenderers = 0;
49
+
50
+ export function markHostRenderer<T extends object>(renderer: T): T {
51
+ _hostRenderers.add(renderer);
52
+ liveHostRenderers += 1;
53
+ const disposable = renderer as { dispose?: () => void };
54
+ const dispose = disposable.dispose;
55
+ if (typeof dispose === 'function') {
56
+ let counted = true;
57
+ disposable.dispose = function wrappedDispose(this: unknown, ...args: unknown[]) {
58
+ if (counted) {
59
+ counted = false;
60
+ liveHostRenderers -= 1;
61
+ }
62
+ return (dispose as (...a: unknown[]) => unknown).apply(this, args);
63
+ } as () => void;
64
+ }
65
+ if (liveHostRenderers >= LIVE_RENDERER_WARN_AT) {
66
+ // biome-ignore lint/suspicious/noConsole: the standing warning channel for a budget the browser enforces by destroying the viewport
67
+ console.warn(
68
+ `[host-renderers] ${liveHostRenderers} editor WebGL renderers are live at once. The ` +
69
+ 'browser evicts the OLDEST context past its limit, which is the main viewport — a white ' +
70
+ 'viewport and "too many active WebGL contexts" is this. Creating stack:',
71
+ new Error('renderer created here').stack,
72
+ );
73
+ }
74
+ return renderer;
75
+ }
76
+
77
+ /** Live count of editor-constructed renderers — for diagnostics that want the
78
+ * number without waiting for the warning threshold. */
79
+ export function liveHostRendererCount(): number {
80
+ return liveHostRenderers;
81
+ }
82
+
83
+ /** True when `renderer` is one the editor constructed for its own rendering. */
84
+ export function isHostRenderer(renderer: unknown): boolean {
85
+ return typeof renderer === 'object' && renderer !== null && _hostRenderers.has(renderer);
86
+ }