@solidrt/3d 0.0.57 → 0.0.59

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 +1479 -678
  2. package/README.md +4 -2
  3. package/examples/README.md +56 -4
  4. package/examples/bloom.tsx +113 -0
  5. package/examples/cascades.tsx +4 -4
  6. package/examples/collision.tsx +6 -6
  7. package/examples/exits.tsx +96 -0
  8. package/examples/first-person.tsx +5 -5
  9. package/examples/fleet.tsx +4 -4
  10. package/examples/fog.tsx +5 -5
  11. package/examples/instanced.tsx +70 -50
  12. package/examples/lamps.tsx +4 -4
  13. package/examples/lod.tsx +125 -0
  14. package/examples/materials.tsx +9 -9
  15. package/examples/morph.tsx +174 -0
  16. package/examples/normals.tsx +118 -0
  17. package/examples/{lit.tsx → phong.tsx} +9 -9
  18. package/examples/polyhedra.tsx +61 -0
  19. package/examples/scene-atlas.tsx +70 -0
  20. package/examples/scene-views.tsx +5 -5
  21. package/examples/shadows.tsx +9 -9
  22. package/examples/sky-lit.tsx +3 -3
  23. package/examples/skybox.tsx +5 -5
  24. package/examples/standard.tsx +2 -2
  25. package/examples/streams.tsx +122 -0
  26. package/examples/sweep-paths.tsx +5 -5
  27. package/examples/wireframe.tsx +129 -0
  28. package/package.json +3 -3
  29. package/src/clip.ts +18 -5
  30. package/src/color.ts +11 -2
  31. package/src/components/context.tsx +11 -2
  32. package/src/components/directional-light.tsx +2 -2
  33. package/src/components/first-person-camera.tsx +4 -4
  34. package/src/components/group.tsx +9 -5
  35. package/src/components/hemisphere-light.tsx +2 -2
  36. package/src/components/index.ts +4 -1
  37. package/src/components/instanced-mesh.tsx +28 -10
  38. package/src/components/lod.tsx +102 -0
  39. package/src/components/mesh.tsx +24 -6
  40. package/src/components/node-props.ts +35 -5
  41. package/src/components/orbit-camera.tsx +15 -12
  42. package/src/components/point-light.tsx +2 -2
  43. package/src/components/record-mesh.tsx +12 -7
  44. package/src/components/scene.tsx +55 -2
  45. package/src/components/spot-light.tsx +2 -2
  46. package/src/components/sprite.tsx +2 -2
  47. package/src/components/view3d.tsx +18 -1
  48. package/src/environment.ts +9 -8
  49. package/src/first-person.ts +125 -38
  50. package/src/geometry-gpu.ts +187 -22
  51. package/src/geometry.ts +1718 -203
  52. package/src/glsl.ts +398 -113
  53. package/src/gltf.ts +457 -154
  54. package/src/index.ts +32 -21
  55. package/src/input.ts +11 -2
  56. package/src/light.ts +7 -8
  57. package/src/lod.ts +148 -0
  58. package/src/material.ts +322 -190
  59. package/src/mesh.ts +492 -259
  60. package/src/mixer.ts +27 -14
  61. package/src/model-file.ts +54 -15
  62. package/src/model.ts +151 -54
  63. package/src/motion.ts +17 -0
  64. package/src/node.ts +423 -23
  65. package/src/orbit.ts +378 -116
  66. package/src/profile.ts +4 -25
  67. package/src/resolve.ts +181 -0
  68. package/src/scene-shadows.ts +94 -93
  69. package/src/scene.ts +608 -185
  70. package/src/skeleton.ts +211 -0
  71. package/src/sweep.ts +27 -8
  72. package/tools/model.ts +3 -1
package/src/gltf.ts CHANGED
@@ -1,9 +1,12 @@
1
1
  // glTF 2.0, the subset an app needs to show authored models: the scene's
2
2
  // node tree RETAINED - a node table of local TRS with parent links (matrix
3
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
4
+ // per mesh primitive referencing its node - positions, normals (flat ones
5
+ // generated when absent, per the spec, for triangles), one UV set and
6
+ // indices in the primitive's topology (strips and fans unrolled to
7
+ // triangle lists, a line loop closed into a strip; lines and points keep
8
+ // theirs on the geometry), and materials reduced to what phong()/unlit()
9
+ // draw - base color
7
10
  // factor and texture, normal map (with scale), emissive factor and map
8
11
  // (KHR_materials_emissive_strength as the intensity), double-sidedness, alpha
9
12
  // blending and masking - plus the file's animations as baked clips
@@ -19,23 +22,30 @@
19
22
  // bytes in `images`, and uploading is the engine side's job.
20
23
  //
21
24
  // Outside the subset: Draco/meshopt-compressed meshes and any other
22
- // required extension throw naming it; non-triangle primitives, sparse
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.
25
+ // required extension throw naming it; tangents (the base channel and
26
+ // the morph target one) and further UV sets are dropped. Sparse
27
+ // accessors are read (what exporters write morph targets as), and
28
+ // morph targets land packed on the part's geometry with the mesh's
29
+ // weights on its node and the "weights" channel path kept. COLOR_0 IS
30
+ // parsed, linear and premultiplied as glTF stores it, into the aColor
31
+ // channel the stock materials read under vertexColors: the "colored"
32
+ // layout for a static primitive, the skinned list plus aColor for a
33
+ // rigged one. Skins ARE parsed: joints, inverse binds, and the "skinned"
34
+ // vertex layout.
27
35
 
28
36
  import { compose, decompose, det3, mat4, multiply } from "./math.ts"
29
37
  import { linearToSrgb } from "./color.ts"
30
38
  import type { Mat4, Quat, Vec3 } from "./math.ts"
31
- import { layoutStride, packGeometry, packIndices, STANDARD_FLOATS } from "./geometry.ts"
39
+ import { attributeAccess, layoutKey, layoutStride, packIndices, packMorphTargets, vertexView } from "./geometry.ts"
40
+ import type { AttributeAccess, MorphTarget, VertexLayout } from "./geometry.ts"
32
41
  import type { Geometry } from "./geometry.ts"
42
+ import type { Topology, VertexAttribute, VertexFormat } from "@solidrt/core/gpu"
33
43
 
