The e2e asset check now completes: PASS on all six steps - empty maps 9 s, demo build 10 s, review build 9 s, captures 80 s, judge 388 s, review file. Four breakages from the folder move had to be fixed first: - ue_placement and ue_common are siblings of the scripts that import them, but the rewrite put only the tools root on sys.path, so every Unreal commandlet died with ModuleNotFoundError. Both paths are inserted now. - render_screenshots.sh resolved the repository root one level up, so commandlet paths became Tools/Tools/... and the capture step failed in 0 s. - the driver treated a result file left by an earlier run as success, which is how a ten-second 'demo build' passed without building. Result files are unlinked before each step and child output streams to a per-step log file rather than a pipe. - two editors on one project hang instead of failing, so the driver refuses to start while one is running. Also corrects ue_recreate_maps.py: LevelEditorSubsystem.new_level() refuses a path whose package is still in memory and delete_asset() leaves the file on disk, measured create->delete->create returning False. The step now loads each map, destroys every actor, saves, and reports the count it left (0), which is the property the builders actually need. Open question recorded in TODO.md: the rebuilt map holds 409 actors and 613 KB against 600 and 886 KB before, and the ~190 missing ones look like the facade-kit and theatre-trim placements that run as separate steps.
436 lines
19 KiB
Python
436 lines
19 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/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
|
|
|
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
sys.path.insert(0, os.path.dirname(_HERE)) # tools root, for _common
|
|
sys.path.insert(0, _HERE) # siblings in this folder (ue_common, ue_placement)
|
|
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())
|