Seven characters shipped "All checks passed" and four were visibly broken
in game — lying on their backs at seven times scale, facing backwards, or
holding a gun that floated near their chest. Nothing in the suite was
wrong; it just never asked the questions that mattered. That distinction
is the whole lesson, and it is now written down in the skill as
references/failure-modes.md, generalised per failure.
Two root causes are measured and certain:
- flatten_and_scale() normalises the bounding box along Blender Z because
Z is up. For a model that arrives lying along Y that measures the
character's THICKNESS, so it scales by ~7 and leaves them on their back.
One assumption, both symptoms. The trap is that the normalised number
always comes out right — the exporter maps Blender Z to glTF Y, so "is
the height 1.75" passes on a character who is 7.5 m tall lying down.
All three casualties are VRM files that went through a Blender
round-trip and came back with a baked axis rotation.
- SkinnedPlayerModel.set_weapon() finds the hand with three hardcoded
spellings, which match none of the four non-Rigify rigs — their hands
resolve perfectly in the sidecar as "Right wrist" and "J_Bip_R_Hand".
When it misses, the weapon is parented to the model root at a fixed
chest offset, so it is not attached to the character at all. Same class
of bug as the leg check that name-matched thigh/shin. rig_map.py exists
so nothing downstream has to guess a bone name; only some consumers read
the roles it publishes.
Three new hard checks, none needing more than the vertices and the
sidecar:
character stands up in world space — against WORLD up, not against the
model's own proportions. "Is the spine the longest axis?" catches
nothing: a model rotated as a whole is internally consistent and
passes it comfortably.
character is a plausible size / height
every role the runtime needs is resolved
They separate the four good characters from the three broken ones on the
first run. Also fixed the posture measurement to read vertices rather than
object.bound_box, which is cached and still stale right after an import —
it reported a 1.75 m character as 1.18 m tall.
Recorded but not yet fixed: kiyoko faces backwards (facing is inferred by
two independent mechanisms and verified by neither), miku's grown hair
chains stretch under animation (generated chains are never validated
against the geometry they drive), and the mannequin's rifle hold does not
convince despite resolving correctly.
Co-Authored-By: Claude Opus 5 <[email protected]>
201 lines
9.0 KiB
Markdown
201 lines
9.0 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 (hips → head), not from a
|
|
convention, and rotate the model upright before scaling anything.
|
|
|
|
**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, and the nose/head is forward of the spine. Assert it on the OUTPUT, after
|
|
every transform has been applied. A blanket constant like `facing_flip` is a
|
|
guess about the source that must be replaced by a measurement.
|
|
|
|
---
|
|
|
|
## 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 single-piece rig still needs its arms checked
|
|
|
|
**Symptom:** the gun is held, but not convincingly — the stock is not in the
|
|
shoulder and the hands are not on the grip. **Hit:** mannequin.
|
|
**Confidence: uncertain** — its hand bone resolves, so this is not #2.
|
|
|
|
The rifle hold places the weapon from the shoulder joint and the aim direction,
|
|
then solves both arms onto the grip and foregrip with two-bone IK. It is tuned
|
|
against proportions like Taila's. A rig with different arm lengths, a different
|
|
rest pose (A-pose vs T-pose) or a different bone roll will put the hands
|
|
somewhere plausible for the maths and wrong for the eye.
|
|
|
|
**Rule:** the hold has measurable success criteria — the distance from each hand
|
|
bone to the grip point it was solved onto. Assert those, per character, rather
|
|
than judging by eye.
|
|
|
|
---
|
|
|
|
## 6. 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.
|