@solidrt/3d 0.0.55 → 0.0.57

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.
Files changed (72) hide show
  1. package/AGENTS.md +1206 -224
  2. package/README.md +16 -7
  3. package/examples/README.md +88 -9
  4. package/examples/aim.tsx +2 -3
  5. package/examples/cascades.tsx +1 -4
  6. package/examples/collision.tsx +263 -0
  7. package/examples/first-person.tsx +129 -0
  8. package/examples/fleet.tsx +200 -0
  9. package/examples/fog.tsx +2 -3
  10. package/examples/instanced.tsx +72 -32
  11. package/examples/lamps.tsx +92 -0
  12. package/examples/lit.tsx +3 -5
  13. package/examples/materials.tsx +150 -0
  14. package/examples/model-load.tsx +95 -0
  15. package/examples/model.glb +0 -0
  16. package/examples/model.tsx +35 -9
  17. package/examples/pick.tsx +145 -37
  18. package/examples/probe.tsx +107 -0
  19. package/examples/scene-background.tsx +2 -4
  20. package/examples/scene-basic.tsx +2 -4
  21. package/examples/scene-views.tsx +68 -48
  22. package/examples/shadows.tsx +2 -4
  23. package/examples/sky-lit.tsx +92 -0
  24. package/examples/skybox.tsx +134 -0
  25. package/examples/sprites.tsx +4 -6
  26. package/examples/standard.tsx +138 -0
  27. package/examples/sweep-paths.tsx +2 -4
  28. package/package.json +3 -3
  29. package/src/camera.ts +148 -0
  30. package/src/clip.ts +96 -0
  31. package/src/collision.ts +20 -0
  32. package/src/color.ts +32 -0
  33. package/src/components/context.tsx +35 -0
  34. package/src/components/directional-light.tsx +48 -0
  35. package/src/components/first-person-camera.tsx +72 -0
  36. package/src/components/group.tsx +18 -0
  37. package/src/components/hemisphere-light.tsx +24 -0
  38. package/src/components/index.ts +46 -0
  39. package/src/components/instanced-mesh.tsx +105 -0
  40. package/src/components/mesh.tsx +92 -0
  41. package/src/components/node-props.ts +90 -0
  42. package/src/components/orbit-camera.tsx +75 -0
  43. package/src/components/perspective-camera.tsx +32 -0
  44. package/src/components/point-light.tsx +52 -0
  45. package/src/components/record-mesh.tsx +68 -0
  46. package/src/components/scene.tsx +281 -0
  47. package/src/components/spot-light.tsx +65 -0
  48. package/src/components/sprite.tsx +38 -0
  49. package/src/components/view3d.tsx +152 -0
  50. package/src/environment-bake.ts +492 -0
  51. package/src/environment.ts +325 -0
  52. package/src/first-person.ts +258 -0
  53. package/src/geometry.ts +6 -2
  54. package/src/glsl.ts +1343 -82
  55. package/src/gltf.ts +432 -112
  56. package/src/index.ts +61 -9
  57. package/src/input.ts +70 -0
  58. package/src/light.ts +383 -0
  59. package/src/material.ts +667 -232
  60. package/src/math.ts +110 -0
  61. package/src/mesh.ts +728 -0
  62. package/src/mixer.ts +476 -0
  63. package/src/model-file.ts +80 -15
  64. package/src/model.ts +204 -35
  65. package/src/node.ts +579 -0
  66. package/src/orbit.ts +292 -212
  67. package/src/scene-pointer.ts +294 -0
  68. package/src/scene-shadows.ts +471 -0
  69. package/src/scene.ts +1435 -1576
  70. package/tools/environment.ts +69 -0
  71. package/tools/model.ts +4 -3
  72. package/src/components.tsx +0 -420
package/src/gltf.ts CHANGED
@@ -1,10 +1,16 @@
1
1
  // glTF 2.0, the subset an app needs to show authored models: the scene's
2
- // node tree with world transforms BAKED into the vertices (one part per
3
- // mesh node, its name kept), triangles with positions, normals (flat ones
4
- // generated when absent, per the spec), one UV set and indices, and
5
- // materials reduced to what lit()/unlit() draw - base color factor and
6
- // texture, double-sidedness, alpha blending. Both containers: .gltf JSON
7
- // with external or data: buffers and images, and single-file .glb.
2
+ // node tree RETAINED - a node table of local TRS with parent links (matrix
3
+ // nodes decomposed; shear dropped), vertices in NODE-LOCAL space, one part
4
+ // per mesh primitive referencing its node - triangles with positions,
5
+ // normals (flat ones generated when absent, per the spec), one UV set and
6
+ // indices, and materials reduced to what lit()/unlit() draw - base color
7
+ // factor and texture, normal map (with scale), emissive factor and map
8
+ // (KHR_materials_emissive_strength as the intensity), double-sidedness, alpha
9
+ // blending and masking - plus the file's animations as baked clips
10
+ // (times/values per channel, the mixer's food). Nodes with no part below
11
+ // them and no animation channel targeting them (cameras, lights, unused
12
+ // empties) are pruned. Both containers: .gltf JSON with external or
13
+ // data: buffers and images, and single-file .glb.
8
14
  //
9
15
  // Pure module by design - a parse is JSON plus typed-array views plus one
10
16
  // interleave loop per primitive, so it runs the same under bun (the bake
@@ -14,19 +20,22 @@
14
20
  //
15
21
  // Outside the subset: Draco/meshopt-compressed meshes and any other
16
22
  // required extension throw naming it; non-triangle primitives, sparse
17
- // accessors and morph/skin data are skipped or ignored; vertex colors,
18
- // tangents and further UV sets are dropped (the standard layout has no
19
- // slot for them yet).
23
+ // accessors and morph targets (the "weights" channel path) are skipped
24
+ // or ignored; vertex colors, tangents and further UV sets are dropped
25
+ // (the standard layout has no slot for them yet). Skins ARE parsed:
26
+ // joints, inverse binds, and the "skinned" vertex layout.
20
27
 