34
- /** What lit()/unlit() take from a glTF material. */
44
+ /** What phong()/unlit() take from a glTF material. */
35
45
  export type ModelMaterial = {
36
46
  name: string
37
47
  /** Straight [r, g, b, a] 0..1: glTF's LINEAR baseColorFactor encoded
38
- * to sRGB, what lit({ color }) takes. */
48
+ * to sRGB, what phong({ color }) takes. */
39
49
  color: [number, number, number, number]
40
50
  /** Index into ModelData.images (the base color texture), or null. */
41
51
  map: number | null
@@ -54,9 +64,9 @@ export type ModelMaterial = {
54
64
  /** glTF normalTexture.scale (default 1); meaningful with normalMap. */
55
65
  normalScale: number
56
66
  /** glTF's linear emissiveFactor (default [0, 0, 0] = off) encoded to
57
- * sRGB, what lit({ emissive }) takes. */
67
+ * sRGB, what phong({ emissive }) takes. */
58
68
  emissive: [number, number, number]
59
- /** KHR_materials_emissive_strength (default 1): lit's emissiveIntensity. */
69
+ /** KHR_materials_emissive_strength (default 1): phong's emissiveIntensity. */
60
70
  emissiveIntensity: number
61
71
  /** Index into ModelData.images (the emissive map), or null. */
62
72
  emissiveMap: number | null
@@ -85,6 +95,11 @@ export type ModelNode = {
85
95
  /** Unit quaternion [x, y, z, w]. */
86
96
  rotation: Quat
87
97
  scale: Vec3
98
+ /** Present on a node whose mesh carries morph targets: the initial
99
+ * weight per target (the node's own `weights`, else the mesh's, else
100
+ * zeros), what the node's parts draw with until a clip or
101
+ * setMorphWeights writes them. */
102
+ weights?: number[]
88
103
  }
89
104
 
90
105
  /** One drawable: a mesh node's primitive. Vertices are LOCAL to its
@@ -99,8 +114,17 @@ export type ModelPart = {
99
114
  * this part (unused for placement when `skin` is set). */
100
115
  node: number
101
116
  /** Index into ModelData.skins, or null. A skinned part's geometry has
102
- * the "skinned" layout (aJoints/aWeights after the standard prefix). */
117
+ * the "skinned" layout (aJoints/aWeights after the base prefix). */
103
118
  skin: number | null
119
+ /** The part's vertices and indices. The layout is the base prefix,
120
+ * aJoints/aWeights for a rigged primitive, aColor for one with COLOR_0
121
+ * (premultiplied linear), each channel in the file's own format when
122
+ * the vertex vocabulary has it (a quantized uv, a byte color, u8/u16
123
+ * joints) and float otherwise - except joints, an integer channel:
124
+ * an off-spec float JOINTS_0 lands in uint16x4. Positions and normals
125
+ * are always float.
126
+ * The preset name ("base", "colored", "skinned") is used when the
127
+ * list equals it. */
104
128
  geometry: Geometry
105
129
  /** Index into ModelData.materials. */
106
130
  material: number
@@ -126,15 +150,18 @@ export type ModelSkin = {
126
150
  export type ModelChannel = {
127
151
  /** Index into ModelData.nodes. */
128
152
  node: number
129
- path: "position" | "rotation" | "scale"
153
+ /** The node's local TRS, or "weights": the morph target weights of the
154
+ * node's mesh (glTF's weights path). */
155
+ path: "position" | "rotation" | "scale" | "weights"
130
156
  /** glTF's three: "step" holds each key, "linear" lerps (a rotation
131
157
  * slerps), "cubic" is CUBICSPLINE - a Hermite with per-key tangents. */
132
158
  interpolation: "step" | "linear" | "cubic"
133
159
  /** Key times in seconds, ascending. */
134
160
  times: Float32Array
135
161
  /** 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. */
162
+ * for rotation, one per morph target for weights (`channelElements`
163
+ * reads it off the data). "cubic" stores THREE elements per key -
164
+ * in-tangent, value, out-tangent, in that order. */
138
165
  values: Float32Array
139
166
  }
140
167
 
@@ -170,8 +197,11 @@ export type ModelData = {
170
197
  /** Encoded image files (PNG/JPEG bytes) the materials' `map` index. */
171
198
  images: Uint8Array[]
172
199
  /** 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. */
200
+ * part - each part's local box through its node's composed transform, and
201
+ * a skinned part's per-joint boxes through the joints' rest transforms
202
+ * (where the skin places its vertices, armature scale included) - so it
203
+ * is conservative (not vertex-tight) for parts under rotated nodes and
204
+ * for skinned parts. */
175
205
  bounds: Float32Array
176
206
  }
177
207
 
@@ -182,15 +212,82 @@ export type UriResolver = (uri: string) => Uint8Array
182
212
  const GLB_MAGIC = 0x46546c67
183
213
  const CHUNK_JSON = 0x4e4f534a
184
214
  const CHUNK_BIN = 0x004e4942
215
+ // glTF primitive modes (the GL enum values).
216
+ const MODE_POINTS = 0
217
+ const MODE_LINES = 1
218
+ const MODE_LINE_LOOP = 2
219
+ const MODE_LINE_STRIP = 3
185
220
  const MODE_TRIANGLES = 4
221
+ const MODE_TRIANGLE_STRIP = 5
222
+ const MODE_TRIANGLE_FAN = 6
223
+
224
+ /** A triangle strip unrolled to a list: triangle t is (t, t+1, t+2),
225
+ * every odd one with its last two swapped so the winding stays put. */
226
+ function stripTriangles(strip: ArrayLike<number>): number[] {
227
+ let out: number[] = []
228
+ for (let t = 0; t + 2 < strip.length; t++) {
229
+ if (t % 2 === 0) out.push(strip[t]!, strip[t + 1]!, strip[t + 2]!)
230
+ else out.push(strip[t]!, strip[t + 2]!, strip[t + 1]!)
231
+ }
232
+ return out
233
+ }
234
+
235
+ /** A triangle fan unrolled to a list: every triangle shares the hub. */
236
+ function fanTriangles(fan: ArrayLike<number>): number[] {
237
+ let out: number[] = []
238
+ for (let t = 1; t + 1 < fan.length; t++) out.push(fan[0]!, fan[t]!, fan[t + 1]!)
239
+ return out
240
+ }
241
+
242
+ /** A line loop as a strip that returns to its first vertex. */
243
+ function closeLoop(loop: ArrayLike<number>): number[] {
244
+ let out = Array.from(loop)
245
+ if (loop.length > 0) out.push(loop[0]!)
246
+ return out
247
+ }
186
248
 
187
249
  // The spec's alphaCutoff when a MASK material leaves it out.
188
250
  const GLTF_ALPHA_CUTOFF = 0.5
189
251
 
190
- // The "skinned" layout's stride (standard prefix + aJoints + aWeights).
191
- const SKINNED_FLOATS = layoutStride("skinned")
252
+ // The vertex format an accessor's (componentType, normalized, element
253
+ // count) spells when the table has that row, keyed "<type>[n]x<count>".
254
+ // These are exactly the quantized forms KHR_mesh_quantization allows for
255
+ // uvs, colors, joints and weights, so a quantized export keeps its bytes
256
+ // in the vertex buffer; a channel with no row (a byte-pair uv, a short
257
+ // normal, any position) widens to float32xN.
258
+ const ACCESSOR_FORMATS: Record<string, VertexFormat> = {
259
+ "5126x1": "float32",
260
+ "5126x2": "float32x2",
261
+ "5126x3": "float32x3",
262
+ "5126x4": "float32x4",
263
+ "5121nx4": "unorm8x4",
264
+ "5120nx4": "snorm8x4",
265
+ "5123nx2": "unorm16x2",
266
+ "5123nx4": "unorm16x4",
267
+ "5122nx2": "snorm16x2",
268
+ "5122nx4": "snorm16x4",
269
+ "5121x4": "uint8x4",
270
+ "5123x2": "uint16x2",
271
+ "5123x4": "uint16x4",
272
+ }
273
+ const FLOAT_FORMATS: Record<number, VertexFormat> = { 1: "float32", 2: "float32x2", 3: "float32x3", 4: "float32x4" }
274
+
275
+ // The vertex format a channel of `elements` components lands in: the
276
+ // accessor's own when it has a row, float otherwise.
277
+ function channelFormat(format: VertexFormat | null, elements: number): VertexFormat {
278
+ return format ?? FLOAT_FORMATS[elements]!
279
+ }
192
280
 
193
- const IDENTITY = mat4()
281
+ // A part's layout as the preset it equals, else its list: what the
282
+ // container writes and what the presets' docs promise ("base",
283
+ // "colored", "skinned" when the file carried floats).
284
+ function presetOrList(attrs: VertexAttribute[]): VertexLayout | undefined {
285
+ let key = layoutKey(attrs)
286
+ if (key === layoutKey("base")) return undefined
287
+ if (key === layoutKey("colored")) return "colored"
288
+ if (key === layoutKey("skinned")) return "skinned"
289
+ return attrs
290
+ }
194
291
 
195
292
  const COMPONENT_BYTES: Record<number, number> = { 5120: 1, 5121: 1, 5122: 2, 5123: 2, 5125: 4, 5126: 4 }
196
293
  const TYPE_ELEMENTS: Record<string, number> = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4, MAT2: 4, MAT3: 9, MAT4: 16 }
@@ -341,34 +438,58 @@ export function parseGltf(bytes: Uint8Array, resolve?: UriResolver): ModelData {
341
438
  // only when something uses it.
342
439
  let defaultMaterial = -1
343
440
 
344
- let accessorFloats = (index: number, what: string): { data: Float32Array; elements: number; count: number } => {
441
+ let accessorFloats = (index: number, what: string): { data: Float32Array; elements: number; count: number; format: VertexFormat | null } => {
345
442
  let acc = gltf.accessors[index]
346
443
  if (acc === undefined) throw new Error("parseGltf: " + what + " names a missing accessor " + index)
347
- if (acc.sparse !== undefined) throw new Error("parseGltf: " + what + " uses a sparse accessor, which is not supported")
348
444
  let elements = TYPE_ELEMENTS[acc.type]
349
445
  let compBytes = COMPONENT_BYTES[acc.componentType]
350
446
  if (elements === undefined || compBytes === undefined) throw new Error("parseGltf: " + what + " has an unknown accessor type")
351
447
  let out = new Float32Array(acc.count * elements)
352
- if (acc.bufferView === undefined) return { data: out, elements, count: acc.count }
353
- let view = gltf.bufferViews[acc.bufferView]
354
- let bytes = bufferViewBytes(acc.bufferView)
355
- let stride = view.byteStride ?? compBytes * elements
356
- let dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
357
- let base = acc.byteOffset ?? 0
358
448
  let normalized = acc.normalized === true
449
+ let format = ACCESSOR_FORMATS[acc.componentType + (normalized ? "n" : "") + "x" + elements] ?? null
359
450
  let read = readerFor(acc.componentType, normalized)
360
- for (let i = 0; i < acc.count; i++) {
361
- let at = base + i * stride
362
- for (let e = 0; e < elements; e++) out[i * elements + e] = read(dv, at + e * compBytes)
451
+ if (acc.bufferView !== undefined) {
452
+ let view = gltf.bufferViews[acc.bufferView]
453
+ let bytes = bufferViewBytes(acc.bufferView)
454
+ let stride = view.byteStride ?? compBytes * elements
455
+ let dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
456
+ let base = acc.byteOffset ?? 0
457
+ for (let i = 0; i < acc.count; i++) {
458
+ let at = base + i * stride
459
+ for (let e = 0; e < elements; e++) out[i * elements + e] = read(dv, at + e * compBytes)
460
+ }
363
461
  }
364
- return { data: out, elements, count: acc.count }
462
+ // A sparse accessor overrides `sparse.count` elements of the base
463
+ // (the bufferView, or zeros without one): tightly packed indices in
464
+ // their own component type and values in the accessor's - the form
465
+ // exporters write morph targets in.
466
+ let sparse = acc.sparse
467
+ if (sparse !== undefined) {
468
+ let indexBytes = COMPONENT_BYTES[sparse.indices?.componentType]
469
+ if (indexBytes === undefined || sparse.indices.bufferView === undefined || sparse.values?.bufferView === undefined) {
470
+ throw new Error("parseGltf: " + what + " has a malformed sparse accessor")
471
+ }
472
+ let ib = bufferViewBytes(sparse.indices.bufferView)
473
+ let idv = new DataView(ib.buffer, ib.byteOffset, ib.byteLength)
474
+ let ibase = sparse.indices.byteOffset ?? 0
475
+ let readIndex = readerFor(sparse.indices.componentType, false)
476
+ let vb = bufferViewBytes(sparse.values.bufferView)
477
+ let vdv = new DataView(vb.buffer, vb.byteOffset, vb.byteLength)
478
+ let vbase = sparse.values.byteOffset ?? 0
479
+ for (let i = 0; i < sparse.count; i++) {
480
+ let target = readIndex(idv, ibase + i * indexBytes)
481
+ if (target >= acc.count) throw new Error("parseGltf: " + what + " sparse index " + target + " is past the accessor's " + acc.count + " elements")
482
+ for (let e = 0; e < elements; e++) out[target * elements + e] = read(vdv, vbase + (i * elements + e) * compBytes)
483
+ }
484
+ }
485
+ return { data: out, elements, count: acc.count, format }
365
486
  }
366
487
 
367
488
  // The node table is built lazily: a glTF node gets a slot only when a
368
489
  // part somewhere below it needs the chain (materialize walks ancestors
369
490
  // first, so the table stays in pre-order), which is what prunes cameras,
370
491
  // lights and unused empties.
371
- type PendingNode = { name: string; parent: PendingNode | null; position: Vec3; rotation: Quat; scale: Vec3; index: number | null }
492
+ type PendingNode = { name: string; parent: PendingNode | null; position: Vec3; rotation: Quat; scale: Vec3; world: Mat4; index: number | null }
372
493
  let nodes: ModelNode[] = []
373
494
  let materialize = (p: PendingNode): number => {
374
495
  if (p.index !== null) return p.index
@@ -379,24 +500,44 @@ export function parseGltf(bytes: Uint8Array, resolve?: UriResolver): ModelData {
379
500
  }
380
501
 
381
502
  let parts: ModelPart[] = []
382
- let pendingJointBounds: { skin: number; positions: Float32Array; count: number; joints: Float32Array; weights: Float32Array }[] = []
503
+ let pendingJointBounds: { skin: number; positions: Float32Array; count: number; joints: Float32Array; weights: Float32Array; slack: number }[] = []
383
504
  let bounds = new Float32Array([Infinity, Infinity, Infinity, -Infinity, -Infinity, -Infinity])
505
+ // Grow a bounds box by a local box through a transform: its 8 corners,
506
+ // conservative under rotation, exact under translation and axis-aligned
507
+ // scale. An empty box (no vertex grew it) contributes nothing.
508
+ let growBounds = (into: Float32Array, box: ArrayLike<number>, m: Mat4): void => {
509
+ if (!(box[0]! <= box[3]!)) return
510
+ for (let corner = 0; corner < 8; corner++) {
511
+ let x = corner & 1 ? box[3]! : box[0]!, y = corner & 2 ? box[4]! : box[1]!, z = corner & 4 ? box[5]! : box[2]!
512
+ let wx = m[0] * x + m[4] * y + m[8] * z + m[12]
513
+ let wy = m[1] * x + m[5] * y + m[9] * z + m[13]
514
+ let wz = m[2] * x + m[6] * y + m[10] * z + m[14]
515
+ if (wx < into[0]!) into[0] = wx
516
+ if (wy < into[1]!) into[1] = wy
517
+ if (wz < into[2]!) into[2] = wz
518
+ if (wx > into[3]!) into[3] = wx
519
+ if (wy > into[4]!) into[4] = wy
520
+ if (wz > into[5]!) into[5] = wz
521
+ }
522
+ }
384
523
 
385
- let emit = (prim: any, name: string, node: number, world: Mat4, skin: number | null): void => {
386
- if ((prim.mode ?? MODE_TRIANGLES) !== MODE_TRIANGLES) return
524
+ let emit = (prim: any, name: string, node: number, world: Mat4, skin: number | null, targetNames: string[] | null): void => {
387
525
  if (prim.attributes?.POSITION === undefined) return
526
+ let mode: number = prim.mode ?? MODE_TRIANGLES
388
527
  let pos = accessorFloats(prim.attributes.POSITION, name + " POSITION")
389
528
  if (pos.elements !== 3) throw new Error("parseGltf: " + name + " POSITION is not VEC3")
390
529
  let count = pos.count
391
530
  let nrm = prim.attributes.NORMAL !== undefined ? accessorFloats(prim.attributes.NORMAL, name + " NORMAL").data : null
392
- let uv = prim.attributes.TEXCOORD_0 !== undefined ? accessorFloats(prim.attributes.TEXCOORD_0, name + " TEXCOORD_0").data : null
531
+ let uv = prim.attributes.TEXCOORD_0 !== undefined ? accessorFloats(prim.attributes.TEXCOORD_0, name + " TEXCOORD_0") : null
532
+ let color = prim.attributes.COLOR_0 !== undefined ? accessorFloats(prim.attributes.COLOR_0, name + " COLOR_0") : null
533
+ if (color !== null && color.elements !== 3 && color.elements !== 4) throw new Error("parseGltf: " + name + " COLOR_0 is not VEC3 or VEC4")
393
534
  // A skinned primitive: joints and weights become the "skinned"
394
535
  // layout's extra channels; its vertices are ALREADY model-space bind
395
536
  // pose (the node transform is ignored, per spec), so no flip and
396
537
  // identity bounds.
397
538
  let skinned = skin !== null
398
- let joints: Float32Array | null = null
399
- let weights: Float32Array | null = null
539
+ let joints: { data: Float32Array; format: VertexFormat | null } | null = null
540
+ let weights: { data: Float32Array; format: VertexFormat | null } | null = null
400
541
  if (skinned) {
401
542
  if (prim.attributes.JOINTS_0 === undefined || prim.attributes.WEIGHTS_0 === undefined) {
402
543
  throw new Error("parseGltf: " + name + " is skinned but lacks JOINTS_0/WEIGHTS_0")
@@ -404,36 +545,100 @@ export function parseGltf(bytes: Uint8Array, resolve?: UriResolver): ModelData {
404
545
  let j = accessorFloats(prim.attributes.JOINTS_0, name + " JOINTS_0")
405
546
  let w = accessorFloats(prim.attributes.WEIGHTS_0, name + " WEIGHTS_0")
406
547
  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
548
+ joints = j
549
+ weights = w
550
+ }
551
+ // The layout the vertices are written in: the base prefix (a
552
+ // position is always float; a uv keeps a quantized form), the skin
553
+ // channels for a skinned primitive in the file's own integer forms
554
+ // (joints feed an integer `in`, so a float JOINTS_0 narrows to u16),
555
+ // aColor after them for one with COLOR_0 (a VEC3 color widens to four
556
+ // floats: it gains an alpha).
557
+ let attrs: VertexAttribute[] = [
558
+ { name: "aPos", format: "float32x3" },
559
+ { name: "aNormal", format: "float32x3" },
560
+ { name: "aUV", format: channelFormat(uv?.format ?? null, 2) },
561
+ ]
562
+ if (joints !== null && weights !== null) {
563
+ attrs.push({ name: "aJoints", format: joints.format ?? "uint16x4" }, { name: "aWeights", format: channelFormat(weights.format, 4) })
409
564
  }
410
- let stride = skinned ? SKINNED_FLOATS : STANDARD_FLOATS
411
- let indices: ArrayLike<number> =
565
+ if (color !== null) attrs.push({ name: "aColor", format: channelFormat(color.elements === 4 ? color.format : null, 4) })
566
+ let layout = presetOrList(attrs)
567
+ let raw: ArrayLike<number> =
412
568
  prim.indices !== undefined ? accessorFloats(prim.indices, name + " indices").data : Array.from({ length: count }, (_, i) => i)
413
- if (indices.length % 3 !== 0) throw new Error("parseGltf: " + name + " index count is not a multiple of 3")
569
+ // The primitive's topology, carried on the geometry. Triangle strips
570
+ // and fans are unrolled to lists (what the winding flip, the
571
+ // flat-shading un-index and the picking shape expect) and a line
572
+ // loop, which has no GPU topology, is closed into a strip.
573
+ let topology: Topology
574
+ let indices: ArrayLike<number>
575
+ switch (mode) {
576
+ case MODE_TRIANGLES:
577
+ topology = "triangles"
578
+ indices = raw
579
+ break
580
+ case MODE_TRIANGLE_STRIP:
581
+ topology = "triangles"
582
+ indices = stripTriangles(raw)
583
+ break
584
+ case MODE_TRIANGLE_FAN:
585
+ topology = "triangles"
586
+ indices = fanTriangles(raw)
587
+ break
588
+ case MODE_LINES:
589
+ topology = "lines"
590
+ indices = raw
591
+ break
592
+ case MODE_LINE_STRIP:
593
+ topology = "line-strip"
594
+ indices = raw
595
+ break
596
+ case MODE_LINE_LOOP:
597
+ topology = "line-strip"
598
+ indices = closeLoop(raw)
599
+ break
600
+ case MODE_POINTS:
601
+ topology = "points"
602
+ indices = raw
603
+ break
604
+ default:
605
+ throw new Error("parseGltf: " + name + " has an unknown primitive mode " + mode)
606
+ }
607
+ let triangles = topology === "triangles"
608
+ if (triangles && indices.length % 3 !== 0) throw new Error("parseGltf: " + name + " index count is not a multiple of 3")
414
609
 
415
610
  // A mirroring chain (negative world determinant at the rest pose)
416
611
  // flips the displayed winding, so the index order is flipped here to
417
612
  // compensate - cull: "back" keeps the outside. Stored normals stay the
418
613
  // authored ones: the runtime's inverse-transpose maps them outward for
419
614
  // 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! })
426
-
427
- let vertices: Float32Array
615
+ // across zero would unbake it, which is pathological. Lines and
616
+ // points have no winding.
617
+ let flip = triangles && !skinned && det3(world) < 0
618
+
619
+ // The channels of the part's buffer, written through the accessors:
620
+ // whatever a channel's format, the writer takes floats.
621
+ let source: VertexSource = { pos: pos.data, nrm, uv: uv?.data ?? null, color: color === null ? null : { data: color.data, elements: color.elements }, joints: joints?.data ?? null, weights: weights?.data ?? null }
622
+ let vertices: ArrayBufferView
623
+ let writer: VertexWriter
428
624
  let packedIndices: number[]
429
- if (nrm !== null) {
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)
432
- packedIndices = new Array(indices.length)
433
- for (let i = 0; i < indices.length; i += 3) {
434
- packedIndices[i] = indices[i]!
435
- packedIndices[i + 1] = flip ? indices[i + 2]! : indices[i + 1]!
436
- packedIndices[i + 2] = flip ? indices[i + 1]! : indices[i + 2]!
625
+ // Output slot -> source vertex, for the channels written after the
626
+ // interleave (the morph targets): null while the slots are the
627
+ // source order, the un-index's corner table otherwise.
628
+ let remap: number[] | null = null
629
+ if (nrm !== null || !triangles) {
630
+ // Indexed as authored. Lines and points without normals get zero
631
+ // ones (there is no face to take one from; nothing lights them).
632
+ vertices = vertexView(layout, new ArrayBuffer(count * layoutStride(layout)))
633
+ writer = vertexWriter(vertices, layout)
634
+ for (let i = 0; i < count; i++) writeVertex(writer, i, source, i)
635
+ packedIndices = Array.from(indices)
636
+ if (flip) {
637
+ for (let i = 0; i < packedIndices.length; i += 3) {
638
+ let b = packedIndices[i + 1]!
639
+ packedIndices[i + 1] = packedIndices[i + 2]!
640
+ packedIndices[i + 2] = b
641
+ }
437
642
  }
438
643
  } else {
439
644
  // No normals: the spec asks for flat shading, which needs one vertex
@@ -442,59 +647,93 @@ export function parseGltf(bytes: Uint8Array, resolve?: UriResolver): ModelData {
442
647
  // after a flip), the direction authored normals would have, so the
443
648
  // runtime maps both the same way.
444
649
  let triangles = indices.length / 3
445
- vertices = new Float32Array(triangles * 3 * stride)
650
+ vertices = vertexView(layout, new ArrayBuffer(triangles * 3 * layoutStride(layout)))
651
+ writer = vertexWriter(vertices, layout)
446
652
  packedIndices = new Array(triangles * 3)
653
+ remap = new Array(triangles * 3)
447
654
  let face: Vec3 = [0, 0, 0]
448
655
  for (let t = 0; t < triangles; t++) {
449
656
  let a = indices[t * 3]!, b = indices[t * 3 + 1]!, c = indices[t * 3 + 2]!
450
657
  if (flip) [b, c] = [c, b]
451
658
  let out = t * 3
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)
659
+ writeVertex(writer, out, source, a)
660
+ writeVertex(writer, out + 1, source, b)
661
+ writeVertex(writer, out + 2, source, c)
662
+ remap[out] = a
663
+ remap[out + 1] = b
664
+ remap[out + 2] = c
665
+ faceNormal(face, writer.pos, out)
456
666
  if (flip) {
457
667
  face[0] = -face[0]
458
668
  face[1] = -face[1]
459
669
  face[2] = -face[2]
460
670
  }
461
671
  for (let k = 0; k < 3; k++) {
462
- let at = (out + k) * stride + 3
463
- vertices[at] = face[0]
464
- vertices[at + 1] = face[1]
465
- vertices[at + 2] = face[2]
672
+ for (let c = 0; c < 3; c++) writer.nrm.set(out + k, c, face[c]!)
466
673
  packedIndices[out + k] = out + k
467
674
  }
468
675
  }
469
676
  count = triangles * 3
470
677
  }
471
678
 
679
+ // Morph targets: each target's POSITION and NORMAL deltas (a target
680
+ // may carry either; TANGENT is dropped with the base tangents), read
681
+ // dense through the accessors (sparse ones expand there), remapped
682
+ // through the un-index when the primitive was flattened - normal
683
+ // deltas are dropped then, as flat normals are generated - and packed
684
+ // sparse by vertex onto the geometry. Names come from the mesh's
685
+ // extras.targetNames (what Blender writes), else "target<t>".
686
+ let targets: any[] = prim.targets ?? []
687
+ let morphs = targets.length === 0 ? undefined : packMorphTargets(count, targets.map((target: any, t: number): MorphTarget => {
688
+ let what = name + " target " + t
689
+ let deltas = (attribute: string): Float32Array | null => {
690
+ if (target?.[attribute] === undefined) return null
691
+ let acc = accessorFloats(target[attribute], what + " " + attribute)
692
+ if (acc.elements !== 3 || acc.count !== pos.count) throw new Error("parseGltf: " + what + " " + attribute + " is not VEC3 over the primitive's vertices")
693
+ if (remap === null) return acc.data
694
+ let out = new Float32Array(count * 3)
695
+ for (let slot = 0; slot < count; slot++) {
696
+ let from = remap[slot]! * 3
697
+ out[slot * 3] = acc.data[from]!
698
+ out[slot * 3 + 1] = acc.data[from + 1]!
699
+ out[slot * 3 + 2] = acc.data[from + 2]!
700
+ }
701
+ return out
702
+ }
703
+ return { name: targetNames?.[t] ?? "target" + t, position: deltas("POSITION") ?? new Float32Array(count * 3), normal: remap === null ? deltas("NORMAL") : null }
704
+ }))
705
+
472
706
  // Model bounds: the part's local box through the node's rest-pose
473
707
  // 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]
479
- for (let i = 0; i < count; i++) {
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
708
+ // under translation and axis-aligned scale), grown by the morph
709
+ // extent first so a morphed shape stays inside. A skinned part's
710
+ // vertices are placed by its joints, not its node, so its box is
711
+ // folded in after the skins exist (growBounds over the joint boxes).
712
+ if (!skinned) {
713
+ let lo: Vec3 = [Infinity, Infinity, Infinity]
714
+ let hi: Vec3 = [-Infinity, -Infinity, -Infinity]
715
+ for (let i = 0; i < count; i++) {
716
+ for (let k = 0; k < 3; k++) {
717
+ let v = writer.pos.get(i, k)
718
+ if (v < lo[k]!) lo[k] = v
719
+ if (v > hi[k]!) hi[k] = v
720
+ }
485
721
  }
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
722
+ if (morphs !== undefined) {
723
+ for (let k = 0; k < 3; k++) {
724
+ lo[k] = lo[k]! + morphs.extent[k]!
725
+ hi[k] = hi[k]! + morphs.extent[3 + k]!
726
+ }
727
+ }
728
+ growBounds(bounds, [lo[0], lo[1], lo[2], hi[0], hi[1], hi[2]], world)
729
+ } else {
730
+ // Joint boxes need the skin's inverse binds, and skins are built
731
+ // after the walk (their joints are ordinary nodes the walk
732
+ // registers), so the arrays are parked under the FILE's skin index
733
+ // and grown then - each box padded by the largest morph delta, as
734
+ // a morph moves a vertex in whichever joint's space.
735
+ let slack = morphs === undefined ? 0 : Math.max(...Array.from(morphs.extent, Math.abs))
736
+ pendingJointBounds.push({ skin: skin!, positions: pos.data, count: pos.count, joints: joints!.data, weights: weights!.data, slack })
498
737
  }
499
738
 
500
739
  let material = prim.material
@@ -505,9 +744,10 @@ export function parseGltf(bytes: Uint8Array, resolve?: UriResolver): ModelData {
505
744
  }
506
745
  material = defaultMaterial
507
746
  }
508
- let geometry: Geometry = skinned
509
- ? { vertices, indices: packIndices(packedIndices, count), layout: "skinned", label: name }
510
- : packGeometry(vertices, packedIndices, { label: name })
747
+ let geometry: Geometry = { vertices, indices: packIndices(packedIndices, count), label: name }
748
+ if (layout !== undefined) geometry.layout = layout
749
+ if (morphs !== undefined) geometry.morphs = morphs
750
+ if (!triangles) geometry.topology = topology
511
751
  parts.push({ name, node, skin, geometry, material })
512
752
  }
513
753
 
@@ -531,7 +771,7 @@ export function parseGltf(bytes: Uint8Array, resolve?: UriResolver): ModelData {
531
771
  // winding and bounds match what the runtime will render.
532
772
  let world = mat4()
533
773
  multiply(world, parentWorld, compose(local, position, rotation, scale))
534
- let pending: PendingNode = { name: node.name ?? "node" + index, parent, position, rotation, scale, index: null }
774
+ let pending: PendingNode = { name: node.name ?? "node" + index, parent, position, rotation, scale, world, index: null }
535
775
  pendingByIndex.set(index, pending)
536
776
  if (node.mesh !== undefined) {
537
777
  let mesh = gltf.meshes[node.mesh]
@@ -540,7 +780,20 @@ export function parseGltf(bytes: Uint8Array, resolve?: UriResolver): ModelData {
540
780
  let slot = materialize(pending)
541
781
  let skin = typeof node.skin === "number" ? node.skin : null
542
782
  let prims: any[] = mesh.primitives ?? []
543
- for (let k = 0; k < prims.length; k++) emit(prims[k], prims.length > 1 ? pending.name + "#" + k : pending.name, slot, world, skin)
783
+ // Morph target names and the initial weights are the MESH's (a
784
+ // node may override the weights); the weights land on the node,
785
+ // which is what a weights channel and setMorphWeights address.
786
+ let targetCount = Math.max(0, ...prims.map((p: any): number => p?.targets?.length ?? 0))
787
+ let targetNames: string[] | null = Array.isArray(mesh.extras?.targetNames) ? mesh.extras.targetNames.map(String) : null
788
+ if (targetCount > 0) {
789
+ let weights: number[] = Array.isArray(node.weights) ? node.weights : Array.isArray(mesh.weights) ? mesh.weights : []
790
+ if (weights.length !== targetCount) {
791
+ if (weights.length !== 0) throw new Error("parseGltf: node " + pending.name + " has " + weights.length + " weights for " + targetCount + " morph targets")
792
+ weights = new Array(targetCount).fill(0)
793
+ }
794
+ nodes[slot]!.weights = weights.map(Number)
795
+ }
796
+ for (let k = 0; k < prims.length; k++) emit(prims[k], prims.length > 1 ? pending.name + "#" + k : pending.name, slot, world, skin, targetNames)
544
797
  }
545
798
  for (let child of node.children ?? []) walk(child, pending, world)
546
799
  }
@@ -558,7 +811,7 @@ export function parseGltf(bytes: Uint8Array, resolve?: UriResolver): ModelData {
558
811
  // compact skins exist: each influenced vertex (weight above zero) goes
559
812
  // through the joint's inverse bind into joint space and grows that
560
813
  // joint's box.
561
- let growJointBounds = (skin: ModelSkin, positions: Float32Array, count: number, joints: Float32Array, weights: Float32Array): void => {
814
+ let growJointBounds = (skin: ModelSkin, positions: Float32Array, count: number, joints: Float32Array, weights: Float32Array, slack: number): void => {
562
815
  let jb = skin.jointBounds
563
816
  let ib = skin.inverseBind
564
817
  for (let i = 0; i < count; i++) {
@@ -572,25 +825,29 @@ export function parseGltf(bytes: Uint8Array, resolve?: UriResolver): ModelData {
572
825
  let py = ib[m + 1]! * x + ib[m + 5]! * y + ib[m + 9]! * z + ib[m + 13]!
573
826
  let pz = ib[m + 2]! * x + ib[m + 6]! * y + ib[m + 10]! * z + ib[m + 14]!
574
827
  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
828
+ if (px - slack < jb[b]!) jb[b] = px - slack
829
+ if (py - slack < jb[b + 1]!) jb[b + 1] = py - slack
830
+ if (pz - slack < jb[b + 2]!) jb[b + 2] = pz - slack
831
+ if (px + slack > jb[b + 3]!) jb[b + 3] = px + slack
832
+ if (py + slack > jb[b + 4]!) jb[b + 4] = py + slack
833
+ if (pz + slack > jb[b + 5]!) jb[b + 5] = pz + slack
581
834
  }
582
835
  }
583
836
  }
584
837
  let skinSlots = new Map<number, number>()
838
+ // Each compact skin's joints' rest-pose world transforms, for the bounds.
839
+ let skinJointWorlds: Mat4[][] = []
585
840
  for (let part of parts) {
586
841
  if (part.skin === null) continue
587
842
  let slot = skinSlots.get(part.skin)
588
843
  if (slot === undefined) {
589
844
  let sk = gltf.skins?.[part.skin]
590
845
  if (sk === undefined) throw new Error("parseGltf: part '" + part.name + "' names a missing skin " + part.skin)
846
+ let jointWorlds: Mat4[] = []
591
847
  let jointIndices: number[] = (sk.joints ?? []).map((j: number): number => {
592
848
  let pending = pendingByIndex.get(j)
593
849
  if (pending === undefined) throw new Error("parseGltf: skin joint node " + j + " is not in the scene")
850
+ jointWorlds.push(pending.world)
594
851
  return materialize(pending)
595
852
  })
596
853
  if (jointIndices.length === 0) throw new Error("parseGltf: skin " + part.skin + " has no joints")
@@ -615,29 +872,43 @@ export function parseGltf(bytes: Uint8Array, resolve?: UriResolver): ModelData {
615
872
  let jointBounds = new Float32Array(jointIndices.length * 6)
616
873
  for (let j = 0; j < jointIndices.length; j++) jointBounds.set([Infinity, Infinity, Infinity, -Infinity, -Infinity, -Infinity], j * 6)
617
874
  skins.push({ joints: jointIndices, inverseBind, jointBounds })
875
+ skinJointWorlds.push(jointWorlds)
618
876
  skinSlots.set(part.skin, slot)
619
877
  }
620
878
  part.skin = slot
621
879
  }
622
880
  for (let parked of pendingJointBounds) {
623
881
  let slot = skinSlots.get(parked.skin)
624
- if (slot !== undefined) growJointBounds(skins[slot]!, parked.positions, parked.count, parked.joints, parked.weights)
882
+ if (slot !== undefined) growJointBounds(skins[slot]!, parked.positions, parked.count, parked.joints, parked.weights, parked.slack)
625
883
  }
884
+ // Skinned parts' share of the model bounds: each joint's box (joint
885
+ // space) through that joint's rest-pose world transform, which is where
886
+ // the skin places the vertices it influences at rest (a vertex blended
887
+ // between joints lands inside the union's box). The bind-pose vertex
888
+ // box itself is not where the part renders once an armature carries a
889
+ // rotation or a scale.
890
+ skins.forEach((skin, s) => {
891
+ let worlds = skinJointWorlds[s]!
892
+ for (let j = 0; j < skin.joints.length; j++) growBounds(bounds, skin.jointBounds.subarray(j * 6, j * 6 + 6), worlds[j]!)
893
+ })
626
894
 
627
895
  // Animations, after the walk so channels can materialize their target
628
896
  // 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" }
897
+ const CHANNEL_PATHS: Record<string, ModelChannel["path"]> = { translation: "position", rotation: "rotation", scale: "scale", weights: "weights" }
630
898
  let clips: ModelClip[] = (gltf.animations ?? []).map((anim: any, ai: number): ModelClip => {
631
899
  let clipName = anim.name ?? "clip" + ai
632
900
  let channels: ModelChannel[] = []
633
901
  let duration = 0
634
902
  for (let ch of anim.channels ?? []) {
635
- // Outside the subset: "weights" (morph targets) and extension paths.
903
+ // Extension paths (KHR_animation_pointer) are outside the subset.
636
904
  let path = CHANNEL_PATHS[ch.target?.path]
637
905
  if (path === undefined) continue
638
- // A target outside the walked scene has nothing to move.
906
+ // A target outside the walked scene has nothing to move, and a
907
+ // weights channel on a node without morph targets nothing to weigh.
639
908
  let pending = pendingByIndex.get(ch.target.node)
640
909
  if (pending === undefined) continue
910
+ let targetCount = pending.index === null ? undefined : nodes[pending.index]!.weights?.length
911
+ if (path === "weights" && targetCount === undefined) continue
641
912
  let sampler = anim.samplers?.[ch.sampler]
642
913
  if (sampler === undefined) throw new Error("parseGltf: animation " + clipName + " channel names a missing sampler " + ch.sampler)
643
914
  let what = "animation " + clipName
@@ -645,11 +916,20 @@ export function parseGltf(bytes: Uint8Array, resolve?: UriResolver): ModelData {
645
916
  let output = accessorFloats(sampler.output, what + " output")
646
917
  let interpolation: ModelChannel["interpolation"] =
647
918
  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
919
  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")
920
+ if (path === "weights") {
921
+ // Scalars, one per target per key: the output is as long as the
922
+ // key count times the node's target count.
923
+ if (output.elements !== 1) throw new Error("parseGltf: " + what + " weights output is not SCALAR")
924
+ if (output.count !== input.count * perKey * targetCount!) {
925
+ throw new Error("parseGltf: " + what + " weights has " + output.count + " values for " + input.count + " keys of " + targetCount + " targets")
926
+ }
927
+ } else {
928
+ let elements = path === "rotation" ? 4 : 3
929
+ if (output.elements !== elements) throw new Error("parseGltf: " + what + " " + path + " output is not " + (elements === 4 ? "VEC4" : "VEC3"))
930
+ if (output.count !== input.count * perKey) {
931
+ throw new Error("parseGltf: " + what + " " + path + " has " + output.count + " values for " + input.count + " keys")
932
+ }
653
933
  }
654
934
  if (input.count > 0) duration = Math.max(duration, input.data[input.count - 1]!)
655
935
  channels.push({ node: materialize(pending), path, interpolation, times: input.data, values: output.data })
@@ -657,7 +937,9 @@ export function parseGltf(bytes: Uint8Array, resolve?: UriResolver): ModelData {
657
937
  return { name: clipName, duration, channels }
658
938
  })
659
939
 
660
- if (parts.length === 0) bounds.fill(0)
940
+ // Nothing grew the box (no parts, or skinned parts whose weights reach
941
+ // no joint): an empty model sits at the origin.
942
+ if (!(bounds[0]! <= bounds[3]!)) bounds.fill(0)
661
943
  return { nodes, parts, skins, clips, materials, images, bounds }
662
944
  }
663
945
 
@@ -710,55 +992,76 @@ function readerFor(componentType: number, normalized: boolean): (dv: DataView, a
710
992
  }
711
993
  }
712
994
 
995
+ // The source channels of a primitive as the accessors decoded them
996
+ // (floats, whatever the file's component type) and the writers over the
997
+ // part's buffer, one per channel the layout carries.
998
+ type VertexSource = {
999
+ pos: Float32Array
1000
+ nrm: Float32Array | null
1001
+ uv: Float32Array | null
1002
+ color: { data: Float32Array; elements: number } | null
1003
+ joints: Float32Array | null
1004
+ weights: Float32Array | null
1005
+ }
1006
+ type VertexWriter = {
1007
+ pos: AttributeAccess
1008
+ nrm: AttributeAccess
1009
+ uv: AttributeAccess
1010
+ color: AttributeAccess | null
1011
+ joints: AttributeAccess | null
1012
+ weights: AttributeAccess | null
1013
+ }
1014
+
1015
+ function vertexWriter(vertices: ArrayBufferView, layout: VertexLayout | undefined): VertexWriter {
1016
+ return {
1017
+ pos: attributeAccess(vertices, layout, "aPos")!,
1018
+ nrm: attributeAccess(vertices, layout, "aNormal")!,
1019
+ uv: attributeAccess(vertices, layout, "aUV")!,
1020
+ color: attributeAccess(vertices, layout, "aColor"),
1021
+ joints: attributeAccess(vertices, layout, "aJoints"),
1022
+ weights: attributeAccess(vertices, layout, "aWeights"),
1023
+ }
1024
+ }
1025
+
713
1026
  // One interleaved vertex, node-local: position and normal copied as
714
1027
  // 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
1028
+ // zero, and for a skinned layout the joint indices plus their weights
716
1029
  // RENORMALIZED to sum 1 (quantized exports drift a little; the spec asks
717
- // for normalized weights).
718
- function writeVertex(
719
- out: Float32Array,
720
- slot: number,
721
- pos: Float32Array,
722
- nrm: Float32Array | null,
723
- uv: Float32Array | null,
724
- src: number,
725
- stride: number,
726
- joints: Float32Array | null = null,
727
- weights: Float32Array | null = null,
728
- ): void {
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]!
733
- if (nrm !== null) {
734
- out[at + 3] = nrm[src * 3]!
735
- out[at + 4] = nrm[src * 3 + 1]!
736
- out[at + 5] = nrm[src * 3 + 2]!
1030
+ // for normalized weights). A COLOR_0 (VEC3 or VEC4, already 0..1 - the
1031
+ // normalized-integer path unpacks quantized exports) lands at the
1032
+ // layout's aColor slot premultiplied, alpha 1 for VEC3. Every write goes
1033
+ // through the channel's accessor, which re-encodes a packed format.
1034
+ function writeVertex(w: VertexWriter, slot: number, src: VertexSource, i: number): void {
1035
+ for (let k = 0; k < 3; k++) w.pos.set(slot, k, src.pos[i * 3 + k]!)
1036
+ if (src.nrm !== null) {
1037
+ for (let k = 0; k < 3; k++) w.nrm.set(slot, k, src.nrm[i * 3 + k]!)
1038
+ }
1039
+ if (src.uv !== null) {
1040
+ w.uv.set(slot, 0, src.uv[i * 2]!)
1041
+ w.uv.set(slot, 1, src.uv[i * 2 + 1]!)
737
1042
  }
738
- if (uv !== null) {
739
- out[at + 6] = uv[src * 2]!
740
- out[at + 7] = uv[src * 2 + 1]!
1043
+ if (src.color !== null && w.color !== null) {
1044
+ let c = i * src.color.elements
1045
+ let a = src.color.elements === 4 ? src.color.data[c + 3]! : 1
1046
+ for (let k = 0; k < 3; k++) w.color.set(slot, k, src.color.data[c + k]! * a)
1047
+ w.color.set(slot, 3, a)
741
1048
  }
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]!
1049
+ if (src.joints !== null && src.weights !== null && w.joints !== null && w.weights !== null) {
1050
+ let j = i * 4
1051
+ let sum = src.weights[j]! + src.weights[j + 1]! + src.weights[j + 2]! + src.weights[j + 3]!
749
1052
  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
1053
+ for (let k = 0; k < 4; k++) {
1054
+ w.joints.set(slot, k, src.joints[j + k]!)
1055
+ w.weights.set(slot, k, src.weights[j + k]! * inv)
1056
+ }
754
1057
  }
755
1058
  }
756
1059
 
757
1060
  // The unit normal of the triangle at three consecutive vertex slots.
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
760
- let abx = v[b]! - v[a]!, aby = v[b + 1]! - v[a + 1]!, abz = v[b + 2]! - v[a + 2]!
761
- let acx = v[c]! - v[a]!, acy = v[c + 1]! - v[a + 1]!, acz = v[c + 2]! - v[a + 2]!
1061
+ function faceNormal(out: Vec3, pos: AttributeAccess, first: number): void {
1062
+ let a = first, b = first + 1, c = first + 2
1063
+ let abx = pos.get(b, 0) - pos.get(a, 0), aby = pos.get(b, 1) - pos.get(a, 1), abz = pos.get(b, 2) - pos.get(a, 2)
1064
+ let acx = pos.get(c, 0) - pos.get(a, 0), acy = pos.get(c, 1) - pos.get(a, 1), acz = pos.get(c, 2) - pos.get(a, 2)
762
1065
  let nx = aby * acz - abz * acy
763
1066
  let ny = abz * acx - abx * acz
764
1067
  let nz = abx * acy - aby * acx