@dylanebert/shallot-gltf 0.0.0-stage → 0.1.0-next.1

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,53 @@
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 2.0 loading and placement for Shallot. Not specification-complete or optimized.
4
+
5
+ ```sh
6
+ bun add @dylanebert/shallot-gltf @dylanebert/shallot
7
+ ```
8
+
9
+ ```ts
10
+ import { MeshPlugin, type Plugin, runApp } from "@dylanebert/shallot";
11
+ import { loadGltfScene, spawnGltfScene } from "@dylanebert/shallot-gltf";
12
+
13
+ const Model: Plugin = {
14
+ name: "Model",
15
+ dependencies: [MeshPlugin],
16
+ async initialize(world, onProgress) {
17
+ const model = await loadGltfScene(world, new URL("/model.glb", location.href), onProgress);
18
+ spawnGltfScene(world, model, { translation: [0, 0, 0] });
19
+ console.log(model.unsupported);
20
+ },
21
+ };
22
+ await runApp({ plugins: [Model] });
23
+ ```
24
+
25
+ The game owns loading and placement; this package needs no plugin state. Call `loadGltfScene` from the game's async `initialize`, before image warm-up. Its handle belongs to that world. `spawnGltfScene` creates one entity per primitive instance, composing placement onto its node world transform. A composition with shear refuses with the node named, before spawning any entities. There is no core parent relation or model-root entity. Repeated placements share the load's meshes and materials, rather than registering copies. `model.primitives` exposes each `mesh`, `material` and model-space `matrix` for games that supply placements through `registerProducerTable` instead.
26
+
27
+ `loadGltf` and `parse` remain deviceless entry points. [examples/gltf-model](examples/gltf-model) loads the textured Khronos Cesium Milk Truck through the package name. Run it with `bunx vite examples/gltf-model`; check its presentation with `bunx playwright test -c examples/gltf-model --workers=1`.
28
+
29
+ ## Compatibility
30
+
31
+ | shallot-gltf | Shallot |
32
+ | --- | --- |
33
+ | 0.1.0-next.1 | 0.10.0-next.5 and later 0.10 releases |
34
+
35
+ ## Supported subset
36
+
37
+ - `.gltf` and `.glb` v2, with external, data-URI or BIN buffers. `parse` accepts already-resolved buffers. External image URIs become absolute; embedded images expose their bytes. `loadGltfScene` registers these images in Shallot's shared sRGB image array; Shallot decodes them at warm.
38
+ - Triangle primitives: positions, normals, first UV set and unsigned indices, including interleaved accessors and accessor offsets. Missing normals deindex the triangles and compute flat normals as glTF requires (three.js's smooth fallback is deliberately not copied).
39
+ - Default scene hierarchy, matrix or TRS nodes, shared mesh primitives and column-major world matrices per primitive instance. Matrices retain shear; geometry remains mesh-local. No core parent relation is needed.
40
+ - Linear base color RGBA, base color image reference, metallic, roughness and emissive factors, OPAQUE/MASK/BLEND, cutoff (default 0.5), and `KHR_materials_unlit`. These target Shallot's existing `StandardMaterial` fields rather than extending core.
41
+
42
+ Unknown required extensions refuse with their names, including compression extensions. Unknown optional extensions and ignored features are named in `unsupported`. Non-triangle primitives and sparse-accessor primitives are skipped and reported. Vertex colors, tangents and extra UV sets are ignored and named. Base color textures requesting a UV set other than zero are omitted and reported. Base-color textures register their own wrap modes and texel filters, including glTF's default REPEAT wrapping. Unspecified filters follow GLTFLoader: linear magnification and linear-mipmap-linear minification. Shallot selects the nearest mip, so `unsupported` includes `sampler.minFilter.mipSelection` for linear mip blending or disabled mipmapping, including the unspecified minification default. Double-sided materials are reported, not mapped. Metallic-roughness, normal, occlusion and emissive maps are each named when ignored; their factors still decode.
43
+
44
+ ## Scope cuts
45
+
46
+ - Skinning, VAT, morph targets and animation clips are outside the static mesh pipeline; static geometry remains, without deformation or playback.
47
+ - Lights and cameras are outside that pipeline.
48
+ - Draco, meshopt and KTX2/Basis decoders are excluded: Bevy's core glTF loader ships none of them; each needs a vendored WASM decoder without a consumer here. Required compressed features refuse; optional extensions are reported and fallback data is used when available.
49
+ - The archived worker pool, albedo buckets, palette union and scene routes depended on Shallot's removed renderer and are not recovered.
50
+
51
+ The accessor, packing and node-local transform code is adapted from Shallot's archived `src/transitional/gltf/` at `f1023acd28a0fe31f64bc61345d4605887495201`; Accessor and GLB container tests are recovered from `a4e87190^:src/extras/gltf/`. Loading registers images before warm-up; runtime streaming is outside the supported subset.
52
+
53
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for verification and [fixture provenance](tests/fixtures/README.md) for licenses.
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-next.1",
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-next.5"
36
+ },
37
+ "devDependencies": {
38
+ "@biomejs/biome": "^2.5.13",
39
+ "@dylanebert/shallot": "0.10.0-next.5",
40
+ "@playwright/test": "^1.64.1",
41
+ "@types/bun": "^1.4.3",
42
+ "@types/three": "0.180.0",
43
+ "bun-webgpu": "github:dylanebert/bun-webgpu#7b75d0440d2f22e2c9a1cb383d15529652c53d98",
44
+ "three": "0.180.0",
45
+ "vite": "^8.3.4"
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,490 @@
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, index) => {
393
+ const sampler =
394
+ texture.sampler === undefined ? undefined : gltf.samplers?.[texture.sampler];
395
+ // Shallot selects the nearest mip; glTF's unspecified min filter follows GLTFLoader.
396
+ const min = sampler?.minFilter ?? 9987;
397
+ if (
398
+ materials.some((material) => material.texture === index) &&
399
+ min !== 9984 &&
400
+ min !== 9985
401
+ )
402
+ ignored.add("sampler.minFilter.mipSelection");
403
+ return {
404
+ image: texture.source,
405
+ sampler: {
406
+ addressModeU: addressMode(sampler?.wrapS),
407
+ addressModeV: addressMode(sampler?.wrapT),
408
+ magFilter:
409
+ sampler?.magFilter === 9728 ? ImageFilterMode.Nearest : ImageFilterMode.Linear,
410
+ minFilter:
411
+ min === 9728 || min === 9984 || min === 9986
412
+ ? ImageFilterMode.Nearest
413
+ : ImageFilterMode.Linear,
414
+ },
415
+ };
416
+ });
417
+ const meshes: GltfMesh[] = [];
418
+ const meshEntries = (gltf.meshes ?? []).map((mesh, meshIndex) => {
419
+ const entries: number[] = [];
420
+ for (const [primitiveIndex, prim] of mesh.primitives.entries()) {
421
+ for (const semantic of Object.keys(prim.attributes)) {
422
+ if (!["POSITION", "NORMAL", "TEXCOORD_0"].includes(semantic)) ignored.add(semantic);
423
+ }
424
+ if (prim.targets?.length) ignored.add("morph");
425
+ if ((prim.mode ?? TRIANGLES) !== TRIANGLES) {
426
+ ignored.add("primitive-mode");
427
+ continue;
428
+ }
429
+ const accessors = [
430
+ prim.attributes.POSITION,
431
+ prim.attributes.NORMAL,
432
+ prim.attributes.TEXCOORD_0,
433
+ prim.indices,
434
+ ].filter((a): a is number => a !== undefined);
435
+ if (
436
+ accessors.some(
437
+ (a) =>
438
+ gltf.accessors?.[a]?.sparse ||
439
+ gltf.accessors?.[a]?.bufferView === undefined,
440
+ )
441
+ ) {
442
+ ignored.add("sparse-accessor");
443
+ continue;
444
+ }
445
+ entries.push(meshes.length);
446
+ meshes.push(
447
+ decodeMesh(
448
+ gltf,
449
+ buffers,
450
+ prim,
451
+ `${mesh.name ?? `Mesh${meshIndex}`}/Primitive${primitiveIndex}`,
452
+ ),
453
+ );
454
+ }
455
+ return entries;
456
+ });
457
+ const instances: GltfScene["instances"] = [];
458
+ const nodes = gltf.nodes ?? [];
459
+ const children = new Set(nodes.flatMap((n) => n.children ?? []));
460
+ const roots =
461
+ gltf.scenes?.[gltf.scene ?? 0]?.nodes ??
462
+ nodes.map((_, i) => i).filter((i) => !children.has(i));
463
+ const walk = (index: number, parent: Float32Array, ancestors: Set<number>): void => {
464
+ if (ancestors.has(index)) throw new Error("Cyclic glTF node hierarchy");
465
+ const node = nodes[index];
466
+ const matrix = multiply(parent, localMatrix(node, new Float32Array(16)));
467
+ if (node.mesh !== undefined)
468
+ for (const mesh of meshEntries[node.mesh])
469
+ instances.push({ mesh, node: node.name ?? `Node${index}`, matrix });
470
+ for (const child of node.children ?? [])
471
+ walk(child, matrix, new Set([...ancestors, index]));
472
+ };
473
+ const identity = compose(0, 0, 0, 0, 0, 0, 1, 1, 1, 1);
474
+ for (const root of roots) walk(root, identity, new Set());
475
+ return {
476
+ meshes,
477
+ instances,
478
+ materials,
479
+ textures,
480
+ images: (gltf.images ?? []).map((image) => {
481
+ if (image.bufferView === undefined) return { uri: image.uri, mimeType: image.mimeType };
482
+ const view = gltf.bufferViews![image.bufferView];
483
+ return {
484
+ mimeType: image.mimeType,
485
+ bytes: new Uint8Array(buffers[view.buffer], view.byteOffset ?? 0, view.byteLength),
486
+ };
487
+ }),
488
+ unsupported: [...ignored].sort(),
489
+ };
490
+ }
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
+ }