@galfus/gltf-loader 0.0.0-stage → 0.31.1-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/README.md
CHANGED
|
@@ -1,3 +1,94 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @galfus/gltf-loader
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Canonical **glTF 2.0** and **GLB** asset loader for the **Galfus Engine** ecosystem.
|
|
4
|
+
|
|
5
|
+
Implements the official Khronos glTF 2.0 specification with automatic compression decoders (Draco, Meshopt), two-stage GPU asset caching, skeletal skinning, animation clips, GPU instancing (`EXT_mesh_gpu_instancing`), and modular sub-factories.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
- **Dual Format Support**: Seamlessly loads both binary `.glb` containers and text `.gltf` files with embedded Base64 data URIs or binary chunks.
|
|
12
|
+
- **Two-Stage Asset Lifecycle**:
|
|
13
|
+
- `GltfAssetFactory`: Uploads shared geometries (packed into Galfus canonical 136-byte stride), PBR materials, and textures to the GPU **once**.
|
|
14
|
+
- `GltfInstance`: Spawns lightweight, independent ECS entities sharing the pre-uploaded GPU assets.
|
|
15
|
+
- **Modular Sub-Factories (`createSubFactory`)**: Isolate and instantiate individual sub-trees or alternate scenes from modular asset packs without duplicating VRAM.
|
|
16
|
+
- **Auto-Decoders (Zero Configuration)**: Automatically detects `KHR_draco_mesh_compression` and `EXT_meshopt_compression` extensions in headers and initializes decoders on demand.
|
|
17
|
+
- **Hardware GPU Instancing**: Automatically maps `EXT_mesh_gpu_instancing` nodes to Galfus native 32-float instance buffers, rendering thousands of objects in a single draw call.
|
|
18
|
+
- **Skeletal Skinning & Animations**: Binds glTF skins to `Skin3DComponent` (solved via `HierarchyCoordinator`) and compiles multi-channel animations for `AnimationPlayer3DComponent`.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
bun add @galfus/gltf-loader @galfus/model-loader-types @galfus/engine
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## Usage
|
|
31
|
+
|
|
32
|
+
### 1. Basic Loading and Instantiation
|
|
33
|
+
|
|
34
|
+
```typescript
|
|
35
|
+
import { createBunTransport } from '@galfus/transport-bun';
|
|
36
|
+
import { createRuntime } from '@galfus/engine';
|
|
37
|
+
import { loadGltfAsset } from '@galfus/gltf-loader';
|
|
38
|
+
|
|
39
|
+
// Initialize engine and 3D Realm
|
|
40
|
+
const transport = await createBunTransport({ targetFps: 60 });
|
|
41
|
+
const runtime = createRuntime(transport);
|
|
42
|
+
const realm3d = runtime.createRealm3D(1);
|
|
43
|
+
|
|
44
|
+
// Load GLB file bytes
|
|
45
|
+
const glbData = await Bun.file('models/character.glb').bytes();
|
|
46
|
+
const factory = await loadGltfAsset(realm3d, glbData);
|
|
47
|
+
|
|
48
|
+
// Spawn multiple characters sharing the same GPU mesh & textures
|
|
49
|
+
const char1 = factory.instantiate({ transform: { position: [0, 0, 0] } });
|
|
50
|
+
const char2 = factory.instantiate({ transform: { position: [5, 0, 0] } });
|
|
51
|
+
|
|
52
|
+
// Play animations
|
|
53
|
+
char1.animationPlayer?.play('Run', 'repeat');
|
|
54
|
+
|
|
55
|
+
// Cleanup:
|
|
56
|
+
char1.dispose(); // Destroys only char1 ECS entities
|
|
57
|
+
factory.dispose(); // Destroys all instances and purges VRAM textures/meshes
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
### 2. Modular Sub-Factories (Asset Kits)
|
|
63
|
+
|
|
64
|
+
```typescript
|
|
65
|
+
// Load a dungeon kit containing multiple modular pieces in one GLB
|
|
66
|
+
const dungeonKit = await loadGltfAsset(realm3d, dungeonKitBytes);
|
|
67
|
+
|
|
68
|
+
// Create sub-factories for specific modular pieces
|
|
69
|
+
const wallFactory = dungeonKit.createSubFactory('Wall_Straight');
|
|
70
|
+
const doorFactory = dungeonKit.createSubFactory('Door_Arch');
|
|
71
|
+
|
|
72
|
+
// Instantiate 20 walls with zero VRAM duplication
|
|
73
|
+
for (let i = 0; i < 20; i++) {
|
|
74
|
+
wallFactory.instantiate({ transform: { position: [i * 2, 0, 0] } });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Destroy only the walls
|
|
78
|
+
wallFactory.disposeAllInstances();
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
### 3. Granular Entity Queries and Attachment
|
|
84
|
+
|
|
85
|
+
```typescript
|
|
86
|
+
const instance = factory.instantiate();
|
|
87
|
+
|
|
88
|
+
// Query entity by name assigned in authoring tool (e.g. Blender)
|
|
89
|
+
const handEntity = instance.getEntityByName('RightHand');
|
|
90
|
+
if (handEntity !== undefined) {
|
|
91
|
+
// Attach accessory to bone
|
|
92
|
+
realm3d.hierarchy.attach(swordEntity, handEntity);
|
|
93
|
+
}
|
|
94
|
+
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,45 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@galfus/gltf-loader",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.31.1-alpha",
|
|
4
|
+
"description": "Fast, comprehensive glTF / GLB 2.0 asset loader for Galfus Engine",
|
|
5
|
+
"author": "Renato Rodrigues (morbden) <renato@vulppi.dev>",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/galfus-lang/galfus-engine.git",
|
|
10
|
+
"directory": "host/gltf-loader"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/galfus-lang/galfus-engine/issues"
|
|
14
|
+
},
|
|
15
|
+
"homepage": "https://github.com/galfus-lang/galfus-engine#readme",
|
|
16
|
+
"keywords": [
|
|
17
|
+
"galfus",
|
|
18
|
+
"game-engine",
|
|
19
|
+
"gltf",
|
|
20
|
+
"glb",
|
|
21
|
+
"3d",
|
|
22
|
+
"loader",
|
|
23
|
+
"assets",
|
|
24
|
+
"mesh",
|
|
25
|
+
"skinning"
|
|
26
|
+
],
|
|
27
|
+
"type": "module",
|
|
28
|
+
"main": "./src/index.ts",
|
|
29
|
+
"types": "./src/index.ts",
|
|
30
|
+
"files": [
|
|
31
|
+
"src",
|
|
32
|
+
"README.md"
|
|
33
|
+
],
|
|
34
|
+
"exports": {
|
|
35
|
+
".": "./src/index.ts"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@galfus/engine": "^0.31.1-alpha",
|
|
39
|
+
"@galfus/model-loader-types": "^0.31.1-alpha",
|
|
40
|
+
"fflate": "^0.8.3"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@galfus/transport-types": "^0.31.1-alpha"
|
|
44
|
+
}
|
|
45
|
+
}
|
package/src/animation.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skeletal skin binding and animation clip compilation for glTF assets.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { AnimationClip, InterpolationMode, KeyframeTrack, Realm3D } from '@galfus/engine';
|
|
6
|
+
import { readAccessor } from './parser';
|
|
7
|
+
import type { SpawnedSceneResult } from './scene';
|
|
8
|
+
import type { GlTF } from './types';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Creates and registers Skin3D components for all skinned models in a spawned scene.
|
|
12
|
+
*
|
|
13
|
+
* @param realm The target Realm3D.
|
|
14
|
+
* @param gltf The root glTF descriptor.
|
|
15
|
+
* @param buffers Array of resolved binary buffers.
|
|
16
|
+
* @param sceneResult Result of the scene spawning phase.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```typescript
|
|
20
|
+
* setupSkins(realm, gltf, buffers, sceneResult);
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
export function setupSkins(
|
|
24
|
+
realm: Realm3D,
|
|
25
|
+
gltf: GlTF,
|
|
26
|
+
buffers: Uint8Array[],
|
|
27
|
+
sceneResult: SpawnedSceneResult
|
|
28
|
+
): void {
|
|
29
|
+
if (!gltf.nodes || !gltf.skins) return;
|
|
30
|
+
|
|
31
|
+
for (let nIdx = 0; nIdx < gltf.nodes.length; nIdx++) {
|
|
32
|
+
const node = gltf.nodes[nIdx]!;
|
|
33
|
+
if (node.skin === undefined || node.mesh === undefined) continue;
|
|
34
|
+
|
|
35
|
+
const modelEntityId = sceneResult.nodeEntityMap.get(nIdx);
|
|
36
|
+
if (modelEntityId === undefined) continue;
|
|
37
|
+
|
|
38
|
+
const skin = gltf.skins[node.skin];
|
|
39
|
+
if (!skin) continue;
|
|
40
|
+
|
|
41
|
+
// Resolve joint entity IDs
|
|
42
|
+
const joints: number[] = [];
|
|
43
|
+
for (const jointNodeIdx of skin.joints) {
|
|
44
|
+
const jointEntityId = sceneResult.nodeEntityMap.get(jointNodeIdx);
|
|
45
|
+
if (jointEntityId !== undefined) {
|
|
46
|
+
joints.push(jointEntityId);
|
|
47
|
+
} else {
|
|
48
|
+
throw new Error(`Skin ${node.skin} references missing joint node ${jointNodeIdx}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (joints.length === 0) continue;
|
|
53
|
+
|
|
54
|
+
// Extract inverse bind matrices (16 floats per joint)
|
|
55
|
+
let inverseBindMatrices: Float32Array;
|
|
56
|
+
if (skin.inverseBindMatrices !== undefined) {
|
|
57
|
+
inverseBindMatrices = readAccessor(gltf, buffers, skin.inverseBindMatrices) as Float32Array;
|
|
58
|
+
} else {
|
|
59
|
+
// Fallback: 16-float identity matrices
|
|
60
|
+
inverseBindMatrices = new Float32Array(joints.length * 16);
|
|
61
|
+
for (let j = 0; j < joints.length; j++) {
|
|
62
|
+
const offset = j * 16;
|
|
63
|
+
inverseBindMatrices[offset + 0] = 1;
|
|
64
|
+
inverseBindMatrices[offset + 5] = 1;
|
|
65
|
+
inverseBindMatrices[offset + 10] = 1;
|
|
66
|
+
inverseBindMatrices[offset + 15] = 1;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
for (const entityId of sceneResult.primitiveEntities.get(nIdx) ?? [modelEntityId]) {
|
|
71
|
+
const { entityId: skinEntityId } = realm.createSkin(entityId, joints, inverseBindMatrices);
|
|
72
|
+
sceneResult.entities.push(skinEntityId);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Compiles raw glTF animation channels and samplers into native Galfus AnimationClips.
|
|
79
|
+
*
|
|
80
|
+
* @param gltf The root glTF descriptor.
|
|
81
|
+
* @param buffers Array of resolved binary buffers.
|
|
82
|
+
* @param nodeEntityMap Mapping from glTF node indices to instantiated ECS entity IDs.
|
|
83
|
+
* @returns Array of compiled, ready-to-play AnimationClips.
|
|
84
|
+
*
|
|
85
|
+
* @example
|
|
86
|
+
* ```typescript
|
|
87
|
+
* const clips = compileAnimationClips(gltf, buffers, sceneResult.nodeEntityMap);
|
|
88
|
+
* console.log(`Compiled ${clips.length} animation clips`);
|
|
89
|
+
* ```
|
|
90
|
+
*/
|
|
91
|
+
export function compileAnimationClips(
|
|
92
|
+
gltf: GlTF,
|
|
93
|
+
buffers: Uint8Array[],
|
|
94
|
+
nodeEntityMap: Map<number, number>,
|
|
95
|
+
primitiveEntities?: Map<number, number[]>
|
|
96
|
+
): AnimationClip[] {
|
|
97
|
+
if (!gltf.animations || gltf.animations.length === 0) {
|
|
98
|
+
return [];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const clips: AnimationClip[] = [];
|
|
102
|
+
|
|
103
|
+
for (let aIdx = 0; aIdx < gltf.animations.length; aIdx++) {
|
|
104
|
+
const anim = gltf.animations[aIdx]!;
|
|
105
|
+
const tracks: KeyframeTrack[] = [];
|
|
106
|
+
let maxDuration = 0;
|
|
107
|
+
|
|
108
|
+
for (let cIdx = 0; cIdx < anim.channels.length; cIdx++) {
|
|
109
|
+
const channel = anim.channels[cIdx]!;
|
|
110
|
+
const targetNodeIdx = channel.target.node;
|
|
111
|
+
if (targetNodeIdx === undefined) continue;
|
|
112
|
+
|
|
113
|
+
const targetEntityId = nodeEntityMap.get(targetNodeIdx);
|
|
114
|
+
if (targetEntityId === undefined) continue;
|
|
115
|
+
|
|
116
|
+
const sampler = anim.samplers[channel.sampler];
|
|
117
|
+
if (!sampler) continue;
|
|
118
|
+
|
|
119
|
+
const timestamps = readAccessor(gltf, buffers, sampler.input) as Float32Array;
|
|
120
|
+
const values = readAccessor(gltf, buffers, sampler.output) as Float32Array;
|
|
121
|
+
|
|
122
|
+
if (timestamps.length > 0) {
|
|
123
|
+
const lastTime = timestamps[timestamps.length - 1] ?? 0;
|
|
124
|
+
if (lastTime > maxDuration) {
|
|
125
|
+
maxDuration = lastTime;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const interpolation: InterpolationMode =
|
|
130
|
+
sampler.interpolation === 'STEP'
|
|
131
|
+
? 'step'
|
|
132
|
+
: sampler.interpolation === 'CUBICSPLINE'
|
|
133
|
+
? 'cubicspline'
|
|
134
|
+
: 'linear';
|
|
135
|
+
|
|
136
|
+
const targets =
|
|
137
|
+
channel.target.path === 'weights'
|
|
138
|
+
? (primitiveEntities?.get(targetNodeIdx) ?? [targetEntityId])
|
|
139
|
+
: [targetEntityId];
|
|
140
|
+
for (const targetEntity of targets)
|
|
141
|
+
tracks.push({
|
|
142
|
+
targetEntity,
|
|
143
|
+
path: channel.target.path,
|
|
144
|
+
timestamps,
|
|
145
|
+
values,
|
|
146
|
+
interpolation
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (tracks.length > 0) {
|
|
151
|
+
clips.push({
|
|
152
|
+
name: anim.name ?? `Animation_${aIdx}`,
|
|
153
|
+
duration: maxDuration,
|
|
154
|
+
tracks
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return clips;
|
|
160
|
+
}
|
package/src/bounds.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spatial bounding box and sphere computation for glTF 2.0 assets.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { mat4, quat } from '@galfus/engine';
|
|
6
|
+
import type { ModelBounds } from '@galfus/model-loader-types';
|
|
7
|
+
import type { GlTF, GlTFNode } from './types';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Computes local 4x4 matrix for a glTF node from either matrix or TRS parameters.
|
|
11
|
+
*/
|
|
12
|
+
function getNodeLocalMatrix(node: GlTFNode, outMat: mat4.Mat4): void {
|
|
13
|
+
if (node.matrix) {
|
|
14
|
+
for (let i = 0; i < 16; i++) {
|
|
15
|
+
outMat[i] = node.matrix[i] ?? (i % 5 === 0 ? 1 : 0);
|
|
16
|
+
}
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const t: [number, number, number] = node.translation ?? [0, 0, 0];
|
|
21
|
+
const r: [number, number, number, number] = node.rotation ?? [0, 0, 0, 1];
|
|
22
|
+
const s: [number, number, number] = node.scale ?? [1, 1, 1];
|
|
23
|
+
|
|
24
|
+
mat4.fromRotationTranslationScale(outMat, r as unknown as quat.ReadonlyQuat, t, s);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Transforms a 3D point by a 4x4 column-major matrix.
|
|
29
|
+
*/
|
|
30
|
+
function transformPoint(p: [number, number, number], m: mat4.Mat4): [number, number, number] {
|
|
31
|
+
return [
|
|
32
|
+
p[0] * m[0] + p[1] * m[4] + p[2] * m[8] + m[12],
|
|
33
|
+
p[0] * m[1] + p[1] * m[5] + p[2] * m[9] + m[13],
|
|
34
|
+
p[0] * m[2] + p[1] * m[6] + p[2] * m[10] + m[14]
|
|
35
|
+
];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Calculates the axis-aligned bounding box (AABB) and bounding radius for a glTF asset.
|
|
40
|
+
*
|
|
41
|
+
* Traverses the node hierarchy from root scenes, applies accumulated world matrices,
|
|
42
|
+
* and transforms the min/max extents of all mesh primitive POSITION accessors.
|
|
43
|
+
*
|
|
44
|
+
* @param gltf The parsed glTF 2.0 JSON structure.
|
|
45
|
+
* @returns Spatial ModelBounds containing min, max, center, size, and radius, or undefined if empty.
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* ```typescript
|
|
49
|
+
* const bounds = computeGltfBounds(gltf);
|
|
50
|
+
* if (bounds) {
|
|
51
|
+
* console.log(`Model extents: ${bounds.size.join('x')} m`);
|
|
52
|
+
* console.log(`Bounding radius: ${bounds.radius.toFixed(2)} m`);
|
|
53
|
+
* }
|
|
54
|
+
* ```
|
|
55
|
+
*/
|
|
56
|
+
export function computeGltfBounds(gltf: GlTF): ModelBounds | undefined {
|
|
57
|
+
if (!gltf.nodes || gltf.nodes.length === 0 || !gltf.meshes || gltf.meshes.length === 0) {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const globalMin: [number, number, number] = [Infinity, Infinity, Infinity];
|
|
62
|
+
const globalMax: [number, number, number] = [-Infinity, -Infinity, -Infinity];
|
|
63
|
+
let foundAnyVertex = false;
|
|
64
|
+
|
|
65
|
+
const identityMat = mat4.create();
|
|
66
|
+
|
|
67
|
+
function traverseNode(nodeIndex: number, parentWorldMat: mat4.Mat4): void {
|
|
68
|
+
const node = gltf.nodes?.[nodeIndex];
|
|
69
|
+
if (!node) return;
|
|
70
|
+
|
|
71
|
+
const localMat = mat4.create();
|
|
72
|
+
getNodeLocalMatrix(node, localMat);
|
|
73
|
+
|
|
74
|
+
const worldMat = mat4.create();
|
|
75
|
+
mat4.multiply(worldMat, parentWorldMat, localMat);
|
|
76
|
+
|
|
77
|
+
if (node.mesh !== undefined) {
|
|
78
|
+
const mesh = gltf.meshes?.[node.mesh];
|
|
79
|
+
if (mesh) {
|
|
80
|
+
for (const prim of mesh.primitives) {
|
|
81
|
+
const posAccIndex = prim.attributes?.POSITION;
|
|
82
|
+
if (posAccIndex !== undefined) {
|
|
83
|
+
const acc = gltf.accessors?.[posAccIndex];
|
|
84
|
+
if (acc?.min && acc?.max && acc.min.length >= 3 && acc.max.length >= 3) {
|
|
85
|
+
const minX = acc.min[0]!;
|
|
86
|
+
const minY = acc.min[1]!;
|
|
87
|
+
const minZ = acc.min[2]!;
|
|
88
|
+
const maxX = acc.max[0]!;
|
|
89
|
+
const maxY = acc.max[1]!;
|
|
90
|
+
const maxZ = acc.max[2]!;
|
|
91
|
+
|
|
92
|
+
const corners: [number, number, number][] = [
|
|
93
|
+
[minX, minY, minZ],
|
|
94
|
+
[maxX, minY, minZ],
|
|
95
|
+
[minX, maxY, minZ],
|
|
96
|
+
[maxX, maxY, minZ],
|
|
97
|
+
[minX, minY, maxZ],
|
|
98
|
+
[maxX, minY, maxZ],
|
|
99
|
+
[minX, maxY, maxZ],
|
|
100
|
+
[maxX, maxY, maxZ]
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
for (const corner of corners) {
|
|
104
|
+
const [tx, ty, tz] = transformPoint(corner, worldMat);
|
|
105
|
+
if (tx < globalMin[0]) globalMin[0] = tx;
|
|
106
|
+
if (tx > globalMax[0]) globalMax[0] = tx;
|
|
107
|
+
if (ty < globalMin[1]) globalMin[1] = ty;
|
|
108
|
+
if (ty > globalMax[1]) globalMax[1] = ty;
|
|
109
|
+
if (tz < globalMin[2]) globalMin[2] = tz;
|
|
110
|
+
if (tz > globalMax[2]) globalMax[2] = tz;
|
|
111
|
+
foundAnyVertex = true;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (node.children) {
|
|
120
|
+
for (const childIdx of node.children) {
|
|
121
|
+
traverseNode(childIdx, worldMat);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Traverse through active scene roots (or all root candidates)
|
|
127
|
+
const activeSceneIdx = gltf.scene ?? 0;
|
|
128
|
+
const activeScene = gltf.scenes?.[activeSceneIdx];
|
|
129
|
+
const rootNodeIndices = activeScene?.nodes ?? (gltf.nodes.length > 0 ? [0] : []);
|
|
130
|
+
|
|
131
|
+
for (const rootIdx of rootNodeIndices) {
|
|
132
|
+
traverseNode(rootIdx, identityMat);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (!foundAnyVertex || !isFinite(globalMin[0]) || !isFinite(globalMax[0])) {
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const size: [number, number, number] = [
|
|
140
|
+
globalMax[0] - globalMin[0],
|
|
141
|
+
globalMax[1] - globalMin[1],
|
|
142
|
+
globalMax[2] - globalMin[2]
|
|
143
|
+
];
|
|
144
|
+
|
|
145
|
+
const center: [number, number, number] = [
|
|
146
|
+
(globalMin[0] + globalMax[0]) * 0.5,
|
|
147
|
+
(globalMin[1] + globalMax[1]) * 0.5,
|
|
148
|
+
(globalMin[2] + globalMax[2]) * 0.5
|
|
149
|
+
];
|
|
150
|
+
|
|
151
|
+
const radius = Math.hypot(size[0], size[1], size[2]) * 0.5;
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
min: globalMin,
|
|
155
|
+
max: globalMax,
|
|
156
|
+
center,
|
|
157
|
+
size,
|
|
158
|
+
radius
|
|
159
|
+
};
|
|
160
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Automatic lazy decoder resolution for compressed glTF extensions (Draco, Meshopt).
|
|
3
|
+
*
|
|
4
|
+
* Automatically sniffs extensions in glTF headers and loads decompression runtimes
|
|
5
|
+
* on demand without adding overhead for standard uncompressed models.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { GlTF } from '../types';
|
|
9
|
+
|
|
10
|
+
export interface DracoAttributeMapping {
|
|
11
|
+
position?: Float32Array;
|
|
12
|
+
normal?: Float32Array;
|
|
13
|
+
uv?: Float32Array;
|
|
14
|
+
uv1?: Float32Array;
|
|
15
|
+
tangent?: Float32Array;
|
|
16
|
+
color?: Float32Array;
|
|
17
|
+
joints?: Uint16Array | Uint32Array;
|
|
18
|
+
weights?: Float32Array;
|
|
19
|
+
indices?: Uint16Array | Uint32Array;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface DracoDecoder {
|
|
23
|
+
decode(
|
|
24
|
+
buffer: Uint8Array,
|
|
25
|
+
attributeIds: Record<string, number>
|
|
26
|
+
): Promise<DracoAttributeMapping> | DracoAttributeMapping;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface MeshoptDecoder {
|
|
30
|
+
decodeGpuBuffer(
|
|
31
|
+
count: number,
|
|
32
|
+
byteStride: number,
|
|
33
|
+
source: Uint8Array,
|
|
34
|
+
mode: 'ATTRIBUTES' | 'TRIANGLES' | 'INDICES',
|
|
35
|
+
filter?: 'NONE' | 'OCTAHEDRAL' | 'QUATERNION' | 'EXPONENTIAL'
|
|
36
|
+
): Promise<Uint8Array> | Uint8Array;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ResolvedDecoders {
|
|
40
|
+
draco?: DracoDecoder;
|
|
41
|
+
meshopt?: MeshoptDecoder;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Global cached decoder singletons to avoid repeated initialization
|
|
45
|
+
let cachedDracoDecoder: DracoDecoder | undefined;
|
|
46
|
+
let cachedMeshoptDecoder: MeshoptDecoder | undefined;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Registers a global Draco decoder instance shared across all asset loaders.
|
|
50
|
+
*/
|
|
51
|
+
export function registerGlobalDracoDecoder(decoder: DracoDecoder): void {
|
|
52
|
+
cachedDracoDecoder = decoder;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Registers a global Meshopt decoder instance shared across all asset loaders.
|
|
57
|
+
*/
|
|
58
|
+
export function registerGlobalMeshoptDecoder(decoder: MeshoptDecoder): void {
|
|
59
|
+
cachedMeshoptDecoder = decoder;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Sniffs glTF header extensions and automatically resolves required decoders.
|
|
64
|
+
*
|
|
65
|
+
* @param gltf The parsed glTF root structure.
|
|
66
|
+
* @param userDecoders Optional explicit decoder overrides provided by the user.
|
|
67
|
+
* @returns Object holding resolved decoders.
|
|
68
|
+
* @throws Error if an extension is strictly required but no compatible decoder is available.
|
|
69
|
+
*
|
|
70
|
+
* @example
|
|
71
|
+
* ```typescript
|
|
72
|
+
* const decoders = await resolveDecoders(gltf, options?.decoders);
|
|
73
|
+
* ```
|
|
74
|
+
*/
|
|
75
|
+
export async function resolveDecoders(
|
|
76
|
+
gltf: GlTF,
|
|
77
|
+
userDecoders?: Record<string, unknown>
|
|
78
|
+
): Promise<ResolvedDecoders> {
|
|
79
|
+
const required = gltf.extensionsRequired ?? [];
|
|
80
|
+
const used = gltf.extensionsUsed ?? [];
|
|
81
|
+
const allExts = new Set([...required, ...used]);
|
|
82
|
+
|
|
83
|
+
const resolved: ResolvedDecoders = {};
|
|
84
|
+
|
|
85
|
+
// 1. Draco Compression (KHR_draco_mesh_compression)
|
|
86
|
+
if (allExts.has('KHR_draco_mesh_compression')) {
|
|
87
|
+
if (userDecoders?.['draco']) {
|
|
88
|
+
resolved.draco = userDecoders['draco'] as DracoDecoder;
|
|
89
|
+
} else if (cachedDracoDecoder) {
|
|
90
|
+
resolved.draco = cachedDracoDecoder;
|
|
91
|
+
} else {
|
|
92
|
+
// Attempt dynamic on-demand import if available in runtime
|
|
93
|
+
try {
|
|
94
|
+
const dynamicModule = (await import(/* @vite-ignore */ 'draco3d' as string).catch(
|
|
95
|
+
() => null
|
|
96
|
+
)) as { createDecoderModule?: () => Promise<unknown> } | null;
|
|
97
|
+
if (dynamicModule?.createDecoderModule) {
|
|
98
|
+
// Wrapped adapter if draco3d is present in environment
|
|
99
|
+
// (Can be populated when package is installed)
|
|
100
|
+
}
|
|
101
|
+
} catch {
|
|
102
|
+
// Ignored; handled below if strictly required
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!resolved.draco && required.includes('KHR_draco_mesh_compression')) {
|
|
106
|
+
throw new Error(
|
|
107
|
+
'glTF asset requires "KHR_draco_mesh_compression", but no Draco decoder is registered. ' +
|
|
108
|
+
'Provide one via ModelImportOptions.decoders or registerGlobalDracoDecoder().'
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 2. Meshopt Compression (EXT_meshopt_compression)
|
|
115
|
+
if (allExts.has('EXT_meshopt_compression')) {
|
|
116
|
+
if (userDecoders?.['meshopt']) {
|
|
117
|
+
resolved.meshopt = userDecoders['meshopt'] as MeshoptDecoder;
|
|
118
|
+
} else if (cachedMeshoptDecoder) {
|
|
119
|
+
resolved.meshopt = cachedMeshoptDecoder;
|
|
120
|
+
} else {
|
|
121
|
+
if (required.includes('EXT_meshopt_compression')) {
|
|
122
|
+
throw new Error(
|
|
123
|
+
'glTF asset requires "EXT_meshopt_compression", but no Meshopt decoder is registered. ' +
|
|
124
|
+
'Provide one via ModelImportOptions.decoders or registerGlobalMeshoptDecoder().'
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return resolved;
|
|
131
|
+
}
|