feat: implement automated 3D character pipeline with retargeting and rig management tools

This commit is contained in:
Nicholas Butzke
2026-07-24 20:52:35 -04:00
parent afc954e129
commit 374d9f9822
19 changed files with 3220 additions and 221 deletions
+195 -95
View File
@@ -1,127 +1,227 @@
# Character Pipeline: SketchfabRigged → Animated → In-Game
# Character Pipeline: source modelrigged → animated → in-game
One command turns a Sketchfab model into a playable character skin:
One command turns a model into a playable character skin:
```bash
python tools/pipeline.py --uid <sketchfab-uid> --name space_marine
```
That downloads the model, auto-rigs it in Blender, merges the shared
animation library onto it, writes `assets/characters/skins/space_marine.glb`,
and registers it in `skins.json`. Restart the game — the skin is in the main
menu dropdown, fully animated in first AND third person, synced in multiplayer.
That downloads the model, moves the shared animation library onto it, writes
`assets/characters/skins/space_marine.glb` plus a `.rig.json` sidecar, verifies
the result, and registers it in `skins.json`. Restart the game — the skin is in
the main menu dropdown, fully animated in first AND third person, synced in
multiplayer.
## Prerequisites (one-time setup)
## The one rule: keep the model's own rig
1. **Sketchfab API token** — from <https://sketchfab.com/settings/password>.
Put it in the env var `SKETCHFAB_API_TOKEN`, or in a file named
`.sketchfab_token` in the project root (gitignored).
2. **Blender 3.6+** — on PATH, or set `BLENDER_PATH` to `blender.exe`.
3. **Animation library** — already bundled. `assets/characters/animations/_library.glb`
is the CC0 Quaternius Universal Animation Library (13 game-relevant clips
mapped in `LIBRARY_CLIP_MAP`). `tools/autorig.py` rigs every character to
this library's skeleton (fitting the arm bones to the model's actual pose),
and `merge_animations.py` retargets each clip onto that fitted rest. To use a
different/larger set instead, see "Swapping the animation library" below.
**If a model arrives with a skeleton, that skeleton is what ships.** Its bones,
its artist-painted weights, its separate per-part meshes, and its dedicated
skirt/hair bone chains all survive; only the ANIMATION is moved onto it.
This is the whole point, and the pipeline used to do the opposite. The old route
(`strip_rig.py``autorig.py``merge_animations.py`) discarded any foreign
skeleton, joined every mesh into one, and rebound the result by weighting each
vertex to its nearest four bone segments. It did that to work around a *naming*
problem — the retarget matched bones by exact name — and the cost was the entire
asset. Measured on the shipped `taila.glb` against the source it was built from:
| | source model | old pipeline output |
|---|---|---|
| vertices pulled by BOTH legs | 17 (0.1%) | **2817 (16%)**, worst a dead 50/50 |
| vertices at the full 4 influences | 26% | **86%** |
| meshes (body / cloth / hair separable) | 18 | **1** |
| skirt bone chains | 21 bones | **0** |
| hair bone chains | ~50 bones | **0** |
| limb twist bones | 8 | **0** |
A vertex pulled equally by both legs sits between them and stays there while
they separate, stretching every triangle around it — that is the squashing and
the "elongated boot". `characters/skin_leg_repair.gd` exists solely to undo this
at load time, by snapping weights and deleting triangles on a mesh it has to
rebuild every spawn.
The naming problem is now solved properly, in `tools/rig_map.py`, so nothing has
to be thrown away.
## The pipeline, step by step
Each step is also runnable on its own:
| Step | Tool | What it does |
|---|---|---|
| 1. Find | `python tools/sketchfab_import.py search "anime robot" --rigged` | Search downloadable models (license shown per result) |
| 2. Download | `python tools/sketchfab_import.py download <uid>` | GLB + license/attribution JSON into `assets/characters/incoming/` |
| 3. Auto-rig | `blender --background --python tools/autorig.py -- in.glb out.glb` | Fits the skeleton (incl. arm bones) to the mesh, binds smooth nearest-bone weights |
| 4. Animate | `blender --background --python tools/merge_animations.py -- rigged.glb assets/characters/animations out.glb` | Retargets the clip library onto the fitted rest, strips root motion, canonical names |
| 5. Register | (automatic in pipeline.py) | Copies to `skins/`, adds entry to `skins.json` |
| 2. Download | `python tools/sketchfab_import.py download <uid>` | GLB + license JSON into `assets/characters/incoming/` |
| 3. Retarget | `blender --background --python tools/retarget.py -- in.glb assets/characters/animations out.glb` | Keeps the rig; moves the clip library onto it |
| 3b. *(unrigged only)* | `blender --background --python tools/autorig.py -- in.glb rigged.glb` | Fits the library skeleton and solves weights — lossy, see below |
| 4. Verify | `blender --background --python tools/verify_character.py -- out.glb` | Gates the build on the defects listed below |
| 5. Register | (automatic in `pipeline.py`) | Copies to `skins/`, adds an entry to `skins.json` |
`tools/pipeline.py` chains all of it. Useful flags:
`pipeline.py` chains all of it and picks the path automatically — it reads the
glTF container to see whether a `skins` array is present. Useful flags:
- `--input file.glb` instead of `--uid` for local files (GLB/FBX/OBJ).
- `--rigged`skip auto-rig for models that already have a skeleton
(Mixamo/AccuRig/Tripo output). Bones get renamed to Mixamo convention
automatically when recognizable.
- `--height 1.6` — target character height in meters.
- `--input file.glb` instead of `--uid` for local files.
- `--rigged`force the keep-the-rig path (needed for FBX, which cannot be probed).
- `--rebind` — force the lossy path. Last resort.
- `--height 1.6` — target character height in metres.
## Auto-rigging: what to use when
### How the retarget works
Mixamo has **no public API** (and Adobe has said one isn't coming), so full
automation needs an alternative. In order of preference:
`tools/rig_map.py` resolves both skeletons to ROLES and pairs them up, so bone
names never have to match. It works structurally wherever a name would lie:
1. **Built-in autorig (`tools/autorig.py`)** — heuristic skeleton fit +
Blender automatic weights, fully automated, zero cost. Works well for
normal-proportioned upright humanoids. Weakest on flowing
dresses/capes/extreme proportions.
2. **Mixamo web (manual, ~2 min/model)** — upload FBX/OBJ at
<https://www.mixamo.com>, place 7 markers, download rigged FBX "without
animations", then `pipeline.py --input rigged.fbx --rigged --name x`.
Best weight quality for the effort.
3. **Reallusion AccuRig** (free desktop app) — better auto-weights than
Mixamo for tricky meshes; export FBX and feed with `--rigged`.
4. **UniRig / Tripo AI / Meshy / Anything World** — ML auto-riggers. UniRig is
open source (VAST/Tripo); Tripo, Meshy, and Anything World offer paid HTTP
APIs if you ever want a fully hosted rigging step with no Blender at all.
- **Hips** is found as the base of the longest non-limb, non-cosmetic chain —
not by looking for "hips". Rigify calls it `DEF-spine`.
- **The head** is wherever that chain ends. A stock Rigify rig has no bone with
"head" in its name at all; the head is `DEF-spine.006`.
- **Chains of different lengths** are matched by normalised position, so a
6-bone spine is driven by a 5-bone one.
All roads lead to the same place: a GLB with Mixamo bone names, which is what
`merge_animations.py` and the game expect.
`tools/retarget.py` then bakes each clip as a rest-relative delta —
`R_world = src_pose · src_rest⁻¹`, applied as `R_world · tgt_rest` — rather than
copying absolute world orientation, which would force the library's bone roll
onto a mesh bound with a different one and twist every limb by a constant offset.
It also **rebuilds parenting**. A Rigify DEF-rig exports its chain roots
parented straight to the armature root (Rigify drives them by constraint, not
hierarchy), so on import the thighs, skirt and hair all hang off the root and
would float in place while the body moves. Orphans are re-attached by anatomy
where it is known and by rest geometry otherwise. **Cloth may only ever attach
to the trunk, never to a limb** — anchor a skirt panel to the nearest bone and
16 of Taila's 21 land on a thigh, where the panel rides one leg like a trouser
leg.
### What is deliberately NOT driven
Skirt, hair, twist and face bones get **no animation tracks at all**. They rest
relative to their parents and belong to the runtime instead. That split — clips
animate the body, physics animates the cloth — is what makes clothes read as
clothes, and it is why the exported clips only carry the ~53 bones they need
(`taila.glb` went from 3.5 MB to 2.9 MB even after regaining its textures).
## Secondary motion (`characters/spring_bones.gd`)
Skin weights can only ever make a garment a rigid shell of whatever it is
weighted to: weight a skirt to the thighs and it becomes trousers, weight it to
the hips and it becomes a bell that never moves. Neither is cloth. A skirt is
cloth because it LAGS. That is inertia, and it has to be integrated, not skinned.
Each cloth bone is a damped spring holding its tip toward where rigidly
following its parent would have put it, then pinned to the bone's length and
pushed out of the leg capsules so a skirt swings AROUND a thigh rather than
through it. Bone tip directions and capsule radii are **measured from the
model's own geometry at build time** and stored in the sidecar — a glTF skeleton
carries no bone tails, and Taila's skirt bones have no children either, so
nothing in the skeleton says which way a panel hangs or how thick a thigh is.
Tuning per class (hair stiffer and lighter, skirt slacker and heavier) lives in
`SpringBones.TUNING`.
### `<model>.rig.json`
Written next to every built GLB, so the runtime never re-guesses anatomy:
- `roles` — resolved bone per role. `ShooterPoseModifier` reads this; without it
its hips/neck/head lookups silently missed on any rig that names them
differently, and every lean and aim-pitch did nothing.
- `chains` — cloth/hair chains, with a measured tip vector per bone.
- `colliders` — leg capsules with measured radii.
- `twist` — limb twist bones.
- `weights_authored`**measured**, not inferred from which branch ran. This is
what decides whether `SkinLegRepair` runs at load time. A model that arrives
unrigged still gets a sidecar, and its solved weights still need the repair.
Raw `.glb` **and `.json`** must be in the export include filter, or the sidecar
is missing from a build and every character loses its cloth.
## When a model has no skeleton
Then there is no authored weighting to keep and `autorig.py` fits the library
skeleton with nearest-bone weights. This is genuinely lossy and the verifier
reports it as warnings rather than failures, because no better result is
available:
```
[WARN] cross-leg blending is limited to draping cloth — 590 verts (21.2%)
[WARN] influences look authored, not solved — 78% of verts carry 4 influences
[WARN] model keeps its per-part meshes — 1 meshes
```
`miku` is such a model. `SkinLegRepair` stays on for it at load time.
Prefer, in order: a model that ships rigged → **Mixamo web** (upload FBX/OBJ,
place 7 markers, download rigged "without animations") → **Reallusion AccuRig**
→ UniRig / Tripo / Meshy. All of them produce a rig this pipeline will keep.
## Materials
Anime models are very often exported "unlit": `KHR_materials_unlit`, a **black**
`baseColorFactor`, and the real texture wired to `emissiveTexture`. Renderers
honouring the unlit extension use base colour and ignore emission — so Blender
reads black, never references the images, and imports with `bpy.data.images`
*empty*. The character comes out a silhouette, and there is no node graph left
to patch afterwards.
`tools/gltf_fix.py` rewrites the container **before** import: emissive becomes
base colour, the unlit flag is dropped. The game shades characters with its own
toon material off ALBEDO anyway.
## Swapping the animation library
`merge_animations.py` supports two library layouts in
`assets/characters/animations/`:
`assets/characters/animations/_library.glb` is the CC0 Quaternius Universal
Animation Library. Clips map through `LIBRARY_CLIP_MAP` in `tools/retarget.py`
(18 mapped: Idle, Walk, Run, Sprint, Jump, Fall, Land, CrouchIdle, CrouchWalk,
Dash, Death, Hit, Dance, Grapple, PistolIdle/Shoot/Reload, Throw).
**A. Multi-clip library GLB (the bundled default).** If `_library.glb` exists,
its clips are merged and mapped through `LIBRARY_CLIP_MAP` in
`tools/merge_animations.py`. The bundled file is the CC0 Quaternius Universal
Animation Library (13 clips: Idle, Walk, Run, Sprint, Jump, Fall, Land,
CrouchIdle, CrouchWalk, Dash, Death, Hit, Dance). **Important:** `autorig.py`
rigs characters to *this GLB's own skeleton*, so if you replace `_library.glb`
you must re-rig characters against the new one (the pipeline does this
automatically). To pull in more of Quaternius's 120-clip set, drop in the Pro
`_library.glb` and add rows to `LIBRARY_CLIP_MAP`.
Characters no longer have to be rigged to the library's skeleton, so **replacing
`_library.glb` does not require re-rigging anything** — just rebuild the skins.
Missing clips are fine: the game falls back along sensible chains
(`Slide → CrouchIdle → Idle`, `WallRun → Run` — see `CLIP_FALLBACKS` in
`characters/skinned_player_model.gd`).
**B. Loose one-clip-per-file (Mixamo).** Delete `_library.glb` and instead add
FBX/GLB files named by clip (`idle.fbx``Idle`, `crouch_walk.fbx`
`CrouchWalk`). Download from mixamo.com as *FBX Binary, Without Skin, 30 fps*
("In Place" variants). In this mode characters must be on the Mixamo skeleton
(rig via Mixamo web / AccuRig and pass `--rigged`, or let the heuristic
`build_mixamo_armature` fit one).
## Measuring, not eyeballing
Missing clips are fine either way: the game falls back along sensible chains
(`Slide → CrouchIdle → Idle`, `WallRun → Run`, etc. — see `CLIP_FALLBACKS` in
`characters/skinned_player_model.gd`). A model with just Idle/Walk/Run still
animates in every movement state.
- `blender --background --python tools/verify_character.py -- <glb>` — the build
gate. Every check corresponds to a defect this project actually shipped.
- `godot --headless --path . -s res://debug/limb_deform_check.gd -- <glb>`
skins the mesh itself and reports lengthwise stretch and cross-section loss
against the skeleton's REST pose, so an unposed model reads exactly 1.00 and a
wrong metric is visible immediately. Read its header before trusting a number
you add: three earlier versions of this measurement were themselves wrong and
nearly caused bad "fixes".
- `godot --path . --windowed --resolution 1280x720 -s res://debug/anim_capture.gd -- <out_dir> <skin_id>`
— renders every movement state front and side.
Current Taila, worst over a run/walk/jump/fall/slide/dash sweep: knee
cross-section 0.850.86, everything else 0.891.00, worst stretch 1.16.
## How it works in-game
- **`SkinManager` (autoload)** reads `assets/characters/skins/skins.json` at
boot. Selected skin persists per-user and is synced to other players via
`synced_skin_id`.
- **`SkinnedPlayerModel`** loads the GLB at runtime, maps canonical clip
names, sets loop modes, blends between clips (0.15 s), and scales
locomotion playback speed to actual movement speed.
- **First person (owner):** the model renders shadows-only for the owner (the
camera sits inside the head, so drawing the mesh would show its inside). It's
still fully animated and casts a real shadow; other players see the full body.
- **Third person:** press **V** (`toggle_camera_view`) to swap to an
over-the-shoulder `SpringArm3D` camera and reveal your own animated model —
the easiest way to eyeball that a new skin's animations look right. Firing
still uses the first-person camera, so aim is unchanged. Press V again to
return to first person.
- **Other players** always see the full third-person model, driven by the
synced movement state, with the current weapon attached to the hand bone.
- **Licensing:** every Sketchfab download writes `<name>.license.json`.
CC-BY models require crediting the author — keep these files and surface
them in your credits screen before shipping.
- **`SkinManager`** (autoload) reads `skins.json` at boot; the selection persists
per user and syncs via `synced_skin_id`.
- **`SkinnedPlayerModel`** loads the GLB, reads the sidecar, installs
`ShooterPoseModifier` (lean / ADS / slide / wall-run / weapon hold) then
`SpringBones` — in that order, so the springs react to the FINAL body pose.
- **`SkinJointHelper`** runs for every model regardless of rig. It is not a
weight repair: linear-blend skinning collapses any joint by cos(θ/2) however
good the weights are. It subdivides the knee through helper bones. It resolves
the joint's parent from the SKELETON — hardcoding `DEF-thigh.L` meant it
silently did nothing on a rig with twist bones, and the knee measured 0.76
instead of 0.85.
- **First person (owner):** the model renders shadows-only (the camera sits
inside the head). Press **V** to swap to the over-the-shoulder camera.
- **Licensing:** every Sketchfab download writes `<name>.license.json`. CC-BY
models require crediting the author — surface these in a credits screen.
## Troubleshooting
- *Model T-poses in game* — the GLB has no animations; re-run
`merge_animations.py` and check it printed `Merged N clips`.
- *Mesh deforms badly at shoulders/hips* — heuristic rig didn't fit; rig via
Mixamo web or AccuRig and re-run with `--rigged`.
- *Character slides while walking* — clips exported with root motion; re-run
without `--keep-root-motion` (stripping is the default).
- *Skin missing in exported build* — raw `.glb` files must be included in the
export: Project → Export → Resources → include filter `*.glb, *.json`.
- *Character is a black silhouette* — unlit materials; see **Materials**. Check
the built GLB actually has images.
- *Model T-poses* — the GLB has no animations, or the retarget produced a frozen
rest pose. `verify_character.py` catches both.
- *Skirt rides one leg* — a cloth bone got parented to a limb. Cloth must anchor
to the trunk only.
- *Cloth is rigid* — the sidecar is missing (check the export filter) or the
clips are keying cloth bones (`verify_character.py` checks this).
- *Cloth flies off the model* — a spring instability. `SpringBones` substeps and
clamps for exactly this; do not remove those guards.
- *Limbs squash at a stride* — measure with `limb_deform_check.gd` before
changing anything. Renders are repeatedly misleading; a slim anime leg at full
stride genuinely looks stretched.