Files
Papay-Shooter/.claude/skills/character-pipeline/SKILL.md
T
Nicholas ButzkeandClaude Opus 5 1e6f3001ac docs(skill): the surface table, rig anchors, and asserting the consequence
The skill is this project's own instructions, and three commits made it wrong:
it described a runtime that re-guessed what every surface was, a lab that only
tuned weapon holds, and a suite that could not see any of the last five defects.
It also still listed a seventh character that is not in skins.json.

Records what is new — the surface table and how it is decided, the layered
tuning files and why a fixed rotation on the weapon mount is the wrong answer,
the rig lab — and what the work taught:

  * assert the CONSEQUENCE. Asking a model which clip it is playing reads a
    variable it set on itself, and says "Idle" just as happily when nothing is
    ticking. Asking whether an anchor saved says nothing about whether the gun
    moved.
  * four of the last five real defects came from looking at a PNG. Every one of
    them passed every assertion.
  * the line-work rule now exists twice, at build time and as the runtime
    fallback, and they must be changed together or a model with a surface table
    starts rendering differently from one without.

Also records momo's broken idle and the stray Icosphere under known-unsolved,
with what is worth suspecting first in each case, since neither is fixed.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-27 15:00:42 -04:00

14 KiB

name, description
name description
character-pipeline Import, rig, stylize and animate an anime-styled character into Papaya-Shooter as a selectable skin — keeping the model's own skeleton, artist weights and separate body/cloth/hair meshes, with cloth and hair driven by the spring solver. Use when adding a new playable character, re-importing an existing one, debugging skinning/cloth/hair/animation problems on a character, or changing the cel-shaded look. Triggers on "add a character", "import a skin", "new playable model", "skirt clipping", "hair flailing", "T-posing", "character looks squashed".

Character pipeline

Turns a source model into a playable, cel-shaded, cloth-simulated character skin.

The whole design follows one principle, which is also what the Hoyoverse-class anime pipelines (Genshin / Star Rail / Zenless Zone Zero) are built on:

The character is not one object. It is a body, a set of garments, and hair — authored separately, rigged separately, and moved by different systems. The body is skinned and animated. The garments and hair are bone chains that the animation never touches; physics moves them. Keeping those separate is what makes the result read as an anime character instead of a mannequin in a painted-on costume.

Everything below exists to protect that separation.

The one rule

If a model arrives with a skeleton, that skeleton ships. Its bones, its artist-painted weights, its per-part meshes and its skirt/hair chains all survive. Only the ANIMATION is moved onto it.

The old route (strip_rig.pyautorig.pymerge_animations.py) solved a bone-naming problem by destroying the asset — 18 meshes became 1, 21 skirt bones and ~50 hair bones became 0, and 16% of vertices ended up pulled by both legs. tools/rig_map.py solves naming properly now. Never reach for strip_rig.py or --rebind unless the model genuinely has no skeleton at all.

Doing it

# From an already-rigged local model (the normal case)
python tools/pipeline.py --input assets/characters/incoming/<name>.glb --name <name> --rigged

# From a Sketchfab UID (needs SKETCHFAB_API_TOKEN)
python tools/pipeline.py --uid <uid> --name <name>

# From an unrigged mesh — auto-rigs, and accepts the quality loss
python tools/pipeline.py --input <mesh.glb> --name <name>

# Rigged (or auto-rigged) but with NO skirt/hair bones — grow them, or the
# costume is welded solid and the spring solver has nothing to simulate
python tools/pipeline.py --input <model.glb> --name <name> --rigged --grow-cloth

Then, once, so Godot sees the new files:

godot --headless --path . --import

The result is assets/characters/skins/<name>.glb + <name>.rig.json, and a registry entry in skins.json that SkinManager picks up with no code change.

Blender is required (BLENDER_PATH, or auto-found under C:\Program Files\Blender Foundation). Godot lives at C:\Program Files\Godot\Godot_v4.7-stable_win64_console.exe.

The stages, and what each one protects

