#!/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())