@solidrt/3d 0.0.55 → 0.0.56

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 (54) hide show
  1. package/AGENTS.md +1083 -183
  2. package/README.md +15 -7
  3. package/examples/README.md +79 -5
  4. package/examples/aim.tsx +2 -3
  5. package/examples/cascades.tsx +1 -4
  6. package/examples/collision.tsx +250 -0
  7. package/examples/first-person.tsx +119 -0
  8. package/examples/fleet.tsx +194 -0
  9. package/examples/fog.tsx +2 -3
  10. package/examples/instanced.tsx +72 -32
  11. package/examples/lamps.tsx +92 -0
  12. package/examples/lit.tsx +3 -5
  13. package/examples/materials.tsx +150 -0
  14. package/examples/model-load.tsx +95 -0
  15. package/examples/model.glb +0 -0
  16. package/examples/model.tsx +32 -6
  17. package/examples/pick.tsx +2 -4
  18. package/examples/probe.tsx +101 -0
  19. package/examples/scene-background.tsx +2 -4
  20. package/examples/scene-basic.tsx +2 -4
  21. package/examples/scene-views.tsx +32 -3
  22. package/examples/shadows.tsx +2 -4
  23. package/examples/sky-lit.tsx +86 -0
  24. package/examples/skybox.tsx +128 -0
  25. package/examples/sprites.tsx +4 -6
  26. package/examples/standard.tsx +132 -0
  27. package/examples/sweep-paths.tsx +2 -4
  28. package/package.json +3 -3
  29. package/src/camera.ts +148 -0
  30. package/src/clip.ts +96 -0
  31. package/src/collision.ts +199 -0
  32. package/src/color.ts +32 -0
  33. package/src/components.tsx +763 -82
  34. package/src/environment-bake.ts +492 -0
  35. package/src/environment.ts +325 -0
  36. package/src/first-person.ts +306 -0
  37. package/src/geometry.ts +6 -2
  38. package/src/glsl.ts +1343 -82
  39. package/src/gltf.ts +432 -112
  40. package/src/index.ts +57 -9
  41. package/src/light.ts +383 -0
  42. package/src/material.ts +667 -232
  43. package/src/math.ts +110 -0
  44. package/src/mesh.ts +728 -0
  45. package/src/mixer.ts +476 -0
  46. package/src/model-file.ts +80 -15
  47. package/src/model.ts +204 -35
  48. package/src/node.ts +506 -0
  49. package/src/orbit.ts +97 -48
  50. package/src/scene-pointer.ts +166 -0
  51. package/src/scene-shadows.ts +471 -0
  52. package/src/scene.ts +1242 -1557
  53. package/tools/environment.ts +69 -0
  54. package/tools/model.ts +4 -3
