@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,163 @@
|
|
|
1
|
+
import * as THREE from 'three';
|
|
2
|
+
import { createNeutralMatcapTexture } from './matcap-texture';
|
|
3
|
+
|
|
4
|
+
/** Temporary developer-facing shading modes. These never become scene data. */
|
|
5
|
+
export type ViewportShadingMode =
|
|
6
|
+
| 'solid'
|
|
7
|
+
| 'clay'
|
|
8
|
+
| 'unlit'
|
|
9
|
+
| 'wireframe'
|
|
10
|
+
| 'matcap'
|
|
11
|
+
| 'normals'
|
|
12
|
+
| 'overdraw';
|
|
13
|
+
|
|
14
|
+
type MaterialPair = {
|
|
15
|
+
clay: THREE.MeshStandardMaterial;
|
|
16
|
+
unlit: THREE.MeshBasicMaterial;
|
|
17
|
+
wireframe: THREE.MeshBasicMaterial;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function materialColor(material: THREE.Material): THREE.Color {
|
|
21
|
+
const color = (material as THREE.Material & { color?: THREE.Color }).color;
|
|
22
|
+
return color?.clone() ?? new THREE.Color(0xbec7d1);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function textureOf(material: THREE.Material, key: 'map' | 'alphaMap'): THREE.Texture | null {
|
|
26
|
+
return (
|
|
27
|
+
(material as THREE.Material & { map?: THREE.Texture | null; alphaMap?: THREE.Texture | null })[
|
|
28
|
+
key
|
|
29
|
+
] ?? null
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Applies a render-only material view and restores every native material in a
|
|
35
|
+
* `finally` block. Gameplay and authoring therefore always observe the real
|
|
36
|
+
* materials; only renderer work inside {@link render} sees the diagnostic view.
|
|
37
|
+
*/
|
|
38
|
+
export class ViewportShadingRenderer {
|
|
39
|
+
private readonly _derived = new Map<THREE.Material, MaterialPair>();
|
|
40
|
+
private readonly _normals = new THREE.MeshNormalMaterial();
|
|
41
|
+
/**
|
|
42
|
+
* The clay/sculpt look. Built on FIRST USE, not at construction: the matcap
|
|
43
|
+
* sphere is drawn into a 2D canvas, and every ViewportShadingRenderer that
|
|
44
|
+
* only ever draws `solid` would otherwise pay for a texture nobody samples.
|
|
45
|
+
* `toneMapped: false` for the same reason the overdraw material sets it —
|
|
46
|
+
* a diagnostic look must read exactly as authored, not as the document's
|
|
47
|
+
* exposure and tone curve happen to grade it.
|
|
48
|
+
*/
|
|
49
|
+
private _matcap: THREE.MeshMatcapMaterial | null = null;
|
|
50
|
+
private _matcapTexture: THREE.Texture | null = null;
|
|
51
|
+
private readonly _overdraw = new THREE.MeshBasicMaterial({
|
|
52
|
+
color: 0xffffff,
|
|
53
|
+
transparent: true,
|
|
54
|
+
opacity: 0.08,
|
|
55
|
+
depthTest: false,
|
|
56
|
+
depthWrite: false,
|
|
57
|
+
blending: THREE.AdditiveBlending,
|
|
58
|
+
side: THREE.DoubleSide,
|
|
59
|
+
toneMapped: false,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
render(
|
|
63
|
+
scene: THREE.Scene,
|
|
64
|
+
mode: ViewportShadingMode,
|
|
65
|
+
draw: () => void,
|
|
66
|
+
include: (mesh: THREE.Mesh) => boolean = () => true,
|
|
67
|
+
): void {
|
|
68
|
+
if (mode === 'solid') {
|
|
69
|
+
draw();
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const originals: Array<{
|
|
74
|
+
mesh: THREE.Mesh;
|
|
75
|
+
material: THREE.Material | THREE.Material[];
|
|
76
|
+
}> = [];
|
|
77
|
+
scene.traverse((object) => {
|
|
78
|
+
const mesh = object as THREE.Mesh;
|
|
79
|
+
if (!mesh.isMesh || !mesh.material || !include(mesh)) return;
|
|
80
|
+
originals.push({ mesh, material: mesh.material });
|
|
81
|
+
mesh.material = Array.isArray(mesh.material)
|
|
82
|
+
? mesh.material.map((material) => this._materialFor(material, mode))
|
|
83
|
+
: this._materialFor(mesh.material, mode);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
draw();
|
|
88
|
+
} finally {
|
|
89
|
+
for (const { mesh, material } of originals) mesh.material = material;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
dispose(): void {
|
|
94
|
+
for (const pair of this._derived.values()) {
|
|
95
|
+
pair.clay.dispose();
|
|
96
|
+
pair.unlit.dispose();
|
|
97
|
+
pair.wireframe.dispose();
|
|
98
|
+
}
|
|
99
|
+
this._derived.clear();
|
|
100
|
+
this._normals.dispose();
|
|
101
|
+
this._overdraw.dispose();
|
|
102
|
+
this._matcap?.dispose();
|
|
103
|
+
this._matcapTexture?.dispose();
|
|
104
|
+
this._matcap = null;
|
|
105
|
+
this._matcapTexture = null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private _materialFor(
|
|
109
|
+
material: THREE.Material,
|
|
110
|
+
mode: Exclude<ViewportShadingMode, 'solid'>,
|
|
111
|
+
): THREE.Material {
|
|
112
|
+
if (mode === 'normals') return this._normals;
|
|
113
|
+
if (mode === 'overdraw') return this._overdraw;
|
|
114
|
+
if (mode === 'matcap') {
|
|
115
|
+
if (!this._matcap) {
|
|
116
|
+
this._matcapTexture = createNeutralMatcapTexture();
|
|
117
|
+
this._matcap = new THREE.MeshMatcapMaterial({
|
|
118
|
+
matcap: this._matcapTexture,
|
|
119
|
+
toneMapped: false,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
return this._matcap;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
let pair = this._derived.get(material);
|
|
126
|
+
if (!pair) {
|
|
127
|
+
const common: THREE.MeshBasicMaterialParameters = {
|
|
128
|
+
color: materialColor(material),
|
|
129
|
+
map: textureOf(material, 'map'),
|
|
130
|
+
alphaMap: textureOf(material, 'alphaMap'),
|
|
131
|
+
alphaTest: material.alphaTest,
|
|
132
|
+
opacity: material.opacity,
|
|
133
|
+
transparent: material.transparent,
|
|
134
|
+
side: material.side,
|
|
135
|
+
depthTest: material.depthTest,
|
|
136
|
+
depthWrite: material.depthWrite,
|
|
137
|
+
vertexColors: Boolean(
|
|
138
|
+
(material as THREE.Material & { vertexColors?: boolean }).vertexColors,
|
|
139
|
+
),
|
|
140
|
+
fog: (material as THREE.Material & { fog?: boolean }).fog ?? true,
|
|
141
|
+
};
|
|
142
|
+
pair = {
|
|
143
|
+
clay: new THREE.MeshStandardMaterial({
|
|
144
|
+
color: 0xaeb6c0,
|
|
145
|
+
roughness: 0.82,
|
|
146
|
+
metalness: 0,
|
|
147
|
+
flatShading: true,
|
|
148
|
+
alphaMap: textureOf(material, 'alphaMap'),
|
|
149
|
+
alphaTest: material.alphaTest,
|
|
150
|
+
opacity: material.opacity,
|
|
151
|
+
transparent: material.transparent,
|
|
152
|
+
side: material.side,
|
|
153
|
+
depthTest: material.depthTest,
|
|
154
|
+
depthWrite: material.depthWrite,
|
|
155
|
+
}),
|
|
156
|
+
unlit: new THREE.MeshBasicMaterial(common),
|
|
157
|
+
wireframe: new THREE.MeshBasicMaterial({ ...common, wireframe: true }),
|
|
158
|
+
};
|
|
159
|
+
this._derived.set(material, pair);
|
|
160
|
+
}
|
|
161
|
+
return pair[mode];
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How far a Three authoring view's perspective camera can see.
|
|
3
|
+
*
|
|
4
|
+
* ## The measured defect this exists for
|
|
5
|
+
*
|
|
6
|
+
* The editor camera was constructed once, with `far = 1000`, and never
|
|
7
|
+
* adjusted. That is fine for the starter template (a cube on a 50-unit grid)
|
|
8
|
+
* and wrong for anything world-sized: framing a world several hundred units
|
|
9
|
+
* across puts the camera past the far plane, so every triangle is clipped and
|
|
10
|
+
* the Scene viewport is a flat, empty field. The failure is graded, which is
|
|
11
|
+
* what made it confusing — at a 454-unit framing the world draws; at 1099 only
|
|
12
|
+
* the fragments within 1000 survive; at 1276 nothing does. A game whose own
|
|
13
|
+
* camera declares `far = 10000` is stating the honest scale of its world, and
|
|
14
|
+
* the editor camera has to reach it.
|
|
15
|
+
*
|
|
16
|
+
* Nothing about that is ingest-specific — a first-party world larger than
|
|
17
|
+
* ~1000 units disappears the same way — so the fix is a property of the editor
|
|
18
|
+
* camera, not of the ingest route.
|
|
19
|
+
*
|
|
20
|
+
* ## Why grow `far` instead of just setting it large
|
|
21
|
+
*
|
|
22
|
+
* The depth buffer's precision is governed by `far / near`, not by `far`. A
|
|
23
|
+
* blanket `far = 100000` would give every small scene a 10^6 ratio and visible
|
|
24
|
+
* z-fighting. So `far` tracks the content the camera actually has to reach,
|
|
25
|
+
* and `near` is pushed out only as much as is needed to keep the ratio bounded
|
|
26
|
+
* — which, for anything at the template's scale, is not at all: the planes come
|
|
27
|
+
* back exactly `0.1 / 1000`, the values the camera has always had.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** The camera's minimum near plane — also the exact value for any small scene. */
|
|
31
|
+
export const EDITOR_CAMERA_NEAR = 0.1;
|
|
32
|
+
|
|
33
|
+
/** The camera's minimum far plane — also the exact value for any small scene. */
|
|
34
|
+
export const EDITOR_CAMERA_FAR = 1000;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Ceiling on `far / near`. 0.1/1000 is 10^4, so this leaves two doublings of
|
|
38
|
+
* headroom before `near` starts moving at all, and caps the depth-precision
|
|
39
|
+
* loss a very large world can inflict.
|
|
40
|
+
*/
|
|
41
|
+
export const EDITOR_CAMERA_MAX_DEPTH_RATIO = 20_000;
|
|
42
|
+
|
|
43
|
+
/** Slack past the farthest content, so the far plane never grazes it. */
|
|
44
|
+
const REACH_MARGIN = 1.25;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Clip planes that reach `contentRadius` around a center `distanceToCenter`
|
|
48
|
+
* away, never tighter than the base planes.
|
|
49
|
+
*
|
|
50
|
+
* Pure and total: a non-finite or negative input yields the base planes rather
|
|
51
|
+
* than an `Infinity`/`NaN` projection matrix, because a camera whose bounds
|
|
52
|
+
* could not be measured must still draw.
|
|
53
|
+
*/
|
|
54
|
+
export function fitClipPlanes(
|
|
55
|
+
distanceToCenter: number,
|
|
56
|
+
contentRadius: number,
|
|
57
|
+
): { near: number; far: number } {
|
|
58
|
+
const d = Number.isFinite(distanceToCenter) ? Math.max(0, distanceToCenter) : 0;
|
|
59
|
+
const r = Number.isFinite(contentRadius) ? Math.max(0, contentRadius) : 0;
|
|
60
|
+
const far = Math.max(EDITOR_CAMERA_FAR, (d + r) * REACH_MARGIN);
|
|
61
|
+
const near = Math.max(EDITOR_CAMERA_NEAR, far / EDITOR_CAMERA_MAX_DEPTH_RATIO);
|
|
62
|
+
return { near, far };
|
|
63
|
+
}
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* World-space bounds of the GAME OBJECT a reader selected — the one walk every
|
|
3
|
+
* bounds consumer in the editor uses instead of `Box3.setFromObject`.
|
|
4
|
+
*
|
|
5
|
+
* ## Why `Box3.setFromObject` is the wrong instrument here
|
|
6
|
+
*
|
|
7
|
+
* `setFromObject` answers "what is the extent of this whole subtree", and a
|
|
8
|
+
* live game's subtree contains machinery as well as content. The marks in
|
|
9
|
+
* `@volter/editor-threejs/adapter/hierarchy-marks` already say which is which — a component
|
|
10
|
+
* WRAPS its functionality and MARKS the implementation it attaches — and the
|
|
11
|
+
* hierarchy panel and the drag rules already read them. Bounds is the third
|
|
12
|
+
* consumer, and it is the one where getting it wrong is visible on screen.
|
|
13
|
+
*
|
|
14
|
+
* The measurement that forced this (2026-08-14, the translated platformer's
|
|
15
|
+
* coins): a component root at `(12, 3, -5)` owning a 0.5 m coin mesh and, as a
|
|
16
|
+
* real child, its three.quarks `BatchedRenderer` with
|
|
17
|
+
* `matrixWorldAutoUpdate = false`. That renderer draws in WORLD space, so its
|
|
18
|
+
* identity `matrixWorld` is deliberate and correct for rendering — but its
|
|
19
|
+
* `VFXBatch` child carries the billboard template geometry, whose bounding box
|
|
20
|
+
* is `(-0.5, -0.5, 0)…(0.5, 0.5, 0)` and, at identity, sits AT THE WORLD
|
|
21
|
+
* ORIGIN. `setFromObject` unioned the two and returned
|
|
22
|
+
* `(-0.5, -0.5, -5.05)…(12.25, 3.25, 0)`: a selection cage stretching from the
|
|
23
|
+
* coin all the way back to the origin, on a coin that is 0.5 m across. The
|
|
24
|
+
* batch contributes that box whether or not a single particle is alive, so the
|
|
25
|
+
* cage was wrong from the first frame.
|
|
26
|
+
*
|
|
27
|
+
* That is the general shape, not a quarks quirk: ANY built-internal child whose
|
|
28
|
+
* world transform its owner writes by hand (`matrixWorldAutoUpdate = false` is
|
|
29
|
+
* the mechanism the marks' own header recommends) sits somewhere the subtree
|
|
30
|
+
* walk has no reason to expect, and pooled machinery routinely carries stale or
|
|
31
|
+
* template-sized geometry. Excluding implementation is the only rule that
|
|
32
|
+
* survives all of them.
|
|
33
|
+
*
|
|
34
|
+
* ## What is NOT excluded
|
|
35
|
+
*
|
|
36
|
+
* Editor furniture is deliberately still measured. `isEditorOwnedObject`
|
|
37
|
+
* matches `userData.engineInternal`, and the splat bounds proxy
|
|
38
|
+
* (`@volter/editor-threejs-runtime/asset-loaders`, `__vgai_splat_bounds`) is engine-internal
|
|
39
|
+
* geometry that exists PRECISELY so generic focus/selection bounds can frame a
|
|
40
|
+
* Gaussian splat — a splat renders no `BufferGeometry` of its own. Excluding
|
|
41
|
+
* editor-owned nodes here would silently un-frame every splat. Consumers that
|
|
42
|
+
* want helpers out of their measurement (`_hasBoundableGeometry`, the asset
|
|
43
|
+
* preview's staging) filter for that themselves, on top of this walk.
|
|
44
|
+
*
|
|
45
|
+
* The selected object ITSELF is always measured, even when it is marked. A
|
|
46
|
+
* revealed internals row is selectable, and answering "where is it" with an
|
|
47
|
+
* empty box would be a refusal dressed as a measurement.
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
import { isBuiltInternal } from '@volter/editor-threejs/adapter/hierarchy-marks';
|
|
51
|
+
import * as THREE from 'three';
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Visit `object` and every descendant that is not implementation, top-down.
|
|
55
|
+
*
|
|
56
|
+
* Subtree-scoped, matching the mark's own contract: a marked node is skipped
|
|
57
|
+
* WITH everything under it, so a rig or a pool costs one check rather than one
|
|
58
|
+
* per bone. `object` itself is always visited — see the header.
|
|
59
|
+
*/
|
|
60
|
+
export function traverseContent(
|
|
61
|
+
object: THREE.Object3D,
|
|
62
|
+
visit: (node: THREE.Object3D) => void,
|
|
63
|
+
): void {
|
|
64
|
+
visit(object);
|
|
65
|
+
for (const child of object.children) {
|
|
66
|
+
if (isBuiltInternal(child)) continue;
|
|
67
|
+
traverseContent(child, visit);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Scratch for one node's transformed geometry box — this module is synchronous
|
|
72
|
+
* and single-threaded, so one instance serves every call. */
|
|
73
|
+
const nodeBox = new THREE.Box3();
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Is this box a measurement at all?
|
|
77
|
+
*
|
|
78
|
+
* `Box3.isEmpty()` compares `max < min`, and EVERY comparison against `NaN` is
|
|
79
|
+
* false — so a box with a non-finite corner reports itself as a real,
|
|
80
|
+
* non-empty box, unions into every consumer, and turns the whole world's
|
|
81
|
+
* extent into `NaN`.
|
|
82
|
+
*
|
|
83
|
+
* That is not hypothetical and it is not the game's fault: a live world holds
|
|
84
|
+
* transforms that are not numbers yet. Measured on the racing-game mount
|
|
85
|
+
* (2026-08-15) — two of its meshes sit at `quaternion = (NaN, NaN, NaN, NaN)`
|
|
86
|
+
* while the game is PAUSED at frame zero, because a physics binding reads its
|
|
87
|
+
* worker's buffers before the worker has ever answered. The editor mounts an
|
|
88
|
+
* ingested game paused, so that is the exact state the first look measures.
|
|
89
|
+
* One such node poisoned all 71 boxes: the framing refused (`isEmpty()` false,
|
|
90
|
+
* fit distance `NaN`), the game-camera seed refused (`radius` `NaN`), and the
|
|
91
|
+
* Scene tab opened on the editor's boot pose — inside canyon geometry, a
|
|
92
|
+
* full-bleed rectangle of rock.
|
|
93
|
+
*
|
|
94
|
+
* The clip planes already made this decision for themselves
|
|
95
|
+
* (`viewport-clip-planes.ts`: "a camera whose bounds could not be measured
|
|
96
|
+
* must still draw"). Dropping the node HERE is the same decision made once, at
|
|
97
|
+
* the measurement, so framing, clipping and the selection cage all get the
|
|
98
|
+
* finite answer instead of each guarding separately.
|
|
99
|
+
*/
|
|
100
|
+
function isFiniteBox(box: THREE.Box3): boolean {
|
|
101
|
+
return (
|
|
102
|
+
Number.isFinite(box.min.x) &&
|
|
103
|
+
Number.isFinite(box.min.y) &&
|
|
104
|
+
Number.isFinite(box.min.z) &&
|
|
105
|
+
Number.isFinite(box.max.x) &&
|
|
106
|
+
Number.isFinite(box.max.y) &&
|
|
107
|
+
Number.isFinite(box.max.z)
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Scratch for the per-instance walk below — same single-threaded argument. */
|
|
112
|
+
const instanceMatrix = new THREE.Matrix4();
|
|
113
|
+
const instanceBox = new THREE.Box3();
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Union an `InstancedMesh`'s OWN drawn units into `target`, in world space.
|
|
117
|
+
*
|
|
118
|
+
* **Why not the object-level `boundingBox` three's own rule prefers here:
|
|
119
|
+
* `InstancedMesh.computeBoundingBox()` CACHES, and instanced content MOVES.**
|
|
120
|
+
* `boundingBox` starts `null`, is filled on the first request, and is never
|
|
121
|
+
* recomputed — three's docs say so outright ("You may need to recompute the
|
|
122
|
+
* bounding box if an instance is transformed"). A trail system that rewrites
|
|
123
|
+
* every instance matrix each frame (the racing-game's `Dust`/`Skid`) is
|
|
124
|
+
* therefore measured forever at whatever pose the editor first happened to ask
|
|
125
|
+
* about, and the per-frame selection cage — which exists to be glued to the
|
|
126
|
+
* thing — is glued to where the content USED to be. Nothing invalidates that
|
|
127
|
+
* cache, because nothing in the game's code knows the editor is looking.
|
|
128
|
+
*
|
|
129
|
+
* So the union is taken from the instance matrices as they stand, per call. The
|
|
130
|
+
* per-instance finite filter is the same decision {@link isFiniteBox} makes one
|
|
131
|
+
* level up and for the same reason: a live world holds transforms that are not
|
|
132
|
+
* numbers yet, and one such unit would otherwise turn the node's whole box into
|
|
133
|
+
* `NaN` — which `isEmpty()` cannot see.
|
|
134
|
+
*
|
|
135
|
+
* Cost is O(count) per call, which is exactly what `computeBoundingBox` costs;
|
|
136
|
+
* the callers that pay it per frame are scoped to the selection.
|
|
137
|
+
*/
|
|
138
|
+
function expandByInstances(
|
|
139
|
+
target: THREE.Box3,
|
|
140
|
+
node: THREE.InstancedMesh,
|
|
141
|
+
geometryBox: THREE.Box3,
|
|
142
|
+
): void {
|
|
143
|
+
for (let index = 0; index < node.count; index++) {
|
|
144
|
+
node.getMatrixAt(index, instanceMatrix);
|
|
145
|
+
instanceBox.copy(geometryBox).applyMatrix4(instanceMatrix).applyMatrix4(node.matrixWorld);
|
|
146
|
+
if (!isFiniteBox(instanceBox)) continue;
|
|
147
|
+
target.union(instanceBox);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Union `node`'s OWN renderable extent into `target`, ignoring its children.
|
|
153
|
+
*
|
|
154
|
+
* The object-level `boundingBox` (SkinnedMesh / BatchedMesh, which account for
|
|
155
|
+
* skinning and draw ranges) takes precedence over the shared geometry's, exactly
|
|
156
|
+
* as three's own `Box3.expandByObject` decides it. We cannot call that method —
|
|
157
|
+
* it walks children, which is the whole thing this module exists to control — so
|
|
158
|
+
* the per-node half is spelled here, over three's public `boundingBox` /
|
|
159
|
+
* `computeBoundingBox` API. Same shape as `model-thumbnail.ts`'s
|
|
160
|
+
* `modelPreviewBounds`, which already needed a filtered walk for its own reason.
|
|
161
|
+
*
|
|
162
|
+
* `InstancedMesh` is the one node three's own rule gets wrong for a LIVE world;
|
|
163
|
+
* see {@link expandByInstances}.
|
|
164
|
+
*/
|
|
165
|
+
function expandByNodeGeometry(target: THREE.Box3, node: THREE.Object3D): void {
|
|
166
|
+
const geometry = (node as THREE.Mesh).geometry as THREE.BufferGeometry | undefined;
|
|
167
|
+
if (!geometry) return;
|
|
168
|
+
const local = localRenderableBox(node, geometry);
|
|
169
|
+
if (!local) return;
|
|
170
|
+
if ((node as THREE.InstancedMesh).isInstancedMesh) {
|
|
171
|
+
expandByInstances(target, node as THREE.InstancedMesh, local);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
nodeBox.copy(local).applyMatrix4(node.matrixWorld);
|
|
175
|
+
if (!isFiniteBox(nodeBox)) return;
|
|
176
|
+
target.union(nodeBox);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The box `node` draws in its OWN space, computing it on first ask exactly as
|
|
180
|
+
* three does. `null` when the geometry cannot produce one at all. */
|
|
181
|
+
function localRenderableBox(
|
|
182
|
+
node: THREE.Object3D,
|
|
183
|
+
geometry: THREE.BufferGeometry,
|
|
184
|
+
): THREE.Box3 | null {
|
|
185
|
+
const renderable = node as THREE.Object3D & {
|
|
186
|
+
boundingBox?: THREE.Box3 | null;
|
|
187
|
+
computeBoundingBox?: () => void;
|
|
188
|
+
};
|
|
189
|
+
// An InstancedMesh's own `boundingBox` already has the instance matrices
|
|
190
|
+
// baked in, so it is the GEOMETRY box that `expandByInstances` needs.
|
|
191
|
+
//
|
|
192
|
+
// A SkinnedMesh is measured by its GEOMETRY (bind-pose) box, and its
|
|
193
|
+
// mesh-level cache is deliberately never touched. `computeBoundingBox()` on
|
|
194
|
+
// a SkinnedMesh runs the CPU skinning path, and on the humanoid rigs it
|
|
195
|
+
// yields bone-WORLD-contaminated "local" bounds (measured live on the
|
|
196
|
+
// hosted editor: local box z≈7.8..8.2 for a body whose matrixWorld already
|
|
197
|
+
// carries z=8). Three CACHES that box on the mesh, and `SkinnedMesh.raycast`
|
|
198
|
+
// then uses it as an early-out gate against the LOCALIZED ray — so the one
|
|
199
|
+
// framing/thumbnail pass that touched a character made it click-through
|
|
200
|
+
// FOREVER: rays hit the floor behind while the body rendered right under
|
|
201
|
+
// the cursor (the enemy-torso click that selected the jump pad ten units
|
|
202
|
+
// behind it). The bind-pose box is the honest edit-mode answer here anyway
|
|
203
|
+
// — edit mode shows the authored pose.
|
|
204
|
+
if ((node as THREE.SkinnedMesh).isSkinnedMesh) {
|
|
205
|
+
if (geometry.boundingBox === null) geometry.computeBoundingBox();
|
|
206
|
+
return geometry.boundingBox;
|
|
207
|
+
}
|
|
208
|
+
if (renderable.boundingBox !== undefined && !(node as THREE.InstancedMesh).isInstancedMesh) {
|
|
209
|
+
if (renderable.boundingBox === null) renderable.computeBoundingBox?.();
|
|
210
|
+
return renderable.boundingBox ?? null;
|
|
211
|
+
}
|
|
212
|
+
if (geometry.boundingBox === null) geometry.computeBoundingBox();
|
|
213
|
+
return geometry.boundingBox;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Grow `target` to contain `object`'s content subtree, in world space.
|
|
218
|
+
*
|
|
219
|
+
* Drop-in for `Box3.expandByObject(object)`, minus implementation subtrees.
|
|
220
|
+
* Like three's own, it refreshes each visited node's `matrixWorld` from its
|
|
221
|
+
* parent as it descends and never touches ancestors — a caller that needs the
|
|
222
|
+
* chain above `object` current calls `updateWorldMatrix(true, false)` first,
|
|
223
|
+
* exactly as it does today.
|
|
224
|
+
*/
|
|
225
|
+
export function expandBoxByContent(target: THREE.Box3, object: THREE.Object3D): THREE.Box3 {
|
|
226
|
+
traverseContent(object, (node) => {
|
|
227
|
+
node.updateWorldMatrix(false, false);
|
|
228
|
+
expandByNodeGeometry(target, node);
|
|
229
|
+
});
|
|
230
|
+
return target;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Scratch for the per-node collection below — same single-threaded argument. */
|
|
234
|
+
const collectBox = new THREE.Box3();
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* One drawing node's world box, plus whether it is authored as a non-occluding
|
|
238
|
+
* overlay. The 3D board's framing uses the overlay bit to leave a light-beam
|
|
239
|
+
* cone (or a trigger / AoE) out of the default camera without judging size.
|
|
240
|
+
*/
|
|
241
|
+
export interface ContentNodeRecord {
|
|
242
|
+
readonly box: THREE.Box3;
|
|
243
|
+
/**
|
|
244
|
+
* Every material on this mesh has `depthWrite === false`. That is the
|
|
245
|
+
* authored "I am a volume, not a body" flag — measured on the lighthouse
|
|
246
|
+
* beam (`transparent`, `opacity: 0.16`, `depthWrite: false`) and absent
|
|
247
|
+
* from every body mesh of that prefab and of an opaque hull.
|
|
248
|
+
*/
|
|
249
|
+
readonly overlay: boolean;
|
|
250
|
+
/**
|
|
251
|
+
* Every material on this mesh is `BackSide`. That is the authored
|
|
252
|
+
* "I am the inside of an enclosure" flag a skybox carries, and a hull
|
|
253
|
+
* or building does not.
|
|
254
|
+
*/
|
|
255
|
+
readonly enclosure: boolean;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function nodeMaterials(node: THREE.Object3D): THREE.Material[] {
|
|
259
|
+
const material = (node as THREE.Mesh).material as THREE.Material | THREE.Material[] | undefined;
|
|
260
|
+
if (!material) return [];
|
|
261
|
+
return Array.isArray(material) ? material : [material];
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** True when every material on `node` is authored not to write depth. */
|
|
265
|
+
export function nodeIsOverlay(node: THREE.Object3D): boolean {
|
|
266
|
+
const list = nodeMaterials(node);
|
|
267
|
+
return list.length > 0 && list.every((entry) => entry.depthWrite === false);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** True when every material on `node` is authored as an interior enclosure. */
|
|
271
|
+
export function nodeIsEnclosure(node: THREE.Object3D): boolean {
|
|
272
|
+
const list = nodeMaterials(node);
|
|
273
|
+
return list.length > 0 && list.every((entry) => entry.side === THREE.BackSide);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* The same walk as {@link expandBoxByContent}, reported one record PER NODE
|
|
278
|
+
* instead of unioned.
|
|
279
|
+
*
|
|
280
|
+
* The union answers "how big is this world"; framing needs "where is this
|
|
281
|
+
* world's content", and those are different questions the moment a world has a
|
|
282
|
+
* backdrop — one skybox sphere or one far prop moves the union and moves
|
|
283
|
+
* nothing else (`scene-framing.ts` has the measurement). Only nodes that
|
|
284
|
+
* actually draw contribute a box, so groups, lights and cameras add nothing to
|
|
285
|
+
* the distribution.
|
|
286
|
+
*/
|
|
287
|
+
export function collectContentNodeRecords(
|
|
288
|
+
object: THREE.Object3D,
|
|
289
|
+
out: ContentNodeRecord[] = [],
|
|
290
|
+
): ContentNodeRecord[] {
|
|
291
|
+
traverseContent(object, (node) => {
|
|
292
|
+
node.updateWorldMatrix(false, false);
|
|
293
|
+
collectBox.makeEmpty();
|
|
294
|
+
expandByNodeGeometry(collectBox, node);
|
|
295
|
+
if (collectBox.isEmpty()) return;
|
|
296
|
+
out.push({
|
|
297
|
+
box: collectBox.clone(),
|
|
298
|
+
overlay: nodeIsOverlay(node),
|
|
299
|
+
enclosure: nodeIsEnclosure(node),
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
return out;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* The same walk as {@link collectContentNodeRecords}, boxes only. Callers that
|
|
307
|
+
* do not need the overlay bit keep this entry.
|
|
308
|
+
*/
|
|
309
|
+
export function collectContentNodeBoxes(
|
|
310
|
+
object: THREE.Object3D,
|
|
311
|
+
out: THREE.Box3[] = [],
|
|
312
|
+
): THREE.Box3[] {
|
|
313
|
+
for (const record of collectContentNodeRecords(object)) out.push(record.box);
|
|
314
|
+
return out;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* The world AABB of `object`'s content subtree.
|
|
319
|
+
*
|
|
320
|
+
* Drop-in for `new THREE.Box3().setFromObject(object)`. Pass `target` to reuse
|
|
321
|
+
* a box across frames (the selection cage recomputes one per selected entity
|
|
322
|
+
* per frame).
|
|
323
|
+
*/
|
|
324
|
+
export function contentWorldBounds(
|
|
325
|
+
object: THREE.Object3D,
|
|
326
|
+
target: THREE.Box3 = new THREE.Box3(),
|
|
327
|
+
): THREE.Box3 {
|
|
328
|
+
target.makeEmpty();
|
|
329
|
+
return expandBoxByContent(target, object);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* The Bounds diagnostic's twelve-edge box, drawn on {@link contentWorldBounds}
|
|
334
|
+
* rather than `BoxHelper`'s own `setFromObject`.
|
|
335
|
+
*
|
|
336
|
+
* `THREE.BoxHelper` recomputes its box internally on every `update()`, so it
|
|
337
|
+
* cannot be pointed at a filtered walk; `Box3Helper` is three's own class for
|
|
338
|
+
* "draw THIS box", which is what we have. Same twelve edges, same colour.
|
|
339
|
+
*/
|
|
340
|
+
export class ContentBoundsHelper extends THREE.Box3Helper {
|
|
341
|
+
/** The entity object this box is measured from. */
|
|
342
|
+
readonly entityObject: THREE.Object3D;
|
|
343
|
+
|
|
344
|
+
constructor(object: THREE.Object3D, color: THREE.ColorRepresentation) {
|
|
345
|
+
super(new THREE.Box3(), color);
|
|
346
|
+
this.entityObject = object;
|
|
347
|
+
this.update();
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Re-measure. Named to match `BoxHelper.update()`, which the viewport's
|
|
351
|
+
* per-frame refresh already calls on everything in its bounds map. */
|
|
352
|
+
update(): void {
|
|
353
|
+
contentWorldBounds(this.entityObject, this.box);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { getObjectMark } from '@volter/editor-threejs/ecs/object-marks';
|
|
2
|
+
import type * as THREE from 'three';
|
|
3
|
+
|
|
4
|
+
/** Layer used for editor-only infrastructure (grid, gizmos, helpers, editor lights). */
|
|
5
|
+
export const EDITOR_LAYER = 31;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Temporary mask layer used by the native Three selection effect. Unlike
|
|
9
|
+
* {@link EDITOR_LAYER}, membership here does NOT make an object editor-owned:
|
|
10
|
+
* OutlineEffect adds this bit to the selected game mesh while deriving its
|
|
11
|
+
* mask. Editor cameras exclude the bit from their ordinary pass so the
|
|
12
|
+
* effect's depth comparison can actually separate selected pixels.
|
|
13
|
+
*/
|
|
14
|
+
export const EDITOR_SELECTION_LAYER = 30;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* True when this object is the EDITOR'S OWN furniture rather than game content.
|
|
18
|
+
*
|
|
19
|
+
* There are two marking conventions in this codebase and neither one covers
|
|
20
|
+
* everything: the viewport's constructor-time furniture (`GridHelper`, the
|
|
21
|
+
* editor ambient/directional lights, the three.quarks `BatchedRenderer`, the
|
|
22
|
+
* TransformControls helper, the pivot dummy, the snap indicators) is marked
|
|
23
|
+
* ONLY with `layers.set(EDITOR_LAYER)`, while helpers added later
|
|
24
|
+
* (skeleton/physics/crowd/component gizmos) are marked ONLY with
|
|
25
|
+
* `userData.editorHelper`. A walk that checks one convention silently presents
|
|
26
|
+
* the other half as game content.
|
|
27
|
+
*
|
|
28
|
+
* That is the SimCity ledger's S-1 hierarchy defect: `projectThreeScene`
|
|
29
|
+
* checked only the userData flags, so when an ingest mount FAILED — leaving
|
|
30
|
+
* `store.scene` pointed at the editor viewport scene instead of a captured game
|
|
31
|
+
* scene — the hierarchy listed the editor's own grid, lights, BatchedRenderer,
|
|
32
|
+
* gizmo helper, pivot dummy and two snap indicators under the game world's id.
|
|
33
|
+
* A dead game was indistinguishable from a live one.
|
|
34
|
+
*
|
|
35
|
+
* This is the SAME predicate the engine's ingest walk already applies
|
|
36
|
+
* (`@volter/editor-threejs-runtime/adapter/ingest/structural-ids`'s `isEditorOnly`, layer half) plus
|
|
37
|
+
* the userData half `viewport-shading-boundary.ts` applies — stated once here
|
|
38
|
+
* so the two halves can never drift apart again.
|
|
39
|
+
*/
|
|
40
|
+
export function isEditorOwnedObject(object: THREE.Object3D): boolean {
|
|
41
|
+
return (
|
|
42
|
+
// Read the public mask, which has existed for the full three.js range the
|
|
43
|
+
// ingest devtools hook supports. `Layers.isEnabled()` is newer than r129:
|
|
44
|
+
// calling it rejected an otherwise healthy Cuberun capture before the
|
|
45
|
+
// hierarchy could mount.
|
|
46
|
+
(object.layers.mask & (1 << EDITOR_LAYER)) !== 0 ||
|
|
47
|
+
!!getObjectMark(object, 'editorHelper') ||
|
|
48
|
+
!!getObjectMark(object, 'engineInternal')
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** True when `object` is editor furniture itself or lives below an
|
|
53
|
+
* editor-owned root. Helper conventions are not guaranteed to be repeated on
|
|
54
|
+
* every descendant, so hit-testing a leaf must check its ancestry. */
|
|
55
|
+
export function isInEditorOwnedSubtree(object: THREE.Object3D): boolean {
|
|
56
|
+
let current: THREE.Object3D | null = object;
|
|
57
|
+
while (current) {
|
|
58
|
+
if (isEditorOwnedObject(current)) return true;
|
|
59
|
+
current = current.parent;
|
|
60
|
+
}
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import * as THREE from 'three';
|
|
2
|
+
import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js';
|
|
3
|
+
|
|
4
|
+
/** A baked IBL environment and the handle that frees its render target. */
|
|
5
|
+
export interface StandardEnvironment {
|
|
6
|
+
readonly texture: THREE.Texture;
|
|
7
|
+
dispose(): void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Bake Three's RoomEnvironment for an authoring view. The caller owns the
|
|
12
|
+
* returned target; a preview pool may lend its texture without transferring
|
|
13
|
+
* ownership. No palette, document registration or product service is needed.
|
|
14
|
+
*/
|
|
15
|
+
export function createStandardEnvironment(renderer: THREE.WebGLRenderer): StandardEnvironment {
|
|
16
|
+
const pmrem = new THREE.PMREMGenerator(renderer);
|
|
17
|
+
const room = new RoomEnvironment();
|
|
18
|
+
try {
|
|
19
|
+
const target = pmrem.fromScene(room, 0.04);
|
|
20
|
+
return { texture: target.texture, dispose: () => target.dispose() };
|
|
21
|
+
} finally {
|
|
22
|
+
// A failed bake must release its temporary scene and generator too.
|
|
23
|
+
room.dispose();
|
|
24
|
+
pmrem.dispose();
|
|
25
|
+
}
|
|
26
|
+
}
|