The support hand was upside down on the handguard. Its rotation was built as a shortest arc from the hand's forearm line to the barrel, plus a constant 0.5 rad twist. A shortest arc is the MINIMAL rotation between two directions and says nothing at all about roll, so the entire roll of that hand came from the constant — and a constant is right only for the one rig it was tuned against. Orienting a hand onto something it grips is a frame-to-frame problem, and saying it that way leaves nothing free to guess. A hand wrapping a cylinder has its curl axis along that cylinder, or the fingers close across the handguard rather than around it; and its palm faces the cylinder, which for a hand supporting from underneath means up. The third axis falls out of the other two. Map the hand's rest anatomical frame onto that target and the roll is determined rather than chosen. The frame is the same one the finger curl already uses — along, palm, curl — now stored whole instead of just its curl axis. Verified by render on the mannequin (Rigify names, 0.524 m arm) and Kiyoko (VRoid names, 0.470 m): fingers wrap the handguard from below and over the top, stock at the shoulder, arms not crossing, consistent across idle, ADS and run. L_HAND_TWIST is deleted rather than retuned — it was the bug. Smoke 0 failures. Co-Authored-By: Claude Opus 5 <[email protected]>
300 lines
15 KiB
Markdown
300 lines
15 KiB
Markdown
# How imports fail, and why the checks did not catch it
|
|
|
|
Seven characters shipped with "All checks passed" and four of them were visibly
|
|
broken in game — lying on their backs, seven times too big, facing backwards,
|
|
holding a gun that floated near their chest. Nothing in the verification suite
|
|
was wrong. It just never asked the questions that mattered.
|
|
|
|
**The generalised lesson, which is the whole of this page:**
|
|
|
|
> The suite verified that the character was *well-formed* — skeleton attached,
|
|
> weights authored, clips non-frozen, cloth unkeyed. It never verified that the
|
|
> character was *correct*: the right size, the right way up, the right way round,
|
|
> and reachable by the runtime. Structural validity and usable output are
|
|
> different properties, and a pipeline that only checks the first will ship the
|
|
> second broken every time a source deviates from the one it was written against.
|
|
|
|
Every check below is cheap. None of them existed.
|
|
|
|
---
|
|
|
|
## 1. Up is not always +Z — measure it, never assume it
|
|
|
|
**Symptom:** the character is enormous and lying on their back.
|
|
**Hit:** aria, momo, hikari. **Confidence: certain** — measured, not inferred.
|
|
|
|
`flatten_and_scale()` sets the character's real-world size with
|
|
|
|
```python
|
|
height = hi.z - lo.z # Blender Z is up
|
|
s = target_height / height
|
|
```
|
|
|
|
which is right for a model that arrives Z-up in Blender, and catastrophic for one
|
|
that does not. If the character is actually lying along Blender's Y, `hi.z - lo.z`
|
|
measures their **thickness** — about 0.25 m — so `s = 1.75 / 0.25 ≈ 7`. The model
|
|
is scaled seven-fold *and* left on its back. One wrong assumption, both symptoms.
|
|
|
|
The bind-pose bounding boxes say it plainly. A correct character is tall on Y and
|
|
narrow on X and Z:
|
|
|
|
| | X | Y | Z | |
|
|
|---|---|---|---|---|
|
|
| taila | 1.24 | **1.75** | 0.69 | correct |
|
|
| kiyoko | 1.50 | **1.75** | 0.32 | correct |
|
|
| mannequin | 1.86 | **1.75** | 0.35 | correct |
|
|
| aria | 6.12 | 1.75 | **7.48** | tall axis is Z — lying down, ~7x too big |
|
|
| momo | 6.89 | 1.75 | **7.95** | same |
|
|
| hikari | 6.23 | 1.75 | **13.01** | same, and worse |
|
|
|
|
The 1.75 lands on Y for everyone because the exporter maps Blender Z to glTF Y.
|
|
That is exactly what makes the bug invisible: **the number you normalised always
|
|
comes out right, whether or not it was the right number.** A check on "is the
|
|
height 1.75" passes on all seven of these.
|
|
|
|
**Rule:** derive the up axis from the SKELETON, and rotate the model upright
|
|
before scaling anything. `flatten_and_scale()` now does this.
|
|
|
|
**Measure it from the FEET to the HIPS, not from the hips to the head.** The head
|
|
is not a reliable landmark: the spine walk ends on whatever the last non-cosmetic
|
|
bone in the chain is, and on a rig with a facial skeleton that can be a bone
|
|
sitting BELOW the hips. Momo's did, so the first version of this fix stood her
|
|
neatly on her head — correct size, correct proportions, upside down. Feet cannot
|
|
be mistaken; they are the bottom of a standing character on every rig, and
|
|
`foot.L/R` have resolved on every source met so far.
|
|
|
|
**And compare it to WORLD up, not to the model's own proportions.** The obvious
|
|
test — "is the spine the longest axis of the bounding box?" — catches nothing
|
|
here, because a model rotated as a whole is internally consistent: aria's spine
|
|
*is* her longest axis, she is just lying down. She passes that test comfortably.
|
|
The question is whether the character stands up in the world the game runs in,
|
|
which means asserting the spine runs along Blender +Z, full stop.
|
|
|
|
Two more numbers are worth asserting for free: the other two extents should be
|
|
under about 2.5 m, and the height itself should land in a human range. Those
|
|
three together are what separated the four good characters from the three broken
|
|
ones on the first run.
|
|
|
|
**Where this comes from:** all three casualties have bone names like `Hips`,
|
|
`Left leg`, `Upper Chest`, `Breast_L` — a VRM that someone imported into Blender,
|
|
renamed, and re-exported. Kiyoko kept raw VRoid `J_Bip_*` names and was fine. A
|
|
**Blender round-trip can bake an axis rotation into the export**, and that family
|
|
of files is common on Sketchfab. Treat "the bone names have been humanised" as a
|
|
signal to check the axes.
|
|
|
|
---
|
|
|
|
## 2. The runtime must look bones up by ROLE, not by name
|
|
|
|
**Symptom:** the gun is not in the hands — it floats near the chest, and can
|
|
point backwards. **Hit:** aria, momo, kiyoko, hikari.
|
|
**Confidence: certain** — measured.
|
|
|
|
`SkinnedPlayerModel.set_weapon()` finds the hand with
|
|
|
|
```gdscript
|
|
var hand_idx := _find_bone(["RightHand", "Hand_R", "hand.R"])
|
|
```
|
|
|
|
Three hardcoded spellings. Against the shipped roster:
|
|
|
|
| Skin | `hand.R` resolved in the sidecar | matched by `_find_bone` |
|
|
|---|---|---|
|
|
| taila, miku, mannequin | `DEF-hand.R` | yes |
|
|
| kiyoko | `J_Bip_R_Hand` | **no** |
|
|
| aria, momo, hikari | `Right wrist` | **no** |
|
|
|
|
When it misses, `set_weapon` falls back to parenting the weapon to the model root
|
|
at a fixed chest-height offset. The gun is then not attached to the character at
|
|
all; it hangs in space near the torso and inherits none of the arm's motion.
|
|
|
|
This is the same class of bug as `verify_character.py` looking for legs by the
|
|
substrings `thigh`/`shin`. **`tools/rig_map.py` exists precisely so that nothing
|
|
downstream has to guess a bone name, and the resolved roles are written to
|
|
`<model>.rig.json` for exactly this purpose — but only some consumers read them.**
|
|
|
|
**Rule:** every bone lookup anywhere in the runtime or the tools goes through the
|
|
sidecar roles, with a name heuristic only as a last-resort fallback. Grep for
|
|
`find_bone`, `findn(`, and any tuple of bone-name spellings; each one is a rig
|
|
this project has not met yet.
|
|
|
|
---
|
|
|
|
## 3. Facing is inferred and never verified
|
|
|
|
**Symptom:** the character runs backwards. **Hit:** kiyoko.
|
|
**Confidence: probable** — the mechanism is understood, the specific cause is not
|
|
yet isolated.
|
|
|
|
Two independent things decide which way a character ends up pointing:
|
|
`retarget.py::facing_correction()` computes a yaw to align the character's rest
|
|
pose with the library's, and `SkinnedPlayerModel.facing_flip` then applies a
|
|
blanket 180° because "glTF forward is +Z; players face -Z". If the source already
|
|
faces the other way, the two compose to a character running backwards — and
|
|
nothing anywhere measures the finished result.
|
|
|
|
**Rule:** facing is measurable from the skeleton — the toes are forward of the
|
|
ankles. `flatten_and_scale()` now snaps that to Blender -Y, the convention the
|
|
runtime flip is built around, so every character leaves the pipeline pointing the
|
|
same way whatever the source did. Kiyoko was 180 degrees off and is now correct.
|
|
|
|
Snap to the nearest QUARTER TURN, not to the measured angle: a rest pose with the
|
|
feet slightly splayed is not a character who is 7 degrees turned, and correcting
|
|
it as one puts a permanent yaw on the whole skeleton.
|
|
|
|
---
|
|
|
|
## 4. Generated cloth chains must be validated against the geometry they drive
|
|
|
|
**Symptom:** hair stretches wildly during animation.
|
|
**Hit:** miku. **Confidence: probable.**
|
|
|
|
`tools/cloth_bones.py` grows chains for a costume that has none, then **clears
|
|
each vertex's existing body weights** and re-assigns it to the fitted chain,
|
|
keeping the original only over the first 22%. That is correct when the polyline
|
|
actually follows the clump. When it does not — a large or forked island, a
|
|
mis-picked root end — vertices land on a bone travelling somewhere else entirely,
|
|
and linear-blend skinning turns that into stretching.
|
|
|
|
The tool reports how many chains it grew. It never checks whether they *work*.
|
|
|
|
**Rule:** after growing chains, verify per vertex that its assigned bone stays
|
|
near it — pose the chain a few degrees and assert the vertex moves with its bone
|
|
rather than away from it. And never destroy the original weights without a
|
|
fallback: a generated chain should blend against the body weight it replaced, so
|
|
a bad fit degrades to "stiff" rather than to "torn".
|
|
|
|
---
|
|
|
|
## 5. A weapon has to be scaled to the arm that holds it
|
|
|
|
**Symptom:** hands flat and open, both fists bunched together at the grip, the
|
|
stock nowhere near the shoulder. **Hit:** every model.
|
|
**Confidence: certain** — measured and fixed.
|
|
|
|
Three separate causes, all of them "a constant where a measurement belonged".
|
|
|
|
**The gun was mounted with a constant rotation.** `set_weapon` used
|
|
`rotation_degrees = (0, 90, -90)`. A bone attachment is expressed in the BONE's
|
|
axes and no two rigs agree on those, so one constant mounts the weapon
|
|
differently on every character. It never needed to be right: the pose layer aims
|
|
the gun by rotating the WRIST until the weapon's forward lies on the aim line, so
|
|
handing it the IDENTITY means "forward is the hand bone's -Z" — true by
|
|
construction on any rig — and the wrist absorbs the roll.
|
|
|
|
**The gun was full size on a stylised character.** The set is modelled at
|
|
real-world scale (an M4 is 0.84 m butt to muzzle); these characters have 0.47 m
|
|
arms against an adult 0.52. That puts the handguard 0.66 m from the support
|
|
shoulder — 0.2 m beyond reach — so a loop slid the support hand back down the
|
|
weapon until it fitted. On Taila a support offset authored at 0.35 m collapsed to
|
|
**0.083 m**: two fists together at the grip, which reads as a two-handed pistol
|
|
grip, not a rifle.
|
|
|
|
The fix is not a fixed scale factor. The binding constraint is the SUPPORT arm:
|
|
its hand must reach `stock + fore` in front of the pocket, from a shoulder half a
|
|
shoulder-width off the weapon axis. Solve that triangle for the largest gun whose
|
|
handguard still lands inside the arm's reach. Taila and Kiyoko come out at
|
|
different scales from the same code, both with the support hand at its full
|
|
authored handguard distance and no sliding at all.
|
|
|
|
Also give the slide-back loop a FLOOR. A slightly straight support arm looks far
|
|
better than no handguard hold.
|
|
|
|
**Nothing posed the fingers.** Every hand was flat and open — the single loudest
|
|
tell that a character is not really holding anything. Fingers are now closed by
|
|
the pose layer, using an axis derived from each hand's OWN anatomy in the rest
|
|
pose, because no two rigs agree on finger bone orientation:
|
|
|
|
```
|
|
along wrist -> middle knuckle the length of the hand
|
|
palm middle knuckle -> thumb tip across it; the thumb OPPOSES the
|
|
fingers, so it is on the palm side by
|
|
construction — a fact about hands, not
|
|
a rig convention
|
|
curl along x palm turning about this swings the fingers
|
|
into the palm, not sideways
|
|
```
|
|
|
|
The trigger hand's index finger gets a much shallower curl than the rest — it
|
|
lies along the trigger. Curling it with the others is what makes a character look
|
|
like they are squeezing a bar of soap.
|
|
|
|
Finger bones now resolve by role too (`rig_map.DIGITS`), across all three naming
|
|
families met so far: Rigify `DEF-f_index.01.L`, VRoid `J_Bip_L_Index1`, and
|
|
Blender-export `IndexFinger1_L`. Segments are ordered by DEPTH BELOW THE HAND,
|
|
not by the number in the name — the numbering is not consistent between families,
|
|
but the hierarchy always runs knuckle to fingertip.
|
|
|
|
**The support hand came out upside down**, because its orientation was built as
|
|
a shortest arc plus a constant twist: align the hand's forearm line to the barrel
|
|
(`Quaternion(fa_rest_dir, aim_dir)`), then add 0.5 rad of roll. A shortest arc
|
|
says NOTHING about roll — it is the minimal rotation between two directions — so
|
|
the entire roll came from that constant, and a constant is right only for the rig
|
|
it was tuned on.
|
|
|
|
Orienting a hand onto something it grips is a FRAME-TO-FRAME problem, and framing
|
|
it that way leaves nothing free to guess:
|
|
|
|
```
|
|
curl axis must lie along the object's axis, or the fingers close ACROSS the
|
|
handguard instead of around it
|
|
palm must face the object — up, for a hand supporting from underneath
|
|
along falls out of the other two (palm x curl)
|
|
```
|
|
|
|
Map the hand's rest anatomical frame onto that target and the roll is determined,
|
|
not chosen. Verified on both the Rigify-named mannequin and the VRoid-named
|
|
Kiyoko: fingers wrap the handguard from below, over the top.
|
|
|
|
**Rule:** anything expressed as a constant in a rig's local frame — a mount
|
|
rotation, a grip offset, a curl axis, a weapon size, a wrist twist — is a guess
|
|
about one skeleton. Derive it from the skeleton, or hand it to a solver that
|
|
already knows the answer. And when a rotation needs a specific ROLL, never build
|
|
it from a shortest arc: that operator has no opinion about roll, so whatever you
|
|
add afterwards is doing all the work.
|
|
|
|
## 6. Some sources are not salvageable, and the gate should say so
|
|
|
|
**Hit:** hikari. She failed every way at once — stretched and warped, tiny, far
|
|
away, gun backwards. Her rig has been through at least two toolchains: her
|
|
cosmetic bones are zero-length terminators, she carried a second armature with
|
|
its own clips, and her feet and her spine disagree about which way is up, so the
|
|
stand-up correction cannot resolve her either. She is now REJECTED by the gate
|
|
and removed from the roster rather than shipped broken.
|
|
|
|
**Rule:** a source that fails several unrelated checks is not a tuning problem,
|
|
it is a bad file. Spend the effort on finding a cleaner source, not on repairing
|
|
this one — and make sure the gate blocks it, because the failure mode before this
|
|
work was that everything passed and the breakage was only visible in game.
|
|
|
|
The vetting snippet in `separation.md` catches most of these before download:
|
|
several meshes, 50+ joints, cosmetic bones present. Add a look for duplicate
|
|
armatures and for bone chains whose bones are all at the same position.
|
|
|
|
## 7. Known-unsolved, and honestly so
|
|
|
|
Taila's legs still clip through the front of her skirt in a run, slide and dash
|
|
(~95 mm). See `cloth-and-hair.md`. The solver sees the contact and pushes on it
|
|
every iteration; what remains is a standing fight between the collision and the
|
|
garment's shape constraints, not a missing check.
|
|
|
|
---
|
|
|
|
## The check that would have caught most of this
|
|
|
|
One pass over the finished GLB, before it is ever registered:
|
|
|
|
```
|
|
POSTURE tallest axis of the bind-pose AABB == the hips->head axis,
|
|
and the other two are under ~2.5 m (catches #1)
|
|
SCALE height within a few percent of --height (catches #1)
|
|
FACING toes forward of ankles, along the world forward the game
|
|
expects, AFTER facing_flip (catches #3)
|
|
REACHABLE every role the runtime looks up — hands, head, spine — resolves
|
|
through the sidecar and not by name (catches #2)
|
|
CLOTH every chain has measurable extent, and its vertices track it (#4)
|
|
```
|
|
|
|
None of these needs Blender or the engine; the bind-pose AABB and the inverse
|
|
bind matrices in the GLB are enough for the first four.
|