Feat/outline thickness and tp weapon hold #22
@@ -81,6 +81,9 @@ Blender is required (`BLENDER_PATH`, or auto-found under
|
|||||||
Load the reference that matches what you are doing. They are short and each one
|
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.
|
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
|
- **`references/separation.md`** — body vs. garments vs. hair: what must stay
|
||||||
separate, how cloth chains are detected and classed, why cloth is never
|
separate, how cloth chains are detected and classed, why cloth is never
|
||||||
skinned to a leg, and the ZZZ-convention mapping.
|
skinned to a leg, and the ZZZ-convention mapping.
|
||||||
@@ -112,6 +115,16 @@ is a list of things that cost a debugging cycle to learn.
|
|||||||
deletes triangles. It exists only to undo auto-rigging. It is gated on
|
deletes triangles. It exists only to undo auto-rigging. It is gated on
|
||||||
`weights_authored`, which is MEASURED, not assumed.
|
`weights_authored`, which is MEASURED, not assumed.
|
||||||
5. **Measure from inside the modifier pass.** See `references/verification.md`.
|
5. **Measure from inside the modifier pass.** See `references/verification.md`.
|
||||||
|
6. **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`.
|
||||||
|
7. **Never look a bone up by name.** `tools/rig_map.py` resolves roles and writes
|
||||||
|
them to the sidecar so nothing downstream has to guess. Any hardcoded spelling
|
||||||
|
— `_find_bone(["RightHand", ...])`, a `thigh`/`shin` substring 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
|
## After importing
|
||||||
|
|
||||||
@@ -132,6 +145,7 @@ What "good" looks like on Taila, for calibration:
|
|||||||
| Leg inside cloth, run/slide/dash | ~95 mm *(current, unsolved)* | — |
|
| Leg inside cloth, run/slide/dash | ~95 mm *(current, unsolved)* | — |
|
||||||
| Solver cost | ~2.6 ms/character | 10 ms |
|
| Solver cost | ~2.6 ms/character | 10 ms |
|
||||||
| Stride vs. travel direction | < 10° (except a capped sidestep) | 90° |
|
| 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
|
## Characters currently shipping
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
# 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.
|
||||||
@@ -66,6 +66,18 @@ It also applies the collider's `from` offset, so it tests the same band of thigh
|
|||||||
the solver is defending. Measuring the full bone tests the hip cap the solver
|
the solver is defending. Measuring the full bone tests the hip cap the solver
|
||||||
deliberately excludes and reports it as clipping no tuning can fix.
|
deliberately excludes and reports it as clipping no tuning can fix.
|
||||||
|
|
||||||
|
## What the suite still does not check
|
||||||
|
|
||||||
|
It verifies that a character is WELL-FORMED, not that it is CORRECT. Those are
|
||||||
|
different properties, and only the first was ever asserted — which is how four
|
||||||
|
characters shipped "All checks passed" while lying on their backs, seven times
|
||||||
|
too large, facing backwards, or unable to hold a gun. See `failure-modes.md`.
|
||||||
|
|
||||||
|
`posture` and `bone roles reachable at runtime` are now hard checks. Still
|
||||||
|
missing, and worth adding when a source next exposes them: facing measured on the
|
||||||
|
OUTPUT, and per-vertex validation that a generated cloth chain actually tracks
|
||||||
|
the geometry it was given.
|
||||||
|
|
||||||
## Diagnosing "the solver isn't working"
|
## Diagnosing "the solver isn't working"
|
||||||
|
|
||||||
In order:
|
In order:
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
from mathutils import Vector
|
||||||
|
|
||||||
argv = sys.argv
|
argv = sys.argv
|
||||||
argv = argv[argv.index("--") + 1:] if "--" in argv else []
|
argv = argv[argv.index("--") + 1:] if "--" in argv else []
|
||||||
@@ -171,6 +172,73 @@ check(len(meshes) > 1, "model keeps its per-part meshes",
|
|||||||
if len(meshes) == 1 else ""),
|
if len(meshes) == 1 else ""),
|
||||||
advisory=True)
|
advisory=True)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- posture
|
||||||
|
#
|
||||||
|
# Is this character the right size and the right way up?
|
||||||
|
#
|
||||||
|
# Nothing asked before, and it is the single cheapest check in the file. The
|
||||||
|
# scale step normalises the bounding box along Blender's Z because Z is up — for
|
||||||
|
# a model that arrives lying along Y that measures the character's THICKNESS, so
|
||||||
|
# it gets scaled by ~7 and left on its back. Three of seven shipped that way.
|
||||||
|
#
|
||||||
|
# The trap is that the normalised number always comes out right: the export maps
|
||||||
|
# Blender Z to glTF Y, so "is the height 1.75" passes on a character who is
|
||||||
|
# 7.5 m tall lying down. The real question is whether the axis that was
|
||||||
|
# normalised is the one the SKELETON is tall along.
|
||||||
|
# From the VERTICES, not from object.bound_box — that is cached and is still
|
||||||
|
# stale immediately after an import, which quietly reported a 1.75 m character
|
||||||
|
# as 1.18 m tall.
|
||||||
|
lo = [1e9] * 3
|
||||||
|
hi = [-1e9] * 3
|
||||||
|
for m in meshes:
|
||||||
|
mw = m.matrix_world
|
||||||
|
for v in m.data.vertices:
|
||||||
|
p = mw @ v.co
|
||||||
|
for k in range(3):
|
||||||
|
lo[k] = min(lo[k], p[k])
|
||||||
|
hi[k] = max(hi[k], p[k])
|
||||||
|
ext = [hi[k] - lo[k] for k in range(3)]
|
||||||
|
|
||||||
|
up_axis = None
|
||||||
|
_hips = arm.data.bones.get(SIDECAR_INFO.get("roles", {}).get("hips", ""))
|
||||||
|
_head = arm.data.bones.get(SIDECAR_INFO.get("roles", {}).get("head", ""))
|
||||||
|
if _hips and _head:
|
||||||
|
d = [abs(_head.head_local[k] - _hips.head_local[k]) for k in range(3)]
|
||||||
|
up_axis = d.index(max(d))
|
||||||
|
|
||||||
|
if up_axis is not None and max(ext) > 1e-4:
|
||||||
|
tall = ext.index(max(ext))
|
||||||
|
others = [ext[k] for k in range(3) if k != up_axis]
|
||||||
|
detail = ("%.2f m tall along %s, %.2f x %.2f m across"
|
||||||
|
% (ext[up_axis], "XYZ"[up_axis], others[0], others[1]))
|
||||||
|
# Against WORLD up (Blender Z), not against the widest axis. A model that has
|
||||||
|
# been rotated as a whole is internally consistent — its spine IS its longest
|
||||||
|
# axis — so comparing the two agrees with itself and catches nothing. The
|
||||||
|
# question is whether the character stands up in the world the game runs in.
|
||||||
|
check(up_axis == 2, "character stands up in world space",
|
||||||
|
detail + ("" if up_axis == 2 else
|
||||||
|
" — the spine runs along %s, not Z, so the character is "
|
||||||
|
"lying down; whatever was normalised to the target height "
|
||||||
|
"was not their height" % "XYZ"[up_axis]))
|
||||||
|
check(max(others) < 2.5, "character is a plausible size", detail)
|
||||||
|
check(1.2 < ext[up_axis] < 2.4, "character is a plausible height", detail)
|
||||||
|
|
||||||
|
# --------------------------------------------------- reachable from the runtime
|
||||||
|
#
|
||||||
|
# The game looks these up to attach a weapon and to drive the pose layer. It used
|
||||||
|
# to do it with hardcoded spellings, so four characters whose roles resolved
|
||||||
|
# perfectly still could not hold a gun. The roles are the contract; if one the
|
||||||
|
# runtime needs is missing from the sidecar, the character will be subtly broken
|
||||||
|
# in a way no other check here can see.
|
||||||
|
_roles = SIDECAR_INFO.get("roles", {})
|
||||||
|
if _roles:
|
||||||
|
needed = ["hips", "head", "hand.R", "hand.L", "upper_arm.R", "upper_arm.L",
|
||||||
|
"forearm.R", "forearm.L", "thigh.L", "thigh.R", "shin.L", "shin.R"]
|
||||||
|
absent = [r for r in needed
|
||||||
|
if not _roles.get(r) or _roles[r] not in arm.data.bones]
|
||||||
|
check(not absent, "every role the runtime needs is resolved",
|
||||||
|
"missing or unresolvable: %s" % absent)
|
||||||
|
|
||||||
# ------------------------------------------------------------------- skeleton
|
# ------------------------------------------------------------------- skeleton
|
||||||
def is_rootish(b):
|
def is_rootish(b):
|
||||||
return b is None or any(t in b.name.lower()
|
return b is None or any(t in b.name.lower()
|
||||||
|
|||||||
Reference in New Issue
Block a user