21
- import { compose, mat4, multiply, normalMatrix } from "./math.ts"
28
+ import { compose, decompose, det3, mat4, multiply } from "./math.ts"
29
+ import { linearToSrgb } from "./color.ts"
22
30
  import type { Mat4, Quat, Vec3 } from "./math.ts"
23
- import { packGeometry, STANDARD_FLOATS } from "./geometry.ts"
31
+ import { layoutStride, packGeometry, packIndices, STANDARD_FLOATS } from "./geometry.ts"
24
32
  import type { Geometry } from "./geometry.ts"
25
33
 
26
34
  /** What lit()/unlit() take from a glTF material. */
27
35
  export type ModelMaterial = {
28
36
  name: string
29
- /** Straight [r, g, b, a] 0..1 (glTF baseColorFactor). */
37
+ /** Straight [r, g, b, a] 0..1: glTF's LINEAR baseColorFactor encoded
38
+ * to sRGB, what lit({ color }) takes. */
30
39
  color: [number, number, number, number]
31
40
  /** Index into ModelData.images (the base color texture), or null. */
32
41
  map: number | null
@@ -40,26 +49,129 @@ export type ModelMaterial = {
40
49
  alphaMode: "OPAQUE" | "MASK" | "BLEND"
41
50
  /** glTF alphaCutoff (default 0.5); meaningful for MASK only. */
42
51
  alphaCutoff: number
52
+ /** Index into ModelData.images (the tangent-space normal map), or null. */
53
+ normalMap: number | null
54
+ /** glTF normalTexture.scale (default 1); meaningful with normalMap. */
55
+ normalScale: number
56
+ /** glTF's linear emissiveFactor (default [0, 0, 0] = off) encoded to
57
+ * sRGB, what lit({ emissive }) takes. */
58
+ emissive: [number, number, number]
59
+ /** KHR_materials_emissive_strength (default 1): lit's emissiveIntensity. */
60
+ emissiveIntensity: number
61
+ /** Index into ModelData.images (the emissive map), or null. */
62
+ emissiveMap: number | null
63
+ /** glTF metallicFactor (default 1): standard's metalness. */
64
+ metalness: number
65
+ /** glTF roughnessFactor (default 1): standard's roughness. */
66
+ roughness: number
67
+ /** Index into ModelData.images (glTF's ONE packed
68
+ * metallicRoughnessTexture: green = roughness, blue = metalness), or
69
+ * null; createModel hands it to `material` as both maps.metalnessMap
70
+ * and maps.roughnessMap, standard's two channel-select options. */
71
+ metalnessRoughnessMap: number | null
43
72
  }
44
73
 
45
- /** One drawable: a mesh node's primitive, vertices in WORLD space. */
74
+ /** One node of the model's retained hierarchy: a local TRS under `parent`
75
+ * (an earlier index into ModelData.nodes, or null for a root). Parts and
76
+ * - in rigged models - skin joints and animation tracks reference nodes
77
+ * by index. */
78
+ export type ModelNode = {
79
+ /** The glTF node's name (or `node<i>`). */
80
+ name: string
81
+ /** Index of the parent in ModelData.nodes (always lower - the table is
82
+ * in pre-order), or null for a root node. */
83
+ parent: number | null
84
+ position: Vec3
85
+ /** Unit quaternion [x, y, z, w]. */
86
+ rotation: Quat
87
+ scale: Vec3
88
+ }
89
+
90
+ /** One drawable: a mesh node's primitive. Vertices are LOCAL to its
91
+ * node - except a skinned part, whose vertices are in MODEL space at the
92
+ * bind pose (the spec ignores a skinned node's transform; the skin
93
+ * matrices place them). */
46
94
  export type ModelPart = {
47
- /** The glTF node's name (or the mesh's, or `node<i>`); a node with
48
- * several primitives numbers them `name#<k>`. */
95
+ /** The owning node's name; a node whose mesh has several primitives
96
+ * numbers them `name#<k>`. */
49
97
  name: string
98
+ /** Index into ModelData.nodes - the node whose world transform places
99
+ * this part (unused for placement when `skin` is set). */
100
+ node: number
101
+ /** Index into ModelData.skins, or null. A skinned part's geometry has
102
+ * the "skinned" layout (aJoints/aWeights after the standard prefix). */
103
+ skin: number | null
50
104
  geometry: Geometry
51
105
  /** Index into ModelData.materials. */
52
106
  material: number
53
107
  }
54
108
 
109
+ /** One skin: the joints (as node-table indices) and each joint's inverse
110
+ * bind matrix - model space to joint space at the bind pose. The palette
111
+ * a skinned draw needs is jointWorld x inverseBind per joint. */
112
+ export type ModelSkin = {
113
+ /** Indices into ModelData.nodes, one per joint. */
114
+ joints: number[]
115
+ /** 16 floats per joint, column-major, in joint order. */
116
+ inverseBind: Float32Array
117
+ /** 6 floats per joint: the box, in the JOINT's space, of every vertex it
118
+ * influences - the runtime culls a skinned part by the union of these
119
+ * carried through the posed joints, so the box follows the animation
120
+ * (Godot's and Unity's per-bone bounds). A joint influencing nothing
121
+ * holds an inverted box (min > max). */
122
+ jointBounds: Float32Array
123
+ }
124
+
125
+ /** One animated property of one node: baked key times and values. */
126
+ export type ModelChannel = {
127
+ /** Index into ModelData.nodes. */
128
+ node: number
129
+ path: "position" | "rotation" | "scale"
130
+ /** glTF's three: "step" holds each key, "linear" lerps (a rotation
131
+ * slerps), "cubic" is CUBICSPLINE - a Hermite with per-key tangents. */
132
+ interpolation: "step" | "linear" | "cubic"
133
+ /** Key times in seconds, ascending. */
134
+ times: Float32Array
135
+ /** One element per key: 3 floats for position/scale, 4 (a quaternion)
136
+ * for rotation. "cubic" stores THREE elements per key - in-tangent,
137
+ * value, out-tangent, in that order. */
138
+ values: Float32Array
139
+ }
140
+
141
+ /** One named animation: baked channels over the model's nodes - what the
142
+ * mixer plays and what animation-core will consume. */
143
+ export type ModelClip = {
144
+ name: string
145
+ /** Seconds: the largest key time over the channels. */
146
+ duration: number
147
+ channels: ModelChannel[]
148
+ /** @internal The core clip id once a mixer registered it; freed by
149
+ * model.dispose. */
150
+ _core?: number
151
+ /** @internal Root-corrected core clips (travel pinned and/or height
152
+ * rebased) by variant key, registered on first such play; freed by
153
+ * model.dispose. */
154
+ _coreVariants?: Map<string, number>
155
+ }
156
+
55
157
  /** A parsed model: plain data, no GPU resources. What parseGltf and
56
158
  * decodeModel produce and createModel consumes. */
57
159
  export type ModelData = {
160
+ /** The retained node hierarchy, pre-order (parents before children),
161
+ * pruned to nodes with a part somewhere below them or an animation
162
+ * channel targeting them. */
163
+ nodes: ModelNode[]
58
164
  parts: ModelPart[]
165
+ /** The skins the parts reference (empty for an unrigged model). */
166
+ skins: ModelSkin[]
167
+ /** The file's animations (empty when it has none). */
168
+ clips: ModelClip[]
59
169
  materials: ModelMaterial[]
60
170
  /** Encoded image files (PNG/JPEG bytes) the materials' `map` index. */
61
171
  images: Uint8Array[]
62
- /** World-space [minX, minY, minZ, maxX, maxY, maxZ] over every part. */
172
+ /** World-space rest-pose [minX, minY, minZ, maxX, maxY, maxZ] over every
173
+ * part - each part's local box through its node's composed transform, so
174
+ * it is conservative (not vertex-tight) for parts under rotated nodes. */
63
175
  bounds: Float32Array
64
176
  }
65
177
 
@@ -75,6 +187,11 @@ const MODE_TRIANGLES = 4
75
187
  // The spec's alphaCutoff when a MASK material leaves it out.
76
188
  const GLTF_ALPHA_CUTOFF = 0.5
77
189
 
190
+ // The "skinned" layout's stride (standard prefix + aJoints + aWeights).
191
+ const SKINNED_FLOATS = layoutStride("skinned")
192
+
193
+ const IDENTITY = mat4()
194
+
78
195
  const COMPONENT_BYTES: Record<number, number> = { 5120: 1, 5121: 1, 5122: 2, 5123: 2, 5125: 4, 5126: 4 }
79
196
  const TYPE_ELEMENTS: Record<string, number> = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4, MAT2: 4, MAT3: 9, MAT4: 16 }
