The lab could move the GUN and not the anchor points the hands are solved onto. `grip_offset` slides the weapon around inside the fist; `gun_fore` and `gun_stock` are distances ALONG the barrel, so the trigger and support hands could travel up and down the weapon's own axis and nowhere else. Nothing could take a hand off that axis, which is what a handguard below the bore, an angled foregrip, or a pistol whose grip is nowhere near its barrel line all need. Two things fix that. `grip_shift` and `fore_shift` give the two hand anchors real three-dimensional freedom, expressed in the GUN's own across/up/along frame so a sideways nudge stays sideways as the weapon pitches between low ready and ADS. Zero is exactly the old behaviour. Their z overlaps the along-axis distances, which is redundant and deliberate: keeping those separate is what lets the reach solver slide the support hand back down the handguard without undoing a considered sideways offset. And the markers are now draggable. They already showed the anchors; now they are handles. The one under the mouse swells and draws through the body — depth testing is right for judging whether a hand reached its target and wrong for a handle, because at any useful framing the hands occlude all three. Verified three ways, and each one had to be rebuilt once: anchor_shift_check first compared absolute positions and reported a 3.5 mm error that was the character BREATHING — there is a sin() on the muzzle pitch, so no anchor is ever in the same place twice. Measuring each anchor relative to the one it hangs off, rotated into the current gun basis, cancels the breathing, the ADS blend and the recoil exactly. 48 checks, six characters, both poses. anchor_drag_check asserts the drag writes the knob the MOUSE asked for, derived independently from the camera: 0.00-0.01 mm on all three. It does not assert the marker lands under the cursor, because it does not — the anchors hang off the shoulder and the arm chasing them moves the shoulder, so a drag settles at 0.77x-1.13x. Small enough to ignore interactively. That feedback first read as 1.5x-1.8x, because the cases were compounding on each other, and waiting LONGER for the pose to settle made it worse rather than better — which is the opposite of how a settling error behaves and is what gave it away. The buttstock case also failed for a while on a bug entirely in the test: it read an absent knob as zero when `pocket_hip` defaults to (30, -70, 60) mm. The lab has a note about that trap in `_reset`. It is just as easy to walk into from a test, and now has one there too. Co-Authored-By: Claude Opus 5 <[email protected]>
16 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.py → autorig.py → merge_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 defaults → skins.<skin>._all → skins.<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.
Drag the coloured markers. They ARE the anchor points the hands are solved onto — red the trigger grip, green the support hand, blue the buttstock — and the one under the mouse swells and draws through the body so it can be grabbed where the hands would otherwise hide it.
Dragging an anchor is not the same as any slider:
| moves | |
|---|---|
grip_offset (ANCHORS) |
the GUN, inside the fist |
gun_fore / gun_stock (HOLD) |
the hands ALONG the weapon's own axis |
| dragging a marker | the anchor itself, in three dimensions |
That distinction was the gap. gun_fore and gun_stock are distances along the
barrel, so the two hand anchors could slide up and down the gun and nowhere
else — no use for a handguard below the bore, an angled foregrip, or a pistol
whose grip is nowhere near its barrel line. A drag writes grip_shift or
fore_shift in the gun's own across/up/along frame, so a sideways nudge stays
sideways as the weapon pitches; the buttstock marker writes the shoulder pocket
for whichever pose is showing.
The anchors sit on the shoulder, and the arm chasing them moves the shoulder, so a drag settles at 0.77x-1.13x of the mouse. Small enough to ignore — you stop when it looks right — and measured, not assumed.
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.md— read 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
- 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.
- 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.
- 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 inretarget.pysays why. - Never run
SkinLegRepairon authored weights. It snaps weights and deletes triangles. It exists only to undo auto-rigging. It is gated onweights_authored, which is MEASURED, not assumed. - Measure from inside the modifier pass. See
references/verification.md. - 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. - Never look a bone up by name.
tools/rig_map.pyresolves roles and writes them to the sidecar so nothing downstream has to guess. Any hardcoded spelling —_find_bone(["RightHand", ...]), athigh/shinsubstring 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.Diagnosed, not fixed. Her
driven_bonescontainsRoot_001throughRoot_007— and those are her HAIR roots. The Godot surface dump showsHair_Adominated byRoot_001_001:3203,Root_007:3203,Root_005:2642. So the animation is keying bones that physics is supposed to own, which is non-negotiable #2 being violated by the role resolver rather than by a clip. The corroboration is in the surface table: her hair surfaces report only 1.8% and 9.4% chain share, because most of their vertices belong toRoot_00N, which is in no chain at all.Root_00Nmatches no COSMETIC stem, sois_cosmeticdoes not catch it and nothing keeps it out of the driven set. Fixing it by adding "root" to the stems would be wrong — a rig whose actual root is calledRootwould lose its hips. The fix is structural: a bone whose geometry is dominated by a mesh classifiedhairis a hair bone, whatever it is called. The surface table now makes that answerable at build time, which it was not when this rig was imported. Not attempted here — it needs a Blender re-run and re-verification of all six characters.Her
headrole is also wrong (Unused_Noname_010, when a realHeadbone exists and is in her spine chain), and her spine chain runs two junk bones PAST the head. Probably the same import; worth fixing in the same pass. -
A stray
Icosphereships 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 insurface_map, but the export should not be producing it.