feat(characters): import the Quaternius mannequin as a selectable skin

A third playable character, built with the pipeline skill from a source
that was already in the repo: the animation library ships a rigged
Mannequin mesh on the exact 53-joint reference skeleton, CC0, so it needed
no download and retargets perfectly. 18 clips, 0.3% cross-leg bleed, 7% of
verts at four influences — a clean authored-weight import. Licence
recorded in mannequin.license.json as the other skins do.

It has no cloth chains, correctly: it is a mannequin and has neither hair
nor clothes.

Importing it turned up two real bugs, both of which would have hit any
flat-coloured or single-piece model:

- LevelMaterials.apply_character_look treated ANY untextured surface on a
  character as the model's own outline shell and hid it, so the mannequin
  rendered as a solid black silhouette — its body and joint materials are
  untextured flat colours, not ink. _is_line_work() now asks whether the
  surface is named eyes*, is drawn front-face-culled (the inverted-hull
  setup), or is near-black. Taila and Miku are unaffected: their materials
  are textured and never reach that branch. Verified by render.

- verify_character.py failed the build for having one mesh. That check
  cannot tell "the pipeline joined them" from "the artist authored one
  mesh" — Quaternius' mannequin is one piece on purpose. It is advisory
  now; the join path's two unambiguous signatures, cross-leg bleed and the
  4-influences-everywhere spread, are still hard checks.

Also restored Miku's description, which the re-import had blanked.

