@solidrt/3d 0.0.57 → 0.0.58

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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 lit()/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,8 +22,8 @@
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
25
+ // required extension throw naming it; sparse accessors and morph
26
+ // targets (the "weights" channel path) are skipped
24
27
  // or ignored; vertex colors, tangents and further UV sets are dropped
25
28
  // (the standard layout has no slot for them yet). Skins ARE parsed:
26
29
  // joints, inverse binds, and the "skinned" vertex layout.
@@ -30,6 +33,7 @@ import { linearToSrgb } from "./color.ts"
30
33
  import type { Mat4, Quat, Vec3 } from "./math.ts"
31
34
  import { layoutStride, packGeometry, packIndices, STANDARD_FLOATS } from "./geometry.ts"
32
35
  import type { Geometry } from "./geometry.ts"
36
+ import type { Topology } from "@solidrt/core/gpu"
33
37
 
34
38
  /** What lit()/unlit() take from a glTF material. */
35
39
  export type ModelMaterial = {
@@ -182,7 +186,39 @@ export type UriResolver = (uri: string) => Uint8Array
182
186
  const GLB_MAGIC = 0x46546c67
183
187
  const CHUNK_JSON = 0x4e4f534a
184
188
  const CHUNK_BIN = 0x004e4942
189
+ // glTF primitive modes (the GL enum values).
190
+ const MODE_POINTS = 0
191
+ const MODE_LINES = 1
192
+ const MODE_LINE_LOOP = 2
193
+ const MODE_LINE_STRIP = 3
185
194
  const MODE_TRIANGLES = 4
195
+ const MODE_TRIANGLE_STRIP = 5
196
+ const MODE_TRIANGLE_FAN = 6
197
+
198
+ /** A triangle strip unrolled to a list: triangle t is (t, t+1, t+2),
199
+ * every odd one with its last two swapped so the winding stays put. */
200
+ function stripTriangles(strip: ArrayLike<number>): number[] {
201
+ let out: number[] = []
202
+ for (let t = 0; t + 2 < strip.length; t++) {
203
+ if (t % 2 === 0) out.push(strip[t]!, strip[t + 1]!, strip[t + 2]!)
204
+ else out.push(strip[t]!, strip[t + 2]!, strip[t + 1]!)
205
+ }
206
+ return out
207
+ }
208
+
209
+ /** A triangle fan unrolled to a list: every triangle shares the hub. */
210
+ function fanTriangles(fan: ArrayLike<number>): number[] {
211
+ let out: number[] = []
212
+ for (let t = 1; t + 1 < fan.length; t++) out.push(fan[0]!, fan[t]!, fan[t + 1]!)
213
+ return out
214
+ }
215
+
216
+ /** A line loop as a strip that returns to its first vertex. */
217
+ function closeLoop(loop: ArrayLike<number>): number[] {
218
+ let out = Array.from(loop)
219
+ if (loop.length > 0) out.push(loop[0]!)
220
+ return out
221
+ }
186
222
 
187
223
  // The spec's alphaCutoff when a MASK material leaves it out.
188
224
  const GLTF_ALPHA_CUTOFF = 0.5
@@ -383,8 +419,8 @@ export function parseGltf(bytes: Uint8Array, resolve?: UriResolver): ModelData {
383
419
  let bounds = new Float32Array([Infinity, Infinity, Infinity, -Infinity, -Infinity, -Infinity])
384
420
 
385
421
  let emit = (prim: any, name: string, node: number, world: Mat4, skin: number | null): void => {
386
- if ((prim.mode ?? MODE_TRIANGLES) !== MODE_TRIANGLES) return
387
422
  if (prim.attributes?.POSITION === undefined) return
423
+ let mode: number = prim.mode ?? MODE_TRIANGLES
388
424
  let pos = accessorFloats(prim.attributes.POSITION, name + " POSITION")
389
425
  if (pos.elements !== 3) throw new Error("parseGltf: " + name + " POSITION is not VEC3")
390
426
  let count = pos.count
@@ -408,17 +444,57 @@ export function parseGltf(bytes: Uint8Array, resolve?: UriResolver): ModelData {
408
444
  weights = w.data
409
445
  }
410
446
  let stride = skinned ? SKINNED_FLOATS : STANDARD_FLOATS
411
- let indices: ArrayLike<number> =
447
+ let raw: ArrayLike<number> =
412
448
  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")
449
+ // The primitive's topology, carried on the geometry. Triangle strips
450
+ // and fans are unrolled to lists (what the winding flip, the
451
+ // flat-shading un-index and the picking shape expect) and a line
452
+ // loop, which has no GPU topology, is closed into a strip.
453
+ let topology: Topology
454
+ let indices: ArrayLike<number>
455
+ switch (mode) {
456
+ case MODE_TRIANGLES:
457
+ topology = "triangles"
458
+ indices = raw
459
+ break
460
+ case MODE_TRIANGLE_STRIP:
461
+ topology = "triangles"
462
+ indices = stripTriangles(raw)
463
+ break
464
+ case MODE_TRIANGLE_FAN:
465
+ topology = "triangles"
466
+ indices = fanTriangles(raw)
467
+ break
468
+ case MODE_LINES:
469
+ topology = "lines"
470
+ indices = raw
471
+ break
472
+ case MODE_LINE_STRIP:
473
+ topology = "line-strip"
474
+ indices = raw
475
+ break
476
+ case MODE_LINE_LOOP:
477
+ topology = "line-strip"
478
+ indices = closeLoop(raw)
479
+ break
480
+ case MODE_POINTS:
481
+ topology = "points"
482
+ indices = raw
483
+ break
484
+ default:
485
+ throw new Error("parseGltf: " + name + " has an unknown primitive mode " + mode)
486
+ }
487
+ let triangles = topology === "triangles"
488
+ if (triangles && indices.length % 3 !== 0) throw new Error("parseGltf: " + name + " index count is not a multiple of 3")
414
489
 
415
490
  // A mirroring chain (negative world determinant at the rest pose)
416
491
  // flips the displayed winding, so the index order is flipped here to
417
492
  // compensate - cull: "back" keeps the outside. Stored normals stay the
418
493
  // authored ones: the runtime's inverse-transpose maps them outward for
419
494
  // 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
495
+ // across zero would unbake it, which is pathological. Lines and
496
+ // points have no winding.
497
+ let flip = triangles && !skinned && det3(world) < 0
422
498
  // Joint boxes need the skin's inverse binds, and skins are built after
423
499
  // the walk (their joints are ordinary nodes the walk registers), so
424
500
  // the arrays are parked under the FILE's skin index and grown then.
@@ -426,14 +502,18 @@ export function parseGltf(bytes: Uint8Array, resolve?: UriResolver): ModelData {
426
502
 
427
503
  let vertices: Float32Array
428
504
  let packedIndices: number[]
429
- if (nrm !== null) {
505
+ if (nrm !== null || !triangles) {
506
+ // Indexed as authored. Lines and points without normals get zero
507
+ // ones (there is no face to take one from; nothing lights them).
430
508
  vertices = new Float32Array(count * stride)
431
509
  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]!
510
+ packedIndices = Array.from(indices)
511
+ if (flip) {
512
+ for (let i = 0; i < packedIndices.length; i += 3) {
513
+ let b = packedIndices[i + 1]!
514
+ packedIndices[i + 1] = packedIndices[i + 2]!
515
+ packedIndices[i + 2] = b
516
+ }
437
517
  }
438
518
  } else {
439
519
  // No normals: the spec asks for flat shading, which needs one vertex
@@ -508,6 +588,7 @@ export function parseGltf(bytes: Uint8Array, resolve?: UriResolver): ModelData {
508
588
  let geometry: Geometry = skinned
509
589
  ? { vertices, indices: packIndices(packedIndices, count), layout: "skinned", label: name }
510
590
  : packGeometry(vertices, packedIndices, { label: name })
591
+ if (!triangles) geometry.topology = topology
511
592
  parts.push({ name, node, skin, geometry, material })
512
593
  }
513
594
 
package/src/index.ts CHANGED
@@ -35,7 +35,7 @@ export { linearColor, linearToSrgb, srgbToLinear } from "./color.ts"
35
35
  export type { CameraState, CameraUpdate, OrthoExtent } from "./camera.ts"
36
36
  export type { NodeTransition, NodeTransitionSpec } from "flux:spatial"
37
37
  export { disposeGeometry } from "./geometry-gpu.ts"
38
- export { box, circle, cone, cylinder, fillAttribute, fillColors, geometryBounds, layoutAttributes, layoutKey, layoutSlot, layoutStride, mergeGeometries, packGeometry, plane, ring, sphere, torus, torusKnot, transformGeometry, validateGeometry, withAttribute, withColors, STANDARD_FLOATS, VERTEX_LAYOUTS } from "./geometry.ts"
38
+ export { box, circle, cone, cylinder, fillAttribute, fillColors, edgesGeometry, geometryBounds, geometryTopology, layoutAttributes, layoutKey, layoutSlot, layoutStride, mergeGeometries, packGeometry, plane, ring, sphere, torus, torusKnot, transformGeometry, validateGeometry, wireframeGeometry, withAttribute, withColors, STANDARD_FLOATS, VERTEX_LAYOUTS } from "./geometry.ts"
39
39
  export type { AttributeFill, BoxOptions, CircleOptions, ColorFill, ConeOptions, CylinderOptions, Geometry, GeometryOptions, PlaneOptions, RingOptions, SphereOptions, TorusKnotOptions, TorusOptions, VertexLayout } from "./geometry.ts"
40
40
  export { fillet, roundRect, shape, triangulate } from "./profile.ts"
41
41
  export type { Profile, ProfilePoint } from "./profile.ts"
@@ -69,6 +69,7 @@ export type { ModelChannel, ModelClip, ModelData, ModelMaterial, ModelNode, Mode
69
69
  export { decodeModel, encodeModel } from "./model-file.ts"
70
70
  export { createModel, loadGltf, loadModel } from "./model.ts"
71
71
  export type { Model, ModelMaps, ModelOptions } from "./model.ts"
72
+ export { bindSkeleton } from "./skeleton.ts"
72
73
  export { createMixer } from "./mixer.ts"
73
74
  export type { Mixer, MixerOptions, MixerPlayOptions } from "./mixer.ts"
74
75
  export { sampleChannel } from "./clip.ts"
package/src/input.ts CHANGED
@@ -31,7 +31,9 @@ export type CameraDevices = {
31
31
  export let orbitActions = { rotate: "vec2", zoom: "axis", pan: "vec2" } as const
32
32
 
33
33
  /**
34
- * The orbit camera's standard bindings: a drag rotates, a pinch and the
34
+ * The orbit camera's standard bindings: a drag rotates, a Ctrl-drag or a
35
+ * right-drag pans (the desktop pans, Three's and Blender's; a chorded or
36
+ * right-button drag feeds only `pan`, never `rotate`), a pinch and the
35
37
  * wheel zoom, two fingers pan; the right stick rotates, the triggers
36
38
  * zoom, the left stick pans; the arrow keys rotate and minus/equals zoom.
37
39
  */
@@ -39,7 +41,14 @@ export function orbitBindings(devices: CameraDevices): Binding[] {
39
41
  let out: Binding[] = []
40
42
  let { pointer, gamepad, keyboard } = devices
41
43
  if (pointer) {
42
- out.push({ action: "rotate", source: pointer.drag }, { action: "zoom", source: pointer.pinch }, { action: "zoom", source: pointer.wheel }, { action: "pan", source: pointer.pan })
44
+ out.push(
45
+ { action: "rotate", source: pointer.drag },
46
+ { action: "pan", source: pointer.drag("Ctrl") },
47
+ { action: "pan", source: pointer.drag("Right") },
48
+ { action: "pan", source: pointer.pan },
49
+ { action: "zoom", source: pointer.pinch },
50
+ { action: "zoom", source: pointer.wheel },
51
+ )
43
52
  }
44
53
  if (gamepad) {
45
54
  out.push({ action: "rotate", source: invert(gamepad.rightStick) }, { action: "zoom", source: gamepad.triggers }, { action: "pan", source: invert(gamepad.leftStick) })
package/src/material.ts CHANGED
@@ -41,7 +41,7 @@ import type {
41
41
  Topology,
42
42
  VertexAttribute,
43
43
  } from "@solidrt/core/gpu"
44
- import { layoutAttributes, layoutKey, layoutSlot } from "./geometry.ts"
44
+ import { FORMAT_FLOATS, layoutAttributes, layoutKey, layoutSlot } from "./geometry.ts"
45
45
  import type { VertexLayout } from "./geometry.ts"
46
46
  import { linearColor } from "./color.ts"
47
47
  import {
@@ -63,9 +63,10 @@ import {
63
63
  } from "./glsl.ts"
64
64
 
65
65
  export type Material = {
66
- /** The pipeline this material draws with for geometry of `layout`
67
- * (lazily created, one per layout met). */
68
- pipeline(layout: VertexLayout | undefined): RenderPipelineId
66
+ /** The pipeline this material draws with for geometry of `layout` and
67
+ * `topology` (lazily created, one per pair met): the geometry says what
68
+ * its indices are, the material draws them that way. */
69
+ pipeline(layout: VertexLayout | undefined, topology: Topology): RenderPipelineId
69
70
  /** Per-entry uniform values this material contributes at addDraw. */
70
71
  params: ShaderParams
71
72
  /** Per-entry sampler bindings, when the material samples textures. */
@@ -1072,7 +1073,6 @@ export type ShaderMaterialClassOptions = {
1072
1073
  depthWrite?: boolean
1073
1074
  blend?: BlendMode
1074
1075
  cull?: CullMode
1075
- topology?: Topology
1076
1076
  label?: string
1077
1077
  }
1078
1078
 
@@ -1142,6 +1142,24 @@ export function shaderMaterialClass(opts: ShaderMaterialClassOptions): ShaderMat
1142
1142
  let transparent = opts.transparent ?? (opts.blend !== undefined && opts.blend !== "none")
1143
1143
  let depth = opts.depth ?? true
1144
1144
  let cull = opts.cull ?? "back"
1145
+ // Formats are the vertex vocabulary (f32/vec2/vec3/vec4), not WebGPU's
1146
+ // spelling, and an unknown one has no float count: it would leave here
1147
+ // intact, make a NaN record stride at the mesh, and fail as an arithmetic
1148
+ // complaint from createRecordMesh naming neither the attribute nor the
1149
+ // format. Checked where the name is written instead.
1150
+ for (let attr of opts.instanceAttributes ?? []) {
1151
+ if (!(attr.format in FORMAT_FLOATS)) {
1152
+ throw new Error(
1153
+ "shaderMaterial unknown instance attribute format '" +
1154
+ String(attr.format) +
1155
+ "' for " +
1156
+ attr.name +
1157
+ " (expected " +
1158
+ Object.keys(FORMAT_FLOATS).join(", ") +
1159
+ ")",
1160
+ )
1161
+ }
1162
+ }
1145
1163
  // An empty list declares nothing - same as absent (the engine requires an
1146
1164
  // instance buffer exactly when attributes are declared).
1147
1165
  let instanceAttributes = opts.instanceAttributes?.length ? opts.instanceAttributes.map(a => ({ ...a })) : undefined
@@ -1192,8 +1210,8 @@ export function shaderMaterialClass(opts: ShaderMaterialClassOptions): ShaderMat
1192
1210
  // record buffer, declared on the pipeline beside the layout).
1193
1211
  let attributes = (): VertexAttribute[] =>
1194
1212
  programAttributes(programFor()).filter(a => !instanceAttributes?.some(i => i.name === a.name))
1195
- let pipelineFor = (layout: VertexLayout | undefined): RenderPipelineId => {
1196
- let key = layoutKey(layout)
1213
+ let pipelineFor = (layout: VertexLayout | undefined, topology: Topology): RenderPipelineId => {
1214
+ let key = layoutKey(layout) + "|" + topology
1197
1215
  let pipeline = pipelines.get(key)
1198
1216
  if (pipeline === undefined) {
1199
1217
  pipeline = createRenderPipeline(programFor(), {
@@ -1205,7 +1223,7 @@ export function shaderMaterialClass(opts: ShaderMaterialClassOptions): ShaderMat
1205
1223
  depthWrite: opts.depthWrite ?? (transparent && depth ? false : undefined),
1206
1224
  blend: opts.blend ?? (transparent ? "alpha" : undefined),
1207
1225
  cull,
1208
- topology: opts.topology,
1226
+ topology,
1209
1227
  label: opts.label,
1210
1228
  })
1211
1229
  pipelines.set(key, pipeline)
package/src/mesh.ts CHANGED
@@ -4,8 +4,8 @@
4
4
  // scene side is reached through the node's SceneHooks (node.ts).
5
5
 
6
6
  import { createBuffer, destroyBuffer, writeBuffer } from "@solidrt/core/gpu"
7
- import type { BufferId, DrawId, InstanceAttribute, ShaderParams, TextureBindings, VertexAttribute } from "@solidrt/core/gpu"
8
- import { geometryBounds, plane } from "./geometry.ts"
7
+ import type { BufferId, DrawId, InstanceAttribute, ShaderParams, TextureBindings } from "@solidrt/core/gpu"
8
+ import { FORMAT_FLOATS, geometryBounds, plane } from "./geometry.ts"
9
9
  import type { Geometry } from "./geometry.ts"
10
10
  import type { GeometryBuffers } from "./geometry-gpu.ts"
11
11
  import type { Material } from "./material.ts"
@@ -24,7 +24,8 @@ export type Mesh = SceneNode & {
24
24
  renderOrder: number
25
25
  /** Draw into the scene's shadow map (default false, Three's default).
26
26
  * Set with setCastShadow. A casting instanced mesh is skipped (the
27
- * depth pass cannot know its record layout). */
27
+ * depth pass cannot know its record layout), and so is any geometry
28
+ * that is not a triangle list: lines and points cast nothing. */
28
29
  castShadow: boolean
29
30
  /** Layer membership bitmask (default 1, Three's `object.layers`): a
30
31
  * target draws the mesh when its mask intersects this. Not inherited
@@ -225,13 +226,11 @@ export function localBounds(mesh: Mesh): Float32Array | null {
225
226
  return mesh._sprite ? SPRITE_BOUNDS : geometryBounds(mesh.geometry)
226
227
  }
227
228
 
228
- const ATTRIBUTE_FLOATS: Record<VertexAttribute["format"], number> = { f32: 1, vec2: 2, vec3: 3, vec4: 4 }
229
-
230
229
  /** Floats per record of one instance slot of an attribute list (slot 0
231
230
  * by default; the list's `slot` keys pick the others). */
232
231
  export function instanceStride(attributes: InstanceAttribute[], slot = 0): number {
233
232
  let stride = 0
234
- for (let a of attributes) if ((a.slot ?? 0) === slot) stride += ATTRIBUTE_FLOATS[a.format]
233
+ for (let a of attributes) if ((a.slot ?? 0) === slot) stride += FORMAT_FLOATS[a.format]
235
234
  return stride
236
235
  }
237
236
 
package/src/model.ts CHANGED
@@ -24,6 +24,8 @@ import { add, createGroup, remove, setTransform } from "./node.ts"
24
24
  import type { SceneNode } from "./node.ts"
25
25
  import { createMesh } from "./mesh.ts"
26
26
  import type { Mesh } from "./mesh.ts"
27
+ import { refreshJointBounds, unbindSkeleton } from "./skeleton.ts"
28
+ import type { WornPiece } from "./skeleton.ts"
27
29
 
28
30
  /** Anisotropic filtering level for a model's textures: the engines' usual
29
31
  * default (Godot ships 2x, Unity's quality presets 2-8x) - enough to keep a
@@ -80,8 +82,30 @@ export type Model = SceneNode & {
80
82
  /** Local rest-pose [minX, minY, minZ, maxX, maxY, maxZ] over every part
81
83
  * (conservative for parts under rotated nodes). */
82
84
  bounds: Float32Array
83
- /** Detach the model and free its geometry buffers and textures. */
85
+ /** Detach the model and free its geometry buffers and textures. A
86
+ * worn piece (bindSkeleton) comes off its body; a body takes the
87
+ * pieces it wears with it. */
84
88
  dispose(): void
89
+ /** @internal The skins the parts draw, in file order: the palette
90
+ * texture (shared by identical skins), the joint nodes in row order,
91
+ * their inverse binds and joint-space influence boxes. */
92
+ _skins: ModelSkinNodes[]
93
+ /** @internal The pieces bound onto this model's skeleton. */
94
+ _worn: WornPiece[]
95
+ /** @internal The body this model is bound onto, or null. */
96
+ _body: Model | null
97
+ /** @internal Subtrees bindSkeleton moved under the body's joints (no
98
+ * longer under this model); detached again by dispose. */
99
+ _grafts: SceneNode[]
100
+ }
101
+
102
+ /** @internal One skin of a built model: ModelSkin with its joints
103
+ * resolved to the model's nodes and its palette texture. */
104
+ export type ModelSkinNodes = {
105
+ texture: TextureId
106
+ joints: SceneNode[]
107
+ inverseBind: Float32Array
108
+ jointBounds: Float32Array
85
109
  }
86
110
 
87
111
  /**
@@ -162,6 +186,10 @@ export function createModel(data: ModelData, opts: ModelOptions = {}): Model {
162
186
  let materials = data.materials.map((_, i) => materialFor(i, false))
163
187
 
164
188
  let model = createGroup() as Model
189
+ model._skins = []
190
+ model._worn = []
191
+ model._body = null
192
+ model._grafts = []
165
193
  // The node table is pre-order, so every parent group exists before its
166
194
  // children reference it.
167
195
  let groups: SceneNode[] = data.nodes.map((n) => {
@@ -183,39 +211,38 @@ export function createModel(data: ModelData, opts: ModelOptions = {}): Model {
183
211
  // joints move - there is no JS palette walk, and posing joints with
184
212
  // setTransform is always enough. Identical skins (same joints, same
185
213
  // inverse binds - the body/legs and LOD splits exporters produce) share
186
- // one texture, so their palette is computed and uploaded once.
187
- let skinTextures: TextureId[] = []
214
+ // one texture, so their palette is computed and uploaded once. A worn
215
+ // piece's rows move onto another model's joints (bindSkeleton).
188
216
  data.skins.forEach((skin, i) => {
189
- let joints = skin.joints.join(",")
190
- for (let j = 0; j < i; j++) {
191
- let other = data.skins[j]!
192
- if (
193
- other.joints.join(",") === joints &&
194
- other.inverseBind.length === skin.inverseBind.length &&
195
- other.inverseBind.every((v, k) => v === skin.inverseBind[k])
196
- ) {
197
- skinTextures.push(skinTextures[j]!)
198
- return
199
- }
200
- }
201
- let texture = createMutableTexture(new Float32Array(skin.joints.length * 16), 4, skin.joints.length, {
202
- format: "rgba32f",
203
- autoFree: false,
204
- label: label ? label + "-skin" + i : "skin" + i,
217
+ let joints = skin.joints.map((n) => {
218
+ let joint = groups[n]
219
+ if (joint === undefined) throw new Error("createModel: skin " + i + " names a missing node " + n)
220
+ return joint
205
221
  })
222
+ let key = skin.joints.join(",")
223
+ let same = model._skins.find(
224
+ (other, j) =>
225
+ data.skins[j]!.joints.join(",") === key &&
226
+ other.inverseBind.length === skin.inverseBind.length &&
227
+ other.inverseBind.every((v, k) => v === skin.inverseBind[k]),
228
+ )
229
+ let texture =
230
+ same?.texture ??
231
+ createMutableTexture(new Float32Array(skin.joints.length * 16), 4, skin.joints.length, {
232
+ format: "rgba32f",
233
+ autoFree: false,
234
+ label: label ? label + "-skin" + i : "skin" + i,
235
+ })
236
+ model._skins.push({ texture, joints, inverseBind: skin.inverseBind, jointBounds: skin.jointBounds })
237
+ if (same !== undefined) return
206
238
  textures.push(texture)
207
- skinTextures.push(texture)
208
- for (let j = 0; j < skin.joints.length; j++) {
209
- let joint = groups[skin.joints[j]!]
210
- if (joint === undefined) throw new Error("createModel: skin " + i + " names a missing node " + skin.joints[j])
239
+ joints.forEach((joint, j) => {
211
240
  ;(joint._palettes ??= []).push({ texture, row: j, post: skin.inverseBind.slice(j * 16, j * 16 + 16), anchor: model })
212
- // The joint's influence box, in joint space: culling-only bounds the
213
- // flush carries through the pose. A joint influencing nothing (an
214
- // inverted box) gets none and contributes nothing to the union.
215
- let jb = skin.jointBounds.subarray(j * 6, j * 6 + 6)
216
- if (jb[0]! <= jb[3]! && jb[1]! <= jb[4]! && jb[2]! <= jb[5]!) joint._cullBounds = Float32Array.from(jb)
217
- }
241
+ })
218
242
  })
243
+ // Each joint's influence box, in joint space: culling-only bounds the
244
+ // flush carries through the pose, united over every skin reaching it.
245
+ refreshJointBounds(model)
219
246
  model.parts = data.parts.map((part) => {
220
247
  let skinned = part.skin !== null
221
248
  let material = materialFor(part.material, skinned)
@@ -225,11 +252,11 @@ export function createModel(data: ModelData, opts: ModelOptions = {}): Model {
225
252
  // matrices place them, so its mesh hangs off the model root (the
226
253
  // spec ignores the node's transform for skinned meshes) and uModel
227
254
  // stays the model's own placement.
228
- let texture = skinTextures[part.skin!]
229
- if (texture === undefined) throw new Error("createModel: part '" + part.name + "' names a missing skin " + part.skin)
230
- mesh._textures = { uBones: texture }
255
+ let skin = model._skins[part.skin!]
256
+ if (skin === undefined) throw new Error("createModel: part '" + part.name + "' names a missing skin " + part.skin)
257
+ mesh._textures = { uBones: skin.texture }
231
258
  // Culled by its joints' boxes, so the box follows the animation.
232
- mesh._cullJoints = data.skins[part.skin!]!.joints.map((n) => groups[n]!)
259
+ mesh._cullJoints = skin.joints.slice()
233
260
  add(model, mesh)
234
261
  } else {
235
262
  let node = groups[part.node]
@@ -242,6 +269,11 @@ export function createModel(data: ModelData, opts: ModelOptions = {}): Model {
242
269
  model.clips = data.clips
243
270
  model.bounds = data.bounds
244
271
  model.dispose = () => {
272
+ // A worn piece comes off first (its rows sit on the body's joints);
273
+ // a body takes its pieces with it (their skins read joints that are
274
+ // about to go).
275
+ if (model._body !== null) unbindSkeleton(model)
276
+ for (let worn of model._worn.slice()) worn.piece.dispose()
245
277
  if (model.parent !== null) remove(model)
246
278
  for (let part of model.parts) disposeGeometry(part.mesh.geometry)
247
279
  for (let id of textures) destroyTexture(id)
package/src/node.ts CHANGED
@@ -143,7 +143,8 @@ export type SceneNode = {
143
143
  * skin): bound to the core at every scene enter, so the flush writes
144
144
  * `inverse(anchorWorld) * world * post` - the model-local bone matrix -
145
145
  * to the palette texture's row whenever the node moves. `anchor` is the
146
- * model root; null for non-joints (the common case pays one null check). */
146
+ * model root - the body's, for a piece bound onto its skeleton - and
147
+ * null for non-joints (the common case pays one null check). */
147
148
  _palettes: { texture: TextureId; row: number; post: Float32Array; anchor: SceneNode }[] | null
148
149
  /** A culling-only local box (a model joint's influence region, in joint
149
150
  * space): bound at every scene enter so the joint's world box follows
package/src/scene.ts CHANGED
@@ -50,7 +50,7 @@ import { cameraParams, cameraState, ensureCamera, makeCamera, updateCamera } fro
50
50
  import type { Camera, CameraState, CameraUpdate } from "./camera.ts"
51
51
  import { makeShadowSystem } from "./scene-shadows.ts"
52
52
  import { makePointerInput } from "./scene-pointer.ts"
53
- import { geometryBounds, layoutKey, validateGeometry } from "./geometry.ts"
53
+ import { geometryBounds, geometryTopology, layoutKey, validateGeometry } from "./geometry.ts"
54
54
  import type { Geometry } from "./geometry.ts"
55
55
  import { acquireGeometryBuffers, releaseGeometryBuffers } from "./geometry-gpu.ts"
56
56
  import { backgroundPipeline, missingAttributes, SKYBOX_FRAGMENT } from "./material.ts"
@@ -1289,7 +1289,7 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1289
1289
  // so added live it would draw at the seeded identity until then. The
1290
1290
  // mismatch branch in sync() turns it on in the same pass that writes
1291
1291
  // uModel.
1292
- mesh._entry = addDraw(texture, mesh.material.pipeline(mesh.geometry.layout), entrySeed(mesh.material, mesh._params), {
1292
+ mesh._entry = addDraw(texture, mesh.material.pipeline(mesh.geometry.layout, geometryTopology(mesh.geometry)), entrySeed(mesh.material, mesh._params), {
1293
1293
  buffer: bufs.buffer,
1294
1294
  indexBuffer: bufs.index,
1295
1295
  indexFormat: bufs.indexFormat,
@@ -1319,6 +1319,9 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1319
1319
  if (v.entries.has(mesh)) return
1320
1320
  let inst = mesh._instances
1321
1321
  if (v.shadowFilter !== null && !v.shadowFilter(mesh)) return
1322
+ // Lines and points cast nothing: a one-pixel wireframe in a shadow
1323
+ // map is speckle on the floor, never a shadow anyone wanted.
1324
+ if (v.shadowFilter !== null && geometryTopology(mesh.geometry) !== "triangles") return
1322
1325
  if ((mesh.layers & v.mask) === 0) return
1323
1326
  let material = viewMaterial(v, mesh)
1324
1327
  // An override pipeline cannot know a populated mesh's record layout,
@@ -1331,7 +1334,7 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1331
1334
  checkInstancePairing(material, inst, "Shadow material")
1332
1335
  }
1333
1336
  let bufs = mesh._buffers!
1334
- let entry = addDraw(v.texture, material.pipeline(mesh.geometry.layout), entrySeed(material, v.override !== null ? null : mesh._params), {
1337
+ let entry = addDraw(v.texture, material.pipeline(mesh.geometry.layout, geometryTopology(mesh.geometry)), entrySeed(material, v.override !== null ? null : mesh._params), {
1335
1338
  buffer: bufs.buffer,
1336
1339
  indexBuffer: bufs.index,
1337
1340
  indexFormat: bufs.indexFormat,