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,284 @@
|
|
|
1
|
+
// AnimSequence → tracks. The raw, uncompressed bone curves an editor asset keeps in its
|
|
2
|
+
// `AnimDataModel` (§4.2): one track per animated bone, keyed by bone NAME, local space, UE frame,
|
|
3
|
+
// centimetres. `NumberOfKeys` = `NumberOfFrames` + 1 — key i sits at i / fps and the last key at
|
|
4
|
+
// `PlayLength`; a track with a single key is constant for the whole clip.
|
|
5
|
+
//
|
|
6
|
+
// Two data models exist in the wild and both are read here. `AnimDataModel` (the FBX-import
|
|
7
|
+
// default) keeps bone tracks; `AnimationSequencerDataModel` (UE 5.2+, a clip authored or edited in
|
|
8
|
+
// Sequencer) keeps the same motion as MovieScene ControlRig channels — `sequencer.ts` evaluates
|
|
9
|
+
// those onto the same grid, so callers never learn which one a file used.
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
classOf, readProperties, readObjectProperties, readArrayHeader, readFloatArray,
|
|
13
|
+
propFloat, propInt, propName, propObject, propEnum, resolveObject, findExport,
|
|
14
|
+
type PropertyTag, type UePackage, type BinaryReader,
|
|
15
|
+
} from "./package"
|
|
16
|
+
import { readSequencerSection, rotatorToQuat, sampleChannel } from "./sequencer"
|
|
17
|
+
|
|
18
|
+
export type UeTrack = {
|
|
19
|
+
/** Bone name — the only binding key; `boneIndex` is the skeleton's own index, a cross-check. */
|
|
20
|
+
name: string
|
|
21
|
+
boneIndex: number
|
|
22
|
+
/** n × 3 (cm), n × 4 (x,y,z,w), n × 3 — each either `keys` long or 1 (constant), or empty. */
|
|
23
|
+
pos: Float32Array
|
|
24
|
+
rot: Float32Array
|
|
25
|
+
scale: Float32Array
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type UeClipEvent = { name: string, time: number, kind: "marker" | "notify" }
|
|
29
|
+
|
|
30
|
+
export type UeAnimSequence = {
|
|
31
|
+
name: string
|
|
32
|
+
fps: number
|
|
33
|
+
numFrames: number
|
|
34
|
+
numKeys: number
|
|
35
|
+
playLength: number
|
|
36
|
+
tracks: UeTrack[]
|
|
37
|
+
/** `/Game/…/SK_Mannequin` — the skeleton asset this clip was authored on (§6.3 resolves it). */
|
|
38
|
+
skeletonPath: string
|
|
39
|
+
skeletonName: string
|
|
40
|
+
rootMotion: boolean
|
|
41
|
+
/** `AAT_None` → null; anything else means the poses are DELTAS against a base pose. */
|
|
42
|
+
additive: string | null
|
|
43
|
+
interpolation: "Linear" | "Step"
|
|
44
|
+
rateScale: number
|
|
45
|
+
events: UeClipEvent[]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** What an AnimSequence says about itself, tracks aside (`readAnimMeta`). */
|
|
49
|
+
export type UeAnimMeta = {
|
|
50
|
+
name: string
|
|
51
|
+
export: NonNullable<ReturnType<typeof findExport>>
|
|
52
|
+
props: ReturnType<typeof readProperties>
|
|
53
|
+
/** Positioned reader over the export — the caller may dig further into `props`. */
|
|
54
|
+
reader: BinaryReader
|
|
55
|
+
skeletonPath: string
|
|
56
|
+
skeletonName: string
|
|
57
|
+
rootMotion: boolean
|
|
58
|
+
additive: string | null
|
|
59
|
+
interpolation: "Linear" | "Step"
|
|
60
|
+
rateScale: number
|
|
61
|
+
events: UeClipEvent[]
|
|
62
|
+
sequenceLength: number
|
|
63
|
+
targetFps: number | undefined
|
|
64
|
+
dataModel: DataModelKind
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const timeOfKey = (i: number, fps: number, numKeys: number, playLength: number): number =>
|
|
68
|
+
(numKeys <= 1 ? 0 : Math.min(i / fps, playLength))
|
|
69
|
+
|
|
70
|
+
/** Key times of a clip: i / fps, the last one clamped to PlayLength. */
|
|
71
|
+
export const keyTimes = (a: UeAnimSequence): Float32Array => {
|
|
72
|
+
const out = new Float32Array(a.numKeys)
|
|
73
|
+
for (let i = 0; i < a.numKeys; i++) out[i] = timeOfKey(i, a.fps, a.numKeys, a.playLength)
|
|
74
|
+
return out
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** `FrameRate` is a TAGGED struct — Denominator is omitted when it is 1. */
|
|
78
|
+
const readFrameRate = (r: BinaryReader, pkg: UePackage, tag: PropertyTag | undefined): number | undefined => {
|
|
79
|
+
if (!tag) return undefined
|
|
80
|
+
r.offset = tag.data
|
|
81
|
+
const p = readProperties(r, pkg)
|
|
82
|
+
const num = propInt(r, p.byName.get("Numerator"))
|
|
83
|
+
const den = propInt(r, p.byName.get("Denominator")) ?? 1
|
|
84
|
+
return num && den ? num / den : undefined
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Array of TAGGED structs: `{ name, time }` pairs out of AnimSyncMarker / AnimNotifyEvent. */
|
|
88
|
+
const readEvents = (r: BinaryReader, pkg: UePackage, tag: PropertyTag | undefined, kind: "marker" | "notify"): UeClipEvent[] => {
|
|
89
|
+
if (!tag) return []
|
|
90
|
+
const { names } = pkg
|
|
91
|
+
const out: UeClipEvent[] = []
|
|
92
|
+
const { count } = readArrayHeader(r, pkg, tag)
|
|
93
|
+
for (let i = 0; i < count; i++) {
|
|
94
|
+
const p = readProperties(r, pkg)
|
|
95
|
+
const after = r.offset
|
|
96
|
+
const name = propName(r, names, p.byName.get("MarkerName")) ?? propName(r, names, p.byName.get("NotifyName")) ?? `${kind}${i}`
|
|
97
|
+
// AnimSyncMarker keeps `Time`; an AnimNotifyEvent's time is its linked value (older assets:
|
|
98
|
+
// DisplayTime). Whichever is present wins; the events are informational either way.
|
|
99
|
+
const time = propFloat(r, p.byName.get("Time")) ?? propFloat(r, p.byName.get("LinkValue")) ?? propFloat(r, p.byName.get("DisplayTime_DEPRECATED")) ?? 0
|
|
100
|
+
if (name !== "None") out.push({ name, time, kind })
|
|
101
|
+
r.offset = after
|
|
102
|
+
}
|
|
103
|
+
return out
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** One `RawAnimSequenceTrack` (tagged since UE5): PosKeys / RotKeys / ScaleKeys. */
|
|
107
|
+
const readTrackData = (r: BinaryReader, pkg: UePackage, tag: PropertyTag): { pos: Float32Array, rot: Float32Array, scale: Float32Array } => {
|
|
108
|
+
r.offset = tag.data
|
|
109
|
+
const p = readProperties(r, pkg)
|
|
110
|
+
const grab = (key: string, comps: number): Float32Array => {
|
|
111
|
+
const t = p.byName.get(key)
|
|
112
|
+
if (!t) return new Float32Array(0)
|
|
113
|
+
const a = readFloatArray(r, pkg, t)
|
|
114
|
+
if (a.comps !== comps) throw new Error(`${r.label}: ${key} holds ${a.comps}-component keys, expected ${comps}`)
|
|
115
|
+
return a.values
|
|
116
|
+
}
|
|
117
|
+
return { pos: grab("PosKeys", 3), rot: grab("RotKeys", 4), scale: grab("ScaleKeys", 3) }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* A track whose keys are all the same value, collapsed to the one key UE would normally have
|
|
122
|
+
* stored for it. Some packs write a static pose as N identical keys with a key count that does
|
|
123
|
+
* not match the model's (the Archer pack's aim-offset cells: 2 identical keys, `NumberOfKeys 3`),
|
|
124
|
+
* and a constant track is a constant track whatever the count says. Anything that actually varies
|
|
125
|
+
* is left alone, so a real count mismatch still reaches the check below.
|
|
126
|
+
*/
|
|
127
|
+
const collapseConstant = (a: Float32Array, comps: number): Float32Array => {
|
|
128
|
+
const n = a.length / comps
|
|
129
|
+
if (n < 2) return a
|
|
130
|
+
for (let k = comps; k < a.length; k++) if (Math.abs(a[k] - a[k % comps]) > 1e-6) return a
|
|
131
|
+
return a.slice(0, comps)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export type DataModelKind = "AnimDataModel" | "AnimationSequencerDataModel" | "none"
|
|
135
|
+
|
|
136
|
+
/** Which data model an AnimSequence uses — what decides whether it can be read at all. */
|
|
137
|
+
export const dataModelKind = (pkg: UePackage): DataModelKind => {
|
|
138
|
+
for (const e of pkg.exports) {
|
|
139
|
+
const c = classOf(pkg, e)
|
|
140
|
+
if (c === "AnimDataModel" || c === "AnimationSequencerDataModel") return c
|
|
141
|
+
}
|
|
142
|
+
return "none"
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* A Sequencer-authored clip: the motion sits in a MovieScene ControlRig section instead of bone
|
|
147
|
+
* tracks (`sequencer.ts`). The channels are evaluated on the clip's own frame grid, which produces
|
|
148
|
+
* exactly what `AnimDataModel` hands over — same bones, same key times, same units — so nothing
|
|
149
|
+
* downstream can tell the two apart.
|
|
150
|
+
*/
|
|
151
|
+
const sequencerAnimSequence = (pkg: UePackage, meta: UeAnimMeta): UeAnimSequence => {
|
|
152
|
+
let section: ReturnType<typeof readSequencerSection>
|
|
153
|
+
try { section = readSequencerSection(pkg) } catch (e) {
|
|
154
|
+
throw new Error(`${pkg.label}: ${(e as Error).message}. ${SEQUENCER_MODEL_MESSAGE}`)
|
|
155
|
+
}
|
|
156
|
+
const fps = section.displayRate || meta.targetFps || 30
|
|
157
|
+
const playLength = meta.sequenceLength
|
|
158
|
+
const numFrames = Math.max(0, Math.round(playLength * fps))
|
|
159
|
+
const numKeys = numFrames + 1
|
|
160
|
+
const tracks: UeTrack[] = []
|
|
161
|
+
for (const c of section.controls) {
|
|
162
|
+
const animated = [ ...c.translation, ...c.rotation, ...c.scale ].some((ch) => ch && ch.times.length > 0)
|
|
163
|
+
const keys = animated ? numKeys : 1
|
|
164
|
+
const pos = new Float32Array(keys * 3), rot = new Float32Array(keys * 4), scale = new Float32Array(keys * 3)
|
|
165
|
+
for (let k = 0; k < keys; k++) {
|
|
166
|
+
const time = k / fps
|
|
167
|
+
for (let axis = 0; axis < 3; axis++) {
|
|
168
|
+
const t = c.translation[axis], s = c.scale[axis]
|
|
169
|
+
pos[k * 3 + axis] = t ? sampleChannel(t, time, 0) : 0
|
|
170
|
+
scale[k * 3 + axis] = s ? sampleChannel(s, time, 1) : 1
|
|
171
|
+
}
|
|
172
|
+
// rotation channels are an FRotator in degrees: [0] roll (X), [1] pitch (Y), [2] yaw (Z)
|
|
173
|
+
const e = [ 0, 1, 2 ].map((axis) => (c.rotation[axis] ? sampleChannel(c.rotation[axis]!, time, 0) : 0))
|
|
174
|
+
const q = rotatorToQuat(e[0], e[1], e[2])
|
|
175
|
+
rot.set(q, k * 4)
|
|
176
|
+
}
|
|
177
|
+
tracks.push({ name: c.bone, boneIndex: -1, pos, rot, scale })
|
|
178
|
+
}
|
|
179
|
+
return {
|
|
180
|
+
name: meta.name, fps, numFrames, numKeys, playLength,
|
|
181
|
+
tracks,
|
|
182
|
+
skeletonPath: meta.skeletonPath, skeletonName: meta.skeletonName,
|
|
183
|
+
rootMotion: meta.rootMotion, additive: meta.additive, interpolation: meta.interpolation,
|
|
184
|
+
rateScale: meta.rateScale, events: meta.events,
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export const SEQUENCER_MODEL_MESSAGE =
|
|
189
|
+
"this clip uses the Sequencer data model, and its ControlRig section could not be read. " +
|
|
190
|
+
"Re-save the asset in UE with 'Animation Sequencer Data Model' disabled, or export it to FBX."
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Everything an AnimSequence says about ITSELF — length, rate, root motion, sync markers, the
|
|
194
|
+
* skeleton it belongs to — without touching the data model that holds the tracks. Kept separate
|
|
195
|
+
* because a Sequencer-model clip's metadata is perfectly readable even though its motion is not:
|
|
196
|
+
* `probe` can then say what the clip is before saying why its tracks cannot be had.
|
|
197
|
+
*/
|
|
198
|
+
export const readAnimMeta = (pkg: UePackage): UeAnimMeta => {
|
|
199
|
+
const found = findExport(pkg, "AnimSequence")
|
|
200
|
+
if (!found) throw new Error(`${pkg.label}: no AnimSequence export (exports: ${pkg.exports.map((e) => `${e.objectName}:${classOf(pkg, e)}`).join(", ")})`)
|
|
201
|
+
const { reader: r, props: seq } = readObjectProperties(pkg, found.export)
|
|
202
|
+
const skelRef = resolveObject(pkg, propObject(r, seq.byName.get("Skeleton")) ?? 0)
|
|
203
|
+
const additiveRaw = propEnum(r, pkg.names, seq.byName.get("AdditiveAnimType"))
|
|
204
|
+
const interpRaw = propEnum(r, pkg.names, seq.byName.get("Interpolation"))
|
|
205
|
+
return {
|
|
206
|
+
name: found.export.objectName,
|
|
207
|
+
export: found,
|
|
208
|
+
props: seq,
|
|
209
|
+
reader: r,
|
|
210
|
+
skeletonPath: skelRef?.packagePath ?? "",
|
|
211
|
+
skeletonName: skelRef?.name ?? "",
|
|
212
|
+
rootMotion: seq.byName.get("bEnableRootMotion")?.bool ?? false,
|
|
213
|
+
additive: additiveRaw && !/AAT_None|^0$/.test(additiveRaw) ? additiveRaw : null,
|
|
214
|
+
interpolation: interpRaw && /Step/i.test(interpRaw) ? "Step" : "Linear",
|
|
215
|
+
rateScale: propFloat(r, seq.byName.get("RateScale")) ?? 1,
|
|
216
|
+
events: [
|
|
217
|
+
...readEvents(r, pkg, seq.byName.get("AuthoredSyncMarkers"), "marker"),
|
|
218
|
+
...readEvents(r, pkg, seq.byName.get("Notifies"), "notify"),
|
|
219
|
+
].sort((a, b) => a.time - b.time),
|
|
220
|
+
sequenceLength: propFloat(r, seq.byName.get("SequenceLength")) ?? 0,
|
|
221
|
+
targetFps: readFrameRate(r, pkg, seq.byName.get("TargetFrameRate")),
|
|
222
|
+
dataModel: dataModelKind(pkg),
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** The `AnimSequence` export of a package (an `A_*.uasset`), with its raw tracks. */
|
|
227
|
+
export const readAnimSequence = (pkg: UePackage): UeAnimSequence => {
|
|
228
|
+
const meta = readAnimMeta(pkg)
|
|
229
|
+
const { reader: r, props: seq, rootMotion, additive, interpolation, rateScale, events, sequenceLength, targetFps } = meta
|
|
230
|
+
|
|
231
|
+
// Which data model: the DataModel property points at the export that holds the tracks; a 5.3
|
|
232
|
+
// sequencer asset instead carries DataModelInterface and a MovieScene export.
|
|
233
|
+
const modelRef = resolveObject(pkg, propObject(r, seq.byName.get("DataModel")) ?? 0)
|
|
234
|
+
const kind = meta.dataModel
|
|
235
|
+
if (kind === "AnimationSequencerDataModel") return sequencerAnimSequence(pkg, meta)
|
|
236
|
+
const model = modelRef?.kind === "export" ? pkg.exports[modelRef.index] : findExport(pkg, "AnimDataModel")?.export
|
|
237
|
+
if (!model) throw new Error(`${pkg.label}: the AnimSequence names no readable data model (found ${kind})`)
|
|
238
|
+
|
|
239
|
+
const { reader: mr, props: m } = readObjectProperties(pkg, model)
|
|
240
|
+
const playLength = propFloat(mr, m.byName.get("PlayLength")) ?? sequenceLength
|
|
241
|
+
const numFrames = propInt(mr, m.byName.get("NumberOfFrames")) ?? 0
|
|
242
|
+
const numKeys = propInt(mr, m.byName.get("NumberOfKeys")) ?? numFrames + 1
|
|
243
|
+
const fps = readFrameRate(mr, pkg, m.byName.get("FrameRate")) ?? targetFps ?? (playLength > 0 ? numFrames / playLength : 30)
|
|
244
|
+
|
|
245
|
+
const tracks: UeTrack[] = []
|
|
246
|
+
const tracksTag = m.byName.get("BoneAnimationTracks")
|
|
247
|
+
if (tracksTag) {
|
|
248
|
+
const { count } = readArrayHeader(mr, pkg, tracksTag)
|
|
249
|
+
for (let i = 0; i < count; i++) {
|
|
250
|
+
const el = readProperties(mr, pkg)
|
|
251
|
+
const after = mr.offset
|
|
252
|
+
const name = propName(mr, pkg.names, el.byName.get("Name")) ?? ""
|
|
253
|
+
const boneIndex = propInt(mr, el.byName.get("BoneTreeIndex")) ?? -1
|
|
254
|
+
const data = el.byName.get("InternalTrackData")
|
|
255
|
+
const keys = data ? readTrackData(mr, pkg, data) : { pos: new Float32Array(0), rot: new Float32Array(0), scale: new Float32Array(0) }
|
|
256
|
+
for (const [ key, comps, field ] of [ [ "PosKeys", 3, "pos" ], [ "RotKeys", 4, "rot" ], [ "ScaleKeys", 3, "scale" ] ] as const) {
|
|
257
|
+
let arr = keys[field]
|
|
258
|
+
let n = arr.length / comps
|
|
259
|
+
// an off-count track that never actually varies is a constant one written long-hand
|
|
260
|
+
if (n !== 0 && n !== 1 && n !== numKeys) { arr = collapseConstant(arr, comps); n = arr.length / comps; keys[field] = arr }
|
|
261
|
+
if (n !== 0 && n !== 1 && n !== numKeys) {
|
|
262
|
+
throw new Error(`${pkg.label}: bone "${name}" has ${n} ${key} for a ${numKeys}-key clip — unexpected key count`)
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (name) tracks.push({ name, boneIndex, ...keys })
|
|
266
|
+
mr.offset = after
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
name: meta.name,
|
|
272
|
+
fps, numFrames, numKeys, playLength: playLength || (numFrames && fps ? numFrames / fps : 0),
|
|
273
|
+
tracks,
|
|
274
|
+
skeletonPath: meta.skeletonPath,
|
|
275
|
+
skeletonName: meta.skeletonName,
|
|
276
|
+
rootMotion, additive, interpolation, rateScale, events,
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Cheap classification for `probe` / listings: what an export table says a package holds. */
|
|
281
|
+
export const packageKind = (pkg: UePackage): "AnimSequence" | "SkeletalMesh" | "Skeleton" | "Texture2D" | "other" => {
|
|
282
|
+
for (const want of [ "AnimSequence", "SkeletalMesh", "Skeleton", "Texture2D" ] as const) if (findExport(pkg, want)) return want
|
|
283
|
+
return "other"
|
|
284
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// An Unreal AnimSequence as a `ClipSource` — the shape `mergeClipScene` merges into an existing
|
|
2
|
+
// model by bone name. Same conversion as the document builder uses (`unrealClipTracks`), so a
|
|
3
|
+
// `.uasset` clip lands identically whether it is written out on its own skeleton or merged onto a
|
|
4
|
+
// character that came from somewhere else.
|
|
5
|
+
//
|
|
6
|
+
// `isRoot` is false for every node: it marks ufbx's synthetic scene root, which Unreal data has
|
|
7
|
+
// none of — UE's `root` is a real bone whose track must be merged like any other.
|
|
8
|
+
|
|
9
|
+
import type { ClipSource } from "../fbx/convert"
|
|
10
|
+
import type { UeSkeleton } from "./skeleton"
|
|
11
|
+
import type { UeAnimSequence } from "./anim"
|
|
12
|
+
import { unrealClipTracks, clipExtras } from "./toDocument"
|
|
13
|
+
import { ueLocalTransform } from "./convert"
|
|
14
|
+
|
|
15
|
+
export const clipSourceFromUnreal = (skeleton: UeSkeleton, clip: UeAnimSequence): ClipSource => {
|
|
16
|
+
const { tracks } = unrealClipTracks(skeleton, clip)
|
|
17
|
+
return {
|
|
18
|
+
nodes: skeleton.bones.map((b) => {
|
|
19
|
+
const l = ueLocalTransform(b.t, b.q, b.s, b.parent < 0)
|
|
20
|
+
return { name: b.name, parent: b.parent, t: l.t, r: l.r, s: l.s, isRoot: false }
|
|
21
|
+
}),
|
|
22
|
+
animations: [ { name: clip.name, duration: clip.playLength, tracks } ],
|
|
23
|
+
extras: () => clipExtras(clip),
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// UE frame → glTF frame. The ONE place the axis/unit convention lives.
|
|
2
|
+
//
|
|
3
|
+
// Unreal is centimetres, X-forward / Y-right / Z-up, LEFT-handed; glTF is metres, Y-up,
|
|
4
|
+
// right-handed. The recipe below is not a free choice: a GLB made from a `.uasset` has to be
|
|
5
|
+
// interchangeable with the GLB the FBX route makes from the same pack's FBX sources, because both
|
|
6
|
+
// feed `--clips` merging by bone name and the retargeter. UE's FBX exporter mirrors Y (left-handed
|
|
7
|
+
// → right-handed Z-up) and ufbx then turns Z-up into Y-up by rotating the root −90° about X, so
|
|
8
|
+
// that is exactly what is reproduced here:
|
|
9
|
+
//
|
|
10
|
+
// S = 0.01 (cm → m). M = mirror Y. Rx = −90° about X, qRx = (−√½, 0, 0, √½).
|
|
11
|
+
// non-root bone (local transform and every local animation key):
|
|
12
|
+
// t' = ( tx, −ty, tz ) · S
|
|
13
|
+
// q' = ( −qx, qy, −qz, qw ) // M q M⁻¹ for an improper M: axis mirrored, angle negated
|
|
14
|
+
// root bone (local = world):
|
|
15
|
+
// t' = Rx · ( tx, −ty, tz ) · S = ( tx, tz, ty ) · S
|
|
16
|
+
// q' = qRx ⊗ ( −qx, qy, −qz, qw )
|
|
17
|
+
// mesh points / normals (already in the skeleton's world = bind space):
|
|
18
|
+
// p' = ( px, pz, py ) · S ; n' = ( nx, nz, ny )
|
|
19
|
+
//
|
|
20
|
+
// Verified numerically against the FBX route on all 267 channels of a real clip: worst translation
|
|
21
|
+
// error 1.1e-7 m, worst rotation 0.30° (the FBX route's own key reduction). docs/unreal-assets-plan.md §5.
|
|
22
|
+
|
|
23
|
+
export type Vec3 = [ number, number, number ]
|
|
24
|
+
export type Quat = [ number, number, number, number ]
|
|
25
|
+
|
|
26
|
+
/** cm → m. */
|
|
27
|
+
export const UE_SCALE = 0.01
|
|
28
|
+
|
|
29
|
+
const R2 = Math.SQRT1_2
|
|
30
|
+
/** −90° about X: the Z-up → Y-up turn, applied to root bones only. */
|
|
31
|
+
export const Q_ZUP_TO_YUP: Quat = [ -R2, 0, 0, R2 ]
|
|
32
|
+
|
|
33
|
+
/** Hamilton product (x, y, z, w). */
|
|
34
|
+
export const qmul = (a: Quat, b: Quat): Quat => [
|
|
35
|
+
a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1],
|
|
36
|
+
a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0],
|
|
37
|
+
a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3],
|
|
38
|
+
a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2],
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
/** A UE translation in a bone's LOCAL frame → glTF, metres. */
|
|
42
|
+
export const ueTranslation = (x: number, y: number, z: number): Vec3 => [ x * UE_SCALE, -y * UE_SCALE, z * UE_SCALE ]
|
|
43
|
+
|
|
44
|
+
/** A UE rotation in a bone's LOCAL frame → glTF (the Y mirror flips the axis and the angle). */
|
|
45
|
+
export const ueQuat = (x: number, y: number, z: number, w: number): Quat => [ -x, y, -z, w ]
|
|
46
|
+
|
|
47
|
+
/** A ROOT bone's translation → glTF: the mirror, then the Z-up → Y-up turn. */
|
|
48
|
+
export const ueRootTranslation = (x: number, y: number, z: number): Vec3 => [ x * UE_SCALE, z * UE_SCALE, y * UE_SCALE ]
|
|
49
|
+
|
|
50
|
+
/** A ROOT bone's rotation → glTF. */
|
|
51
|
+
export const ueRootQuat = (x: number, y: number, z: number, w: number): Quat => qmul(Q_ZUP_TO_YUP, ueQuat(x, y, z, w))
|
|
52
|
+
|
|
53
|
+
/** A point/vector already in the skeleton's WORLD (bind) space → glTF, metres. */
|
|
54
|
+
export const ueWorldPoint = (x: number, y: number, z: number): Vec3 => [ x * UE_SCALE, z * UE_SCALE, y * UE_SCALE ]
|
|
55
|
+
|
|
56
|
+
/** A direction already in the skeleton's WORLD space (a normal) → glTF; length preserved. */
|
|
57
|
+
export const ueWorldDir = (x: number, y: number, z: number): Vec3 => [ x, z, y ]
|
|
58
|
+
|
|
59
|
+
/** UE scale is per-axis and handedness-free. */
|
|
60
|
+
export const ueScale = (x: number, y: number, z: number): Vec3 => [ x, y, z ]
|
|
61
|
+
|
|
62
|
+
/** Local transform of one bone (root or not) → glTF. */
|
|
63
|
+
export const ueLocalTransform = (t: Vec3 | number[], q: Quat | number[], s: Vec3 | number[], isRoot: boolean): { t: Vec3, r: Quat, s: Vec3 } => ({
|
|
64
|
+
t: isRoot ? ueRootTranslation(t[0], t[1], t[2]) : ueTranslation(t[0], t[1], t[2]),
|
|
65
|
+
r: isRoot ? ueRootQuat(q[0], q[1], q[2], q[3]) : ueQuat(q[0], q[1], q[2], q[3]),
|
|
66
|
+
s: ueScale(s[0], s[1], s[2]),
|
|
67
|
+
})
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// `convert <x.uasset>` — the asset-level entry point, the counterpart of `convertFbx`.
|
|
2
|
+
//
|
|
3
|
+
// SkeletalMesh → mesh + skin + its own bone list
|
|
4
|
+
// Skeleton → the skeleton alone
|
|
5
|
+
// AnimSequence → the skeleton it was authored on (found in the pack, §6.3) + that clip
|
|
6
|
+
//
|
|
7
|
+
// `--clips` files (FBX or `.uasset`) are merged onto the result by bone name through the same
|
|
8
|
+
// `mergeClipScene` the FBX route uses.
|
|
9
|
+
|
|
10
|
+
import { basename } from "node:path"
|
|
11
|
+
import type { Document } from "@gltf-transform/core"
|
|
12
|
+
import { mergeClipScene, loadClipSource, type ConvertReport } from "../fbx/convert"
|
|
13
|
+
import type { ConvertWarning } from "../fbx/toDocument"
|
|
14
|
+
import { loadUnreal, type LoadUnrealOptions } from "./index"
|
|
15
|
+
import { unrealToDocument } from "./toDocument"
|
|
16
|
+
import { meshBounds } from "./mesh"
|
|
17
|
+
import { applyUnrealTextures } from "./materials"
|
|
18
|
+
|
|
19
|
+
export type ConvertUnrealOptions = LoadUnrealOptions & {
|
|
20
|
+
clips?: string[]
|
|
21
|
+
clipNames?: string[]
|
|
22
|
+
rename?: Record<string, string>
|
|
23
|
+
dropModelClips?: boolean
|
|
24
|
+
/** Pull the pack's textures onto the materials (default: on when a decoder is available). */
|
|
25
|
+
textures?: boolean
|
|
26
|
+
/** Longest texture edge kept (default 2048, 0 = the source's own size). */
|
|
27
|
+
maxSize?: number
|
|
28
|
+
/** UE authors normal maps DirectX-style; glTF wants OpenGL. Default: flip. */
|
|
29
|
+
flipNormalGreen?: boolean
|
|
30
|
+
/** Last-resort object-path lookup for hosts with no pack on disk (see `TextureOptions`). */
|
|
31
|
+
resolveAsset?: (objectPath: string) => string | null
|
|
32
|
+
warn?: (w: ConvertWarning) => void
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type ConvertUnrealResult = {
|
|
36
|
+
doc: Document
|
|
37
|
+
report: { clips: ConvertReport["clips"] }
|
|
38
|
+
/** One line for the CLI: what was read and where its skeleton came from. */
|
|
39
|
+
info: string
|
|
40
|
+
textures?: Awaited<ReturnType<typeof applyUnrealTextures>>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const convertUnreal = async (path: string, opts: ConvertUnrealOptions = {}): Promise<ConvertUnrealResult> => {
|
|
44
|
+
const warn = opts.warn ?? (() => {})
|
|
45
|
+
const u = loadUnreal(path, opts)
|
|
46
|
+
const own = u.anim && !opts.dropModelClips ? [ u.anim ] : []
|
|
47
|
+
const r = unrealToDocument({ skeleton: u.skeleton, mesh: u.mesh, clips: own }, { warn })
|
|
48
|
+
|
|
49
|
+
const clips: ConvertReport["clips"] = r.bound.map((b, i) => ({
|
|
50
|
+
name: b.name, source: basename(path), duration: own[i]?.playLength ?? 0, tracks: b.tracks, bound: b.bound,
|
|
51
|
+
}))
|
|
52
|
+
for (let i = 0; i < (opts.clips ?? []).length; i++) {
|
|
53
|
+
const file = opts.clips![i]
|
|
54
|
+
const source = await loadClipSource(file, { skeleton: opts.skeleton, contentRoot: opts.contentRoot })
|
|
55
|
+
clips.push(...mergeClipScene(r.doc, source, basename(file), opts.clipNames?.[i], opts.rename, warn))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Textures: the pack's material instances name them, and the source images sit Oodle-compressed
|
|
59
|
+
// inside the texture assets (see `materials.ts` / `oodle.ts`). Without a decoder the materials
|
|
60
|
+
// stay the grey slots the document was built with — the geometry is unaffected either way.
|
|
61
|
+
let textures: Awaited<ReturnType<typeof applyUnrealTextures>> | undefined
|
|
62
|
+
if (u.mesh && opts.textures !== false && u.mesh.materials.length) {
|
|
63
|
+
textures = await applyUnrealTextures(r.doc, path, u.mesh.materials, {
|
|
64
|
+
maxSize: opts.maxSize, flipNormalGreen: opts.flipNormalGreen, contentRoot: opts.contentRoot, resolveAsset: opts.resolveAsset, warn,
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const parts = [ `${u.engine} · ${u.kind === "anim" ? "AnimSequence" : u.kind === "mesh" ? "SkeletalMesh" : "Skeleton"} · skeleton ${u.skeleton.name} (${u.skeleton.bones.length} bones)` ]
|
|
69
|
+
if (u.skeletonFile !== path) parts.push(`from ${basename(u.skeletonFile)}`)
|
|
70
|
+
if (u.mesh) {
|
|
71
|
+
const b = meshBounds(u.mesh)
|
|
72
|
+
parts.push(`LOD ${u.mesh.lod} of ${u.mesh.lods.length} · ${u.mesh.faceWedges.length / 3} tris · ${((b.max[2] - b.min[2]) / 100).toFixed(2)} m tall`)
|
|
73
|
+
if (r.flippedWinding) parts.push("winding flipped (mirror)")
|
|
74
|
+
const points = u.mesh.points.length / 3
|
|
75
|
+
if (r.droppedWeight > 0.005 * points) parts.push(`skin trimmed to 4 bones/vertex (${(100 * r.droppedWeight / points).toFixed(1)} % of weight redistributed)`)
|
|
76
|
+
}
|
|
77
|
+
if (textures?.textures) parts.push(`${textures.textures} texture(s) from ${textures.slots.filter((s) => s.roles.length).length} material instance(s)`)
|
|
78
|
+
if (u.anim?.additive) parts.push(`ADDITIVE (${u.anim.additive}) — poses are deltas`)
|
|
79
|
+
if (u.anim?.rootMotion) parts.push("root motion")
|
|
80
|
+
return { doc: r.doc, report: { clips }, info: parts.join(" · "), textures }
|
|
81
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// One call for the CLI: a `.uasset` path in, everything the pipeline needs out — the asset's kind,
|
|
2
|
+
// its skeleton (resolved from the pack's Content folder when the file is a clip), the clip, the
|
|
3
|
+
// mesh. Skeletons are cached per path because 42 clips of a pack share one.
|
|
4
|
+
|
|
5
|
+
import { readFileSync } from "node:fs"
|
|
6
|
+
import { basename, dirname } from "node:path"
|
|
7
|
+
import { readPackage, engineLabel, classOf, importPackagePath, type UePackage } from "./package"
|
|
8
|
+
import { readSkeleton, type UeSkeleton } from "./skeleton"
|
|
9
|
+
import { readAnimSequence, dataModelKind, packageKind, type UeAnimSequence, type DataModelKind } from "./anim"
|
|
10
|
+
import { readSkeletalMesh, type UeSkeletalMesh } from "./mesh"
|
|
11
|
+
import { resolveObjectFile, searchedRoots } from "./resolve"
|
|
12
|
+
import { skeletonFromMesh } from "./toDocument"
|
|
13
|
+
|
|
14
|
+
export type UnrealKind = "anim" | "skeleton" | "mesh"
|
|
15
|
+
|
|
16
|
+
export type LoadUnrealOptions = {
|
|
17
|
+
/** Explicit skeleton asset (`SK_*.uasset`, or a `SKM_*.uasset` whose bone list is used). */
|
|
18
|
+
skeleton?: string
|
|
19
|
+
/** The pack's Content folder, when the walk-up search cannot find it. */
|
|
20
|
+
contentRoot?: string
|
|
21
|
+
/** Mesh LOD to read (default 0). */
|
|
22
|
+
lod?: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type LoadedUnreal = {
|
|
26
|
+
kind: UnrealKind
|
|
27
|
+
path: string
|
|
28
|
+
package: UePackage
|
|
29
|
+
engine: string
|
|
30
|
+
dataModel: DataModelKind
|
|
31
|
+
skeleton: UeSkeleton
|
|
32
|
+
/** Where the skeleton came from — this file, or the asset an object path named. */
|
|
33
|
+
skeletonFile: string
|
|
34
|
+
anim?: UeAnimSequence
|
|
35
|
+
mesh?: UeSkeletalMesh
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const packageCache = new Map<string, UePackage>()
|
|
39
|
+
const skeletonCache = new Map<string, UeSkeleton>()
|
|
40
|
+
|
|
41
|
+
export const readPackageFile = (path: string): UePackage => {
|
|
42
|
+
const hit = packageCache.get(path)
|
|
43
|
+
if (hit) return hit
|
|
44
|
+
const pkg = readPackage(new Uint8Array(readFileSync(path)), basename(path))
|
|
45
|
+
packageCache.set(path, pkg)
|
|
46
|
+
return pkg
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A skeleton out of a `Skeleton` asset, or out of a `SkeletalMesh`'s own bone list. */
|
|
50
|
+
export const readSkeletonFile = (path: string): UeSkeleton => {
|
|
51
|
+
const hit = skeletonCache.get(path)
|
|
52
|
+
if (hit) return hit
|
|
53
|
+
const pkg = readPackageFile(path)
|
|
54
|
+
const kind = packageKind(pkg)
|
|
55
|
+
const sk = kind === "Skeleton" ? readSkeleton(pkg)
|
|
56
|
+
: kind === "SkeletalMesh" ? skeletonFromMesh(readSkeletalMesh(pkg))
|
|
57
|
+
: (() => { throw new Error(`${basename(path)}: not a Skeleton or SkeletalMesh asset (${pkg.exports.map((e) => classOf(pkg, e)).join(", ")})`) })()
|
|
58
|
+
skeletonCache.set(path, sk)
|
|
59
|
+
return sk
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const loadUnreal = (path: string, opts: LoadUnrealOptions = {}): LoadedUnreal => {
|
|
63
|
+
const pkg = readPackageFile(path)
|
|
64
|
+
const engine = engineLabel(pkg.summary)
|
|
65
|
+
const dataModel = dataModelKind(pkg)
|
|
66
|
+
const kind = packageKind(pkg)
|
|
67
|
+
|
|
68
|
+
if (kind === "AnimSequence") {
|
|
69
|
+
const anim = readAnimSequence(pkg)
|
|
70
|
+
let skeletonFile = opts.skeleton
|
|
71
|
+
if (!skeletonFile) {
|
|
72
|
+
const found = anim.skeletonPath ? resolveObjectFile(anim.skeletonPath, dirname(path), opts.contentRoot) : null
|
|
73
|
+
if (!found) {
|
|
74
|
+
throw new Error(
|
|
75
|
+
`${basename(path)}: cannot find the skeleton asset "${anim.skeletonPath || "(unnamed)"}" this clip was authored on. ` +
|
|
76
|
+
`Looked for it in ${searchedRoots(dirname(path)).slice(0, 4).map((d) => `${d}${anim.skeletonPath.replace("/Game", "")}.uasset`).join(", ")}… ` +
|
|
77
|
+
`— point at it with --skeleton <SK_*.uasset>, or name the pack's Content folder with --content-root <dir>.`,
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
skeletonFile = found.file
|
|
81
|
+
}
|
|
82
|
+
return { kind: "anim", path, package: pkg, engine, dataModel, skeleton: readSkeletonFile(skeletonFile), skeletonFile, anim }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (kind === "SkeletalMesh") {
|
|
86
|
+
const mesh = readSkeletalMesh(pkg, { lod: opts.lod })
|
|
87
|
+
return { kind: "mesh", path, package: pkg, engine, dataModel, skeleton: skeletonFromMesh(mesh), skeletonFile: path, mesh }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (kind === "Skeleton") {
|
|
91
|
+
return { kind: "skeleton", path, package: pkg, engine, dataModel, skeleton: readSkeleton(pkg), skeletonFile: path }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
throw new Error(`${basename(path)}: holds no skeleton, mesh or animation (${engine}; exports: ${pkg.exports.map((e) => `${e.objectName}:${classOf(pkg, e)}`).slice(0, 6).join(", ")})`)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export const isUasset = (path: string): boolean => /\.uasset$/i.test(path)
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The `/Game/...` path of the skeleton an asset belongs to, read off the IMPORT TABLE alone — no
|
|
101
|
+
* geometry, no tracks. A clip and the meshes bound to one rig all name the same path, which is what
|
|
102
|
+
* groups a pack (and what lets a flat file store find the skeleton a dropped clip needs).
|
|
103
|
+
*/
|
|
104
|
+
export const skeletonPathOf = (pkg: UePackage): string => {
|
|
105
|
+
for (let i = 0; i < pkg.imports.length; i++) if (pkg.imports[i].className === "Skeleton") return importPackagePath(pkg, i)
|
|
106
|
+
return pkg.summary.packageName.startsWith("/") ? pkg.summary.packageName : ""
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export { readPackage, engineLabel, classOf } from "./package"
|
|
110
|
+
export { readSkeleton, type UeSkeleton, type UeBone } from "./skeleton"
|
|
111
|
+
export { readAnimSequence, readAnimMeta, packageKind, dataModelKind, keyTimes, type UeAnimSequence, type UeAnimMeta, type UeTrack } from "./anim"
|
|
112
|
+
export { readSkeletalMesh, meshBounds, type UeSkeletalMesh } from "./mesh"
|
|
113
|
+
export { unrealToDocument, skeletonFromMesh, boneWorldMatrices, type UnrealDocumentResult } from "./toDocument"
|
|
114
|
+
export { resolveObjectFile, objectPathToFile } from "./resolve"
|