@volter/editor-threejs 0.5.65 → 0.5.66
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/NOTICE +2 -0
- package/contributions/animation-mixers.service.ts +20 -0
- package/dist-node/serving.mjs +405 -0
- package/package.json +22 -4
- package/serving/animation-live-module.ts +70 -0
- package/serving/animation-stamp.ts +88 -0
- package/serving/index.ts +14 -0
- package/serving/model-import-conversion.ts +344 -0
- package/src/adapter/ingest/scene-capture.ts +1 -23
- package/src/animation/live-mixers.ts +55 -0
- package/src/ecs/user-data.ts +1 -8
- package/src/viewport/content-bounds.ts +38 -4
- package/src/viewport/environment.ts +16 -0
- package/src/animation/animation-clock.ts +0 -479
- package/src/animation/runtime-inspection.ts +0 -45
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
import { dirname, extname } from 'node:path';
|
|
6
|
+
import * as THREE from 'three';
|
|
7
|
+
import { GLTFExporter } from 'three/addons/exporters/GLTFExporter.js';
|
|
8
|
+
import { ColladaLoader } from 'three/addons/loaders/ColladaLoader.js';
|
|
9
|
+
import { FBXLoader } from 'three/addons/loaders/FBXLoader.js';
|
|
10
|
+
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
|
|
11
|
+
import { MTLLoader } from 'three/addons/loaders/MTLLoader.js';
|
|
12
|
+
import { OBJLoader } from 'three/addons/loaders/OBJLoader.js';
|
|
13
|
+
import { PLYLoader } from 'three/addons/loaders/PLYLoader.js';
|
|
14
|
+
import { STLLoader } from 'three/addons/loaders/STLLoader.js';
|
|
15
|
+
import { TDSLoader } from 'three/addons/loaders/TDSLoader.js';
|
|
16
|
+
import { mergeVertices } from 'three/addons/utils/BufferGeometryUtils.js';
|
|
17
|
+
import type { ModelConverter, ModelImportSettings } from '@volter/editor-sdk/session/project-serving';
|
|
18
|
+
|
|
19
|
+
/** three.js's conversion of the source formats its loaders read, as the asset library's
|
|
20
|
+
* model converter (`ProjectServingServices.registerModelConverter`). */
|
|
21
|
+
export const threeModelConverter: ModelConverter = {
|
|
22
|
+
formats: ['fbx', 'obj', 'dae', 'stl', 'ply', '3ds'],
|
|
23
|
+
convert: (primaryPath, format, settings) => convertStagedModelToGlb(primaryPath, format, settings),
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Convert a staged source model to a runtime GLB without requiring Blender. */
|
|
27
|
+
export async function convertStagedModelToGlb(
|
|
28
|
+
primaryPath: string,
|
|
29
|
+
format: string,
|
|
30
|
+
settings: ModelImportSettings,
|
|
31
|
+
): Promise<Uint8Array> {
|
|
32
|
+
installNodeDom();
|
|
33
|
+
const bytes = new Uint8Array(await readFile(primaryPath));
|
|
34
|
+
const object = await parseSourceModel(primaryPath, format.toLowerCase(), bytes);
|
|
35
|
+
discardUnloadedTextures(object);
|
|
36
|
+
applyModelImportSettings(object, settings);
|
|
37
|
+
installNodeFileReader();
|
|
38
|
+
try {
|
|
39
|
+
const result = await new Promise<ArrayBuffer>((resolve, reject) => {
|
|
40
|
+
new GLTFExporter().parse(
|
|
41
|
+
object,
|
|
42
|
+
(output) =>
|
|
43
|
+
output instanceof ArrayBuffer
|
|
44
|
+
? resolve(output)
|
|
45
|
+
: reject(new Error('Model conversion produced JSON instead of binary GLB.')),
|
|
46
|
+
reject,
|
|
47
|
+
{ binary: true, animations: object.animations, onlyVisible: false },
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
const output = new Uint8Array(result);
|
|
51
|
+
await validateConvertedGlb(output);
|
|
52
|
+
return output;
|
|
53
|
+
} finally {
|
|
54
|
+
disposeObject(object);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const materialTextureSlots = [
|
|
59
|
+
'map',
|
|
60
|
+
'alphaMap',
|
|
61
|
+
'aoMap',
|
|
62
|
+
'bumpMap',
|
|
63
|
+
'displacementMap',
|
|
64
|
+
'emissiveMap',
|
|
65
|
+
'envMap',
|
|
66
|
+
'lightMap',
|
|
67
|
+
'metalnessMap',
|
|
68
|
+
'normalMap',
|
|
69
|
+
'roughnessMap',
|
|
70
|
+
'specularMap',
|
|
71
|
+
] as const;
|
|
72
|
+
|
|
73
|
+
/** Apply the persisted conversion settings before the runtime GLB is exported. */
|
|
74
|
+
export function applyModelImportSettings(
|
|
75
|
+
object: THREE.Object3D,
|
|
76
|
+
settings: ModelImportSettings,
|
|
77
|
+
): void {
|
|
78
|
+
object.scale.multiplyScalar(settings.scale * sourceUnitScale(settings.sourceUnits));
|
|
79
|
+
object.quaternion.premultiply(importAxisRotation(settings.upAxis, settings.forwardAxis));
|
|
80
|
+
|
|
81
|
+
const discardedTextures = new Set<THREE.Texture>();
|
|
82
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: one traversal deliberately applies the persisted mesh, material, texture, normal, tangent, and optimization settings to each converted renderable.
|
|
83
|
+
object.traverse((node) => {
|
|
84
|
+
const mesh = node as THREE.Mesh;
|
|
85
|
+
if (!mesh.geometry) return;
|
|
86
|
+
let geometry = mesh.geometry;
|
|
87
|
+
if (settings.meshOptimization !== 'none') {
|
|
88
|
+
const optimized = mergeVertices(
|
|
89
|
+
geometry,
|
|
90
|
+
settings.meshOptimization === 'aggressive' ? 1e-3 : 1e-6,
|
|
91
|
+
);
|
|
92
|
+
if (optimized !== geometry) {
|
|
93
|
+
mesh.geometry = optimized;
|
|
94
|
+
geometry.dispose();
|
|
95
|
+
geometry = optimized;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (settings.normals !== 'preserve') {
|
|
99
|
+
geometry.deleteAttribute('normal');
|
|
100
|
+
geometry.computeVertexNormals();
|
|
101
|
+
}
|
|
102
|
+
if (settings.tangents === 'discard') geometry.deleteAttribute('tangent');
|
|
103
|
+
if (
|
|
104
|
+
settings.tangents === 'generate' &&
|
|
105
|
+
geometry.index &&
|
|
106
|
+
geometry.hasAttribute('position') &&
|
|
107
|
+
geometry.hasAttribute('normal') &&
|
|
108
|
+
geometry.hasAttribute('uv')
|
|
109
|
+
) {
|
|
110
|
+
geometry.computeTangents();
|
|
111
|
+
}
|
|
112
|
+
geometry.computeBoundingBox();
|
|
113
|
+
geometry.computeBoundingSphere();
|
|
114
|
+
|
|
115
|
+
const priorMaterials = Array.isArray(mesh.material)
|
|
116
|
+
? mesh.material
|
|
117
|
+
: mesh.material
|
|
118
|
+
? [mesh.material]
|
|
119
|
+
: [];
|
|
120
|
+
if (settings.materials === 'discard') {
|
|
121
|
+
mesh.material = Array.isArray(mesh.material)
|
|
122
|
+
? priorMaterials.map(() => new THREE.MeshStandardMaterial())
|
|
123
|
+
: new THREE.MeshStandardMaterial();
|
|
124
|
+
for (const material of priorMaterials) material.dispose();
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (settings.textures !== 'discard') return;
|
|
128
|
+
for (const material of priorMaterials) {
|
|
129
|
+
const textured = material as THREE.Material & Record<string, unknown>;
|
|
130
|
+
for (const slot of materialTextureSlots) {
|
|
131
|
+
const texture = textured[slot];
|
|
132
|
+
if (texture instanceof THREE.Texture) discardedTextures.add(texture);
|
|
133
|
+
textured[slot] = null;
|
|
134
|
+
}
|
|
135
|
+
material.needsUpdate = true;
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
for (const texture of discardedTextures) texture.dispose();
|
|
139
|
+
|
|
140
|
+
if (settings.animations.length > 0) {
|
|
141
|
+
const requested = new Set(settings.animations);
|
|
142
|
+
const selected = object.animations.filter((clip) => requested.has(clip.name));
|
|
143
|
+
const missing = settings.animations.filter(
|
|
144
|
+
(name) => !selected.some((clip) => clip.name === name),
|
|
145
|
+
);
|
|
146
|
+
if (missing.length > 0) throw new Error(`Animation clips not found: ${missing.join(', ')}.`);
|
|
147
|
+
object.animations = selected;
|
|
148
|
+
}
|
|
149
|
+
object.updateWorldMatrix(true, true);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function sourceUnitScale(units: ModelImportSettings['sourceUnits']): number {
|
|
153
|
+
return { auto: 1, mm: 0.001, cm: 0.01, m: 1, in: 0.0254, ft: 0.3048 }[units];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function importAxisRotation(
|
|
157
|
+
upAxis: ModelImportSettings['upAxis'],
|
|
158
|
+
forwardAxis: ModelImportSettings['forwardAxis'],
|
|
159
|
+
): THREE.Quaternion {
|
|
160
|
+
const up = upAxis === 'auto' ? null : axisVector(upAxis);
|
|
161
|
+
const forward = forwardAxis === 'auto' ? null : axisVector(forwardAxis);
|
|
162
|
+
if (up && forward) {
|
|
163
|
+
if (Math.abs(up.dot(forward)) > 0.001)
|
|
164
|
+
throw new Error('Import up and forward axes must be perpendicular.');
|
|
165
|
+
const right = new THREE.Vector3().crossVectors(forward, up).normalize();
|
|
166
|
+
const correctedForward = new THREE.Vector3().crossVectors(up, right).normalize();
|
|
167
|
+
const sourceBasis = new THREE.Matrix4().makeBasis(right, up, correctedForward.clone().negate());
|
|
168
|
+
return new THREE.Quaternion().setFromRotationMatrix(sourceBasis.invert());
|
|
169
|
+
}
|
|
170
|
+
if (up) return new THREE.Quaternion().setFromUnitVectors(up, new THREE.Vector3(0, 1, 0));
|
|
171
|
+
if (forward)
|
|
172
|
+
return new THREE.Quaternion().setFromUnitVectors(forward, new THREE.Vector3(0, 0, -1));
|
|
173
|
+
return new THREE.Quaternion();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function axisVector(axis: Exclude<ModelImportSettings['forwardAxis'], 'auto'> | 'x' | 'y' | 'z') {
|
|
177
|
+
const sign = axis.startsWith('-') ? -1 : 1;
|
|
178
|
+
const name = axis.replace('-', '');
|
|
179
|
+
return new THREE.Vector3(
|
|
180
|
+
name === 'x' ? sign : 0,
|
|
181
|
+
name === 'y' ? sign : 0,
|
|
182
|
+
name === 'z' ? sign : 0,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function parseSourceModel(
|
|
187
|
+
primaryPath: string,
|
|
188
|
+
format: string,
|
|
189
|
+
bytes: Uint8Array,
|
|
190
|
+
): Promise<THREE.Object3D> {
|
|
191
|
+
const resourcePath = `${dirname(primaryPath)}/`;
|
|
192
|
+
switch (format) {
|
|
193
|
+
case 'fbx':
|
|
194
|
+
return new FBXLoader().parse(bytes.buffer as ArrayBuffer, resourcePath);
|
|
195
|
+
case 'obj': {
|
|
196
|
+
const loader = new OBJLoader();
|
|
197
|
+
const materialPath = primaryPath.replace(/\.obj$/i, '.mtl');
|
|
198
|
+
try {
|
|
199
|
+
const materials = new MTLLoader().parse(await readFile(materialPath, 'utf8'), resourcePath);
|
|
200
|
+
materials.preload();
|
|
201
|
+
loader.setMaterials(materials);
|
|
202
|
+
} catch {
|
|
203
|
+
// Geometry-only OBJ conversion remains valid when no MTL is present.
|
|
204
|
+
}
|
|
205
|
+
return loader.parse(new TextDecoder().decode(bytes));
|
|
206
|
+
}
|
|
207
|
+
case 'dae': {
|
|
208
|
+
return new ColladaLoader().parse(new TextDecoder().decode(bytes), resourcePath).scene;
|
|
209
|
+
}
|
|
210
|
+
case 'stl': {
|
|
211
|
+
const geometry = new STLLoader().parse(bytes.buffer as ArrayBuffer);
|
|
212
|
+
return new THREE.Mesh(
|
|
213
|
+
geometry,
|
|
214
|
+
new THREE.MeshStandardMaterial({ color: 0x8fb5da, roughness: 0.75 }),
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
case 'ply': {
|
|
218
|
+
const geometry = new PLYLoader().parse(bytes.buffer as ArrayBuffer);
|
|
219
|
+
if (!geometry.hasAttribute('normal')) geometry.computeVertexNormals();
|
|
220
|
+
return new THREE.Mesh(
|
|
221
|
+
geometry,
|
|
222
|
+
new THREE.MeshStandardMaterial({
|
|
223
|
+
color: geometry.hasAttribute('color') ? 0xffffff : 0xb8d09b,
|
|
224
|
+
vertexColors: geometry.hasAttribute('color'),
|
|
225
|
+
roughness: 0.8,
|
|
226
|
+
side: THREE.DoubleSide,
|
|
227
|
+
}),
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
case '3ds':
|
|
231
|
+
return new TDSLoader().parse(bytes.buffer as ArrayBuffer, resourcePath);
|
|
232
|
+
default:
|
|
233
|
+
throw new Error(`Built-in conversion does not support ${format || extname(primaryPath)}.`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Three's source loaders construct browser image elements even when the
|
|
238
|
+
* conversion runs in Node. JSDOM supplies that parser boundary; unresolved
|
|
239
|
+
* asynchronous images are removed below rather than passed to GLTFExporter
|
|
240
|
+
* as invalid zero-sized image objects. */
|
|
241
|
+
function installNodeDom(): void {
|
|
242
|
+
if (typeof document !== 'undefined' && typeof DOMParser !== 'undefined') return;
|
|
243
|
+
const { JSDOM } = createRequire(import.meta.url)('jsdom') as {
|
|
244
|
+
JSDOM: new () => {
|
|
245
|
+
window: {
|
|
246
|
+
document: Document;
|
|
247
|
+
DOMParser: typeof DOMParser;
|
|
248
|
+
HTMLImageElement: typeof HTMLImageElement;
|
|
249
|
+
HTMLCanvasElement: typeof HTMLCanvasElement;
|
|
250
|
+
Image: typeof Image;
|
|
251
|
+
};
|
|
252
|
+
};
|
|
253
|
+
};
|
|
254
|
+
const window = new JSDOM().window;
|
|
255
|
+
Object.assign(globalThis, {
|
|
256
|
+
document: window.document,
|
|
257
|
+
DOMParser: window.DOMParser,
|
|
258
|
+
HTMLImageElement: window.HTMLImageElement,
|
|
259
|
+
HTMLCanvasElement: window.HTMLCanvasElement,
|
|
260
|
+
Image: window.Image,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function discardUnloadedTextures(root: THREE.Object3D): void {
|
|
265
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: one bounded traversal removes only unresolved image slots across every supported material shape.
|
|
266
|
+
root.traverse((object) => {
|
|
267
|
+
const mesh = object as THREE.Mesh;
|
|
268
|
+
const materials = Array.isArray(mesh.material)
|
|
269
|
+
? mesh.material
|
|
270
|
+
: mesh.material
|
|
271
|
+
? [mesh.material]
|
|
272
|
+
: [];
|
|
273
|
+
for (const material of materials) {
|
|
274
|
+
const textured = material as THREE.Material & Record<string, unknown>;
|
|
275
|
+
for (const slot of materialTextureSlots) {
|
|
276
|
+
const value = textured[slot];
|
|
277
|
+
if (!(value instanceof THREE.Texture)) continue;
|
|
278
|
+
const image = value.image as { width?: number; height?: number } | null;
|
|
279
|
+
if (image && (image.width ?? 0) > 0 && (image.height ?? 0) > 0) continue;
|
|
280
|
+
textured[slot] = null;
|
|
281
|
+
value.dispose();
|
|
282
|
+
}
|
|
283
|
+
material.needsUpdate = true;
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function validateConvertedGlb(bytes: Uint8Array): Promise<void> {
|
|
289
|
+
const parsed = await new GLTFLoader().parseAsync(Uint8Array.from(bytes).buffer, '');
|
|
290
|
+
let renderable = false;
|
|
291
|
+
parsed.scene.traverse((object) => {
|
|
292
|
+
renderable ||= Boolean((object as THREE.Mesh).isMesh || (object as THREE.Points).isPoints);
|
|
293
|
+
});
|
|
294
|
+
disposeObject(parsed.scene);
|
|
295
|
+
if (!renderable && parsed.animations.length === 0) {
|
|
296
|
+
throw new Error('Converted GLB contains neither renderable geometry nor animation clips.');
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function installNodeFileReader(): void {
|
|
301
|
+
if (typeof FileReader !== 'undefined') return;
|
|
302
|
+
class NodeFileReader {
|
|
303
|
+
result: string | ArrayBuffer | null = null;
|
|
304
|
+
error: Error | null = null;
|
|
305
|
+
onloadend: ((event: ProgressEvent<FileReader>) => void) | null = null;
|
|
306
|
+
onerror: ((event: ProgressEvent<FileReader>) => void) | null = null;
|
|
307
|
+
readAsArrayBuffer(blob: Blob): void {
|
|
308
|
+
void blob.arrayBuffer().then(
|
|
309
|
+
(result) => {
|
|
310
|
+
this.result = result;
|
|
311
|
+
this.onloadend?.({ target: this } as unknown as ProgressEvent<FileReader>);
|
|
312
|
+
},
|
|
313
|
+
(error) => {
|
|
314
|
+
this.error = error instanceof Error ? error : new Error(String(error));
|
|
315
|
+
this.onerror?.({ target: this } as unknown as ProgressEvent<FileReader>);
|
|
316
|
+
},
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
readAsDataURL(blob: Blob): void {
|
|
320
|
+
void blob.arrayBuffer().then((result) => {
|
|
321
|
+
this.result = `data:${blob.type || 'application/octet-stream'};base64,${Buffer.from(result).toString('base64')}`;
|
|
322
|
+
this.onloadend?.({ target: this } as unknown as ProgressEvent<FileReader>);
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
Object.assign(globalThis, { FileReader: NodeFileReader });
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function disposeObject(root: THREE.Object3D): void {
|
|
330
|
+
root.traverse((object) => {
|
|
331
|
+
const renderable = object as THREE.Mesh;
|
|
332
|
+
renderable.geometry?.dispose();
|
|
333
|
+
const materials = Array.isArray(renderable.material)
|
|
334
|
+
? renderable.material
|
|
335
|
+
: renderable.material
|
|
336
|
+
? [renderable.material]
|
|
337
|
+
: [];
|
|
338
|
+
for (const material of materials) {
|
|
339
|
+
for (const value of Object.values(material))
|
|
340
|
+
if (value instanceof THREE.Texture) value.dispose();
|
|
341
|
+
material.dispose();
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
}
|
|
@@ -53,6 +53,7 @@
|
|
|
53
53
|
* (`docs/f13-bloom-composer-proof/record-fixed.mjs`).
|
|
54
54
|
*/
|
|
55
55
|
|
|
56
|
+
import type { WorldAdoptionEvent } from '@volter/editor-sdk/kit/world-adoption-event';
|
|
56
57
|
import type * as THREE from 'three';
|
|
57
58
|
import {
|
|
58
59
|
type CaptureWaitOptions,
|
|
@@ -144,29 +145,6 @@ export interface SceneCaptureOptions {
|
|
|
144
145
|
onWorldAdoption?: (event: WorldAdoptionEvent) => void;
|
|
145
146
|
}
|
|
146
147
|
|
|
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
148
|
|
|
171
149
|
/**
|
|
172
150
|
* How many distinct alternates are recorded before the trap stops looking.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The mixers a running world animates with, as the editor reads them: the registry the served
|
|
3
|
+
* animation stamp fills (`serving/animation-live-module.ts`) in the game's graph. An instrument
|
|
4
|
+
* that drives time on an object asks here for the mixer the game already made for it, so the
|
|
5
|
+
* skeleton keeps one writer.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type * as THREE from 'three';
|
|
9
|
+
|
|
10
|
+
export interface LiveMixer {
|
|
11
|
+
/** The call site that made it (`src/prefabs/Player.tsx:358:37`). */
|
|
12
|
+
readonly key: string;
|
|
13
|
+
readonly mixer: THREE.AnimationMixer;
|
|
14
|
+
/** Every clip the game has played through it, and every clip `useAnimations` was handed. */
|
|
15
|
+
readonly clips: ReadonlyMap<string, THREE.AnimationClip>;
|
|
16
|
+
/** The objects its actions animate. */
|
|
17
|
+
readonly roots: ReadonlySet<unknown>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface Registry {
|
|
21
|
+
readonly mixers: Map<unknown, LiveMixer>;
|
|
22
|
+
readonly listeners: Set<() => void>;
|
|
23
|
+
version: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const REGISTRY = Symbol.for('volter.three.animation.live');
|
|
27
|
+
|
|
28
|
+
function registry(): Registry {
|
|
29
|
+
const holder = globalThis as unknown as Record<symbol, Registry | undefined>;
|
|
30
|
+
return (holder[REGISTRY] ??= { mixers: new Map(), listeners: new Set(), version: 0 });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The live mixer that animates `object`, or null when the game animates it with none. */
|
|
34
|
+
export function liveMixerFor(object: THREE.Object3D): LiveMixer | null {
|
|
35
|
+
for (const entry of registry().mixers.values()) {
|
|
36
|
+
if (entry.clips.size === 0) continue;
|
|
37
|
+
if (entry.mixer.getRoot() === object || entry.roots.has(object)) return entry;
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Every mixer the running world's code has made, in the order it made them. */
|
|
43
|
+
export function liveMixers(): LiveMixer[] {
|
|
44
|
+
return [...registry().mixers.values()];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function subscribeLiveMixers(listener: () => void): () => void {
|
|
48
|
+
const listeners = registry().listeners;
|
|
49
|
+
listeners.add(listener);
|
|
50
|
+
return () => listeners.delete(listener);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function liveMixersVersion(): number {
|
|
54
|
+
return registry().version;
|
|
55
|
+
}
|
package/src/ecs/user-data.ts
CHANGED
|
@@ -42,10 +42,8 @@
|
|
|
42
42
|
*
|
|
43
43
|
* Animation (load-bearing — ED5 disposal contract):
|
|
44
44
|
* - `_animMixer` — THREE.AnimationMixer driving this subtree's clips.
|
|
45
|
-
* - `_animClips` — Map<string, AnimationClip> discovered on the GLTF.
|
|
46
45
|
* - `_availableClips` — string[] of clip names discovered on the GLTF
|
|
47
|
-
* (inspector dropdown
|
|
48
|
-
* - `_animationRuntime` — format-neutral live native mixer/action inspection.
|
|
46
|
+
* (inspector dropdown).
|
|
49
47
|
*
|
|
50
48
|
* Disposal contract:
|
|
51
49
|
* - `__sharedGeometry` — `true` when a mesh's geometry is shared/cached and MUST
|
|
@@ -119,7 +117,6 @@ import type { ConstraintMark } from '../adapter/constraint';
|
|
|
119
117
|
import type { Object3DAuthoringSubjectMark } from '../adapter/object3d-authoring-subject';
|
|
120
118
|
import type { ReflectionProbeMark } from '../adapter/reflection-probe';
|
|
121
119
|
import type { TriggerVolumeMark } from '../adapter/trigger-volume';
|
|
122
|
-
import type { AnimationRuntimeInspection } from '../animation/runtime-inspection';
|
|
123
120
|
import { ObjectMarkKeys, type ObjectMarkSchema } from './object-marks';
|
|
124
121
|
|
|
125
122
|
export type { EditorHelperType } from './object-marks';
|
|
@@ -141,9 +138,7 @@ export interface UserDataSchema extends ObjectMarkSchema {
|
|
|
141
138
|
_particleSystem: ParticleSystem;
|
|
142
139
|
gaussianSplat: { src: string; numSplats: number };
|
|
143
140
|
_animMixer: THREE.AnimationMixer;
|
|
144
|
-
_animClips: Map<string, THREE.AnimationClip>;
|
|
145
141
|
_availableClips: string[];
|
|
146
|
-
_animationRuntime: AnimationRuntimeInspection;
|
|
147
142
|
__sharedGeometry: boolean;
|
|
148
143
|
__shadeOrig: THREE.Material | THREE.Material[];
|
|
149
144
|
__shadeUnlit: THREE.Material[];
|
|
@@ -195,9 +190,7 @@ export const UserDataKeys = {
|
|
|
195
190
|
_particleSystem: '_particleSystem',
|
|
196
191
|
gaussianSplat: 'gaussianSplat',
|
|
197
192
|
_animMixer: '_animMixer',
|
|
198
|
-
_animClips: '_animClips',
|
|
199
193
|
_availableClips: '_availableClips',
|
|
200
|
-
_animationRuntime: '_animationRuntime',
|
|
201
194
|
__sharedGeometry: '__sharedGeometry',
|
|
202
195
|
__shadeOrig: '__shadeOrig',
|
|
203
196
|
__shadeUnlit: '__shadeUnlit',
|
|
@@ -71,6 +71,7 @@ export function traverseContent(
|
|
|
71
71
|
/** Scratch for one node's transformed geometry box — this module is synchronous
|
|
72
72
|
* and single-threaded, so one instance serves every call. */
|
|
73
73
|
const nodeBox = new THREE.Box3();
|
|
74
|
+
const frameMatrix = new THREE.Matrix4();
|
|
74
75
|
|
|
75
76
|
/**
|
|
76
77
|
* Is this box a measurement at all?
|
|
@@ -139,10 +140,15 @@ function expandByInstances(
|
|
|
139
140
|
target: THREE.Box3,
|
|
140
141
|
node: THREE.InstancedMesh,
|
|
141
142
|
geometryBox: THREE.Box3,
|
|
143
|
+
frame: THREE.Matrix4 | null,
|
|
142
144
|
): void {
|
|
143
145
|
for (let index = 0; index < node.count; index++) {
|
|
144
146
|
node.getMatrixAt(index, instanceMatrix);
|
|
145
|
-
|
|
147
|
+
// ONE transform into the target space: a box carried through two is re-boxed twice, and a
|
|
148
|
+
// rotated object's box grows at each step.
|
|
149
|
+
instanceMatrix.premultiply(node.matrixWorld);
|
|
150
|
+
if (frame) instanceMatrix.premultiply(frame);
|
|
151
|
+
instanceBox.copy(geometryBox).applyMatrix4(instanceMatrix);
|
|
146
152
|
if (!isFiniteBox(instanceBox)) continue;
|
|
147
153
|
target.union(instanceBox);
|
|
148
154
|
}
|
|
@@ -162,16 +168,26 @@ function expandByInstances(
|
|
|
162
168
|
* `InstancedMesh` is the one node three's own rule gets wrong for a LIVE world;
|
|
163
169
|
* see {@link expandByInstances}.
|
|
164
170
|
*/
|
|
165
|
-
function expandByNodeGeometry(
|
|
171
|
+
function expandByNodeGeometry(
|
|
172
|
+
target: THREE.Box3,
|
|
173
|
+
node: THREE.Object3D,
|
|
174
|
+
frame: THREE.Matrix4 | null = null,
|
|
175
|
+
): void {
|
|
166
176
|
const geometry = (node as THREE.Mesh).geometry as THREE.BufferGeometry | undefined;
|
|
167
177
|
if (!geometry) return;
|
|
168
178
|
const local = localRenderableBox(node, geometry);
|
|
169
179
|
if (!local) return;
|
|
170
180
|
if ((node as THREE.InstancedMesh).isInstancedMesh) {
|
|
171
|
-
expandByInstances(target, node as THREE.InstancedMesh, local);
|
|
181
|
+
expandByInstances(target, node as THREE.InstancedMesh, local, frame);
|
|
172
182
|
return;
|
|
173
183
|
}
|
|
174
|
-
|
|
184
|
+
if (frame) {
|
|
185
|
+
// One transform, for the same reason as the instances above.
|
|
186
|
+
frameMatrix.multiplyMatrices(frame, node.matrixWorld);
|
|
187
|
+
nodeBox.copy(local).applyMatrix4(frameMatrix);
|
|
188
|
+
} else {
|
|
189
|
+
nodeBox.copy(local).applyMatrix4(node.matrixWorld);
|
|
190
|
+
}
|
|
175
191
|
if (!isFiniteBox(nodeBox)) return;
|
|
176
192
|
target.union(nodeBox);
|
|
177
193
|
}
|
|
@@ -329,6 +345,24 @@ export function contentWorldBounds(
|
|
|
329
345
|
return expandBoxByContent(target, object);
|
|
330
346
|
}
|
|
331
347
|
|
|
348
|
+
/**
|
|
349
|
+
* `object`'s content box in a FRAME — `worldToFrame` carries world space into it (the
|
|
350
|
+
* inverse of the object's own `matrixWorld` gives its own axes, the box a rotated object's
|
|
351
|
+
* selection turns with in Godot). Same walk as {@link contentWorldBounds}.
|
|
352
|
+
*/
|
|
353
|
+
export function contentBoundsInFrame(
|
|
354
|
+
object: THREE.Object3D,
|
|
355
|
+
worldToFrame: THREE.Matrix4,
|
|
356
|
+
target: THREE.Box3 = new THREE.Box3(),
|
|
357
|
+
): THREE.Box3 {
|
|
358
|
+
target.makeEmpty();
|
|
359
|
+
traverseContent(object, (node) => {
|
|
360
|
+
node.updateWorldMatrix(false, false);
|
|
361
|
+
expandByNodeGeometry(target, node, worldToFrame);
|
|
362
|
+
});
|
|
363
|
+
return target;
|
|
364
|
+
}
|
|
365
|
+
|
|
332
366
|
/**
|
|
333
367
|
* The Bounds diagnostic's twelve-edge box, drawn on {@link contentWorldBounds}
|
|
334
368
|
* rather than `BoxHelper`'s own `setFromObject`.
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import * as THREE from 'three';
|
|
2
2
|
import { RoomEnvironment } from 'three/examples/jsm/environments/RoomEnvironment.js';
|
|
3
|
+
import { EXRLoader } from 'three/examples/jsm/loaders/EXRLoader.js';
|
|
4
|
+
import { HDRLoader } from 'three/examples/jsm/loaders/HDRLoader.js';
|
|
3
5
|
|
|
4
6
|
/** A baked IBL environment and the handle that frees its render target. */
|
|
5
7
|
export interface StandardEnvironment {
|
|
@@ -24,3 +26,17 @@ export function createStandardEnvironment(renderer: THREE.WebGLRenderer): Standa
|
|
|
24
26
|
pmrem.dispose();
|
|
25
27
|
}
|
|
26
28
|
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* An environment image (`@volter/editor-sdk/kit/environment-images`) as an equirectangular
|
|
32
|
+
* texture in scene-referred light. Read as half float: a sun past white stays bright, and half
|
|
33
|
+
* float filters linearly where full float cannot (phones).
|
|
34
|
+
*/
|
|
35
|
+
export async function loadEnvironmentImage(url: string, format: 'exr' | 'hdr'): Promise<THREE.DataTexture> {
|
|
36
|
+
const loader = format === 'exr' ? new EXRLoader() : new HDRLoader();
|
|
37
|
+
loader.setDataType(THREE.HalfFloatType);
|
|
38
|
+
const texture = await loader.loadAsync(url);
|
|
39
|
+
texture.mapping = THREE.EquirectangularReflectionMapping;
|
|
40
|
+
texture.colorSpace = THREE.LinearSRGBColorSpace;
|
|
41
|
+
return texture;
|
|
42
|
+
}
|