@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 CHANGED
@@ -1,3 +1,94 @@
1
- # Temporary Holding Version
1
+ # @galfus/gltf-loader
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }
@@ -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
+ }