fix: correct rig deformation — arms no longer cross body, feet no longer stretch to center

The model animated but deformed wrong: arms crossed the torso and foot heels
stretched to the model's center-bottom. Two root causes, both fixed:

1. Pose mismatch. Sketchfab models are modelled in an A-pose (arms angled
   down) but the Quaternius library rests in a T-pose. Binding the A-pose mesh
   to T-pose arm bones misweighted the arms, and the library clips (which
   rotate arms ~70° down from the T rest) then shoved the already-drooped arms
   across the body.
   Fix: autorig.fit_arms_to_mesh() reorients each arm bone chain along the
   model's real arm direction; merge_animations retargets every clip from the
   library's T-pose rest onto the fitted rest via world-space constraint bake
   (Copy Rotation/Location + nla.bake), so world motion is reproduced
   regardless of the rest difference.

2. Feet-to-center stretch. The library's deform-flagged `root` bone runs up
   the body centre-line; nearest-bone weighting assigned inner-foot/heel verts
   to it, stretching them to center when animated.
   Fix: exclude root/control/mechanism bones from weighting (_NON_DEFORM_HINTS).

Also upgraded nearest-bone weighting from rigid (1 bone/vert) to a smooth
inverse-distance blend across the nearest 4 bones, so joints deform cleanly.
Bone-heat still fails on Miku's joined hair/clothing mesh, so this is the
primary path.

