Files
gig-line/Tools/character_anim_build.py
T
zwitschi 46b84884b8 fix(character): the arms hang at the sides — and why that took so long
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.
2026-11-09 11:42:36 +01:00

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())