@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/mixer.ts CHANGED
@@ -9,9 +9,11 @@
9
9
  // (getTransform) and may overwrite them (root-motion strips, skeleton
10
10
  // copies), last write wins, palettes and uModel following at the frame's
11
11
  // flush. A channel the active clips do not animate keeps the node's
12
- // current pose. Playing requires the model to be IN A SCENE (players
13
- // bind live arena nodes); removing it drops the players, and a re-added
14
- // model plays again from play().
12
+ // current pose. A weights channel (morph targets) plays the same way,
13
+ // into the owning node's weights register (`getMorphWeights` reads it),
14
+ // blended across players like a TRS path. Playing requires the model
15
+ // to be IN A SCENE (players bind live arena nodes); removing it drops
16
+ // the players, and a re-added model plays again from play().
15
17
  //
16
18
  // Root motion: a locomotion clip translates its root, which is what a game
17
19
  // consumes to move the character and what sends a viewer's model through
@@ -44,7 +46,7 @@ import * as spatial from "flux:spatial"
44
46
  import type { NodeId } from "flux:spatial"
45
47
  import { on } from "srt:events"
46
48
  import type { ModelChannel, ModelClip } from "./gltf.ts"
47
- import { sampleChannel } from "./clip.ts"
49
+ import { channelElements, sampleChannel } from "./clip.ts"
48
50
  import type { Vec3 } from "./math.ts"
49
51
  import type { Model } from "./model.ts"
50
52
 
@@ -111,8 +113,10 @@ export type Mixer = {
111
113
  }
112
114
 
113
115
  // The packed-clip codes of flux:spatial's createClip layout.
114
- const PATH_CODE = { position: 0, rotation: 1, scale: 2 } as const
116
+ const PATH_CODE = { position: 0, rotation: 1, scale: 2, weights: 3 } as const
115
117
  const INTERP_CODE = { step: 0, linear: 1, cubic: 2 } as const
118
+ // Meta words per channel in that layout.
119
+ const CLIP_META_WORDS = 5
116
120
 
117
121
  // Net root x/z drift, as a fraction of the model's rest height, above
118
122
  // which a clip counts as travelling. Measured on a stock rig: run cycles
