Feat/outline thickness and tp weapon hold #22
@@ -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