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]>
8.4 KiB
Body, garments, hair — what must stay separate
The single structural idea behind an anime-styled character rig, and the thing every failure in this project traced back to.
The convention this pipeline follows
Hoyoverse-class character rigs (Genshin, Star Rail, Zenless Zone Zero) are built the same way, and the parts that matter are visible in any of their exported assets and in the toolchains built around them (Magica Cloth 2, UnityChan SpringBone, VRM's spring-bone spec — all of which exist because this shape is the convention):
| Convention | What this repo does |
|---|---|
| Body, face, hair and each garment are SEPARATE meshes with separate materials | Never join meshes; 18 meshes on Taila are all kept |
| Skirts get a radial grid of bone chains — many panels, several segments each | 21 panels × 4 segments, subdivided at build time |
| Hair is chains of 2–4 bones from the scalp | Detected from the source rig; 14 chains on Taila |
| Cloth/hair bones carry NO animation keys; physics owns them | export_optimize_animation_keep_anim_armature=False |
| Physics colliders are a small set of capsules: thighs, shins, and a big one at the waist acting as a lid | 5 capsules, measured from the mesh (_leg_colliders) |
| Neighbouring skirt panels are linked sideways | 278 cross-panel distance links from shared vertices |
| Each surface is TAGGED with what it is, so shading can differ per class | tools/surface_map.py writes it; SkinSurfaces reads it |
| Cel shading with a ramp, plus a separate outline pass | LevelMaterials.apply_toon_recursive + apply_character_look |
Where we differ: their collider capsules and cloth parameters are hand-authored
per character by a technical artist. We MEASURE them from the model's own
geometry at build time, because there is no artist in this loop. That is the
whole reason <model>.rig.json exists.
Where there IS an artist in the loop, there is now somewhere to put the answer:
debug/rig_lab.tscn and the layered files behind it (see the SKILL). Measuring
is the default and hand-authoring is the override, rather than the other way
round.
Separation is only half of it — the parts have to be NAMED
Keeping the meshes apart is structural. Knowing which is which is what lets anything act on the difference, and until the surface table existed nothing did: every surface of every character took one set of shading numbers, calibrated on skin, because there was no way to ask whether a surface was hair.
The table lives in the sidecar as surfaces, keyed on the MATERIAL name — mesh
node names are Object_7 through Object_32 on every character in this game and
carry no meaning, while material names survive the glTF round trip intact and are
what the artist actually chose. It is decided three ways, in descending order of
how much it trusts them:
- the material name. On VRoid exports it is formal —
N00_000_00_Body_00_SKIN_Instancecarries its own class infix, and every VRoid character here uses SKIN / FACE / EYE / HAIR / CLOTH. - the weights. Decisive when the name says nothing: a surface pulled by the skirt chain is a skirt whatever it is called. The threshold is deliberately low (5%), because VRoid welds the whole cap of the hair to the head bone and springs only the strands — kiyoko's hair mesh is 85% head, and a majority rule would call it skin.
- the material flags. These catch line-work, which is the one class that is not a surface of the character at all.
It is built from the same chains the spring solver uses, so the two can never disagree about which bones are a skirt.
Why the separation is load-bearing
Materials. The body wants skin shading, hair wants an anisotropic-ish ramp and its own outline weight, cloth wants flat banding. One merged mesh gets one treatment and everything reads as plastic.
The cloth solver. SkinnedPlayerModel._cloth_hulls extracts, per cloth bone,
the vertices that bone dominates — that is only meaningful while the garment is
its own mesh with its own weights. Merge the meshes and the solver has no way to
know which vertices are skirt.
Weights. A joined mesh rebound by nearest-bone weighting produced 2817 vertices pulled by BOTH legs on Taila (16% of the model, worst a dead 50/50). Such a vertex sits between the legs and stays there while they separate, stretching every triangle around it. That is the "squashing on jump" and the "elongated boot".
How cloth is detected and classed
tools/rig_map.py::is_cosmetic matches WHOLE TOKENS in a bone name against:
hair skirt cloth ribbon tail cape coat scarf sleeve breast bust
feather strap antenna wing (+ face/eye classes that must never swing)
Whole-token only — shoulder must not match should, and a bone called
hair_root is hair while chairbone is not.
retarget.py::SPRING_CLASSES is a NARROWER set: the classes that actually get
secondary motion. A face-shape or eye chain is cosmetic but must never swing.
Each chain lands in <model>.rig.json as:
{ "class": "skirt",
"root_parent": "DEF-spine.001",
"bones": ["DEF-skirt", "DEF-skirt.seg1", "DEF-skirt.seg2", "DEF-skirt.seg3"],
"tips": [[x,y,z], ...], // where each bone points, in its own space
"hulls": [[[x,y,z], ...], ...], // sample of the geometry it drives
"neighbours": [{"DEF-skirt.L": 10.7, ...}] // shared-vertex weight
}
tips exists because a glTF skeleton carries no bone tails at all, and
Taila's skirt panel bones have no children either, so nothing in the skeleton
says which way a panel hangs. It is measured from the geometry the bone drives.
neighbours means SHARED VERTICES — the artist's own answer to which pieces of
cloth are sewn together. Adjacency by name or by rest distance would both be
guesses.
The three rules that keep it intact
-
Cloth may only ever parent to the trunk, never to a limb.
rebuild_hierarchyenforces this. A skirt parented to a thigh becomes trousers. -
Cloth is never SKINNED to a leg. There was a
bind_cloth_to_legs()that gave cloth vertices near a thigh a share of that thigh, so the skirt would ride the leg the way a real one does. It is deleted. 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 leg — and 0.9 of a rotation always lags the surface doing 1.0 of it, so the leg overtakes it anyway. It also poisoned the collider measurement: 2258 skirt vertices counted as thigh geometry and fitted a 0.28 m thigh. -
Cloth bones carry no animation tracks. If the exporter bakes rest-pose tracks onto them (
keep_anim_armature), the AnimationPlayer overwrites the spring solver every frame.
Worked example: why the two shipped characters differ so much
Both are in assets/characters/skins/. Compare their sidecars:
| Taila | Miku | |
|---|---|---|
| source had a skeleton | yes | no — 5 meshes, 0 joints |
weights_authored |
true | false |
| cloth chains | 35 (127 bones) | 0 |
| twist bones | 8 | 0 |
| meshes shipped | 18 | 1 |
Miku's source (assets/characters/incoming/miku_test.glb) is an unrigged mesh,
so she went through autorig.py: joined to one mesh, rebound by nearest-bone
weighting, no cloth chains. Her twin tails and skirt are dead geometry that
cannot move, and SkinLegRepair runs destructively on her every spawn.
Nothing downstream can recover this. The single highest-leverage decision in this whole pipeline is choosing a source model that already has a skeleton with skirt and hair bones. Everything else is recoverable; this is not.
A quick check on any candidate, without Blender:
import json, struct
with open(path,'rb') as f:
f.read(12); clen,_=struct.unpack('<II',f.read(8))
j=json.loads(f.read(clen))
nodes=[n.get('name','') for n in j['nodes']]
joints=[nodes[i] for s in j.get('skins',[]) for i in s['joints']]
print(len(j['meshes']), 'meshes', len(joints), 'joints')
print([n for n in joints if any(t in n.lower() for t in ('hair','skirt','tail','ribbon'))])
Several meshes, 50+ joints, and a non-empty cosmetic list means a good source.
Checking a source model before importing
python tools/verify_character.py <model.glb>
What you want to see: several meshes, bone names containing skirt/hair,
twist bones (thigh.L.001), and weights that are NOT all at 4 influences.
weights_authored in the sidecar is measured from exactly this and decides
whether the destructive load-time repair runs.