@solidrt/3d 0.0.58 → 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 (70) hide show
  1. package/AGENTS.md +734 -264
  2. package/README.md +4 -2
  3. package/examples/README.md +50 -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 +24 -2
  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 +172 -39
  51. package/src/geometry.ts +1573 -239
  52. package/src/glsl.ts +398 -113
  53. package/src/gltf.ts +363 -141
  54. package/src/index.ts +31 -21
  55. package/src/light.ts +7 -8
  56. package/src/lod.ts +148 -0
  57. package/src/material.ts +316 -202
  58. package/src/mesh.ts +490 -256
  59. package/src/mixer.ts +27 -14
  60. package/src/model-file.ts +54 -15
  61. package/src/model.ts +86 -21
  62. package/src/motion.ts +17 -0
  63. package/src/node.ts +421 -22
  64. package/src/orbit.ts +378 -116
  65. package/src/profile.ts +4 -25
  66. package/src/resolve.ts +181 -0
  67. package/src/scene-shadows.ts +94 -93
  68. package/src/scene.ts +605 -185
  69. package/src/sweep.ts +27 -8
  70. 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,16 +11,18 @@
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"
@@ -33,7 +35,7 @@ import type { WornPiece } from "./skeleton.ts"
33
35
  * the device by the runtime. */
34
36
  const MODEL_ANISOTROPY = 4
35
37
 
36
- /** A glTF material's uploaded textures, by lit()/standard() option name;
38
+ /** A glTF material's uploaded textures, by phong()/standard() option name;
37
39
  * null where the material has none. */
