lecodes-assets 0.1.0
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 +226 -0
- package/dist/cli.js +4423 -0
- package/dist/index.js +4462 -0
- package/package.json +40 -0
package/README.md
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# lecodes-assets — the LeCodes asset pipeline
|
|
2
|
+
|
|
3
|
+
FBX → GLB converter + GLB doctor, one package on one glTF-Transform `Document`. Reachable as
|
|
4
|
+
`lecodes assets …` (optional peer of the `lecodes` CLI) or `bun src/cli.ts …` in the monorepo.
|
|
5
|
+
Plan/rationale: [docs/animation-plan.md §2.9](../../docs/animation-plan.md).
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
lecodes assets convert "Erika Archer.fbx" --clips Idle.fbx Walking.fbx "Fast Run.fbx" --drop-model-clips -o hero.glb
|
|
9
|
+
lecodes assets convert hero.glb --clips Jump.fbx # add clips to an existing GLB
|
|
10
|
+
lecodes assets convert hero.fbx --fix # + doctor passes (bone cap, texture normalize)
|
|
11
|
+
lecodes assets convert hero.fbx --ktx2 # + textures → KTX2 (needs KTX-Software toktx)
|
|
12
|
+
lecodes assets probe hero.fbx | hero.glb # skeleton / clips / meshes / textures / bind sanity / world size
|
|
13
|
+
lecodes assets doctor hero.glb [--fix] [--ktx2] [--dump] # the old tools/glb-reduce
|
|
14
|
+
lecodes assets doctor hero.glb --fix-scale # bake a unit fix (see Scale below)
|
|
15
|
+
lecodes assets retarget fox.glb --from wolf.fbx --map auto # clips from a DIFFERENT skeleton (map from skeleton shape)
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Retarget — clips from a different skeleton
|
|
19
|
+
|
|
20
|
+
`convert --clips` binds tracks by BONE NAME and copies them verbatim; that only works when both
|
|
21
|
+
files came from the same rig (the Mixamo case). `retarget` drives one rig with a completely
|
|
22
|
+
different rig's clips — different bone names, different chain lengths, different proportions,
|
|
23
|
+
different local axis conventions, even different facing:
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
lecodes assets retarget fox.glb --from Wolf_Animation.fbx --map auto --clips wolf_walk wolf_run -o fox-anim.glb
|
|
27
|
+
lecodes assets retarget giraffe.glb --from Deer_Animation.fbx --auto-map # writes giraffe-from-Deer_Animation.map.json to edit
|
|
28
|
+
lecodes assets retarget giraffe.glb --from Deer_Animation.fbx --map giraffe-from-Deer_Animation.map.json -o giraffe-anim.glb
|
|
29
|
+
lecodes assets retarget fox-anim.glb --from Wolf_Howl.fbx --map … --keep-clips --clip-names howl
|
|
30
|
+
lecodes assets retarget fox.glb --from Wolf_Animation.fbx --skeletons # both bone lists, to write a map by hand
|
|
31
|
+
lecodes assets retarget fox.glb --from Cat.fbx --map auto --anim Cat_Sleep.anim # donor clips that live outside the FBX
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
**The map** is target-bone → donor-bone; `root` names the bone that receives root motion. `--map auto`
|
|
35
|
+
/ `--auto-map` derive it from the two skeletons' SHAPE — root, spine, neck, head, jaw, ears, tail and
|
|
36
|
+
the four legs (side + front/back) are read off the bind pose, then paired chain by chain with bones
|
|
37
|
+
matched by arc length (names only break ties when they agree: `backAnkle`↔`Ankle_L`). It works on
|
|
38
|
+
`joint1…joint42` rigs and prints exactly what it paired — read it before trusting it. Optional keys:
|
|
39
|
+
|
|
40
|
+
```json
|
|
41
|
+
{ "root": "Hips",
|
|
42
|
+
"bones": { "Hips": "Root_M", "chest": "Chest_M", "backleg": "Hip_L", "backleg0": "Knee_L", "…": "…" },
|
|
43
|
+
"align": { "chest": "keep", "tail": "direction", "head": [ "headend" ] }, // per-bone reference policy (see below)
|
|
44
|
+
"legs": [ "backleg2", "R_backleg2", "frontleg2", "R_frontleg2" ], // feet, when auto-detection picks wrong
|
|
45
|
+
"donorRef": "auto" | "bind" | { "clip": "wolf_idle", "time": 0 } }
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
**How it works.** Everything happens in WORLD space, where a rotation means the same thing in both
|
|
49
|
+
rigs: `ΔR(t) = Rdonor(t)·Rdonor_ref⁻¹` is applied to the target's own reference orientation and
|
|
50
|
+
converted back to local against the already-retargeted parent. That transfer is exact — the thing
|
|
51
|
+
that decides quality is what the two REFERENCE poses are and what happens where proportions differ:
|
|
52
|
+
|
|
53
|
+
- **Donor reference** (`donorRef`, default `auto`). An exporter's bind is often a posture the clips
|
|
54
|
+
never visit — the polyperfect wolf binds upright on vertical thighs and walks 10% lower with its
|
|
55
|
+
thighs 30° forward; measured from that bind every clip reads as "crouch and lean". When the two
|
|
56
|
+
binds put the legs differently (>12°: different conventions), the donor is referenced to the MEAN
|
|
57
|
+
pose of its walk (else idle, else the longest clip) — its actual neutral posture. When the binds
|
|
58
|
+
agree, bind-to-bind is the faithful transfer (an identity retarget reproduces the donor exactly).
|
|
59
|
+
`--donor-ref bind|auto|<clip>` overrides.
|
|
60
|
+
- **Target reference**, per bone: `keep` its bind orientation, or `direction` — turn it so the bone
|
|
61
|
+
points the way the donor's does (an auto-T-pose step, measured to the nearest mapped descendant).
|
|
62
|
+
A target that binds in a STANCE (feet level, legs hanging) keeps everything: that is its natural
|
|
63
|
+
posture and the donor's deltas ride on it — the horse stays tall under wolf clips, the giraffe's
|
|
64
|
+
neck stays up under deer clips. A target that binds posed (T-pose, folded legs) keeps its core
|
|
65
|
+
(root, pelvis, chest, head — artists put those joints anywhere, so their joint lines are not
|
|
66
|
+
comparable) and direction-aligns its chains. `align` overrides per bone.
|
|
67
|
+
- **Feet** (`--ik legs`, the default). Directions transfer, positions do not: with different
|
|
68
|
+
front/back leg ratios the feet float or sink, which the eye catches first. Legs are detected from
|
|
69
|
+
the map (lateral chains reaching the ground; a foot is what stands on the donor's ground, a horse's
|
|
70
|
+
wrist-ended front leg is not), the reference pose is planted (root lowered/raised, every foot bent
|
|
71
|
+
onto the ground), then every frame each foot gets a goal built as THE TARGET'S OWN STANCE plus the
|
|
72
|
+
donor's departure from ITS stance, scaled by that leg's length ratio, on all three axes — height
|
|
73
|
+
above each rig's own stance level, and the horizontal offset each rig plants its foot at under its
|
|
74
|
+
own hip — and a FABRIK pass from the FK pose bends the chain onto it. Both builds are therefore
|
|
75
|
+
preserved: a target that tucks its feet inboard of its hips keeps doing so against a donor that
|
|
76
|
+
stands on vertical legs. (Copying the donor's absolute foot-under-hip offset instead planted this
|
|
77
|
+
repo's fox — which tucks each foot ~8% of body height inboard, while every polyperfect animal
|
|
78
|
+
stands vertical — bow-legged in every clip.) When the legs are scaled copies the goal IS the FK
|
|
79
|
+
pose, so IK only ever spends the proportion residual; goals out of reach are reported per leg.
|
|
80
|
+
- **Root motion** is scaled by the mean leg-length ratio (a stride is leg length, not body height),
|
|
81
|
+
so stance feet that were still on the donor stay still on the target.
|
|
82
|
+
- **Chain lengths.** A longer donor chain collapses for free (the target's `chest` mapped to
|
|
83
|
+
`Chest_M` picks up Spine1+Spine2, because a world rotation already contains everything above it).
|
|
84
|
+
A longer target chain — unmapped bones between two mapped ones — interpolates the delta along the
|
|
85
|
+
chain (a 6-bone spine against a 2-bone one bends everywhere, not at one joint).
|
|
86
|
+
- **Spaces.** The anchor is the BIND pose (inverse of the skin's inverse-bind matrices), never the TRS
|
|
87
|
+
on the nodes — the wolf's node pose is 0.37 m from its bind. A skin whose bind space is scaled
|
|
88
|
+
against node space (Maya "actor" exports, ×100) is rescaled; a donor facing the other way is turned
|
|
89
|
+
(facing is read from the skeleton shape, so `joint1…` rigs work too); non-uniform scales are refused.
|
|
90
|
+
|
|
91
|
+
What it does not do yet: foot locking on the target beyond what the donor's feet do (an in-place
|
|
92
|
+
donor still treadmills), stride warping when a leg is far too short for the donor's reach (reported
|
|
93
|
+
as "out of reach"), torso pitch to compensate for mismatched front/hind leg ratios, bipeds/arms (the
|
|
94
|
+
auto-map is quadruped-shaped; a humanoid map is best written by hand or by name).
|
|
95
|
+
|
|
96
|
+
### Donors whose clips are not in the FBX
|
|
97
|
+
|
|
98
|
+
Asset packs routinely ship the rig as FBX and the animations as Unity `.anim` assets — the
|
|
99
|
+
polyperfect cat and all three dogs have EMPTY FBX files and eight `.anim` clips each. `--anim` reads
|
|
100
|
+
those directly (`src/retarget/unityAnim.ts`, YAML only — a project saved with binary serialisation
|
|
101
|
+
has to be re-exported with *Asset Serialization = Force Text*) and attaches them to the donor rig as
|
|
102
|
+
clips, bound by the LAST segment of each curve's transform path:
|
|
103
|
+
|
|
104
|
+
```
|
|
105
|
+
lecodes assets retarget fox.glb --from Cat.fbx --map fox-from-cat.map.json --anim Cat_Walk.anim Cat_Idle.anim Cat_Sleep.anim Cat_StandUp.anim --clips Cat_Sleep Cat_StandUp --clip-names sleep stand_up -o fox-cat.glb
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Pass the donor's locomotion clips even when you do not want them in the output: `--donor-ref auto`
|
|
109
|
+
takes the donor's neutral posture from a walk/idle, and without one it falls back to the bind pose.
|
|
110
|
+
|
|
111
|
+
### What a short spine cannot borrow
|
|
112
|
+
|
|
113
|
+
Rotations transfer; positions follow from the TARGET's own bones. Leg IK covers that for feet, but
|
|
114
|
+
nothing covers the spine, so a clip whose whole point is a spine CURL does not survive a target with
|
|
115
|
+
fewer spine bones. The cat sits by curving a 7-bone spine to hold its chest up over a dropping
|
|
116
|
+
pelvis; this fox has two spine bones and no way to curve, so its chest is simply carried down by the
|
|
117
|
+
pelvis and the head ends up on the floor (4% of body height, against the cat's 79%). Hanging the
|
|
118
|
+
pelvis on a mid-spine bone instead recovers part of it (27%) but then breaks lying-down clips, whose
|
|
119
|
+
heads go through the floor. Clips that END lying (`Sleep`) or start there (`StandUp`) transfer fine —
|
|
120
|
+
they need no upward curl.
|
|
121
|
+
|
|
122
|
+
### Seeing the result
|
|
123
|
+
|
|
124
|
+
A bone map is judged by eye. `tools/filmstrip.ts` skins a clip on the CPU and writes a PNG strip —
|
|
125
|
+
no browser, no GPU, no engine, so it runs on every iteration:
|
|
126
|
+
|
|
127
|
+
```
|
|
128
|
+
bun tools/filmstrip.ts fox-anim.glb strip.png 6 wolf_walk threequarter
|
|
129
|
+
BONES=1 CELL=360 bun tools/filmstrip.ts fox-anim.glb strip.png 5 wolf_walk side # skeleton overlay
|
|
130
|
+
BIND=1 BONES=1 bun tools/filmstrip.ts fox.glb bind.png 1 - side # the bind pose itself
|
|
131
|
+
CELL=440 bun tools/filmstrip.ts fox-anim.glb strip.png 4 wolf_walk side "fox.glb|-|0" # rest pose first
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
For the real thing on the real engine, `packages/viewer` → `node anim-check/model-check.mjs`.
|
|
135
|
+
|
|
136
|
+
## Scale
|
|
137
|
+
|
|
138
|
+
`probe` and `doctor` always **report** the model's rendered world size; nothing is changed without a
|
|
139
|
+
flag. A skinned primitive ignores its node's transform (glTF spec), so the size is measured through
|
|
140
|
+
the skin's bind space (`world(joint₀)·IBM₀`) — measuring via the mesh node reports garbage on exactly
|
|
141
|
+
the assets that are mis-scaled.
|
|
142
|
+
|
|
143
|
+
| flag | what it does |
|
|
144
|
+
|---|---|
|
|
145
|
+
| `--fix-scale` | guess the unit and bake it: cm / mm / feet / inches / the Blender-FBX `0.01` armature quirk. Picks the **smallest** correction that lands the largest dimension in 0.1–100 m |
|
|
146
|
+
| `--scale N` | bake an explicit factor (works on clip-only files too) |
|
|
147
|
+
| `--scale-to M` | bake whatever makes the largest dimension `M` metres |
|
|
148
|
+
|
|
149
|
+
The factor is **baked into the data** — positions, node translations, IBM offsets and translation
|
|
150
|
+
tracks all × s — not parked on a wrapper node. No node is added and no local scale is rewritten, so
|
|
151
|
+
every node path an app addresses stays what it was. Rotation and scale tracks are untouched by
|
|
152
|
+
construction: scaling only translations gives `world' = S·world·S⁻¹`, and the skinning identity then
|
|
153
|
+
needs `IBM' = S·IBM·S⁻¹`, which is just the IBM's translation column × s.
|
|
154
|
+
|
|
155
|
+
Quantized (`KHR_mesh_quantization`) positions are refused rather than silently corrupted.
|
|
156
|
+
|
|
157
|
+
## Shading — why a downloaded model renders too bright
|
|
158
|
+
|
|
159
|
+
`doctor` always reports two material quirks that make an asset look washed out, and neither one is
|
|
160
|
+
the usual suspect (metallic, or the texture):
|
|
161
|
+
|
|
162
|
+
- **self-lit** — the base colour texture is *also* wired into `emissiveTexture` with
|
|
163
|
+
`emissiveFactor = [1,1,1]`, so the shader draws `lit(albedo) + albedo`: roughly twice as bright as
|
|
164
|
+
intended, with the shading flattened out. Common from Sketchfab downloads and several FBX
|
|
165
|
+
exporters. `--fix-emissive` drops it and leaves the material otherwise PBR.
|
|
166
|
+
- **specular boost** — `KHR_materials_specular` with a `specularColorFactor` above 1.
|
|
167
|
+
|
|
168
|
+
`--unlit` goes further and converts the material to **`KHR_materials_unlit`**: flat
|
|
169
|
+
`baseColorFactor × baseColorTexture`, no lights, no IBL, no exposure — the shading model hand-painted
|
|
170
|
+
stylised assets are usually authored for. It also clears the emissive and drops specular/ior/normal/
|
|
171
|
+
occlusion/MR inputs, because gltfio *refuses* to combine specular with unlit ("Cannot use specular
|
|
172
|
+
extension with unlit") and silently falls back to lit if it is left in place.
|
|
173
|
+
|
|
174
|
+
Both engines implement it: Filament through gltfio's `unlit_opaque` ubershader, web-lite through the
|
|
175
|
+
`UNLIT` branch of its glTF shader (`viewer-lite/src/gl/glb.ts`, covered by `bun run render:check`).
|
|
176
|
+
|
|
177
|
+
## What `convert` does
|
|
178
|
+
|
|
179
|
+
- **Reads FBX with [ufbx](https://github.com/ufbx/ufbx)** compiled to wasm (`native/fbx_reader.c` +
|
|
180
|
+
`vendor/ufbx`, built by `build-wasm.sh` into `src/wasm/fbxReader.js` — a single-file module, no
|
|
181
|
+
native builds, runs in Bun/Node/browsers). Everything is converted to glTF conventions in C:
|
|
182
|
+
meters, right-handed Y-up (`space_conversion = MODIFY_GEOMETRY`), geometry transforms / inherit
|
|
183
|
+
modes as helper nodes, triangulated + welded primitives, top-4 skin weights renormalized, UV v
|
|
184
|
+
flipped, animations **baked** (30 fps, key-reduced, trimmed to start at 0). One JSON description +
|
|
185
|
+
one binary arena cross the wasm boundary; `src/fbx/toDocument.ts` builds the Document.
|
|
186
|
+
- **Textures** embedded: PNG/JPEG pass through, TGA/BMP decoded here (→ PNG), everything else via
|
|
187
|
+
`sharp` when installed; duplicates de-duplicated by content hash; external references resolved
|
|
188
|
+
next to the FBX / `textures/`.
|
|
189
|
+
- **Materials**: ufbx's PBR view of FBX Phong/Lambert → metallic-roughness (opacity map → MASK,
|
|
190
|
+
opacity factor < 1 → BLEND, double-sided honored). Separate metallic/roughness maps are not
|
|
191
|
+
packed (factors used).
|
|
192
|
+
- **`--clips`**: each clip file's animations are re-targeted onto the model **by bone name** (exact,
|
|
193
|
+
then the part after the last `:`/`|` — the same rule the SDK `Animator` uses); clip meshes/skins
|
|
194
|
+
dropped; unbound tracks counted + warned. Naming: a single-clip file → its stack name unless generic
|
|
195
|
+
(`mixamo.com`, `Take 001`) → the file's basename; `--clip-names` overrides.
|
|
196
|
+
- Output goes through the same validation as the doctor (`validateGlb`) and a `probe` summary.
|
|
197
|
+
|
|
198
|
+
Skipped in v1: cameras/lights, morph targets, NURBS.
|
|
199
|
+
|
|
200
|
+
## Verified with
|
|
201
|
+
|
|
202
|
+
`packages/viewer` → `node anim-check/model-check.mjs anim-check/fixtures/<file>.glb --clip N`: loads the
|
|
203
|
+
GLB on the Filament wasm engine, binds every clip through the native Animator, screenshots the rest
|
|
204
|
+
pose and a playing clip (the mesh must draw and deform). `bun test` here covers the reader/converter
|
|
205
|
+
against the Mixamo sample set when `LECODES_FBX_SAMPLES` (or the default sample folder) exists.
|
|
206
|
+
|
|
207
|
+
Retargeting was tuned on the polyperfect *Low Poly Animated Animals* zoo (different authoring
|
|
208
|
+
conventions within one pack: AdvancedSkeleton `Root_M/Hip_L`, Maya `actor:skin_*` with ×100 bind
|
|
209
|
+
space and −Z facing, unnamed `joint1…joint42`): wolf → Meshy fox, lion → cat, wolf → horse / golden
|
|
210
|
+
retriever, deer → giraffe, lion → elephant, all through `--map auto`, judged on `BONES=1` filmstrips
|
|
211
|
+
next to the donor's own walk. `tests/retarget.test.ts` pins the invariants on toy rigs (exact
|
|
212
|
+
direction transfer, keep/direction policies, bridges, leg detection + IK goals, donor reference).
|
|
213
|
+
|
|
214
|
+
## Layout
|
|
215
|
+
|
|
216
|
+
```
|
|
217
|
+
native/fbx_reader.c ufbx → JSON + arena (compiled to wasm)
|
|
218
|
+
vendor/ufbx/ ufbx.h / ufbx.c (MIT)
|
|
219
|
+
src/wasm/fbxReader.js generated single-file Emscripten module (tracked; rebuild with build-wasm.sh)
|
|
220
|
+
src/fbx/ read.ts (wasm boundary + IR) · toDocument.ts · convert.ts (merge, write)
|
|
221
|
+
src/textures/ resolveImage.ts (sniff, TGA/BMP decode, PNG encode, external lookup)
|
|
222
|
+
src/doctor/ doctor.ts (analyze/apply/report) · reduceGlbBones · transcodeTextures · transcodeKtx2 · normalizeScale · validateGlb (mirror of viewer's)
|
|
223
|
+
src/retarget/ rig.ts (hierarchy + bind pose + clip sampling) · retarget.ts (reference poses, world-delta transfer, leg IK, bridges) · automap.ts (skeleton classification + chain pairing) · unityAnim.ts (Unity .anim YAML reader)
|
|
224
|
+
tools/filmstrip.ts CPU-skinned PNG filmstrip of a GLB clip (browser-free visual check)
|
|
225
|
+
src/probe.ts · src/cli.ts · src/index.ts
|
|
226
|
+
```
|