3 GLB skins selectable (6 with the built-in colour skins). Smoke 0
failures, 11/11 movement tests, cloth idle 0.024-0.078 deg/frame.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Nicholas Butzke
2026-07-26 13:25:26 -04:00
co-authored by Claude Opus 5
parent 270d5f0973
commit cd0d1b2d99
10 changed files with 1046 additions and 24 deletions
+271
View File
@@ -114,6 +114,236 @@ 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`.
### Collision, and why cloth "never settles"
Getting the capsules wrong does not look like a collision bug — it looks like
cloth that jitters forever. Four things had to be right:
1. **A limb tapers.** Taila's thigh is ~0.10 m across at the hip and ~0.055 m
above the knee. Head and tail radii are stored separately and interpolated.
2. **A leg's own vertices are not the leg.** Most of the thigh is weighted to
the TWIST bone; what is left dominated by `DEF-thigh.L` is the hip flare,
which measured a 0.154 m radius — a 30 cm thigh. Twist children are folded
in, and only vertices that clearly belong to the limb (dominant weight > 0.6)
are counted, or a hip/thigh/glute blend puts the flare straight back.
2b. **Measure the taper, do not pick a percentile of a pooled bucket.** Every
single-number answer is wrong at one end: the median leaves half the limb's
surface OUTSIDE its own collider, so cloth pushed out to it is clear of the
capsule while the thigh is visibly through it; a high percentile over-
measures the shaft by 30% because the top bucket is still the hip. Take the
90th percentile in each of ten bands along the bone, drop the two contaminated
end bands, and fit a line. Taila's thigh: 0.116 m at the hip to 0.063 m above
the knee, against a real surface of 0.106 → 0.058 plus cloth thickness.
3. **The rest pose must be a valid state.** The artist modelled the skirt over
these legs, so a capsule is capped per bone to just inside its own rest
clearance. Without that, bones resting against the thigh were shoved out and
pulled straight back in every frame — measured 13 of 70 cloth bones in
permanent contact in a dead-still idle, a limit cycle that never decayed.
That was the "hair and skirt never settle", and it also left the collider
saturated and useless against real clipping.
4. **Resolve as a rotation, sampled along the bone.** A skirt panel is a sheet
and its bone tip is at the far edge; pushing only the tip out leaves the
middle of the panel inside the thigh — the thigh visibly clipping through the
front of the skirt at a stride.
5. **Carry the whole rotation back, not a tip.** Most of what lifts a wide sheet
off a thigh is rotation about the bone's OWN axis, and a twist moves the tip
not at all. Reading the correction off the corrected tip therefore threw away
the part that mattered: the solver measured 5070 mm of thigh inside a panel,
corrected it every frame, and the render never changed.
6. **Apply the drape to the bone, not to the spring's target.** The final
rotation is measured FROM the drape target and applied TO the undraped basis,
so rotating only the target cancelled out exactly at equilibrium and the
drape was a silent no-op. This is why raising the drape weight — even to 0.99
— never moved a panel off a thigh, and it sent three rounds hunting the
collision solver for a fault that was never there. In a chain, apply each
link's share as the DIFFERENCE from what its parent already carries, or three
segments at 0.45 compound to 1.35 of the thigh's swing at the hem.
7. **Fix the deepest contact, not the one wanting the largest angle.** The angle
is depth ÷ leverage, so a graze on a hull point sitting almost on the bone's
head outbids a 60 mm impalement further down and spends the pass rotating
about a point that barely moves. Cap the turn so it never throws the rest of
the panel further than the overlap being fixed, or a 5 mm contact swings a
panel 34°.
8. **Take the leg out of the REST TARGET too.** While the spring's target sits
inside a thigh, the collision pushes out and the spring hauls straight back
forever. Resolving the target first makes resting on a leg an equilibrium.
### The drape has to RIDE the limb, not copy its angle
This was the one that mattered, and it hid behind every other theory for a long
time. The drape used to take the thigh's ROTATION and apply it to the panel's
basis — which turns the panel about ITS OWN head, up at the waist. The thigh
turns about the HIP, some 20 cm lower. The two arcs are nothing alike, so the
panel never tracked the leg however high the drape weight went, and the thigh
walked straight out through the front of the skirt.
Carry the bone's rest tip through the limb's FULL transform instead — pivot
included — and aim the bone at the result:
```
carried = L_now * L_rest⁻¹ * tip_rest # where the limb would take it
aim = slerp(current_dir, carried_dir, w) # w = how much of it rests there
```
That is what "the cloth rides the leg" actually means, and it is the difference
between a panel that gets overtaken and one that lifts over the thigh.
Two supporting pieces, both needed:
- **Which limb, decided per frame.** A static both-thigh split by rest distance
is right for a panel hanging between the legs and wrong the moment they
scissor — the halves cancel, the panel does not move, and the advancing thigh
walks into it. `DRAPE_BITE` keeps a limb's share climbing once its gap goes
negative, so the leg actually inside the cloth wins outright.
- **The correction walks UP the chain** (`_lift_chains`). A bone rotates about
its own head, so it can move a point by at most twice that point's distance
from the head — and the front panels' contacts sit 10-48 mm from their pivot
against a thigh ~100 mm inside them. Their ancestors have 5-15x the lever, so
a few degrees up the chain does what no local rotation could. Keep the per-bone
cap small (4°): at 9° over 3 passes the whole panel hiked up.
Tuning that matters: `DRAPE_MAX` 0.55. At 0.9 the panel over-swings and hikes;
at 0.0 the thigh passes straight through. Panels subdivide into 4 segments — 3
leaves the pivots too far from the contacts, 6 lets the chain curl up.
### What a production cloth setup actually does
Everything above was arrived at by measurement, and it converged on a solver that
was still missing the single most important piece. From Magica Cloth 2's BoneCloth
skirt guide (the Unity asset most anime-style games use for exactly this garment):
> Normally, the skirt bones are linked vertically due to the parent-child
> structure, **but not horizontally** … If horizontal bones are not connected, the
> accuracy of collision detection will be significantly reduced. **This is the
> most important work when expressing a skirt with BoneCloth.**
That was the gap. Every chain here solved alone, so each panel individually
satisfied its constraints while the garment came apart. Four things were taken
from that guide and its penetration page:
1. **Horizontal links between chains** (`LINK_PASSES`, `_build_links`). Distance
constraints between the TIPS of bones that share mesh vertices, relaxed
Gauss-Seidel after the springs and collision — Magica's "Near Point"
connection. Adjacency comes from the sidecar's `neighbours`, which is shared
vertex weight: the artist's answer to what is sewn to what, not a guess from
names or rest distance. 215 links on Taila.
2. **A lid across the waist.** "Put one big sphere collider on your waist… it acts
as a lid that prevents particles in the skirt from slipping into the body."
There was no torso collider at all — leg capsules stop cloth going through a
thigh, but nothing stopped a panel swinging INWARD into the pelvis, which is
where several of the worst contacts sat. Marked `lid` in the sidecar so the
drape does not treat it as a limb to be carried by, and sized from non-cloth
geometry (including the garment measured the skirt itself, 0.24 m).
3. **A graded bend clamp.** "The start point can be bent up to 20 degrees and the
tip up to 50." A flat limit either lets the waist collapse or stops the hem
moving; `MAX_SWING_ROOT`/`MAX_SWING_TIP` interpolate along each chain.
Collision still overrides it, which is Magica's precedence too.
4. **Collision is not enough on its own** — "if you make a vigorous movement, you
will inevitably penetrate" — which is why the drape and the bodily shift exist
alongside it rather than instead of it. Magica's two extra modes are Surface
Penetration (needs the cloth weighted to the main bones) and Collider
Penetration (for cloth that does not follow the leg animation). This skirt is
deliberately the second kind, since any leg weighting on it tears.
### The skirt "breaking" instead of stretching
A separate failure from clipping, and it looks like clipping: the front of the
skirt is pushed aside and then splits, and the thigh shows through the split.
Measure it with `debug/cloth_stretch_check.gd`, which skins every cloth triangle
and compares each edge against its own rest length — a bone-level or capsule-
level number cannot see it, because every individual bone is behaving.
It was the WEIGHTS. A band of skirt vertices carried `DEF-thigh.L` at 0.24 while
the vertices next to them carried none, so when the thigh swung one followed it
and its neighbour did not. Measured during a slide: 80 mm apart, 3.3x rest
length. 0.24 of the thigh's ~0.35 m of travel is 84 mm, so that discontinuity
was the whole of it.
**Cloth is not skinned to the legs at all**`tools/retarget.py::
unbind_cloth_from_legs`. Smoothing the field instead of deleting it does not
work, and the arithmetic says why: differential motion is the weight GRADIENT
times how far the limb travels, so holding an edge under 10 mm against a thigh
that moves 350 mm needs under 0.03 of weight difference across it — and this
skirt's edges are ~48 mm long, so the falloff would have to span most of the
character. Tried: a 1.7-radius falloff put 0.24 on one vertex and 0.00 on its
neighbour and tore by 95 mm. After stripping, the worst cloth edge in the whole
sweep grows **0.0 mm**.
There was also a `bind_cloth_to_legs()` that did this deliberately, on the
(wrong) conclusion that clipping was a weights problem. It is gone for the same
reason, plus two of its own: 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; and it
poisoned the collider measurement, fitting a 0.28 m thigh out of 2258 skirt
vertices.
Keeping the leg out of the weights costs the one thing the weights were doing,
so the solver has to make it up:
- **The capsule starts 30% down the thigh.** The top of a thigh is hip, not limb.
Cloth points there sit 20-30 mm from their own bone's head, and a rotation
moves a point near its pivot by at most twice the lever — 48 mm against 86 mm
of overlap. The solver spent all six passes saturated at its cap and still left
60-90 mm.
- **The drape picks its limb per frame, by current proximity.** Splitting a panel
between both thighs by REST distance is right for a panel hanging between them
and wrong the moment they scissor: the two opposite swings cancel, the panel
does not move, and the advancing thigh walks into it.
- **Chains are shifted bodily as well as turned** (`PUSH_MAX`). Translation is
the degree of freedom rotation lacks, and it is safe here precisely because the
whole chain moves as one piece — unlike a weight gradient it cannot stretch the
mesh, and neighbouring panels see the same contact and shift the same way, so
the seams between them stay shut.
- **Neighbouring cloth bones are known to the runtime.** `neighbours` in the
sidecar means SHARED VERTICES — the artist's own answer to which pieces are
sewn together — and the drape is relaxed across them so sewn panels cannot be
handed wildly different shares.
On the shipped materials the skirt now reads as one continuous sheet with an
unbroken hem through the whole sweep, and the thighs emerge from below it.
Two measurement traps found while confirming that, both of which produced
"remaining clipping" that was not there:
- **A silhouette test cannot see clipping.** When the legs are apart you see the
FAR side of the skirt through the gap between them, and the thighs are
legitimately in front of that. `tools/measure_clipview.py` counts those as
hits. It is a good continuity check — a tear is a hole in the cloth mask — and
a bad clipping check. Judge clipping from `debug/skirt_probe.gd` and the plain
render.
- **The torso is in front of the waistband, and that is correct.** Counting all
body pixels reported 5% of the cloth covered on a pose that is clean, so the
measurement is cut at the hip joint. Colouring the legs separately does not
work either: the bare thigh is part of the body mesh, and a per-surface colour
caught only the boots.
Together those took idle penetration from 65 mm to ~1 mm and contacts from ~10
per frame to ~2. Damping then matters: 0.3 rang for about three visible
oscillations after every step, so it sits near 0.6. The lag that makes a skirt
read as cloth comes from inertia, not from low damping, so this costs nothing in
the run cycle. Gravity is small for the same reason — a constant force offsets
the resting tip by `g/w²`, and the 5.0 first used pulled the hem 41 mm below
where it was modelled, into the thigh it then had to be pushed out of.
## Locomotion transitions
Two separate things change when a character starts running, and they have to
arrive together:
- **The clip**, crossfaded by `AnimationNodeTransition` over `BLEND_TIMES`
(~0.4 s for locomotion). Tier selection has hysteresis and a minimum dwell:
without them, hard acceleration crossed Idle → Walk → Run in under a second
and each crossfade cut off the one before it, giving Walk 0.19 s of a 0.40 s
blend. With a dwell, a hard start now goes Idle → Run in one step.
- **The procedural lean** (`ShooterPoseModifier`), which is NOT part of the
blend graph. The controller passes a normalised input direction, so it stepped
0 → 1 the instant a key went down and planted a full run posture in about a
tenth of a second — the body snapping forward ahead of the run cycle. It is
now scaled by actual speed and smoothed on its own slower rate
(`LEAN_SMOOTH`), so it grows as the character accelerates.
Measure with `debug/transition_check.gd`: it reports clip changes, when the lean
reaches 10% and 90%, and the worst single-frame change.
### `<model>.rig.json`
Written next to every built GLB, so the runtime never re-guesses anatomy:
@@ -188,6 +418,41 @@ Missing clips are fine: the game falls back along sensible chains
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.
- `godot --headless --path . -s res://debug/cloth_settle_check.gd -- <glb>` — does
the cloth actually come to rest? Reports deviation, per-frame motion and, most
usefully, which bones are penetrating a leg capsule and by how much. A steady
non-zero contact count is a limit cycle that no amount of damping will fix.
- `godot --headless --path . -s res://debug/transition_check.gd -- <glb>` — clip
changes and lean ramp when accelerating from a standstill.
- `godot --path . --windowed --resolution 900x900 -s res://debug/skirt_clip_view.gd -- <out_dir>`
**the tool that settles "is the leg through the skirt?"**. Paints every cloth
surface flat magenta and the body flat grey across a movement sweep, so grey
inside the magenta is the leg in front of the cloth and grey outside it is just
the leg past the hem. Those two look identical on the shipped materials and
were guessed at, in both directions, for several rounds. It also saves a
cloth-only frame, which separates clipping from a gap opening between panels.
- `godot --headless --path . -s res://debug/skirt_probe.gd` — per cloth bone, how
deep the leg is inside it and whether the solver can SEE that depth (the
rest-clearance allowance can hide it). Also reports the lever the solver has on
the point, and its height above the hip joint — anything positive is inside the
fictional sphere the capsule puts at the top of the thigh, not inside the leg.
- `godot --headless --path . -s res://debug/cloth_stretch_check.gd` — **the tool
for "the skirt breaks instead of stretching"**. Skins every cloth triangle over
a movement sweep and compares each edge against its own rest length, reporting
seams between panels separately from edges inside one. Nothing at bone or
capsule level can see a tear, because each bone individually is fine. Restrict
it to genuinely cloth-owned vertices — body surfaces carry stray cloth
influence (one arm vertex measured 0.54 forearm, 0.35 skirt) and counting those
made the skirt look like it was tearing by half a metre when the arm moved.
- `godot --headless --path . -s res://debug/leg_radius_check.gd` — the real
per-band radius of each limb next to the capsule actually shipped.
- `godot --path . --windowed --resolution 900x900 -s res://debug/idle_jitter_check.gd -- <out_dir>`
— consecutive frames of a still idle. Counting changed PIXELS between them is
the only trustworthy settling measure: `cloth_settle_check` reports LOCAL bone
rotation, and a parent's correction shows up as an equal and opposite delta on
each of its segments, so a hem that has not moved on screen can read 18
deg/frame. Measured here: 24866 changed px/frame with collision on against
38594 with it off — the collision was damping the idle, not driving it.
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.
@@ -222,6 +487,12 @@ cross-section 0.850.86, everything else 0.891.00, worst stretch 1.16.
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.
- *Cloth jitters and never settles* — almost certainly a capsule the rest pose is
already inside, not the damping. Run `cloth_settle_check.gd` and look at the
contact count before touching `TUNING`.
- *A limb clips through cloth* — check the measured capsule in the sidecar is a
believable size for that limb, and remember the collider only knows about the
legs.
- *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.