tools: measure rest poses, and diagnose momo
momo's idle plays with her arms overhead and her waist pinched, and every assertion in the suite passes on her. The first suspicion was the retarget's rest-relative delta: it applies "what the clip does to the LIBRARY's rest" to THIS rig's rest, which quietly assumes the two rests are alike. rest_pose_check.gd tests that, and disproves it — miku's arms rest 41° off the library's and taila's 32°, and both animate correctly. The delta retarget handles a rest-pose difference, which is what it is for. Recorded in the reference so nobody spends that hour again. Writing the tool reproduced this project's own recurring mistake in miniature. Measuring "the direction from a bone to its first child" reported kiyoko's and aria's legs 71° off the library — because a thigh's first child is as likely to be a skirt bone as a shin, and it was measuring the hang of a skirt panel. Pointing it at the next limb BY ROLE dropped both to 1°. What momo actually has: `Root_001` through `Root_007` are in her driven_bones, and they are her HAIR roots — Hair_A is dominated by Root_001_001, Root_007 and Root_005. The animation is keying bones the spring solver is supposed to own, which is non-negotiable #2 broken by the role resolver rather than by a clip. The surface table corroborates it: her hair surfaces report 1.8% and 9.4% chain share, because most of their vertices belong to bones in no chain at all. Not fixed here. `Root_00N` matches no COSMETIC stem, and adding "root" to the stems would cost a rig whose actual root is called `Root` its hips. The structural fix is that a bone whose geometry is dominated by a mesh classified `hair` is a hair bone whatever it is called — which the surface table makes answerable at build time, and did not when momo was imported. It needs a Blender re-run and re-verification of all six characters. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
1e6f3001ac
commit
700d0925d7
@@ -250,11 +250,29 @@ The six in `skins.json`, with what `surface_class_check` reports:
|
|||||||
still runs its legs ~40° off the direction of travel.
|
still runs its legs ~40° off the direction of travel.
|
||||||
- **momo's idle pose is wrong** — arms overhead and a pinched waist. Every
|
- **momo's idle pose is wrong** — arms overhead and a pinched waist. Every
|
||||||
assertion passes on her: she loads, animates, classifies and mounts a weapon.
|
assertion passes on her: she loads, animates, classifies and mounts a weapon.
|
||||||
It shows up only in `roster_capture`. Her source rig is the reason to suspect —
|
It shows up only in `roster_capture`.
|
||||||
its bones are auto-named (`Item_O_Sphere_005`, `Unused_Noname_004`,
|
|
||||||
`Root_001_001`), so the role resolver has almost nothing to go on, and a limb
|
**Diagnosed, not fixed.** Her `driven_bones` contains `Root_001` through
|
||||||
role claimed by the wrong bone would look exactly like this. Start by dumping
|
`Root_007` — and those are her HAIR roots. The Godot surface dump shows
|
||||||
her resolved roles against her skeleton.
|
`Hair_A` dominated by `Root_001_001:3203`, `Root_007:3203`, `Root_005:2642`.
|
||||||
|
So the animation is keying bones that physics is supposed to own, which is
|
||||||
|
non-negotiable #2 being violated by the role resolver rather than by a clip.
|
||||||
|
The corroboration is in the surface table: her hair surfaces report only 1.8%
|
||||||
|
and 9.4% chain share, because most of their vertices belong to `Root_00N`,
|
||||||
|
which is in no chain at all.
|
||||||
|
|
||||||
|
`Root_00N` matches no COSMETIC stem, so `is_cosmetic` does not catch it and
|
||||||
|
nothing keeps it out of the driven set. Fixing it by adding "root" to the
|
||||||
|
stems would be wrong — a rig whose actual root is called `Root` would lose its
|
||||||
|
hips. The fix is structural: a bone whose geometry is dominated by a mesh
|
||||||
|
classified `hair` is a hair bone, whatever it is called. The surface table
|
||||||
|
now makes that answerable at build time, which it was not when this rig was
|
||||||
|
imported. Not attempted here — it needs a Blender re-run and re-verification
|
||||||
|
of all six characters.
|
||||||
|
|
||||||
|
Her `head` role is also wrong (`Unused_Noname_010`, when a real `Head` bone
|
||||||
|
exists and is in her spine chain), and her spine chain runs two junk bones
|
||||||
|
PAST the head. Probably the same import; worth fixing in the same pass.
|
||||||
- **A stray `Icosphere` ships inside every skin GLB** — 42 vertices, no parent,
|
- **A stray `Icosphere` ships inside every skin GLB** — 42 vertices, no parent,
|
||||||
no vertex groups. It rides in from the animation library. Harmless, and now
|
no vertex groups. It rides in from the animation library. Harmless, and now
|
||||||
skipped by construction rather than by name in `surface_map`, but the export
|
skipped by construction rather than by name in `surface_map`, but the export
|
||||||
|
|||||||
@@ -47,6 +47,22 @@ Two related traps:
|
|||||||
| `anim_capture.gd` / `orbit_capture.gd` | renders, for looking | — |
|
| `anim_capture.gd` / `orbit_capture.gd` | renders, for looking | — |
|
||||||
| `roster_capture.gd` | one photo of every character, from the picker | — |
|
| `roster_capture.gd` | one photo of every character, from the picker | — |
|
||||||
| `ui_capture.gd` | one photo of every menu screen | — |
|
| `ui_capture.gd` | one photo of every menu screen | — |
|
||||||
|
| `rest_pose_check.gd` | each rig's bind-pose limb directions vs. the library's | see below |
|
||||||
|
|
||||||
|
## What `rest_pose_check` actually established
|
||||||
|
|
||||||
|
It was written to test a suspicion — that the rest-relative retarget silently
|
||||||
|
assumes both rigs rest alike — and it disproved it. Miku's arms rest **41°** off
|
||||||
|
the animation library's and Taila's **32°**, and both animate correctly. The
|
||||||
|
delta retarget handles a rest-pose difference, which is what it is for. Do not
|
||||||
|
go looking there again.
|
||||||
|
|
||||||
|
It also demonstrates the measurement trap in miniature. Written as "the direction
|
||||||
|
from a bone to its FIRST CHILD", it reported kiyoko's and aria's legs 71° off —
|
||||||
|
because a thigh's first child is as likely to be a skirt bone as a shin, and it
|
||||||
|
was measuring the hang of a skirt panel. Pointing it at the next limb BY ROLE
|
||||||
|
dropped both to 1°. The same rule as everywhere else in this pipeline: resolve
|
||||||
|
roles, never take whatever the rig happens to hand you.
|
||||||
|
|
||||||
## Assert the consequence, not the plumbing
|
## Assert the consequence, not the plumbing
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
## Which way does each character's REST pose point their limbs?
|
||||||
|
##
|
||||||
|
## The retarget bakes every clip as a rest-relative delta: it works out what the
|
||||||
|
## clip does to the LIBRARY's rest pose and applies that rotation to THIS rig's
|
||||||
|
## rest pose. That is what stops a foreign bone roll twisting a limb, and it is
|
||||||
|
## right — but it carries an assumption nobody has checked, which is that the two
|
||||||
|
## rest poses are broadly alike. If a character's arms rest overhead and the
|
||||||
|
## library's rest at its sides, then "what the clip does relative to the sides"
|
||||||
|
## applied to overhead leaves the arms overhead, in every clip, forever.
|
||||||
|
##
|
||||||
|
## So: measure it. For each character, the world-space direction each limb points
|
||||||
|
## in its BIND pose, and the angle between that and the library's.
|
||||||
|
##
|
||||||
|
## godot --headless --path . -s res://debug/rest_pose_check.gd
|
||||||
|
|
||||||
|
## limb role -> the role of the bone it points AT. Not "its first child": a
|
||||||
|
## thigh's first child is as likely to be a skirt bone as a shin, and taking it
|
||||||
|
## reported kiyoko's and aria's legs as 71° off the library when their legs are
|
||||||
|
## fine — it was measuring the hang of a skirt panel.
|
||||||
|
const LIMBS := {
|
||||||
|
"upper_arm.L": "forearm.L", "upper_arm.R": "forearm.R",
|
||||||
|
"forearm.L": "hand.L", "forearm.R": "hand.R",
|
||||||
|
"thigh.L": "shin.L", "thigh.R": "shin.R",
|
||||||
|
"shin.L": "foot.L", "shin.R": "foot.R",
|
||||||
|
}
|
||||||
|
## The reference is the mannequin: it comes from the animation library itself, so
|
||||||
|
## its rest pose IS the pose every clip was authored against.
|
||||||
|
const REFERENCE := "mannequin"
|
||||||
|
|
||||||
|
|
||||||
|
func _init() -> void:
|
||||||
|
await process_frame
|
||||||
|
var data = JSON.parse_string(FileAccess.get_file_as_string(
|
||||||
|
"res://assets/characters/skins/skins.json"))
|
||||||
|
|
||||||
|
var dirs := {}
|
||||||
|
for entry in data["skins"]:
|
||||||
|
var d := _limb_directions(entry.get("model", ""))
|
||||||
|
if not d.is_empty():
|
||||||
|
dirs[entry["id"]] = d
|
||||||
|
|
||||||
|
var ref: Dictionary = dirs.get(REFERENCE, {})
|
||||||
|
print("Rest-pose limb directions, and the angle from the animation library's own rig\n")
|
||||||
|
for id in dirs:
|
||||||
|
var parts: PackedStringArray = []
|
||||||
|
var worst := 0.0
|
||||||
|
for limb in LIMBS:
|
||||||
|
if not dirs[id].has(limb) or not ref.has(limb):
|
||||||
|
continue
|
||||||
|
var v: Vector3 = dirs[id][limb]
|
||||||
|
var deg := rad_to_deg(v.angle_to(ref[limb]))
|
||||||
|
worst = maxf(worst, deg)
|
||||||
|
parts.append("%s %3.0f°" % [limb, deg])
|
||||||
|
parts.sort()
|
||||||
|
print("%-10s worst %5.1f° %s" % [id, worst, " ".join(parts)])
|
||||||
|
print("\nA limb tens of degrees from the reference will play every clip with")
|
||||||
|
print("that offset baked in, because the clip only ever supplied the DELTA.")
|
||||||
|
quit()
|
||||||
|
|
||||||
|
|
||||||
|
## limb role -> the unit vector the bone points along, in bind pose, in the
|
||||||
|
## model's own space.
|
||||||
|
##
|
||||||
|
## Read from the sidecar's roles, never by bone name — a rig whose bones are
|
||||||
|
## called `Unused_Noname_010` is exactly the case this has to survive.
|
||||||
|
func _limb_directions(path: String) -> Dictionary:
|
||||||
|
if path == "" or not ResourceLoader.exists(path):
|
||||||
|
return {}
|
||||||
|
var rig = JSON.parse_string(FileAccess.get_file_as_string(
|
||||||
|
path.get_basename() + ".rig.json"))
|
||||||
|
if typeof(rig) != TYPE_DICTIONARY:
|
||||||
|
return {}
|
||||||
|
var scene: Node = load(path).instantiate()
|
||||||
|
var skel: Skeleton3D = _first_skeleton(scene)
|
||||||
|
var out := {}
|
||||||
|
if skel:
|
||||||
|
for limb in LIMBS:
|
||||||
|
var roles: Dictionary = rig.get("roles", {})
|
||||||
|
var from_name: String = roles.get(limb, "")
|
||||||
|
var to_name: String = roles.get(LIMBS[limb], "")
|
||||||
|
if from_name == "" or to_name == "":
|
||||||
|
continue
|
||||||
|
var bi := skel.find_bone(from_name)
|
||||||
|
var ti := skel.find_bone(to_name)
|
||||||
|
if bi < 0 or ti < 0:
|
||||||
|
continue
|
||||||
|
# Where the limb points: at the NEXT limb along, by role. A bone's
|
||||||
|
# own axes say nothing on their own — every rig rolls them
|
||||||
|
# differently, which is the whole reason the retarget works in
|
||||||
|
# deltas — so the direction has to come from the geometry.
|
||||||
|
var here := skel.get_bone_global_rest(bi).origin
|
||||||
|
var there := skel.get_bone_global_rest(ti).origin
|
||||||
|
var v := there - here
|
||||||
|
if v.length() > 1e-5:
|
||||||
|
out[limb] = v.normalized()
|
||||||
|
scene.free()
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
func _first_skeleton(node: Node) -> Skeleton3D:
|
||||||
|
if node is Skeleton3D:
|
||||||
|
return node
|
||||||
|
for c in node.get_children():
|
||||||
|
var f := _first_skeleton(c)
|
||||||
|
if f:
|
||||||
|
return f
|
||||||
|
return null
|
||||||
Reference in New Issue
Block a user