@dylanebert/shallot-gltf 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dylan Ebert
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,25 @@
1
- # Temporary Holding Version
1
+ # shallot-gltf
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
+ experimental static glTF loading for [shallot](https://github.com/dylanebert/shallot).
4
+
5
+ ```bash
6
+ bun add @dylanebert/shallot-gltf
7
+ ```
8
+
9
+ ```ts
10
+ // in your plugin's async initialize
11
+ const model = await loadGltfScene(world, url, onProgress);
12
+ spawnGltfScene(world, model, { translation: [0, 0, 0] });
13
+ ```
14
+
15
+ See [`examples/gltf-model`](examples/gltf-model).
16
+
17
+ Meshes, base colour textures and `StandardMaterial` factors. Missing required extensions refuse by name; anything else ignored is listed in `model.unsupported`. No animation, skinning, lights, cameras, or Draco, meshopt and KTX2 compression.
18
+
19
+ | shallot-gltf | shallot |
20
+ | --- | --- |
21
+ | 0.1.0-next.1 | 0.10.0-next.5 and later 0.10 |
22
+ | 0.1.0-next.2 | 0.10.0-next.6 and later 0.10 |
23
+ | 0.1.0 | ^0.10.0 |
24
+
25
+ changing it: [`CONTRIBUTING.md`](CONTRIBUTING.md). mit.
package/package.json CHANGED
@@ -1,6 +1,47 @@
1
1
  {
2
- "name": "@dylanebert/shallot-gltf",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@dylanebert/shallot-gltf",
3
+ "version": "0.1.0",
4
+ "description": "Experimental static glTF loading and placement for Shallot.",
5
+ "license": "MIT",
6
+ "author": "Dylan Ebert <dylan.ebert@gmail.com>",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/dylanebert/shallot-gltf.git"
10
+ },
11
+ "homepage": "https://github.com/dylanebert/shallot-gltf",
12
+ "bugs": "https://github.com/dylanebert/shallot-gltf/issues",
13
+ "keywords": [
14
+ "gltf",
15
+ "shallot-plugin"
16
+ ],
17
+ "type": "module",
18
+ "sideEffects": false,
19
+ "packageManager": "bun@1.4.3",
20
+ "engines": {
21
+ "bun": ">=1.4.3"
22
+ },
23
+ "exports": {
24
+ ".": "./src/index.ts"
25
+ },
26
+ "files": [
27
+ "src",
28
+ "!src/**/*.test.ts"
29
+ ],
30
+ "scripts": {
31
+ "lint": "biome check",
32
+ "test": "bun test"
33
+ },
34
+ "peerDependencies": {
35
+ "@dylanebert/shallot": "^0.10.0"
36
+ },
37
+ "devDependencies": {
38
+ "@biomejs/biome": "^2.5.13",
39
+ "@dylanebert/shallot": "0.10.0",
40
+ "@playwright/test": "^1.64.1",
41
+ "@types/bun": "^1.4.3",
42
+ "@types/three": "0.180.0",
43
+ "three": "0.180.0",
44
+ "vite": "^8.3.4",
45
+ "webgpu": "^0.6.2"
46
+ }
47
+ }
package/src/glb.ts ADDED
@@ -0,0 +1,34 @@
1
+ import type { GltfJson } from "./gltf";
2
+
3
+ const MAGIC = 0x46546c67; // "glTF", little-endian
4
+ const JSON_CHUNK = 0x4e4f534a; // "JSON"
5
+ const BIN_CHUNK = 0x004e4942; // "BIN\0"
6
+
7
+ export function isGlb(bytes: ArrayBuffer): boolean {
8
+ return bytes.byteLength >= 12 && new DataView(bytes).getUint32(0, true) === MAGIC;
9
+ }
10
+
11
+ export function parseGlb(bytes: ArrayBuffer): { json: GltfJson; bin?: ArrayBuffer } {
12
+ const dv = new DataView(bytes);
13
+ if (dv.getUint32(0, true) !== MAGIC) throw new Error("[gltf] not a .glb (bad magic)");
14
+ const version = dv.getUint32(4, true);
15
+ if (version !== 2) throw new Error(`[gltf] unsupported .glb version ${version} (expected 2)`);
16
+ const total = Math.min(dv.getUint32(8, true), bytes.byteLength);
17
+
18
+ let json: GltfJson | undefined;
19
+ let bin: ArrayBuffer | undefined;
20
+
21
+ for (let o = 12; o + 8 <= total; ) {
22
+ const length = dv.getUint32(o, true);
23
+ const type = dv.getUint32(o + 4, true);
24
+ const start = o + 8;
25
+ if (type === JSON_CHUNK) {
26
+ json = JSON.parse(new TextDecoder().decode(new Uint8Array(bytes, start, length)));
27
+ } else if (type === BIN_CHUNK) {
28
+ bin = bytes.slice(start, start + length);
29
+ }
30
+ o = start + length;
31
+ }
32
+ if (!json) throw new Error("[gltf] .glb has no JSON chunk");
33
+ return { json, bin };
34
+ }
package/src/gltf.ts ADDED
@@ -0,0 +1,485 @@
1
+ import {
2
+ ImageAddressMode,
3
+ ImageFilterMode,
4
+ type ImageSamplerDescriptor,
5
+ } from "@dylanebert/shallot/rendering";
6
+ import { composeMat4 as compose, multiplyMat4 as multiply } from "@dylanebert/shallot/utils";
7
+
8
+ const VERTEX_FLOATS = 8;
9
+
10
+ export interface GltfJson {
11
+ accessors?: Accessor[];
12
+ bufferViews?: BufferView[];
13
+ meshes?: GltfJsonMesh[];
14
+ materials?: Material[];
15
+ images?: { uri?: string; bufferView?: number; mimeType?: string }[];
16
+ textures?: { source?: number; sampler?: number; extensions?: Extensions }[];
17
+ samplers?: { wrapS?: number; wrapT?: number; magFilter?: number; minFilter?: number }[];
18
+ nodes?: Node[];
19
+ scenes?: { nodes?: number[] }[];
20
+ scene?: number;
21
+ buffers?: { uri?: string; byteLength: number; extensions?: Record<string, unknown> }[];
22
+ animations?: object[];
23
+ skins?: object[];
24
+ extensionsRequired?: string[];
25
+ extensionsUsed?: string[];
26
+ }
27
+
28
+ type Extensions = Record<string, unknown>;
29
+
30
+ interface Accessor {
31
+ bufferView?: number;
32
+ byteOffset?: number;
33
+ componentType: number;
34
+ normalized?: boolean;
35
+ count: number;
36
+ type: "SCALAR" | "VEC2" | "VEC3" | "VEC4" | "MAT2" | "MAT3" | "MAT4";
37
+ sparse?: object;
38
+ }
39
+
40
+ interface BufferView {
41
+ buffer: number;
42
+ byteOffset?: number;
43
+ byteLength: number;
44
+ byteStride?: number;
45
+ extensions?: Extensions;
46
+ }
47
+
48
+ interface GltfJsonMesh {
49
+ name?: string;
50
+ primitives: Primitive[];
51
+ }
52
+
53
+ interface Primitive {
54
+ attributes: {
55
+ POSITION: number;
56
+
57
+ NORMAL?: number;
58
+
59
+ TEXCOORD_0?: number;
60
+
61
+ TEXCOORD_1?: number;
62
+
63
+ COLOR_0?: number;
64
+
65
+ JOINTS_0?: number;
66
+
67
+ WEIGHTS_0?: number;
68
+
69
+ TANGENT?: number;
70
+ };
71
+ indices?: number;
72
+ material?: number;
73
+ mode?: number;
74
+ targets?: object[];
75
+ extensions?: Extensions;
76
+ }
77
+
78
+ interface Material {
79
+ name?: string;
80
+ doubleSided?: boolean;
81
+ pbrMetallicRoughness?: {
82
+ baseColorFactor?: number[];
83
+ baseColorTexture?: { index: number; texCoord?: number };
84
+ metallicFactor?: number;
85
+ roughnessFactor?: number;
86
+ metallicRoughnessTexture?: { index: number };
87
+ };
88
+ normalTexture?: { index: number; scale?: number };
89
+ occlusionTexture?: { index: number; strength?: number };
90
+ emissiveFactor?: number[];
91
+ emissiveTexture?: { index: number };
92
+ alphaMode?: "OPAQUE" | "MASK" | "BLEND";
93
+ alphaCutoff?: number;
94
+ extensions?: Extensions;
95
+ }
96
+
97
+ interface Node {
98
+ name?: string;
99
+ mesh?: number;
100
+ skin?: number;
101
+ children?: number[];
102
+ matrix?: number[];
103
+ translation?: number[];
104
+ rotation?: number[];
105
+ scale?: number[];
106
+ }
107
+
108
+ export interface GltfMesh {
109
+ name: string;
110
+ /** Packed (px py pz u)(nx ny nz v), eight floats per vertex. */
111
+ vertices: Float32Array;
112
+ indices: Uint32Array;
113
+ material: number;
114
+ }
115
+
116
+ export interface GltfMaterial {
117
+ color: [number, number, number, number];
118
+ texture?: number;
119
+ metallic: number;
120
+ roughness: number;
121
+ emissive: [number, number, number];
122
+ alphaMode: "OPAQUE" | "MASK" | "BLEND";
123
+ cutoff: number;
124
+ unlit: boolean;
125
+ }
126
+ export interface GltfScene {
127
+ meshes: GltfMesh[];
128
+ instances: { mesh: number; node: string; matrix: Float32Array }[];
129
+ materials: GltfMaterial[];
130
+ images: { uri?: string; bytes?: Uint8Array; mimeType?: string }[];
131
+ textures: { image?: number; sampler: ImageSamplerDescriptor }[];
132
+ unsupported: string[];
133
+ }
134
+ const COMPONENTS = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4, MAT2: 4, MAT3: 9, MAT4: 16 } as const;
135
+
136
+ const BYTE = 5120;
137
+ const UNSIGNED_BYTE = 5121;
138
+ const SHORT = 5122;
139
+ const UNSIGNED_SHORT = 5123;
140
+ const UNSIGNED_INT = 5125;
141
+ const TRIANGLES = 4;
142
+
143
+ function localMatrix(node: Node, out: Float32Array): Float32Array {
144
+ if (node.matrix) {
145
+ out.set(node.matrix);
146
+ return out;
147
+ }
148
+ const t = node.translation ?? [0, 0, 0];
149
+ const r = node.rotation ?? [0, 0, 0, 1];
150
+ const s = node.scale ?? [1, 1, 1];
151
+ return compose(t[0], t[1], t[2], r[0], r[1], r[2], r[3], s[0], s[1], s[2], out);
152
+ }
153
+
154
+ function compSize(componentType: number): number {
155
+ switch (componentType) {
156
+ case BYTE:
157
+ case UNSIGNED_BYTE:
158
+ return 1;
159
+ case SHORT:
160
+ case UNSIGNED_SHORT:
161
+ return 2;
162
+ default:
163
+ return 4; // FLOAT / UNSIGNED_INT
164
+ }
165
+ }
166
+
167
+ function readComponent(
168
+ view: DataView,
169
+ o: number,
170
+ componentType: number,
171
+ normalized: boolean,
172
+ ): number {
173
+ switch (componentType) {
174
+ case BYTE: {
175
+ const v = view.getInt8(o);
176
+ return normalized ? Math.max(v / 127, -1) : v;
177
+ }
178
+ case UNSIGNED_BYTE: {
179
+ const v = view.getUint8(o);
180
+ return normalized ? v / 255 : v;
181
+ }
182
+ case SHORT: {
183
+ const v = view.getInt16(o, true);
184
+ return normalized ? Math.max(v / 32767, -1) : v;
185
+ }
186
+ case UNSIGNED_SHORT: {
187
+ const v = view.getUint16(o, true);
188
+ return normalized ? v / 65535 : v;
189
+ }
190
+ default:
191
+ return view.getFloat32(o, true);
192
+ }
193
+ }
194
+
195
+ function readFloats(gltf: GltfJson, buffers: ArrayBuffer[], index: number): Float32Array {
196
+ const acc = gltf.accessors![index];
197
+ const bv = gltf.bufferViews![acc.bufferView!];
198
+ const comps = COMPONENTS[acc.type];
199
+ const size = compSize(acc.componentType);
200
+ const stride = bv.byteStride ?? comps * size;
201
+ const base = (bv.byteOffset ?? 0) + (acc.byteOffset ?? 0);
202
+ const norm = acc.normalized ?? false;
203
+ const view = new DataView(buffers[bv.buffer]);
204
+ const out = new Float32Array(acc.count * comps);
205
+ for (let i = 0; i < acc.count; i++) {
206
+ const o = base + i * stride;
207
+ for (let c = 0; c < comps; c++)
208
+ out[i * comps + c] = readComponent(view, o + c * size, acc.componentType, norm);
209
+ }
210
+ return out;
211
+ }
212
+
213
+ function readIndices(gltf: GltfJson, buffers: ArrayBuffer[], index: number): Uint32Array {
214
+ const acc = gltf.accessors![index];
215
+ const bv = gltf.bufferViews![acc.bufferView!];
216
+ const size =
217
+ acc.componentType === UNSIGNED_INT ? 4 : acc.componentType === UNSIGNED_SHORT ? 2 : 1;
218
+ const stride = bv.byteStride ?? size;
219
+ const base = (bv.byteOffset ?? 0) + (acc.byteOffset ?? 0);
220
+ const view = new DataView(buffers[bv.buffer]);
221
+ const out = new Uint32Array(acc.count);
222
+ for (let i = 0; i < acc.count; i++) {
223
+ const o = base + i * stride;
224
+ out[i] =
225
+ acc.componentType === UNSIGNED_INT
226
+ ? view.getUint32(o, true)
227
+ : acc.componentType === UNSIGNED_SHORT
228
+ ? view.getUint16(o, true)
229
+ : view.getUint8(o);
230
+ }
231
+ return out;
232
+ }
233
+
234
+ export function computeNormals(pos: Float32Array, indices: Uint32Array): Float32Array {
235
+ const normals = new Float32Array(pos.length);
236
+ for (let i = 0; i + 2 < indices.length; i += 3) {
237
+ const a = indices[i] * 3;
238
+ const b = indices[i + 1] * 3;
239
+ const c = indices[i + 2] * 3;
240
+ const e1x = pos[b] - pos[a];
241
+ const e1y = pos[b + 1] - pos[a + 1];
242
+ const e1z = pos[b + 2] - pos[a + 2];
243
+ const e2x = pos[c] - pos[a];
244
+ const e2y = pos[c + 1] - pos[a + 1];
245
+ const e2z = pos[c + 2] - pos[a + 2];
246
+ const nx = e1y * e2z - e1z * e2y;
247
+ const ny = e1z * e2x - e1x * e2z;
248
+ const nz = e1x * e2y - e1y * e2x;
249
+ normals[a] += nx;
250
+ normals[a + 1] += ny;
251
+ normals[a + 2] += nz;
252
+ normals[b] += nx;
253
+ normals[b + 1] += ny;
254
+ normals[b + 2] += nz;
255
+ normals[c] += nx;
256
+ normals[c + 1] += ny;
257
+ normals[c + 2] += nz;
258
+ }
259
+ for (let i = 0; i < normals.length; i += 3) {
260
+ const len = Math.hypot(normals[i], normals[i + 1], normals[i + 2]) || 1;
261
+ normals[i] /= len;
262
+ normals[i + 1] /= len;
263
+ normals[i + 2] /= len;
264
+ }
265
+ return normals;
266
+ }
267
+
268
+ function packVertices(
269
+ pos: Float32Array,
270
+ normal: Float32Array | null,
271
+ uv: Float32Array | null,
272
+ ): Float32Array {
273
+ const count = pos.length / 3;
274
+ const vertices = new Float32Array(count * VERTEX_FLOATS);
275
+ for (let i = 0; i < count; i++) {
276
+ const o = i * VERTEX_FLOATS;
277
+ vertices[o] = pos[i * 3];
278
+ vertices[o + 1] = pos[i * 3 + 1];
279
+ vertices[o + 2] = pos[i * 3 + 2];
280
+ vertices[o + 3] = uv ? uv[i * 2] : 0;
281
+ vertices[o + 4] = normal ? normal[i * 3] : 0;
282
+ vertices[o + 5] = normal ? normal[i * 3 + 1] : 0;
283
+ vertices[o + 6] = normal ? normal[i * 3 + 2] : 0;
284
+ vertices[o + 7] = uv ? uv[i * 2 + 1] : 0;
285
+ }
286
+ return vertices;
287
+ }
288
+
289
+ function decodeMesh(
290
+ gltf: GltfJson,
291
+ buffers: ArrayBuffer[],
292
+ prim: Primitive,
293
+ name: string,
294
+ ): GltfMesh {
295
+ let pos = readFloats(gltf, buffers, prim.attributes.POSITION);
296
+ let uv =
297
+ prim.attributes.TEXCOORD_0 !== undefined
298
+ ? readFloats(gltf, buffers, prim.attributes.TEXCOORD_0)
299
+ : null;
300
+ let indices =
301
+ prim.indices !== undefined
302
+ ? readIndices(gltf, buffers, prim.indices)
303
+ : Uint32Array.from({ length: pos.length / 3 }, (_, i) => i);
304
+
305
+ if (prim.attributes.NORMAL === undefined) {
306
+ const source = pos;
307
+ pos = Float32Array.from(
308
+ Array.from(indices, (i) => Array.from(source.slice(i * 3, i * 3 + 3))).flat(),
309
+ );
310
+ if (uv) {
311
+ const sourceUv = uv;
312
+ uv = Float32Array.from(
313
+ Array.from(indices, (i) => Array.from(sourceUv.slice(i * 2, i * 2 + 2))).flat(),
314
+ );
315
+ }
316
+ indices = Uint32Array.from({ length: indices.length }, (_, i) => i);
317
+ }
318
+ const normal =
319
+ prim.attributes.NORMAL !== undefined
320
+ ? readFloats(gltf, buffers, prim.attributes.NORMAL)
321
+ : computeNormals(pos, indices);
322
+ return {
323
+ name,
324
+ vertices: packVertices(pos, normal, uv),
325
+ indices,
326
+ material: prim.material ?? -1,
327
+ };
328
+ }
329
+
330
+ function supportedExtension(name: string): boolean {
331
+ return name === "KHR_materials_unlit";
332
+ }
333
+
334
+ /** Refuses unsupported required extensions before their resources are read. */
335
+ export function validateRequiredExtensions(gltf: GltfJson): void {
336
+ for (const name of gltf.extensionsRequired ?? []) {
337
+ if (!supportedExtension(name))
338
+ throw new Error(`Unsupported required glTF extension: ${name}`);
339
+ }
340
+ }
341
+
342
+ /** Decodes resolved buffers without a device. Required unsupported extensions refuse by name.
343
+ * Instances retain column-major world matrices, including shear; geometry stays mesh-local.
344
+ */
345
+ export function parse(gltf: GltfJson, buffers: ArrayBuffer[]): GltfScene {
346
+ validateRequiredExtensions(gltf);
347
+ const ignored = new Set<string>();
348
+ const scan = (value: unknown): void => {
349
+ if (!value || typeof value !== "object") return;
350
+ for (const [key, child] of Object.entries(value)) {
351
+ if (key === "extensions") {
352
+ for (const name of Object.keys(child))
353
+ if (!supportedExtension(name)) ignored.add(name);
354
+ } else scan(child);
355
+ }
356
+ };
357
+ scan(gltf);
358
+ for (const name of gltf.extensionsUsed ?? []) if (!supportedExtension(name)) ignored.add(name);
359
+ if (gltf.skins?.length) ignored.add("skin");
360
+ if (gltf.animations?.length) ignored.add("animation");
361
+ if ("cameras" in gltf) ignored.add("camera");
362
+ const materials = (gltf.materials ?? []).map((m): GltfMaterial => {
363
+ const pbr = m.pbrMetallicRoughness;
364
+ if ((pbr?.baseColorTexture?.texCoord ?? 0) !== 0) ignored.add("baseColorTexture.texCoord");
365
+ const texture = pbr?.baseColorTexture;
366
+ const textureIndex = (texture?.texCoord ?? 0) === 0 ? texture?.index : undefined;
367
+ for (const [name, value] of Object.entries({
368
+ metallicRoughnessTexture: pbr?.metallicRoughnessTexture,
369
+ normalTexture: m.normalTexture,
370
+ occlusionTexture: m.occlusionTexture,
371
+ emissiveTexture: m.emissiveTexture,
372
+ doubleSided: m.doubleSided,
373
+ }))
374
+ if (value) ignored.add(name);
375
+ return {
376
+ color: (pbr?.baseColorFactor ?? [1, 1, 1, 1]) as GltfMaterial["color"],
377
+ texture: textureIndex,
378
+ metallic: pbr?.metallicFactor ?? 1,
379
+ roughness: pbr?.roughnessFactor ?? 1,
380
+ emissive: (m.emissiveFactor ?? [0, 0, 0]) as GltfMaterial["emissive"],
381
+ alphaMode: m.alphaMode ?? "OPAQUE",
382
+ cutoff: m.alphaCutoff ?? 0.5,
383
+ unlit: m.extensions?.KHR_materials_unlit !== undefined,
384
+ };
385
+ });
386
+ const addressMode = (wrap = 10497) =>
387
+ wrap === 33071
388
+ ? ImageAddressMode.ClampToEdge
389
+ : wrap === 33648
390
+ ? ImageAddressMode.MirrorRepeat
391
+ : ImageAddressMode.Repeat;
392
+ const textures = (gltf.textures ?? []).map((texture) => {
393
+ const sampler =
394
+ texture.sampler === undefined ? undefined : gltf.samplers?.[texture.sampler];
395
+ const min = sampler?.minFilter ?? 9987;
396
+ return {
397
+ image: texture.source,
398
+ sampler: {
399
+ addressModeU: addressMode(sampler?.wrapS),
400
+ addressModeV: addressMode(sampler?.wrapT),
401
+ magFilter:
402
+ sampler?.magFilter === 9728 ? ImageFilterMode.Nearest : ImageFilterMode.Linear,
403
+ minFilter:
404
+ min === 9728 || min === 9984 || min === 9986
405
+ ? ImageFilterMode.Nearest
406
+ : ImageFilterMode.Linear,
407
+ mipmapFilter:
408
+ min === 9986 || min === 9987 ? ImageFilterMode.Linear : ImageFilterMode.Nearest,
409
+ },
410
+ };
411
+ });
412
+ const meshes: GltfMesh[] = [];
413
+ const meshEntries = (gltf.meshes ?? []).map((mesh, meshIndex) => {
414
+ const entries: number[] = [];
415
+ for (const [primitiveIndex, prim] of mesh.primitives.entries()) {
416
+ for (const semantic of Object.keys(prim.attributes)) {
417
+ if (!["POSITION", "NORMAL", "TEXCOORD_0"].includes(semantic)) ignored.add(semantic);
418
+ }
419
+ if (prim.targets?.length) ignored.add("morph");
420
+ if ((prim.mode ?? TRIANGLES) !== TRIANGLES) {
421
+ ignored.add("primitive-mode");
422
+ continue;
423
+ }
424
+ const accessors = [
425
+ prim.attributes.POSITION,
426
+ prim.attributes.NORMAL,
427
+ prim.attributes.TEXCOORD_0,
428
+ prim.indices,
429
+ ].filter((a): a is number => a !== undefined);
430
+ if (
431
+ accessors.some(
432
+ (a) =>
433
+ gltf.accessors?.[a]?.sparse ||
434
+ gltf.accessors?.[a]?.bufferView === undefined,
435
+ )
436
+ ) {
437
+ ignored.add("sparse-accessor");
438
+ continue;
439
+ }
440
+ entries.push(meshes.length);
441
+ meshes.push(
442
+ decodeMesh(
443
+ gltf,
444
+ buffers,
445
+ prim,
446
+ `${mesh.name ?? `Mesh${meshIndex}`}/Primitive${primitiveIndex}`,
447
+ ),
448
+ );
449
+ }
450
+ return entries;
451
+ });
452
+ const instances: GltfScene["instances"] = [];
453
+ const nodes = gltf.nodes ?? [];
454
+ const children = new Set(nodes.flatMap((n) => n.children ?? []));
455
+ const roots =
456
+ gltf.scenes?.[gltf.scene ?? 0]?.nodes ??
457
+ nodes.map((_, i) => i).filter((i) => !children.has(i));
458
+ const walk = (index: number, parent: Float32Array, ancestors: Set<number>): void => {
459
+ if (ancestors.has(index)) throw new Error("Cyclic glTF node hierarchy");
460
+ const node = nodes[index];
461
+ const matrix = multiply(parent, localMatrix(node, new Float32Array(16)));
462
+ if (node.mesh !== undefined)
463
+ for (const mesh of meshEntries[node.mesh])
464
+ instances.push({ mesh, node: node.name ?? `Node${index}`, matrix });
465
+ for (const child of node.children ?? [])
466
+ walk(child, matrix, new Set([...ancestors, index]));
467
+ };
468
+ const identity = compose(0, 0, 0, 0, 0, 0, 1, 1, 1, 1);
469
+ for (const root of roots) walk(root, identity, new Set());
470
+ return {
471
+ meshes,
472
+ instances,
473
+ materials,
474
+ textures,
475
+ images: (gltf.images ?? []).map((image) => {
476
+ if (image.bufferView === undefined) return { uri: image.uri, mimeType: image.mimeType };
477
+ const view = gltf.bufferViews![image.bufferView];
478
+ return {
479
+ mimeType: image.mimeType,
480
+ bytes: new Uint8Array(buffers[view.buffer], view.byteOffset ?? 0, view.byteLength),
481
+ };
482
+ }),
483
+ unsupported: [...ignored].sort(),
484
+ };
485
+ }
package/src/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ export { isGlb, parseGlb } from "./glb";
2
+ export { type GltfJson, type GltfMaterial, type GltfMesh, type GltfScene, parse } from "./gltf";
3
+ export { loadGltf } from "./load";
4
+ export {
5
+ type Gltf,
6
+ type GltfPlacement,
7
+ type GltfPrimitive,
8
+ loadGltfScene,
9
+ spawnGltfScene,
10
+ } from "./scene";
package/src/load.ts ADDED
@@ -0,0 +1,33 @@
1
+ import { isGlb, parseGlb } from "./glb";
2
+ import { type GltfJson, type GltfScene, parse, validateRequiredExtensions } from "./gltf";
3
+
4
+ /** Reads a glTF or GLB URL and resolves external and data-URI buffers with the supplied fetch.
5
+ * Image sources are returned without decoding; loading performs no GPU work.
6
+ */
7
+ export async function loadGltf(url: string | URL, read: typeof fetch = fetch): Promise<GltfScene> {
8
+ const base = new URL(url);
9
+ const response = await read(base);
10
+ if (!response.ok) throw new Error(`glTF load failed: ${base} (${response.status})`);
11
+ const bytes = await response.arrayBuffer();
12
+ const { json, bin } = isGlb(bytes)
13
+ ? parseGlb(bytes)
14
+ : { json: JSON.parse(new TextDecoder().decode(bytes)) as GltfJson, bin: undefined };
15
+ validateRequiredExtensions(json);
16
+ const buffers = await Promise.all(
17
+ (json.buffers ?? []).map(async (buffer) => {
18
+ if (!buffer.uri) {
19
+ if (!bin) throw new Error("glTF buffer has no URI or BIN chunk");
20
+ return bin;
21
+ }
22
+ const response = await read(new URL(buffer.uri, base));
23
+ if (!response.ok)
24
+ throw new Error(`glTF buffer load failed: ${buffer.uri} (${response.status})`);
25
+ return response.arrayBuffer();
26
+ }),
27
+ );
28
+ const scene = parse(json, buffers);
29
+ scene.images = scene.images.map((image) =>
30
+ image.uri ? { ...image, uri: new URL(image.uri, base).href } : image,
31
+ );
32
+ return scene;
33
+ }
package/src/scene.ts ADDED
@@ -0,0 +1,137 @@
1
+ import {
2
+ AlphaMode,
3
+ composeMat4,
4
+ decomposeMat4,
5
+ Materials,
6
+ Meshes,
7
+ type MeshHandle,
8
+ MeshInstance,
9
+ MeshMaterial,
10
+ multiplyMat4,
11
+ registerImage,
12
+ StandardMaterial,
13
+ Transform,
14
+ type World,
15
+ } from "@dylanebert/shallot";
16
+ import type { MaterialHandle } from "@dylanebert/shallot/standard/rendering";
17
+ import { loadGltf } from "./load";
18
+
19
+ export interface GltfPrimitive {
20
+ readonly mesh: MeshHandle;
21
+ readonly material: MaterialHandle;
22
+ readonly node: string;
23
+ /** Node world transform in model space, before placement; treat its elements as immutable. */
24
+ readonly matrix: Float32Array;
25
+ }
26
+
27
+ export interface Gltf {
28
+ readonly world: World;
29
+ readonly primitives: readonly GltfPrimitive[];
30
+ readonly unsupported: readonly string[];
31
+ }
32
+
33
+ export interface GltfPlacement {
34
+ translation?: readonly [number, number, number];
35
+ rotation?: readonly [number, number, number, number];
36
+ scale?: readonly [number, number, number];
37
+ }
38
+
39
+ /** Registers the default scene before warm, from the game's async initialize.
40
+ * The handle and its mesh, image and material registrations belong to this world until disposal.
41
+ */
42
+ export async function loadGltfScene(
43
+ world: World,
44
+ url: string | URL,
45
+ onProgress?: (progress: number) => void,
46
+ ): Promise<Gltf> {
47
+ onProgress?.(0);
48
+ const scene = await loadGltf(url);
49
+ onProgress?.(0.5);
50
+ const textures = scene.textures.map((texture) => {
51
+ if (texture.image === undefined) return undefined;
52
+ const image = scene.images[texture.image];
53
+ return registerImage(
54
+ world,
55
+ image.uri ?? new Blob([image.bytes!.slice().buffer], { type: image.mimeType }),
56
+ { sampler: texture.sampler },
57
+ );
58
+ });
59
+ const materials = world.resource(Materials);
60
+ const handles = scene.materials.map((material) =>
61
+ materials.add(
62
+ StandardMaterial({
63
+ baseColor: material.color,
64
+ baseColorTexture:
65
+ material.texture === undefined ? undefined : textures[material.texture],
66
+ metallic: material.metallic,
67
+ perceptualRoughness: material.roughness,
68
+ emissive: material.emissive,
69
+ unlit: material.unlit,
70
+ }),
71
+ {
72
+ alphaMode:
73
+ material.alphaMode === "MASK"
74
+ ? AlphaMode.Mask(material.cutoff)
75
+ : material.alphaMode === "BLEND"
76
+ ? AlphaMode.Blend
77
+ : AlphaMode.Opaque,
78
+ },
79
+ ),
80
+ );
81
+ let defaultMaterial: MaterialHandle | undefined;
82
+ const meshes = scene.meshes.map((mesh) => world.resource(Meshes).add(mesh));
83
+ const primitives = scene.instances.map((instance) => {
84
+ const materialIndex = scene.meshes[instance.mesh].material;
85
+ if (materialIndex < 0)
86
+ defaultMaterial ??= materials.add(
87
+ StandardMaterial({ metallic: 1, perceptualRoughness: 1 }),
88
+ );
89
+ return {
90
+ mesh: meshes[instance.mesh],
91
+ material: handles[materialIndex] ?? defaultMaterial!,
92
+ node: instance.node,
93
+ matrix: instance.matrix,
94
+ };
95
+ });
96
+ onProgress?.(1);
97
+ return { world, primitives, unsupported: scene.unsupported };
98
+ }
99
+
100
+ /** Spawns one entity per primitive instance, composing placement onto the node's world transform.
101
+ * Refuses a foreign-world handle or a non-TRS composition, naming the node, before spawning anything.
102
+ */
103
+ export function spawnGltfScene(world: World, gltf: Gltf, placement: GltfPlacement = {}): number[] {
104
+ if (gltf.world !== world) throw new Error("glTF handle belongs to another world");
105
+ const matrix = composeMat4(
106
+ ...(placement.translation ?? [0, 0, 0]),
107
+ ...(placement.rotation ?? [0, 0, 0, 1]),
108
+ ...(placement.scale ?? [1, 1, 1]),
109
+ );
110
+ const transforms = gltf.primitives.map((primitive) => {
111
+ const composed = multiplyMat4(matrix, primitive.matrix);
112
+ const t = decomposeMat4(composed);
113
+ const recomposed = composeMat4(t[0], t[1], t[2], t[3], t[4], t[5], t[6], t[7], t[8], t[9]);
114
+ if (
115
+ composed.some(
116
+ (value, i) =>
117
+ !Number.isFinite(value) ||
118
+ Math.abs(value - recomposed[i]) > 1e-5 * Math.max(1, Math.abs(value)),
119
+ )
120
+ )
121
+ throw new Error(
122
+ `glTF node "${primitive.node}": composed transform has shear or cannot be represented as TRS`,
123
+ );
124
+ return {
125
+ translation: [t[0], t[1], t[2]] as [number, number, number],
126
+ rotation: [t[3], t[4], t[5], t[6]] as [number, number, number, number],
127
+ scale: [t[7], t[8], t[9]] as [number, number, number],
128
+ };
129
+ });
130
+ return gltf.primitives.map((primitive, i) => {
131
+ const eid = world.create();
132
+ world.add(eid, Transform, transforms[i]);
133
+ world.add(eid, MeshInstance, { mesh: primitive.mesh });
134
+ world.add(eid, MeshMaterial, primitive.material);
135
+ return eid;
136
+ });
137
+ }