One directory with sixty scripts stopped being navigable, and the grouping is also the sandbox boundary:
Tools/unreal/ commandlets and the two map builders, with ue_common.py and ue_placement.py as the
shared imports (they need the engine)
Tools/blender/ builders, exporters, the FBX preset, the MPFB character pipeline, blender_screenshots
(they need the Blender Flatpak)
Tools/review/ the visual loop: render_screenshots.sh, judge_screenshot.py, hero_compare.py,
build_comparison_md.py, e2e_asset_check.py, hero_iterate.sh, visual_loop.py,
visual_targets.json
Tools/gen/ image and texture generation: the ComfyUI driver, deterministic texture writers,
reference fetching
Tools/input/ OS-level input injection
root _common.py, check-lfs.sh, addon_compat_probe.py, sibling_asset_audit.py, tests/
Every moved script now resolves the repository root as ../.. and puts the tools root plus its siblings on
sys.path, so 'import ue_common' still works from unreal/. The test suite bootstrap does the same for the five
folders. render_screenshots.sh takes the folder as a third argument to run_cmdlet, and the e2e driver,
hero_iterate.sh and the comparison generator were repointed.
51 README rows now link to their new location, the README gains a Layout section naming each folder's
sandbox, and the architecture notes and Blender README follow the moves.
Verified: 108 tests pass, prettier is clean, judge_screenshot.py and hero_compare.py run from their new
homes, and the e2e run was aborted mid-way (it had been rebuilding an empty DemoMap for 15 minutes) and is
being rerun against the new layout.
434 lines
18 KiB
Python
434 lines
18 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
|
|
|
|
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())
|