@galfus/gltf-loader 0.0.0-stage → 0.31.0-alpha
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/README.md +93 -2
- package/package.json +43 -4
- package/src/animation.ts +160 -0
- package/src/bounds.ts +160 -0
- package/src/decoders/resolver.ts +131 -0
- package/src/factory.ts +303 -0
- package/src/index.ts +185 -0
- package/src/instance.ts +126 -0
- package/src/parser.ts +334 -0
- package/src/resources.ts +391 -0
- package/src/scene.ts +354 -0
- package/src/types.ts +270 -0
- package/src/zip.ts +173 -0
package/src/factory.ts
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Two-stage glTF asset factory and modular sub-factory implementations.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { Realm3D, TextureId } from '@galfus/engine';
|
|
6
|
+
import { createAnimationPlayer3D } from '@galfus/engine';
|
|
7
|
+
import type {
|
|
8
|
+
ModelAssetFactory,
|
|
9
|
+
ModelBounds,
|
|
10
|
+
ModelImportOptions,
|
|
11
|
+
ModelInstance,
|
|
12
|
+
ModelInstantiateOptions,
|
|
13
|
+
SubModelAssetFactory
|
|
14
|
+
} from '@galfus/model-loader-types';
|
|
15
|
+
import { compileAnimationClips, setupSkins } from './animation';
|
|
16
|
+
import { computeGltfBounds } from './bounds';
|
|
17
|
+
import { GltfInstance } from './instance';
|
|
18
|
+
import type { UploadedGltfResources } from './resources';
|
|
19
|
+
import { spawnSceneGraph } from './scene';
|
|
20
|
+
import type { GlTF } from './types';
|
|
21
|
+
|
|
22
|
+
export interface GltfAssetFactoryInit {
|
|
23
|
+
assetName: string;
|
|
24
|
+
realm: Realm3D;
|
|
25
|
+
gltf: GlTF;
|
|
26
|
+
buffers: Uint8Array[];
|
|
27
|
+
resources: UploadedGltfResources;
|
|
28
|
+
importOptions?: ModelImportOptions;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Concrete SubModelAssetFactory spawning a specific sub-tree or scene
|
|
33
|
+
* while sharing the GPU resources uploaded by the parent GltfAssetFactory.
|
|
34
|
+
*/
|
|
35
|
+
export class SubGltfAssetFactory implements SubModelAssetFactory {
|
|
36
|
+
readonly name: string;
|
|
37
|
+
private readonly _parent: GltfAssetFactory;
|
|
38
|
+
private readonly _targetNodeIndices: number[];
|
|
39
|
+
private readonly _activeInstances = new Set<GltfInstance>();
|
|
40
|
+
private readonly _forgetInstance = (instance: GltfInstance): void => {
|
|
41
|
+
this._activeInstances.delete(instance);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
constructor(name: string, parent: GltfAssetFactory, targetNodeIndices: number[]) {
|
|
45
|
+
this.name = name;
|
|
46
|
+
this._parent = parent;
|
|
47
|
+
this._targetNodeIndices = [...targetNodeIndices];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Spawns a new independent instance of this specific sub-tree.
|
|
52
|
+
*/
|
|
53
|
+
instantiate(options?: ModelInstantiateOptions): ModelInstance {
|
|
54
|
+
const instance = this._parent._instantiateInternal(
|
|
55
|
+
options,
|
|
56
|
+
this._targetNodeIndices,
|
|
57
|
+
this._forgetInstance
|
|
58
|
+
);
|
|
59
|
+
this._activeInstances.add(instance);
|
|
60
|
+
return instance;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Destroys all active instances spawned through this sub-factory.
|
|
65
|
+
*/
|
|
66
|
+
disposeAllInstances(): void {
|
|
67
|
+
for (const inst of this._activeInstances) {
|
|
68
|
+
inst.dispose();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Primary glTF asset factory.
|
|
75
|
+
*
|
|
76
|
+
* Holds all shared GPU resources (geometries, materials, textures) in VRAM and
|
|
77
|
+
* provides high-performance instantiation of ECS entities and sub-factories.
|
|
78
|
+
*
|
|
79
|
+
* @example
|
|
80
|
+
* ```typescript
|
|
81
|
+
* const factory = await loadGltfAsset(realm, data);
|
|
82
|
+
* const character1 = factory.instantiate({ transform: { position: [0, 0, 0] } });
|
|
83
|
+
* const character2 = factory.instantiate({ transform: { position: [5, 0, 0] } });
|
|
84
|
+
*
|
|
85
|
+
* // Full cleanup
|
|
86
|
+
* factory.dispose();
|
|
87
|
+
* ```
|
|
88
|
+
*/
|
|
89
|
+
export class GltfAssetFactory implements ModelAssetFactory {
|
|
90
|
+
readonly assetName: string;
|
|
91
|
+
readonly createdGeometryIds: readonly number[];
|
|
92
|
+
readonly createdMaterialIds: readonly number[];
|
|
93
|
+
readonly createdTextureIds: readonly number[];
|
|
94
|
+
readonly animationNames: readonly string[];
|
|
95
|
+
readonly bounds?: ModelBounds;
|
|
96
|
+
|
|
97
|
+
private readonly _realm: Realm3D;
|
|
98
|
+
private readonly _gltf: GlTF;
|
|
99
|
+
private readonly _buffers: Uint8Array[];
|
|
100
|
+
private readonly _resources: UploadedGltfResources;
|
|
101
|
+
private readonly _importOptions?: ModelImportOptions;
|
|
102
|
+
private readonly _activeInstances = new Set<GltfInstance>();
|
|
103
|
+
private _nextInstanceId = 1;
|
|
104
|
+
private _isDisposed = false;
|
|
105
|
+
|
|
106
|
+
constructor(init: GltfAssetFactoryInit) {
|
|
107
|
+
this.assetName = init.assetName;
|
|
108
|
+
this._realm = init.realm;
|
|
109
|
+
this._gltf = init.gltf;
|
|
110
|
+
this._buffers = init.buffers;
|
|
111
|
+
this._resources = init.resources;
|
|
112
|
+
this._importOptions = init.importOptions;
|
|
113
|
+
|
|
114
|
+
this.createdGeometryIds = Object.freeze([...init.resources.createdGeometryIds]);
|
|
115
|
+
this.createdMaterialIds = Object.freeze([...init.resources.createdMaterialIds]);
|
|
116
|
+
this.createdTextureIds = Object.freeze([...init.resources.createdTextureIds]);
|
|
117
|
+
this.animationNames = Object.freeze(
|
|
118
|
+
(init.gltf.animations ?? []).map((a, i) => a.name ?? `Animation_${i}`)
|
|
119
|
+
);
|
|
120
|
+
this.bounds = computeGltfBounds(init.gltf);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Indicates whether this factory and its GPU resources have been destroyed. */
|
|
124
|
+
get isDisposed(): boolean {
|
|
125
|
+
return this._isDisposed;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Spawns a new independent instance of the complete model graph in the ECS.
|
|
130
|
+
*/
|
|
131
|
+
instantiate(options?: ModelInstantiateOptions): ModelInstance {
|
|
132
|
+
return this._instantiateInternal(options);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Internal instantiation driver supporting scoped root node filtering for sub-factories.
|
|
137
|
+
* @internal
|
|
138
|
+
*/
|
|
139
|
+
_instantiateInternal(
|
|
140
|
+
options?: ModelInstantiateOptions,
|
|
141
|
+
rootNodeIndices?: number[],
|
|
142
|
+
onDispose?: (instance: GltfInstance) => void
|
|
143
|
+
): GltfInstance {
|
|
144
|
+
if (this._isDisposed) {
|
|
145
|
+
throw new Error(`Cannot instantiate from disposed GltfAssetFactory '${this.assetName}'`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const instanceId = this._nextInstanceId++;
|
|
149
|
+
|
|
150
|
+
// 1. Spawn Scene Graph entities in the ECS
|
|
151
|
+
const sceneResult = spawnSceneGraph(
|
|
152
|
+
this._realm,
|
|
153
|
+
this._gltf,
|
|
154
|
+
this._buffers,
|
|
155
|
+
this._resources,
|
|
156
|
+
options,
|
|
157
|
+
this._importOptions,
|
|
158
|
+
rootNodeIndices
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
// 2. Bind Skeletal Skins if present
|
|
162
|
+
setupSkins(this._realm, this._gltf, this._buffers, sceneResult);
|
|
163
|
+
|
|
164
|
+
// 3. Compile Animation Clips and bind AnimationPlayer3D
|
|
165
|
+
const clips = compileAnimationClips(
|
|
166
|
+
this._gltf,
|
|
167
|
+
this._buffers,
|
|
168
|
+
sceneResult.nodeEntityMap,
|
|
169
|
+
sceneResult.primitiveEntities
|
|
170
|
+
);
|
|
171
|
+
let animationPlayer: ReturnType<typeof createAnimationPlayer3D> | undefined;
|
|
172
|
+
|
|
173
|
+
if (clips.length > 0) {
|
|
174
|
+
animationPlayer = createAnimationPlayer3D();
|
|
175
|
+
for (const clip of clips) {
|
|
176
|
+
animationPlayer.addClip(clip);
|
|
177
|
+
}
|
|
178
|
+
this._realm.world.attachComponent(sceneResult.rootEntityId, animationPlayer);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// 4. Construct GltfInstance wrapper
|
|
182
|
+
const instance = new GltfInstance({
|
|
183
|
+
instanceId,
|
|
184
|
+
realm: this._realm,
|
|
185
|
+
rootEntityId: sceneResult.rootEntityId,
|
|
186
|
+
entities: sceneResult.entities,
|
|
187
|
+
instancedModelIds: sceneResult.instancedModelIds,
|
|
188
|
+
animationPlayer,
|
|
189
|
+
onDispose: (inst) => {
|
|
190
|
+
this._activeInstances.delete(inst);
|
|
191
|
+
onDispose?.(inst);
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
this._activeInstances.add(instance);
|
|
196
|
+
return instance;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Creates a sub-factory focused on a specific node or sub-tree by name or numerical index.
|
|
201
|
+
*
|
|
202
|
+
* @param nodeNameOrIndex The target node name or index.
|
|
203
|
+
* @returns SubModelAssetFactory for modular instantiation.
|
|
204
|
+
*/
|
|
205
|
+
createSubFactory(nodeNameOrIndex: string | number): SubModelAssetFactory {
|
|
206
|
+
if (this._isDisposed) {
|
|
207
|
+
throw new Error(
|
|
208
|
+
`Cannot create sub-factory from disposed GltfAssetFactory '${this.assetName}'`
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
let nodeIdx: number | undefined;
|
|
213
|
+
if (typeof nodeNameOrIndex === 'number') {
|
|
214
|
+
nodeIdx = nodeNameOrIndex;
|
|
215
|
+
} else {
|
|
216
|
+
const nodes = this._gltf.nodes ?? [];
|
|
217
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
218
|
+
if (nodes[i]!.name === nodeNameOrIndex) {
|
|
219
|
+
nodeIdx = i;
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (nodeIdx === undefined || !this._gltf.nodes?.[nodeIdx]) {
|
|
226
|
+
throw new Error(`Node '${nodeNameOrIndex}' not found in glTF asset '${this.assetName}'`);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const subName = `${this.assetName}::${this._gltf.nodes[nodeIdx]!.name ?? `Node_${nodeIdx}`}`;
|
|
230
|
+
return new SubGltfAssetFactory(subName, this, [nodeIdx]);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Creates a sub-factory focused on an alternative scene by index.
|
|
235
|
+
*
|
|
236
|
+
* @param sceneIndex The zero-based scene index.
|
|
237
|
+
* @returns SubModelAssetFactory for scene-scoped instantiation.
|
|
238
|
+
*/
|
|
239
|
+
createSubFactoryByScene(sceneIndex: number): SubModelAssetFactory {
|
|
240
|
+
if (this._isDisposed) {
|
|
241
|
+
throw new Error(
|
|
242
|
+
`Cannot create sub-factory from disposed GltfAssetFactory '${this.assetName}'`
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const scene = this._gltf.scenes?.[sceneIndex];
|
|
247
|
+
if (!scene) {
|
|
248
|
+
throw new Error(`Scene index ${sceneIndex} out of bounds in glTF asset '${this.assetName}'`);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const subName = `${this.assetName}::${scene.name ?? `Scene_${sceneIndex}`}`;
|
|
252
|
+
return new SubGltfAssetFactory(subName, this, scene.nodes ?? []);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Destroys all currently active instances spawned by this factory.
|
|
257
|
+
*/
|
|
258
|
+
disposeAllInstances(): void {
|
|
259
|
+
for (const inst of this._activeInstances) {
|
|
260
|
+
inst.dispose();
|
|
261
|
+
}
|
|
262
|
+
this._activeInstances.clear();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Terminal teardown: destroys all instances and frees all geometries, materials,
|
|
267
|
+
* and textures from the Realm and GPU.
|
|
268
|
+
*/
|
|
269
|
+
dispose(): void {
|
|
270
|
+
if (this._isDisposed) return;
|
|
271
|
+
this._isDisposed = true;
|
|
272
|
+
|
|
273
|
+
// 1. Destroy all spawned instances
|
|
274
|
+
this.disposeAllInstances();
|
|
275
|
+
|
|
276
|
+
// 2. Free shared Realm Geometries
|
|
277
|
+
for (const geoId of this.createdGeometryIds) {
|
|
278
|
+
try {
|
|
279
|
+
this._realm.destroyGeometry(geoId);
|
|
280
|
+
} catch {
|
|
281
|
+
// Ignored during shutdown
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// 3. Free shared Realm Materials
|
|
286
|
+
for (const matId of this.createdMaterialIds) {
|
|
287
|
+
try {
|
|
288
|
+
this._realm.destroyMaterial(matId);
|
|
289
|
+
} catch {
|
|
290
|
+
// Ignored during shutdown
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// 4. Free shared GPU Textures
|
|
295
|
+
for (const texId of this.createdTextureIds) {
|
|
296
|
+
try {
|
|
297
|
+
this._realm.resources.disposeTexture(texId as unknown as TextureId);
|
|
298
|
+
} catch {
|
|
299
|
+
// Ignored during shutdown
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module @galfus/gltf-loader
|
|
3
|
+
*
|
|
4
|
+
* Canonical glTF 2.0 and GLB asset loader for Galfus Engine.
|
|
5
|
+
* Supports auto-decoders, two-stage GPU asset caching, skeletal skinning,
|
|
6
|
+
* animation clips, GPU instancing, and modular sub-factories.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Realm3D } from '@galfus/engine';
|
|
10
|
+
import type { ModelImportOptions } from '@galfus/model-loader-types';
|
|
11
|
+
import { resolveDecoders } from './decoders/resolver';
|
|
12
|
+
import { GltfAssetFactory } from './factory';
|
|
13
|
+
import { parseGltf } from './parser';
|
|
14
|
+
import { uploadGltfResources } from './resources';
|
|
15
|
+
import type { GlTF, GltfMaterialAlphaModeResolver } from './types';
|
|
16
|
+
import { createZipResolver, findPrimaryGltfPath, isZipContainer, unpackZipArchive } from './zip';
|
|
17
|
+
|
|
18
|
+
export * from './animation';
|
|
19
|
+
export * from './bounds';
|
|
20
|
+
export * from './decoders/resolver';
|
|
21
|
+
export * from './factory';
|
|
22
|
+
export * from './instance';
|
|
23
|
+
export * from './parser';
|
|
24
|
+
export * from './resources';
|
|
25
|
+
export * from './scene';
|
|
26
|
+
export * from './types';
|
|
27
|
+
export * from './zip';
|
|
28
|
+
export * from '@galfus/model-loader-types';
|
|
29
|
+
|
|
30
|
+
export interface GltfLoadOptions extends ModelImportOptions {
|
|
31
|
+
/** Overrides a material's alpha mode for applications that need a specific rendering policy. */
|
|
32
|
+
resolveMaterialAlphaMode?: GltfMaterialAlphaModeResolver;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Resolves source input into a contiguous Uint8Array byte buffer.
|
|
37
|
+
*/
|
|
38
|
+
async function resolveSourceBytes(source: Uint8Array | ArrayBuffer | string): Promise<Uint8Array> {
|
|
39
|
+
if (typeof source !== 'string') {
|
|
40
|
+
return source instanceof Uint8Array ? source : new Uint8Array(source);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// HTTP(S) URL
|
|
44
|
+
if (source.startsWith('http://') || source.startsWith('https://')) {
|
|
45
|
+
const res = await fetch(source);
|
|
46
|
+
if (!res.ok) {
|
|
47
|
+
throw new Error(`Failed to fetch glTF asset from "${source}": HTTP ${res.status}`);
|
|
48
|
+
}
|
|
49
|
+
const buf = await res.arrayBuffer();
|
|
50
|
+
return new Uint8Array(buf);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Node.js or Bun local filesystem path
|
|
54
|
+
if (typeof process !== 'undefined' && process.versions?.node) {
|
|
55
|
+
const { readFile } = await import('node:fs/promises');
|
|
56
|
+
const nodeBuf = await readFile(source);
|
|
57
|
+
return new Uint8Array(nodeBuf.buffer, nodeBuf.byteOffset, nodeBuf.byteLength);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Fallback fetch for browser environments
|
|
61
|
+
const res = await fetch(source);
|
|
62
|
+
if (!res.ok) {
|
|
63
|
+
throw new Error(`Failed to load glTF asset from "${source}": HTTP ${res.status}`);
|
|
64
|
+
}
|
|
65
|
+
const buf = await res.arrayBuffer();
|
|
66
|
+
return new Uint8Array(buf);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Loads a glTF 2.0 (.gltf, .glb, or .zip archive) asset into a high-performance two-stage GltfAssetFactory.
|
|
71
|
+
*
|
|
72
|
+
* Transparently accepts:
|
|
73
|
+
* 1. Monolithic binary .glb containers (in memory or file path).
|
|
74
|
+
* 2. Standalone .gltf JSON files with embedded data URIs.
|
|
75
|
+
* 3. Compact .zip archives containing .gltf, .bin buffers, and texture subdirectories.
|
|
76
|
+
*
|
|
77
|
+
* Uploads all shared geometries, PBR materials, and textures to the GPU once,
|
|
78
|
+
* resolving compression extensions (Draco, Meshopt) automatically.
|
|
79
|
+
*
|
|
80
|
+
* @param realm The destination Realm3D where resources and entities are instantiated.
|
|
81
|
+
* @param source Raw glTF binary (.glb), JSON (.gltf), ZIP (.zip) bytes, or file path / URL.
|
|
82
|
+
* @param options Optional import filters, node renamers, and camera/light toggles.
|
|
83
|
+
* @returns Ready-to-use GltfAssetFactory for spawning ECS instances.
|
|
84
|
+
*
|
|
85
|
+
* @example
|
|
86
|
+
* ```typescript
|
|
87
|
+
* import { loadGltfAsset } from '@galfus/gltf-loader';
|
|
88
|
+
*
|
|
89
|
+
* // 1. Load from ZIP archive file path
|
|
90
|
+
* const factoryA = await loadGltfAsset(realm, './models/character.zip');
|
|
91
|
+
*
|
|
92
|
+
* // 2. Load from raw GLB bytes
|
|
93
|
+
* const factoryB = await loadGltfAsset(realm, glbData);
|
|
94
|
+
*
|
|
95
|
+
* const instance = factoryA.instantiate({ transform: { position: [0, 0, 0] } });
|
|
96
|
+
* ```
|
|
97
|
+
*/
|
|
98
|
+
export async function loadGltfAsset(
|
|
99
|
+
realm: Realm3D,
|
|
100
|
+
source: Uint8Array | ArrayBuffer | string,
|
|
101
|
+
options?: GltfLoadOptions
|
|
102
|
+
): Promise<GltfAssetFactory> {
|
|
103
|
+
const bytes = await resolveSourceBytes(source);
|
|
104
|
+
|
|
105
|
+
let gltf: GlTF;
|
|
106
|
+
let buffers: Uint8Array[];
|
|
107
|
+
let externalResolver: ((uri: string) => Promise<Uint8Array>) | undefined;
|
|
108
|
+
|
|
109
|
+
// A) Check if source is a ZIP archive
|
|
110
|
+
if (isZipContainer(bytes)) {
|
|
111
|
+
const fileMap = unpackZipArchive(bytes);
|
|
112
|
+
const primaryGltfPath = findPrimaryGltfPath(fileMap);
|
|
113
|
+
if (!primaryGltfPath) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
'Invalid ZIP model archive: no .gltf or .glb file found in the decompressed directory tree'
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
const primaryBytes = fileMap.get(primaryGltfPath)!;
|
|
119
|
+
externalResolver = createZipResolver(fileMap, primaryGltfPath);
|
|
120
|
+
const parsed = await parseGltf(primaryBytes, externalResolver);
|
|
121
|
+
gltf = parsed.gltf;
|
|
122
|
+
buffers = parsed.buffers;
|
|
123
|
+
} else {
|
|
124
|
+
// B) Standard GLB binary or standalone glTF JSON
|
|
125
|
+
if (typeof source === 'string') {
|
|
126
|
+
if (/^https?:\/\//i.test(source)) {
|
|
127
|
+
externalResolver = async (uri) => resolveSourceBytes(new URL(uri, source).href);
|
|
128
|
+
} else if (typeof process !== 'undefined' && process.versions?.node) {
|
|
129
|
+
const path = await import('node:path');
|
|
130
|
+
externalResolver = async (uri) =>
|
|
131
|
+
resolveSourceBytes(path.resolve(path.dirname(source), decodeURIComponent(uri)));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const parsed = await parseGltf(bytes, externalResolver);
|
|
135
|
+
gltf = parsed.gltf;
|
|
136
|
+
buffers = parsed.buffers;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// 2. Automatically resolve required compression decoders (Draco, Meshopt)
|
|
140
|
+
const decoders = await resolveDecoders(gltf, options?.decoders);
|
|
141
|
+
|
|
142
|
+
if (decoders.meshopt) {
|
|
143
|
+
for (const view of gltf.bufferViews ?? []) {
|
|
144
|
+
const ext = view.extensions?.EXT_meshopt_compression;
|
|
145
|
+
if (!ext) continue;
|
|
146
|
+
const sourceBuffer = buffers[ext.buffer];
|
|
147
|
+
if (!sourceBuffer) throw new Error('Missing Meshopt source buffer');
|
|
148
|
+
const offset = ext.byteOffset ?? 0;
|
|
149
|
+
const decoded = await decoders.meshopt.decodeGpuBuffer(
|
|
150
|
+
ext.count,
|
|
151
|
+
ext.byteStride,
|
|
152
|
+
sourceBuffer.subarray(offset, offset + ext.byteLength),
|
|
153
|
+
ext.mode,
|
|
154
|
+
ext.filter
|
|
155
|
+
);
|
|
156
|
+
if (decoded.byteLength !== ext.count * ext.byteStride)
|
|
157
|
+
throw new Error('Invalid Meshopt decoded buffer length');
|
|
158
|
+
view.buffer = buffers.length;
|
|
159
|
+
view.byteOffset = 0;
|
|
160
|
+
view.byteLength = decoded.byteLength;
|
|
161
|
+
buffers.push(decoded);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// 3. Upload shared GPU resources (geometries in canonical 136-byte format, PBR materials, textures)
|
|
166
|
+
const resources = await uploadGltfResources(
|
|
167
|
+
realm,
|
|
168
|
+
gltf,
|
|
169
|
+
buffers,
|
|
170
|
+
decoders,
|
|
171
|
+
externalResolver,
|
|
172
|
+
options?.resolveMaterialAlphaMode
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
// 4. Construct and return primary asset factory
|
|
176
|
+
const assetName = gltf.asset.generator ?? 'glTF_Asset';
|
|
177
|
+
return new GltfAssetFactory({
|
|
178
|
+
assetName,
|
|
179
|
+
realm,
|
|
180
|
+
gltf,
|
|
181
|
+
buffers,
|
|
182
|
+
resources,
|
|
183
|
+
importOptions: options
|
|
184
|
+
});
|
|
185
|
+
}
|
package/src/instance.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Concrete implementation of ModelInstance for spawned glTF scene graphs.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { AnimationPlayer3DComponent, Realm3D } from '@galfus/engine';
|
|
6
|
+
import type { ModelInstance } from '@galfus/model-loader-types';
|
|
7
|
+
|
|
8
|
+
export interface GltfInstanceInit {
|
|
9
|
+
instanceId: number;
|
|
10
|
+
realm: Realm3D;
|
|
11
|
+
rootEntityId: number;
|
|
12
|
+
entities: number[];
|
|
13
|
+
instancedModelIds?: number[];
|
|
14
|
+
animationPlayer?: AnimationPlayer3DComponent;
|
|
15
|
+
onDispose?: (instance: GltfInstance) => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Concrete ModelInstance representing a live glTF scene graph in the ECS.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* ```typescript
|
|
23
|
+
* const instance = factory.instantiate();
|
|
24
|
+
* const head = instance.getEntityByName('Head');
|
|
25
|
+
* instance.dispose(); // Destroys entities while preserving shared GPU assets
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
export class GltfInstance implements ModelInstance {
|
|
29
|
+
readonly instanceId: number;
|
|
30
|
+
readonly rootEntityId: number;
|
|
31
|
+
readonly entities: readonly number[];
|
|
32
|
+
readonly animationPlayer?: AnimationPlayer3DComponent;
|
|
33
|
+
|
|
34
|
+
private readonly _realm: Realm3D;
|
|
35
|
+
private readonly _instancedModelIds: number[];
|
|
36
|
+
private readonly _onDispose?: (instance: GltfInstance) => void;
|
|
37
|
+
private _isDisposed = false;
|
|
38
|
+
|
|
39
|
+
constructor(init: GltfInstanceInit) {
|
|
40
|
+
this.instanceId = init.instanceId;
|
|
41
|
+
this._realm = init.realm;
|
|
42
|
+
this.rootEntityId = init.rootEntityId;
|
|
43
|
+
this.entities = Object.freeze([...init.entities]);
|
|
44
|
+
this._instancedModelIds = init.instancedModelIds ? [...init.instancedModelIds] : [];
|
|
45
|
+
this.animationPlayer = init.animationPlayer;
|
|
46
|
+
this._onDispose = init.onDispose;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Indicates whether this instance has already been destroyed. */
|
|
50
|
+
get isDisposed(): boolean {
|
|
51
|
+
return this._isDisposed;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Resolves an entity ID by its assigned name within this instance.
|
|
56
|
+
*
|
|
57
|
+
* @param name The entity name to look up.
|
|
58
|
+
* @returns Entity ID or undefined if not found.
|
|
59
|
+
*/
|
|
60
|
+
getEntityByName(name: string): number | undefined {
|
|
61
|
+
if (this._isDisposed) return undefined;
|
|
62
|
+
for (const entityId of this.entities) {
|
|
63
|
+
if (this._realm.getEntityName(entityId) === name) {
|
|
64
|
+
return entityId;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Queries all entities within this instance matching a metadata key and optional value.
|
|
72
|
+
*
|
|
73
|
+
* @param key The metadata key.
|
|
74
|
+
* @param value Optional expected value to match.
|
|
75
|
+
* @returns Array of matching entity IDs.
|
|
76
|
+
*/
|
|
77
|
+
getEntitiesByMetadata(key: string, value?: unknown): number[] {
|
|
78
|
+
if (this._isDisposed) return [];
|
|
79
|
+
const results: number[] = [];
|
|
80
|
+
for (const entityId of this.entities) {
|
|
81
|
+
const val = this._realm.getMetadataKV(entityId, key);
|
|
82
|
+
if (val !== undefined) {
|
|
83
|
+
if (value === undefined || val === value) {
|
|
84
|
+
results.push(entityId);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return results;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Destroys all ECS entities, stop bound animations, and frees hardware instanced draws.
|
|
93
|
+
*
|
|
94
|
+
* Safe and idempotent. Shared textures, materials, and geometries in the factory remain alive.
|
|
95
|
+
*/
|
|
96
|
+
dispose(): void {
|
|
97
|
+
if (this._isDisposed) return;
|
|
98
|
+
this._isDisposed = true;
|
|
99
|
+
|
|
100
|
+
// 1. Stop animation player if present
|
|
101
|
+
if (this.animationPlayer) {
|
|
102
|
+
this.animationPlayer.stop();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 2. Destroy hardware instanced models
|
|
106
|
+
for (const instId of this._instancedModelIds) {
|
|
107
|
+
try {
|
|
108
|
+
this._realm.destroyInstancedModel(instId);
|
|
109
|
+
} catch {
|
|
110
|
+
// Ignored during shutdown
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 3. Destroy all ECS entities belonging to this instance
|
|
115
|
+
for (const entityId of this.entities) {
|
|
116
|
+
try {
|
|
117
|
+
this._realm.destroyEntity(entityId);
|
|
118
|
+
} catch {
|
|
119
|
+
// Ignored if entity was already destroyed
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 4. Notify parent factory of disposal
|
|
124
|
+
this._onDispose?.(this);
|
|
125
|
+
}
|
|
126
|
+
}
|