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,113 @@
|
|
|
1
|
+
// `unreal ls <dir>` — what a pack holds, before converting anything. Reads only the summary, the
|
|
2
|
+
// name table and the tables (no bulk data, no tracks), so a 796-package pack lists in well under a
|
|
3
|
+
// second; a clip's skeleton comes from its imports, which is enough to group clips by rig.
|
|
4
|
+
//
|
|
5
|
+
// Assets the reader cannot handle are listed with their reason instead of being skipped — the point
|
|
6
|
+
// of listing a pack is to find out what is usable.
|
|
7
|
+
|
|
8
|
+
import { readdirSync, readFileSync, statSync } from "node:fs"
|
|
9
|
+
import { dirname, join, relative, resolve } from "node:path"
|
|
10
|
+
import { resolveObjectFile } from "./resolve"
|
|
11
|
+
import { readPackage, engineLabel, classOf, assetExport } from "./package"
|
|
12
|
+
import { skeletonPathOf } from "./index"
|
|
13
|
+
import { dataModelKind, packageKind } from "./anim"
|
|
14
|
+
|
|
15
|
+
export type UnrealAssetRow = {
|
|
16
|
+
file: string
|
|
17
|
+
kind: ReturnType<typeof packageKind>
|
|
18
|
+
engine: string
|
|
19
|
+
/** The `/Game/...` path of the skeleton a clip or mesh names. */
|
|
20
|
+
skeleton: string
|
|
21
|
+
dataModel: string
|
|
22
|
+
note: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const listUassets = (dir: string): string[] => {
|
|
26
|
+
const out: string[] = []
|
|
27
|
+
const walk = (d: string): void => {
|
|
28
|
+
for (const e of readdirSync(d, { withFileTypes: true })) {
|
|
29
|
+
const p = join(d, e.name)
|
|
30
|
+
if (e.isDirectory()) walk(p)
|
|
31
|
+
else if (/\.uasset$/i.test(e.name)) out.push(p)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (statSync(dir).isDirectory()) walk(dir)
|
|
35
|
+
else out.push(dir)
|
|
36
|
+
return out.sort()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A pack's assets, grouped by the skeleton they belong to. A `Skeleton` asset does not know its own
|
|
41
|
+
* `/Game/...` path in UE 5.0 files (`packageName` is "None" there), so it is matched to the path its
|
|
42
|
+
* clips name by resolving that path back to a file — the same walk-up `--clips` uses.
|
|
43
|
+
*/
|
|
44
|
+
export const scanUnreal = (dir: string): UnrealAssetRow[] => {
|
|
45
|
+
const rows = scanPackages(dir)
|
|
46
|
+
const byFile = new Map(rows.map((r) => [ resolve(r.file), r ]))
|
|
47
|
+
// one referencing file per skeleton path, captured before anything is re-keyed below
|
|
48
|
+
const referrer = new Map<string, string>()
|
|
49
|
+
for (const r of rows) if (r.skeleton.startsWith("/") && !referrer.has(r.skeleton)) referrer.set(r.skeleton, r.file)
|
|
50
|
+
for (const [ path, from ] of referrer) {
|
|
51
|
+
const found = resolveObjectFile(path, dirname(from))
|
|
52
|
+
const row = found ? byFile.get(resolve(found.file)) : undefined
|
|
53
|
+
if (row) row.skeleton = path
|
|
54
|
+
}
|
|
55
|
+
return rows
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const scanPackages = (dir: string): UnrealAssetRow[] =>
|
|
59
|
+
listUassets(dir).map((file): UnrealAssetRow => {
|
|
60
|
+
try {
|
|
61
|
+
const pkg = readPackage(new Uint8Array(readFileSync(file)), file)
|
|
62
|
+
const kind = packageKind(pkg)
|
|
63
|
+
const model = dataModelKind(pkg)
|
|
64
|
+
return {
|
|
65
|
+
file, kind, engine: engineLabel(pkg.summary),
|
|
66
|
+
// a UE 5.0 package does not carry its own path ("None") — `scanUnreal` fills it in
|
|
67
|
+
skeleton: kind === "Skeleton" ? (pkg.summary.packageName.startsWith("/") ? pkg.summary.packageName : "") : skeletonPathOf(pkg),
|
|
68
|
+
dataModel: model === "none" ? "" : model,
|
|
69
|
+
// the class of the export the package IS (an AnimMontage, not the AnimDataModel beside it)
|
|
70
|
+
note: kind === "other" ? (() => { const a = assetExport(pkg); return a ? classOf(pkg, a) : "?" })() : "",
|
|
71
|
+
}
|
|
72
|
+
} catch (e) {
|
|
73
|
+
return { file, kind: "other", engine: "?", skeleton: "", dataModel: "", note: (e as Error).message.replace(/^.*?: /, "") }
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
export const formatUnrealListing = (dir: string, rows: UnrealAssetRow[], opts: { all?: boolean } = {}): string => {
|
|
78
|
+
const lines: string[] = []
|
|
79
|
+
const usable = rows.filter((r) => r.kind !== "other" && r.kind !== "Texture2D")
|
|
80
|
+
lines.push(`${dir}: ${rows.length} package(s) — ${usable.length} readable by this pipeline`)
|
|
81
|
+
const engines = new Map<string, number>()
|
|
82
|
+
for (const r of rows) engines.set(r.engine, (engines.get(r.engine) ?? 0) + 1)
|
|
83
|
+
lines.push(` engines: ${[ ...engines ].map(([ e, n ]) => `${e} ×${n}`).join(", ")}`)
|
|
84
|
+
|
|
85
|
+
// grouped by skeleton, so a pack's rigs and the clips on them read as a unit
|
|
86
|
+
const bySkeleton = new Map<string, UnrealAssetRow[]>()
|
|
87
|
+
for (const r of usable) {
|
|
88
|
+
const key = r.skeleton || "(no skeleton named)"
|
|
89
|
+
const list = bySkeleton.get(key) ?? []
|
|
90
|
+
list.push(r)
|
|
91
|
+
bySkeleton.set(key, list)
|
|
92
|
+
}
|
|
93
|
+
for (const [ skel, list ] of [ ...bySkeleton ].sort((a, b) => b[1].length - a[1].length)) {
|
|
94
|
+
const counts = { AnimSequence: 0, SkeletalMesh: 0, Skeleton: 0, Texture2D: 0 } as Record<string, number>
|
|
95
|
+
for (const r of list) counts[r.kind]++
|
|
96
|
+
const blocked = list.filter((r) => r.note)
|
|
97
|
+
lines.push(` ${skel}: ${counts.Skeleton} skeleton(s), ${counts.SkeletalMesh} mesh(es), ${counts.AnimSequence} clip(s)${blocked.length ? ` ⚠ ${blocked.length} not readable (${blocked[0].note})` : ""}`)
|
|
98
|
+
for (const r of list) {
|
|
99
|
+
if (!opts.all && r.kind === "AnimSequence" && !r.note && list.length > 12) continue
|
|
100
|
+
lines.push(` ${r.kind.padEnd(13)} ${relative(dir, r.file).replace(/\\/g, "/")}${r.note ? ` ⚠ ${r.note}` : ""}`)
|
|
101
|
+
}
|
|
102
|
+
if (!opts.all && counts.AnimSequence > 0 && list.length > 12) lines.push(` (${counts.AnimSequence} clip(s) not listed — pass --all)`)
|
|
103
|
+
}
|
|
104
|
+
const textures = rows.filter((r) => r.kind === "Texture2D")
|
|
105
|
+
if (textures.length) lines.push(` textures: ${textures.length} Texture2D asset(s)`)
|
|
106
|
+
const other = rows.filter((r) => r.kind === "other")
|
|
107
|
+
if (other.length) {
|
|
108
|
+
const kinds = new Map<string, number>()
|
|
109
|
+
for (const r of other) kinds.set(r.note || "?", (kinds.get(r.note || "?") ?? 0) + 1)
|
|
110
|
+
lines.push(` other: ${[ ...kinds ].sort((a, b) => b[1] - a[1]).slice(0, 8).map(([ k, n ]) => `${k} ×${n}`).join(", ")}`)
|
|
111
|
+
}
|
|
112
|
+
return lines.join("\n")
|
|
113
|
+
}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
// Material slots → glTF materials with their textures.
|
|
2
|
+
//
|
|
3
|
+
// The route is exact, not guessed: a mesh's slot names come from its import data, the material
|
|
4
|
+
// assets it uses come from its IMPORT TABLE, and a `MaterialInstanceConstant` lists its textures as
|
|
5
|
+
// `TextureParameterValues` — a parameter name plus the Texture2D asset it points at. What is NOT
|
|
6
|
+
// knowable without evaluating the material graph is what each parameter DOES, so a texture's role
|
|
7
|
+
// is read from three agreeing signals: the parameter's name (`BaseColor Map`, `Normal`), the
|
|
8
|
+
// asset's suffix (`_D`, `_N`, `_ORM`) and the texture's own compression setting (`TC_Normalmap`,
|
|
9
|
+
// `TC_Masks`). Anything that does not land in glTF's five slots (masks, tangent maps, detail
|
|
10
|
+
// layers) is reported and left out rather than wired somewhere plausible.
|
|
11
|
+
//
|
|
12
|
+
// Scalar/vector parameters (tints, roughness powers) are NOT applied: whether `SkinTone` multiplies
|
|
13
|
+
// the base colour or drives something else is a property of the master material's graph. Gear comes
|
|
14
|
+
// out in its neutral colours, as it did in the predecessor Python exporter.
|
|
15
|
+
|
|
16
|
+
import { basename, dirname } from "node:path"
|
|
17
|
+
import type { Document, Material, Texture } from "@gltf-transform/core"
|
|
18
|
+
import type { ConvertWarning } from "../fbx/toDocument"
|
|
19
|
+
import { findExport, readArrayHeader, readFName, readProperties, readObjectProperties, resolveObject, classOf, type UePackage } from "./package"
|
|
20
|
+
import { readPackageFile } from "./index"
|
|
21
|
+
import { resolveObjectFile } from "./resolve"
|
|
22
|
+
import { extractTexture, readTexture2D, type UeTexture } from "./texture"
|
|
23
|
+
import { findOodle } from "./oodle"
|
|
24
|
+
|
|
25
|
+
export type TextureRole = "base" | "normal" | "orm" | "other"
|
|
26
|
+
|
|
27
|
+
export type UeTextureParam = {
|
|
28
|
+
/** The material parameter's name (`BaseColor Map`, `Base Texture`, `Normal`). */
|
|
29
|
+
param: string
|
|
30
|
+
/** `/Game/…/T_F_Arms_D` — the Texture2D asset. */
|
|
31
|
+
path: string
|
|
32
|
+
asset: string
|
|
33
|
+
role: TextureRole
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// A material names more textures than glTF has slots for, and several of them look like the one you
|
|
37
|
+
// want: the UE5 mannequin's material has BOTH `Normal` (the normal map) and `BNormal` (a bent
|
|
38
|
+
// normal), and picking whichever came first put the wrong one on the mesh. So candidates are SCORED
|
|
39
|
+
// and the best one wins — an exact parameter name beats an asset suffix, which beats the texture's
|
|
40
|
+
// compression setting, and a parameter that names something glTF has no slot for scores nothing.
|
|
41
|
+
const NOT_A_MAP = /bent|bnormal|detail|aniso|tangent|mask|msk|paint|emis|opacity|packed|_msr|specular|subsurface|scatter|fuzz|sheen|clear ?coat|cccc|ccr/i
|
|
42
|
+
|
|
43
|
+
const SCORES: Record<Exclude<TextureRole, "other">, { re: RegExp, on: "param" | "asset" | "compression", score: number }[]> = {
|
|
44
|
+
base: [
|
|
45
|
+
{ re: /^(base ?colou?r|base ?tex(ture)?|albedo|diffuse|colou?r ?map)( map)?$/i, on: "param", score: 100 },
|
|
46
|
+
{ re: /_(d|bc|albedo|basecolor|diff)$/i, on: "asset", score: 90 },
|
|
47
|
+
{ re: /base ?colou?r|albedo|diffuse/i, on: "param", score: 80 },
|
|
48
|
+
],
|
|
49
|
+
normal: [
|
|
50
|
+
{ re: /^(normal|normal ?map|nrm)$/i, on: "param", score: 100 },
|
|
51
|
+
{ re: /_n$/i, on: "asset", score: 90 },
|
|
52
|
+
{ re: /^normal/i, on: "param", score: 85 },
|
|
53
|
+
{ re: /^TC_Normalmap$/i, on: "compression", score: 50 },
|
|
54
|
+
],
|
|
55
|
+
orm: [
|
|
56
|
+
{ re: /orm|occlusion.?roughness.?metallic/i, on: "param", score: 100 },
|
|
57
|
+
{ re: /_orm$/i, on: "asset", score: 90 },
|
|
58
|
+
],
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** How well a texture fits a glTF slot: 0 = not a candidate. */
|
|
62
|
+
export const scoreTexture = (role: Exclude<TextureRole, "other">, param: string, asset: string, compression = ""): number => {
|
|
63
|
+
if (NOT_A_MAP.test(param) || NOT_A_MAP.test(asset)) return 0
|
|
64
|
+
let best = 0
|
|
65
|
+
for (const rule of SCORES[role]) {
|
|
66
|
+
const subject = rule.on === "param" ? param : rule.on === "asset" ? asset : compression
|
|
67
|
+
if (subject && rule.re.test(subject)) best = Math.max(best, rule.score)
|
|
68
|
+
}
|
|
69
|
+
// a mask texture never becomes a normal map, whatever its name suggests
|
|
70
|
+
if (role === "normal" && compression === "TC_Masks") return 0
|
|
71
|
+
return best
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The slot a texture fits best — what the report prints; the wiring uses `scoreTexture` directly. */
|
|
75
|
+
export const classifyTexture = (param: string, asset: string, compression?: string): TextureRole => {
|
|
76
|
+
let role: TextureRole = "other", best = 0
|
|
77
|
+
for (const r of [ "base", "normal", "orm" ] as const) {
|
|
78
|
+
const score = scoreTexture(r, param, asset, compression)
|
|
79
|
+
if (score > best) { best = score; role = r }
|
|
80
|
+
}
|
|
81
|
+
return role
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A material asset's `TextureParameterValues`, classified. */
|
|
85
|
+
export const readMaterialTextures = (pkg: UePackage): UeTextureParam[] => {
|
|
86
|
+
const found = findExport(pkg, "MaterialInstanceConstant") ?? findExport(pkg, "Material")
|
|
87
|
+
if (!found) return []
|
|
88
|
+
const { reader: r, props } = readObjectProperties(pkg, found.export)
|
|
89
|
+
const tag = props.byName.get("TextureParameterValues")
|
|
90
|
+
if (!tag) return []
|
|
91
|
+
const out: UeTextureParam[] = []
|
|
92
|
+
const { count } = readArrayHeader(r, pkg, tag)
|
|
93
|
+
for (let i = 0; i < count; i++) {
|
|
94
|
+
const el = readProperties(r, pkg)
|
|
95
|
+
const after = r.offset
|
|
96
|
+
let param = ""
|
|
97
|
+
let path = "", asset = ""
|
|
98
|
+
const info = el.byName.get("ParameterInfo")
|
|
99
|
+
if (info) {
|
|
100
|
+
r.offset = info.data
|
|
101
|
+
const inner = readProperties(r, pkg)
|
|
102
|
+
const n = inner.byName.get("Name")
|
|
103
|
+
if (n) { r.offset = n.data; param = readFName(r, pkg.names) }
|
|
104
|
+
}
|
|
105
|
+
const value = el.byName.get("ParameterValue")
|
|
106
|
+
if (value) {
|
|
107
|
+
r.offset = value.data
|
|
108
|
+
const ref = resolveObject(pkg, r.i32())
|
|
109
|
+
if (ref) { path = ref.packagePath; asset = ref.name }
|
|
110
|
+
}
|
|
111
|
+
if (path) out.push({ param, path, asset, role: classifyTexture(param, asset) })
|
|
112
|
+
r.offset = after
|
|
113
|
+
}
|
|
114
|
+
return out
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const normalize = (s: string): string => s.toLowerCase().replace(/[^a-z0-9]/g, "")
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Which material asset each mesh slot uses. The mesh's import table names the materials it
|
|
121
|
+
* references; slots are matched to them by name — exact, then normalised, then (when the counts
|
|
122
|
+
* agree and nothing else did) in order, which is how the assets line up in practice.
|
|
123
|
+
*/
|
|
124
|
+
export const resolveSlotMaterials = (pkg: UePackage, slots: string[]): (string | null)[] => {
|
|
125
|
+
const refs: { name: string, path: string }[] = []
|
|
126
|
+
for (let i = 0; i < pkg.imports.length; i++) {
|
|
127
|
+
const imp = pkg.imports[i]
|
|
128
|
+
if (imp.className !== "MaterialInstanceConstant" && imp.className !== "Material") continue
|
|
129
|
+
const path = resolveObject(pkg, -i - 1)?.packagePath ?? ""
|
|
130
|
+
if (path && !refs.some((r) => r.path === path)) refs.push({ name: imp.objectName, path })
|
|
131
|
+
}
|
|
132
|
+
const out: (string | null)[] = slots.map<string | null>(() => null)
|
|
133
|
+
const left = [ ...refs ]
|
|
134
|
+
const take = (i: number, r: { name: string, path: string }): void => { out[i] = r.path; left.splice(left.indexOf(r), 1) }
|
|
135
|
+
slots.forEach((s, i) => { const hit = left.find((r) => r.name.toLowerCase() === s.toLowerCase()); if (hit) take(i, hit) })
|
|
136
|
+
slots.forEach((s, i) => { if (!out[i]) { const hit = left.find((r) => normalize(r.name) === normalize(s)); if (hit) take(i, hit) } })
|
|
137
|
+
slots.forEach((s, i) => {
|
|
138
|
+
if (out[i] || !left.length) return
|
|
139
|
+
const n = normalize(s)
|
|
140
|
+
const hit = left.find((r) => normalize(r.name).includes(n) || n.includes(normalize(r.name)))
|
|
141
|
+
if (hit) take(i, hit)
|
|
142
|
+
})
|
|
143
|
+
const anyMatched = out.some((v) => v !== null)
|
|
144
|
+
if (!anyMatched && refs.length === slots.length) refs.forEach((r, i) => { out[i] = r.path })
|
|
145
|
+
return out
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export type TextureOptions = {
|
|
149
|
+
/** Last-resort lookup for an object path, for hosts with no pack on disk (the studio's flat
|
|
150
|
+
* upload store): `/Game/…/MI_Manny_01` → whichever uploaded file carries that asset. */
|
|
151
|
+
resolveAsset?: (objectPath: string) => string | null
|
|
152
|
+
/** Longest edge kept; larger sources are downscaled (needs `sharp`). Default 2048, 0 = no cap. */
|
|
153
|
+
maxSize?: number
|
|
154
|
+
/** UE authors normal maps DirectX-style (green down); glTF wants OpenGL (green up). */
|
|
155
|
+
flipNormalGreen?: boolean
|
|
156
|
+
contentRoot?: string
|
|
157
|
+
warn?: (w: ConvertWarning) => void
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export type AppliedTextures = {
|
|
161
|
+
/** Per slot: which roles were wired. */
|
|
162
|
+
slots: { slot: string, material: string | null, roles: string[], skipped: string[] }[]
|
|
163
|
+
textures: number
|
|
164
|
+
/** Set when nothing could be read because the Oodle library is missing. */
|
|
165
|
+
oodleMissing: boolean
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Decode → fix channels → re-encode. Uses `sharp` when it is installed, else the package's own
|
|
169
|
+
* PNG codec (correct, just slower and without the resize). */
|
|
170
|
+
export const fixChannels = async (data: Uint8Array, mime: string, opts: { swapRb: boolean, flipGreen: boolean, maxSize: number }): Promise<{ data: Uint8Array, mimeType: string }> => {
|
|
171
|
+
const { swapRb, flipGreen, maxSize } = opts
|
|
172
|
+
if (!swapRb && !flipGreen && !maxSize) return { data, mimeType: mime }
|
|
173
|
+
try {
|
|
174
|
+
const sharp = (await import("sharp")).default
|
|
175
|
+
let img = sharp(Buffer.from(data), { limitInputPixels: false, unlimited: true })
|
|
176
|
+
const meta = await img.metadata()
|
|
177
|
+
if (maxSize && Math.max(meta.width ?? 0, meta.height ?? 0) > maxSize) img = img.resize(maxSize, maxSize, { fit: "inside" })
|
|
178
|
+
const { data: raw, info } = await img.raw().toBuffer({ resolveWithObject: true })
|
|
179
|
+
const ch = info.channels
|
|
180
|
+
for (let i = 0; i < raw.length; i += ch) {
|
|
181
|
+
if (swapRb) { const t = raw[i]; raw[i] = raw[i + 2]; raw[i + 2] = t }
|
|
182
|
+
if (flipGreen && ch > 1) raw[i + 1] = 255 - raw[i + 1]
|
|
183
|
+
}
|
|
184
|
+
let usesAlpha = false
|
|
185
|
+
if (ch === 4) for (let i = 3; i < raw.length; i += 4) if (raw[i] !== 255) { usesAlpha = true; break }
|
|
186
|
+
const out = sharp(raw, { raw: { width: info.width, height: info.height, channels: ch } })
|
|
187
|
+
const encoded = usesAlpha
|
|
188
|
+
? await out.png({ compressionLevel: 9 }).toBuffer()
|
|
189
|
+
: await out.removeAlpha().jpeg({ quality: 92, chromaSubsampling: "4:4:4" }).toBuffer()
|
|
190
|
+
return { data: new Uint8Array(encoded), mimeType: usesAlpha ? "image/png" : "image/jpeg" }
|
|
191
|
+
} catch {
|
|
192
|
+
// no sharp: the channel fix still has to happen, so do it on the package's own PNG codec
|
|
193
|
+
const { decodePng } = await import("../unity/png")
|
|
194
|
+
const { encodePng } = await import("../textures/resolveImage")
|
|
195
|
+
const img = mime === "image/png" ? decodePng(data) : null
|
|
196
|
+
if (!img) return { data, mimeType: mime }
|
|
197
|
+
for (let i = 0; i < img.data.length; i += 4) {
|
|
198
|
+
if (swapRb) { const t = img.data[i]; img.data[i] = img.data[i + 2]; img.data[i + 2] = t }
|
|
199
|
+
if (flipGreen) img.data[i + 1] = 255 - img.data[i + 1]
|
|
200
|
+
}
|
|
201
|
+
return { data: encodePng(img), mimeType: "image/png" }
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Put the pack's textures on a document's materials. The document must already carry one material
|
|
207
|
+
* per mesh slot, named after the slot (that is what `unrealToDocument` builds).
|
|
208
|
+
*/
|
|
209
|
+
export const applyUnrealTextures = async (doc: Document, meshPath: string, slots: string[], opts: TextureOptions = {}): Promise<AppliedTextures> => {
|
|
210
|
+
const warn = opts.warn ?? (() => {})
|
|
211
|
+
const maxSize = opts.maxSize ?? 2048
|
|
212
|
+
const flipNormals = opts.flipNormalGreen ?? true
|
|
213
|
+
const report: AppliedTextures = { slots: [], textures: 0, oodleMissing: false }
|
|
214
|
+
const meshPkg = readPackageFile(meshPath)
|
|
215
|
+
/** An object path → a file: the pack's own layout first, then whatever the host can offer. */
|
|
216
|
+
const find = (objectPath: string, from = dirname(meshPath)): string | null =>
|
|
217
|
+
resolveObjectFile(objectPath, from, opts.contentRoot)?.file ?? opts.resolveAsset?.(objectPath) ?? null
|
|
218
|
+
const materials = doc.getRoot().listMaterials()
|
|
219
|
+
const assets = resolveSlotMaterials(meshPkg, slots)
|
|
220
|
+
const cache = new Map<string, Texture | null>()
|
|
221
|
+
|
|
222
|
+
const loadTexture = async (path: string, role: TextureRole, texAsset: UeTexture | null, file: string): Promise<Texture | null> => {
|
|
223
|
+
const key = `${path}|${role}`
|
|
224
|
+
const hit = cache.get(key)
|
|
225
|
+
if (hit !== undefined) return hit
|
|
226
|
+
let tex: Texture | null = null
|
|
227
|
+
try {
|
|
228
|
+
const img = await extractTexture(readPackageFile(file))
|
|
229
|
+
const prepared = await fixChannels(img.data, img.mimeType, {
|
|
230
|
+
swapRb: img.swapRb,
|
|
231
|
+
flipGreen: role === "normal" && flipNormals,
|
|
232
|
+
maxSize,
|
|
233
|
+
})
|
|
234
|
+
tex = doc.createTexture(img.texture.name).setImage(prepared.data).setMimeType(prepared.mimeType)
|
|
235
|
+
report.textures++
|
|
236
|
+
} catch (e) {
|
|
237
|
+
const msg = (e as Error).message
|
|
238
|
+
if (/Oodle/i.test(msg)) { report.oodleMissing = true; tex = null } else warn({ level: "warn", message: `texture ${basename(file)}: ${msg}` })
|
|
239
|
+
}
|
|
240
|
+
void texAsset
|
|
241
|
+
cache.set(key, tex)
|
|
242
|
+
return tex
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
for (let i = 0; i < slots.length; i++) {
|
|
246
|
+
const slot = slots[i]
|
|
247
|
+
const assetPath = assets[i]
|
|
248
|
+
const material: Material | undefined = materials[i]
|
|
249
|
+
const row = { slot, material: assetPath ? assetPath.split("/").pop() ?? null : null, roles: [] as string[], skipped: [] as string[] }
|
|
250
|
+
report.slots.push(row)
|
|
251
|
+
if (!assetPath || !material) continue
|
|
252
|
+
const matFile = find(assetPath)
|
|
253
|
+
if (!matFile) { warn({ level: "info", message: `material ${row.material}: asset not found next to the mesh — slot '${slot}' keeps its default` }); continue }
|
|
254
|
+
|
|
255
|
+
let params: UeTextureParam[]
|
|
256
|
+
try { params = readMaterialTextures(readPackageFile(matFile)) } catch (e) { warn({ level: "warn", message: `material ${row.material}: ${(e as Error).message}` }); continue }
|
|
257
|
+
|
|
258
|
+
// the texture asset's own compression setting is the third vote on what a parameter is for
|
|
259
|
+
const resolved = params.map((p) => {
|
|
260
|
+
const file = find(p.path, dirname(matFile))
|
|
261
|
+
let texture: UeTexture | null = null
|
|
262
|
+
if (file) { try { texture = readTexture2D(readPackageFile(file)) } catch { texture = null } }
|
|
263
|
+
return { ...p, file, texture, role: classifyTexture(p.param, p.asset, texture?.compressionSettings) }
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
for (const role of [ "base", "normal", "orm" ] as const) {
|
|
267
|
+
const pick = resolved
|
|
268
|
+
.filter((p) => p.file)
|
|
269
|
+
.map((p) => ({ p, score: scoreTexture(role, p.param, p.asset, p.texture?.compressionSettings) }))
|
|
270
|
+
.filter((c) => c.score > 0)
|
|
271
|
+
.sort((a, b) => b.score - a.score)[0]?.p
|
|
272
|
+
if (!pick) continue
|
|
273
|
+
const tex = await loadTexture(pick.path, role, pick.texture, pick.file!)
|
|
274
|
+
if (!tex) continue
|
|
275
|
+
if (role === "base") {
|
|
276
|
+
material.setBaseColorTexture(tex).setBaseColorFactor([ 1, 1, 1, 1 ])
|
|
277
|
+
material.getBaseColorTexture()?.setName(pick.asset)
|
|
278
|
+
} else if (role === "normal") {
|
|
279
|
+
material.setNormalTexture(tex)
|
|
280
|
+
} else {
|
|
281
|
+
// UE packs occlusion/roughness/metallic into R/G/B — glTF reads exactly the same channels
|
|
282
|
+
material.setMetallicRoughnessTexture(tex).setOcclusionTexture(tex).setMetallicFactor(1).setRoughnessFactor(1)
|
|
283
|
+
}
|
|
284
|
+
row.roles.push(role)
|
|
285
|
+
}
|
|
286
|
+
row.skipped = resolved.filter((p) => p.role === "other").map((p) => `${p.param || p.asset}`)
|
|
287
|
+
}
|
|
288
|
+
if (report.oodleMissing) warn({ level: "info", message: `textures skipped — no Oodle decoder (${findOodle() ? "found the library but could not call it" : "set LECODES_OODLE"})` })
|
|
289
|
+
void classOf
|
|
290
|
+
return report
|
|
291
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// SkeletalMesh → geometry + skin + bones, out of the EDITOR IMPORT DATA (`FSkeletalMeshImportData`
|
|
2
|
+
// — what the FBX importer saw), which a `SkeletalMeshEditorData` child export keeps as bulk data.
|
|
3
|
+
// That is the same source the render data is built from, and the only one that still carries the
|
|
4
|
+
// skin influences and the source bone list.
|
|
5
|
+
//
|
|
6
|
+
// A mesh whose LODs were generated in-engine has no import data at all (an 84-byte payload, header
|
|
7
|
+
// only); the UE4 mannequin of the Stairs pack is one. Such a mesh cannot be exported by this path —
|
|
8
|
+
// `lodCount` reports which LODs have a source (§8).
|
|
9
|
+
//
|
|
10
|
+
// The vertex model is UE's: POINTS carry position and skin weights, WEDGES are per-corner
|
|
11
|
+
// (point + UV + colour), FACES name three wedges plus a per-corner normal. glTF wants one array of
|
|
12
|
+
// vertices, so `toDocument` de-duplicates wedges per material slot.
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
BinaryReader, findExport, classOf, readObjectProperties, readBulkHeader, readBulkPayload,
|
|
16
|
+
bulkIsCompressed, propObject, resolveObject, type UePackage, type BulkDataHeader,
|
|
17
|
+
} from "./package"
|
|
18
|
+
import { skipObjectGuid } from "./skeleton"
|
|
19
|
+
|
|
20
|
+
export type UeMeshBone = {
|
|
21
|
+
name: string
|
|
22
|
+
parent: number
|
|
23
|
+
/** LOCAL transform, UE frame, centimetres (floats here, unlike the USkeleton's doubles). */
|
|
24
|
+
t: [ number, number, number ]
|
|
25
|
+
q: [ number, number, number, number ]
|
|
26
|
+
s: [ number, number, number ]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type UeInfluence = { weight: number, point: number, bone: number }
|
|
30
|
+
|
|
31
|
+
export type UeSkeletalMesh = {
|
|
32
|
+
name: string
|
|
33
|
+
/** Skeleton asset this mesh is bound to (`/Game/…/SK_Mannequin`), when it names one. */
|
|
34
|
+
skeletonPath: string
|
|
35
|
+
skeletonName: string
|
|
36
|
+
/** Which LOD was read, and what the file holds. */
|
|
37
|
+
lod: number
|
|
38
|
+
lods: { index: number, bytes: number, compressed: boolean, hasSource: boolean }[]
|
|
39
|
+
/** version of the import-data blob (3 = UE 5.0, 5 = UE 5.3). */
|
|
40
|
+
version: number
|
|
41
|
+
/** n × 3 floats, UE frame, cm — the skeleton's bind (= reference) space. */
|
|
42
|
+
points: Float32Array
|
|
43
|
+
/** per wedge: point index, uv0, uv1 (NaN-free zeros when absent), material slot. */
|
|
44
|
+
wedgePoint: Uint32Array
|
|
45
|
+
wedgeUv0: Float32Array
|
|
46
|
+
wedgeUv1: Float32Array
|
|
47
|
+
hasUv1: boolean
|
|
48
|
+
/** per face: 3 wedge indices, material slot, 3 corner normals (UE frame). */
|
|
49
|
+
faceWedges: Uint32Array
|
|
50
|
+
faceMaterial: Uint8Array
|
|
51
|
+
faceNormals: Float32Array
|
|
52
|
+
influences: UeInfluence[]
|
|
53
|
+
materials: string[]
|
|
54
|
+
bones: UeMeshBone[]
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const BULK_EMPTY_MAX = 100 // a generated LOD's "import data" is a header and nothing else
|
|
58
|
+
|
|
59
|
+
/** `FRawSkeletalMeshBulkData` list of the `SkeletalMeshEditorData` export. */
|
|
60
|
+
const readEditorDataLods = (pkg: UePackage, editorExportIndex: number): { header: BulkDataHeader }[] => {
|
|
61
|
+
const e = pkg.exports[editorExportIndex]
|
|
62
|
+
const { reader: r } = readObjectProperties(pkg, e) // empty tag list (just `None`)
|
|
63
|
+
skipObjectGuid(r)
|
|
64
|
+
const lodCount = r.i32()
|
|
65
|
+
if (lodCount < 0 || lodCount > 64) throw new Error(`${pkg.label}: ${lodCount} LODs in ${e.objectName} — not a mesh editor-data export`)
|
|
66
|
+
const out: { header: BulkDataHeader }[] = []
|
|
67
|
+
for (let i = 0; i < lodCount; i++) {
|
|
68
|
+
r.u8(); r.u8() // geo / skinning import versions
|
|
69
|
+
const header = readBulkHeader(r)
|
|
70
|
+
r.skip(16) // guid
|
|
71
|
+
r.i32() // bGuidIsHash
|
|
72
|
+
out.push({ header })
|
|
73
|
+
}
|
|
74
|
+
return out
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** `FSkeletalMeshImportData` — the arrays sit back to back with an i32 count in front of each. */
|
|
78
|
+
const parseImportData = (blob: Uint8Array, label: string): Omit<UeSkeletalMesh, "name" | "skeletonPath" | "skeletonName" | "lod" | "lods"> => {
|
|
79
|
+
const r = new BinaryReader(blob, 0, `${label} import data`)
|
|
80
|
+
const version = r.i32()
|
|
81
|
+
r.skip(32) // 8 more header ints (flags: normals, tangents, …)
|
|
82
|
+
|
|
83
|
+
const faceCount = r.i32()
|
|
84
|
+
const faceWedges = new Uint32Array(faceCount * 3)
|
|
85
|
+
const faceMaterial = new Uint8Array(faceCount)
|
|
86
|
+
const faceNormals = new Float32Array(faceCount * 9)
|
|
87
|
+
{
|
|
88
|
+
const base = r.offset
|
|
89
|
+
if (faceCount < 0 || base + faceCount * 127 > blob.byteLength) throw new Error(`${label}: ${faceCount} faces do not fit the import data (${blob.byteLength} B)`)
|
|
90
|
+
const v = r.view
|
|
91
|
+
for (let i = 0; i < faceCount; i++) {
|
|
92
|
+
const o = base + i * 127
|
|
93
|
+
faceMaterial[i] = blob[o]
|
|
94
|
+
faceWedges[i * 3] = v.getUint32(o + 7, true)
|
|
95
|
+
faceWedges[i * 3 + 1] = v.getUint32(o + 11, true)
|
|
96
|
+
faceWedges[i * 3 + 2] = v.getUint32(o + 15, true)
|
|
97
|
+
for (let k = 0; k < 9; k++) faceNormals[i * 9 + k] = v.getFloat32(o + 91 + k * 4, true) // TangentZ per corner
|
|
98
|
+
}
|
|
99
|
+
r.offset = base + faceCount * 127
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const influenceCount = r.i32()
|
|
103
|
+
const influences: UeInfluence[] = new Array(influenceCount)
|
|
104
|
+
{
|
|
105
|
+
const base = r.offset
|
|
106
|
+
if (influenceCount < 0 || base + influenceCount * 12 > blob.byteLength) throw new Error(`${label}: ${influenceCount} influences do not fit the import data`)
|
|
107
|
+
const v = r.view
|
|
108
|
+
for (let i = 0; i < influenceCount; i++) {
|
|
109
|
+
const o = base + i * 12
|
|
110
|
+
influences[i] = { weight: v.getFloat32(o, true), point: v.getInt32(o + 4, true), bone: v.getInt32(o + 8, true) }
|
|
111
|
+
}
|
|
112
|
+
r.offset = base + influenceCount * 12
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const materialCount = r.i32()
|
|
116
|
+
const materials: string[] = []
|
|
117
|
+
for (let i = 0; i < materialCount; i++) materials.push(r.fstring())
|
|
118
|
+
|
|
119
|
+
const pointCount = r.i32()
|
|
120
|
+
const points = new Float32Array(pointCount * 3)
|
|
121
|
+
for (let i = 0; i < points.length; i++) points[i] = r.f32()
|
|
122
|
+
|
|
123
|
+
const mapCount = r.i32()
|
|
124
|
+
r.skip(mapCount * 4) // PointToRawMap
|
|
125
|
+
|
|
126
|
+
const boneCount = r.i32()
|
|
127
|
+
const bones: UeMeshBone[] = new Array(boneCount)
|
|
128
|
+
for (let i = 0; i < boneCount; i++) {
|
|
129
|
+
const name = r.fstring()
|
|
130
|
+
r.u32() // flags
|
|
131
|
+
r.i32() // numChildren
|
|
132
|
+
const parent = r.i32()
|
|
133
|
+
const q = r.floats(4), t = r.floats(3), s = r.floats(3)
|
|
134
|
+
bones[i] = { name, parent, t: [ t[0], t[1], t[2] ], q: [ q[0], q[1], q[2], q[3] ], s: [ s[0], s[1], s[2] ] }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const wedgeCount = r.i32()
|
|
138
|
+
const wedgePoint = new Uint32Array(wedgeCount)
|
|
139
|
+
const wedgeUv0 = new Float32Array(wedgeCount * 2)
|
|
140
|
+
const wedgeUv1 = new Float32Array(wedgeCount * 2)
|
|
141
|
+
let hasUv1 = false
|
|
142
|
+
{
|
|
143
|
+
const base = r.offset
|
|
144
|
+
if (wedgeCount < 0 || base + wedgeCount * 42 > blob.byteLength) throw new Error(`${label}: ${wedgeCount} wedges do not fit the import data`)
|
|
145
|
+
const v = r.view
|
|
146
|
+
for (let i = 0; i < wedgeCount; i++) {
|
|
147
|
+
const o = base + i * 42
|
|
148
|
+
wedgePoint[i] = v.getUint32(o, true)
|
|
149
|
+
wedgeUv0[i * 2] = v.getFloat32(o + 10, true)
|
|
150
|
+
wedgeUv0[i * 2 + 1] = v.getFloat32(o + 14, true)
|
|
151
|
+
const u1 = v.getFloat32(o + 18, true), v1 = v.getFloat32(o + 22, true)
|
|
152
|
+
wedgeUv1[i * 2] = u1; wedgeUv1[i * 2 + 1] = v1
|
|
153
|
+
if (!hasUv1 && (u1 !== 0 || v1 !== 0)) hasUv1 = true
|
|
154
|
+
}
|
|
155
|
+
r.offset = base + wedgeCount * 42
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
for (const [ i, w ] of faceWedges.entries()) {
|
|
159
|
+
if (w >= wedgeCount) throw new Error(`${label}: face ${Math.floor(i / 3)} names wedge ${w} of ${wedgeCount}`)
|
|
160
|
+
}
|
|
161
|
+
return { version, points, wedgePoint, wedgeUv0, wedgeUv1, hasUv1, faceWedges, faceMaterial, faceNormals, influences, materials, bones }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export type ReadMeshOptions = { lod?: number }
|
|
165
|
+
|
|
166
|
+
/** The `SkeletalMesh` export of a package (an `SKM_*.uasset`), at one LOD (default 0). */
|
|
167
|
+
export const readSkeletalMesh = (pkg: UePackage, opts: ReadMeshOptions = {}): UeSkeletalMesh => {
|
|
168
|
+
const found = findExport(pkg, "SkeletalMesh")
|
|
169
|
+
if (!found) throw new Error(`${pkg.label}: no SkeletalMesh export (exports: ${pkg.exports.map((e) => `${e.objectName}:${classOf(pkg, e)}`).join(", ")})`)
|
|
170
|
+
const { reader: r, props } = readObjectProperties(pkg, found.export)
|
|
171
|
+
const skelRef = resolveObject(pkg, propObject(r, props.byName.get("Skeleton")) ?? 0)
|
|
172
|
+
|
|
173
|
+
const editorRef = resolveObject(pkg, propObject(r, props.byName.get("MeshEditorDataObject")) ?? 0)
|
|
174
|
+
const editorIndex = editorRef?.kind === "export" ? editorRef.index : pkg.exports.findIndex((e) => classOf(pkg, e) === "SkeletalMeshEditorData")
|
|
175
|
+
if (editorIndex < 0) {
|
|
176
|
+
throw new Error(`${pkg.label}: the mesh keeps no editor import data (a cooked or engine-generated mesh) — only its skeleton can be read`)
|
|
177
|
+
}
|
|
178
|
+
const lodsRaw = readEditorDataLods(pkg, editorIndex)
|
|
179
|
+
const lods = lodsRaw.map((l, i) => ({ index: i, bytes: l.header.elementCount, compressed: bulkIsCompressed(l.header), hasSource: l.header.elementCount > BULK_EMPTY_MAX }))
|
|
180
|
+
const want = opts.lod ?? 0
|
|
181
|
+
const chosen = lods[want]
|
|
182
|
+
if (!chosen) throw new Error(`${pkg.label}: no LOD ${want} (the mesh has ${lods.length})`)
|
|
183
|
+
if (!chosen.hasSource) {
|
|
184
|
+
const alt = lods.filter((l) => l.hasSource).map((l) => l.index)
|
|
185
|
+
throw new Error(`${pkg.label}: LOD ${want} has no source geometry (${chosen.bytes} B — an engine-generated LOD)${alt.length ? `; LOD ${alt.join("/")} do${alt.length > 1 ? "" : "es"}` : " and neither does any other LOD, so this mesh cannot be exported"}`)
|
|
186
|
+
}
|
|
187
|
+
const blob = readBulkPayload(pkg, lodsRaw[want].header)
|
|
188
|
+
const data = parseImportData(blob, pkg.label)
|
|
189
|
+
return {
|
|
190
|
+
name: found.export.objectName,
|
|
191
|
+
skeletonPath: skelRef?.packagePath ?? "",
|
|
192
|
+
skeletonName: skelRef?.name ?? "",
|
|
193
|
+
lod: want, lods,
|
|
194
|
+
...data,
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Bounding box of the points, UE frame, centimetres. */
|
|
199
|
+
export const meshBounds = (m: UeSkeletalMesh): { min: number[], max: number[] } => {
|
|
200
|
+
const min = [ Infinity, Infinity, Infinity ], max = [ -Infinity, -Infinity, -Infinity ]
|
|
201
|
+
for (let i = 0; i < m.points.length; i += 3) {
|
|
202
|
+
for (let k = 0; k < 3; k++) { min[k] = Math.min(min[k], m.points[i + k]); max[k] = Math.max(max[k], m.points[i + k]) }
|
|
203
|
+
}
|
|
204
|
+
return { min, max }
|
|
205
|
+
}
|