317 lines
10 KiB
Python
317 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Retarget the authored left/right wall-run clips into an existing character GLB.
|
|
|
|
Unlike retarget.py, this is deliberately animation-only: it does not rebuild
|
|
the hierarchy, touch skin weights, subdivide cloth, scale meshes, or rewrite
|
|
the rig sidecar. It is safe to use on a character that has already passed the
|
|
full one-time character build.
|
|
|
|
Usage:
|
|
blender --background --python tools/inject_wallrun.py -- \
|
|
<character.glb> <_wallrun.glb> <output.glb>
|
|
"""
|
|
import math
|
|
import os
|
|
import sys
|
|
|
|
import bpy
|
|
from mathutils import Matrix, Quaternion, Vector
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
from rig_map import RigRoles, build_map
|
|
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:
|
|
print(__doc__)
|
|
sys.exit(1)
|
|
|
|
CHARACTER, LIBRARY, OUTPUT = argv
|
|
UP = Vector((0.0, 0.0, 1.0))
|
|
CLIPS = ("WallRunLeft", "WallRunRight")
|
|
|
|
|
|
def find_armature(objects):
|
|
return next((obj for obj in objects if obj.type == "ARMATURE"), None)
|
|
|
|
|
|
def assign_action(obj, action):
|
|
if obj.animation_data is None:
|
|
obj.animation_data_create()
|
|
obj.animation_data.action = action
|
|
if action is None:
|
|
return
|
|
try:
|
|
if len(action.slots):
|
|
obj.animation_data.action_slot = action.slots[0]
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def world_rest(arm):
|
|
return {
|
|
bone.name: arm.matrix_world @ bone.matrix_local
|
|
for bone in arm.data.bones
|
|
}
|
|
|
|
|
|
def bone_order(arm):
|
|
out = []
|
|
|
|
def walk(bone):
|
|
out.append(bone.name)
|
|
for child in bone.children:
|
|
walk(child)
|
|
|
|
for bone in arm.data.bones:
|
|
if bone.parent is None:
|
|
walk(bone)
|
|
return out
|
|
|
|
|
|
def rig_forward(arm, roles):
|
|
acc = Vector((0.0, 0.0, 0.0))
|
|
for side in ("L", "R"):
|
|
for role in ("toe", "foot"):
|
|
name = roles.limb.get((role, side))
|
|
if not name:
|
|
continue
|
|
bone = arm.data.bones[name]
|
|
direction = (
|
|
(arm.matrix_world @ bone.tail_local)
|
|
- (arm.matrix_world @ bone.matrix_local.translation)
|
|
)
|
|
direction.z = 0.0
|
|
if direction.length > 1e-5:
|
|
acc += direction.normalized()
|
|
break
|
|
return acc.normalized() if acc.length > 1e-5 else None
|
|
|
|
|
|
def facing_correction(src_arm, src_roles, target_arm, target_roles):
|
|
source = rig_forward(src_arm, src_roles)
|
|
target = rig_forward(target_arm, target_roles)
|
|
if source is None or target is None:
|
|
return Quaternion()
|
|
angle = Vector((source.x, source.y)).angle_signed(
|
|
Vector((target.x, target.y)), 0.0
|
|
)
|
|
return Quaternion(UP, angle) if abs(angle) >= 1e-4 else Quaternion()
|
|
|
|
|
|
def solve_pose(arm, order, rest_world, desired_rot, hips, hips_head):
|
|
pose_world = {}
|
|
basis = {}
|
|
bones = arm.data.bones
|
|
for name in order:
|
|
bone = bones[name]
|
|
rest = rest_world[name]
|
|
if bone.parent is not None:
|
|
parent = bone.parent.name
|
|
matrix = pose_world[parent] @ rest_world[parent].inverted() @ rest
|
|
else:
|
|
matrix = rest
|
|
rotation = Quaternion()
|
|
if name in desired_rot:
|
|
rotation = matrix.to_quaternion().inverted() @ desired_rot[name]
|
|
location = Vector((0.0, 0.0, 0.0))
|
|
if name == hips and hips_head is not None:
|
|
location = matrix.inverted() @ hips_head
|
|
basis[name] = (location, rotation)
|
|
pose_world[name] = (
|
|
matrix
|
|
@ Matrix.Translation(location)
|
|
@ rotation.to_matrix().to_4x4()
|
|
)
|
|
return basis
|
|
|
|
|
|
def retarget_action(target_arm, target_roles, src_arm, src_roles, mapping,
|
|
src_action, clip_name, yaw, scale):
|
|
src_rest = world_rest(src_arm)
|
|
target_rest = world_rest(target_arm)
|
|
target_order = bone_order(target_arm)
|
|
src_rest_rot = {
|
|
name: matrix.to_quaternion() for name, matrix in src_rest.items()
|
|
}
|
|
target_rest_rot = {
|
|
name: matrix.to_quaternion() for name, matrix in target_rest.items()
|
|
}
|
|
yaw_inverse = yaw.inverted()
|
|
segment_pairs = build_segment_pairs(mapping, src_roles, target_roles)
|
|
src_hips = src_roles.hips
|
|
target_hips = target_roles.hips
|
|
src_hips_rest = src_rest[src_hips].translation.copy()
|
|
target_hips_rest = target_rest[target_hips].translation.copy()
|
|
|
|
assign_action(src_arm, src_action)
|
|
frame_start, frame_end = (
|
|
int(round(value)) for value in src_action.frame_range
|
|
)
|
|
baked = bpy.data.actions.new(clip_name)
|
|
assign_action(target_arm, baked)
|
|
for pose_bone in target_arm.pose.bones:
|
|
pose_bone.rotation_mode = "QUATERNION"
|
|
|
|
scene = bpy.context.scene
|
|
for frame in range(frame_start, frame_end + 1):
|
|
scene.frame_set(frame)
|
|
depsgraph = bpy.context.evaluated_depsgraph_get()
|
|
src_eval = src_arm.evaluated_get(depsgraph)
|
|
source_world = src_eval.matrix_world
|
|
desired = {}
|
|
for target_name, source_name in mapping.items():
|
|
if (
|
|
source_name not in src_eval.pose.bones
|
|
or target_name not in target_rest_rot
|
|
):
|
|
continue
|
|
desired[target_name] = authored_world_rotation(
|
|
target_name,
|
|
source_name,
|
|
src_eval,
|
|
source_world,
|
|
src_rest_rot,
|
|
target_rest,
|
|
target_rest_rot,
|
|
yaw,
|
|
yaw_inverse,
|
|
segment_pairs,
|
|
)
|
|
|
|
moved = (
|
|
source_world @ src_eval.pose.bones[src_hips].matrix
|
|
).translation
|
|
displacement = yaw @ ((moved - src_hips_rest) * scale)
|
|
# Gameplay owns horizontal travel. Preserve vertical compression and
|
|
# lift from the performance, but keep the cycle in-place.
|
|
displacement.x = 0.0
|
|
displacement.y = 0.0
|
|
hips_head = target_hips_rest + displacement
|
|
basis = solve_pose(
|
|
target_arm,
|
|
target_order,
|
|
target_rest,
|
|
desired,
|
|
target_hips,
|
|
hips_head,
|
|
)
|
|
for name in mapping:
|
|
if name not in basis:
|
|
continue
|
|
pose_bone = target_arm.pose.bones[name]
|
|
pose_bone.rotation_quaternion = basis[name][1]
|
|
pose_bone.keyframe_insert("rotation_quaternion", frame=frame)
|
|
pose_bone = target_arm.pose.bones[target_hips]
|
|
pose_bone.location = basis[target_hips][0]
|
|
pose_bone.keyframe_insert("location", frame=frame)
|
|
|
|
assign_action(target_arm, None)
|
|
return baked
|
|
|
|
|
|
def add_clip_track(arm, action, name):
|
|
action.name = name
|
|
action.use_fake_user = True
|
|
track = arm.animation_data.nla_tracks.new()
|
|
track.name = name
|
|
strip = track.strips.new(name, 0, action)
|
|
strip.name = name
|
|
track.mute = True
|
|
|
|
|
|
def main():
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
bpy.ops.import_scene.gltf(filepath=CHARACTER)
|
|
target_arm = find_armature(bpy.data.objects)
|
|
if target_arm is None:
|
|
raise RuntimeError(f"{CHARACTER}: no armature")
|
|
target_roles = RigRoles(target_arm)
|
|
missing = target_roles.missing_core()
|
|
if missing:
|
|
raise RuntimeError(f"{CHARACTER}: missing target roles {missing}")
|
|
|
|
# Remove the old relabelled sprint, plus stale versions from a prior run.
|
|
for track in list(target_arm.animation_data.nla_tracks):
|
|
if track.name in ("WallRun",) + CLIPS:
|
|
target_arm.animation_data.nla_tracks.remove(track)
|
|
for action in list(bpy.data.actions):
|
|
if action.name in ("WallRun",) + CLIPS:
|
|
bpy.data.actions.remove(action)
|
|
assign_action(target_arm, None)
|
|
|
|
before_objects = set(bpy.data.objects)
|
|
before_actions = set(bpy.data.actions)
|
|
bpy.ops.import_scene.gltf(filepath=LIBRARY)
|
|
new_objects = [obj for obj in bpy.data.objects if obj not in before_objects]
|
|
new_actions = [
|
|
action for action in bpy.data.actions if action not in before_actions
|
|
]
|
|
src_arm = find_armature(new_objects)
|
|
if src_arm is None:
|
|
raise RuntimeError(f"{LIBRARY}: no source armature")
|
|
src_roles = RigRoles(src_arm)
|
|
mapping = build_map(src_roles, target_roles, {})
|
|
if len(mapping) < 20:
|
|
raise RuntimeError(f"{LIBRARY}: only {len(mapping)} mapped bones")
|
|
|
|
yaw = facing_correction(src_arm, src_roles, target_arm, target_roles)
|
|
src_h = (
|
|
src_arm.matrix_world
|
|
@ src_arm.data.bones[src_roles.hips].matrix_local
|
|
).translation.z
|
|
target_h = (
|
|
target_arm.matrix_world
|
|
@ target_arm.data.bones[target_roles.hips].matrix_local
|
|
).translation.z
|
|
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)
|
|
if source is None:
|
|
raise RuntimeError(f"{LIBRARY}: missing {clip}")
|
|
baked = retarget_action(
|
|
target_arm,
|
|
target_roles,
|
|
src_arm,
|
|
src_roles,
|
|
mapping,
|
|
source,
|
|
clip,
|
|
yaw,
|
|
scale,
|
|
)
|
|
add_clip_track(target_arm, baked, clip)
|
|
|
|
for obj in new_objects:
|
|
bpy.data.objects.remove(obj, do_unlink=True)
|
|
for action in new_actions:
|
|
if action.users == 0:
|
|
bpy.data.actions.remove(action)
|
|
assign_action(target_arm, None)
|
|
|
|
os.makedirs(os.path.dirname(os.path.abspath(OUTPUT)), exist_ok=True)
|
|
bpy.ops.export_scene.gltf(
|
|
filepath=OUTPUT,
|
|
export_format="GLB",
|
|
export_yup=True,
|
|
export_apply=False,
|
|
export_skins=True,
|
|
export_animations=True,
|
|
export_animation_mode="NLA_TRACKS",
|
|
export_bake_animation=False,
|
|
export_optimize_animation_size=True,
|
|
export_optimize_animation_keep_anim_armature=False,
|
|
)
|
|
print(
|
|
f"Injected {', '.join(CLIPS)} into {OUTPUT}; "
|
|
f"{len(bpy.data.actions)} total actions"
|
|
)
|
|
|
|
|
|
main()
|