lecodes-assets 0.1.2 → 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 +240 -5
- package/dist/cli.js +7757 -683
- package/dist/index.js +7866 -675
- 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,144 @@
|
|
|
1
|
+
// `probe <x.uasset>` — what an Unreal asset holds, before converting anything: engine version,
|
|
2
|
+
// class, data model, the skeleton (and WHICH FILE it was found in), a clip's keys/rate/root travel,
|
|
3
|
+
// a mesh's LODs, counts and size. Anything the reader cannot handle is printed as the reason, not
|
|
4
|
+
// thrown — the point of probing a pack is to find out which assets are usable.
|
|
5
|
+
|
|
6
|
+
import { basename, dirname } from "node:path"
|
|
7
|
+
import { engineLabel, classOf, type UePackage } from "./package"
|
|
8
|
+
import { readSkeleton, rootBones, type UeSkeleton } from "./skeleton"
|
|
9
|
+
import { readAnimSequence, readAnimMeta, dataModelKind, packageKind, type UeAnimSequence } from "./anim"
|
|
10
|
+
import { readSkeletalMesh, meshBounds, type UeSkeletalMesh } from "./mesh"
|
|
11
|
+
import { readPackageFile, readSkeletonFile } from "./index"
|
|
12
|
+
import { resolveObjectFile } from "./resolve"
|
|
13
|
+
import { UE_SCALE } from "./convert"
|
|
14
|
+
import { readTexture2D, findCompressedBuffer } from "./texture"
|
|
15
|
+
import { findOodle } from "./oodle"
|
|
16
|
+
import { resolveSlotMaterials, readMaterialTextures, classifyTexture } from "./materials"
|
|
17
|
+
|
|
18
|
+
const f = (n: number, d = 2): string => n.toFixed(d)
|
|
19
|
+
|
|
20
|
+
/** Where a clip's root ends up travelling, in the glTF frame (X right, Y up, Z forward). */
|
|
21
|
+
export const rootTravel = (anim: UeAnimSequence, skeleton: UeSkeleton): { size: [ number, number, number ], up: number, fwd: number } | null => {
|
|
22
|
+
const roots = new Set(rootBones(skeleton).map((i) => skeleton.bones[i].name))
|
|
23
|
+
const track = anim.tracks.find((t) => roots.has(t.name) && t.pos.length >= 6)
|
|
24
|
+
if (!track) return null
|
|
25
|
+
const lo = [ Infinity, Infinity, Infinity ], hi = [ -Infinity, -Infinity, -Infinity ]
|
|
26
|
+
for (let i = 0; i < track.pos.length; i += 3) {
|
|
27
|
+
for (let k = 0; k < 3; k++) { lo[k] = Math.min(lo[k], track.pos[i + k]); hi[k] = Math.max(hi[k], track.pos[i + k]) }
|
|
28
|
+
}
|
|
29
|
+
const n = track.pos.length / 3
|
|
30
|
+
// UE root local = world; the glTF frame turns (x, y, z)cm into (x, z, y)m
|
|
31
|
+
const size: [ number, number, number ] = [ (hi[0] - lo[0]) * UE_SCALE, (hi[2] - lo[2]) * UE_SCALE, (hi[1] - lo[1]) * UE_SCALE ]
|
|
32
|
+
return { size, up: (track.pos[(n - 1) * 3 + 2] - track.pos[2]) * UE_SCALE, fwd: (track.pos[(n - 1) * 3 + 1] - track.pos[1]) * UE_SCALE }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const skeletonLine = (sk: UeSkeleton, file: string, from: string): string => {
|
|
36
|
+
const roots = rootBones(sk).map((i) => sk.bones[i].name)
|
|
37
|
+
return ` skeleton ${sk.name} (${sk.bones.length} bones, root${roots.length > 1 ? "s" : ""}: ${roots.join(", ") || "none"})${file !== from ? ` ← ${basename(file)}` : ""}`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const animLines = (anim: UeAnimSequence, sk: UeSkeleton): string[] => {
|
|
41
|
+
const out: string[] = []
|
|
42
|
+
const travel = rootTravel(anim, sk)
|
|
43
|
+
out.push(` ${anim.tracks.length}/${sk.bones.length} bones animated · ${anim.numKeys} keys @ ${+anim.fps.toFixed(3)} fps · ${f(anim.playLength, 3)} s · ${anim.interpolation.toLowerCase()} interpolation${anim.rateScale !== 1 ? ` · rate ×${anim.rateScale}` : ""}`)
|
|
44
|
+
out.push(` root motion: ${travel ? `travels ${f(travel.size[0])}×${f(travel.size[1])}×${f(travel.size[2])} m (up ${f(travel.up)}, fwd ${f(travel.fwd)})` : "root not animated"} · bEnableRootMotion ${anim.rootMotion ? "ON" : "off"}`)
|
|
45
|
+
if (anim.additive) out.push(` ⚠ ADDITIVE clip (${anim.additive}) — its poses are DELTAS against a base pose, not a playable pose`)
|
|
46
|
+
if (anim.events.length) out.push(` events ${anim.events.length}: ${anim.events.slice(0, 8).map((e) => `${e.name}@${f(e.time, 2)}s`).join(", ")}${anim.events.length > 8 ? "…" : ""}`)
|
|
47
|
+
const missing = anim.tracks.filter((t) => !sk.bones.some((b) => b.name === t.name)).map((t) => t.name)
|
|
48
|
+
if (missing.length) out.push(` ⚠ ${missing.length} track(s) name a bone this skeleton lacks: ${missing.slice(0, 6).join(", ")}`)
|
|
49
|
+
return out
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const meshLines = (mesh: UeSkeletalMesh): string[] => {
|
|
53
|
+
const b = meshBounds(mesh)
|
|
54
|
+
const dims = [ (b.max[0] - b.min[0]) * UE_SCALE, (b.max[2] - b.min[2]) * UE_SCALE, (b.max[1] - b.min[1]) * UE_SCALE ]
|
|
55
|
+
return [
|
|
56
|
+
` LODs ${mesh.lods.length}: ${mesh.lods.map((l) => `${l.index}${l.hasSource ? `=${(l.bytes / 1e6).toFixed(1)}MB${l.compressed ? " zlib" : " raw"}` : "=no source"}`).join(", ")} (reading LOD ${mesh.lod}, import-data v${mesh.version})`,
|
|
57
|
+
` ${mesh.faceWedges.length / 3} tris · ${mesh.points.length / 3} points / ${mesh.wedgePoint.length} wedges · ${mesh.influences.length} influences · ${mesh.bones.length} bones · size ${dims.map((d) => f(d, 3)).join("×")} m${mesh.hasUv1 ? " · UV1" : ""}`,
|
|
58
|
+
` materials ${mesh.materials.length}: ${mesh.materials.join(", ") || "none"}`,
|
|
59
|
+
]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Per slot: the material asset it uses and the textures that material names. */
|
|
63
|
+
const materialLines = (pkg: UePackage, slots: string[], meshPath: string): string[] => {
|
|
64
|
+
const out: string[] = []
|
|
65
|
+
const assets = resolveSlotMaterials(pkg, slots)
|
|
66
|
+
slots.forEach((slot, i) => {
|
|
67
|
+
const assetPath = assets[i]
|
|
68
|
+
if (!assetPath) { out.push(` ${slot}: no material asset referenced`); return }
|
|
69
|
+
const file = resolveObjectFile(assetPath, dirname(meshPath))
|
|
70
|
+
const name = assetPath.split("/").pop()
|
|
71
|
+
if (!file) { out.push(` ${slot} ← ${name} (asset not found next to the mesh)`); return }
|
|
72
|
+
try {
|
|
73
|
+
const params = readMaterialTextures(readPackageFile(file.file))
|
|
74
|
+
const roles = params.map((t) => `${t.asset}${classifyTexture(t.param, t.asset) !== "other" ? ` [${classifyTexture(t.param, t.asset)}]` : ""}`)
|
|
75
|
+
out.push(` ${slot} ← ${name}: ${roles.join(", ") || "no textures"}`)
|
|
76
|
+
} catch (e) { out.push(` ${slot} ← ${name}: ${(e as Error).message}`) }
|
|
77
|
+
})
|
|
78
|
+
return out
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export const probeUnreal = async (path: string): Promise<string> => {
|
|
82
|
+
const lines: string[] = []
|
|
83
|
+
let pkg: UePackage
|
|
84
|
+
try { pkg = readPackageFile(path) } catch (e) { return `UASSET ${path}\n ✗ ${(e as Error).message}` }
|
|
85
|
+
const kind = packageKind(pkg)
|
|
86
|
+
void findCompressedBuffer
|
|
87
|
+
const model = dataModelKind(pkg)
|
|
88
|
+
lines.push(`UASSET ${path}`)
|
|
89
|
+
lines.push(` ${engineLabel(pkg.summary)} (ue4 ${pkg.summary.ue4}, ue5 ${pkg.summary.ue5}) · ${kind === "other" ? pkg.exports.map((e) => classOf(pkg, e)).slice(0, 4).join(", ") : kind}${model !== "none" ? ` · data model ${model}` : ""}`)
|
|
90
|
+
|
|
91
|
+
let skeleton: UeSkeleton | undefined
|
|
92
|
+
let anim: UeAnimSequence | undefined
|
|
93
|
+
let mesh: UeSkeletalMesh | undefined
|
|
94
|
+
try {
|
|
95
|
+
if (kind === "AnimSequence") {
|
|
96
|
+
anim = readAnimSequence(pkg)
|
|
97
|
+
const found = anim.skeletonPath ? resolveObjectFile(anim.skeletonPath, dirname(path)) : null
|
|
98
|
+
lines.push(` authored on ${anim.skeletonName || "?"} (${anim.skeletonPath || "no skeleton named"})`)
|
|
99
|
+
if (found) { skeleton = readSkeletonFile(found.file); lines.push(skeletonLine(skeleton, found.file, path)) }
|
|
100
|
+
else lines.push(` ⚠ that skeleton asset is not next to this file — pass --skeleton <SK_*.uasset> to convert it`)
|
|
101
|
+
if (skeleton) lines.push(...animLines(anim, skeleton))
|
|
102
|
+
} else if (kind === "SkeletalMesh") {
|
|
103
|
+
mesh = readSkeletalMesh(pkg)
|
|
104
|
+
skeleton = { name: mesh.skeletonName || mesh.name, bones: mesh.bones.map((b) => ({ name: b.name, parent: b.parent, t: b.t, q: b.q, s: b.s })) }
|
|
105
|
+
lines.push(` bound to ${mesh.skeletonName || "?"} (${mesh.skeletonPath || "no skeleton named"})`)
|
|
106
|
+
lines.push(...meshLines(mesh))
|
|
107
|
+
lines.push(...materialLines(pkg, mesh.materials, path))
|
|
108
|
+
} else if (kind === "Texture2D") {
|
|
109
|
+
const t = readTexture2D(pkg)
|
|
110
|
+
lines.push(` ${t.source.width}×${t.source.height} · ${t.source.format || "format?"} · ${t.source.compression || "uncompressed"} · ${t.compressionSettings}${t.srgb ? " · sRGB" : ""}${t.sourceFile ? ` · from ${t.sourceFile}` : ""}`)
|
|
111
|
+
const buf = findCompressedBuffer(pkg)
|
|
112
|
+
lines.push(buf
|
|
113
|
+
? ` source payload: ${(buf.rawSize / 1e6).toFixed(1)} MB in ${buf.blocks.length} block(s), ${buf.method === 0 ? "stored" : buf.method === 3 ? "Oodle" : `method ${buf.method}`}${buf.method === 3 ? ` (${findOodle() ? "decoder found" : "NO decoder — set LECODES_OODLE"})` : ""}`
|
|
114
|
+
: " ⚠ no source payload in this package")
|
|
115
|
+
return lines.join("\n")
|
|
116
|
+
} else if (kind === "Skeleton") {
|
|
117
|
+
skeleton = readSkeleton(pkg)
|
|
118
|
+
lines.push(skeletonLine(skeleton, path, path))
|
|
119
|
+
} else {
|
|
120
|
+
lines.push(` nothing this pipeline reads (exports: ${pkg.exports.map((e) => `${e.objectName}:${classOf(pkg, e)}`).slice(0, 6).join(", ")})`)
|
|
121
|
+
}
|
|
122
|
+
} catch (e) {
|
|
123
|
+
lines.push(` ✗ ${(e as Error).message}`)
|
|
124
|
+
// A Sequencer-model clip (or any clip whose tracks fail) still describes itself: length, rate,
|
|
125
|
+
// root motion and the sync markers all live on the AnimSequence, not in the data model.
|
|
126
|
+
if (kind === "AnimSequence") {
|
|
127
|
+
try {
|
|
128
|
+
const meta = readAnimMeta(pkg)
|
|
129
|
+
lines.push(` the clip itself: ${f(meta.sequenceLength, 3)} s${meta.targetFps ? ` @ ${+meta.targetFps.toFixed(3)} fps` : ""} · authored on ${meta.skeletonName || "?"} (${meta.skeletonPath || "unnamed"})${meta.rootMotion ? " · root motion ON" : ""}${meta.additive ? ` · ADDITIVE (${meta.additive})` : ""}`)
|
|
130
|
+
if (meta.events.length) lines.push(` events ${meta.events.length}: ${meta.events.slice(0, 8).map((x) => `${x.kind === "marker" ? "sync" : "notify"} ${x.name}@${f(x.time, 2)}s`).join(", ")}${meta.events.length > 8 ? "…" : ""}`)
|
|
131
|
+
} catch { /* the export itself is unreadable — the message above already says so */ }
|
|
132
|
+
}
|
|
133
|
+
return lines.join("\n")
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// …and what it would convert to, through the normal GLB probe
|
|
137
|
+
if (skeleton) {
|
|
138
|
+
const { unrealToDocument } = await import("./toDocument")
|
|
139
|
+
const { probeDocument } = await import("../probe")
|
|
140
|
+
const r = unrealToDocument({ skeleton, mesh, clips: anim ? [ anim ] : [] })
|
|
141
|
+
lines.push(...probeDocument(r.doc, path).split("\n").slice(1))
|
|
142
|
+
}
|
|
143
|
+
return lines.join("\n")
|
|
144
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Finding the file an object path names. An AnimSequence knows its skeleton only as
|
|
2
|
+
// `/Game/StairsSet/Demo/Mannequins/Meshes/SK_Mannequin`, where `/Game/` is the pack's Content
|
|
3
|
+
// folder — which is not marked in any way on disk. So: turn the path into a relative file name and
|
|
4
|
+
// walk up from the clip until a directory answers to it. The first one that does IS the content root.
|
|
5
|
+
|
|
6
|
+
import { existsSync, statSync } from "node:fs"
|
|
7
|
+
import { dirname, join, resolve, sep } from "node:path"
|
|
8
|
+
|
|
9
|
+
export const GAME_PREFIX = "/Game/"
|
|
10
|
+
|
|
11
|
+
/** `/Game/A/B/Name` → `A/B/Name.uasset`. */
|
|
12
|
+
export const objectPathToFile = (objectPath: string): string | null => {
|
|
13
|
+
if (!objectPath.startsWith(GAME_PREFIX)) return null
|
|
14
|
+
const rel = objectPath.slice(GAME_PREFIX.length)
|
|
15
|
+
return rel ? `${rel.replace(/\//g, sep)}.uasset` : null
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type ResolveResult = { file: string, contentRoot: string } | null
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The `.uasset` an object path points at, searched from `fromDir` upwards.
|
|
22
|
+
* `contentRoot` short-circuits the search (`--content-root`).
|
|
23
|
+
*/
|
|
24
|
+
export const resolveObjectFile = (objectPath: string, fromDir: string, contentRoot?: string): ResolveResult => {
|
|
25
|
+
const rel = objectPathToFile(objectPath)
|
|
26
|
+
if (!rel) return null
|
|
27
|
+
if (contentRoot) {
|
|
28
|
+
const f = join(contentRoot, rel)
|
|
29
|
+
return existsSync(f) ? { file: f, contentRoot } : null
|
|
30
|
+
}
|
|
31
|
+
let dir = resolve(fromDir)
|
|
32
|
+
for (;;) {
|
|
33
|
+
const f = join(dir, rel)
|
|
34
|
+
if (existsSync(f) && statSync(f).isFile()) return { file: f, contentRoot: dir }
|
|
35
|
+
const up = dirname(dir)
|
|
36
|
+
if (up === dir) return null
|
|
37
|
+
dir = up
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The directories `resolveObjectFile` looked in — for the error message when it finds nothing. */
|
|
42
|
+
export const searchedRoots = (fromDir: string): string[] => {
|
|
43
|
+
const out: string[] = []
|
|
44
|
+
let dir = resolve(fromDir)
|
|
45
|
+
for (;;) {
|
|
46
|
+
out.push(dir)
|
|
47
|
+
const up = dirname(dir)
|
|
48
|
+
if (up === dir) return out
|
|
49
|
+
dir = up
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// An Unreal asset as a retargeter `Rig` (src/retarget/rig.ts) — built directly, not through a
|
|
2
|
+
// Document, so `retarget --from SKM_Manny.uasset` costs one file read.
|
|
3
|
+
//
|
|
4
|
+
// In Unreal the REFERENCE POSE IS THE BIND POSE, so bind == rest exactly; the inverse-bind matrices
|
|
5
|
+
// handed to `makeRig` are just the inverses of the rest world transforms. What they really carry is
|
|
6
|
+
// WHICH BONES THE SKIN USES: the classifiers read `fromSkin` as "part of the body", and a UE rig is
|
|
7
|
+
// full of bones no vertex is weighted to — `ik_foot_l`, `ik_hand_gun`, `interaction`,
|
|
8
|
+
// `center_of_mass`. With a mesh at hand those come out unskinned, exactly as they should; with only
|
|
9
|
+
// a `Skeleton` asset there is nothing to tell them apart, so every bone counts.
|
|
10
|
+
|
|
11
|
+
import { makeRig, qnorm, qslerp, type Clip, type Pose, type Quat, type Rig, type Vec3 } from "../retarget/rig"
|
|
12
|
+
import { boneInverseBindMatrices, unrealClipTracks, type UnrealTrack } from "./toDocument"
|
|
13
|
+
import { ueLocalTransform } from "./convert"
|
|
14
|
+
import type { UeSkeleton } from "./skeleton"
|
|
15
|
+
import type { UeSkeletalMesh } from "./mesh"
|
|
16
|
+
import type { UeAnimSequence } from "./anim"
|
|
17
|
+
import { loadUnreal, type LoadUnrealOptions } from "./index"
|
|
18
|
+
|
|
19
|
+
const sampleTrack = (tr: UnrealTrack, time: number): number[] => {
|
|
20
|
+
const n = tr.path === "rotation" ? 4 : 3
|
|
21
|
+
const last = tr.times.length - 1
|
|
22
|
+
const at = (i: number): number[] => Array.from(tr.values.subarray(i * n, i * n + n))
|
|
23
|
+
if (time <= tr.times[0]) return at(0)
|
|
24
|
+
if (time >= tr.times[last]) return at(last)
|
|
25
|
+
let i = 0
|
|
26
|
+
while (i < last && tr.times[i + 1] < time) i++
|
|
27
|
+
const u = (time - tr.times[i]) / (tr.times[i + 1] - tr.times[i] || 1)
|
|
28
|
+
const a = at(i), b = at(i + 1)
|
|
29
|
+
if (n === 4) return qslerp(a as Quat, b as Quat, u)
|
|
30
|
+
return a.map((v, k) => v + (b[k] - v) * u)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Add the bones that lie INSIDE the skinned subtree — a skinned ancestor above, a skinned bone
|
|
35
|
+
* somewhere below — to the body set.
|
|
36
|
+
*
|
|
37
|
+
* The UE5 mannequin weights its limbs to the twist helpers (`thigh_twist_01_l`, `upperarm_twist_02_r`)
|
|
38
|
+
* and leaves `thigh_l` / `upperarm_l` themselves without a single vertex. Taking "has weights"
|
|
39
|
+
* literally then cuts the legs and arms out of the body, and the humanoid classifier — which looks
|
|
40
|
+
* for a pelvis with two chains reaching the ground — reads the rig as not a biped at all. A bone
|
|
41
|
+
* between two skinned bones is part of the body whatever the weights say; a bone with nothing
|
|
42
|
+
* skinned below it (`ik_foot_root`, `ik_hand_gun`, `weapon_l`, `interaction`, `center_of_mass`) or
|
|
43
|
+
* nothing skinned above it (`root`) is not, which is exactly what those bones should read as.
|
|
44
|
+
*/
|
|
45
|
+
export const fillSkinnedInterior = (parents: number[], skinned: Set<number>): void => {
|
|
46
|
+
const order: number[] = [] // parents before children
|
|
47
|
+
const seen = new Set<number>()
|
|
48
|
+
const visit = (i: number): void => { if (seen.has(i)) return; if (parents[i] >= 0) visit(parents[i]); seen.add(i); order.push(i) }
|
|
49
|
+
parents.forEach((_, i) => visit(i))
|
|
50
|
+
const above = new Array(parents.length).fill(false)
|
|
51
|
+
for (const i of order) above[i] = parents[i] >= 0 && (skinned.has(parents[i]) || above[parents[i]])
|
|
52
|
+
const below = new Array(parents.length).fill(false)
|
|
53
|
+
for (let k = order.length - 1; k >= 0; k--) {
|
|
54
|
+
const i = order[k], p = parents[i]
|
|
55
|
+
if (p >= 0 && (skinned.has(i) || below[i])) below[p] = true
|
|
56
|
+
}
|
|
57
|
+
for (const i of order) if (!skinned.has(i) && above[i] && below[i]) skinned.add(i)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type UnrealRigInput = {
|
|
61
|
+
skeleton: UeSkeleton
|
|
62
|
+
/** When present, only the bones it weights count as "part of the body". */
|
|
63
|
+
mesh?: UeSkeletalMesh
|
|
64
|
+
clips?: UeAnimSequence[]
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const rigFromUnrealData = (label: string, input: UnrealRigInput): Rig => {
|
|
68
|
+
const { skeleton, mesh } = input
|
|
69
|
+
const names = skeleton.bones.map((b) => b.name)
|
|
70
|
+
const parents = skeleton.bones.map((b) => b.parent)
|
|
71
|
+
const local = skeleton.bones.map((b) => ueLocalTransform(b.t, b.q, b.s, b.parent < 0))
|
|
72
|
+
const rest: Pose = { t: local.map((l) => [ ...l.t ] as Vec3), r: local.map((l) => [ ...l.r ] as Quat), s: local.map((l) => [ ...l.s ] as Vec3) }
|
|
73
|
+
|
|
74
|
+
let skinned: Set<number> | null = null
|
|
75
|
+
if (mesh) {
|
|
76
|
+
const byName = new Map(names.map((n, i) => [ n, i ]))
|
|
77
|
+
skinned = new Set<number>()
|
|
78
|
+
for (const inf of mesh.influences) {
|
|
79
|
+
if (inf.weight <= 0) continue
|
|
80
|
+
const bone = mesh.bones[inf.bone]
|
|
81
|
+
const i = bone ? byName.get(bone.name) : undefined
|
|
82
|
+
if (i !== undefined) skinned.add(i)
|
|
83
|
+
}
|
|
84
|
+
fillSkinnedInterior(parents, skinned)
|
|
85
|
+
}
|
|
86
|
+
const ibm = boneInverseBindMatrices(skeleton.bones)
|
|
87
|
+
const ibmByNode = new Map<number, number[]>()
|
|
88
|
+
ibm.forEach((m, i) => { if (!skinned || skinned.has(i)) ibmByNode.set(i, m) })
|
|
89
|
+
|
|
90
|
+
const makeClips = (base: Pose): Clip[] => (input.clips ?? []).map((clip) => {
|
|
91
|
+
const { tracks } = unrealClipTracks(skeleton, clip)
|
|
92
|
+
return {
|
|
93
|
+
name: clip.name,
|
|
94
|
+
duration: clip.playLength,
|
|
95
|
+
sample: (time: number): Pose => {
|
|
96
|
+
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) }
|
|
97
|
+
for (const tr of tracks) {
|
|
98
|
+
const v = sampleTrack(tr, time)
|
|
99
|
+
if (tr.path === "translation") pose.t[tr.node] = v as Vec3
|
|
100
|
+
else if (tr.path === "rotation") pose.r[tr.node] = qnorm(v as Quat)
|
|
101
|
+
else pose.s[tr.node] = v as Vec3
|
|
102
|
+
}
|
|
103
|
+
return pose
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
return makeRig(label, names, parents, rest, makeClips, ibmByNode)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** `retarget --from x.uasset` / `--donor-rig x.uasset`: a mesh, a skeleton, or a clip + its skeleton. */
|
|
112
|
+
export const rigFromUnreal = (path: string, opts: LoadUnrealOptions = {}): Rig => {
|
|
113
|
+
const u = loadUnreal(path, opts)
|
|
114
|
+
return rigFromUnrealData(path.split(/[\\/]/).pop() ?? path, { skeleton: u.skeleton, mesh: u.mesh, clips: u.anim ? [ u.anim ] : [] })
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** `retarget --anim a.uasset b.uasset`: clips bound BY BONE NAME onto a donor rig loaded elsewhere. */
|
|
118
|
+
export const attachUnrealClips = (rig: Rig, skeleton: UeSkeleton, clips: UeAnimSequence[]): { clips: Clip[], report: { name: string, bound: number, total: number, unbound: string[] }[] } => {
|
|
119
|
+
const out: Clip[] = []
|
|
120
|
+
const report: { name: string, bound: number, total: number, unbound: string[] }[] = []
|
|
121
|
+
const base = rig.bind.local
|
|
122
|
+
for (const clip of clips) {
|
|
123
|
+
// convert on the CLIP's own skeleton, then move each track onto the rig's bone of that name
|
|
124
|
+
const { tracks, unbound } = unrealClipTracks(skeleton, clip)
|
|
125
|
+
const bound: UnrealTrack[] = []
|
|
126
|
+
const missing = new Set<string>(unbound)
|
|
127
|
+
for (const tr of tracks) {
|
|
128
|
+
const i = rig.index.get(skeleton.bones[tr.node].name)
|
|
129
|
+
if (i === undefined) { missing.add(skeleton.bones[tr.node].name); continue }
|
|
130
|
+
bound.push({ ...tr, node: i })
|
|
131
|
+
}
|
|
132
|
+
report.push({ name: clip.name, bound: new Set(bound.map((t) => t.node)).size, total: clip.tracks.length, unbound: [ ...missing ] })
|
|
133
|
+
out.push({
|
|
134
|
+
name: clip.name,
|
|
135
|
+
duration: clip.playLength,
|
|
136
|
+
sample: (time: number): Pose => {
|
|
137
|
+
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) }
|
|
138
|
+
for (const tr of bound) {
|
|
139
|
+
const v = sampleTrack(tr, time)
|
|
140
|
+
if (tr.path === "translation") pose.t[tr.node] = v as Vec3
|
|
141
|
+
else if (tr.path === "rotation") pose.r[tr.node] = qnorm(v as Quat)
|
|
142
|
+
else pose.s[tr.node] = v as Vec3
|
|
143
|
+
}
|
|
144
|
+
return pose
|
|
145
|
+
},
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
return { clips: out, report }
|
|
149
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// The OTHER data model: `AnimationSequencerDataModel`, what UE 5.2+ writes when a clip is authored
|
|
2
|
+
// or edited in Sequencer through a Control Rig. The motion is not bone tracks but a MovieScene
|
|
3
|
+
// section — `MovieSceneControlRigParameterSection.TransformParameterNamesAndCurves`, one element per
|
|
4
|
+
// FK control (`pelvis_CONTROL`), each holding nine `FMovieSceneFloatChannel`s: 3 translation,
|
|
5
|
+
// 3 rotation (EULER, degrees, `FRotator` order), 3 scale.
|
|
6
|
+
//
|
|
7
|
+
// A control's channels carry the bone's LOCAL transform in UE units, absolutely — not a delta
|
|
8
|
+
// against the reference pose — so once the channels are evaluated on a key grid the result is the
|
|
9
|
+
// same thing `AnimDataModel` hands over, and everything downstream (the coordinate recipe, clip
|
|
10
|
+
// merging, retargeting) is unchanged.
|
|
11
|
+
//
|
|
12
|
+
// `FMovieSceneFloatChannel` is serialized natively (no property tags), verified byte-for-byte
|
|
13
|
+
// against real files — the sizes below account for every byte of all 89 controls of a UE 5.3 clip:
|
|
14
|
+
//
|
|
15
|
+
// u8 PreInfinityExtrap, u8 PostInfinityExtrap
|
|
16
|
+
// BulkSerialize<FFrameNumber> i32 elemSize(4), i32 count, count × i32 frame numbers
|
|
17
|
+
// BulkSerialize<FMovieSceneFloatValue> i32 elemSize(28), i32 count, count × 28 B value + tangents
|
|
18
|
+
// f32 DefaultValue, i32 bHasDefaultValue, i32 TickNumerator, i32 TickDenominator, i32 (unused)
|
|
19
|
+
//
|
|
20
|
+
// A channel with no keys is a CONSTANT: its `DefaultValue` is the bone's own local component, which
|
|
21
|
+
// is why an unanimated bone still comes out in the right place.
|
|
22
|
+
|
|
23
|
+
import { BinaryReader, classOf, readObjectProperties, readArrayHeader, readFName, readProperties, propInt, type UePackage } from "./package"
|
|
24
|
+
|
|
25
|
+
/** `ERichCurveInterpMode`. */
|
|
26
|
+
export const RCIM_LINEAR = 0, RCIM_CONSTANT = 1, RCIM_CUBIC = 2
|
|
27
|
+
|
|
28
|
+
export type MovieSceneChannel = {
|
|
29
|
+
/** Frame numbers, in the channel's own tick rate. */
|
|
30
|
+
times: Int32Array
|
|
31
|
+
values: Float32Array
|
|
32
|
+
arriveTangent: Float32Array
|
|
33
|
+
leaveTangent: Float32Array
|
|
34
|
+
interp: Uint8Array
|
|
35
|
+
defaultValue: number
|
|
36
|
+
hasDefault: boolean
|
|
37
|
+
tickRate: number
|
|
38
|
+
/** Bytes the channel actually consumed — the caller checks it against the property's size. */
|
|
39
|
+
size: number
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const VALUE_SIZE = 28
|
|
43
|
+
|
|
44
|
+
/** One `FMovieSceneFloatChannel` at `at`. */
|
|
45
|
+
export const readFloatChannel = (bytes: Uint8Array, at: number, label = "channel"): MovieSceneChannel => {
|
|
46
|
+
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
|
47
|
+
let o = at + 2 // pre/post infinity extrapolation
|
|
48
|
+
const timeElem = dv.getInt32(o, true), timeCount = dv.getInt32(o + 4, true); o += 8
|
|
49
|
+
if (timeElem !== 4 || timeCount < 0 || o + timeCount * 4 > bytes.byteLength) {
|
|
50
|
+
throw new Error(`${label}: bad frame-number array (element ${timeElem}, count ${timeCount})`)
|
|
51
|
+
}
|
|
52
|
+
const times = new Int32Array(timeCount)
|
|
53
|
+
for (let i = 0; i < timeCount; i++) times[i] = dv.getInt32(o + i * 4, true)
|
|
54
|
+
o += timeCount * 4
|
|
55
|
+
const valueElem = dv.getInt32(o, true), valueCount = dv.getInt32(o + 4, true); o += 8
|
|
56
|
+
if (valueElem !== VALUE_SIZE || valueCount !== timeCount) {
|
|
57
|
+
throw new Error(`${label}: ${valueCount} values of ${valueElem} B for ${timeCount} keys — not a float channel`)
|
|
58
|
+
}
|
|
59
|
+
const values = new Float32Array(valueCount)
|
|
60
|
+
const arriveTangent = new Float32Array(valueCount)
|
|
61
|
+
const leaveTangent = new Float32Array(valueCount)
|
|
62
|
+
const interp = new Uint8Array(valueCount)
|
|
63
|
+
for (let i = 0; i < valueCount; i++) {
|
|
64
|
+
const b = o + i * VALUE_SIZE
|
|
65
|
+
values[i] = dv.getFloat32(b, true)
|
|
66
|
+
arriveTangent[i] = dv.getFloat32(b + 4, true)
|
|
67
|
+
leaveTangent[i] = dv.getFloat32(b + 8, true)
|
|
68
|
+
interp[i] = bytes[b + 24]
|
|
69
|
+
}
|
|
70
|
+
o += valueCount * VALUE_SIZE
|
|
71
|
+
const defaultValue = dv.getFloat32(o, true)
|
|
72
|
+
const hasDefault = dv.getInt32(o + 4, true) !== 0
|
|
73
|
+
const num = dv.getInt32(o + 8, true), den = dv.getInt32(o + 12, true)
|
|
74
|
+
o += 20 // + the trailing i32
|
|
75
|
+
return { times, values, arriveTangent, leaveTangent, interp, defaultValue, hasDefault, tickRate: den ? num / den : 0, size: o - at }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The channel's value at a time in SECONDS. Keys are held before the first and after the last (the
|
|
80
|
+
* clip's own extrapolation is not applied — a baked grid never leaves the range). Cubic segments are
|
|
81
|
+
* evaluated as the bezier UE builds from the tangents; weighted tangents are approximated by the
|
|
82
|
+
* unweighted curve, which is what a baked mocap clip's keys are anyway.
|
|
83
|
+
*/
|
|
84
|
+
export const sampleChannel = (c: MovieSceneChannel, time: number, fallback = 0): number => {
|
|
85
|
+
const n = c.times.length
|
|
86
|
+
if (n === 0) return c.hasDefault ? c.defaultValue : fallback
|
|
87
|
+
const rate = c.tickRate || 1
|
|
88
|
+
const first = c.times[0] / rate, last = c.times[n - 1] / rate
|
|
89
|
+
if (time <= first) return c.values[0]
|
|
90
|
+
if (time >= last) return c.values[n - 1]
|
|
91
|
+
let i = 0
|
|
92
|
+
while (i < n - 1 && c.times[i + 1] / rate < time) i++
|
|
93
|
+
const t0 = c.times[i] / rate, t1 = c.times[i + 1] / rate
|
|
94
|
+
const span = t1 - t0
|
|
95
|
+
const u = span > 0 ? (time - t0) / span : 0
|
|
96
|
+
const v0 = c.values[i], v1 = c.values[i + 1]
|
|
97
|
+
if (c.interp[i] === RCIM_CONSTANT) return v0
|
|
98
|
+
if (c.interp[i] !== RCIM_CUBIC) return v0 + (v1 - v0) * u
|
|
99
|
+
// UE's cubic: control points a third of the interval along each tangent (value per second)
|
|
100
|
+
const p1 = v0 + (c.leaveTangent[i] * span) / 3
|
|
101
|
+
const p2 = v1 - (c.arriveTangent[i + 1] * span) / 3
|
|
102
|
+
const w = 1 - u
|
|
103
|
+
return w * w * w * v0 + 3 * w * w * u * p1 + 3 * w * u * u * p2 + u * u * u * v1
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export type SequencerControl = {
|
|
107
|
+
/** `pelvis_CONTROL`. */
|
|
108
|
+
control: string
|
|
109
|
+
/** …and the bone it drives. */
|
|
110
|
+
bone: string
|
|
111
|
+
translation: (MovieSceneChannel | null)[]
|
|
112
|
+
rotation: (MovieSceneChannel | null)[]
|
|
113
|
+
scale: (MovieSceneChannel | null)[]
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export type SequencerSection = {
|
|
117
|
+
controls: SequencerControl[]
|
|
118
|
+
/** Frames per second of the MovieScene's own timeline (`DisplayRate`). */
|
|
119
|
+
displayRate: number
|
|
120
|
+
tickRate: number
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const frameRate = (r: BinaryReader, pkg: UePackage, props: ReturnType<typeof readProperties>, key: string): number | undefined => {
|
|
124
|
+
const tag = props.byName.get(key)
|
|
125
|
+
if (!tag) return undefined
|
|
126
|
+
r.offset = tag.data
|
|
127
|
+
const p = readProperties(r, pkg)
|
|
128
|
+
const num = propInt(r, p.byName.get("Numerator")), den = propInt(r, p.byName.get("Denominator")) ?? 1
|
|
129
|
+
return num && den ? num / den : undefined
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The MovieScene's own rates — the frame grid a Sequencer clip is authored on. */
|
|
133
|
+
export const readMovieSceneRates = (pkg: UePackage): { displayRate: number, tickRate: number } => {
|
|
134
|
+
const idx = pkg.exports.findIndex((e) => classOf(pkg, e) === "MovieScene")
|
|
135
|
+
if (idx < 0) return { displayRate: 30, tickRate: 0 }
|
|
136
|
+
const { reader: r, props } = readObjectProperties(pkg, pkg.exports[idx])
|
|
137
|
+
return {
|
|
138
|
+
displayRate: frameRate(r, pkg, props, "DisplayRate") ?? 30,
|
|
139
|
+
tickRate: frameRate(r, pkg, props, "TickResolution") ?? 0,
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Every FK control of the package's ControlRig section, with its nine channels. */
|
|
144
|
+
export const readSequencerSection = (pkg: UePackage): SequencerSection => {
|
|
145
|
+
const idx = pkg.exports.findIndex((e) => classOf(pkg, e) === "MovieSceneControlRigParameterSection")
|
|
146
|
+
if (idx < 0) throw new Error(`${pkg.label}: no MovieSceneControlRigParameterSection — not a Sequencer clip`)
|
|
147
|
+
const rates = readMovieSceneRates(pkg)
|
|
148
|
+
const { reader: r, props } = readObjectProperties(pkg, pkg.exports[idx])
|
|
149
|
+
const tag = props.byName.get("TransformParameterNamesAndCurves")
|
|
150
|
+
if (!tag) throw new Error(`${pkg.label}: the ControlRig section holds no transform curves (a curve-only or space-switch section?)`)
|
|
151
|
+
const { count } = readArrayHeader(r, pkg, tag)
|
|
152
|
+
const controls: SequencerControl[] = []
|
|
153
|
+
for (let i = 0; i < count; i++) {
|
|
154
|
+
const el = readProperties(r, pkg)
|
|
155
|
+
const after = r.offset
|
|
156
|
+
let control = ""
|
|
157
|
+
const nameTag = el.byName.get("ParameterName")
|
|
158
|
+
if (nameTag) { r.offset = nameTag.data; control = readFName(r, pkg.names) }
|
|
159
|
+
const slots: Record<string, (MovieSceneChannel | null)[]> = { Translation: [ null, null, null ], Rotation: [ null, null, null ], Scale: [ null, null, null ] }
|
|
160
|
+
for (const t of el.list) {
|
|
161
|
+
const slot = slots[t.name]
|
|
162
|
+
if (!slot || t.type !== "StructProperty" || t.arrayIndex < 0 || t.arrayIndex > 2) continue
|
|
163
|
+
const ch = readFloatChannel(pkg.bytes, t.data, `${pkg.label}:${control}.${t.name}[${t.arrayIndex}]`)
|
|
164
|
+
if (ch.size !== t.size) throw new Error(`${pkg.label}: ${control}.${t.name}[${t.arrayIndex}] read ${ch.size} of ${t.size} bytes — channel layout mismatch`)
|
|
165
|
+
slot[t.arrayIndex] = ch
|
|
166
|
+
}
|
|
167
|
+
if (control) {
|
|
168
|
+
controls.push({
|
|
169
|
+
control, bone: control.replace(/_CONTROL$/i, ""),
|
|
170
|
+
translation: slots.Translation, rotation: slots.Rotation, scale: slots.Scale,
|
|
171
|
+
})
|
|
172
|
+
}
|
|
173
|
+
r.offset = after
|
|
174
|
+
}
|
|
175
|
+
return { controls, ...rates }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* `FRotator` (Roll = X, Pitch = Y, Yaw = Z, degrees) → the quaternion UE builds from it. Straight
|
|
180
|
+
* out of `FRotator::Quaternion()` — the signs are not a convention to pick, they are that function.
|
|
181
|
+
*/
|
|
182
|
+
export const rotatorToQuat = (roll: number, pitch: number, yaw: number): [ number, number, number, number ] => {
|
|
183
|
+
const half = Math.PI / 360 // degrees → radians, halved
|
|
184
|
+
const sp = Math.sin(pitch * half), cp = Math.cos(pitch * half)
|
|
185
|
+
const sy = Math.sin(yaw * half), cy = Math.cos(yaw * half)
|
|
186
|
+
const sr = Math.sin(roll * half), cr = Math.cos(roll * half)
|
|
187
|
+
return [
|
|
188
|
+
cr * sp * sy - sr * cp * cy,
|
|
189
|
+
-cr * sp * cy - sr * cp * sy,
|
|
190
|
+
cr * cp * sy - sr * sp * cy,
|
|
191
|
+
cr * cp * cy + sr * sp * sy,
|
|
192
|
+
]
|
|
193
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// USkeleton → bones. A UE skeleton asset carries the reference skeleton: bone names, parents and
|
|
2
|
+
// the ref pose. In Unreal THE REFERENCE POSE IS THE BIND POSE (skinned meshes are bound to it), so
|
|
3
|
+
// a rig built from one has `bind = rest` and needs no inverse-bind matrices to be meaningful.
|
|
4
|
+
//
|
|
5
|
+
// Layout (§4.1): the tagged part (BoneTree, Sockets, …) is skipped, then the native
|
|
6
|
+
// `FReferenceSkeleton`: bone infos (name, parent, editor export name), then one `FTransform` per
|
|
7
|
+
// bone — LOCAL, UE frame, centimetres, doubles since ue5 1004 (LWC).
|
|
8
|
+
|
|
9
|
+
import { BinaryReader, findExport, readFName, readObjectProperties, type UePackage } from "./package"
|
|
10
|
+
|
|
11
|
+
export type UeBone = {
|
|
12
|
+
name: string
|
|
13
|
+
parent: number
|
|
14
|
+
/** LOCAL transform in the UE frame, centimetres. */
|
|
15
|
+
t: [ number, number, number ]
|
|
16
|
+
q: [ number, number, number, number ]
|
|
17
|
+
s: [ number, number, number ]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type UeSkeleton = {
|
|
21
|
+
/** The asset's name (`SK_Mannequin`). */
|
|
22
|
+
name: string
|
|
23
|
+
bones: UeBone[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* After a UObject's tag list comes an `i32` "has object guid" flag (always 0 in these packages).
|
|
28
|
+
* Some classes do not write it, so a non-0/1 value is treated as the class's own data and rewound.
|
|
29
|
+
*/
|
|
30
|
+
export const skipObjectGuid = (r: BinaryReader): void => {
|
|
31
|
+
const at = r.offset
|
|
32
|
+
const v = r.i32()
|
|
33
|
+
if (v === 0) return
|
|
34
|
+
if (v === 1) { r.skip(16); return }
|
|
35
|
+
r.offset = at
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** `FReferenceSkeleton` (editor build) at the reader's position. */
|
|
39
|
+
export const readReferenceSkeleton = (r: BinaryReader, names: string[], label: string): UeBone[] => {
|
|
40
|
+
const boneCount = r.i32()
|
|
41
|
+
if (boneCount < 0 || boneCount > 65536) throw new Error(`${label}: implausible bone count ${boneCount} at ${r.offset - 4}`)
|
|
42
|
+
const bones: UeBone[] = new Array(boneCount)
|
|
43
|
+
for (let i = 0; i < boneCount; i++) {
|
|
44
|
+
const name = readFName(r, names)
|
|
45
|
+
const parent = r.i32()
|
|
46
|
+
r.fstring() // exportName (editor only)
|
|
47
|
+
if (parent < -1 || parent >= boneCount) throw new Error(`${label}: bone ${i} ("${name}") has parent ${parent}`)
|
|
48
|
+
bones[i] = { name, parent, t: [ 0, 0, 0 ], q: [ 0, 0, 0, 1 ], s: [ 1, 1, 1 ] }
|
|
49
|
+
}
|
|
50
|
+
const poseCount = r.i32()
|
|
51
|
+
if (poseCount !== boneCount) throw new Error(`${label}: reference pose has ${poseCount} transforms for ${boneCount} bones`)
|
|
52
|
+
for (let i = 0; i < poseCount; i++) {
|
|
53
|
+
const q = r.doubles(4) // FTransform: rotation, translation, scale
|
|
54
|
+
const t = r.doubles(3)
|
|
55
|
+
const s = r.doubles(3)
|
|
56
|
+
bones[i].q = [ q[0], q[1], q[2], q[3] ]
|
|
57
|
+
bones[i].t = [ t[0], t[1], t[2] ]
|
|
58
|
+
bones[i].s = [ s[0], s[1], s[2] ]
|
|
59
|
+
}
|
|
60
|
+
return bones
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The `Skeleton` export of a package (a `SK_*.uasset`). */
|
|
64
|
+
export const readSkeleton = (pkg: UePackage): UeSkeleton => {
|
|
65
|
+
const found = findExport(pkg, "Skeleton")
|
|
66
|
+
if (!found) throw new Error(`${pkg.label}: no Skeleton export (classes: ${pkg.exports.map((e) => e.objectName).join(", ")})`)
|
|
67
|
+
const { reader: r } = readObjectProperties(pkg, found.export) // BoneTree, Sockets, PreviewSkeletalMesh, …
|
|
68
|
+
skipObjectGuid(r)
|
|
69
|
+
const bones = readReferenceSkeleton(r, pkg.names, pkg.label)
|
|
70
|
+
return { name: found.export.objectName, bones }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Bones with no parent — `root` for a UE rig, but a skeleton may carry several. */
|
|
74
|
+
export const rootBones = (sk: UeSkeleton): number[] => sk.bones.map((b, i) => (b.parent < 0 ? i : -1)).filter((i) => i >= 0)
|