@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,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
|
+
}
|