package/src/mixer.ts ADDED
@@ -0,0 +1,476 @@
1
+ // Clip playback for models: `createMixer(model)` plays the model's baked
2
+ // animation clips (`model.clips`) through the spatial core's clip
3
+ // players. Policy lives here at O(changes) - `play(name, { fadeMs })`
4
+ // crossfades by creating one player and writing fades on the others (the
5
+ // idle/walk/run/attack shape) - while sampling, blending and the TRS
6
+ // writes are native, once per frame, BEFORE the frame's JS. So playback
7
+ // needs no frame loop at all (there is no update() to call), and your
8
+ // onFrame is the post-animation hook: it reads freshly posed joints
9
+ // (getTransform) and may overwrite them (root-motion strips, skeleton
10
+ // copies), last write wins, palettes and uModel following at the frame's
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().
15
+ //
16
+ // Root motion: a locomotion clip translates its root, which is what a game
17
+ // consumes to move the character and what sends a viewer's model through
18
+ // the lens. Every animation system grows the switch (Unity's
19
+ // applyRootMotion, Godot's root_motion_track); here it is `inPlace`, per
20
+ // play, automatic by default: a clip whose root position track ends
21
+ // somewhere else than it began (NET DRIFT, not how far it wanders - a
22
+ // taunt roams and returns and must not be pinned or the slide moves into
23
+ // the feet) plays in place. The strip is baked, not hooked: the in-place
24
+ // form of a clip is a second core clip whose root x/z sit at the first
25
+ // key and whose y is rebased onto the root's rest height (the bob
26
+ // survives), so it costs nothing per frame and blends like any clip. A
27
+ // mixer `rootHeight` rebases every clip the same way without the pin,
28
+ // for exports whose clips ride at another baseline than the rest pose.
29
+ //
30
+ // A game wants the opposite of a viewer: the travel kept, but moved from
31
+ // the root joint onto the character. `rootMotion` does that through the
32
+ // core: every clip plays fully pinned (the root held at its first key on
33
+ // all three axes) while the core samples the AUTHORED root track at the
34
+ // player's time and hands the per-advance delta on - "apply" adds it to
35
+ // the model node itself (Unity's applyRootMotion), "report" leaves it in
36
+ // `rootDelta()` for a controller to spend (Godot's
37
+ // get_root_motion_position, the collision tier's move-and-slide).
38
+ //
39
+ // `sampleChannel` (./clip.ts) stays the pure JS sampling core (checks,
40
+ // custom drivers, bake-side resampling) - the native evaluator implements
41
+ // the same glTF contract.
42
+
43
+ import * as spatial from "flux:spatial"
44
+ import type { NodeId } from "flux:spatial"
45
+ import { on } from "srt:events"
46
+ import type { ModelChannel, ModelClip } from "./gltf.ts"
47
+ import { sampleChannel } from "./clip.ts"
48
+ import type { Vec3 } from "./math.ts"
49
+ import type { Model } from "./model.ts"
50
+
51
+ export type MixerPlayOptions = {
52
+ /** Repeat until something else plays. Defaults to true; false plays
53
+ * once, holds the final pose and fires onFinish. */
54
+ loop?: boolean
55
+ /** Playback rate, 1 = as authored. */
56
+ speed?: number
57
+ /** Crossfade: this clip fades in and every other active clip fades out
58
+ * over this window. 0 (the default) switches instantly. */
59
+ fadeMs?: number
60
+ /** Strip the root's travel so the model stays put (its x/z pinned at the
61
+ * clip's first key, its height rebased onto the rest pose). Unset picks
62
+ * per clip: a root track with net drift plays in place, one without
63
+ * plays as authored. */
64
+ inPlace?: boolean
65
+ }
66
+
67
+ export type RootMotionMode = "apply" | "report"
68
+
69
+ export type MixerOptions = {
70
+ /** Rebase EVERY clip's root height so its first key lands here, pinned
71
+ * or not - for an export whose clips ride at a different baseline than
72
+ * its rest pose. Unset, only in-place clips rebase, onto the root's
73
+ * rest position. */
74
+ rootHeight?: number
75
+ /** Keep the clips' root travel and move it off the root joint: every
76
+ * clip plays in place (its root held, its yaw held) and the authored
77
+ * root tracks' per-frame delta goes to the model - "apply" moves and
78
+ * turns the model node, "report" accumulates it in `rootDelta()` for
79
+ * the app to apply. The object form adds `up` (the root's parent-space
80
+ * up axis, default +y) and `vertical: "pose"` to keep the height in
81
+ * the pose (only the horizontal travel is delivered - for a controller
82
+ * that owns gravity; Unity's bake-into-pose Y). Unset, travelling
83
+ * clips are simply pinned (`inPlace`); `inPlace` is ignored while this
84
+ * is set. */
85
+ rootMotion?: RootMotionMode | { mode: RootMotionMode; up?: Vec3; vertical?: "travel" | "pose" }
86
+ }
87
+
88
+ export type Mixer = {
89
+ /** (Re)start a clip by name from its beginning; an unknown name throws
90
+ * listing what the model has. The model must be in a scene. */
91
+ play(name: string, opts?: MixerPlayOptions): void
92
+ /** Fade every active clip out (instantly with no fadeMs); the nodes
93
+ * keep the last written pose. */
94
+ stop(opts?: { fadeMs?: number }): void
95
+ /** Names of the clips currently playing or fading in. */
96
+ playing(): string[]
97
+ /** Whether a clip's root track drifts (what `inPlace` defaults to). */
98
+ travels(name: string): boolean
99
+ /** The root travel accumulated since the last call (zero without
100
+ * `rootMotion`): the translation in the model's local frame (its
101
+ * current facing) and the yaw in radians (a turn about the up axis).
102
+ * A controller reads it once per frame and moves the character by it;
103
+ * under "apply" it is what the core already applied to the model node. */
104
+ rootDelta(): { position: [number, number, number]; yaw: number }
105
+ /** Fired once when a `loop: false` clip reaches its end; the pose
106
+ * holds until another play(). A plain field, like node pointer
107
+ * handlers. */
108
+ onFinish?: (name: string) => void
109
+ /** The model's clip names, in file order. */
110
+ clips: string[]
111
+ }
112
+
113
+ // The packed-clip codes of flux:spatial's createClip layout.
114
+ const PATH_CODE = { position: 0, rotation: 1, scale: 2 } as const
115
+ const INTERP_CODE = { step: 0, linear: 1, cubic: 2 } as const
116
+
117
+ // Net root x/z drift, as a fraction of the model's rest height, above
118
+ // which a clip counts as travelling. Measured on a stock rig: run cycles
119
+ // net 3-4 heights, every idle and taunt under 0.03 - the two sit far apart
120
+ // on either side of this.
121
+ const ROOT_DRIFT_RATIO = 0.25
122
+ // Keys per second a cubic rotation track is resampled to before its yaw
123
+ // is held: a per-key twist is not a constant the Hermite tangents could
124
+ // carry, so the curve is flattened to linear keys at this rate first.
125
+ const YAW_HOLD_RESAMPLE_HZ = 60
126
+ // The default up axis: the turn axis and the height axis of a root.
127
+ const UP_Y: Vec3 = [0, 1, 0]
128
+
129
+ // Player end reports ("spatialClipEnd", emitted before each frame's JS)
130
+ // routed back to the owning mixer's action.
131
+ const PLAYER_ENDS = new Map<number, (reason: string) => void>()
132
+ on("spatialClipEnd", (event: { player: number; reason: string }) => {
133
+ PLAYER_ENDS.get(event.player)?.(event.reason)
134
+ })
135
+ // Root-motion deltas ("spatialRootMotion", one per bound player per
136
+ // advance) routed to the owning mixer's accumulator.
137
+ const PLAYER_ROOTS = new Map<number, (x: number, y: number, z: number, yaw: number) => void>()
138
+ on("spatialRootMotion", (event: { player: number; x: number; y: number; z: number; yaw: number }) => {
139
+ PLAYER_ROOTS.get(event.player)?.(event.x, event.y, event.z, event.yaw)
140
+ })
141
+
142
+ /** Pack channels into the core's createClip layout. */
143
+ function registerClip(duration: number, channels: ModelChannel[]): number {
144
+ let meta = new Uint32Array(channels.length * 4)
145
+ let timeCount = 0
146
+ let valueCount = 0
147
+ channels.forEach((c, i) => {
148
+ // Target slot = channel index: the player's target table below is
149
+ // 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
+ timeCount += c.times.length
155
+ valueCount += c.values.length
156
+ })
157
+ let times = new Float32Array(timeCount)
158
+ let values = new Float32Array(valueCount)
159
+ let t = 0
160
+ let v = 0
161
+ for (let c of channels) {
162
+ times.set(c.times, t)
163
+ t += c.times.length
164
+ values.set(c.values, v)
165
+ v += c.values.length
166
+ }
167
+ return spatial.createClip(duration, meta, times, values)
168
+ }
169
+
170
+ /** Register the clip's baked channels with the core once; the id is
171
+ * cached on the clip (shared by every mixer) and freed by model.dispose. */
172
+ function coreClip(clip: ModelClip): number {
173
+ if (clip._core !== undefined) return clip._core
174
+ clip._core = registerClip(clip.duration, clip.channels)
175
+ return clip._core
176
+ }
177
+
178
+ /** The clip's root position channel: the one on the topmost node any
179
+ * position channel targets (the table is pre-order, so lowest index is
180
+ * topmost), or null for a clip that moves no translation at all. */
181
+ function rootChannel(clip: ModelClip): ModelChannel | null {
182
+ let root: ModelChannel | null = null
183
+ for (let c of clip.channels) {
184
+ if (c.path === "position" && (root === null || c.node < root.node)) root = c
185
+ }
186
+ return root
187
+ }
188
+
189
+ /** The root's rotation channel, if the clip turns the root at all. */
190
+ function rootRotation(clip: ModelClip, root: ModelChannel): ModelChannel | null {
191
+ return clip.channels.find((c) => c.node === root.node && c.path === "rotation") ?? null
192
+ }
193
+
194
+ /** A quaternion's twist about the unit axis `up`, in radians: the
195
+ * swing-twist split, exact under any lean. */
196
+ function twistOf(x: number, y: number, z: number, w: number, up: Vec3): number {
197
+ let along = x * up[0] + y * up[1] + z * up[2]
198
+ let a = 2 * Math.atan2(along, w)
199
+ if (a > Math.PI) a -= 2 * Math.PI
200
+ else if (a < -Math.PI) a += 2 * Math.PI
201
+ return a
202
+ }
203
+
204
+ /** The rotation of `angle` radians about the unit axis `up`. */
205
+ function axisQuat(up: Vec3, angle: number): [number, number, number, number] {
206
+ let s = Math.sin(angle / 2)
207
+ return [up[0] * s, up[1] * s, up[2] * s, Math.cos(angle / 2)]
208
+ }
209
+
210
+ /** A cubic channel flattened to linear keys at YAW_HOLD_RESAMPLE_HZ (a
211
+ * linear or step channel is returned as is). */
212
+ function linearized(c: ModelChannel, duration: number): ModelChannel {
213
+ if (c.interpolation !== "cubic") return c
214
+ let elements = c.path === "rotation" ? 4 : 3
215
+ let keys = Math.max(2, Math.ceil(duration * YAW_HOLD_RESAMPLE_HZ) + 1)
216
+ let times = new Float32Array(keys)
217
+ let values = new Float32Array(keys * elements)
218
+ let out: number[] = []
219
+ for (let k = 0; k < keys; k++) {
220
+ let t = (k / (keys - 1)) * duration
221
+ times[k] = t
222
+ sampleChannel(c, t, out)
223
+ for (let e = 0; e < elements; e++) values[k * elements + e] = out[e]!
224
+ }
225
+ return { ...c, interpolation: "linear", times, values }
226
+ }
227
+
228
+ /** The rotation channel with its twist about `up` held at the first
229
+ * key's: each key is pre-multiplied by the twist that undoes its own
230
+ * turn, so the lean and pitch of the pose survive and only the turn is
231
+ * gone. Cubic tracks are linearized first. */
232
+ function holdYaw(rotation: ModelChannel, duration: number, up: Vec3): ModelChannel {
233
+ let c = linearized(rotation, duration)
234
+ let src = c.values
235
+ let values = Float32Array.from(src)
236
+ let yaw0 = twistOf(src[0]!, src[1]!, src[2]!, src[3]!, up)
237
+ for (let k = 0; k < values.length; k += 4) {
238
+ let x = src[k]!, y = src[k + 1]!, z = src[k + 2]!, w = src[k + 3]!
239
+ let [ax, ay, az, aw] = axisQuat(up, yaw0 - twistOf(x, y, z, w, up))
240
+ // a * q
241
+ values[k] = aw * x + ax * w + ay * z - az * y
242
+ values[k + 1] = aw * y - ax * z + ay * w + az * x
243
+ values[k + 2] = aw * z + ax * y - ay * x + az * w
244
+ values[k + 3] = aw * w - ax * x - ay * y - az * z
245
+ }
246
+ return { ...c, values }
247
+ }
248
+
249
+ // Floats per key and the value's offset within a key: a key's value sits
250
+ // at [in, value, out] for cubic keys; rotations are four floats.
251
+ function keyLayout(c: ModelChannel): { stride: number; mid: number } {
252
+ let elements = c.path === "rotation" ? 4 : 3
253
+ let cubic = c.interpolation === "cubic"
254
+ return { stride: cubic ? elements * 3 : elements, mid: cubic ? elements : 0 }
255
+ }
256
+
257
+ /** Net horizontal travel of the root track (last key minus first, the
258
+ * component along `up` removed). */
259
+ function rootDrift(c: ModelChannel, up: Vec3): number {
260
+ let { stride, mid } = keyLayout(c)
261
+ let v = c.values
262
+ let last = v.length - stride + mid
263
+ let d = [v[last]! - v[mid]!, v[last + 1]! - v[mid + 1]!, v[last + 2]! - v[mid + 2]!]
264
+ let rise = d[0]! * up[0] + d[1]! * up[1] + d[2]! * up[2]
265
+ return Math.hypot(d[0]! - rise * up[0], d[1]! - rise * up[1], d[2]! - rise * up[2])
266
+ }
267
+
268
+ // How a root variant treats the root's travel: "free" keeps it (height
269
+ // rebase only), "horizontal" holds the travel across `up` at the first
270
+ // key (the bob survives), "all" holds every axis; `yaw` holds the root's
271
+ // twist about `up` too (the rootMotion forms: the travel goes to the
272
+ // model).
273
+ type RootPin = { axes: "free" | "horizontal" | "all"; yaw: boolean }
274
+
275
+ /** A root-corrected form of the clip: the root position channel with its
276
+ * height rebased so the first key lands on `height` along `up` and, per
277
+ * `pin`, its travel held at the first key (cubic tangents projected the
278
+ * same way); the root rotation channel with its yaw held when asked;
279
+ * every other channel shared as is. Registered once per (pin, height,
280
+ * up) and cached on the clip. */
281
+ function rootVariant(clip: ModelClip, root: ModelChannel, pin: RootPin, height: number, up: Vec3): number {
282
+ let key = pin.axes + (pin.yaw ? "+yaw" : "") + ":" + height + ":" + up.join(",")
283
+ let cached = clip._coreVariants?.get(key)
284
+ if (cached !== undefined) return cached
285
+ let { stride, mid } = keyLayout(root)
286
+ let src = root.values
287
+ let values = Float32Array.from(src)
288
+ let first = [src[mid]!, src[mid + 1]!, src[mid + 2]!]
289
+ let firstRise = first[0]! * up[0] + first[1]! * up[1] + first[2]! * up[2]
290
+ let shift = height - firstRise
291
+ // The part of a travel vector a pin keeps: all of it, only its rise
292
+ // along up, or none.
293
+ let kept = (d: number[]): number[] => {
294
+ if (pin.axes === "free") return d
295
+ if (pin.axes === "all") return [0, 0, 0]
296
+ let rise = d[0]! * up[0] + d[1]! * up[1] + d[2]! * up[2]
297
+ return [rise * up[0], rise * up[1], rise * up[2]]
298
+ }
299
+ for (let k = 0; k < values.length; k += stride) {
300
+ let travel = kept([src[k + mid]! - first[0]!, src[k + mid + 1]! - first[1]!, src[k + mid + 2]! - first[2]!])
301
+ for (let e = 0; e < 3; e++) values[k + mid + e] = first[e]! + travel[e]! + shift * up[e]!
302
+ if (stride === 9) {
303
+ let tin = kept([src[k]!, src[k + 1]!, src[k + 2]!])
304
+ let tout = kept([src[k + 6]!, src[k + 7]!, src[k + 8]!])
305
+ for (let e = 0; e < 3; e++) {
306
+ values[k + e] = tin[e]!
307
+ values[k + 6 + e] = tout[e]!
308
+ }
309
+ }
310
+ }
311
+ let rotation = pin.yaw ? rootRotation(clip, root) : null
312
+ let channels = clip.channels.map((c) => (c === root ? { ...c, values } : c === rotation ? holdYaw(c, clip.duration, up) : c))
313
+ let id = registerClip(clip.duration, channels)
314
+ ;(clip._coreVariants ??= new Map()).set(key, id)
315
+ return id
316
+ }
317
+
318
+ // One playing clip: the JS bookkeeping over a core player.
319
+ type Action = { name: string; player: number; fadingOut: boolean; finished: boolean }
320
+
321
+ /**
322
+ * A mixer over a model's clips. One mixer per model; make it where you
323
+ * made the model. Playback is core-driven - nothing to call per frame.
324
+ */
325
+ export function createMixer(model: Model, mixerOpts: MixerOptions = {}): Mixer {
326
+ let rm = mixerOpts.rootMotion
327
+ let rootMotion = rm === undefined ? null : typeof rm === "string" ? { mode: rm, up: UP_Y, vertical: "travel" as const } : { mode: rm.mode, up: rm.up ?? UP_Y, vertical: rm.vertical ?? ("travel" as const) }
328
+ if (rootMotion !== null) {
329
+ let len = Math.hypot(rootMotion.up[0], rootMotion.up[1], rootMotion.up[2])
330
+ if (!(len > 0)) throw new Error("createMixer: rootMotion.up must not be zero")
331
+ rootMotion.up = [rootMotion.up[0] / len, rootMotion.up[1] / len, rootMotion.up[2] / len]
332
+ }
333
+ let up = rootMotion?.up ?? UP_Y
334
+ // Travelling is a property of the clip's baked data, classified once:
335
+ // the horizontal drift against the model's extent along up.
336
+ let extent = [model.bounds[3]! - model.bounds[0]!, model.bounds[4]! - model.bounds[1]!, model.bounds[5]! - model.bounds[2]!]
337
+ let driftLimit = ROOT_DRIFT_RATIO * Math.abs(extent[0]! * up[0] + extent[1]! * up[1] + extent[2]! * up[2])
338
+ let travelling = new Map<ModelClip, boolean>()
339
+ let travels = (clip: ModelClip): boolean => {
340
+ let known = travelling.get(clip)
341
+ if (known !== undefined) return known
342
+ let root = rootChannel(clip)
343
+ let result = root !== null && rootDrift(root, up) > driftLimit
344
+ travelling.set(clip, result)
345
+ return result
346
+ }
347
+ // The core clip a play uses: as authored unless the clip is pinned
348
+ // (asked for, or it travels) or the mixer names a root height; a pin
349
+ // rebases onto the rest height when no rootHeight is given, a
350
+ // rootHeight rebases every clip whether pinned or not. Under rootMotion
351
+ // the root and its yaw are held - the travel goes to the model - with
352
+ // the height held too unless it stays in the pose.
353
+ let clipFor = (clip: ModelClip, opts: MixerPlayOptions): number => {
354
+ let root = rootChannel(clip)
355
+ if (root === null) return coreClip(clip)
356
+ let pin: RootPin =
357
+ rootMotion !== null
358
+ ? { axes: rootMotion.vertical === "pose" ? "horizontal" : "all", yaw: true }
359
+ : { axes: (opts.inPlace ?? travels(clip)) ? "horizontal" : "free", yaw: false }
360
+ if (pin.axes === "free" && mixerOpts.rootHeight === undefined) return coreClip(clip)
361
+ let entry = model.nodes[root.node]
362
+ if (entry === undefined) throw new Error("createMixer: clip '" + clip.name + "' targets a missing node " + root.node)
363
+ let rest = entry.node.position
364
+ let restHeight = rest[0] * up[0] + rest[1] * up[1] + rest[2] * up[2]
365
+ return rootVariant(clip, root, pin, mixerOpts.rootHeight ?? restHeight, up)
366
+ }
367
+ // Root motion, accumulated from the core's per-advance reports until
368
+ // the app takes it.
369
+ let rootAcc = { position: [0, 0, 0] as [number, number, number], yaw: 0 }
370
+ // Channel targets resolve at play: clips index the model's node table,
371
+ // and the arena ids are per scene-entry, so a re-added model binds
372
+ // fresh ones.
373
+ let targetsFor = (clip: ModelClip): NodeId[] =>
374
+ clip.channels.map((c) => {
375
+ let entry = model.nodes[c.node]
376
+ if (entry === undefined) throw new Error("createMixer: clip '" + clip.name + "' targets a missing node " + c.node)
377
+ let id = entry.node._node
378
+ 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
380
+ return id
381
+ })
382
+
383
+ let actions: Action[] = []
384
+ let dropAction = (action: Action) => {
385
+ PLAYER_ENDS.delete(action.player)
386
+ PLAYER_ROOTS.delete(action.player)
387
+ let i = actions.indexOf(action)
388
+ if (i >= 0) actions.splice(i, 1)
389
+ }
390
+ let start = (clip: ModelClip, opts: MixerPlayOptions, weight: number, fade: number): void => {
391
+ let player = spatial.createPlayer(clipFor(clip, opts), targetsFor(clip), opts.speed ?? 1, opts.loop ?? true, weight, fade)
392
+ let action: Action = { name: clip.name, player, fadingOut: false, finished: false }
393
+ PLAYER_ENDS.set(player, (reason) => {
394
+ if (reason === "finished") {
395
+ action.finished = true
396
+ mixer.onFinish?.(action.name)
397
+ } else {
398
+ // Faded out, or the model left the scene / was disposed.
399
+ dropAction(action)
400
+ }
401
+ })
402
+ actions.push(action)
403
+ let root = rootMotion !== null ? rootChannel(clip) : null
404
+ if (root !== null && rootMotion !== null) {
405
+ let anchor = rootMotion.mode === "apply" ? model._node : null
406
+ if (anchor === null && rootMotion.mode === "apply") throw new Error("play: the model must be in a scene before root motion can move it")
407
+ if (anchor !== null) model._native = true
408
+ let rotation = rootRotation(clip, root)
409
+ let rotationIndex = rotation === null ? undefined : clip.channels.indexOf(rotation)
410
+ spatial.bindRootMotion(player, coreClip(clip), clip.channels.indexOf(root), rotationIndex, anchor ?? undefined, {
411
+ up: rootMotion.up,
412
+ vertical: rootMotion.vertical === "travel",
413
+ })
414
+ PLAYER_ROOTS.set(player, (x, y, z, yaw) => {
415
+ rootAcc.position[0] += x
416
+ rootAcc.position[1] += y
417
+ rootAcc.position[2] += z
418
+ rootAcc.yaw += yaw
419
+ })
420
+ }
421
+ }
422
+
423
+ let mixer: Mixer = {
424
+ clips: model.clips.map((c) => c.name),
425
+ play(name, opts = {}) {
426
+ let clip = model.clips.find((c) => c.name === name)
427
+ if (clip === undefined) {
428
+ throw new Error("play: no clip '" + name + "' (the model has: " + (mixer.clips.join(", ") || "none") + ")")
429
+ }
430
+ let fadeMs = opts.fadeMs ?? 0
431
+ if (fadeMs > 0) {
432
+ for (let action of actions) {
433
+ action.fadingOut = true
434
+ spatial.setPlayer(action.player, { fade: -1000 / fadeMs })
435
+ }
436
+ start(clip, opts, 0, 1000 / fadeMs)
437
+ } else {
438
+ for (let action of [...actions]) {
439
+ spatial.destroyPlayer(action.player)
440
+ dropAction(action)
441
+ }
442
+ start(clip, opts, 1, 0)
443
+ }
444
+ },
445
+ stop(opts = {}) {
446
+ let fadeMs = opts.fadeMs ?? 0
447
+ if (fadeMs > 0) {
448
+ for (let action of actions) {
449
+ action.fadingOut = true
450
+ spatial.setPlayer(action.player, { fade: -1000 / fadeMs })
451
+ }
452
+ } else {
453
+ for (let action of [...actions]) {
454
+ spatial.destroyPlayer(action.player)
455
+ dropAction(action)
456
+ }
457
+ }
458
+ },
459
+ playing() {
460
+ return actions.filter((a) => !a.fadingOut).map((a) => a.name)
461
+ },
462
+ rootDelta() {
463
+ let out = rootAcc
464
+ rootAcc = { position: [0, 0, 0], yaw: 0 }
465
+ return out
466
+ },
467
+ travels(name) {
468
+ let clip = model.clips.find((c) => c.name === name)
469
+ if (clip === undefined) {
470
+ throw new Error("travels: no clip '" + name + "' (the model has: " + (mixer.clips.join(", ") || "none") + ")")
471
+ }
472
+ return travels(clip)
473
+ },
474
+ }
475
+ return mixer
476
+ }
package/src/model-file.ts CHANGED
@@ -9,17 +9,33 @@
9
9
  // block starts 4-aligned so Float32Array/Uint32Array views sit on it
