#!/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/blender/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.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. # Finger curl, in radians per joint. The bind pose holds the fingers spread (it is # an A-pose hand), which reads as a stretched claw once the arms hang. Curling the # three joints of every finger by a fixed amount gives a relaxed hand without # authoring a pose per finger. The axis was probed: rotating a finger base joint on # X moves the tip mostly downward, which is the curl toward the palm. FINGER_CURL = (0.28, 0.34, 0.22) PARTS: list[dict] = [] def derive_arm_rotation(arm, target_dir=(0.12, 0.0, -1.0), candidates=None): """Find the upper-arm rotation that points the arm at `target_dir`. The 0.2.0 value (0.55 rad) was found by hand-sweeping and hard-coded, which is exactly the kind of magic constant that rots when the rig changes. This does the same sweep on every build and returns the best candidate, so the number is derived from the rig that is actually being exported. """ from mathutils import Euler, Vector candidates = candidates or [0.35 + 0.05 * i for i in range(11)] target = Vector(target_dir).normalized() bpy.context.view_layer.objects.active = arm bpy.ops.object.mode_set(mode="POSE") upper = arm.pose.bones["upperarm01.L"] upper.rotation_mode = "XYZ" best, best_dot = 0.55, -2.0 for z in candidates: upper.rotation_euler = Euler((0.0, 0.0, -z)) bpy.context.view_layer.update() direction = (upper.matrix.to_3x3() @ Vector((0.0, 1.0, 0.0))).normalized() dot = direction.dot(target) if dot > best_dot: best, best_dot = z, dot upper.rotation_euler = Euler((0.0, 0.0, 0.0)) bpy.ops.object.mode_set(mode="OBJECT") log(f"arm rotation derived: {best:.2f} rad (direction dot {best_dot:.3f})") return best 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}") HAIR_MATERIAL = "M_Character_Hair" def add_hair_cap(arm, mesh): """Add a short-hair cap conforming to the head, skinned to the head bone. The character exported from MPFB is bald, and a bald head reads as a mannequin before anything else does. A cap plus a strand texture is enough at review distance; hair cards and a groomed cut are a later pass. """ head = arm.data.bones.get("head") if head is None or mesh is None: log("hair: no head bone or no mesh - skipped") return None centre = (head.head_local + head.tail_local) * 0.5 radius = max(0.085, (head.tail_local - head.head_local).length * 0.62) bpy.ops.mesh.primitive_uv_sphere_add(segments=24, ring_count=14, radius=radius, location=(centre.x, centre.y, centre.z + 0.012)) cap = bpy.context.active_object cap.name = "HairCap" # Keep the upper hemisphere plus a little: a full sphere reads as a helmet. cut_z = centre.z - radius * 0.12 bpy.ops.object.mode_set(mode="EDIT") bpy.ops.mesh.select_all(action="SELECT") bpy.ops.mesh.bisect(plane_co=(0.0, 0.0, cut_z), plane_no=(0.0, 0.0, 1.0), clear_inner=True, use_fill=False) bpy.ops.object.mode_set(mode="OBJECT") # Slightly ovoid, wider than deep, sitting back on the skull. cap.scale = (1.06, 1.10, 0.92) hair_mat = bpy.data.materials.get(HAIR_MATERIAL) or bpy.data.materials.new(HAIR_MATERIAL) hair_mat.use_nodes = True bsdf = hair_mat.node_tree.nodes.get("Principled BSDF") if bsdf: bsdf.inputs["Base Color"].default_value = (0.055, 0.038, 0.030, 1.0) bsdf.inputs["Roughness"].default_value = 0.42 cap.data.materials.append(hair_mat) bpy.ops.object.select_all(action="DESELECT") cap.select_set(True) mesh.select_set(True) bpy.context.view_layer.objects.active = mesh bpy.ops.object.join() log(f"hair: cap added at head centre, radius {radius:.3f} m, material {HAIR_MATERIAL}") return mesh 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") BASE = {} def fill_base(arm): """Populate the standing base: derived arm angle plus the finger curl.""" arm_z = derive_arm_rotation(arm) BASE.update({ "upperarm01.L": (0.0, 0.0, -arm_z), "upperarm01.R": (0.0, 0.0, arm_z), "lowerarm01.L": (0.0, 0.0, -0.12), "lowerarm01.R": (0.0, 0.0, 0.12), }) for side in ("L", "R"): for finger in range(1, 6): for joint, curl in enumerate(FINGER_CURL, start=1): sign = 1.0 if side == "L" else -1.0 BASE[f"finger{finger}-{joint}.{side}"] = (sign * curl, 0.0, 0.0) log(f"standing base: {len(BASE)} bones (arm {arm_z:.2f} rad, " f"finger curl {FINGER_CURL})") 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) # Key the standing base on every bone it touches, at both ends of the clip, so # the arms and fingers hold their offset instead of snapping back to the bind. for name in BASE: bone = bones.get(name) if bone is None: continue key_euler(bone, 1) key_euler(bone, frames) 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 # cos, not sin: the clip starts at maximum stride. A sine starts and crosses # zero twice per cycle, and the review capture kept landing on those passing # frames - legs together, which reads as a still. The cycle is unchanged. swing = math.cos(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) for name in BASE: bone = bones.get(name) if bone is None: continue key_euler(bone, 1) key_euler(bone, frames) 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) # Hair is built but not exported yet: Unreal's import collapses the second # material slot, so the cap renders with the skin material and reads as a bald # skull. Off until the hair is a separate asset (see TODO.md). ap.add_argument("--hair", action="store_true") 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) fill_base(arm) idle = clip_idle(arm, CLIPS["Idle"]) walk = clip_walk(arm, CLIPS["Walk"]) # Hair before export so the cap is part of the skinned mesh; a separate asset # would need its own bone attachment and a second material assignment. verts_before = len(mesh.data.vertices) if mesh else 0 if args.hair: add_hair_cap(arm, mesh) else: log("hair: skipped (--hair to include; blocked on material slots, see TODO.md)") if mesh and len(mesh.data.vertices) > verts_before: group = mesh.vertex_groups.get("head") or mesh.vertex_groups.new(name="head") group.add(list(range(verts_before, len(mesh.data.vertices))), 1.0, "REPLACE") log(f"hair: {len(mesh.data.vertices) - verts_before} vertices weighted to 'head'") 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())