Stage Where Protects
Fix unlit/emissive materials tools/gltf_fix.py Textures surviving import at all
Resolve bone ROLES, not names tools/rig_map.py The model's own skeleton
Rebuild parenting retarget.py::rebuild_hierarchy Limbs/cloth following the hips
Grow cloth chains (opt-in) tools/cloth_bones.py A costume that has no bones being able to move at all
Subdivide cloth panels retarget.py::subdivide_cloth_panels A skirt being able to bend at all
Retarget clips as rest-relative deltas retarget.py::retarget_clip Limbs not being twisted by foreign bone roll
Leave cosmetic bones unkeyed export_optimize_animation_keep_anim_armature=False Physics owning the cloth
Classify every SURFACE tools/surface_map.py The runtime never re-guessing what a surface is
Write the rig sidecar retarget.py::describe_rig The runtime never re-guessing anatomy
Cel look, per surface class LevelMaterials.apply_character_look Hair not reading as a solid dark cap
Cloth + hair characters/spring_bones.gd Clothes reading as clothes
Per-character judgement calls characters/tuning_store.gd Art direction not becoming another constant

The surface table

The sidecar carries a surfaces list saying what each mesh surface IS — body, cloth, hair, accessory, or linework (the model's own ink shell, which is not a surface of the character at all). It is keyed on the MATERIAL name, because every character in this game arrives with its meshes called Object_7 through Object_32 while material names survive the glTF round trip intact.

SkinSurfaces reads it and apply_character_look acts on it: hair takes a much thinner outline than the body, cloth a heavier one and a crisper terminator, accessories the heaviest. SkinnedPlayerModel.surfaces_of(cls) answers the question for anything else that needs it.

Backfill a character that predates it, without re-importing:

blender --background --python tools/surface_map.py -- \
    assets/characters/skins/<name>.glb

New imports get it from describe_rig, built from the same chains the solver uses, so the surface table and the cloth solver can never disagree about which bones are a skirt.

Per-character judgement

Anything derivable from the skeleton is derived. What is left is genuinely an artist's call, and it lives in layered JSON rather than in a constant:

File Scope Class
assets/characters/weapon_holds.json character + weapon WeaponHoldTuning
assets/characters/rig_anchors.json character RigAnchors

Both layer defaultsskins.<skin>._allskins.<skin>.<subject> through TuningStore. An absent file means "use what the code derives", so nothing here is required for the game to run. Adding a knob is adding a row to a KNOBS spec table — the lab builds its whole UI from those.

RigAnchors is where "the grip sits here in the palm" lives. A hand bone's origin is the WRIST; how far down the palm a grip belongs depends on the character's hand and cannot be derived. It defaults to identity, and identity is exactly the derived mount. Do not put a fixed rotation on the weapon mount instead — a bone attachment is expressed in the BONE's axes, no two rigs agree on those, and that constant is why the hand mount points were once wrong on every character.

The rig lab

godot --path . res://debug/rig_lab.tscn

Pick a character, a weapon, a pose or a single clip. Drag sliders for the HOLD (character + weapon) and the ANCHORS (character), and save. Click a surface class to isolate it — that is how the classifier gets checked: click hair and anything else still standing was misclassified.

Read before you touch anything

Load the reference that matches what you are doing. They are short and each one is a list of things that cost a debugging cycle to learn.

  • references/failure-modes.mdread this first. Seven characters shipped "All checks passed" and four were visibly broken. What each failure was, why the suite missed it, and the rule that generalises it to any model.
  • references/separation.md — body vs. garments vs. hair: what must stay separate, how cloth chains are detected and classed, why cloth is never skinned to a leg, and the ZZZ-convention mapping.
  • references/growing-cloth-bones.md — what to do when the source has no skirt or hair bones: how the chains are fitted to the geometry and re-weighted.
  • references/rigging.md — role resolution, hierarchy rebuild, cloth panel subdivision, twist bones, joint helpers, the retarget maths.
  • references/cloth-and-hair.md — the position-based spring solver, its colliders, per-class tuning, collision hulls, LOD and cost.
  • references/stylization.md — cel shading, outlines, the imported line-work trap, eyes, materials.
  • references/verification.md — every measuring tool, what each one actually measures, and the pose-reading trap that invalidated all of them once. Read this before trusting any measurement.

Non-negotiables

  1. Never join meshes. Per-part meshes are how body, cloth and hair stay separable — for materials, for the outline pass, and for the cloth solver's hull extraction.
  2. Never key cosmetic bones. If a clip has tracks on skirt/hair bones, the AnimationPlayer overwrites the solver every frame and the cloth goes rigid.
  3. Never skin cloth to a leg. A vertex weighted 0.9 to a thigh cannot be moved by its own cloth bone, so the solver loses the authority to push it out of that thigh — and the leg still overtakes it. There was a bind_cloth_to_legs(); it is deleted, and the note above its grave in retarget.py says why.
  4. Never run SkinLegRepair on authored weights. It snaps weights and deletes triangles. It exists only to undo auto-rigging. It is gated on weights_authored, which is MEASURED, not assumed.
  5. Measure from inside the modifier pass. See references/verification.md.
  6. Never assume an axis. Up, forward and scale are all measurable from the skeleton. Assuming +Z is up scaled three characters 7x and left them on their backs — and the height check passed on every one of them, because the number being normalised always comes out right whether or not it was the right number. See references/failure-modes.md.
  7. Never look a bone up by name. tools/rig_map.py resolves roles and writes them to the sidecar so nothing downstream has to guess. Any hardcoded spelling — _find_bone(["RightHand", ...]), a thigh/shin substring test — is a rig this project has not met yet. Four characters could not hold a gun because of exactly one such lookup.

After importing

godot --headless --path . -s res://debug/spawn_smoke_test.gd      # 29 checks
godot --headless --path . -s res://debug/surface_class_check.gd   # every surface classified
godot --headless --path . -s res://debug/character_picker_check.gd # the escape-menu roster
godot --headless --path . -s res://debug/rig_anchor_check.gd      # anchors move the weapon
godot --headless --path . -s res://debug/cloth_clip_check.gd      # leg-through-cloth
godot --headless --path . -s res://debug/cloth_settle_check.gd    # idle stability
godot --headless --path . -s res://debug/cloth_perf_check.gd      # ms per character
godot --headless --path . -s res://debug/travel_dir_check.gd      # legs face travel

And LOOK at it, which is where four of the last five real defects were found:

godot --path . -s res://debug/roster_capture.gd -- <dir>   # every character, one shot each
godot --path . -s res://debug/ui_capture.gd -- <dir>       # every menu screen
godot --path . res://debug/rig_lab.tscn -- shot <png> <skin>

surface_class_check fails on any surface that falls through to the heuristic rather than resolving from the table. That is deliberate: a model whose names stopped matching still RENDERS, because the fallback catches it — it just quietly loses its per-class art direction, which is exactly the kind of regression nothing else would report.

What "good" looks like on Taila, for calibration:

Measure Good Bad
Idle skirt movement < 0.1 deg/frame 0.5+, or never decaying
Leg inside cloth, idle/walk < 25 mm 100 mm
Leg inside cloth, run/slide/dash ~95 mm (current, unsolved)
Solver cost ~2.6 ms/character 10 ms
Stride vs. travel direction < 10° (except a capped sidestep) 90°
Bind-pose AABB tall on the hips→head axis, others < 2.5 m tallest axis is depth

Characters currently shipping

The six in skins.json, with what surface_class_check reports:

Skin Source Cloth chains Surfaces
taila rigged, Sketchfab CC-BY 35 18 — body 6, cloth 7, hair 1, linework 4
kiyoko VRoid, CC-BY 20 13 — body 8, cloth 3, hair 2
aria VRoid, CC-BY 15 15 — body 9, cloth 4, hair 2
momo VRoid, CC-BY 9 5 — body 2, cloth 1, hair 2
miku unrigged source, auto-rigged 0 4 — body 3, hair 1 (one mesh, four surfaces)
mannequin Quaternius CC0, from the animation library 0 2 — body 2

Known-unsolved

  • Peak cloth clipping in a run, slide and dash sits at ~95 mm of thigh inside the skirt. Idle, walk and fall are clean. The solver sees the contact and pushes on it every iteration; the remaining gap is a standing fight between the collision and the garment's own shape constraints.
  • No foot IK. Feet do not plant on ground height, so stairs and uneven ground read as sliding.
  • No strafe or backpedal clips. Direction is conveyed by yawing the hips (SkinnedPlayerModel._update_travel), which is capped, so a pure sidestep still runs its legs ~40° off the direction of travel.
  • momo's idle pose is wrong — arms overhead and a pinched waist. Every assertion passes on her: she loads, animates, classifies and mounts a weapon. It shows up only in roster_capture. Her source rig is the reason to suspect — its bones are auto-named (Item_O_Sphere_005, Unused_Noname_004, Root_001_001), so the role resolver has almost nothing to go on, and a limb role claimed by the wrong bone would look exactly like this. Start by dumping her resolved roles against her skeleton.
  • A stray Icosphere ships inside every skin GLB — 42 vertices, no parent, no vertex groups. It rides in from the animation library. Harmless, and now skipped by construction rather than by name in surface_map, but the export should not be producing it.