The character now stands naturally: arms at its sides with a soft elbow bend, hands at the hips, feet flat. Both character shots confirm it, front and back. The root cause was not the pose. The FBX importer had been silently refusing to replace the existing assets: after changing the arm base from 1.55 rad to 0.55 rad and rebuilding, the imported clip returned the IDENTICAL bone quaternion (+0.006, +0.002, -0.179, -0.984). replace_existing=True was set and delete_asset returned False even after chmod u+w, because the pawn's constructor hard-references the clips. Importing under a new name (CharacterV2) created fresh assets and the same probe then returned (+0.439, +0.898) — the rotation had landed. Every earlier attempt had been invisible for this reason. The rotation itself is measured, not inherited: sweeping it in Blender gives 0.55 rad for arms down and clear of the body, 0.65 for straight down against the hip, 0.75 for past vertical and into the torso. It lives in the clamp keys, not in the bind: every bind experiment was impossible on this shape-keyed MPFB mesh or left Unreal showing the old bind. Remaining, for 0.2.2: the fingers splay from the bind, and the walk clip has still not been captured in motion.
284 lines
12 KiB
Python
284 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""Author the basic character animation set (0.2.0): idle and walk.
|
|
|
|
Three clips on the MPFB standard rig, sampled from parametric cycles rather than
|
|
hand-keyed poses, so the loop is closed by construction and every key can be
|
|
re-derived from the numbers below:
|
|
|
|
GigLine_Idle 60 frames @ 30 fps — breathing sway, feet planted
|
|
GigLine_Walk 30 frames @ 30 fps — one full stride, left and right contact
|
|
|
|
blender -b Art/blender/character/character-2026-11-07.blend \
|
|
--python Tools/character_anim_build.py -- \
|
|
--out /tmp/gigline/character-anim.fbx \
|
|
--report /tmp/gigline/character-anim-report.json
|
|
|
|
Rotation axes are **measured, not assumed**. A probe on the actual rig rotated
|
|
`upperleg01.L` by 0.5 rad on each axis and read the resulting `foot.L` head:
|
|
|
|
X -> foot Y -0.022 -> +0.362 (fore/aft swing: the axis used here)
|
|
Y -> twist along the leg, foot barely moves
|
|
Z -> foot swings sideways into the other leg
|
|
|
|
The arm probe gives the same answer for `upperarm01`, so limbs swing on local X
|
|
and positive X sends the limb backwards. Everything else (spine lean, head nod)
|
|
uses small X deltas for the same reason.
|
|
|
|
The rig is `pelvis.L → upperleg01 → upperleg02 → lowerleg01 → lowerleg02 → foot`,
|
|
all deform bones, no constraints, so a plain pose-bone rotation deforms the mesh.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import math
|
|
import os
|
|
import sys
|
|
|
|
import bpy
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
import fbx_export_preset as fbx_preset # noqa: E402
|
|
|
|
FPS = 30
|
|
CLIPS = {"Idle": 60, "Walk": 30}
|
|
|
|
# Stride amplitude in radians at the hip. A human walk swings the thigh about
|
|
# 25-30 deg forward and 15-20 back; 0.45 rad (~26 deg) is a normal-pace stride.
|
|
HIP = 0.45
|
|
# Knee flexion during swing, measured from the photographs of a normal gait.
|
|
KNEE = 0.55
|
|
# Arm swing counter-phases the leg; a relaxed walk swings about 20 deg.
|
|
ARM = 0.32
|
|
ELBOW = 0.18
|
|
|
|
PARTS: list[dict] = []
|
|
|
|
# The .blend stores the raw A-pose bind: `mpfb_character_export.py --pose idle`
|
|
# bakes the arms down only in the exported FBX, not in the file. A clip that
|
|
# animates only swing therefore leaves the arms out in an A — which is exactly how
|
|
# the first third-person render looked. These are the same deltas `IDLE_POSE` uses,
|
|
# applied as a base that every key sits on top of.
|
|
# Values are **measured, not inherited**. The rig's A bind already points the upper
|
|
# arm (0.65, 0.01, -0.76), i.e. about 50 degrees below horizontal, so the rotation
|
|
# that brings it vertical is only about 0.55 rad - not the 1.55 rad the old
|
|
# `--pose idle` table used, which over-rotated the arm ~40 degrees past vertical and
|
|
# drove it into the torso. Sweeping the rotation in Blender gave:
|
|
# z=-0.55 -> arm (0.16, -0.02, -0.99) down, slightly clear of the body
|
|
# z=-0.65 -> arm (0.06, -0.02, -1.00) straight down, touching the hip
|
|
# z=-0.75 -> arm (-0.04, -0.03, -1.00) past vertical, into the body
|
|
# 0.55 is the value used.
|
|
BASE = {
|
|
"upperarm01.L": (0.0, 0.0, -0.55),
|
|
"upperarm01.R": (0.0, 0.0, 0.55),
|
|
"lowerarm01.L": (0.0, 0.0, -0.12),
|
|
"lowerarm01.R": (0.0, 0.0, 0.12),
|
|
}
|
|
|
|
|
|
def log(msg):
|
|
PARTS.append(msg)
|
|
print(f"[anim] {msg}")
|
|
|
|
|
|
def clear_animation(arm):
|
|
"""Drop any pre-existing actions so a rerun does not stack clips."""
|
|
for action in list(bpy.data.actions):
|
|
bpy.data.actions.remove(action)
|
|
if arm.animation_data:
|
|
arm.animation_data_clear()
|
|
|
|
|
|
def pose_bones(arm):
|
|
bpy.context.view_layer.objects.active = arm
|
|
bpy.ops.object.mode_set(mode="POSE")
|
|
for pb in arm.pose.bones:
|
|
pb.rotation_mode = "XYZ"
|
|
return arm.pose.bones
|
|
|
|
|
|
def apply_base_pose(arm):
|
|
"""Bake the arms-down pose into the REST pose before authoring any clip.
|
|
|
|
The first two attempts added the arms-down as an offset inside every key. That
|
|
is not what the export sees: the exporter writes clip data relative to the rest
|
|
pose, so an offset that lives only in the keys arrives in Unreal as a small
|
|
delta around an A-posed bind - the body stood in an A with a ±20 degree arm
|
|
swing, which is exactly what the Unreal pose probe measured. Baking the pose
|
|
into the rest pose first is the same trick `mpfb_character_export.py --pose
|
|
idle` uses, and it makes bind and clips agree.
|
|
"""
|
|
bpy.context.view_layer.objects.active = arm
|
|
bpy.ops.object.mode_set(mode="POSE")
|
|
for name, eul in BASE.items():
|
|
bone = arm.pose.bones.get(name)
|
|
if bone is None:
|
|
continue
|
|
bone.rotation_mode = "XYZ"
|
|
bone.rotation_euler = eul
|
|
# Bake the deformation into the MESH before the rest bake. Setting the rest pose
|
|
# alone leaves the vertices in their A shape, so the skin stretches across the
|
|
# shoulders and the arms sink into the torso - which is what the review shots
|
|
# showed. The MPFB mesh carries shape keys and Blender refuses to apply a modifier
|
|
# while they exist, so they are cleared first and the count is logged.
|
|
mesh = next((o for o in bpy.data.objects
|
|
if o.type == "MESH" and any(m.type == "ARMATURE" and m.object == arm
|
|
for m in o.modifiers)), None)
|
|
keys = 0
|
|
if mesh is not None:
|
|
if mesh.data.shape_keys:
|
|
keys = len(mesh.data.shape_keys.key_blocks) - 1
|
|
bpy.ops.object.mode_set(mode="OBJECT")
|
|
bpy.context.view_layer.objects.active = mesh
|
|
bpy.ops.object.select_all(action="DESELECT")
|
|
mesh.select_set(True)
|
|
if mesh.data.shape_keys:
|
|
bpy.ops.object.shape_key_remove(all=True)
|
|
for mod in list(mesh.modifiers):
|
|
if mod.type == "ARMATURE" and mod.object == arm:
|
|
bpy.ops.object.modifier_apply(modifier=mod.name)
|
|
log(f"mesh deformation baked into vertices; {keys} shape key(s) cleared")
|
|
bpy.ops.object.select_all(action="DESELECT")
|
|
bpy.context.view_layer.objects.active = arm
|
|
bpy.ops.object.mode_set(mode="POSE")
|
|
|
|
bpy.ops.pose.armature_apply()
|
|
bpy.ops.object.mode_set(mode="OBJECT")
|
|
|
|
if mesh is not None:
|
|
added = mesh.modifiers.new(name="Armature", type="ARMATURE")
|
|
added.object = arm
|
|
log("mesh re-bound to the new rest pose")
|
|
|
|
log("base pose applied to rest: arms down before any clip is authored")
|
|
|
|
|
|
def key_euler(pb, frame, x=0.0, y=0.0, z=0.0):
|
|
"""Key a pose-bone rotation, offset by the standing base.
|
|
|
|
The bind is left alone: the MPFB mesh carries shape keys, and every attempt to
|
|
re-bind it (clear keys, bake the armature modifier, set rest) either failed or
|
|
left Unreal showing the old bind. Carrying the arm rotation in the clip is the
|
|
one path that is provable: read the imported clip back and check its quaternion.
|
|
"""
|
|
bx, by, bz = BASE.get(pb.name, (0.0, 0.0, 0.0))
|
|
pb.rotation_euler = (bx + x, by + y, bz + z)
|
|
pb.keyframe_insert(data_path="rotation_euler", frame=frame)
|
|
|
|
|
|
def clip_idle(arm, frames):
|
|
"""Breathing sway: spine and head counter-rotate, arms drift, feet planted."""
|
|
bones = pose_bones(arm)
|
|
action = bpy.data.actions.new("GigLine_Idle")
|
|
arm.animation_data_create()
|
|
arm.animation_data.action = action
|
|
|
|
for frame in range(1, frames + 1):
|
|
t = 2.0 * math.pi * (frame - 1) / frames
|
|
breathe = math.sin(t)
|
|
# Chest lifts and settles; the head counter-rotates so the gaze stays level.
|
|
key_euler(bones["spine03"], frame, x=-0.020 * breathe)
|
|
key_euler(bones["spine05"], frame, x=-0.012 * breathe)
|
|
key_euler(bones["neck01"], frame, x=0.010 * breathe)
|
|
key_euler(bones["head"], frame, x=0.008 * breathe)
|
|
# Arms hang and drift a little; nothing swings.
|
|
key_euler(bones["upperarm01.L"], frame, x=0.020 * breathe)
|
|
key_euler(bones["upperarm01.R"], frame, x=-0.020 * breathe)
|
|
|
|
action.use_fake_user = True
|
|
log(f"Idle: {frames} frames, spine/neck/head/arms, feet planted")
|
|
return action
|
|
|
|
|
|
def clip_walk(arm, frames):
|
|
"""One full stride: left contact at frame 1, right contact at the half-cycle."""
|
|
bones = pose_bones(arm)
|
|
action = bpy.data.actions.new("GigLine_Walk")
|
|
arm.animation_data.action = action
|
|
|
|
for frame in range(1, frames + 1):
|
|
t = 2.0 * math.pi * (frame - 1) / frames
|
|
swing = math.sin(t)
|
|
# Hip swing, opposite phase per leg; negative X is forward.
|
|
key_euler(bones["upperleg01.L"], frame, x=-HIP * swing)
|
|
key_euler(bones["upperleg01.R"], frame, x=HIP * swing)
|
|
# The knee only flexes while the leg is swinging forward, so it is driven
|
|
# by the half-rectified swing rather than the raw sine.
|
|
knee_l = KNEE * max(0.0, math.sin(t + math.pi))
|
|
knee_r = KNEE * max(0.0, math.sin(t))
|
|
key_euler(bones["lowerleg01.L"], frame, x=knee_l)
|
|
key_euler(bones["lowerleg01.R"], frame, x=knee_r)
|
|
# Ankles keep the foot roughly level: counter the hip plus half the knee.
|
|
key_euler(bones["foot.L"], frame, x=0.25 * HIP * swing - 0.35 * knee_l)
|
|
key_euler(bones["foot.R"], frame, x=-0.25 * HIP * swing - 0.35 * knee_r)
|
|
# Arms counter-phase the legs, elbows carry a constant relaxed bend.
|
|
key_euler(bones["upperarm01.L"], frame, x=ARM * swing)
|
|
key_euler(bones["upperarm01.R"], frame, x=-ARM * swing)
|
|
key_euler(bones["lowerarm01.L"], frame, x=ELBOW + 0.10 * swing)
|
|
key_euler(bones["lowerarm01.R"], frame, x=ELBOW - 0.10 * swing)
|
|
# Torso counter-rotation: the shoulders turn against the hips.
|
|
key_euler(bones["spine04"], frame, z=0.08 * swing)
|
|
key_euler(bones["neck01"], frame, z=-0.04 * swing)
|
|
|
|
action.use_fake_user = True
|
|
log(f"Walk: {frames} frames, stride {HIP:.2f} rad hip / {KNEE:.2f} knee / {ARM:.2f} arm")
|
|
return action
|
|
|
|
|
|
def main(argv=None) -> int:
|
|
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
|
ap = argparse.ArgumentParser(description=__doc__,
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
ap.add_argument("--out", default="/tmp/gigline/character-anim.fbx")
|
|
ap.add_argument("--report", default=None)
|
|
args = ap.parse_args(argv)
|
|
|
|
arm = next((o for o in bpy.data.objects if o.type == "ARMATURE"), None)
|
|
if arm is None:
|
|
raise SystemExit("no armature in the .blend")
|
|
mesh = next((o for o in bpy.data.objects if o.type == "MESH"), None)
|
|
log(f"rig: {arm.name}, {len(arm.pose.bones)} bones; mesh {mesh.name if mesh else 'none'} "
|
|
f"{len(mesh.data.vertices) if mesh else 0} verts")
|
|
|
|
bpy.context.scene.render.fps = FPS
|
|
clear_animation(arm)
|
|
idle = clip_idle(arm, CLIPS["Idle"])
|
|
walk = clip_walk(arm, CLIPS["Walk"])
|
|
|
|
bpy.ops.object.mode_set(mode="OBJECT")
|
|
# Export armature + mesh together: the preset's character flags carry the
|
|
# bake_anim settings that make Unreal create AnimSequences.
|
|
bpy.ops.object.select_all(action="DESELECT")
|
|
arm.select_set(True)
|
|
if mesh:
|
|
mesh.select_set(True)
|
|
bpy.context.view_layer.objects.active = arm
|
|
|
|
os.makedirs(os.path.dirname(args.out), exist_ok=True)
|
|
fbx_preset.export_character(args.out, selection=True)
|
|
log(f"exported {args.out}")
|
|
|
|
if args.report:
|
|
with open(args.report, "w", encoding="utf-8") as handle:
|
|
json.dump({
|
|
"fps": FPS,
|
|
"rig": arm.name,
|
|
"bones": len(arm.pose.bones),
|
|
"clips": [
|
|
{"action": idle.name, "frames": CLIPS["Idle"]},
|
|
{"action": walk.name, "frames": CLIPS["Walk"]},
|
|
],
|
|
"axis_evidence": "probe: upperleg01.L X 0.5 rad moves foot.L "
|
|
"Y -0.022 -> +0.362; Y twists, Z abducts",
|
|
"amplitudes_rad": {"hip": HIP, "knee": KNEE, "arm": ARM, "elbow": ELBOW},
|
|
"export": os.path.basename(args.out),
|
|
"log": PARTS,
|
|
}, handle, indent=2)
|
|
print(f"[anim] report -> {args.report}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|