Verified by rendering Idle/Walk/Run poses (Blender): natural stance, arms at
sides swinging correctly, feet planted, proper running stride. Smoke test
still 30/30.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Nicholas Butzke
2026-07-06 19:38:59 -04:00
co-authored by Claude Fable 5
parent 22cb0a58b7
commit 9939e7e524
4 changed files with 179 additions and 30 deletions
+93 -13
View File
@@ -127,11 +127,58 @@ def mesh_slice_width(obj, y_frac, height, min_z):
return max_x
def fit_arms_to_mesh(arm, mesh):
"""Rotate each arm bone chain to lie along the mesh's actual arm direction.
Sketchfab humanoids are usually modelled in an A-pose (arms angled down),
but the library skeleton rests in a T-pose (arms horizontal). If we bind the
A-pose mesh to T-pose arm bones, bone-heat weighting fails (bones sit outside
the arms) and the animations shove the already-drooped arms across the body.
Reorienting the arm bones down the real arm axis makes the rest pose fit the
mesh, so bone-heat succeeds. The animations are then retargeted from the
library's T-pose rest to this fitted rest in merge_animations.py.
"""
mw = mesh.matrix_world
verts = [mw @ v.co for v in mesh.data.vertices]
bpy.context.view_layer.objects.active = arm
bpy.ops.object.mode_set(mode="EDIT")
eb = arm.data.edit_bones
for side, sx in (("L", 1.0), ("R", -1.0)):
chain = [eb.get(f"DEF-upper_arm.{side}"), eb.get(f"DEF-forearm.{side}"),
eb.get(f"DEF-hand.{side}")]
chain = [b for b in chain if b]
if not chain:
continue
shoulder = chain[0].head.copy()
# Hand tip = farthest mesh vertex to this side, near arm height.
arm_z = shoulder.z
cand = [v for v in verts if (v.x * sx) > abs(shoulder.x) * 0.8
and abs(v.z - arm_z) < 0.35]
if not cand:
continue
hand_tip = max(cand, key=lambda v: v.x * sx)
cur = chain[-1].tail - shoulder
tgt = hand_tip - shoulder
if cur.length < 1e-4 or tgt.length < 1e-4:
continue
rot = cur.normalized().rotation_difference(tgt.normalized())
# Rotate the whole chain about the shoulder joint.
for b in chain:
b.head = shoulder + rot @ (b.head - shoulder)
b.tail = shoulder + rot @ (b.tail - shoulder)
sh = eb.get(f"DEF-shoulder.{side}")
if sh:
sh.tail = chain[0].head
bpy.ops.object.mode_set(mode="OBJECT")
print("Fitted arm bones to mesh A-pose")
def import_library_armature(height):
"""Import the animation library's armature as the rig template.
Rigging to the exact skeleton the clips were authored on means zero
retargeting: merge_animations.py can apply every clip verbatim.
Rigging to the exact skeleton the clips were authored on means clips need
only a rest-pose retarget (handled in merge_animations.py).
"""
before = set(bpy.data.objects)
before_actions = set(bpy.data.actions)
@@ -230,26 +277,42 @@ def _count_weighted_verts(mesh):
return sum(1 for v in mesh.data.vertices if len(v.groups) > 0)
def rigid_nearest_bone_weights(mesh, arm):
"""Assign every vertex fully to its nearest deform bone.
# Bones that must never deform the mesh: the root/master and any rig control
# or mechanism bones. A deform-flagged root runs up the body centre-line, so
# without this it captures inner-leg/heel verts and stretches them to centre.
_NON_DEFORM_HINTS = ("root", "master", "mch-", "mch_", "ctrl", "org-", "-ik",
"_ik", "pole", "target", "properties")
A guaranteed-to-work fallback when bone-heat weighting fails (common on
imported meshes with layered hair/clothing). Produces rigid skinning — no
smooth falloff at joints — but the character follows the skeleton and every
animation plays. Good enough for a fast-paced shooter at gameplay distance.
"""
# Build (name, head, tail) for deform bones in world space.
def _deform_segments(arm):
segs = []
for b in arm.data.bones:
if not b.use_deform:
continue
if any(h in b.name.lower() for h in _NON_DEFORM_HINTS):
continue
head = arm.matrix_world @ b.head_local
tail = arm.matrix_world @ b.tail_local
segs.append((b.name, head, tail))
return segs
def rigid_nearest_bone_weights(mesh, arm):
"""Weight each vertex to its nearest deform bones (distance-falloff blend).
A guaranteed-to-work substitute when bone-heat weighting fails — which it
does on most imported characters, whose joined hair/clothing/body meshes
have the interior and overlapping geometry the heat solver chokes on.
Each vertex is blended across its nearest few bone segments with an
inverse-distance falloff, so joints deform smoothly instead of tearing.
Control/root bones are excluded (see _NON_DEFORM_HINTS) so the mesh follows
real limbs, not the centre-line master bone.
"""
segs = _deform_segments(arm)
if not segs:
return 0
# Fresh vertex groups.
for vg in list(mesh.vertex_groups):
mesh.vertex_groups.remove(vg)
groups = {name: mesh.vertex_groups.new(name=name) for name, _, _ in segs}
@@ -260,11 +323,19 @@ def rigid_nearest_bone_weights(mesh, arm):
t = 0.0 if denom == 0 else max(0.0, min(1.0, (p - a).dot(ab) / denom))
return (p - (a + ab * t)).length
K = 4 # blend across up to this many nearest bones
FALLOFF = 3.0 # higher = tighter to the single nearest bone
mw = mesh.matrix_world
for v in mesh.data.vertices:
p = mw @ v.co
best_name = min(segs, key=lambda s: dist_to_seg(p, s[1], s[2]))[0]
groups[best_name].add([v.index], 1.0, "REPLACE")
dists = sorted(((dist_to_seg(p, s[1], s[2]), s[0]) for s in segs),
key=lambda d: d[0])[:K]
nearest = max(dists[0][0], 1e-5)
# Inverse-distance weights, relative to the nearest bone.
raw = [((nearest / max(d, 1e-5)) ** FALLOFF, name) for d, name in dists]
total = sum(w for w, _ in raw)
for w, name in raw:
groups[name].add([v.index], w / total, "REPLACE")
return _count_weighted_verts(mesh)
@@ -357,15 +428,24 @@ def main():
height = TARGET_HEIGHT
arm = None
from_library = False
if LIBRARY:
print(f"Using animation library skeleton: {LIBRARY}")
arm = import_library_armature(height)
from_library = arm is not None
if not arm:
shoulder_w = mesh_slice_width(mesh, 0.80, height, min_z)
hip_w = mesh_slice_width(mesh, 0.53, height, min_z)
print(f"Fitted: height={height:.2f} shoulder_w={shoulder_w:.2f} hip_w={hip_w:.2f}")
arm = build_mixamo_armature(height, min_z, shoulder_w, hip_w)
# Fit the arm bones to the mesh's real (usually A-pose) arm direction so
# bone-heat weighting fits and the rest pose matches the geometry. The
# library-clip retarget in merge_animations.py compensates for the
# rest-pose change.
if from_library:
fit_arms_to_mesh(arm, mesh)
bind_mesh_to_armature(mesh, arm)
print(f"Exporting {OUTPUT}...")