ani
This commit is contained in:
+19
-7
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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)]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user