lecodes-assets 0.2.0 → 0.2.1
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/README.md +561 -449
- package/dist/cli.js +5632 -521
- package/dist/index.js +5647 -526
- package/package.json +13 -3
- package/src/bvh/fromDocument.ts +136 -0
- package/src/bvh/toDocument.ts +202 -0
- package/src/cli.ts +1039 -0
- package/src/doctor/doctor.ts +241 -0
- package/src/doctor/lightmapUv.ts +452 -0
- package/src/doctor/lod.ts +177 -0
- package/src/doctor/normalizeScale.ts +220 -0
- package/src/doctor/reduceGlbBones.ts +218 -0
- package/src/doctor/shading.ts +95 -0
- package/src/doctor/transcodeKtx2.ts +124 -0
- package/src/doctor/transcodeTextures.ts +101 -0
- package/src/doctor/validateGlb.ts +264 -0
- package/src/fbx/convert.ts +327 -0
- package/src/fbx/read.ts +109 -0
- package/src/fbx/toDocument.ts +209 -0
- package/src/filmstrip.ts +263 -0
- package/src/index.ts +17 -0
- package/src/mergeSkins.ts +117 -0
- package/src/probe.ts +167 -0
- package/src/props.ts +137 -0
- package/src/retarget/automap.ts +276 -0
- package/src/retarget/ghost.ts +23 -0
- package/src/retarget/heading.ts +171 -0
- package/src/retarget/humanoid.ts +561 -0
- package/src/retarget/inPlace.ts +130 -0
- package/src/retarget/marks.ts +581 -0
- package/src/retarget/mirror.ts +0 -0
- package/src/retarget/retarget.ts +2272 -0
- package/src/retarget/rig.ts +542 -0
- package/src/retarget/unityAnim.ts +171 -0
- package/src/retarget/writeMarks.ts +132 -0
- package/src/sky/analyze.ts +136 -0
- package/src/sky/cmgen.ts +71 -0
- package/src/sky/index.ts +151 -0
- package/src/terrainPack.ts +169 -0
- package/src/textures/resolveImage.ts +202 -0
- package/src/unity/materials.ts +593 -0
- package/src/unity/png.ts +75 -0
- package/src/unity/prefabs.ts +80 -0
- package/src/unity/scene.ts +363 -0
- package/src/unity/unpack.ts +130 -0
- package/src/unreal/anim.ts +284 -0
- package/src/unreal/clipSource.ts +25 -0
- package/src/unreal/convert.ts +67 -0
- package/src/unreal/convertAsset.ts +81 -0
- package/src/unreal/index.ts +114 -0
- package/src/unreal/list.ts +113 -0
- package/src/unreal/materials.ts +291 -0
- package/src/unreal/mesh.ts +205 -0
- package/src/unreal/oodle.ts +153 -0
- package/src/unreal/package.ts +659 -0
- package/src/unreal/probe.ts +144 -0
- package/src/unreal/resolve.ts +51 -0
- package/src/unreal/rig.ts +149 -0
- package/src/unreal/sequencer.ts +193 -0
- package/src/unreal/skeleton.ts +74 -0
- package/src/unreal/texture.ts +202 -0
- package/src/unreal/toDocument.ts +360 -0
- package/src/unreal/versions.ts +100 -0
- package/src/wasm/fbxReader.js +14 -0
- package/src/wasm/lightmapUv.js +14 -0
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
// A rig — the minimum both sides of a retarget need: a bone hierarchy, its rest pose, and a way to
|
|
2
|
+
// sample an animated pose at a time. Built from either an FBX scene (the clip donor) or a glTF
|
|
3
|
+
// Document (the model being animated), so the retargeter never sees the difference.
|
|
4
|
+
//
|
|
5
|
+
// Rotations are carried as quaternions composed up the chain rather than extracted from world
|
|
6
|
+
// matrices. That is only valid while every scale in the chain is uniform — `checkUniformScale`
|
|
7
|
+
// enforces it, because a non-uniform scale makes "the world rotation of this bone" meaningless.
|
|
8
|
+
//
|
|
9
|
+
// The pose a rig is MEASURED AGAINST is its BIND pose (inverse of the skin's inverse-bind matrices),
|
|
10
|
+
// not the TRS sitting on its nodes. Those two are the same in a clean export, but an FBX carrying
|
|
11
|
+
// baked clips routinely stores whatever pose the exporter last evaluated — the polyperfect wolf's
|
|
12
|
+
// node pose is 0.37 m away from its bind pose on a 1.5 m animal. Measuring a retarget delta from
|
|
13
|
+
// that is measuring from nothing, so `bind` is what the retargeter anchors on and node TRS is only
|
|
14
|
+
// the fallback for bones no skin binds (leaf tips, helpers).
|
|
15
|
+
import type { Document } from '@gltf-transform/core'
|
|
16
|
+
import { readFbx, f32 } from '../fbx/read'
|
|
17
|
+
import { readFileSync } from 'node:fs'
|
|
18
|
+
|
|
19
|
+
export type Quat = [number, number, number, number] // x, y, z, w
|
|
20
|
+
export type Vec3 = [number, number, number]
|
|
21
|
+
|
|
22
|
+
export const qmul = (a: Quat, b: Quat): Quat => [
|
|
23
|
+
a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1],
|
|
24
|
+
a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0],
|
|
25
|
+
a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3],
|
|
26
|
+
a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2],
|
|
27
|
+
]
|
|
28
|
+
export const qinv = (q: Quat): Quat => [ -q[0], -q[1], -q[2], q[3] ] // unit quaternions only
|
|
29
|
+
const qconj = qinv
|
|
30
|
+
export const qnorm = (q: Quat): Quat => {
|
|
31
|
+
const l = Math.hypot(q[0], q[1], q[2], q[3]) || 1
|
|
32
|
+
return [ q[0] / l, q[1] / l, q[2] / l, q[3] / l ]
|
|
33
|
+
}
|
|
34
|
+
export const qslerp = (a: Quat, b: Quat, t: number): Quat => {
|
|
35
|
+
let d = a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3]
|
|
36
|
+
let e = b
|
|
37
|
+
if (d < 0) { d = -d; e = [ -b[0], -b[1], -b[2], -b[3] ] }
|
|
38
|
+
if (d > 0.9995) return qnorm([ a[0] + (e[0] - a[0]) * t, a[1] + (e[1] - a[1]) * t, a[2] + (e[2] - a[2]) * t, a[3] + (e[3] - a[3]) * t ])
|
|
39
|
+
const th = Math.acos(d), s = Math.sin(th)
|
|
40
|
+
const wa = Math.sin((1 - t) * th) / s, wb = Math.sin(t * th) / s
|
|
41
|
+
return [ a[0] * wa + e[0] * wb, a[1] * wa + e[1] * wb, a[2] * wa + e[2] * wb, a[3] * wa + e[3] * wb ]
|
|
42
|
+
}
|
|
43
|
+
/** Rotate a vector by a quaternion. */
|
|
44
|
+
const sub = (a: Vec3, b: Vec3): Vec3 => [ a[0] - b[0], a[1] - b[1], a[2] - b[2] ]
|
|
45
|
+
const add = (a: Vec3, b: Vec3): Vec3 => [ a[0] + b[0], a[1] + b[1], a[2] + b[2] ]
|
|
46
|
+
const scl = (a: Vec3, k: number): Vec3 => [ a[0] * k, a[1] * k, a[2] * k ]
|
|
47
|
+
const len = (v: Vec3): number => Math.hypot(v[0], v[1], v[2])
|
|
48
|
+
export const qrot = (q: Quat, v: Vec3): Vec3 => {
|
|
49
|
+
const [ x, y, z, w ] = q
|
|
50
|
+
const tx = 2 * (y * v[2] - z * v[1]), ty = 2 * (z * v[0] - x * v[2]), tz = 2 * (x * v[1] - y * v[0])
|
|
51
|
+
return [
|
|
52
|
+
v[0] + w * tx + y * tz - z * ty,
|
|
53
|
+
v[1] + w * ty + z * tx - x * tz,
|
|
54
|
+
v[2] + w * tz + x * ty - y * tx,
|
|
55
|
+
]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface Pose { t: Vec3[]; r: Quat[]; s: Vec3[] }
|
|
59
|
+
export interface Clip { name: string; duration: number; sample: (time: number) => Pose }
|
|
60
|
+
|
|
61
|
+
/** The pose a retarget measures against: bind where a skin defines it, node rest elsewhere. */
|
|
62
|
+
export interface BindPose {
|
|
63
|
+
worldRot: Quat[]; worldPos: Vec3[]; fromSkin: boolean[]
|
|
64
|
+
/** Uniform factor baked into the bind positions to bring the skin's bind space to node-world units (1 = none). */
|
|
65
|
+
scale: number
|
|
66
|
+
/** The same pose in local space — the base a clip is sampled onto, so bones a clip does not
|
|
67
|
+
* animate fall back to BIND rather than to whatever pose the exporter left on the nodes. */
|
|
68
|
+
local: Pose
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface Rig {
|
|
72
|
+
label: string
|
|
73
|
+
names: string[]
|
|
74
|
+
parents: number[]
|
|
75
|
+
/** Rest pose in local space, and the world rotation / position it produces. */
|
|
76
|
+
rest: Pose
|
|
77
|
+
restWorldRot: Quat[]
|
|
78
|
+
restWorldPos: Vec3[]
|
|
79
|
+
bind: BindPose
|
|
80
|
+
order: number[] // topological: every parent before its children
|
|
81
|
+
index: Map<string, number>
|
|
82
|
+
clips: Clip[]
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const buildOrder = (parents: number[]): number[] => {
|
|
86
|
+
const order: number[] = []
|
|
87
|
+
const done = new Set<number>()
|
|
88
|
+
const visit = (i: number): void => {
|
|
89
|
+
if (done.has(i)) return
|
|
90
|
+
if (parents[i] >= 0) visit(parents[i])
|
|
91
|
+
done.add(i); order.push(i)
|
|
92
|
+
}
|
|
93
|
+
parents.forEach((_, i) => visit(i))
|
|
94
|
+
return order
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** World rotation/position for a whole pose, honouring a uniform scale in the chain. */
|
|
98
|
+
export const worldOf = (rig: Rig, pose: Pose): { rot: Quat[]; pos: Vec3[]; scale: number[] } => {
|
|
99
|
+
const rot: Quat[] = new Array(rig.names.length)
|
|
100
|
+
const pos: Vec3[] = new Array(rig.names.length)
|
|
101
|
+
const scale: number[] = new Array(rig.names.length)
|
|
102
|
+
for (const i of rig.order) {
|
|
103
|
+
const p = rig.parents[i]
|
|
104
|
+
if (p < 0) { rot[i] = qnorm(pose.r[i]); pos[i] = pose.t[i]; scale[i] = pose.s[i][0]; continue }
|
|
105
|
+
rot[i] = qnorm(qmul(rot[p], pose.r[i]))
|
|
106
|
+
const off = qrot(rot[p], [ pose.t[i][0] * scale[p], pose.t[i][1] * scale[p], pose.t[i][2] * scale[p] ])
|
|
107
|
+
pos[i] = [ pos[p][0] + off[0], pos[p][1] + off[1], pos[p][2] + off[2] ]
|
|
108
|
+
scale[i] = scale[p] * pose.s[i][0]
|
|
109
|
+
}
|
|
110
|
+
return { rot, pos, scale }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export type Mat4 = number[] // column-major
|
|
114
|
+
|
|
115
|
+
const matMul = (a: Mat4, b: Mat4): Mat4 => {
|
|
116
|
+
const o = new Array(16).fill(0)
|
|
117
|
+
for (let c = 0; c < 4; c++) for (let r = 0; r < 4; r++) o[c * 4 + r] = a[r] * b[c * 4] + a[4 + r] * b[c * 4 + 1] + a[8 + r] * b[c * 4 + 2] + a[12 + r] * b[c * 4 + 3]
|
|
118
|
+
return o
|
|
119
|
+
}
|
|
120
|
+
const matCompose = (t: Vec3, q: Quat, s: Vec3): Mat4 => {
|
|
121
|
+
const [ x, y, z, w ] = q, x2 = x + x, y2 = y + y, z2 = z + z
|
|
122
|
+
const xx = x * x2, xy = x * y2, xz = x * z2, yy = y * y2, yz = y * z2, zz = z * z2, wx = w * x2, wy = w * y2, wz = w * z2
|
|
123
|
+
return [ (1 - (yy + zz)) * s[0], (xy + wz) * s[0], (xz - wy) * s[0], 0,
|
|
124
|
+
(xy - wz) * s[1], (1 - (xx + zz)) * s[1], (yz + wx) * s[1], 0,
|
|
125
|
+
(xz + wy) * s[2], (yz - wx) * s[2], (1 - (xx + yy)) * s[2], 0, t[0], t[1], t[2], 1 ]
|
|
126
|
+
}
|
|
127
|
+
/** Inverse of a rigid matrix with a uniform scale — all an IBM ever is. */
|
|
128
|
+
const matInvRigid = (m: Mat4): Mat4 => {
|
|
129
|
+
const s2 = m[0] * m[0] + m[1] * m[1] + m[2] * m[2] || 1
|
|
130
|
+
const r: Mat4 = [ m[0] / s2, m[4] / s2, m[8] / s2, 0, m[1] / s2, m[5] / s2, m[9] / s2, 0, m[2] / s2, m[6] / s2, m[10] / s2, 0, 0, 0, 0, 1 ]
|
|
131
|
+
r[12] = -(r[0] * m[12] + r[4] * m[13] + r[8] * m[14])
|
|
132
|
+
r[13] = -(r[1] * m[12] + r[5] * m[13] + r[9] * m[14])
|
|
133
|
+
r[14] = -(r[2] * m[12] + r[6] * m[13] + r[10] * m[14])
|
|
134
|
+
return r
|
|
135
|
+
}
|
|
136
|
+
/** Rotation of a matrix as a quaternion, scale divided out first. */
|
|
137
|
+
const matToQuat = (m: Mat4): Quat => {
|
|
138
|
+
const sx = Math.hypot(m[0], m[1], m[2]) || 1, sy = Math.hypot(m[4], m[5], m[6]) || 1, sz = Math.hypot(m[8], m[9], m[10]) || 1
|
|
139
|
+
const a = m[0] / sx, b = m[1] / sx, c = m[2] / sx
|
|
140
|
+
const d = m[4] / sy, e = m[5] / sy, f = m[6] / sy
|
|
141
|
+
const g = m[8] / sz, h = m[9] / sz, i = m[10] / sz
|
|
142
|
+
const tr = a + e + i
|
|
143
|
+
if (tr > 0) { const k = Math.sqrt(tr + 1) * 2; return qnorm([ (f - h) / k, (g - c) / k, (b - d) / k, k / 4 ]) }
|
|
144
|
+
if (a > e && a > i) { const k = Math.sqrt(1 + a - e - i) * 2; return qnorm([ k / 4, (d + b) / k, (g + c) / k, (f - h) / k ]) }
|
|
145
|
+
if (e > i) { const k = Math.sqrt(1 + e - a - i) * 2; return qnorm([ (d + b) / k, k / 4, (h + f) / k, (g - c) / k ]) }
|
|
146
|
+
const k = Math.sqrt(1 + i - a - e) * 2
|
|
147
|
+
return qnorm([ (g + c) / k, (h + f) / k, k / 4, (b - d) / k ])
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Bind pose from inverse-bind matrices, filled in down the hierarchy for bones no skin binds.
|
|
152
|
+
* `ibmByNode` maps a node index to its 16 IBM floats.
|
|
153
|
+
*
|
|
154
|
+
* IBMs are authored in the MESH's space, which is not always the node-world space: a Maya "actor"
|
|
155
|
+
* export keeps the skeleton in metres while the mesh (and so every IBM) sits in a 100× smaller
|
|
156
|
+
* space with a ×100 mesh-node scale that glTF ignores for skinned meshes. The bind pose then lands
|
|
157
|
+
* 100× too small next to the rest pose. Bone LENGTHS are invariant under whatever junk rotations an
|
|
158
|
+
* exporter left on the nodes, so the ratio of total rest bone length to total bind bone length is
|
|
159
|
+
* a robust measure of that scale; it is baked into the bind positions (`scale` reports it).
|
|
160
|
+
*/
|
|
161
|
+
const buildBind = (parents: number[], order: number[], rest: Pose, ibmByNode: Map<number, number[]>, spaceByNode: Map<number, Mat4>): BindPose => {
|
|
162
|
+
const world: Mat4[] = new Array(parents.length)
|
|
163
|
+
const fromSkin: boolean[] = new Array(parents.length).fill(false)
|
|
164
|
+
// Rest world matrices, for the mesh-space fold below and the fill-in of unskinned nodes.
|
|
165
|
+
const restWorld: Mat4[] = new Array(parents.length)
|
|
166
|
+
for (const i of order) {
|
|
167
|
+
const local = matCompose(rest.t[i], rest.r[i], rest.s[i])
|
|
168
|
+
restWorld[i] = parents[i] >= 0 ? matMul(restWorld[parents[i]], local) : local
|
|
169
|
+
}
|
|
170
|
+
// inv(IBM) is the joint in the MESH's geometry space. That space is the world only when the node
|
|
171
|
+
// carrying the mesh sits at identity; an exporter that parks the Z-up→Y-up turn on a geometry
|
|
172
|
+
// helper node (Female Runner's `Humanoid_F`: helper −90°X, joints Y-up, IBMs Z-up) or a ×100 scale
|
|
173
|
+
// on the mesh node (Maya "actor" exports) leaves the raw bind turned/shrunk against the joints.
|
|
174
|
+
// Folding the mesh node's world transform in front is exact for those; it is kept only when it
|
|
175
|
+
// brings the bind CLOSER to the node rest pose (a rig whose mesh node carries an unrelated
|
|
176
|
+
// transform — glTF ignores it for skinned meshes — must not be turned by it).
|
|
177
|
+
let rawDist = 0, foldedDist = 0
|
|
178
|
+
const raw: Mat4[] = new Array(parents.length), folded: Mat4[] = new Array(parents.length)
|
|
179
|
+
for (const i of order) {
|
|
180
|
+
const ibm = ibmByNode.get(i)
|
|
181
|
+
if (!ibm) continue
|
|
182
|
+
raw[i] = matInvRigid(ibm)
|
|
183
|
+
const space = spaceByNode.get(i)
|
|
184
|
+
folded[i] = space ? matMul(space, raw[i]) : raw[i]
|
|
185
|
+
fromSkin[i] = true
|
|
186
|
+
rawDist += Math.hypot(raw[i][12] - restWorld[i][12], raw[i][13] - restWorld[i][13], raw[i][14] - restWorld[i][14])
|
|
187
|
+
foldedDist += Math.hypot(folded[i][12] - restWorld[i][12], folded[i][13] - restWorld[i][13], folded[i][14] - restWorld[i][14])
|
|
188
|
+
}
|
|
189
|
+
const useFolded = foldedDist <= rawDist
|
|
190
|
+
for (const i of order) if (fromSkin[i]) world[i] = useFolded ? folded[i] : raw[i]
|
|
191
|
+
// Accumulated (uniform) scale, taken from the rest pose — scale is not part of the bind problem.
|
|
192
|
+
const accum: number[] = new Array(parents.length).fill(1)
|
|
193
|
+
for (const i of order) accum[i] = (parents[i] >= 0 ? accum[parents[i]] : 1) * rest.s[i][0]
|
|
194
|
+
let restLen = 0, bindLen = 0
|
|
195
|
+
for (const i of order) {
|
|
196
|
+
const p = parents[i]
|
|
197
|
+
if (p < 0 || !fromSkin[i] || !fromSkin[p]) continue
|
|
198
|
+
restLen += Math.hypot(...rest.t[i]) * accum[p]
|
|
199
|
+
bindLen += Math.hypot(world[i][12] - world[p][12], world[i][13] - world[p][13], world[i][14] - world[p][14])
|
|
200
|
+
}
|
|
201
|
+
const scale = restLen > 0 && bindLen > 0 ? restLen / bindLen : 1
|
|
202
|
+
if (Math.abs(scale - 1) > 0.01) for (const i of order) if (fromSkin[i]) { world[i][12] *= scale; world[i][13] *= scale; world[i][14] *= scale }
|
|
203
|
+
for (const i of order) {
|
|
204
|
+
if (fromSkin[i]) continue
|
|
205
|
+
const local = matCompose(rest.t[i], rest.r[i], rest.s[i])
|
|
206
|
+
world[i] = parents[i] >= 0 ? matMul(world[parents[i]], local) : local
|
|
207
|
+
}
|
|
208
|
+
const worldRot = world.map(matToQuat)
|
|
209
|
+
const worldPos = world.map((m) => [ m[12], m[13], m[14] ] as Vec3)
|
|
210
|
+
const local: Pose = { t: [], r: [], s: rest.s.map((v) => [ ...v ] as Vec3) }
|
|
211
|
+
for (const i of order) {
|
|
212
|
+
const p = parents[i]
|
|
213
|
+
if (p < 0) { local.t[i] = [ ...worldPos[i] ] as Vec3; local.r[i] = worldRot[i]; continue }
|
|
214
|
+
const off: Vec3 = [ worldPos[i][0] - worldPos[p][0], worldPos[i][1] - worldPos[p][1], worldPos[i][2] - worldPos[p][2] ]
|
|
215
|
+
const rel = qrot(qconj(worldRot[p]), off)
|
|
216
|
+
const k = accum[p] || 1
|
|
217
|
+
local.t[i] = [ rel[0] / k, rel[1] / k, rel[2] / k ]
|
|
218
|
+
local.r[i] = qnorm(qmul(qconj(worldRot[p]), worldRot[i]))
|
|
219
|
+
}
|
|
220
|
+
return { worldRot, worldPos, fromSkin, local, scale: Math.abs(scale - 1) > 0.01 ? scale : 1 }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Rest world matrix of one node (parents composed), for the mesh-space fold in `buildBind`. */
|
|
224
|
+
const restWorldOf = (parents: number[], rest: Pose, node: number): Mat4 => {
|
|
225
|
+
const chain: number[] = []
|
|
226
|
+
for (let a = node; a >= 0; a = parents[a]) chain.unshift(a)
|
|
227
|
+
let m: Mat4 | null = null
|
|
228
|
+
for (const i of chain) { const local = matCompose(rest.t[i], rest.r[i], rest.s[i]); m = m ? matMul(m, local) : local }
|
|
229
|
+
return m!
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Assemble a Rig: topological order, the bind pose from inverse-bind matrices (`ibmByNode` — the
|
|
234
|
+
* bones a skin binds; the rest are filled in down the hierarchy), and the clips, which are built
|
|
235
|
+
* from the BIND local pose so a bone no clip animates falls back to bind rather than to whatever
|
|
236
|
+
* the exporter left on the node. The one constructor — FBX, glTF and Unreal rigs all come out of it.
|
|
237
|
+
*/
|
|
238
|
+
export const makeRig = (label: string, names: string[], parents: number[], rest: Pose, makeClips: (base: Pose) => Clip[], ibmByNode: Map<number, number[]>, spaceByNode: Map<number, Mat4> = new Map()): Rig => {
|
|
239
|
+
const order = buildOrder(parents)
|
|
240
|
+
const bind = buildBind(parents, order, rest, ibmByNode, spaceByNode)
|
|
241
|
+
const rig: Rig = {
|
|
242
|
+
label, names, parents, rest,
|
|
243
|
+
restWorldRot: [], restWorldPos: [],
|
|
244
|
+
bind,
|
|
245
|
+
order,
|
|
246
|
+
index: new Map(names.map((n, i) => [ n, i ])),
|
|
247
|
+
clips: makeClips(bind.local),
|
|
248
|
+
}
|
|
249
|
+
const w = worldOf(rig, rest)
|
|
250
|
+
rig.restWorldRot = w.rot
|
|
251
|
+
rig.restWorldPos = w.pos
|
|
252
|
+
return rig
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** How far a rig's bind pose sits from the TRS on its nodes — large means the node pose is junk. */
|
|
256
|
+
export const bindVsRest = (rig: Rig): { worst: number; bone: string; skinned: number } => {
|
|
257
|
+
let worst = 0, bone = ''
|
|
258
|
+
for (let i = 0; i < rig.names.length; i++) {
|
|
259
|
+
if (!rig.bind.fromSkin[i]) continue
|
|
260
|
+
const a = rig.bind.worldPos[i], b = rig.restWorldPos[i]
|
|
261
|
+
const d = Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2])
|
|
262
|
+
if (d > worst) { worst = d; bone = rig.names[i] }
|
|
263
|
+
}
|
|
264
|
+
return { worst, bone, skinned: rig.bind.fromSkin.filter(Boolean).length }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Throws on a non-uniform scale — the quaternion chain above would silently lie. */
|
|
268
|
+
export const checkUniformScale = (rig: Rig): void => {
|
|
269
|
+
for (let i = 0; i < rig.names.length; i++) {
|
|
270
|
+
const [ a, b, c ] = rig.rest.s[i]
|
|
271
|
+
if (Math.abs(a - b) > 1e-4 || Math.abs(b - c) > 1e-4) {
|
|
272
|
+
throw new Error(`${rig.label}: bone "${rig.names[i]}" has a non-uniform rest scale [${rig.rest.s[i]}] — retargeting needs uniform scales`)
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ---- FBX (the clip donor) -----------------------------------------------------------------------
|
|
278
|
+
|
|
279
|
+
/** Sample one baked FBX track (times/values in the arena) at `time`. */
|
|
280
|
+
const sampleTrack = (times: Float32Array, values: Float32Array, n: number, time: number, step = false): number[] => {
|
|
281
|
+
const last = times.length - 1
|
|
282
|
+
const at = (i: number): number[] => Array.from(values.subarray(i * n, i * n + n))
|
|
283
|
+
if (time <= times[0]) return at(0)
|
|
284
|
+
if (time >= times[last]) return at(last)
|
|
285
|
+
let i = 0
|
|
286
|
+
while (i < last && times[i + 1] < time) i++
|
|
287
|
+
if (step) return at(i)
|
|
288
|
+
const u = (time - times[i]) / (times[i + 1] - times[i] || 1)
|
|
289
|
+
const a = at(i), b = at(i + 1)
|
|
290
|
+
if (n === 4) return qslerp(a as Quat, b as Quat, u)
|
|
291
|
+
return a.map((v, k) => v + (b[k] - v) * u)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export const rigFromFbx = async (path: string, bytes: Uint8Array): Promise<Rig> => {
|
|
295
|
+
const scene = await readFbx(bytes)
|
|
296
|
+
const names = scene.nodes.map((n) => n.name)
|
|
297
|
+
const parents = scene.nodes.map((n) => n.parent)
|
|
298
|
+
const rest: Pose = {
|
|
299
|
+
t: scene.nodes.map((n) => [ ...n.t ] as Vec3),
|
|
300
|
+
r: scene.nodes.map((n) => [ ...n.r ] as Quat),
|
|
301
|
+
s: scene.nodes.map((n) => [ ...n.s ] as Vec3),
|
|
302
|
+
}
|
|
303
|
+
const ibmByNode = new Map<number, number[]>()
|
|
304
|
+
const spaceByNode = new Map<number, Mat4>()
|
|
305
|
+
scene.skins.forEach((skin, si) => {
|
|
306
|
+
const ibm = f32(scene, skin.ibm, skin.joints.length * 16)
|
|
307
|
+
// The node carrying this skin's mesh (ufbx parks a geometry transform on a helper child, which
|
|
308
|
+
// is then the node that has the mesh): its world is the IBMs' space.
|
|
309
|
+
const meshNode = scene.nodes.findIndex((n) => n.skin === si)
|
|
310
|
+
const space = meshNode >= 0 ? restWorldOf(parents, rest, meshNode) : null
|
|
311
|
+
skin.joints.forEach((node, j) => {
|
|
312
|
+
if (ibmByNode.has(node)) return
|
|
313
|
+
ibmByNode.set(node, Array.from(ibm.subarray(j * 16, j * 16 + 16)))
|
|
314
|
+
if (space) spaceByNode.set(node, space)
|
|
315
|
+
})
|
|
316
|
+
})
|
|
317
|
+
const makeClips = (base: Pose): Clip[] => scene.animations.filter((a) => a.tracks.length > 0).map((anim) => {
|
|
318
|
+
// Pre-resolve the arena views once; sample() is called per frame per clip.
|
|
319
|
+
const tracks = anim.tracks.map((tr) => {
|
|
320
|
+
const n = tr.path === 'rotation' ? 4 : 3
|
|
321
|
+
return { node: tr.node, path: tr.path, n, times: f32(scene, tr.times, tr.count), values: f32(scene, tr.values, tr.count * n) }
|
|
322
|
+
})
|
|
323
|
+
return {
|
|
324
|
+
name: anim.name,
|
|
325
|
+
duration: anim.duration,
|
|
326
|
+
sample: (time: number): Pose => {
|
|
327
|
+
const pose: Pose = { t: base.t.map((v) => [ ...v ] as Vec3), r: base.r.map((v) => [ ...v ] as Quat), s: base.s.map((v) => [ ...v ] as Vec3) }
|
|
328
|
+
for (const tr of tracks) {
|
|
329
|
+
const v = sampleTrack(tr.times, tr.values, tr.n, time)
|
|
330
|
+
if (tr.path === 'translation') pose.t[tr.node] = v as Vec3
|
|
331
|
+
else if (tr.path === 'rotation') pose.r[tr.node] = qnorm(v as Quat)
|
|
332
|
+
else pose.s[tr.node] = v as Vec3
|
|
333
|
+
}
|
|
334
|
+
return pose
|
|
335
|
+
},
|
|
336
|
+
}
|
|
337
|
+
})
|
|
338
|
+
return makeRig(path, names, parents, rest, makeClips, ibmByNode, spaceByNode)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ---- glTF Document (the model being animated) ---------------------------------------------------
|
|
342
|
+
|
|
343
|
+
export interface DocRig extends Rig { doc: Document; nodes: any[] }
|
|
344
|
+
|
|
345
|
+
/** Clips from a glTF Document's animations, sampled the same way the FBX side is. */
|
|
346
|
+
const clipsFromDocument = (doc: Document, nodes: any[], base: Pose): Clip[] => {
|
|
347
|
+
const idx = new Map(nodes.map((n, i) => [ n, i ]))
|
|
348
|
+
return doc.getRoot().listAnimations().map((anim) => {
|
|
349
|
+
const chans = anim.listChannels().map((ch) => {
|
|
350
|
+
const s = ch.getSampler()!
|
|
351
|
+
return {
|
|
352
|
+
node: idx.get(ch.getTargetNode()!)!,
|
|
353
|
+
path: ch.getTargetPath() as string,
|
|
354
|
+
step: s.getInterpolation() === 'STEP',
|
|
355
|
+
times: s.getInput()!.getArray() as Float32Array,
|
|
356
|
+
values: s.getOutput()!.getArray() as Float32Array,
|
|
357
|
+
}
|
|
358
|
+
}).filter((c) => c.node !== undefined)
|
|
359
|
+
let duration = 0
|
|
360
|
+
for (const c of chans) duration = Math.max(duration, c.times[c.times.length - 1] ?? 0)
|
|
361
|
+
return {
|
|
362
|
+
name: anim.getName(),
|
|
363
|
+
duration,
|
|
364
|
+
sample: (time: number): Pose => {
|
|
365
|
+
const pose: Pose = { t: base.t.map((v) => [ ...v ] as Vec3), r: base.r.map((v) => [ ...v ] as Quat), s: base.s.map((v) => [ ...v ] as Vec3) }
|
|
366
|
+
for (const c of chans) {
|
|
367
|
+
const n = c.path === 'rotation' ? 4 : 3
|
|
368
|
+
const v = sampleTrack(c.times, c.values as Float32Array, n, time, c.step)
|
|
369
|
+
if (c.path === 'translation') pose.t[c.node] = v as Vec3
|
|
370
|
+
else if (c.path === 'rotation') pose.r[c.node] = qnorm(v as Quat)
|
|
371
|
+
else if (c.path === 'scale') pose.s[c.node] = v as Vec3
|
|
372
|
+
}
|
|
373
|
+
return pose
|
|
374
|
+
},
|
|
375
|
+
}
|
|
376
|
+
})
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export const rigFromDocument = (doc: Document, label: string): DocRig => {
|
|
380
|
+
const nodes = doc.getRoot().listNodes()
|
|
381
|
+
const idx = new Map(nodes.map((n, i) => [ n, i ]))
|
|
382
|
+
const names = nodes.map((n) => n.getName())
|
|
383
|
+
const parents = nodes.map((n) => { const p = n.getParentNode(); return p ? idx.get(p)! : -1 })
|
|
384
|
+
const rest: Pose = {
|
|
385
|
+
t: nodes.map((n) => [ ...n.getTranslation() ] as Vec3),
|
|
386
|
+
r: nodes.map((n) => [ ...n.getRotation() ] as Quat),
|
|
387
|
+
s: nodes.map((n) => [ ...n.getScale() ] as Vec3),
|
|
388
|
+
}
|
|
389
|
+
const ibmByNode = new Map<number, number[]>()
|
|
390
|
+
const spaceByNode = new Map<number, Mat4>()
|
|
391
|
+
for (const skin of doc.getRoot().listSkins()) {
|
|
392
|
+
const acc = skin.getInverseBindMatrices()
|
|
393
|
+
if (!acc) continue
|
|
394
|
+
// A converted FBX keeps the mesh on the (helper) node whose world is the IBMs' space; glTF
|
|
395
|
+
// ignores that transform for skinning, so it survives as the record of where the bind lives.
|
|
396
|
+
const meshNode = nodes.findIndex((n) => n.getSkin() === skin)
|
|
397
|
+
const space = meshNode >= 0 ? restWorldOf(parents, rest, meshNode) : null
|
|
398
|
+
skin.listJoints().forEach((joint, j) => {
|
|
399
|
+
const i = idx.get(joint)
|
|
400
|
+
if (i === undefined || ibmByNode.has(i)) return
|
|
401
|
+
ibmByNode.set(i, acc.getElement(j, []))
|
|
402
|
+
if (space) spaceByNode.set(i, space)
|
|
403
|
+
})
|
|
404
|
+
}
|
|
405
|
+
return { ...makeRig(label, names, parents, rest, (base) => clipsFromDocument(doc, nodes, base), ibmByNode, spaceByNode), doc, nodes }
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
export const loadRig = async (path: string): Promise<Rig> => rigFromFbx(path, new Uint8Array(readFileSync(path)))
|
|
409
|
+
|
|
410
|
+
/** A skeleton with a skin somewhere — the kind the classifiers can read. */
|
|
411
|
+
export const hasSkin = (rig: Rig): boolean => rig.bind.fromSkin.some(Boolean)
|
|
412
|
+
|
|
413
|
+
const shortBoneName = (n: string): string => n.slice(n.search(/[^:|]*$/))
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Is `source` (a clip file, a model) the same skeleton as `rig` — the kind a clip can be copied
|
|
417
|
+
* onto bone by bone? Names must match (≥ 80 %), and the matched bones' LOCAL OFFSETS (the child in
|
|
418
|
+
* its parent's frame) must point the same way: that vector is fixed by the rig and survives any
|
|
419
|
+
* pose the exporter baked into the nodes, while local rotations do not (a mocap pack's clip files
|
|
420
|
+
* carry their first frame as the rest pose). Bones under 5 cm (fingers, eyes) are ignored. A rig
|
|
421
|
+
* of the same FAMILY (another Mixamo character: same joint frames, other proportions) stays within
|
|
422
|
+
* ~20°; a rig that merely shares the naming convention (two Unreal-style rigs) turns some joint —
|
|
423
|
+
* typically the root's — by 90°, and copying its clips would twist the character.
|
|
424
|
+
*/
|
|
425
|
+
export const sameSkeleton = (rig: Rig, source: Rig): { same: boolean; share: number; worstDeg: number; worstBone: string } => {
|
|
426
|
+
const short = new Map<string, number>()
|
|
427
|
+
rig.names.forEach((n, i) => { const s = shortBoneName(n); if (!short.has(s)) short.set(s, i) })
|
|
428
|
+
const map = source.names.map((n) => rig.index.get(n) ?? short.get(shortBoneName(n)) ?? -1)
|
|
429
|
+
const share = map.filter((j) => j >= 0).length / Math.max(1, source.names.length)
|
|
430
|
+
let worstDeg = 0, worstBone = ''
|
|
431
|
+
source.names.forEach((_, i) => {
|
|
432
|
+
const j = map[i]
|
|
433
|
+
if (j < 0) return
|
|
434
|
+
const ps = source.parents[i], pt = rig.parents[j]
|
|
435
|
+
if (ps < 0 || pt < 0 || map[ps] !== pt) return
|
|
436
|
+
const a = rig.rest.t[j], b = source.rest.t[i]
|
|
437
|
+
const la = Math.hypot(...a), lb = Math.hypot(...b)
|
|
438
|
+
if (la < 0.05 || lb < 0.05) return
|
|
439
|
+
const deg = Math.acos(Math.max(-1, Math.min(1, (a[0] * b[0] + a[1] * b[1] + a[2] * b[2]) / (la * lb)))) * 180 / Math.PI
|
|
440
|
+
if (deg > worstDeg) { worstDeg = deg; worstBone = source.names[i] }
|
|
441
|
+
})
|
|
442
|
+
return { same: share >= 0.8 && worstDeg < 45, share, worstDeg, worstBone }
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Clips of a SKELETON-ONLY file (a mocap pack's clip FBX: nodes and curves, no mesh, no skin) bound
|
|
447
|
+
* to the rig they were animated on, by bone name — exact first, then the name without its
|
|
448
|
+
* `namespace:` prefix. The result is `rig` with those clips: each sample starts from the rig's
|
|
449
|
+
* bind and takes the clip's LOCAL ROTATION on every bone it names, so the rig's own bind pose,
|
|
450
|
+
* skin and classification carry the motion. Bone OFFSETS stay the rig's — a clip file's skeleton
|
|
451
|
+
* is not always the model's (Mixamo's skin-less downloads are ~9 % larger than the character they
|
|
452
|
+
* were made for, with the thighs 22° elsewhere; the legs would come out longer than the bind and
|
|
453
|
+
* every straight leg out of reach). The translations a clip ANIMATES (the hips' travel and bob,
|
|
454
|
+
* a root's travel) are taken from the clip scaled by `scale`: 1 when the two skeletons have the
|
|
455
|
+
* same bone lengths (a pack's own clip files — exact), else the factor that puts the lowest joint
|
|
456
|
+
* of the rebound rig over the clips on the rig's own ground (a locomotion clip touches the ground
|
|
457
|
+
* somewhere; no bone-length ratio says how high the hips ride when the proportions differ).
|
|
458
|
+
* Bones the clip does not name stay in bind; bones the clip names and the rig lacks are reported.
|
|
459
|
+
*/
|
|
460
|
+
export const rebindClips = (rig: Rig, source: Rig): { rig: Rig; bound: number; total: number; unbound: string[]; scale: number } => {
|
|
461
|
+
const short = new Map<string, number>()
|
|
462
|
+
rig.names.forEach((n, i) => { const s = shortBoneName(n); if (!short.has(s)) short.set(s, i) })
|
|
463
|
+
const map = source.names.map((n) => rig.index.get(n) ?? short.get(shortBoneName(n)) ?? -1)
|
|
464
|
+
const unbound = source.names.filter((_, i) => map[i] < 0)
|
|
465
|
+
const bound = map.filter((j) => j >= 0).length
|
|
466
|
+
const base = rig.bind.local, srcRest = source.rest
|
|
467
|
+
// Which translations the clips animate: a bone whose translation leaves its rest anywhere in a clip.
|
|
468
|
+
const animated = source.names.map(() => false)
|
|
469
|
+
for (const c of source.clips) for (let k = 0; k <= 8; k++) {
|
|
470
|
+
const p = c.sample((k / 8) * c.duration)
|
|
471
|
+
p.t.forEach((t, i) => { if (!animated[i] && len(sub(t, srcRest.t[i])) > 1e-6) animated[i] = true })
|
|
472
|
+
}
|
|
473
|
+
const sampleAt = (c: Clip, time: number, scale: number): Pose => {
|
|
474
|
+
const p = c.sample(time)
|
|
475
|
+
const out: Pose = { t: base.t.map((v) => [ ...v ] as Vec3), r: base.r.map((q) => [ ...q ] as Quat), s: base.s.map((v) => [ ...v ] as Vec3) }
|
|
476
|
+
map.forEach((j, i) => {
|
|
477
|
+
if (j < 0) return
|
|
478
|
+
out.r[j] = p.r[i]
|
|
479
|
+
if (animated[i]) out.t[j] = scl(p.t[i], scale)
|
|
480
|
+
})
|
|
481
|
+
return out
|
|
482
|
+
}
|
|
483
|
+
// Same skeleton (every still matched offset within 1 % of its length) → the clip's translations
|
|
484
|
+
// are the rig's, exactly. Otherwise solve for the scale that grounds the rebound rig.
|
|
485
|
+
let sameLengths = true, num = 0, den = 0
|
|
486
|
+
map.forEach((j, i) => {
|
|
487
|
+
if (j < 0 || animated[i] || source.parents[i] < 0 || rig.parents[j] < 0) return
|
|
488
|
+
const a = len(base.t[j]), b = len(srcRest.t[i])
|
|
489
|
+
num += a; den += b
|
|
490
|
+
if (Math.abs(a - b) > 0.01 * Math.max(a, b, 0.01)) sameLengths = false
|
|
491
|
+
})
|
|
492
|
+
let scale = 1
|
|
493
|
+
if (!sameLengths && source.clips.length) {
|
|
494
|
+
const skinned = rig.bind.fromSkin.some(Boolean) ? rig.bind.worldPos.filter((_, i) => rig.bind.fromSkin[i]) : rig.bind.worldPos
|
|
495
|
+
const ground = Math.min(...skinned.map((p) => p[1]))
|
|
496
|
+
const lowest = (s: number): number => {
|
|
497
|
+
let lo = Infinity
|
|
498
|
+
for (const c of source.clips) for (let k = 0; k < 16; k++) {
|
|
499
|
+
const w = worldOf(rig, sampleAt(c, (k / 15) * c.duration, s))
|
|
500
|
+
for (let i = 0; i < w.pos.length; i++) if (rig.bind.fromSkin[i] || !rig.bind.fromSkin.some(Boolean)) lo = Math.min(lo, w.pos[i][1])
|
|
501
|
+
}
|
|
502
|
+
return lo
|
|
503
|
+
}
|
|
504
|
+
let a = 0.5, b = 2, fa = lowest(a) - ground, fb = lowest(b) - ground
|
|
505
|
+
if (fa < 0 && fb > 0) {
|
|
506
|
+
for (let it = 0; it < 24; it++) { const m = (a + b) / 2, fm = lowest(m) - ground; if (fm < 0) { a = m; fa = fm } else { b = m; fb = fm } }
|
|
507
|
+
scale = (a + b) / 2
|
|
508
|
+
} else scale = den > 0 && num > 0 ? num / den : 1 // no ground contact to solve on: the bone-length ratio
|
|
509
|
+
}
|
|
510
|
+
const clips: Clip[] = source.clips.map((c) => ({ name: c.name, duration: c.duration, sample: (time: number): Pose => sampleAt(c, time, scale) }))
|
|
511
|
+
return { rig: { ...rig, clips }, bound, total: source.names.length, unbound, scale }
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* The same rig turned by a world rotation (its root nodes get `q` in front of them): every world
|
|
516
|
+
* orientation, position and sampled pose comes out rotated, nothing else changes. Used to turn a
|
|
517
|
+
* donor to face the way the target faces before a retarget.
|
|
518
|
+
*/
|
|
519
|
+
export const rotateRig = <T extends Rig>(rig: T, q: Quat): T => {
|
|
520
|
+
const roots = rig.parents.map((p, i) => (p < 0 ? i : -1)).filter((i) => i >= 0)
|
|
521
|
+
const turn = (pose: Pose): Pose => {
|
|
522
|
+
const out: Pose = { t: pose.t.map((v) => [ ...v ] as Vec3), r: pose.r.map((v) => [ ...v ] as Quat), s: pose.s }
|
|
523
|
+
for (const i of roots) { out.t[i] = qrot(q, pose.t[i]); out.r[i] = qnorm(qmul(q, pose.r[i])) }
|
|
524
|
+
return out
|
|
525
|
+
}
|
|
526
|
+
const bind: BindPose = {
|
|
527
|
+
...rig.bind,
|
|
528
|
+
worldRot: rig.bind.worldRot.map((r) => qnorm(qmul(q, r))),
|
|
529
|
+
worldPos: rig.bind.worldPos.map((p) => qrot(q, p)),
|
|
530
|
+
local: turn(rig.bind.local),
|
|
531
|
+
}
|
|
532
|
+
const out: T = {
|
|
533
|
+
...rig,
|
|
534
|
+
rest: turn(rig.rest),
|
|
535
|
+
bind,
|
|
536
|
+
clips: rig.clips.map((c) => ({ ...c, sample: (t: number) => turn(c.sample(t)) })),
|
|
537
|
+
}
|
|
538
|
+
const w = worldOf(out, out.rest)
|
|
539
|
+
out.restWorldRot = w.rot
|
|
540
|
+
out.restWorldPos = w.pos
|
|
541
|
+
return out
|
|
542
|
+
}
|