Files
Papay-Shooter/docs/3D_ASSET_PIPELINE.md
T

12 KiB
Raw Blame History

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.pyautorig.pymerge_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.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.

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. 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_authoredmeasured, 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

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.850.86, everything else 0.891.00, worst stretch 1.16.

How it works in-game

  • 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

  • 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.