big
This commit is contained in:
@@ -0,0 +1,316 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user