@@ -141,16 +145,18 @@ on("spatialRootMotion", (event: { player: number; x: number; y: number; z: numbe
141
145
 
142
146
  /** Pack channels into the core's createClip layout. */
143
147
  function registerClip(duration: number, channels: ModelChannel[]): number {
144
- let meta = new Uint32Array(channels.length * 4)
148
+ let meta = new Uint32Array(channels.length * CLIP_META_WORDS)
145
149
  let timeCount = 0
146
150
  let valueCount = 0
147
151
  channels.forEach((c, i) => {
148
152
  // Target slot = channel index: the player's target table below is
149
153
  // built in the same order.
150
- meta[i * 4] = i
151
- meta[i * 4 + 1] = PATH_CODE[c.path]
152
- meta[i * 4 + 2] = INTERP_CODE[c.interpolation]
153
- meta[i * 4 + 3] = c.times.length
154
+ let at = i * CLIP_META_WORDS
155
+ meta[at] = i
156
+ meta[at + 1] = PATH_CODE[c.path]
157
+ meta[at + 2] = INTERP_CODE[c.interpolation]
158
+ meta[at + 3] = c.times.length
159
+ meta[at + 4] = channelElements(c)
154
160
  timeCount += c.times.length
155
161
  valueCount += c.values.length
156
162
  })
@@ -211,7 +217,7 @@ function axisQuat(up: Vec3, angle: number): [number, number, number, number] {
211
217
  * linear or step channel is returned as is). */
212
218
  function linearized(c: ModelChannel, duration: number): ModelChannel {
213
219
  if (c.interpolation !== "cubic") return c
214
- let elements = c.path === "rotation" ? 4 : 3
220
+ let elements = channelElements(c)
215
221
  let keys = Math.max(2, Math.ceil(duration * YAW_HOLD_RESAMPLE_HZ) + 1)
216
222
  let times = new Float32Array(keys)
217
223
  let values = new Float32Array(keys * elements)
@@ -249,7 +255,7 @@ function holdYaw(rotation: ModelChannel, duration: number, up: Vec3): ModelChann
249
255
  // Floats per key and the value's offset within a key: a key's value sits
250
256
  // at [in, value, out] for cubic keys; rotations are four floats.
251
257
  function keyLayout(c: ModelChannel): { stride: number; mid: number } {
252
- let elements = c.path === "rotation" ? 4 : 3
258
+ let elements = channelElements(c)
253
259
  let cubic = c.interpolation === "cubic"
254
260
  return { stride: cubic ? elements * 3 : elements, mid: cubic ? elements : 0 }
255
261
  }
@@ -369,14 +375,21 @@ export function createMixer(model: Model, mixerOpts: MixerOptions = {}): Mixer {
369
375
  let rootAcc = { position: [0, 0, 0] as [number, number, number], yaw: 0 }
370
376
  // Channel targets resolve at play: clips index the model's node table,
371
377
  // and the arena ids are per scene-entry, so a re-added model binds
372
- // fresh ones.
378
+ // fresh ones. A weights channel targets the node's weights register
379
+ // (the glTF node owning its mesh's morph targets, model.ts); its key
380
+ // width must be the node's target count.
373
381
  let targetsFor = (clip: ModelClip): NodeId[] =>
374
382
  clip.channels.map((c) => {
375
383
  let entry = model.nodes[c.node]
376
384
  if (entry === undefined) throw new Error("createMixer: clip '" + clip.name + "' targets a missing node " + c.node)
377
385
  let id = entry.node._node
378
386
  if (id === null) throw new Error("play: the model must be in a scene (add() it first) before clips can play")
379
- entry.node._native = true
387
+ if (c.path === "weights") {
388
+ let targets = entry.node._morph?.names.length ?? 0
389
+ if (channelElements(c) !== targets) {
390
+ throw new Error("play: clip '" + clip.name + "' has " + channelElements(c) + " weights per key for node '" + entry.name + "' with " + targets + " morph targets")
391
+ }
392
+ } else entry.node._native = true
380
393
  return id
381
394
  })
382
395
 
package/src/model-file.ts CHANGED
@@ -10,7 +10,8 @@
10
10
  // directly. Images travel as their encoded files (PNG/JPEG bytes).
11
11
 
12
12
  import type { ModelChannel, ModelData, ModelMaterial, ModelNode, ModelSkin } from "./gltf.ts"
13
- import { layoutStride } from "./geometry.ts"
13
+ import { layoutStride, vertexView, VERTEX_FORMATS } from "./geometry.ts"
14
+ import type { VertexAttribute } from "@solidrt/core/gpu"
14
15
  import type { VertexLayout } from "./geometry.ts"
15
16
 
16
17
  /** "SRTM" read as a little-endian u32. */
@@ -24,11 +25,39 @@ const MAGIC = 0x4d545253
24
25
  // Version 4 adds the metalness/roughness fields to the material records
25
26
  // (metalness, roughness, metalnessRoughnessMap); a version-3 file lacks
26
27
  // them, so it is rejected the same way rather than read as all-metal.
27
- const VERSION = 5
28
+ // Version 6 spells attribute formats in the WebGPU vocabulary
29
+ // (float32x3, unorm8x4, ...) and counts vertices in bytes, so a part may
30
+ // carry packed channels; a version-5 file's "vec3" words do not parse.
31
+ // Version 7 changes the named "skinned" layout's joints from float32x4 to
32
+ // uint8x4 (an integer shader input), so a version-6 skinned part would
33
+ // decode at the wrong stride; it is rejected. Re-bake with `srt tool
34
+ // 3d/model`.
35
+ // Version 8 carries morph targets: a part's packed targets block (names,
36
+ // the sparse-by-vertex texels, the extent - see packMorphTargets), the
37
+ // mesh weights on the node table, and clip channels on the "weights"
38
+ // path. A version-7 file has none, so it is rejected the same way.
39
+ // Version 9 names the plain layout "base" (position, normal, uv - the
40
+ // prefix "colored" and "skinned" extend) where version 8 wrote
41
+ // "standard"; a version-8 file's layout word does not parse.
42
+ const VERSION = 9
28
43
 
29
- // The named layouts the container writes; a custom attribute-list layout
30
- // has no name to store, so encodeModel rejects it.
31
- const NAMED_LAYOUTS = ["standard", "colored", "skinned"]
44
+ // The named layouts the container writes by name; a custom attribute-list
45
+ // layout (a skinned primitive with COLOR_0, a withAttribute channel) is
46
+ // written as its list.
47
+ const NAMED_LAYOUTS = ["base", "colored", "skinned"]
48
+
49
+ // A part's layout as written: a named preset, or a custom attribute list
50
+ // checked to the shape the stride and the pipeline are built from.
51
+ function decodeLayout(layout: string | VertexAttribute[], name: string): VertexLayout {
52
+ if (typeof layout === "string") {
53
+ if (!NAMED_LAYOUTS.includes(layout)) throw new Error("decodeModel: part '" + name + "' has an unsupported layout " + layout)
54
+ return layout as VertexLayout
55
+ }
56
+ if (!Array.isArray(layout) || !layout.every((a) => typeof a.name === "string" && a.format in VERTEX_FORMATS)) {
57
+ throw new Error("decodeModel: part '" + name + "' has a malformed attribute list")
58
+ }
59
+ return layout
60
+ }
32
61
 
33
62
  type Block = { offset: number; bytes: number }
34
63
 
@@ -37,10 +66,12 @@ type PartHeader = Block & {
37
66
  node: number
38
67
  skin: number | null
39
68
  material: number
40
- layout: string
69
+ layout: string | VertexAttribute[]
41
70
  vertexCount: number
42
71
  indexBits: 16 | 32
43
72
  index: Block
73
+ /** The part's packed morph targets, when it has any. */
74
+ morphs?: { names: string[]; texels: Block; extent: number[] }
44
75
  }
45
76
 
46
77
  type SkinHeader = { joints: number[]; inverseBind: Block; jointBounds: Block }
@@ -83,23 +114,28 @@ export function encodeModel(data: ModelData): Uint8Array {
83
114
 
84
115
  let parts: PartHeader[] = data.parts.map((part) => {
85
116
  let g = part.geometry
86
- let layout = g.layout === undefined ? "standard" : g.layout
87
- if (typeof layout !== "string") {
88
- throw new Error("encodeModel: part '" + part.name + "' has a custom attribute-list layout; only the named layouts are written")
117
+ if (g.streams !== undefined && g.streams.length > 0) {
118
+ throw new Error("encodeModel: part '" + part.name + "' carries extra vertex streams; the container writes one interleaved buffer per part")
89
119
  }
120
+ let layout = g.layout === undefined ? "base" : g.layout
90
121
  let vertices = push(new Uint8Array(g.vertices.buffer, g.vertices.byteOffset, g.vertices.byteLength))
91
122
  let index = push(new Uint8Array(g.indices.buffer, g.indices.byteOffset, g.indices.byteLength))
92
- return {
123
+ let header: PartHeader = {
93
124
  ...vertices,
94
125
  name: part.name,
95
126
  node: part.node,
96
127
  skin: part.skin,
97
128
  material: part.material,
98
129
  layout,
99
- vertexCount: g.vertices.length / layoutStride(layout),
130
+ vertexCount: g.vertices.byteLength / layoutStride(layout),
100
131
  indexBits: g.indices instanceof Uint32Array ? 32 : 16,
101
132
  index,
102
133
  }
134
+ if (g.morphs !== undefined) {
135
+ let texels = push(new Uint8Array(g.morphs.texels.buffer, g.morphs.texels.byteOffset, g.morphs.texels.byteLength))
136
+ header.morphs = { names: g.morphs.names, texels, extent: Array.from(g.morphs.extent) }
137
+ }
138
+ return header
103
139
  })
104
140
  let images = data.images.map((image) => push(image))
105
141
  let floats = (f: Float32Array): Block => push(new Uint8Array(f.buffer, f.byteOffset, f.byteLength))
@@ -152,15 +188,18 @@ export function decodeModel(bytes: Uint8Array): ModelData {
152
188
  let payload = base + 12 + jsonLength + ((4 - (jsonLength % 4)) % 4)
153
189
 
154
190
  let parts = header.parts.map((part) => {
155
- if (!NAMED_LAYOUTS.includes(part.layout)) throw new Error("decodeModel: part '" + part.name + "' has an unsupported layout " + part.layout)
156
- let layout = part.layout as VertexLayout
191
+ let layout = decodeLayout(part.layout, part.name)
157
192
  let indexCount = part.index.bytes / (part.indexBits / 8)
158
193
  let geometry: ModelData["parts"][0]["geometry"] = {
159
- vertices: new Float32Array(buffer, payload + part.offset, part.vertexCount * layoutStride(layout)),
194
+ vertices: vertexView(layout, buffer, payload + part.offset, part.vertexCount * layoutStride(layout)),
160
195
  indices: part.indexBits === 32 ? new Uint32Array(buffer, payload + part.index.offset, indexCount) : new Uint16Array(buffer, payload + part.index.offset, indexCount),
161
196
  label: part.name,
162
197
  }
163
- if (part.layout !== "standard") geometry.layout = layout
198
+ if (part.layout !== "base") geometry.layout = layout
199
+ if (part.morphs !== undefined) {
200
+ let m = part.morphs
201
+ geometry.morphs = { names: m.names, texels: new Float32Array(buffer, payload + m.texels.offset, m.texels.bytes / 4), extent: Float32Array.from(m.extent) }
202
+ }
164
203
  return {
165
204
  name: part.name,
166
205
  node: part.node,
package/src/model.ts CHANGED
@@ -11,19 +11,23 @@
11
11
 
12
12
  import { file } from "flux:fs"
13
13
  import * as spatial from "flux:spatial"
14
- import { decodeImage } from "@solidrt/core"
14
+ import { decodeImage, getOwner, isDisposed, onCleanup, runWithOwner } from "@solidrt/core"
15
+ import type { Owner } from "@solidrt/core"
15
16
  import { createMutableTexture, createTexture, destroyTexture } from "@solidrt/core/gpu"
16
17
  import type { TextureId } from "@solidrt/core/gpu"
17
18
  import { gltfExternalUris, parseGltf } from "./gltf.ts"
18
19
  import type { ModelClip, ModelData, ModelMaterial } from "./gltf.ts"
19
20
  import { decodeModel } from "./model-file.ts"
20
21
  import { disposeGeometry } from "./geometry-gpu.ts"
22
+ import { layoutSlot } from "./geometry.ts"
21
23
  import { standard } from "./material.ts"
22
24
  import type { Material } from "./material.ts"
23
- import { add, createGroup, remove, setTransform } from "./node.ts"
25
+ import { add, afterFree, createGroup, createMorphState, remove, setTransform } from "./node.ts"
24
26
  import type { SceneNode } from "./node.ts"
25
27
  import { createMesh } from "./mesh.ts"
26
28
  import type { Mesh } from "./mesh.ts"
29
+ import { refreshJointBounds, unbindSkeleton } from "./skeleton.ts"
30
+ import type { WornPiece } from "./skeleton.ts"
27
31
 
28
32
  /** Anisotropic filtering level for a model's textures: the engines' usual
29
33
  * default (Godot ships 2x, Unity's quality presets 2-8x) - enough to keep a
@@ -31,7 +35,7 @@ import type { Mesh } from "./mesh.ts"
31
35
  * the device by the runtime. */
32
36
  const MODEL_ANISOTROPY = 4
33
37
 
34
- /** A glTF material's uploaded textures, by lit()/standard() option name;
38
+ /** A glTF material's uploaded textures, by phong()/standard() option name;
35
39
  * null where the material has none. */
36
40
  export type ModelMaps = {
37
41
  map: TextureId | null
@@ -47,18 +51,26 @@ export type ModelOptions = {
47
51
  /** The material for each glTF material (default: `standard` with its
48
52
  * color, maps, normal scale, metalness/roughness, emissive and
49
53
  * transparency - the glTF material model; a scene with no `environment`
50
- * renders its metals near black, so set one, or return `lit` here for
54
+ * renders its metals near black, so set one, or return `phong` here for
51
55
  * the Blinn-Phong look). `maps` holds the uploaded textures by
52
- * lit()/standard() option name. Called once per material -
53
- * or once per (material, skinned) combination when skinned parts share
54
- * a material with static ones - and shared by every part using it.
55
- * `skinned` is true when the material must skin (pass it through to
56
- * `lit`/`unlit`, or read aJoints/aWeights + uBones yourself).
57
- * `data.materials` is in file order, so the calls arrive in file order
58
- * too. */
59
- material?: (material: ModelMaterial, maps: ModelMaps, skinned: boolean) => Material
56
+ * phong()/standard() option name. Called once per material -
57
+ * or once per (material, skinned, vertexColors, morphed) combination
58
+ * when parts that differ in any share a material - and shared by every
59
+ * part using it. `skinned` is true when the material must skin (pass it
60
+ * through to `phong`/`unlit`, or read aJoints/aWeights + uBones yourself);
61
+ * `vertexColors` is true when the part carries COLOR_0 in aColor (pass
62
+ * it through, or read aColor yourself); `morphed` is true when the part
63
+ * carries morph targets (pass it through as `morph`, or splice
64
+ * MORPH_DECLS/MORPH_APPLY yourself). `data.materials` is in file order,
65
+ * so the calls arrive in file order too. */
66
+ material?: (material: ModelMaterial, maps: ModelMaps, skinned: boolean, vertexColors: boolean, morphed: boolean) => Material
60
67
  /** Debug name for the textures. */
61
68
  label?: string
69
+ /** Free the model with the owning reactive scope (default true, like
70
+ * createScene: `dispose()` runs at the scope's cleanup); `false` opts
71
+ * out, then call dispose yourself. For loadGltf/loadModel the scope is
72
+ * the caller's at the call, before the await. */
73
+ autoFree?: boolean
62
74
  }
63
75
 
64
76
  /** A model in the scene: a Group carrying the file's node hierarchy as
@@ -80,8 +92,30 @@ export type Model = SceneNode & {
80
92
  /** Local rest-pose [minX, minY, minZ, maxX, maxY, maxZ] over every part
81
93
  * (conservative for parts under rotated nodes). */
82
94
  bounds: Float32Array
83
- /** Detach the model and free its geometry buffers and textures. */
95
+ /** Detach the model and free its geometry buffers and textures. A
96
+ * worn piece (bindSkeleton) comes off its body; a body takes the
97
+ * pieces it wears with it. */
84
98
  dispose(): void
99
+ /** @internal The skins the parts draw, in file order: the palette
100
+ * texture (shared by identical skins), the joint nodes in row order,
101
+ * their inverse binds and joint-space influence boxes. */
102
+ _skins: ModelSkinNodes[]
103
+ /** @internal The pieces bound onto this model's skeleton. */
104
+ _worn: WornPiece[]
105
+ /** @internal The body this model is bound onto, or null. */
106
+ _body: Model | null
107
+ /** @internal Subtrees bindSkeleton moved under the body's joints (no
108
+ * longer under this model); detached again by dispose. */
109
+ _grafts: SceneNode[]
110
+ }
111
+
112
+ /** @internal One skin of a built model: ModelSkin with its joints
113
+ * resolved to the model's nodes and its palette texture. */
114
+ export type ModelSkinNodes = {
115
+ texture: TextureId
116
+ joints: SceneNode[]
117
+ inverseBind: Float32Array
118
+ jointBounds: Float32Array
85
119
  }
86
120
 
87
121
  /**
@@ -110,7 +144,7 @@ export function createModel(data: ModelData, opts: ModelOptions = {}): Model {
110
144
  label: label ? label + "-image" + i : undefined,
111
145
  })
112
146
  })
113
- let make = opts.material ?? ((m: ModelMaterial, maps: ModelMaps, skinned: boolean): Material => {
147
+ let make = opts.material ?? ((m: ModelMaterial, maps: ModelMaps, skinned: boolean, vertexColors: boolean, morphed: boolean): Material => {
114
148
  // An emissive factor of zero is emission OFF (the glTF product rule:
115
149
  // factor times texture), so the map is skipped too - no sampler for
116
150
  // a term that cannot show.
@@ -131,17 +165,20 @@ export function createModel(data: ModelData, opts: ModelOptions = {}): Model {
131
165
  cull: m.doubleSided ? "none" : "back",
132
166
  alphaTest: m.alphaMode === "MASK" ? m.alphaCutoff : undefined,
133
167
  skinned: skinned || undefined,
168
+ vertexColors: vertexColors || undefined,
169
+ morph: morphed || undefined,
134
170
  })
135
171
  })
136
172
  let slot = (index: number | null): TextureId | null => (index === null ? null : textures[index]!)
137
173
  // One instance per glTF material as today, plus a skinned variant per
138
- // material the skinned parts bring (a material shared by a static and
139
- // a skinned part needs two programs - different vertex stages).
174
+ // material the skinned parts bring and a vertex-colored one per material
175
+ // the COLOR_0 parts bring (a material shared by parts that differ in
176
+ // either needs two programs - different vertex stages).
140
177
  let variants = new Map<string, Material>()
141
- let materialFor = (index: number, skinned: boolean): Material => {
178
+ let materialFor = (index: number, skinned: boolean, vertexColors: boolean, morphed: boolean): Material => {
142
179
  let m = data.materials[index]
143
180
  if (m === undefined) throw new Error("createModel: a part names a missing material " + index)
144
- let key = index + (skinned ? "|skinned" : "")
181
+ let key = index + (skinned ? "|skinned" : "") + (vertexColors ? "|colored" : "") + (morphed ? "|morphed" : "")
145
182
  let made = variants.get(key)
146
183
  if (made === undefined) {
147
184
  made = make(
@@ -154,14 +191,20 @@ export function createModel(data: ModelData, opts: ModelOptions = {}): Model {
154
191
  roughnessMap: slot(m.metalnessRoughnessMap),
155
192
  },
156
193
  skinned,
194
+ vertexColors,
195
+ morphed,
157
196
  )
158
197
  variants.set(key, made)
159
198
  }
160
199
  return made
161
200
  }
162
- let materials = data.materials.map((_, i) => materialFor(i, false))
201
+ let materials = data.materials.map((_, i) => materialFor(i, false, false, false))
163
202
 
164
203
  let model = createGroup() as Model
204
+ model._skins = []
205
+ model._worn = []
206
+ model._body = null
207
+ model._grafts = []
165
208
  // The node table is pre-order, so every parent group exists before its
166
209
  // children reference it.
167
210
  let groups: SceneNode[] = data.nodes.map((n) => {
@@ -171,6 +214,16 @@ export function createModel(data: ModelData, opts: ModelOptions = {}): Model {
171
214
  })
172
215
  data.nodes.forEach((n, i) => add(n.parent === null ? model : groups[n.parent]!, groups[i]!))
173
216
  model.nodes = data.nodes.map((n, i) => ({ name: n.name, node: groups[i]! }))
217
+ // A glTF node with morph targets owns its parts' weights (the file's
218
+ // node.weights / mesh.weights seed them): one register, one weights
219
+ // texture, every part of the mesh reads it - so setMorphWeights on
220
+ // the node (and a clip's weights track) moves the whole mesh.
221
+ data.nodes.forEach((n, i) => {
222
+ if (n.weights === undefined) return
223
+ let part = data.parts.find((p) => p.node === i && p.geometry.morphs !== undefined)
224
+ if (part === undefined) return
225
+ groups[i]!._morph = createMorphState(part.geometry.morphs!.names, n.weights, label ? label + "-" + n.name + "-weights" : n.name + "-weights")
226
+ })
174
227
  // Each skin's palette lives in an rgba32f texture, 4 texels wide, one
175
228
  // row per joint (the four columns of that joint's mat4), sized to the
176
229
  // RIG: rig size is bounded by texture height (>= 2048 everywhere), not
@@ -183,53 +236,55 @@ export function createModel(data: ModelData, opts: ModelOptions = {}): Model {
183
236
  // joints move - there is no JS palette walk, and posing joints with
184
237
  // setTransform is always enough. Identical skins (same joints, same
185
238
  // 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[] = []
239
+ // one texture, so their palette is computed and uploaded once. A worn
240
+ // piece's rows move onto another model's joints (bindSkeleton).
188
241
  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,
242
+ let joints = skin.joints.map((n) => {
243
+ let joint = groups[n]
244
+ if (joint === undefined) throw new Error("createModel: skin " + i + " names a missing node " + n)
245
+ return joint
205
246
  })
247
+ let key = skin.joints.join(",")
248
+ let same = model._skins.find(
249
+ (other, j) =>
250
+ data.skins[j]!.joints.join(",") === key &&
251
+ other.inverseBind.length === skin.inverseBind.length &&
252
+ other.inverseBind.every((v, k) => v === skin.inverseBind[k]),
253
+ )
254
+ let texture =
255
+ same?.texture ??
256
+ createMutableTexture(new Float32Array(skin.joints.length * 16), 4, skin.joints.length, {
257
+ format: "rgba32f",
258
+ autoFree: false,
259
+ label: label ? label + "-skin" + i : "skin" + i,
260
+ })
261
+ model._skins.push({ texture, joints, inverseBind: skin.inverseBind, jointBounds: skin.jointBounds })
262
+ if (same !== undefined) return
206
263
  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])
264
+ joints.forEach((joint, j) => {
211
265
  ;(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
- }
266
+ })
218
267
  })
268
+ // Each joint's influence box, in joint space: culling-only bounds the
269
+ // flush carries through the pose, united over every skin reaching it.
270
+ refreshJointBounds(model)
219
271
  model.parts = data.parts.map((part) => {
220
272
  let skinned = part.skin !== null
221
- let material = materialFor(part.material, skinned)
273
+ let morphed = part.geometry.morphs !== undefined
274
+ let material = materialFor(part.material, skinned, layoutSlot(part.geometry.layout, "aColor") !== null, morphed)
222
275
  let mesh = createMesh(part.geometry, material)
276
+ // The part's weights are its glTF node's (above), not its own.
277
+ if (morphed) mesh._morphOwner = groups[part.node] ?? mesh
223
278
  if (skinned) {
224
279
  // A skinned part's vertices are model-space bind pose and the skin
225
280
  // matrices place them, so its mesh hangs off the model root (the
226
281
  // spec ignores the node's transform for skinned meshes) and uModel
227
282
  // 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 }
283
+ let skin = model._skins[part.skin!]
284
+ if (skin === undefined) throw new Error("createModel: part '" + part.name + "' names a missing skin " + part.skin)
285
+ mesh._textures = { uBones: skin.texture }
231
286
  // Culled by its joints' boxes, so the box follows the animation.
232
- mesh._cullJoints = data.skins[part.skin!]!.joints.map((n) => groups[n]!)
287
+ mesh._cullJoints = skin.joints.slice()
233
288
  add(model, mesh)
234
289
  } else {
235
290
  let node = groups[part.node]
@@ -241,11 +296,32 @@ export function createModel(data: ModelData, opts: ModelOptions = {}): Model {
241
296
  model.materials = materials
242
297
  model.clips = data.clips
243
298
  model.bounds = data.bounds
299
+ let disposed = false
244
300
  model.dispose = () => {
301
+ if (disposed) return
302
+ // A worn piece comes off first (its rows sit on the body's joints);
303
+ // a body takes its pieces with it (their skins read joints that are
304
+ // about to go).
305
+ if (model._body !== null) unbindSkeleton(model)
306
+ for (let worn of model._worn.slice()) worn.piece.dispose()
245
307
  if (model.parent !== null) remove(model)
308
+ // A destroyed model still animating out (its root waits for every
309
+ // part's exit) keeps its textures until it is gone: destroy then
310
+ // dispose is the unmount shape, and a corpse must not sample a
311
+ // destroyed texture.
312
+ if (afterFree(model, () => model.dispose())) return
313
+ disposed = true
246
314
  for (let part of model.parts) disposeGeometry(part.mesh.geometry)
247
315
  for (let id of textures) destroyTexture(id)
248
316
  textures.length = 0
317
+ // The nodes' weights textures (the parts' entries are off the scene
318
+ // with the remove above).
319
+ for (let n of model.nodes) {
320
+ if (n.node._morph !== null) {
321
+ destroyTexture(n.node._morph.texture)
322
+ n.node._morph = null
323
+ }
324
+ }
249
325
  // Core clips a mixer registered; their players drop at the next
250
326
  // advance (the leave above already killed the target nodes).
251
327
  for (let clip of model.clips) {
@@ -259,9 +335,27 @@ export function createModel(data: ModelData, opts: ModelOptions = {}): Model {
259
335
  }
260
336
  }
261
337
  }
338
+ if (opts.autoFree !== false && getOwner()) onCleanup(() => model.dispose())
262
339
  return model
263
340
  }
264
341
 
342
+ // Build under the reactive scope a loader was called from, captured
343
+ // before its first await (an await leaves the reactive context; whatever
344
+ // scope happens to be current when the bytes arrive is not the caller's).
345
+ // autoFree then frees the model with that scope. A scope gone by the time
346
+ // the load settles - a component unmounted mid-load - can own nothing
347
+ // any more, so the model is freed on arrival and the promise resolves
348
+ // with it disposed: the caller nobody listens to gets a valid, inert
349
+ // object rather than a rejection or a leak.
350
+ function buildOwned(owner: Owner | null, opts: ModelOptions | undefined, build: () => Model): Model {
351
+ if (opts?.autoFree !== false && owner !== null && isDisposed(owner)) {
352
+ let model = build()
353
+ model.dispose()
354
+ return model
355
+ }
356
+ return runWithOwner(owner, build)
357
+ }
358
+
265
359
  /**
266
360
  * Read a .glb or .gltf (with its external .bin and image files, resolved
267
361
  * next to it) and build the model. The parse runs on the runtime - fine
@@ -269,6 +363,7 @@ export function createModel(data: ModelData, opts: ModelOptions = {}): Model {
269
363
  * `srt tool 3d/model` and use loadModel.
270
364
  */
271
365
  export async function loadGltf(path: string, opts?: ModelOptions): Promise<Model> {
366
+ let owner = getOwner()
272
367
  let bytes = await file(path).bytes()
273
368
  // A .glb usually embeds everything, but external uris are legal there
274
369
  // too (some exporters keep images as files), so both containers get
@@ -278,11 +373,13 @@ export async function loadGltf(path: string, opts?: ModelOptions): Promise<Model
278
373
  for (let uri of gltfExternalUris(bytes)) {
279
374
  if (!files.has(uri)) files.set(uri, await file(dir + decodeURIComponent(uri)).bytes())
280
375
  }
281
- return createModel(parseGltf(bytes, (uri) => files.get(uri)!), opts)
376
+ return buildOwned(owner, opts, () => createModel(parseGltf(bytes, (uri) => files.get(uri)!), opts))
282
377
  }
283
378
 
284
379
  /** Read a baked .srtm model (`srt tool 3d/model`) and build it: no parsing,
285
380
  * the geometry views the file's bytes directly. */
286
381
  export async function loadModel(path: string, opts?: ModelOptions): Promise<Model> {
287
- return createModel(decodeModel(await file(path).bytes()), opts)
382
+ let owner = getOwner()
383
+ let bytes = await file(path).bytes()
384
+ return buildOwned(owner, opts, () => createModel(decodeModel(bytes), opts))
288
385
  }
package/src/motion.ts ADDED
@@ -0,0 +1,17 @@
1
+ // The ease every camera control's self-driven motion runs on: a glide to
2
+ // a commanded pose (glideTo, fit) and the damping of an unbracketed input
3
+ // delta (a wheel notch) both close the gap to a goal pose by the same
4
+ // exponential fraction per frame, so the two controls here and the 2d
5
+ // camera read the same. Frame-rate independent: the step is
6
+ // 1 - e^(-rate * dt), never a fixed fraction.
7
+
8
+ // E-foldings per second toward the goal: high enough that a wheel notch
9
+ // reads as one push, low enough to look smooth (the 2d camera's rate).
10
+ export const GLIDE_EASE = 9
11
+ // A motion lands - snaps to its goal and stops - once this fraction of
12
+ // its initial gap remains: under a pixel on any pose component a glide
13
+ // moves through at ordinary distances.
14
+ export const GLIDE_EPSILON = 0.001
15
+
16
+ /** The fraction of the remaining gap an ease at `rate` closes over dt. */
17
+ export let easeStep = (rate: number, dt: number): number => 1 - Math.exp(-rate * dt)