12 KiB
Character Pipeline: source model → rigged → animated → in-game
One command turns a model into a playable character skin:
python tools/pipeline.py --uid <sketchfab-uid> --name space_marine
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.
The one rule: keep the model's own rig
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
| 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 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 |
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.glbinstead of--uidfor 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.
How the retarget works
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:
- 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.
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.ShooterPoseModifierreads 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 whetherSkinLegRepairruns 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
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).
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).
Measuring, not eyeballing
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.85–0.86, everything else 0.89–1.00, worst stretch 1.16.
How it works in-game
SkinManager(autoload) readsskins.jsonat boot; the selection persists per user and syncs viasynced_skin_id.SkinnedPlayerModelloads the GLB, reads the sidecar, installsShooterPoseModifier(lean / ADS / slide / wall-run / weapon hold) thenSpringBones— in that order, so the springs react to the FINAL body pose.SkinJointHelperruns 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 — hardcodingDEF-thigh.Lmeant 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
- 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.pycatches 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.pychecks this). - Cloth flies off the model — a spring instability.
SpringBonessubsteps and clamps for exactly this; do not remove those guards. - Limbs squash at a stride — measure with
limb_deform_check.gdbefore changing anything. Renders are repeatedly misleading; a slim anime leg at full stride genuinely looks stretched.