80
197
 
@@ -86,6 +203,14 @@ const DEFAULT_MATERIAL: ModelMaterial = {
86
203
  transparent: false,
87
204
  alphaMode: "OPAQUE",
88
205
  alphaCutoff: GLTF_ALPHA_CUTOFF,
206
+ normalMap: null,
207
+ normalScale: 1,
208
+ emissive: [0, 0, 0],
209
+ emissiveIntensity: 1,
210
+ emissiveMap: null,
211
+ metalness: 1,
212
+ roughness: 1,
213
+ metalnessRoughnessMap: null,
89
214
  }
90
215
 
91
216
  /** True when the bytes are a .glb container (the "glTF" magic). */
@@ -93,12 +218,13 @@ export function isGlb(bytes: Uint8Array): boolean {
93
218
  return bytes.length >= 12 && new DataView(bytes.buffer, bytes.byteOffset, 12).getUint32(0, true) === GLB_MAGIC
94
219
  }
95
220
 
96
- /** The external uris a .gltf document references (buffers and images), so
97
- * an async caller can fetch them before parseGltf. Empty for .glb and
98
- * data: uris. */
221
+ /** The external uris a document references (buffers and images), so an
222
+ * async caller can fetch them before parseGltf. Usually empty for .glb -
223
+ * but a .glb MAY reference external files (some exporters write image
224
+ * uris), so this reads its JSON chunk rather than assuming. data: uris
225
+ * are never listed. */
99
226
  export function gltfExternalUris(bytes: Uint8Array): string[] {
100
- if (isGlb(bytes)) return []
101
- let gltf = JSON.parse(new TextDecoder().decode(bytes))
227
+ let gltf = isGlb(bytes) ? readGlb(bytes).json : JSON.parse(new TextDecoder().decode(bytes))
102
228
  let uris: string[] = []
103
229
  for (let item of [...(gltf.buffers ?? []), ...(gltf.images ?? [])]) {
104
230
  if (typeof item.uri === "string" && !item.uri.startsWith("data:")) uris.push(item.uri)
@@ -107,9 +233,11 @@ export function gltfExternalUris(bytes: Uint8Array): string[] {
107
233
  }
108
234
 
109
235
  /**
110
- * Parse a .glb or .gltf into ModelData. `resolve` supplies the bytes of a
111
- * .gltf's external files by their uri as written in the document (still
112
- * percent-encoded); omit it for .glb.
236
+ * Parse a .glb or .gltf into ModelData. `resolve` supplies the bytes of
237
+ * external files by their uri as written in the document (still
238
+ * percent-encoded); a fully self-contained .glb needs none, but a .glb
239
+ * with external image uris (spec-legal, and real exporters write them)
240
+ * resolves the same way.
113
241
  */
114
242
  export function parseGltf(bytes: Uint8Array, resolve?: UriResolver): ModelData {
115
243
  let gltf: any
@@ -128,13 +256,17 @@ export function parseGltf(bytes: Uint8Array, resolve?: UriResolver): ModelData {
128
256
  if (ext === "KHR_draco_mesh_compression" || ext === "EXT_meshopt_compression") {
129
257
  throw new Error("parseGltf: the file's meshes are compressed (" + ext + "), which is not supported: re-export without mesh compression")
130
258
  }
131
- // Quantized attributes read through the normalized-integer path; every
132
- // other required extension changes what the file means.
133
- if (ext !== "KHR_mesh_quantization") {
259
+ // Quantized attributes read through the normalized-integer path,
260
+ // emissive strength is the emissive intensity; every other required
261
+ // extension changes what the file means.
262
+ if (ext !== "KHR_mesh_quantization" && ext !== "KHR_materials_emissive_strength") {
134
263
  throw new Error("parseGltf: the file requires the " + ext + " extension, which is not supported")
135
264
  }
136
265
  }
137
266
 
267
+ // Also reachable from a .glb: the container usually embeds everything,
268
+ // but external uris are legal there too (real exporters use them for
269
+ // images), which is why loadGltf resolves for both containers.
138
270
  let external = (uri: string, what: string): Uint8Array => {
139
271
  if (uri.startsWith("data:")) return decodeDataUri(uri)
140
272
  if (resolve === undefined) throw new Error("parseGltf: " + what + " references the external file " + uri + " and no resolver was given")
@@ -173,22 +305,36 @@ export function parseGltf(bytes: Uint8Array, resolve?: UriResolver): ModelData {
173
305
  return slot
174
306
  }
175
307
 
308
+ // A texture reference's image slot, or null (the reference absent, or
309
+ // its texture imageless). Further UV sets are outside the subset, so a
310
+ // non-zero texCoord is ignored and the map samples the one UV set.
311
+ let textureSlot = (ref: any): number | null => {
312
+ if (ref === undefined) return null
313
+ let texture = gltf.textures?.[ref.index]
314
+ return texture?.source !== undefined ? imageSlot(texture.source) : null
315
+ }
176
316
  let materials: ModelMaterial[] = (gltf.materials ?? []).map((m: any, i: number): ModelMaterial => {
177
317
  let pbr = m.pbrMetallicRoughness ?? {}
178
318
  let factor = pbr.baseColorFactor ?? [1, 1, 1, 1]
179
- let map: number | null = null
180
- if (pbr.baseColorTexture !== undefined) {
181
- let texture = gltf.textures?.[pbr.baseColorTexture.index]
182
- if (texture?.source !== undefined) map = imageSlot(texture.source)
183
- }
319
+ let emissiveFactor: number[] = m.emissiveFactor ?? [0, 0, 0]
320
+ let strength = m.extensions?.KHR_materials_emissive_strength?.emissiveStrength ?? 1
321
+ // glTF factors are linear; the material options are sRGB.
184
322
  return {
185
323
  name: m.name ?? "material" + i,
186
- color: [factor[0], factor[1], factor[2], factor[3] ?? 1],
187
- map,
324
+ color: [linearToSrgb(factor[0]), linearToSrgb(factor[1]), linearToSrgb(factor[2]), factor[3] ?? 1],
325
+ map: textureSlot(pbr.baseColorTexture),
188
326
  doubleSided: m.doubleSided === true,
189
327
  transparent: m.alphaMode === "BLEND",
190
328
  alphaMode: m.alphaMode === "MASK" || m.alphaMode === "BLEND" ? m.alphaMode : "OPAQUE",
191
329
  alphaCutoff: typeof m.alphaCutoff === "number" ? m.alphaCutoff : GLTF_ALPHA_CUTOFF,
330
+ normalMap: textureSlot(m.normalTexture),
331
+ normalScale: typeof m.normalTexture?.scale === "number" ? m.normalTexture.scale : 1,
332
+ emissive: [linearToSrgb(emissiveFactor[0] ?? 0), linearToSrgb(emissiveFactor[1] ?? 0), linearToSrgb(emissiveFactor[2] ?? 0)],
333
+ emissiveIntensity: strength,
334
+ emissiveMap: textureSlot(m.emissiveTexture),
335
+ metalness: typeof pbr.metallicFactor === "number" ? pbr.metallicFactor : 1,
336
+ roughness: typeof pbr.roughnessFactor === "number" ? pbr.roughnessFactor : 1,
337
+ metalnessRoughnessMap: textureSlot(pbr.metallicRoughnessTexture),
192
338
  }
193
339
  })
194
340
  // Primitives without a material draw the spec's default; it is appended
@@ -218,11 +364,25 @@ export function parseGltf(bytes: Uint8Array, resolve?: UriResolver): ModelData {
218
364
  return { data: out, elements, count: acc.count }
219
365
  }
220
366
 
367
+ // The node table is built lazily: a glTF node gets a slot only when a
368
+ // part somewhere below it needs the chain (materialize walks ancestors
369
+ // first, so the table stays in pre-order), which is what prunes cameras,
370
+ // lights and unused empties.
371
+ type PendingNode = { name: string; parent: PendingNode | null; position: Vec3; rotation: Quat; scale: Vec3; index: number | null }
372
+ let nodes: ModelNode[] = []
373
+ let materialize = (p: PendingNode): number => {
374
+ if (p.index !== null) return p.index
375
+ let parent = p.parent === null ? null : materialize(p.parent)
376
+ p.index = nodes.length
377
+ nodes.push({ name: p.name, parent, position: p.position, rotation: p.rotation, scale: p.scale })
378
+ return p.index
379
+ }
380
+
221
381
  let parts: ModelPart[] = []
382
+ let pendingJointBounds: { skin: number; positions: Float32Array; count: number; joints: Float32Array; weights: Float32Array }[] = []
222
383
  let bounds = new Float32Array([Infinity, Infinity, Infinity, -Infinity, -Infinity, -Infinity])
223
- let normal = mat4()
224
384
 
225
- let emit = (prim: any, name: string, world: Mat4): void => {
385
+ let emit = (prim: any, name: string, node: number, world: Mat4, skin: number | null): void => {
226
386
  if ((prim.mode ?? MODE_TRIANGLES) !== MODE_TRIANGLES) return
227
387
  if (prim.attributes?.POSITION === undefined) return
228
388
  let pos = accessorFloats(prim.attributes.POSITION, name + " POSITION")
@@ -230,18 +390,45 @@ export function parseGltf(bytes: Uint8Array, resolve?: UriResolver): ModelData {
230
390
  let count = pos.count
231
391
  let nrm = prim.attributes.NORMAL !== undefined ? accessorFloats(prim.attributes.NORMAL, name + " NORMAL").data : null
232
392
  let uv = prim.attributes.TEXCOORD_0 !== undefined ? accessorFloats(prim.attributes.TEXCOORD_0, name + " TEXCOORD_0").data : null
393
+ // A skinned primitive: joints and weights become the "skinned"
394
+ // layout's extra channels; its vertices are ALREADY model-space bind
395
+ // pose (the node transform is ignored, per spec), so no flip and
396
+ // identity bounds.
397
+ let skinned = skin !== null
398
+ let joints: Float32Array | null = null
399
+ let weights: Float32Array | null = null
400
+ if (skinned) {
401
+ if (prim.attributes.JOINTS_0 === undefined || prim.attributes.WEIGHTS_0 === undefined) {
402
+ throw new Error("parseGltf: " + name + " is skinned but lacks JOINTS_0/WEIGHTS_0")
403
+ }
404
+ let j = accessorFloats(prim.attributes.JOINTS_0, name + " JOINTS_0")
405
+ let w = accessorFloats(prim.attributes.WEIGHTS_0, name + " WEIGHTS_0")
406
+ if (j.elements !== 4 || w.elements !== 4) throw new Error("parseGltf: " + name + " JOINTS_0/WEIGHTS_0 are not VEC4")
407
+ joints = j.data
408
+ weights = w.data
409
+ }
410
+ let stride = skinned ? SKINNED_FLOATS : STANDARD_FLOATS
233
411
  let indices: ArrayLike<number> =
234
412
  prim.indices !== undefined ? accessorFloats(prim.indices, name + " indices").data : Array.from({ length: count }, (_, i) => i)
235
413
  if (indices.length % 3 !== 0) throw new Error("parseGltf: " + name + " index count is not a multiple of 3")
236
414
 
237
- normalMatrix(normal, world)
238
- let flip = det3(world) < 0
415
+ // A mirroring chain (negative world determinant at the rest pose)
416
+ // flips the displayed winding, so the index order is flipped here to
417
+ // compensate - cull: "back" keeps the outside. Stored normals stay the
418
+ // authored ones: the runtime's inverse-transpose maps them outward for
419
+ // the flipped winding. Baked from the REST pose; a scale animated
420
+ // across zero would unbake it, which is pathological.
421
+ let flip = !skinned && det3(world) < 0
422
+ // Joint boxes need the skin's inverse binds, and skins are built after
423
+ // the walk (their joints are ordinary nodes the walk registers), so
424
+ // the arrays are parked under the FILE's skin index and grown then.
425
+ if (skinned) pendingJointBounds.push({ skin: skin!, positions: pos.data, count: pos.count, joints: joints!, weights: weights! })
239
426
 
240
427
  let vertices: Float32Array
241
428
  let packedIndices: number[]
242
429
  if (nrm !== null) {
243
- vertices = new Float32Array(count * STANDARD_FLOATS)
244
- for (let i = 0; i < count; i++) writeVertex(vertices, i, pos.data, nrm, uv, i, world, normal)
430
+ vertices = new Float32Array(count * stride)
431
+ for (let i = 0; i < count; i++) writeVertex(vertices, i, pos.data, nrm, uv, i, stride, joints, weights)
245
432
  packedIndices = new Array(indices.length)
246
433
  for (let i = 0; i < indices.length; i += 3) {
247
434
  packedIndices[i] = indices[i]!
@@ -251,22 +438,28 @@ export function parseGltf(bytes: Uint8Array, resolve?: UriResolver): ModelData {
251
438
  } else {
252
439
  // No normals: the spec asks for flat shading, which needs one vertex
253
440
  // per triangle corner, so the primitive is un-indexed here and each
254
- // corner takes its face normal (computed in world space, after the
255
- // bake, so a mirroring transform is already accounted for).
441
+ // corner takes its face normal - of the AUTHORED winding (negated
442
+ // after a flip), the direction authored normals would have, so the
443
+ // runtime maps both the same way.
256
444
  let triangles = indices.length / 3
257
- vertices = new Float32Array(triangles * 3 * STANDARD_FLOATS)
445
+ vertices = new Float32Array(triangles * 3 * stride)
258
446
  packedIndices = new Array(triangles * 3)
259
447
  let face: Vec3 = [0, 0, 0]
260
448
  for (let t = 0; t < triangles; t++) {
261
449
  let a = indices[t * 3]!, b = indices[t * 3 + 1]!, c = indices[t * 3 + 2]!
262
450
  if (flip) [b, c] = [c, b]
263
451
  let out = t * 3
264
- writeVertex(vertices, out, pos.data, null, uv, a, world, normal)
265
- writeVertex(vertices, out + 1, pos.data, null, uv, b, world, normal)
266
- writeVertex(vertices, out + 2, pos.data, null, uv, c, world, normal)
267
- faceNormal(face, vertices, out)
452
+ writeVertex(vertices, out, pos.data, null, uv, a, stride, joints, weights)
453
+ writeVertex(vertices, out + 1, pos.data, null, uv, b, stride, joints, weights)
454
+ writeVertex(vertices, out + 2, pos.data, null, uv, c, stride, joints, weights)
455
+ faceNormal(face, vertices, out, stride)
456
+ if (flip) {
457
+ face[0] = -face[0]
458
+ face[1] = -face[1]
459
+ face[2] = -face[2]
460
+ }
268
461
  for (let k = 0; k < 3; k++) {
269
- let at = (out + k) * STANDARD_FLOATS + 3
462
+ let at = (out + k) * stride + 3
270
463
  vertices[at] = face[0]
271
464
  vertices[at + 1] = face[1]
272
465
  vertices[at + 2] = face[2]
@@ -276,15 +469,32 @@ export function parseGltf(bytes: Uint8Array, resolve?: UriResolver): ModelData {
276
469
  count = triangles * 3
277
470
  }
278
471
 
472
+ // Model bounds: the part's local box through the node's rest-pose
473
+ // world transform (8 corners - conservative under rotation, exact
474
+ // under translation and axis-aligned scale). Skinned vertices are
475
+ // already model-space, so their box goes through identity.
476
+ let bw = skinned ? IDENTITY : world
477
+ let lo: Vec3 = [Infinity, Infinity, Infinity]
478
+ let hi: Vec3 = [-Infinity, -Infinity, -Infinity]
279
479
  for (let i = 0; i < count; i++) {
280
- let at = i * STANDARD_FLOATS
281
- let x = vertices[at]!, y = vertices[at + 1]!, z = vertices[at + 2]!
282
- if (x < bounds[0]!) bounds[0] = x
283
- if (y < bounds[1]!) bounds[1] = y
284
- if (z < bounds[2]!) bounds[2] = z
285
- if (x > bounds[3]!) bounds[3] = x
286
- if (y > bounds[4]!) bounds[4] = y
287
- if (z > bounds[5]!) bounds[5] = z
480
+ let at = i * stride
481
+ for (let k = 0; k < 3; k++) {
482
+ let v = vertices[at + k]!
483
+ if (v < lo[k]!) lo[k] = v
484
+ if (v > hi[k]!) hi[k] = v
485
+ }
486
+ }
487
+ for (let corner = 0; corner < 8; corner++) {
488
+ let x = corner & 1 ? hi[0] : lo[0], y = corner & 2 ? hi[1] : lo[1], z = corner & 4 ? hi[2] : lo[2]
489
+ let wx = bw[0] * x + bw[4] * y + bw[8] * z + bw[12]
490
+ let wy = bw[1] * x + bw[5] * y + bw[9] * z + bw[13]
491
+ let wz = bw[2] * x + bw[6] * y + bw[10] * z + bw[14]
492
+ if (wx < bounds[0]!) bounds[0] = wx
493
+ if (wy < bounds[1]!) bounds[1] = wy
494
+ if (wz < bounds[2]!) bounds[2] = wz
495
+ if (wx > bounds[3]!) bounds[3] = wx
496
+ if (wy > bounds[4]!) bounds[4] = wy
497
+ if (wz > bounds[5]!) bounds[5] = wz
288
498
  }
289
499
 
290
500
  let material = prim.material
@@ -295,32 +505,160 @@ export function parseGltf(bytes: Uint8Array, resolve?: UriResolver): ModelData {
295
505
  }
296
506
  material = defaultMaterial
297
507
  }
298
- parts.push({ name, geometry: packGeometry(vertices, packedIndices, { label: name }), material })
508
+ let geometry: Geometry = skinned
509
+ ? { vertices, indices: packIndices(packedIndices, count), layout: "skinned", label: name }
510
+ : packGeometry(vertices, packedIndices, { label: name })
511
+ parts.push({ name, node, skin, geometry, material })
299
512
  }
300
513
 
301
514
  let local = mat4()
302
- let walk = (index: number, parent: Mat4): void => {
515
+ let pendingByIndex = new Map<number, PendingNode>()
516
+ let walk = (index: number, parent: PendingNode | null, parentWorld: Mat4): void => {
303
517
  let node = gltf.nodes[index]
304
518
  if (node === undefined) throw new Error("parseGltf: scene names a missing node " + index)
519
+ let position: Vec3 = [0, 0, 0]
520
+ let rotation: Quat = [0, 0, 0, 1]
521
+ let scale: Vec3 = [1, 1, 1]
522
+ if (node.matrix !== undefined) {
523
+ for (let i = 0; i < 16; i++) local[i] = node.matrix[i]
524
+ decompose(local, position, rotation, scale)
525
+ } else {
526
+ if (node.translation !== undefined) position = [node.translation[0], node.translation[1], node.translation[2]]
527
+ if (node.rotation !== undefined) rotation = [node.rotation[0], node.rotation[1], node.rotation[2], node.rotation[3]]
528
+ if (node.scale !== undefined) scale = [node.scale[0], node.scale[1], node.scale[2]]
529
+ }
530
+ // The world composes from the TRS (not a matrix node's raw matrix), so
531
+ // winding and bounds match what the runtime will render.
305
532
  let world = mat4()
306
- multiply(world, parent, nodeMatrix(local, node))
533
+ multiply(world, parentWorld, compose(local, position, rotation, scale))
534
+ let pending: PendingNode = { name: node.name ?? "node" + index, parent, position, rotation, scale, index: null }
535
+ pendingByIndex.set(index, pending)
307
536
  if (node.mesh !== undefined) {
308
537
  let mesh = gltf.meshes[node.mesh]
309
538
  if (mesh === undefined) throw new Error("parseGltf: node " + index + " names a missing mesh " + node.mesh)
310
- let name = node.name ?? mesh.name ?? "node" + index
539
+ if (node.name === undefined && mesh.name !== undefined) pending.name = mesh.name
540
+ let slot = materialize(pending)
541
+ let skin = typeof node.skin === "number" ? node.skin : null
311
542
  let prims: any[] = mesh.primitives ?? []
312
- for (let k = 0; k < prims.length; k++) emit(prims[k], prims.length > 1 ? name + "#" + k : name, world)
543
+ for (let k = 0; k < prims.length; k++) emit(prims[k], prims.length > 1 ? pending.name + "#" + k : pending.name, slot, world, skin)
313
544
  }
314
- for (let child of node.children ?? []) walk(child, world)
545
+ for (let child of node.children ?? []) walk(child, pending, world)
315
546
  }
316
547
 
317
548
  let scene = gltf.scenes?.[gltf.scene ?? 0]
318
549
  let roots: number[] = scene?.nodes ?? (gltf.nodes ?? []).map((_: unknown, i: number) => i)
319
550
  let root = mat4()
320
- for (let index of roots) walk(index, root)
551
+ for (let index of roots) walk(index, null, root)
552
+
553
+ // Skins, after the walk: joints are ordinary nodes (usually meshless),
554
+ // materialized here so the retained table carries them; part.skin
555
+ // remaps from the file's skin index to the compact list.
556
+ let skins: ModelSkin[] = []
557
+ // Per-joint bounds, grown from the parked skinned primitives once the
558
+ // compact skins exist: each influenced vertex (weight above zero) goes
559
+ // through the joint's inverse bind into joint space and grows that
560
+ // joint's box.
561
+ let growJointBounds = (skin: ModelSkin, positions: Float32Array, count: number, joints: Float32Array, weights: Float32Array): void => {
562
+ let jb = skin.jointBounds
563
+ let ib = skin.inverseBind
564
+ for (let i = 0; i < count; i++) {
565
+ let x = positions[i * 3]!, y = positions[i * 3 + 1]!, z = positions[i * 3 + 2]!
566
+ for (let k = 0; k < 4; k++) {
567
+ if (weights[i * 4 + k]! <= 0) continue
568
+ let j = joints[i * 4 + k]!
569
+ if (j >= skin.joints.length) continue
570
+ let m = j * 16
571
+ let px = ib[m]! * x + ib[m + 4]! * y + ib[m + 8]! * z + ib[m + 12]!
572
+ let py = ib[m + 1]! * x + ib[m + 5]! * y + ib[m + 9]! * z + ib[m + 13]!
573
+ let pz = ib[m + 2]! * x + ib[m + 6]! * y + ib[m + 10]! * z + ib[m + 14]!
574
+ let b = j * 6
575
+ if (px < jb[b]!) jb[b] = px
576
+ if (py < jb[b + 1]!) jb[b + 1] = py
577
+ if (pz < jb[b + 2]!) jb[b + 2] = pz
578
+ if (px > jb[b + 3]!) jb[b + 3] = px
579
+ if (py > jb[b + 4]!) jb[b + 4] = py
580
+ if (pz > jb[b + 5]!) jb[b + 5] = pz
581
+ }
582
+ }
583
+ }
584
+ let skinSlots = new Map<number, number>()
585
+ for (let part of parts) {
586
+ if (part.skin === null) continue
587
+ let slot = skinSlots.get(part.skin)
588
+ if (slot === undefined) {
589
+ let sk = gltf.skins?.[part.skin]
590
+ if (sk === undefined) throw new Error("parseGltf: part '" + part.name + "' names a missing skin " + part.skin)
591
+ let jointIndices: number[] = (sk.joints ?? []).map((j: number): number => {
592
+ let pending = pendingByIndex.get(j)
593
+ if (pending === undefined) throw new Error("parseGltf: skin joint node " + j + " is not in the scene")
594
+ return materialize(pending)
595
+ })
596
+ if (jointIndices.length === 0) throw new Error("parseGltf: skin " + part.skin + " has no joints")
597
+ let inverseBind: Float32Array
598
+ if (sk.inverseBindMatrices !== undefined) {
599
+ let acc = accessorFloats(sk.inverseBindMatrices, "skin inverseBindMatrices")
600
+ if (acc.elements !== 16 || acc.count !== jointIndices.length) {
601
+ throw new Error("parseGltf: skin inverseBindMatrices does not match the joint count")
602
+ }
603
+ inverseBind = acc.data
604
+ } else {
605
+ // The spec default: identity binds.
606
+ inverseBind = new Float32Array(jointIndices.length * 16)
607
+ for (let j = 0; j < jointIndices.length; j++) {
608
+ inverseBind[j * 16] = 1
609
+ inverseBind[j * 16 + 5] = 1
610
+ inverseBind[j * 16 + 10] = 1
611
+ inverseBind[j * 16 + 15] = 1
612
+ }
613
+ }
614
+ slot = skins.length
615
+ let jointBounds = new Float32Array(jointIndices.length * 6)
616
+ for (let j = 0; j < jointIndices.length; j++) jointBounds.set([Infinity, Infinity, Infinity, -Infinity, -Infinity, -Infinity], j * 6)
617
+ skins.push({ joints: jointIndices, inverseBind, jointBounds })
618
+ skinSlots.set(part.skin, slot)
619
+ }
620
+ part.skin = slot
621
+ }
622
+ for (let parked of pendingJointBounds) {
623
+ let slot = skinSlots.get(parked.skin)
624
+ if (slot !== undefined) growJointBounds(skins[slot]!, parked.positions, parked.count, parked.joints, parked.weights)
625
+ }
626
+
627
+ // Animations, after the walk so channels can materialize their target
628
+ // nodes (a channel may target a meshless node - a joint, a rig pivot).
629
+ const CHANNEL_PATHS: Record<string, ModelChannel["path"]> = { translation: "position", rotation: "rotation", scale: "scale" }
630
+ let clips: ModelClip[] = (gltf.animations ?? []).map((anim: any, ai: number): ModelClip => {
631
+ let clipName = anim.name ?? "clip" + ai
632
+ let channels: ModelChannel[] = []
633
+ let duration = 0
634
+ for (let ch of anim.channels ?? []) {
635
+ // Outside the subset: "weights" (morph targets) and extension paths.
636
+ let path = CHANNEL_PATHS[ch.target?.path]
637
+ if (path === undefined) continue
638
+ // A target outside the walked scene has nothing to move.
639
+ let pending = pendingByIndex.get(ch.target.node)
640
+ if (pending === undefined) continue
641
+ let sampler = anim.samplers?.[ch.sampler]
642
+ if (sampler === undefined) throw new Error("parseGltf: animation " + clipName + " channel names a missing sampler " + ch.sampler)
643
+ let what = "animation " + clipName
644
+ let input = accessorFloats(sampler.input, what + " input")
645
+ let output = accessorFloats(sampler.output, what + " output")
646
+ let interpolation: ModelChannel["interpolation"] =
647
+ sampler.interpolation === "STEP" ? "step" : sampler.interpolation === "CUBICSPLINE" ? "cubic" : "linear"
648
+ let elements = path === "rotation" ? 4 : 3
649
+ if (output.elements !== elements) throw new Error("parseGltf: " + what + " " + path + " output is not " + (elements === 4 ? "VEC4" : "VEC3"))
650
+ let perKey = interpolation === "cubic" ? 3 : 1
651
+ if (output.count !== input.count * perKey) {
652
+ throw new Error("parseGltf: " + what + " " + path + " has " + output.count + " values for " + input.count + " keys")
653
+ }
654
+ if (input.count > 0) duration = Math.max(duration, input.data[input.count - 1]!)
655
+ channels.push({ node: materialize(pending), path, interpolation, times: input.data, values: output.data })
656
+ }
657
+ return { name: clipName, duration, channels }
658
+ })
321
659
 
322
660
  if (parts.length === 0) bounds.fill(0)
323
- return { parts, materials, images, bounds }
661
+ return { nodes, parts, skins, clips, materials, images, bounds }
324
662
  }
325
663
 
326
664
  function readGlb(bytes: Uint8Array): { json: any; bin: Uint8Array | null } {
@@ -372,28 +710,11 @@ function readerFor(componentType: number, normalized: boolean): (dv: DataView, a
372
710
  }
373
711
  }
374
712
 
375
- const NO_TRANSLATION: Vec3 = [0, 0, 0]
376
- const NO_ROTATION: Quat = [0, 0, 0, 1]
377
- const NO_SCALE: Vec3 = [1, 1, 1]
378
-
379
- function nodeMatrix(out: Mat4, node: any): Mat4 {
380
- if (node.matrix !== undefined) {
381
- for (let i = 0; i < 16; i++) out[i] = node.matrix[i]
382
- return out
383
- }
384
- return compose(out, node.translation ?? NO_TRANSLATION, node.rotation ?? NO_ROTATION, node.scale ?? NO_SCALE)
385
- }
386
-
387
- function det3(m: Mat4): number {
388
- return (
389
- m[0] * (m[5] * m[10] - m[6] * m[9]) -
390
- m[4] * (m[1] * m[10] - m[2] * m[9]) +
391
- m[8] * (m[1] * m[6] - m[2] * m[5])
392
- )
393
- }
394
-
395
- // One interleaved vertex: position through the world matrix, normal (when
396
- // given) through the normal matrix and re-normalized, uv copied or zero.
713
+ // One interleaved vertex, node-local: position and normal copied as
714
+ // authored (the runtime's uModel/uNormal do the placing), uv copied or
715
+ // zero, and for the skinned layout the joint indices plus their weights
716
+ // RENORMALIZED to sum 1 (quantized exports drift a little; the spec asks
717
+ // for normalized weights).
397
718
  function writeVertex(
398
719
  out: Float32Array,
399
720
  slot: number,
@@ -401,42 +722,41 @@ function writeVertex(
401
722
  nrm: Float32Array | null,
402
723
  uv: Float32Array | null,
403
724
  src: number,
404
- world: Mat4,
405
- normal: Mat4,
725
+ stride: number,
726
+ joints: Float32Array | null = null,
727
+ weights: Float32Array | null = null,
406
728
  ): void {
407
- let at = slot * STANDARD_FLOATS
408
- let x = pos[src * 3]!, y = pos[src * 3 + 1]!, z = pos[src * 3 + 2]!
409
- out[at] = world[0] * x + world[4] * y + world[8] * z + world[12]
410
- out[at + 1] = world[1] * x + world[5] * y + world[9] * z + world[13]
411
- out[at + 2] = world[2] * x + world[6] * y + world[10] * z + world[14]
729
+ let at = slot * stride
730
+ out[at] = pos[src * 3]!
731
+ out[at + 1] = pos[src * 3 + 1]!
732
+ out[at + 2] = pos[src * 3 + 2]!
412
733
  if (nrm !== null) {
413
- let nx = nrm[src * 3]!, ny = nrm[src * 3 + 1]!, nz = nrm[src * 3 + 2]!
414
- let wx = normal[0] * nx + normal[4] * ny + normal[8] * nz
415
- let wy = normal[1] * nx + normal[5] * ny + normal[9] * nz
416
- let wz = normal[2] * nx + normal[6] * ny + normal[10] * nz
417
- let len = Math.hypot(wx, wy, wz)
418
- if (len > 1e-12) {
419
- wx /= len
420
- wy /= len
421
- wz /= len
422
- } else {
423
- wx = 0
424
- wy = 1
425
- wz = 0
426
- }
427
- out[at + 3] = wx
428
- out[at + 4] = wy
429
- out[at + 5] = wz
734
+ out[at + 3] = nrm[src * 3]!
735
+ out[at + 4] = nrm[src * 3 + 1]!
736
+ out[at + 5] = nrm[src * 3 + 2]!
430
737
  }
431
738
  if (uv !== null) {
432
739
  out[at + 6] = uv[src * 2]!
433
740
  out[at + 7] = uv[src * 2 + 1]!
434
741
  }
742
+ if (joints !== null && weights !== null) {
743
+ let j = src * 4
744
+ out[at + 8] = joints[j]!
745
+ out[at + 9] = joints[j + 1]!
746
+ out[at + 10] = joints[j + 2]!
747
+ out[at + 11] = joints[j + 3]!
748
+ let sum = weights[j]! + weights[j + 1]! + weights[j + 2]! + weights[j + 3]!
749
+ let inv = sum > 1e-8 ? 1 / sum : 0
750
+ out[at + 12] = weights[j]! * inv
751
+ out[at + 13] = weights[j + 1]! * inv
752
+ out[at + 14] = weights[j + 2]! * inv
753
+ out[at + 15] = weights[j + 3]! * inv
754
+ }
435
755
  }
436
756
 
437
757
  // The unit normal of the triangle at three consecutive vertex slots.
438
- function faceNormal(out: Vec3, v: Float32Array, first: number): void {
439
- let a = first * STANDARD_FLOATS, b = (first + 1) * STANDARD_FLOATS, c = (first + 2) * STANDARD_FLOATS
758
+ function faceNormal(out: Vec3, v: Float32Array, first: number, stride: number): void {
759
+ let a = first * stride, b = (first + 1) * stride, c = (first + 2) * stride
440
760
  let abx = v[b]! - v[a]!, aby = v[b + 1]! - v[a + 1]!, abz = v[b + 2]! - v[a + 2]!
441
761
  let acx = v[c]! - v[a]!, acy = v[c + 1]! - v[a + 1]!, acz = v[c + 2]! - v[a + 2]!
442
762
  let nx = aby * acz - abz * acy