10
10
  // directly. Images travel as their encoded files (PNG/JPEG bytes).
11
11
 
12
- import type { ModelData, ModelMaterial } from "./gltf.ts"
13
- import { layoutKey, layoutStride, STANDARD_FLOATS } from "./geometry.ts"
12
+ import type { ModelChannel, ModelData, ModelMaterial, ModelNode, ModelSkin } from "./gltf.ts"
13
+ import { layoutStride } from "./geometry.ts"
14
+ import type { VertexLayout } from "./geometry.ts"
14
15
 
15
16
  /** "SRTM" read as a little-endian u32. */
16
17
  const MAGIC = 0x4d545253
17
- const VERSION = 1
18
+ // Version 3 retained the node hierarchy - a node table (name/parent/TRS)
19
+ // in the header, parts referencing their node, vertices NODE-LOCAL
20
+ // instead of world-baked - and carries skins (joints + inverse binds,
21
+ // parts in the "skinned" layout) and animation clips (channel
22
+ // times/values as payload blocks). Version-2 files world-baked and have
23
+ // none of it, so they are rejected - re-bake with `srt tool 3d/model`.
24
+ // Version 4 adds the metalness/roughness fields to the material records
25
+ // (metalness, roughness, metalnessRoughnessMap); a version-3 file lacks
26
+ // them, so it is rejected the same way rather than read as all-metal.
27
+ const VERSION = 5
28
+
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"]
18
32
 
