The body stood in an A-pose while the log said the clip was playing. Two independent defects, found by querying the imported asset instead of guessing: 1. PlayAnimation needs the component in AnimationSingleNode with a live single-node instance. The body is hidden at construction and only unhidden by the camera toggle, so the first call could land before the component was ready, and the Wanted == PlayingAnimation guard then returned forever. UpdateBodyAnimation now sets the mode, calls SetAnimation + Play, and re-asserts while the instance is missing. 2. The arms-down offsets lived only in the clip keys. The exporter writes clip data relative to the rest pose, so they arrived as a ±20 degree swing around an A-posed bind - which is exactly what the pose probe measured. apply_base_pose now bakes them into the rest pose before any clip is authored, the same trick mpfb_character_export.py --pose idle uses. The table is applied with inverted signs: as written, the FBX axis mapping produced arms raised in a V. Shot 6 confirms it: the arms hang at the sides. Still open and recorded - the idle pose reads awkwardly (arms forward, hands splayed) and the walk clip has no shot in motion; both are marked in TODO.md and Screenshots/README.md. Instrumentation removed; the diagnostics that remain are behind gigline.SetupLogs.
243 lines
9.8 KiB
Python
243 lines
9.8 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.
|
|
BASE = {
|
|
"shoulder01.L": (0.0, -0.15, -0.03),
|
|
"shoulder01.R": (0.0, 0.15, 0.03),
|
|
"upperarm01.L": (0.0, 0.0, -1.55),
|
|
"upperarm01.R": (0.0, 0.0, 1.55),
|
|
"lowerarm01.L": (0.0, 0.0, -0.10),
|
|
"lowerarm01.R": (0.0, 0.0, 0.10),
|
|
}
|
|
|
|
|
|
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")
|
|
# The sign is inverted relative to the original `--pose idle` table: the first
|
|
# attempt applied BASE as written and the body came out with its arms raised in
|
|
# a V, i.e. the FBX axis mapping flips this rotation. Verified on the imported
|
|
# asset rather than guessed - see MEMORY.md, 2026-11-09.
|
|
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[0], -eul[1], -eul[2])
|
|
bpy.ops.pose.armature_apply()
|
|
bpy.ops.object.mode_set(mode="OBJECT")
|
|
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; the arms-down base is already in the rest pose."""
|
|
pb.rotation_euler = (x, y, 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)
|
|
apply_base_pose(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())
|