This commit is contained in:
Nicholas Butzke
2026-08-09 01:31:44 -04:00
parent 922983429e
commit 9358746582
43 changed files with 4324 additions and 481 deletions
+19 -7
View File
@@ -9,7 +9,12 @@ full one-time character build.
Usage:
blender --background --python tools/inject_wallrun.py -- \
<character.glb> <_wallrun.glb> <output.glb>
<character.glb> <animation_library.glb> <output.glb> [ClipName ...]
With no clip names it keeps the original wall-run behavior. Supplying names
also makes this the small post-export repair used for libraries that Blender's
first, very large NLA export occasionally omits (notably RunBackward on some
Auto-Rig Pro characters).
"""
import math
import os
@@ -25,13 +30,20 @@ from retarget_pose import authored_world_rotation, build_segment_pairs
argv = sys.argv
argv = argv[argv.index("--") + 1:] if "--" in argv else []
if len(argv) != 3:
if len(argv) < 3:
print(__doc__)
sys.exit(1)
CHARACTER, LIBRARY, OUTPUT = argv
CHARACTER, LIBRARY, OUTPUT = argv[:3]
UP = Vector((0.0, 0.0, 1.0))
CLIPS = ("WallRunLeft", "WallRunRight")
RAW_CLIPS = tuple(argv[3:]) or ("WallRunLeft", "WallRunRight")
# ``Source=Destination`` lets the compact repair give a source action its
# canonical runtime name (used by strafe_back-cycle -> RunBackward).
CLIP_SPECS = tuple(
tuple(raw.split("=", 1)) if "=" in raw else (raw, raw)
for raw in RAW_CLIPS
)
CLIPS = tuple(destination for _source, destination in CLIP_SPECS)
def find_armature(objects):
@@ -270,10 +282,10 @@ def main():
scale = target_h / src_h if src_h > 1e-5 else 1.0
by_name = {action.name.split(".")[0]: action for action in new_actions}
for clip in CLIPS:
source = by_name.get(clip)
for source_name, clip in CLIP_SPECS:
source = by_name.get(source_name)
if source is None:
raise RuntimeError(f"{LIBRARY}: missing {clip}")
raise RuntimeError(f"{LIBRARY}: missing {source_name}")
baked = retarget_action(
target_arm,
target_roles,
+20
View File
@@ -220,6 +220,26 @@ def main() -> None:
cmd.append("--keep-root-motion")
run(cmd, "Retarget animation library (Blender)")
# Blender's large NLA export can omit actions imported from the final
# animation libraries on some FBX/Auto-Rig Pro and Mixamo rigs. Re-inject
# the three critical late-library clips in compact passes. Besides making
# the build deterministic, this prevents backward movement and wall-running
# from silently falling through to unrelated ground/fall animations.
injector = os.path.join(TOOLS, "inject_wallrun.py")
wall_library = os.path.join(args.anim_dir, "_wallrun.glb")
backward_library = os.path.join(args.anim_dir, "_directional.glb")
wall_fixed = os.path.join(STAGING, f"{name}_wall_motion.glb")
fully_fixed = os.path.join(STAGING, f"{name}_required_motion.glb")
run([blender, "--background", "--python", injector, "--",
final_path, wall_library, wall_fixed,
"WallRunLeft", "WallRunRight"],
"Guarantee authored wall-run clips (Blender)")
run([blender, "--background", "--python", injector, "--",
wall_fixed, backward_library, fully_fixed,
"strafe_back-cycle=RunBackward"],
"Guarantee authored backward locomotion (Blender)")
shutil.copyfile(fully_fixed, final_path)
# 3. Gate on the checks that encode every way this has gone wrong before.
run([blender, "--background", "--python", os.path.join(TOOLS, "verify_character.py"),
"--", final_path], "Verify the built character (Blender)")
+14 -5
View File
@@ -123,7 +123,13 @@ LIBRARY_V2_CLIP_MAP = {
# Keep the useful authored front/back steps, but label them honestly.
DIRECTIONAL_CLIP_MAP = {
"strafe_front-cycle": "StrafeWalkForward",
"strafe_back-cycle": "StrafeWalkBackward",
# The same coherent back-step is cadence-scaled for running. This source
# keeps pelvis and abdomen in one frame; the previous CMU capture folded
# the torso on the anime rigs.
"strafe_back-cycle": [
("StrafeWalkBackward", 0.0),
("RunBackward", 0.0),
],
}
# Mesh2Motion's CC0 human add-on library supplies authored forward and lateral
@@ -145,9 +151,7 @@ WALLRUN_CLIP_MAP = {
"WallRunRight": "WallRunRight",
}
CMU_CLIP_MAP = {
"RunBackward": "RunBackward",
}
CMU_CLIP_MAP = {}
UP = Vector((0.0, 0.0, 1.0))
@@ -1192,7 +1196,12 @@ def _weights_look_authored(meshes, roles):
bleeding += 1
if total == 0:
return False
authored = bleeding / total < 0.02 and four / total < 0.5
# Use the same cross-leg ceiling as verify_character. A source can be
# beautifully hand-painted overall yet still carry enough opposite-leg
# weight to fold during backward/side steps. Mark that case for the
# runtime's geometry-aware SkinLegRepair instead of exempting it merely
# because the rest of the weighting looks authored.
authored = bleeding / total < 0.005 and four / total < 0.5
print(f"Weights: {bleeding} cross-leg ({bleeding / total * 100:.1f}%), "
f"{four / total * 100:.0f}% at four influences — "
f"{'authored' if authored else 'solved, runtime repair stays on'}")
+167 -10
View File
@@ -129,6 +129,13 @@ def is_cosmetic(name: str) -> bool:
for c in COSMETIC:
if len(t) - len(c) <= 2 and t.startswith(c) and len(t) > len(c):
return True
# Mixamo-style face bones often put the side first and glue the
# cosmetic role behind it: ``LeftEye`` / ``RightEye``. Without
# this mirror case the spine walker follows the eye above Head and
# resolves a facial joint as the anatomical head.
prefix = t[:-len(c)] if t.endswith(c) else ""
if prefix in ("l", "r", "left", "right"):
return True
return False
@@ -149,6 +156,39 @@ def is_segment_of(name: str, all_names: set) -> bool:
return any(strip_gltf_suffix(n) == stem for n in all_names)
def is_twist_helper(name: str) -> bool:
"""True for a deformation helper that must not own an anatomy role.
Auto-Rig Pro exports the actual upper/lower limb as ``*_stretch`` and a
sibling ``*_twist`` bone for roll distribution. The old resolver saw the
longer word "forearm" in the twist helper first and drove that helper as
the whole limb, leaving the elbow chain folded. A twist bone is never the
joint itself, regardless of naming convention.
"""
return "twist" in tokens(name) or "roll" in tokens(name)
def is_attachment_helper(name: str) -> bool:
"""True for armor/IK/control bones whose name embeds an anatomy word.
Several animation libraries ship their visible equipment in the same
armature. Names such as ``footcover.L``, ``kneepad.R``,
``forearmslider.R`` and ``ik_hand_r`` all matched a body role just as well
as the real foot/knee/forearm/hand. Set iteration then made the winner
arbitrary, which is why lateral movement folded and the wall-run drove
armor controls instead of limbs.
"""
stem = canon(name)
helpers = ("cover", "pad", "plate", "slider", "piston", "adjust",
"scaler", "cannon", "module", "target", "control", "helper",
"leaf", "end")
if any(helper in stem for helper in helpers):
return True
ts = tokens(name)
return any(token in ("ik", "ikt", "ctrl") for token in ts) \
or stem.startswith("ik") or stem.endswith("ik") or stem.endswith("ikt")
class RigRoles:
"""Resolved anatomy of one armature."""
@@ -162,11 +202,78 @@ class RigRoles:
self.hips = None
self.head = None
self.neck = None
self._seed_mmd_standard_limbs()
self._resolve_limbs()
self._fill_limbs_by_anatomy()
self._resolve_spine()
self._override_mmd_standard_trunk()
self._resolve_fingers()
# ---------------------------------------------------------- standard MMD
def _exact_bone(self, *authored_names):
"""Return an exact source bone, allowing only glTF's numeric suffix.
Japanese MMD anatomy names do not transliterate into the English token
families used by Mixamo, Rigify and VRoid. They are nevertheless a
stable published convention, so exact matching is safer than trying to
infer them through MMD's hundreds of IK, physics and adjustment bones.
"""
for authored in authored_names:
for actual in self.names:
if strip_gltf_suffix(actual) == authored:
return actual
return None
def _seed_mmd_standard_limbs(self):
"""Seed the canonical body roles used by standard Japanese MMD rigs."""
# Require multiple unmistakable MMD body joints before applying the
# table. A prop that happens to contain one Japanese label must not be
# mistaken for a humanoid skeleton.
if not (self._exact_bone("左腕", "腕.L")
and self._exact_bone("右腕", "腕.R")
and self._exact_bone("左足", "足.L")
and self._exact_bone("右足", "足.R")):
return
names = {
("shoulder", "L"): ("左肩", "肩.L"),
("upper_arm", "L"): ("左腕", "腕.L"),
("forearm", "L"): ("左ひじ", "ひじ.L"),
("hand", "L"): ("左手首", "手首.L"),
("shoulder", "R"): ("右肩", "肩.R"),
("upper_arm", "R"): ("右腕", "腕.R"),
("forearm", "R"): ("右ひじ", "ひじ.R"),
("hand", "R"): ("右手首", "手首.R"),
("thigh", "L"): ("左足", "足.L"),
("shin", "L"): ("左ひざ", "ひざ.L"),
("foot", "L"): ("左足首", "足首.L"),
("toe", "L"): ("左つま先", "つま先.L"),
("thigh", "R"): ("右足", "足.R"),
("shin", "R"): ("右ひざ", "ひざ.R"),
("foot", "R"): ("右足首", "足首.R"),
("toe", "R"): ("右つま先", "つま先.R"),
}
for role, authored in names.items():
actual = self._exact_bone(*authored)
if actual:
self.limb[role] = actual
def _override_mmd_standard_trunk(self):
"""Keep the spine walker out of MMD eye, hair and control chains."""
hips = self._exact_bone("下半身")
head = self._exact_bone("")
upper = self._exact_bone("上半身")
if not (hips and head and upper):
return
self.hips = hips
self.spine = []
for authored in ("上半身", "上半身1", "上半身2", "", ""):
actual = self._exact_bone(authored)
if actual and actual not in self.spine:
self.spine.append(actual)
self.neck = self._exact_bone("")
self.head = head
# ---------------------------------------------------------------- limbs
def _resolve_limbs(self):
@@ -192,23 +299,40 @@ class RigRoles:
"""
claims = []
for name in self.names:
if is_cosmetic(name) or is_segment_of(name, self.names):
if (is_cosmetic(name) or is_segment_of(name, self.names)
or is_twist_helper(name) or is_attachment_helper(name)):
continue
side = side_of(name)
if side is None:
continue
stem = canon(name)
for role in LIMB_ORDER:
hits = [alt.replace("_", "") for alt in LIMB_ROLES[role]
if alt.replace("_", "") in stem]
# Mixamo glues the parent role into every finger name
# (``LeftHandIndex3``). A substring-only resolver therefore
# let an arbitrarily ordered fingertip claim ``hand.L/R`` and
# made all hand-relative equipment/IK disappear at runtime.
# Finger segments are descendants of a hand, never the hand.
if role == "hand" and any(
digit_stem in stem
for digit_stems in DIGITS.values()
for digit_stem in digit_stems):
continue
alternatives = [alt.replace("_", "")
for alt in LIMB_ROLES[role]]
hits = [(len(alt), alternative_index)
for alternative_index, alt in enumerate(alternatives)
if alt in stem]
if hits:
claims.append((len(max(hits, key=len)), role, side, name))
hit_length, alternative_index = max(
hits, key=lambda hit: (hit[0], -hit[1]))
claims.append((hit_length, alternative_index,
role, side, name))
# Longest stem wins; LIMB_ORDER breaks ties, so a bone matching two roles
# equally well still resolves the way the list intends.
order = {role: i for i, role in enumerate(LIMB_ORDER)}
claims.sort(key=lambda c: (-c[0], order[c[1]]))
claims.sort(key=lambda c: (-c[0], c[1], order[c[2]]))
used = set()
for _, role, side, name in claims:
for _, _alternative, role, side, name in claims:
if (role, side) in self.limb or name in used:
continue
self.limb[(role, side)] = name
@@ -234,7 +358,8 @@ class RigRoles:
b = b.parent
if b is None or b.name not in self.names:
break
if is_cosmetic(b.name) or is_segment_of(b.name, self.names):
if (is_cosmetic(b.name) or is_segment_of(b.name, self.names)
or is_twist_helper(b.name) or is_attachment_helper(b.name)):
continue # a twist segment is not a joint of its own
chain.append(b.name)
# chain is now [shin, thigh, hips, ...] going up from the foot.
@@ -298,7 +423,14 @@ class RigRoles:
blocked = set(self.limb.values())
def candidate(name):
if name in blocked or is_cosmetic(name):
if name in blocked or is_cosmetic(name) or is_attachment_helper(name):
return False
# Exporters commonly append terminal marker joints such as
# ``HeadTop_End``. They carry no anatomy or skin and must not turn
# the marker into the resolved head (which lengthens every neck
# retarget and produces the characteristic folded torso).
if ("end" in tokens(name) or "leaf" in tokens(name)
or canon(name).endswith("core")):
return False
return not any(t in ("root", "master", "armature", "scene", "rootjoint")
for t in tokens(name))
@@ -329,8 +461,33 @@ class RigRoles:
if not best:
return
self.hips = best[0]
self.spine = best[1:]
# Auto-Rig Pro exports ``c_traj -> root -> spine_01`` and hangs both
# thighs from that weighted ``root``. In that specific layout root is
# the pelvis, not a disposable scene control. Promote it only when the
# trunk walk actually starts on a named spine bone; this deliberately
# does not promote the animation libraries' ``root -> hips -> spine``.
base = self.bones[best[0]]
parent = base.parent
base_tokens = tokens(base.name)
thighs = [self.limb.get(("thigh", side)) for side in ("L", "R")]
def descends_from(name, ancestor):
bone = self.bones.get(name)
while bone is not None:
if bone == ancestor:
return True
bone = bone.parent
return False
promote_parent = (
parent is not None
and any("spine" in token for token in base_tokens)
and any(token in ("root", "pelvis", "hips")
for token in tokens(parent.name))
and all(name and descends_from(name, parent) for name in thighs)
)
self.hips = parent.name if promote_parent else best[0]
self.spine = best if promote_parent else best[1:]
if self.spine:
self.head = self.spine[-1]
named = [n for n in self.spine if "neck" in tokens(n)]
+11 -3
View File
@@ -241,13 +241,21 @@ if _roles:
# ------------------------------------------------------------------- skeleton
def is_rootish(b):
return b is None or any(t in b.name.lower()
for t in ("root", "master", "armature"))
if b is None:
return True
# Some production rigs call their weighted pelvis simply ``root``. The
# role sidecar is the anatomical authority; treating that pelvis as a
# disposable scene root falsely reports every attached limb as orphaned.
if b.name == SIDECAR_INFO.get("roles", {}).get("hips"):
return False
return any(t in b.name.lower() for t in ("root", "master", "armature"))
orphans = [b.name for b in arm.data.bones
if is_rootish(b.parent) and not is_rootish(b)]
check(len(orphans) <= 1, "every chain is attached to the body",
root_names = {bone.name for bone in arm.data.bones}
root_allowance = 2 if {"全ての親", "操作中心"}.issubset(root_names) else 1
check(len(orphans) <= root_allowance, "every chain is attached to the body",
f"{len(orphans)} bones still on the armature root: {orphans[:6]}")
# ----------------------------------------------------------------------- clips