@solidrt/3d 0.0.55 → 0.0.57
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1206 -224
- package/README.md +16 -7
- package/examples/README.md +88 -9
- package/examples/aim.tsx +2 -3
- package/examples/cascades.tsx +1 -4
- package/examples/collision.tsx +263 -0
- package/examples/first-person.tsx +129 -0
- package/examples/fleet.tsx +200 -0
- package/examples/fog.tsx +2 -3
- package/examples/instanced.tsx +72 -32
- package/examples/lamps.tsx +92 -0
- package/examples/lit.tsx +3 -5
- package/examples/materials.tsx +150 -0
- package/examples/model-load.tsx +95 -0
- package/examples/model.glb +0 -0
- package/examples/model.tsx +35 -9
- package/examples/pick.tsx +145 -37
- package/examples/probe.tsx +107 -0
- package/examples/scene-background.tsx +2 -4
- package/examples/scene-basic.tsx +2 -4
- package/examples/scene-views.tsx +68 -48
- package/examples/shadows.tsx +2 -4
- package/examples/sky-lit.tsx +92 -0
- package/examples/skybox.tsx +134 -0
- package/examples/sprites.tsx +4 -6
- package/examples/standard.tsx +138 -0
- package/examples/sweep-paths.tsx +2 -4
- package/package.json +3 -3
- package/src/camera.ts +148 -0
- package/src/clip.ts +96 -0
- package/src/collision.ts +20 -0
- package/src/color.ts +32 -0
- package/src/components/context.tsx +35 -0
- package/src/components/directional-light.tsx +48 -0
- package/src/components/first-person-camera.tsx +72 -0
- package/src/components/group.tsx +18 -0
- package/src/components/hemisphere-light.tsx +24 -0
- package/src/components/index.ts +46 -0
- package/src/components/instanced-mesh.tsx +105 -0
- package/src/components/mesh.tsx +92 -0
- package/src/components/node-props.ts +90 -0
- package/src/components/orbit-camera.tsx +75 -0
- package/src/components/perspective-camera.tsx +32 -0
- package/src/components/point-light.tsx +52 -0
- package/src/components/record-mesh.tsx +68 -0
- package/src/components/scene.tsx +281 -0
- package/src/components/spot-light.tsx +65 -0
- package/src/components/sprite.tsx +38 -0
- package/src/components/view3d.tsx +152 -0
- package/src/environment-bake.ts +492 -0
- package/src/environment.ts +325 -0
- package/src/first-person.ts +258 -0
- package/src/geometry.ts +6 -2
- package/src/glsl.ts +1343 -82
- package/src/gltf.ts +432 -112
- package/src/index.ts +61 -9
- package/src/input.ts +70 -0
- package/src/light.ts +383 -0
- package/src/material.ts +667 -232
- package/src/math.ts +110 -0
- package/src/mesh.ts +728 -0
- package/src/mixer.ts +476 -0
- package/src/model-file.ts +80 -15
- package/src/model.ts +204 -35
- package/src/node.ts +579 -0
- package/src/orbit.ts +292 -212
- package/src/scene-pointer.ts +294 -0
- package/src/scene-shadows.ts +471 -0
- package/src/scene.ts +1435 -1576
- package/tools/environment.ts +69 -0
- package/tools/model.ts +4 -3
- package/src/components.tsx +0 -420
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 {
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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
|
|
65
|
-
vertexCount: g.vertices.length /
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
}
|