38
40
  export type ModelMaps = {
39
41
  map: TextureId | null
@@ -49,18 +51,26 @@ export type ModelOptions = {
49
51
  /** The material for each glTF material (default: `standard` with its
50
52
  * color, maps, normal scale, metalness/roughness, emissive and
51
53
  * transparency - the glTF material model; a scene with no `environment`
52
- * 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
53
55
  * the Blinn-Phong look). `maps` holds the uploaded textures by
54
- * lit()/standard() option name. Called once per material -
55
- * or once per (material, skinned) combination when skinned parts share
56
- * a material with static ones - and shared by every part using it.
57
- * `skinned` is true when the material must skin (pass it through to
58
- * `lit`/`unlit`, or read aJoints/aWeights + uBones yourself).
59
- * `data.materials` is in file order, so the calls arrive in file order
60
- * too. */
61
- 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
62
67
  /** Debug name for the textures. */
63
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
64
74
  }
65
75
 
66
76
  /** A model in the scene: a Group carrying the file's node hierarchy as
@@ -134,7 +144,7 @@ export function createModel(data: ModelData, opts: ModelOptions = {}): Model {
134
144
  label: label ? label + "-image" + i : undefined,
135
145
  })
136
146
  })
137
- 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 => {
138
148
  // An emissive factor of zero is emission OFF (the glTF product rule:
139
149
  // factor times texture), so the map is skipped too - no sampler for
140
150
  // a term that cannot show.
@@ -155,17 +165,20 @@ export function createModel(data: ModelData, opts: ModelOptions = {}): Model {
155
165
  cull: m.doubleSided ? "none" : "back",
156
166
  alphaTest: m.alphaMode === "MASK" ? m.alphaCutoff : undefined,
157
167
  skinned: skinned || undefined,
168
+ vertexColors: vertexColors || undefined,
169
+ morph: morphed || undefined,
158
170
  })
159
171
  })
160
172
  let slot = (index: number | null): TextureId | null => (index === null ? null : textures[index]!)
161
173
  // One instance per glTF material as today, plus a skinned variant per
162
- // material the skinned parts bring (a material shared by a static and
163
- // 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).
164
177
  let variants = new Map<string, Material>()
165
- let materialFor = (index: number, skinned: boolean): Material => {
178
+ let materialFor = (index: number, skinned: boolean, vertexColors: boolean, morphed: boolean): Material => {
166
179
  let m = data.materials[index]
167
180
  if (m === undefined) throw new Error("createModel: a part names a missing material " + index)
168
- let key = index + (skinned ? "|skinned" : "")
181
+ let key = index + (skinned ? "|skinned" : "") + (vertexColors ? "|colored" : "") + (morphed ? "|morphed" : "")
169
182
  let made = variants.get(key)
170
183
  if (made === undefined) {
171
184
  made = make(
@@ -178,12 +191,14 @@ export function createModel(data: ModelData, opts: ModelOptions = {}): Model {
178
191
  roughnessMap: slot(m.metalnessRoughnessMap),
179
192
  },
180
193
  skinned,
194
+ vertexColors,
195
+ morphed,
181
196
  )
182
197
  variants.set(key, made)
183
198
  }
184
199
  return made
185
200
  }
186
- let materials = data.materials.map((_, i) => materialFor(i, false))
201
+ let materials = data.materials.map((_, i) => materialFor(i, false, false, false))
187
202
 
188
203
  let model = createGroup() as Model
189
204
  model._skins = []
@@ -199,6 +214,16 @@ export function createModel(data: ModelData, opts: ModelOptions = {}): Model {
199
214
  })
200
215
  data.nodes.forEach((n, i) => add(n.parent === null ? model : groups[n.parent]!, groups[i]!))
201
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
+ })
202
227
  // Each skin's palette lives in an rgba32f texture, 4 texels wide, one
203
228
  // row per joint (the four columns of that joint's mat4), sized to the
204
229
  // RIG: rig size is bounded by texture height (>= 2048 everywhere), not
@@ -245,8 +270,11 @@ export function createModel(data: ModelData, opts: ModelOptions = {}): Model {
245
270
  refreshJointBounds(model)
246
271
  model.parts = data.parts.map((part) => {
247
272
  let skinned = part.skin !== null
248
- 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)
249
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
250
278
  if (skinned) {
251
279
  // A skinned part's vertices are model-space bind pose and the skin
252
280
  // matrices place them, so its mesh hangs off the model root (the
@@ -268,16 +296,32 @@ export function createModel(data: ModelData, opts: ModelOptions = {}): Model {
268
296
  model.materials = materials
269
297
  model.clips = data.clips
270
298
  model.bounds = data.bounds
299
+ let disposed = false
271
300
  model.dispose = () => {
301
+ if (disposed) return
272
302
  // A worn piece comes off first (its rows sit on the body's joints);
273
303
  // a body takes its pieces with it (their skins read joints that are
274
304
  // about to go).
275
305
  if (model._body !== null) unbindSkeleton(model)
276
306
  for (let worn of model._worn.slice()) worn.piece.dispose()
277
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
278
314
  for (let part of model.parts) disposeGeometry(part.mesh.geometry)
279
315
  for (let id of textures) destroyTexture(id)
280
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
+ }
281
325
  // Core clips a mixer registered; their players drop at the next
282
326
  // advance (the leave above already killed the target nodes).
283
327
  for (let clip of model.clips) {
@@ -291,9 +335,27 @@ export function createModel(data: ModelData, opts: ModelOptions = {}): Model {
291
335
  }
292
336
  }
293
337
  }
338
+ if (opts.autoFree !== false && getOwner()) onCleanup(() => model.dispose())
294
339
  return model
295
340
  }
296
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
+
297
359
  /**
298
360
  * Read a .glb or .gltf (with its external .bin and image files, resolved
299
361
  * next to it) and build the model. The parse runs on the runtime - fine
@@ -301,6 +363,7 @@ export function createModel(data: ModelData, opts: ModelOptions = {}): Model {
301
363
  * `srt tool 3d/model` and use loadModel.
302
364
  */
303
365
  export async function loadGltf(path: string, opts?: ModelOptions): Promise<Model> {
366
+ let owner = getOwner()
304
367
  let bytes = await file(path).bytes()
305
368
  // A .glb usually embeds everything, but external uris are legal there
306
369
  // too (some exporters keep images as files), so both containers get
@@ -310,11 +373,13 @@ export async function loadGltf(path: string, opts?: ModelOptions): Promise<Model
310
373
  for (let uri of gltfExternalUris(bytes)) {
311
374
  if (!files.has(uri)) files.set(uri, await file(dir + decodeURIComponent(uri)).bytes())
312
375
  }
313
- return createModel(parseGltf(bytes, (uri) => files.get(uri)!), opts)
376
+ return buildOwned(owner, opts, () => createModel(parseGltf(bytes, (uri) => files.get(uri)!), opts))
314
377
  }
315
378
 
316
379
  /** Read a baked .srtm model (`srt tool 3d/model`) and build it: no parsing,
317
380
  * the geometry views the file's bytes directly. */
318
381
  export async function loadModel(path: string, opts?: ModelOptions): Promise<Model> {
319
- 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))
320
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)