19
33
  type Block = { offset: number; bytes: number }
20
34
 
21
35
  type PartHeader = Block & {
22
36
  name: string
37
+ node: number
38
+ skin: number | null
23
39
  material: number
24
40
  layout: string
25
41
  vertexCount: number
@@ -27,10 +43,25 @@ type PartHeader = Block & {
27
43
  index: Block
28
44
  }
29
45
 
46
+ type SkinHeader = { joints: number[]; inverseBind: Block; jointBounds: Block }
47
+
48
+ type ChannelHeader = {
49
+ node: number
50
+ path: ModelChannel["path"]
51
+ interpolation: ModelChannel["interpolation"]
52
+ times: Block
53
+ values: Block
54
+ }
55
+
56
+ type ClipHeader = { name: string; duration: number; channels: ChannelHeader[] }
57
+
30
58
  type Header = {
59
+ nodes: ModelNode[]
31
60
  materials: ModelMaterial[]
32
61
  images: Block[]
33
62
  parts: PartHeader[]
63
+ skins: SkinHeader[]
64
+ clips: ClipHeader[]
34
65
  bounds: number[]
35
66
  }
36
67
 
@@ -52,23 +83,39 @@ export function encodeModel(data: ModelData): Uint8Array {
52
83
 
53
84
  let parts: PartHeader[] = data.parts.map((part) => {
54
85
  let g = part.geometry
55
- if (layoutStride(g.layout) !== STANDARD_FLOATS) {
56
- throw new Error("encodeModel: part '" + part.name + "' has a " + layoutKey(g.layout) + " layout; only standard is written")
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")
57
89
  }
58
90
  let vertices = push(new Uint8Array(g.vertices.buffer, g.vertices.byteOffset, g.vertices.byteLength))
59
91
  let index = push(new Uint8Array(g.indices.buffer, g.indices.byteOffset, g.indices.byteLength))
60
92
  return {
61
93
  ...vertices,
62
94
  name: part.name,
95
+ node: part.node,
96
+ skin: part.skin,
63
97
  material: part.material,
64
- layout: "standard",
65
- vertexCount: g.vertices.length / STANDARD_FLOATS,
98
+ layout,
99
+ vertexCount: g.vertices.length / layoutStride(layout),
66
100
  indexBits: g.indices instanceof Uint32Array ? 32 : 16,
67
101
  index,
68
102
  }
69
103
  })
70
104
  let images = data.images.map((image) => push(image))
71
- let header: Header = { materials: data.materials, images, parts, bounds: Array.from(data.bounds) }
105
+ let floats = (f: Float32Array): Block => push(new Uint8Array(f.buffer, f.byteOffset, f.byteLength))
106
+ let skins: SkinHeader[] = data.skins.map((skin) => ({ joints: skin.joints, inverseBind: floats(skin.inverseBind), jointBounds: floats(skin.jointBounds) }))
107
+ let clips: ClipHeader[] = data.clips.map((clip) => ({
108
+ name: clip.name,
109
+ duration: clip.duration,
110
+ channels: clip.channels.map((c) => ({
111
+ node: c.node,
112
+ path: c.path,
113
+ interpolation: c.interpolation,
114
+ times: floats(c.times),
115
+ values: floats(c.values),
116
+ })),
117
+ }))
118
+ let header: Header = { nodes: data.nodes, materials: data.materials, images, parts, skins, clips, bounds: Array.from(data.bounds) }
72
119
 
73
120
  let json = new TextEncoder().encode(JSON.stringify(header))
74
121
  let jsonPadded = json.byteLength + ((4 - (json.byteLength % 4)) % 4)
@@ -105,18 +152,36 @@ export function decodeModel(bytes: Uint8Array): ModelData {
105
152
  let payload = base + 12 + jsonLength + ((4 - (jsonLength % 4)) % 4)
106
153
 
107
154
  let parts = header.parts.map((part) => {
108
- if (part.layout !== "standard") throw new Error("decodeModel: part '" + part.name + "' has an unsupported layout " + part.layout)
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
109
157
  let indexCount = part.index.bytes / (part.indexBits / 8)
158
+ let geometry: ModelData["parts"][0]["geometry"] = {
159
+ vertices: new Float32Array(buffer, payload + part.offset, part.vertexCount * layoutStride(layout)),
160
+ indices: part.indexBits === 32 ? new Uint32Array(buffer, payload + part.index.offset, indexCount) : new Uint16Array(buffer, payload + part.index.offset, indexCount),
161
+ label: part.name,
162
+ }
163
+ if (part.layout !== "standard") geometry.layout = layout
110
164
  return {
111
165
  name: part.name,
166
+ node: part.node,
167
+ skin: part.skin,
112
168
  material: part.material,
113
- geometry: {
114
- vertices: new Float32Array(buffer, payload + part.offset, part.vertexCount * STANDARD_FLOATS),
115
- indices: part.indexBits === 32 ? new Uint32Array(buffer, payload + part.index.offset, indexCount) : new Uint16Array(buffer, payload + part.index.offset, indexCount),
116
- label: part.name,
117
- },
169
+ geometry,
118
170
  }
119
171
  })
120
172
  let images = header.images.map((block) => new Uint8Array(buffer, payload + block.offset, block.bytes))
121
- return { parts, materials: header.materials, images, bounds: Float32Array.from(header.bounds) }
173
+ let floats = (block: Block): Float32Array => new Float32Array(buffer, payload + block.offset, block.bytes / 4)
174
+ let skins: ModelSkin[] = header.skins.map((skin) => ({ joints: skin.joints, inverseBind: floats(skin.inverseBind), jointBounds: floats(skin.jointBounds) }))
175
+ let clips = header.clips.map((clip) => ({
176
+ name: clip.name,
177
+ duration: clip.duration,
178
+ channels: clip.channels.map((c) => ({
179
+ node: c.node,
180
+ path: c.path,
181
+ interpolation: c.interpolation,
182
+ times: floats(c.times),
183
+ values: floats(c.values),
184
+ })),
185
+ }))
186
+ return { nodes: header.nodes, parts, skins, clips, materials: header.materials, images, bounds: Float32Array.from(header.bounds) }
122
187
  }