lecodes-assets 0.2.0 → 2.4.10
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 +738 -449
- package/dist/cli.js +19760 -8612
- package/dist/index.js +19334 -8167
- package/package.json +15 -3
- package/src/bvh/fromDocument.ts +136 -0
- package/src/bvh/toDocument.ts +202 -0
- package/src/cli-kit/command.ts +100 -0
- package/src/cli-kit/help.ts +72 -0
- package/src/cli-kit/index.ts +4 -0
- package/src/cli-kit/parse.ts +72 -0
- package/src/cli-kit/run.ts +52 -0
- package/src/cli.ts +24 -0
- package/src/commands/avatar.ts +77 -0
- package/src/commands/bvh.ts +26 -0
- package/src/commands/convert.ts +156 -0
- package/src/commands/doctor.ts +67 -0
- package/src/commands/geometry.ts +45 -0
- package/src/commands/index.ts +24 -0
- package/src/commands/marks.ts +38 -0
- package/src/commands/mirror.ts +35 -0
- package/src/commands/probe.ts +43 -0
- package/src/commands/retarget/donor.ts +63 -0
- package/src/commands/retarget/index.ts +169 -0
- package/src/commands/retarget/map.ts +81 -0
- package/src/commands/retarget/report.ts +77 -0
- package/src/commands/scene.ts +103 -0
- package/src/commands/shared.ts +121 -0
- package/src/commands/sky.ts +35 -0
- package/src/commands/terrainPack.ts +40 -0
- package/src/commands/unpack.ts +43 -0
- package/src/commands/unreal.ts +109 -0
- package/src/commands/unwrap.ts +56 -0
- package/src/doctor/cardTrim.ts +286 -0
- package/src/doctor/doctor.ts +305 -0
- package/src/doctor/lod.ts +177 -0
- package/src/doctor/merge/accessors.ts +30 -0
- package/src/doctor/merge/atlasLayout.ts +94 -0
- package/src/doctor/merge/atlasPixels.ts +114 -0
- package/src/doctor/merge/bindSpace.ts +59 -0
- package/src/doctor/merge/classify.ts +136 -0
- package/src/doctor/merge/geometry.ts +167 -0
- package/src/doctor/merge/index.ts +98 -0
- package/src/doctor/merge/matrix.ts +60 -0
- package/src/doctor/merge/plan.ts +88 -0
- package/src/doctor/merge/textures.ts +60 -0
- package/src/doctor/merge/types.ts +77 -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 +275 -0
- package/src/doctor/transcodeTextures.ts +101 -0
- package/src/doctor/validateGlb.ts +264 -0
- package/src/doctor/vertexAo.ts +321 -0
- package/src/fbx/convert.ts +327 -0
- package/src/fbx/read.ts +111 -0
- package/src/fbx/toDocument.ts +227 -0
- package/src/filmstrip.ts +263 -0
- package/src/geometry.ts +222 -0
- package/src/index.ts +19 -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 +238 -0
- package/src/retarget/humanoid.ts +572 -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 +2375 -0
- package/src/retarget/rig.ts +545 -0
- package/src/retarget/unityAnim.ts +171 -0
- package/src/retarget/writeMarks.ts +134 -0
- package/src/sky/analyze.ts +136 -0
- package/src/sky/cmgen.ts +71 -0
- package/src/sky/index.ts +200 -0
- package/src/terrainPack.ts +208 -0
- package/src/textures/resolveImage.ts +202 -0
- package/src/unity/colliderFit.ts +287 -0
- package/src/unity/fbxModel.ts +194 -0
- package/src/unity/foliage.ts +63 -0
- package/src/unity/gameObjects.ts +81 -0
- package/src/unity/guid.ts +104 -0
- package/src/unity/image.ts +58 -0
- package/src/unity/kindGlb.ts +364 -0
- package/src/unity/lecodesLevel.ts +381 -0
- package/src/unity/levelTerrain.ts +116 -0
- package/src/unity/matFile.ts +179 -0
- package/src/unity/materialSlots.ts +53 -0
- package/src/unity/materialWriter.ts +306 -0
- package/src/unity/materials.ts +198 -0
- package/src/unity/math.ts +82 -0
- package/src/unity/png.ts +75 -0
- package/src/unity/prefabTree.ts +285 -0
- package/src/unity/prefabs.ts +54 -0
- package/src/unity/propertyPath.ts +43 -0
- package/src/unity/scene.ts +384 -0
- package/src/unity/serialized.ts +242 -0
- package/src/unity/terrain.ts +221 -0
- package/src/unity/triBudget.ts +77 -0
- package/src/unity/triplanar.ts +122 -0
- package/src/unity/unityMesh.ts +151 -0
- package/src/unity/unpack.ts +149 -0
- package/src/unity/yaml.ts +131 -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 +115 -0
- package/src/unreal/list.ts +113 -0
- package/src/unreal/materialGraph.ts +224 -0
- package/src/unreal/materials.ts +399 -0
- package/src/unreal/mesh.ts +205 -0
- package/src/unreal/oodle.ts +153 -0
- package/src/unreal/package.ts +677 -0
- package/src/unreal/probe.ts +144 -0
- package/src/unreal/resolve.ts +53 -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/sound.ts +128 -0
- package/src/unreal/texture.ts +269 -0
- package/src/unreal/toDocument.ts +367 -0
- package/src/unreal/versions.ts +108 -0
- package/src/unwrap/index.ts +167 -0
- package/src/unwrap/lightmapUv.ts +761 -0
- package/src/wasm/fbxReader.js +14 -0
- package/src/wasm/lightmapUv.js +14 -0
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
// convert: FBX (+ clip FBX files) → one glTF-Transform Document → GLB. Clip files are re-targeted
|
|
2
|
+
// onto the main skeleton BY BONE NAME (exact, then the part after the last ':'/'|' — the same rule
|
|
3
|
+
// the SDK Animator uses), their meshes/skins dropped. See docs/animation-plan.md §2.9.
|
|
4
|
+
|
|
5
|
+
import { readFileSync } from "node:fs"
|
|
6
|
+
import { basename, dirname, extname } from "node:path"
|
|
7
|
+
import { NodeIO, type Document, type Node } from "@gltf-transform/core"
|
|
8
|
+
import { ALL_EXTENSIONS } from "@gltf-transform/extensions"
|
|
9
|
+
import { readFbx, type FbxScene, type FbxAnimation } from "./read"
|
|
10
|
+
import { toDocument, isGenericClipName, fixQuatContinuity, type ConvertWarning } from "./toDocument"
|
|
11
|
+
import { f32 } from "./read"
|
|
12
|
+
import { qmul, qinv, qnorm, qrot, type Quat, type Vec3 } from "../retarget/rig"
|
|
13
|
+
|
|
14
|
+
export type ConvertOptions = {
|
|
15
|
+
/** Extra FBX files whose animations are merged into the model by bone name. */
|
|
16
|
+
clips?: string[]
|
|
17
|
+
/** Explicit names for `clips` (parallel array); default: stack name unless generic → file basename. */
|
|
18
|
+
clipNames?: string[]
|
|
19
|
+
/** Drop the model file's own clips (e.g. Mixamo's 1-frame T-pose). */
|
|
20
|
+
dropModelClips?: boolean
|
|
21
|
+
/** Bone name overrides for clip binding: { clipBoneName: modelBoneName }. */
|
|
22
|
+
rename?: Record<string, string>
|
|
23
|
+
/** Rewrite materials from the Unity `.mat` files found next to the FBX (default true when any exist;
|
|
24
|
+
* false = FBX references only). A string = folder to look for them in. */
|
|
25
|
+
unity?: boolean | string
|
|
26
|
+
/** `unity-index.txt` for GUID → path (default: found walking up from the FBX, else `.meta` sidecars). */
|
|
27
|
+
unityIndex?: string
|
|
28
|
+
/** Unreal `.uasset` clips: the skeleton they were authored on (default: resolved from the pack). */
|
|
29
|
+
skeleton?: string
|
|
30
|
+
/** Unreal `.uasset` clips: the pack's Content folder, when the walk-up search cannot find it. */
|
|
31
|
+
contentRoot?: string
|
|
32
|
+
warn?: (w: ConvertWarning) => void
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type ConvertReport = {
|
|
36
|
+
clips: { name: string, source: string, duration: number, tracks: number, bound: number }[]
|
|
37
|
+
warnings: ConvertWarning[]
|
|
38
|
+
nodes: number
|
|
39
|
+
meshes: number
|
|
40
|
+
skins: number
|
|
41
|
+
textures: number
|
|
42
|
+
materials: number
|
|
43
|
+
unity?: import("../unity/materials").ApplyUnityReport
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const shortName = (n: string): string => n.slice(n.search(/[^:|]*$/))
|
|
47
|
+
|
|
48
|
+
// ---- clip sources ------------------------------------------------------------------------------
|
|
49
|
+
// What `mergeClipScene` needs of a clip file: a bone hierarchy with its rest pose, and baked tracks.
|
|
50
|
+
// An FBX gives it through the ufbx arena; an Unreal AnimSequence builds it from its own keys. Both
|
|
51
|
+
// arrive here in glTF conventions (metres, Y-up), so the merge code below never sees the difference.
|
|
52
|
+
|
|
53
|
+
export type ClipSourceNode = { name: string, parent: number, t: Vec3, r: Quat, s: Vec3, isRoot: boolean }
|
|
54
|
+
export type ClipSourceTrack = { node: number, path: "translation" | "rotation" | "scale", times: Float32Array, values: Float32Array }
|
|
55
|
+
export type ClipSourceAnimation = { name: string, duration: number, tracks: ClipSourceTrack[] }
|
|
56
|
+
export type ClipSource = {
|
|
57
|
+
nodes: ClipSourceNode[]
|
|
58
|
+
animations: ClipSourceAnimation[]
|
|
59
|
+
/** Written onto every animation this source produces (provenance, fps, events). */
|
|
60
|
+
extras?: (clipName: string) => Record<string, unknown> | undefined
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** An ufbx scene as a ClipSource (arena views resolved once). */
|
|
64
|
+
export const clipSourceFromFbx = (scene: FbxScene): ClipSource => ({
|
|
65
|
+
nodes: scene.nodes.map((n) => ({ name: n.name, parent: n.parent, t: n.t, r: n.r, s: n.s, isRoot: n.isRoot })),
|
|
66
|
+
animations: scene.animations.filter((a) => a.tracks.length > 0).map((a) => ({
|
|
67
|
+
name: a.name,
|
|
68
|
+
duration: a.duration,
|
|
69
|
+
tracks: a.tracks.map((tr) => ({
|
|
70
|
+
node: tr.node,
|
|
71
|
+
path: tr.path,
|
|
72
|
+
times: f32(scene, tr.times, tr.count),
|
|
73
|
+
values: f32(scene, tr.values, tr.count * (tr.path === "rotation" ? 4 : 3)),
|
|
74
|
+
})),
|
|
75
|
+
})),
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
/** Build the name → node maps used for clip binding (exact first, then short names; first wins). */
|
|
79
|
+
export const nodeNameMaps = (doc: Document): { exact: Map<string, Node>, short: Map<string, Node> } => {
|
|
80
|
+
const exact = new Map<string, Node>()
|
|
81
|
+
const short = new Map<string, Node>()
|
|
82
|
+
for (const n of doc.getRoot().listNodes()) {
|
|
83
|
+
const name = n.getName()
|
|
84
|
+
if (!name) continue
|
|
85
|
+
if (!exact.has(name)) exact.set(name, n)
|
|
86
|
+
const s = shortName(name)
|
|
87
|
+
if (!short.has(s)) short.set(s, n)
|
|
88
|
+
}
|
|
89
|
+
return { exact, short }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export const findNodeByName = (maps: { exact: Map<string, Node>, short: Map<string, Node> }, name: string, rename?: Record<string, string>): Node | undefined => {
|
|
93
|
+
const n = rename?.[name] ?? name
|
|
94
|
+
return maps.exact.get(n) ?? maps.short.get(shortName(n))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
// ---- parent-space correction for merged tracks -------------------------------------------------
|
|
99
|
+
// A glTF/FBX track is a LOCAL transform — relative to the node's parent. Two files of the same rig
|
|
100
|
+
// do not have to agree on what sits above a bone: Unreal/Unity clip exports put `root` directly
|
|
101
|
+
// under the scene (rest −90°X, the Z-up→Y-up turn), while the skinned-mesh export parks that same
|
|
102
|
+
// turn on an armature node and leaves `root` at identity. Copying the clip's root track verbatim
|
|
103
|
+
// then applies the turn twice and the character plays lying on the floor. So for every bound bone
|
|
104
|
+
// the rest WORLD transform of its parent is compared between the two files and, when they differ,
|
|
105
|
+
// the track is re-expressed in the model's parent space: local_model(t) = inv(Pmodel)·Pclip·local_clip(t).
|
|
106
|
+
// The comparison is made with the clip's FIRST FRAME written onto every bone it animates, in both
|
|
107
|
+
// files — the rest poses of two exports of one rig routinely differ by a hair (Mixamo re-exports the
|
|
108
|
+
// hips 1.6 cm apart) and those differences are overridden by the tracks anyway. What is left is the
|
|
109
|
+
// STATIC structure above a bone: wrapper nodes, a missing root, an unanimated offset.
|
|
110
|
+
type TRS = { t: Vec3, r: Quat, s: Vec3 }
|
|
111
|
+
const TRS_ID: TRS = { t: [ 0, 0, 0 ], r: [ 0, 0, 0, 1 ], s: [ 1, 1, 1 ] }
|
|
112
|
+
const composeTRS = (p: TRS, l: TRS): TRS => {
|
|
113
|
+
const st: Vec3 = [ p.s[0] * l.t[0], p.s[1] * l.t[1], p.s[2] * l.t[2] ]
|
|
114
|
+
const rt = qrot(p.r, st)
|
|
115
|
+
return { t: [ p.t[0] + rt[0], p.t[1] + rt[1], p.t[2] + rt[2] ], r: qnorm(qmul(p.r, l.r)), s: [ p.s[0] * l.s[0], p.s[1] * l.s[1], p.s[2] * l.s[2] ] }
|
|
116
|
+
}
|
|
117
|
+
const invertTRS = (a: TRS): TRS => {
|
|
118
|
+
const s: Vec3 = [ 1 / a.s[0], 1 / a.s[1], 1 / a.s[2] ]
|
|
119
|
+
const r = qinv(a.r)
|
|
120
|
+
const rt = qrot(r, a.t)
|
|
121
|
+
return { t: [ -s[0] * rt[0], -s[1] * rt[1], -s[2] * rt[2] ], r, s }
|
|
122
|
+
}
|
|
123
|
+
const quatAngleDeg = (q: Quat): number => 2 * Math.acos(Math.min(1, Math.abs(q[3]))) * 180 / Math.PI
|
|
124
|
+
const isIdentityTRS = (m: TRS, eps = 1e-4): boolean =>
|
|
125
|
+
Math.hypot(m.t[0], m.t[1], m.t[2]) < eps && quatAngleDeg(m.r) < 0.01 && Math.abs(m.s[0] - 1) < eps && Math.abs(m.s[1] - 1) < eps && Math.abs(m.s[2] - 1) < eps
|
|
126
|
+
|
|
127
|
+
/** First-frame values of a clip per node: whichever of t/r/s it animates. */
|
|
128
|
+
type Frame0 = Map<number, Partial<TRS>>
|
|
129
|
+
const frame0Of = (clip: ClipSource, a: ClipSourceAnimation): Frame0 => {
|
|
130
|
+
const out: Frame0 = new Map()
|
|
131
|
+
for (const tr of a.tracks) {
|
|
132
|
+
if (tr.times.length <= 0) continue
|
|
133
|
+
const comps = tr.path === "rotation" ? 4 : 3
|
|
134
|
+
const v = Array.from(tr.values.subarray(0, comps)) as number[]
|
|
135
|
+
const e = out.get(tr.node) ?? {}
|
|
136
|
+
if (tr.path === "translation") e.t = v as Vec3
|
|
137
|
+
else if (tr.path === "rotation") e.r = v as Quat
|
|
138
|
+
else e.s = v as Vec3
|
|
139
|
+
out.set(tr.node, e)
|
|
140
|
+
}
|
|
141
|
+
return out
|
|
142
|
+
}
|
|
143
|
+
/** World transform of a clip-file node at the clip's first frame (rest where it isn't animated). */
|
|
144
|
+
const clipWorldOf = (clip: ClipSource, f0: Frame0, cache: Map<number, TRS>, idx: number): TRS => {
|
|
145
|
+
if (idx < 0) return TRS_ID
|
|
146
|
+
const hit = cache.get(idx)
|
|
147
|
+
if (hit) return hit
|
|
148
|
+
const n = clip.nodes[idx]
|
|
149
|
+
const o = f0.get(idx)
|
|
150
|
+
const w = composeTRS(clipWorldOf(clip, f0, cache, n.parent), { t: o?.t ?? n.t, r: o?.r ?? n.r, s: o?.s ?? n.s })
|
|
151
|
+
cache.set(idx, w)
|
|
152
|
+
return w
|
|
153
|
+
}
|
|
154
|
+
/** World transform of a Document node (null = the scene) with the clip's first frame written onto
|
|
155
|
+
* the bones the clip binds to — so only STRUCTURAL differences remain against `clipWorldOf`. */
|
|
156
|
+
const docWorldOf = (clip: ClipSource, f0: Frame0, clipIdxOf: Map<Node, number>, clipCache: Map<number, TRS>, cache: Map<Node, TRS>, node: Node | null): TRS => {
|
|
157
|
+
if (!node) return TRS_ID
|
|
158
|
+
const hit = cache.get(node)
|
|
159
|
+
if (hit) return hit
|
|
160
|
+
// a bone the clip animates ends up exactly where the clip puts it — its track is re-expressed to
|
|
161
|
+
// make that so — hence its world IS the clip's world; only the unanimated structure above differs
|
|
162
|
+
const ci = clipIdxOf.get(node)
|
|
163
|
+
const w = ci !== undefined && f0.has(ci)
|
|
164
|
+
? clipWorldOf(clip, f0, clipCache, ci)
|
|
165
|
+
: composeTRS(docWorldOf(clip, f0, clipIdxOf, clipCache, cache, node.getParentNode()), { t: node.getTranslation() as Vec3, r: node.getRotation() as Quat, s: node.getScale() as Vec3 })
|
|
166
|
+
cache.set(node, w)
|
|
167
|
+
return w
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Merge every animation of a clip source into `doc`, re-targeted by bone name. Returns per-clip stats. */
|
|
171
|
+
export const mergeClipScene = (doc: Document, clip: ClipSource, sourceLabel: string, explicitName: string | undefined, rename: Record<string, string> | undefined, warn: (w: ConvertWarning) => void): ConvertReport["clips"] => {
|
|
172
|
+
const maps = nodeNameMaps(doc)
|
|
173
|
+
const buffer = doc.getRoot().listBuffers()[0] ?? doc.createBuffer("buffer")
|
|
174
|
+
const out: ConvertReport["clips"] = []
|
|
175
|
+
// clip node → bound model node (and back), once per clip file
|
|
176
|
+
const clipIdxOf = new Map<Node, number>()
|
|
177
|
+
for (let i = 0; i < clip.nodes.length; i++) {
|
|
178
|
+
const n = clip.nodes[i]
|
|
179
|
+
if (n.isRoot) continue
|
|
180
|
+
const t = findNodeByName(maps, n.name, rename)
|
|
181
|
+
if (t && !clipIdxOf.has(t)) clipIdxOf.set(t, i)
|
|
182
|
+
}
|
|
183
|
+
let clipWorlds = new Map<number, TRS>()
|
|
184
|
+
let docWorlds = new Map<Node, TRS>()
|
|
185
|
+
let corrections = new Map<number, TRS | null>() // clip node index → inv(Pmodel)·Pclip (null = parents agree)
|
|
186
|
+
let f0: Frame0 = new Map()
|
|
187
|
+
const corrected: string[] = []
|
|
188
|
+
const correctionFor = (srcIdx: number, target: Node): TRS | null => {
|
|
189
|
+
const hit = corrections.get(srcIdx)
|
|
190
|
+
if (hit !== undefined) return hit
|
|
191
|
+
const pc = clipWorldOf(clip, f0, clipWorlds, clip.nodes[srcIdx].parent)
|
|
192
|
+
const pm = docWorldOf(clip, f0, clipIdxOf, clipWorlds, docWorlds, target.getParentNode())
|
|
193
|
+
const m = composeTRS(invertTRS(pm), pc)
|
|
194
|
+
let res: TRS | null = isIdentityTRS(m) ? null : m
|
|
195
|
+
if (res && (Math.abs(res.s[0] - 1) > 1e-3 || Math.abs(res.s[1] - 1) > 1e-3 || Math.abs(res.s[2] - 1) > 1e-3)) {
|
|
196
|
+
warn({ level: "warn", message: `bone '${clip.nodes[srcIdx].name}': the clip file and the model disagree on the SCALE above it (${res.s.map((v) => v.toFixed(3)).join(",")}) — track copied verbatim` })
|
|
197
|
+
res = null
|
|
198
|
+
}
|
|
199
|
+
if (res && !corrected.includes(clip.nodes[srcIdx].name)) corrected.push(clip.nodes[srcIdx].name)
|
|
200
|
+
corrections.set(srcIdx, res)
|
|
201
|
+
return res
|
|
202
|
+
}
|
|
203
|
+
const emitted = clip.animations.filter((a) => a.tracks.length > 0)
|
|
204
|
+
const fileBase = basename(sourceLabel, extname(sourceLabel))
|
|
205
|
+
for (const a of emitted) {
|
|
206
|
+
let name = explicitName ?? a.name
|
|
207
|
+
if (!explicitName && (emitted.length === 1 ? isGenericClipName(name) : false)) name = fileBase
|
|
208
|
+
if (!explicitName && emitted.length > 1 && isGenericClipName(name)) name = `${fileBase}/${a.name || "clip"}`
|
|
209
|
+
// unique names inside the document
|
|
210
|
+
const existing = new Set(doc.getRoot().listAnimations().map((x) => x.getName()))
|
|
211
|
+
let unique = name, k = 2
|
|
212
|
+
while (existing.has(unique)) unique = `${name} (${k++})`
|
|
213
|
+
const anim = doc.createAnimation(unique)
|
|
214
|
+
clipWorlds = new Map(); docWorlds = new Map(); corrections = new Map(); f0 = frame0Of(clip, a)
|
|
215
|
+
let bound = 0
|
|
216
|
+
const unbound = new Set<string>()
|
|
217
|
+
for (const tr of a.tracks) {
|
|
218
|
+
const srcNode = clip.nodes[tr.node]
|
|
219
|
+
if (!srcNode || srcNode.isRoot) continue
|
|
220
|
+
const target = findNodeByName(maps, srcNode.name, rename)
|
|
221
|
+
if (!target) { unbound.add(srcNode.name || `(unnamed #${tr.node})`); continue }
|
|
222
|
+
const times = tr.times.slice()
|
|
223
|
+
const comps = tr.path === "rotation" ? 4 : 3
|
|
224
|
+
const values = tr.values.slice()
|
|
225
|
+
const fix = correctionFor(tr.node, target)
|
|
226
|
+
if (fix) {
|
|
227
|
+
if (tr.path === "rotation") {
|
|
228
|
+
for (let k = 0; k < values.length; k += 4) {
|
|
229
|
+
const q = qnorm(qmul(fix.r, [ values[k], values[k + 1], values[k + 2], values[k + 3] ]))
|
|
230
|
+
values[k] = q[0]; values[k + 1] = q[1]; values[k + 2] = q[2]; values[k + 3] = q[3]
|
|
231
|
+
}
|
|
232
|
+
} else if (tr.path === "translation") {
|
|
233
|
+
for (let k = 0; k < values.length; k += 3) {
|
|
234
|
+
const v = qrot(fix.r, [ fix.s[0] * values[k], fix.s[1] * values[k + 1], fix.s[2] * values[k + 2] ])
|
|
235
|
+
values[k] = v[0] + fix.t[0]; values[k + 1] = v[1] + fix.t[1]; values[k + 2] = v[2] + fix.t[2]
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (tr.path === "rotation") fixQuatContinuity(values)
|
|
240
|
+
const sampler = doc.createAnimationSampler()
|
|
241
|
+
.setInput(doc.createAccessor().setType("SCALAR").setArray(times).setBuffer(buffer))
|
|
242
|
+
.setOutput(doc.createAccessor().setType(comps === 4 ? "VEC4" : "VEC3").setArray(values).setBuffer(buffer))
|
|
243
|
+
.setInterpolation("LINEAR")
|
|
244
|
+
const channel = doc.createAnimationChannel().setTargetNode(target).setTargetPath(tr.path).setSampler(sampler)
|
|
245
|
+
anim.addSampler(sampler).addChannel(channel)
|
|
246
|
+
bound++
|
|
247
|
+
}
|
|
248
|
+
const extras = clip.extras?.(a.name)
|
|
249
|
+
if (extras) anim.setExtras(extras)
|
|
250
|
+
const total = a.tracks.filter((t) => !clip.nodes[t.node]?.isRoot).length
|
|
251
|
+
if (bound === 0 && total > 0) warn({ level: "warn", message: `clip '${unique}' (${sourceLabel}): NO track matched a bone of the model — different rig? (bones: ${[ ...unbound ].slice(0, 5).join(", ")}…)` })
|
|
252
|
+
else if (unbound.size > 0) warn({ level: "warn", message: `clip '${unique}' (${sourceLabel}): ${total - bound}/${total} tracks unbound — bones not in the model: ${[ ...unbound ].slice(0, 8).join(", ")}${unbound.size > 8 ? "…" : ""}` })
|
|
253
|
+
out.push({ name: unique, source: sourceLabel, duration: a.duration, tracks: total, bound })
|
|
254
|
+
}
|
|
255
|
+
if (corrected.length > 0) {
|
|
256
|
+
const m = corrections.get(clip.nodes.findIndex((n) => n.name === corrected[0]))!
|
|
257
|
+
const off = Math.hypot(m.t[0], m.t[1], m.t[2])
|
|
258
|
+
warn({ level: "info", message: `${sourceLabel}: '${corrected.join("', '")}' sit${corrected.length === 1 ? "s" : ""} under a different parent than in the model (rotation ${quatAngleDeg(m.r).toFixed(0)}°${off > 1e-4 ? `, offset ${off.toFixed(3)} m` : ""}) — track${corrected.length === 1 ? "" : "s"} re-expressed in the model's parent space` })
|
|
259
|
+
}
|
|
260
|
+
return out
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export type ClipSourceOptions = {
|
|
264
|
+
/** `.uasset` clips: the skeleton they were authored on (default: resolved from the pack). */
|
|
265
|
+
skeleton?: string
|
|
266
|
+
/** `.uasset` clips: the pack's Content folder, when the walk-up search cannot find it. */
|
|
267
|
+
contentRoot?: string
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** A clip file — FBX or Unreal `.uasset` AnimSequence — as a ClipSource. */
|
|
271
|
+
export const loadClipSource = async (path: string, opts: ClipSourceOptions = {}): Promise<ClipSource> => {
|
|
272
|
+
if (/\.uasset$/i.test(path)) {
|
|
273
|
+
const { loadUnreal } = await import("../unreal/index")
|
|
274
|
+
const { clipSourceFromUnreal } = await import("../unreal/clipSource")
|
|
275
|
+
const u = loadUnreal(path, { skeleton: opts.skeleton, contentRoot: opts.contentRoot })
|
|
276
|
+
if (!u.anim) throw new Error(`${basename(path)}: not an AnimSequence (${u.kind}) — --clips wants animation assets`)
|
|
277
|
+
return clipSourceFromUnreal(u.skeleton, u.anim)
|
|
278
|
+
}
|
|
279
|
+
return clipSourceFromFbx(await readFbx(new Uint8Array(readFileSync(path))))
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export type ConvertResult = { doc: Document, report: ConvertReport }
|
|
283
|
+
|
|
284
|
+
/** FBX file (+ clip files) → Document. */
|
|
285
|
+
export const convertFbx = async (fbxPath: string, opts: ConvertOptions = {}): Promise<ConvertResult> => {
|
|
286
|
+
const warnings: ConvertWarning[] = []
|
|
287
|
+
const warn = (w: ConvertWarning): void => { warnings.push(w); opts.warn?.(w) }
|
|
288
|
+
const bytes = new Uint8Array(readFileSync(fbxPath))
|
|
289
|
+
const scene = await readFbx(bytes)
|
|
290
|
+
const base = basename(fbxPath, extname(fbxPath))
|
|
291
|
+
const { doc, clipNames } = await toDocument(scene, { baseDir: dirname(fbxPath), defaultClipName: base, warn })
|
|
292
|
+
let unity: ConvertReport["unity"]
|
|
293
|
+
if (opts.unity !== false) {
|
|
294
|
+
const { applyUnityMaterials, pruneUnusedTextures } = await import("../unity/materials")
|
|
295
|
+
unity = await applyUnityMaterials(doc, fbxPath, { materialDirs: typeof opts.unity === "string" ? [ opts.unity ] : undefined, indexFile: opts.unityIndex, warn })
|
|
296
|
+
if (unity.applied.length) pruneUnusedTextures(doc)
|
|
297
|
+
}
|
|
298
|
+
const clips: ConvertReport["clips"] = []
|
|
299
|
+
if (opts.dropModelClips) {
|
|
300
|
+
for (const a of doc.getRoot().listAnimations()) a.dispose()
|
|
301
|
+
} else {
|
|
302
|
+
doc.getRoot().listAnimations().forEach((a, i) => {
|
|
303
|
+
const src = scene.animations.filter((x) => x.tracks.length > 0)[i]
|
|
304
|
+
clips.push({ name: a.getName(), source: basename(fbxPath), duration: src?.duration ?? 0, tracks: a.listChannels().length, bound: a.listChannels().length })
|
|
305
|
+
})
|
|
306
|
+
void clipNames
|
|
307
|
+
}
|
|
308
|
+
for (let i = 0; i < (opts.clips ?? []).length; i++) {
|
|
309
|
+
const clipPath = opts.clips![i]
|
|
310
|
+
clips.push(...mergeClipScene(doc, await loadClipSource(clipPath, opts), basename(clipPath), opts.clipNames?.[i], opts.rename, warn))
|
|
311
|
+
}
|
|
312
|
+
const root = doc.getRoot()
|
|
313
|
+
return {
|
|
314
|
+
doc,
|
|
315
|
+
report: {
|
|
316
|
+
clips, warnings,
|
|
317
|
+
nodes: root.listNodes().length, meshes: root.listMeshes().length, skins: root.listSkins().length,
|
|
318
|
+
textures: root.listTextures().length, materials: root.listMaterials().length,
|
|
319
|
+
unity,
|
|
320
|
+
},
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export const makeIO = (): NodeIO => new NodeIO().registerExtensions(ALL_EXTENSIONS)
|
|
325
|
+
|
|
326
|
+
/** Serialize a Document to GLB bytes. */
|
|
327
|
+
export const writeGlb = async (doc: Document): Promise<Uint8Array> => makeIO().writeBinary(doc)
|
package/src/fbx/read.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// FBX reading via the ufbx wasm module (native/fbx_reader.c → src/wasm/fbxReader.js). Returns a
|
|
2
|
+
// plain intermediate representation already in glTF conventions (meters, Y-up, triangulated,
|
|
3
|
+
// baked animations); `toDocument` turns it into a glTF-Transform Document.
|
|
4
|
+
|
|
5
|
+
// @ts-ignore — generated by build-wasm.sh (Emscripten single-file module)
|
|
6
|
+
import createFbxReader from "../wasm/fbxReader.js"
|
|
7
|
+
|
|
8
|
+
export type FbxNode = {
|
|
9
|
+
name: string
|
|
10
|
+
parent: number
|
|
11
|
+
t: [number, number, number]
|
|
12
|
+
r: [number, number, number, number]
|
|
13
|
+
s: [number, number, number]
|
|
14
|
+
mesh: number
|
|
15
|
+
skin: number
|
|
16
|
+
isRoot: boolean
|
|
17
|
+
isBone: boolean
|
|
18
|
+
helper: boolean
|
|
19
|
+
}
|
|
20
|
+
export type FbxPrimitive = {
|
|
21
|
+
material: number
|
|
22
|
+
vertexCount: number
|
|
23
|
+
indexCount: number
|
|
24
|
+
pos: number; nrm: number; uv: number; col: number; joints: number; weights: number; indices: number
|
|
25
|
+
/** UV sets 1..3 (Unity uv2 / uv3 / uv4) — a pack's detail layer lives there; -1 when the mesh has none. */
|
|
26
|
+
uv1: number; uv2: number; uv3: number
|
|
27
|
+
min: [number, number, number]
|
|
28
|
+
max: [number, number, number]
|
|
29
|
+
}
|
|
30
|
+
export type FbxMesh = { name: string, primitives: FbxPrimitive[] }
|
|
31
|
+
export type FbxSkin = { name: string, joints: number[], ibm: number }
|
|
32
|
+
export type FbxMaterial = {
|
|
33
|
+
name: string
|
|
34
|
+
baseColor: [number, number, number, number]
|
|
35
|
+
baseTex: number
|
|
36
|
+
metallic: number
|
|
37
|
+
roughness: number
|
|
38
|
+
metallicTex: number
|
|
39
|
+
roughnessTex: number
|
|
40
|
+
normalTex: number
|
|
41
|
+
occlusionTex: number
|
|
42
|
+
emissive: [number, number, number]
|
|
43
|
+
emissiveTex: number
|
|
44
|
+
opacity: number
|
|
45
|
+
opacityTex: number
|
|
46
|
+
doubleSided: boolean
|
|
47
|
+
unlit: boolean
|
|
48
|
+
shader: string
|
|
49
|
+
}
|
|
50
|
+
export type FbxTexture = { name: string, filename: string, relative: string, absolute: string, content: number, size: number }
|
|
51
|
+
export type FbxTrack = { node: number, path: "translation" | "rotation" | "scale", count: number, times: number, values: number, constant: boolean }
|
|
52
|
+
export type FbxAnimation = { name: string, duration: number, tracks: FbxTrack[] }
|
|
53
|
+
|
|
54
|
+
export type FbxScene = {
|
|
55
|
+
meta: { unitMeters: number, exporter: number, creator: string, version: number }
|
|
56
|
+
nodes: FbxNode[]
|
|
57
|
+
meshes: FbxMesh[]
|
|
58
|
+
skins: FbxSkin[]
|
|
59
|
+
materials: FbxMaterial[]
|
|
60
|
+
textures: FbxTexture[]
|
|
61
|
+
animations: FbxAnimation[]
|
|
62
|
+
/** The binary arena every offset above points into (a private copy — safe after close). */
|
|
63
|
+
arena: Uint8Array
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type Module = {
|
|
67
|
+
_malloc(n: number): number
|
|
68
|
+
_free(p: number): void
|
|
69
|
+
_fbx_open(ptr: number, size: number): number
|
|
70
|
+
_fbx_error(): number
|
|
71
|
+
_fbx_json(h: number): number
|
|
72
|
+
_fbx_arena(h: number): number
|
|
73
|
+
_fbx_arena_size(h: number): number
|
|
74
|
+
_fbx_close(h: number): void
|
|
75
|
+
HEAPU8: Uint8Array
|
|
76
|
+
UTF8ToString(ptr: number): string
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let modulePromise: Promise<Module> | undefined
|
|
80
|
+
const getModule = (): Promise<Module> => (modulePromise ??= (createFbxReader as () => Promise<Module>)())
|
|
81
|
+
|
|
82
|
+
/** Parse an FBX (bytes) into the intermediate representation. Throws with ufbx's message on failure. */
|
|
83
|
+
export const readFbx = async (bytes: Uint8Array): Promise<FbxScene> => {
|
|
84
|
+
const m = await getModule()
|
|
85
|
+
const ptr = m._malloc(bytes.byteLength)
|
|
86
|
+
m.HEAPU8.set(bytes, ptr)
|
|
87
|
+
const handle = m._fbx_open(ptr, bytes.byteLength)
|
|
88
|
+
m._free(ptr)
|
|
89
|
+
if (handle === 0) throw new Error(`FBX: ${m.UTF8ToString(m._fbx_error()) || "unknown error"}`)
|
|
90
|
+
try {
|
|
91
|
+
const json = m.UTF8ToString(m._fbx_json(handle))
|
|
92
|
+
const arenaPtr = m._fbx_arena(handle)
|
|
93
|
+
const arenaSize = m._fbx_arena_size(handle)
|
|
94
|
+
const arena = new Uint8Array(arenaSize)
|
|
95
|
+
arena.set(m.HEAPU8.subarray(arenaPtr, arenaPtr + arenaSize))
|
|
96
|
+
const scene = JSON.parse(json) as Omit<FbxScene, "arena">
|
|
97
|
+
return { ...scene, arena }
|
|
98
|
+
} finally {
|
|
99
|
+
m._fbx_close(handle)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---- arena readers ----
|
|
104
|
+
export const f32 = (scene: FbxScene, offset: number, count: number): Float32Array =>
|
|
105
|
+
new Float32Array(scene.arena.buffer, scene.arena.byteOffset + offset, count)
|
|
106
|
+
export const u16 = (scene: FbxScene, offset: number, count: number): Uint16Array =>
|
|
107
|
+
new Uint16Array(scene.arena.buffer, scene.arena.byteOffset + offset, count)
|
|
108
|
+
export const u32 = (scene: FbxScene, offset: number, count: number): Uint32Array =>
|
|
109
|
+
new Uint32Array(scene.arena.buffer, scene.arena.byteOffset + offset, count)
|
|
110
|
+
export const bytes = (scene: FbxScene, offset: number, size: number): Uint8Array =>
|
|
111
|
+
scene.arena.subarray(offset, offset + size)
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// FbxScene (IR from the ufbx wasm reader) → glTF-Transform Document. Nodes/meshes/skins/materials/
|
|
2
|
+
// textures/animations; the doctor passes and the writer then run on the Document like on any GLB.
|
|
3
|
+
|
|
4
|
+
import { Document, type Node, type Texture, type Material } from "@gltf-transform/core"
|
|
5
|
+
import { type FbxScene, type FbxTexture, f32, u16, u32, bytes } from "./read"
|
|
6
|
+
import { resolveTextureImage, type ResolvedImage } from "../textures/resolveImage"
|
|
7
|
+
|
|
8
|
+
export type ConvertWarning = { level: "warn" | "info", message: string }
|
|
9
|
+
|
|
10
|
+
/** A COLOR_0 set (RGBA per vertex) that is black on EVERY vertex - a channel nobody authored, never a colour to multiply by. */
|
|
11
|
+
export function deadVertexColor(col: Float32Array): boolean {
|
|
12
|
+
for (let i = 0; i + 3 < col.length; i += 4) if (col[i] > 0 || col[i + 1] > 0 || col[i + 2] > 0) return false
|
|
13
|
+
return col.length > 0
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type ToDocumentOptions = {
|
|
17
|
+
/** Directory of the source .fbx — external texture references are resolved against it. */
|
|
18
|
+
baseDir?: string
|
|
19
|
+
/** Name used for the file's single clip when its stack name is generic (`mixamo.com`, `Take 001`, `Unreal Take`). */
|
|
20
|
+
defaultClipName?: string
|
|
21
|
+
/** Skip meshes/skins/materials/textures (clip-only conversion). */
|
|
22
|
+
animationsOnly?: boolean
|
|
23
|
+
/** Also write the FBX's further UV sets as TEXCOORD_1..3 (off by default: TEXCOORD_1 is the lightmap set in
|
|
24
|
+
* every LeCodes GLB, and a pack's extra sets are shader inputs — the Unity kind builder reads them and
|
|
25
|
+
* drops them again). */
|
|
26
|
+
extraUvSets?: boolean
|
|
27
|
+
warn?: (w: ConvertWarning) => void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const GENERIC_CLIP_NAMES = new Set([ "", "mixamo.com", "take 001", "take001", "unreal take", "default take", "unnamed", "scene" ])
|
|
31
|
+
export const isGenericClipName = (name: string): boolean => GENERIC_CLIP_NAMES.has(name.trim().toLowerCase())
|
|
32
|
+
|
|
33
|
+
const isIdentityTransform = (t: number[], r: number[], s: number[]): boolean =>
|
|
34
|
+
Math.hypot(t[0], t[1], t[2]) < 1e-9 && Math.abs(r[3] - 1) < 1e-9 && Math.hypot(r[0], r[1], r[2]) < 1e-9 &&
|
|
35
|
+
Math.abs(s[0] - 1) < 1e-9 && Math.abs(s[1] - 1) < 1e-9 && Math.abs(s[2] - 1) < 1e-9
|
|
36
|
+
|
|
37
|
+
/** Make consecutive quaternions hemisphere-consistent so LINEAR (slerp) interpolation never takes the long way. */
|
|
38
|
+
export const fixQuatContinuity = (q: Float32Array): void => {
|
|
39
|
+
for (let i = 4; i < q.length; i += 4) {
|
|
40
|
+
const dot = q[i] * q[i - 4] + q[i + 1] * q[i - 3] + q[i + 2] * q[i - 2] + q[i + 3] * q[i - 1]
|
|
41
|
+
if (dot < 0) { q[i] = -q[i]; q[i + 1] = -q[i + 1]; q[i + 2] = -q[i + 2]; q[i + 3] = -q[i + 3] }
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type ToDocumentResult = {
|
|
46
|
+
doc: Document
|
|
47
|
+
/** glTF node per FBX node index (null for the ufbx root when it was flattened away). */
|
|
48
|
+
nodes: (Node | null)[]
|
|
49
|
+
clipNames: string[]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const toDocument = async (scene: FbxScene, opts: ToDocumentOptions = {}): Promise<ToDocumentResult> => {
|
|
53
|
+
const warn = opts.warn ?? (() => {})
|
|
54
|
+
const doc = new Document()
|
|
55
|
+
const buffer = doc.createBuffer("buffer")
|
|
56
|
+
const gscene = doc.createScene("Scene")
|
|
57
|
+
|
|
58
|
+
// ---- nodes ----
|
|
59
|
+
const nodes: (Node | null)[] = new Array(scene.nodes.length).fill(null)
|
|
60
|
+
const rootIdx = scene.nodes.findIndex((n) => n.isRoot)
|
|
61
|
+
const root = rootIdx >= 0 ? scene.nodes[rootIdx] : undefined
|
|
62
|
+
let rootNode: Node | null = null
|
|
63
|
+
if (root && !isIdentityTransform(root.t, root.r, root.s)) {
|
|
64
|
+
rootNode = doc.createNode(root.name || "RootNode").setTranslation(root.t).setRotation(root.r).setScale(root.s)
|
|
65
|
+
gscene.addChild(rootNode)
|
|
66
|
+
nodes[rootIdx] = rootNode
|
|
67
|
+
}
|
|
68
|
+
for (let i = 0; i < scene.nodes.length; i++) {
|
|
69
|
+
const n = scene.nodes[i]
|
|
70
|
+
if (n.isRoot) continue
|
|
71
|
+
const g = doc.createNode(n.name).setTranslation(n.t).setRotation(n.r).setScale(n.s)
|
|
72
|
+
nodes[i] = g
|
|
73
|
+
}
|
|
74
|
+
for (let i = 0; i < scene.nodes.length; i++) {
|
|
75
|
+
const n = scene.nodes[i]
|
|
76
|
+
const g = nodes[i]
|
|
77
|
+
if (!g || n.isRoot) continue
|
|
78
|
+
const parent = n.parent >= 0 ? nodes[n.parent] : null
|
|
79
|
+
if (parent) parent.addChild(g)
|
|
80
|
+
else gscene.addChild(g)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!opts.animationsOnly) {
|
|
84
|
+
// ---- textures ----
|
|
85
|
+
const textures: (Texture | null)[] = []
|
|
86
|
+
const byHash = new Map<string, Texture>()
|
|
87
|
+
for (let i = 0; i < scene.textures.length; i++) {
|
|
88
|
+
const t = scene.textures[i]
|
|
89
|
+
const img = await resolveTextureImage(t, t.content >= 0 ? bytes(scene, t.content, t.size) : null, opts.baseDir, warn)
|
|
90
|
+
if (!img) { textures.push(null); continue }
|
|
91
|
+
const key = img.hash
|
|
92
|
+
let tex = byHash.get(key)
|
|
93
|
+
if (!tex) {
|
|
94
|
+
tex = doc.createTexture(textureName(t, img)).setImage(img.data).setMimeType(img.mimeType)
|
|
95
|
+
byHash.set(key, tex)
|
|
96
|
+
}
|
|
97
|
+
textures.push(tex)
|
|
98
|
+
}
|
|
99
|
+
const texAt = (i: number): Texture | null => (i >= 0 && i < textures.length ? textures[i] : null)
|
|
100
|
+
|
|
101
|
+
// ---- materials ----
|
|
102
|
+
const materials: Material[] = scene.materials.map((m) => {
|
|
103
|
+
const mat = doc.createMaterial(m.name)
|
|
104
|
+
mat.setBaseColorFactor([ clamp01(m.baseColor[0]), clamp01(m.baseColor[1]), clamp01(m.baseColor[2]), clamp01(m.baseColor[3]) ])
|
|
105
|
+
const base = texAt(m.baseTex)
|
|
106
|
+
if (base) { mat.setBaseColorTexture(base); mat.setBaseColorFactor([ 1, 1, 1, clamp01(m.baseColor[3]) ]) }
|
|
107
|
+
mat.setMetallicFactor(clamp01(m.metallic))
|
|
108
|
+
mat.setRoughnessFactor(clamp01(m.roughness))
|
|
109
|
+
// glTF packs metallic (B) + roughness (G) into ONE texture; FBX keeps them apart. Only when both
|
|
110
|
+
// point at the same image can we forward it — otherwise the factors above stand in.
|
|
111
|
+
if (m.metallicTex >= 0 && m.metallicTex === m.roughnessTex) {
|
|
112
|
+
const mr = texAt(m.metallicTex); if (mr) { mat.setMetallicRoughnessTexture(mr); mat.setMetallicFactor(1); mat.setRoughnessFactor(1) }
|
|
113
|
+
} else if (m.metallicTex >= 0 || m.roughnessTex >= 0) {
|
|
114
|
+
warn({ level: "info", message: `material '${m.name}': separate metallic/roughness maps are not packed (factors used)` })
|
|
115
|
+
}
|
|
116
|
+
const nrm = texAt(m.normalTex); if (nrm) mat.setNormalTexture(nrm)
|
|
117
|
+
const occ = texAt(m.occlusionTex); if (occ) mat.setOcclusionTexture(occ)
|
|
118
|
+
const em = texAt(m.emissiveTex)
|
|
119
|
+
if (em) { mat.setEmissiveTexture(em); mat.setEmissiveFactor([ 1, 1, 1 ]) }
|
|
120
|
+
else mat.setEmissiveFactor([ clamp01(m.emissive[0]), clamp01(m.emissive[1]), clamp01(m.emissive[2]) ])
|
|
121
|
+
// alpha: an opacity map → cutout (hair cards, leaves); a factor < 1 → blend
|
|
122
|
+
if (m.opacityTex >= 0) {
|
|
123
|
+
mat.setAlphaMode("MASK").setAlphaCutoff(0.5)
|
|
124
|
+
if (!base && texAt(m.opacityTex)) mat.setBaseColorTexture(texAt(m.opacityTex)!)
|
|
125
|
+
} else if (m.opacity < 0.999) {
|
|
126
|
+
mat.setAlphaMode("BLEND")
|
|
127
|
+
const f = mat.getBaseColorFactor(); mat.setBaseColorFactor([ f[0], f[1], f[2], clamp01(m.opacity) ])
|
|
128
|
+
}
|
|
129
|
+
mat.setDoubleSided(m.doubleSided)
|
|
130
|
+
return mat
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
// ---- meshes ----
|
|
134
|
+
// An FBX geometry is often unnamed (Maya exports, "wolf" → mesh ""); name it after the node that
|
|
135
|
+
// carries it so probe/doctor/pick-by-name have something to print.
|
|
136
|
+
const meshNodeName: string[] = new Array(scene.meshes.length).fill("")
|
|
137
|
+
for (const n of scene.nodes) if (n.mesh >= 0 && n.mesh < meshNodeName.length && !meshNodeName[n.mesh]) meshNodeName[n.mesh] = n.name
|
|
138
|
+
const meshes = scene.meshes.map((m, mi) => {
|
|
139
|
+
const mesh = doc.createMesh(m.name || meshNodeName[mi])
|
|
140
|
+
for (const p of m.primitives) {
|
|
141
|
+
const prim = doc.createPrimitive()
|
|
142
|
+
prim.setAttribute("POSITION", doc.createAccessor().setType("VEC3").setArray(f32(scene, p.pos, p.vertexCount * 3).slice()).setBuffer(buffer))
|
|
143
|
+
if (p.nrm >= 0) prim.setAttribute("NORMAL", doc.createAccessor().setType("VEC3").setArray(f32(scene, p.nrm, p.vertexCount * 3).slice()).setBuffer(buffer))
|
|
144
|
+
if (p.uv >= 0) prim.setAttribute("TEXCOORD_0", doc.createAccessor().setType("VEC2").setArray(f32(scene, p.uv, p.vertexCount * 2).slice()).setBuffer(buffer))
|
|
145
|
+
if (opts.extraUvSets) for (const [ k, off ] of [ p.uv1, p.uv2, p.uv3 ].entries()) if (off !== undefined && off >= 0) prim.setAttribute(`TEXCOORD_${k + 1}`, doc.createAccessor().setType("VEC2").setArray(f32(scene, off, p.vertexCount * 2).slice()).setBuffer(buffer))
|
|
146
|
+
if (p.col >= 0) {
|
|
147
|
+
const col = f32(scene, p.col, p.vertexCount * 4).slice()
|
|
148
|
+
// An all-black colour set is an UNUSED channel: Unity's Standard shader never reads vertex colour, so a pack
|
|
149
|
+
// ships whatever its DCC left there (the Military FPS hands: zeros on the skin mesh) - while every glTF
|
|
150
|
+
// renderer multiplies it into the base colour, and the mesh comes out black. Dropped, with a note.
|
|
151
|
+
if (deadVertexColor(col)) warn({ level: "info", message: `mesh '${m.name || meshNodeName[mi]}': all-black COLOR_0 dropped (it would multiply the base colour to black)` })
|
|
152
|
+
else prim.setAttribute("COLOR_0", doc.createAccessor().setType("VEC4").setArray(col).setBuffer(buffer))
|
|
153
|
+
}
|
|
154
|
+
if (p.joints >= 0 && p.weights >= 0) {
|
|
155
|
+
prim.setAttribute("JOINTS_0", doc.createAccessor().setType("VEC4").setArray(u16(scene, p.joints, p.vertexCount * 4).slice()).setBuffer(buffer))
|
|
156
|
+
prim.setAttribute("WEIGHTS_0", doc.createAccessor().setType("VEC4").setArray(f32(scene, p.weights, p.vertexCount * 4).slice()).setBuffer(buffer))
|
|
157
|
+
}
|
|
158
|
+
const idx = u32(scene, p.indices, p.indexCount)
|
|
159
|
+
const indices = p.vertexCount <= 65535 ? Uint16Array.from(idx) : idx.slice()
|
|
160
|
+
prim.setIndices(doc.createAccessor().setType("SCALAR").setArray(indices).setBuffer(buffer))
|
|
161
|
+
if (p.material >= 0 && p.material < materials.length) prim.setMaterial(materials[p.material])
|
|
162
|
+
mesh.addPrimitive(prim)
|
|
163
|
+
}
|
|
164
|
+
return mesh
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
// ---- skins ----
|
|
168
|
+
const skins = scene.skins.map((s) => {
|
|
169
|
+
const skin = doc.createSkin(s.name)
|
|
170
|
+
const ibm = f32(scene, s.ibm, s.joints.length * 16).slice()
|
|
171
|
+
for (let j = 0; j < s.joints.length; j++) {
|
|
172
|
+
const jn = s.joints[j] >= 0 ? nodes[s.joints[j]] : null
|
|
173
|
+
if (jn) skin.addJoint(jn)
|
|
174
|
+
else {
|
|
175
|
+
// a cluster without a bone: bind it to the first available node so joint indices stay aligned
|
|
176
|
+
const fallback = nodes.find((n) => n) ?? doc.createNode("missing-joint")
|
|
177
|
+
skin.addJoint(fallback)
|
|
178
|
+
warn({ level: "warn", message: `skin '${s.name}': cluster ${j} has no bone node — bound to a placeholder` })
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
skin.setInverseBindMatrices(doc.createAccessor().setType("MAT4").setArray(ibm).setBuffer(buffer))
|
|
182
|
+
return skin
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
for (let i = 0; i < scene.nodes.length; i++) {
|
|
186
|
+
const n = scene.nodes[i]
|
|
187
|
+
const g = nodes[i]
|
|
188
|
+
if (!g) continue
|
|
189
|
+
if (n.mesh >= 0 && n.mesh < meshes.length) g.setMesh(meshes[n.mesh])
|
|
190
|
+
if (n.skin >= 0 && n.skin < skins.length) g.setSkin(skins[n.skin])
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ---- animations ----
|
|
195
|
+
const emitted = scene.animations.filter((a) => a.tracks.length > 0)
|
|
196
|
+
const clipNames: string[] = []
|
|
197
|
+
for (const a of emitted) {
|
|
198
|
+
let name = a.name
|
|
199
|
+
if (emitted.length === 1 && isGenericClipName(name) && opts.defaultClipName) name = opts.defaultClipName
|
|
200
|
+
clipNames.push(name)
|
|
201
|
+
const anim = doc.createAnimation(name)
|
|
202
|
+
for (const tr of a.tracks) {
|
|
203
|
+
const target = nodes[tr.node]
|
|
204
|
+
if (!target) continue
|
|
205
|
+
const times = f32(scene, tr.times, tr.count).slice()
|
|
206
|
+
const comps = tr.path === "rotation" ? 4 : 3
|
|
207
|
+
const values = f32(scene, tr.values, tr.count * comps).slice()
|
|
208
|
+
if (tr.path === "rotation") fixQuatContinuity(values)
|
|
209
|
+
const sampler = doc.createAnimationSampler()
|
|
210
|
+
.setInput(doc.createAccessor().setType("SCALAR").setArray(times).setBuffer(buffer))
|
|
211
|
+
.setOutput(doc.createAccessor().setType(comps === 4 ? "VEC4" : "VEC3").setArray(values).setBuffer(buffer))
|
|
212
|
+
.setInterpolation("LINEAR")
|
|
213
|
+
const channel = doc.createAnimationChannel().setTargetNode(target).setTargetPath(tr.path).setSampler(sampler)
|
|
214
|
+
anim.addSampler(sampler).addChannel(channel)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return { doc, nodes, clipNames }
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const clamp01 = (v: number): number => (v < 0 ? 0 : v > 1 ? 1 : v)
|
|
222
|
+
|
|
223
|
+
const textureName = (t: FbxTexture, img: ResolvedImage): string => {
|
|
224
|
+
const src = t.relative || t.filename || t.absolute || t.name
|
|
225
|
+
const base = src.split(/[\\/]/).pop() ?? t.name
|
|
226
|
+
return (base.replace(/\.[^.]+$/, "") || t.name || "texture") + (img.converted ? "" : "")
|
|
227
|
+
}
|