Two parametric cycles authored at 30 fps instead of hand-keyed poses, so the loop closes by construction: GigLine_Idle (60 f, breathing sway, feet planted) and GigLine_Walk (30 f, one stride, hip 0.45 rad / knee 0.55 / arm 0.32, arms counter-phased, torso counter-rotation). The limb axes were measured, not assumed: a probe rotated upperleg01.L by 0.5 rad on each axis and read foot.L, which moved -0.022 -> +0.362 in Y on X, twisted on Y and abducted on Z. Using the wrong axis here would have produced a character scissoring sideways, which no import error would have caught. The character export preset now bakes animation. Without bake_anim the exporter writes a static bind pose and Unreal imports a mesh with no AnimSequence and no error, so the importer asserts a clip exists and prints VERDICT: PASS/FAIL. Verified in /Game/Test/Character: Idle 1.967 s and Walk 0.967 s, both AnimSequences, skeleton intact. Playback on a possessed pawn is not yet verified: the demo pawn has no visible body, so that check belongs with the third-person camera item.
199 lines
7.7 KiB
Python
199 lines
7.7 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] = []
|
|
|
|
|
|
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 key_euler(pb, frame, x=0.0, y=0.0, z=0.0):
|
|
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)
|
|
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())
|