feat(anim): idle and walk clips on the MPFB rig
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.
This commit is contained in:
1 parent
817313aabd
commit
96238bdb6a
12 files changed
+343
-2
No files matched your search
@@ -56,6 +56,7 @@ Python scripts in `Tools/` drive the content pipeline:
|
||||
| --- | --- |
|
||||
| `export_*`, `hero_facade_build` | Blender headless export (FBX) |
|
||||
| `ue_import_*`, `ue_hero_facade_import` | Unreal headless import + placement |
|
||||
| `character_anim_build`, `ue_character_anim_import` | Character animation clips (`GigLine_Idle`, `GigLine_Walk`) and their import |
|
||||
| `ue_build_demo_map`, `ue_build_review_map` | Map assembly (lighting, meshes, player start) |
|
||||
| `ue_create_input_assets` | Enhanced Input actions + mapping context |
|
||||
| `render_screenshots.sh` | Screenshot capture pipeline |
|
||||
|
||||
@@ -200,6 +200,7 @@ Python scripts in `Tools/` drive the content pipeline:
|
||||
| --- | --- |
|
||||
| `export_*`, `hero_facade_build` | Blender headless export (FBX) |
|
||||
| `ue_import_*`, `ue_hero_facade_import` | Unreal headless import + placement |
|
||||
| `character_anim_build`, `ue_character_anim_import` | Character animation clips (`GigLine_Idle`, `GigLine_Walk`) and their import |
|
||||
| `ue_build_demo_map`, `ue_build_review_map` | Map assembly (lighting, meshes, player start) |
|
||||
| `ue_create_input_assets` | Enhanced Input actions + mapping context |
|
||||
| `render_screenshots.sh` | Screenshot capture pipeline |
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -26,8 +26,8 @@ Suggested blender skills: **references** (reference-image-match), **archviz**, *
|
||||
- [x] Generate MPFB human base mesh; export baked materials and deform skeleton. Done in bootstrap: idle-pose 19 158-vert human with a 137-bone standard rig, `Tools/mpfb_character_export.py` (now `--pose idle` + warm `make_diffuse`).
|
||||
- [x] Import character into Unreal; validate skeleton, scale, and materials. Done: `/Game/Test/Character` — `SkeletalMesh` (138 bones), `Skeleton`, warm `M_Character_Export` material with baked `T_Character_Diffuse`.
|
||||
- [x] Promote the character asset into a reusable NPC prefab (visible body, collision capsule, idle pose) ready for pedestrians (`0.5.0`) once the third-person body lands. Done 2026-11-07: `AGigLineNPCPerson` (see the 0.1.0 Character-development item); `0.5.0` reuses it as the pedestrian base.
|
||||
- [A] Create basic idle/walk animation set.
|
||||
- [ ] Verify animation plays correctly on the imported rig.
|
||||
- [x] Create basic idle/walk animation set. Done 2026-11-09: `Tools/character_anim_build.py` authors `GigLine_Idle` (60 f) and `GigLine_Walk` (30 f) at 30 fps from parametric cycles, and `Tools/ue_character_anim_import.py` imports them. Both AnimSequences verified in `/Game/Test/Character`: Idle 1.967 s, Walk 0.967 s. Limb axes were **measured** on the rig, not assumed — the probe rotated `upperleg01.L` 0.5 rad per axis and read `foot.L`, which showed X is the fore/aft swing (Y twists, Z abducts).
|
||||
- [A] Verify animation plays correctly on the imported rig. Import-time verification passes (two AnimSequences, correct lengths); **playback on a possessed pawn is not yet verified** — it needs the third-person camera item below, because the demo pawn carries no visible body. Visual confirmation lands with that item.
|
||||
|
||||
### Camera and controls
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ Geographic tools live in [`Geo/tools/`](../Geo/README.md) and the Blender-side b
|
||||
| Tool | Purpose |
|
||||
| --- | --- |
|
||||
| `hero_facade_build.py` | Parametric hero façade; emits a JSON report naming the evidence behind every part |
|
||||
| `character_anim_build.py` | Authors the basic animation set on the MPFB rig — `GigLine_Idle` (60 f) and `GigLine_Walk` (30 f) sampled from parametric cycles at 30 fps — and exports one animated FBX with the `ue_character_v1` preset. Rotation axes are measured on the rig, not assumed (probe in the docstring). `--out`, `--report`. |
|
||||
| `mpfb_character_export.py` | MPFB human, standard rig, baked skin, FBX export |
|
||||
|
||||
## Unreal import and level building
|
||||
@@ -53,6 +54,7 @@ Geographic tools live in [`Geo/tools/`](../Geo/README.md) and the Blender-side b
|
||||
| `ue_build_demo_map.py` | Builds `/Game/Hollywood/DemoMap`: lighting, façade, site meshes, player start |
|
||||
| `ue_build_review_map.py` | Rebuilds `Test/ReviewMap`, stages the shot camera list, and stages the demo-map views |
|
||||
| `ue_create_input_assets.py` | Creates `/Game/Input` actions and the mapping context headlessly |
|
||||
| `ue_character_anim_import.py` | Imports the animated FBX into `/Game/Test/Character` and **verifies an AnimSequence exists** — an FBX exported without `bake_anim` imports a mesh and no clips, with no error. Writes `VERDICT: PASS/FAIL` to `/tmp/gigline/character-anim-import.txt`. |
|
||||
| `ue_character_import.py` | Skeletal import plus the material/texture chain Interchange skips |
|
||||
|
||||
## Visual review
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
#!/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())
|
||||
@@ -49,12 +49,25 @@ _METRIC = {
|
||||
}
|
||||
|
||||
# Character exports differ only in the object set and the armature/path flags.
|
||||
#
|
||||
# Animation export (added for the 0.2.0 animation set): without `bake_anim` the
|
||||
# exporter silently writes a static bind pose even when the armature carries
|
||||
# actions, and Unreal then imports a character with no AnimSequence and no error.
|
||||
# `bake_anim_use_all_actions` exports every action as its own clip; NLA strips are
|
||||
# off because the project does not author NLA. `bake_anim_simplify_factor=0.0`
|
||||
# keeps the sampled keys exactly as authored - the clips are short, so the size
|
||||
# cost is irrelevant and a simplified curve would drift from the source poses.
|
||||
_CHARACTER = dict(
|
||||
_METRIC,
|
||||
object_types={"ARMATURE", "MESH"},
|
||||
add_leaf_bones=False,
|
||||
path_mode="COPY",
|
||||
embed_textures=False,
|
||||
bake_anim=True,
|
||||
bake_anim_use_all_actions=True,
|
||||
bake_anim_use_nla_strips=False,
|
||||
bake_anim_force_startend_keying=True,
|
||||
bake_anim_simplify_factor=0.0,
|
||||
)
|
||||
|
||||
PRESETS = {"ue_metric_v1": _METRIC, "ue_character_v1": _CHARACTER}
|
||||
|
||||
@@ -52,6 +52,24 @@ class CharacterPresetTest(unittest.TestCase):
|
||||
self.assertEqual(kwargs["mesh_smooth_type"], "FACE")
|
||||
|
||||
|
||||
class CharacterAnimationTest(unittest.TestCase):
|
||||
"""The animation flags are the difference between a clip and a silent bind pose."""
|
||||
|
||||
def test_bakes_animation(self):
|
||||
kwargs = preset.preset_kwargs("ue_character_v1")
|
||||
self.assertTrue(kwargs["bake_anim"], "without bake_anim Unreal imports no AnimSequence")
|
||||
self.assertTrue(kwargs["bake_anim_use_all_actions"])
|
||||
self.assertFalse(kwargs["bake_anim_use_nla_strips"])
|
||||
|
||||
def test_does_not_simplify_authored_keys(self):
|
||||
kwargs = preset.preset_kwargs("ue_character_v1")
|
||||
self.assertEqual(kwargs["bake_anim_simplify_factor"], 0.0)
|
||||
|
||||
def test_static_preset_stays_animation_free(self):
|
||||
kwargs = preset.preset_kwargs("ue_metric_v1")
|
||||
self.assertNotIn("bake_anim", kwargs)
|
||||
|
||||
|
||||
class OverrideTest(unittest.TestCase):
|
||||
def test_overrides_win(self):
|
||||
kwargs = preset.preset_kwargs(mesh_smooth_type="OFF")
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
# Import the character animation clips into Unreal (0.2.0).
|
||||
#
|
||||
# blender -b Art/blender/character/character-2026-11-07.blend \
|
||||
# --python Tools/character_anim_build.py -- --out /tmp/gigline/character-anim.fbx
|
||||
# <engine>/Engine/Binaries/Linux/UnrealEditor-Cmd <uproject> -run=pythonscript \
|
||||
# -script=<repo>/Tools/ue_character_anim_import.py -nullrhi -unattended -nopause -stdout
|
||||
#
|
||||
# Imports the animated FBX next to the existing character assets and verifies the
|
||||
# result rather than assuming it: an FBX whose export omitted `bake_anim` imports
|
||||
# without error and produces a skeletal mesh but no AnimSequence, which is exactly
|
||||
# the failure this tool must catch.
|
||||
#
|
||||
# Destination: /Game/Test/Character. Result file: /tmp/gigline/character-anim-import.txt
|
||||
|
||||
import glob
|
||||
import os
|
||||
|
||||
import unreal
|
||||
|
||||
SRC = "/tmp/gigline/character-anim.fbx"
|
||||
DEST = "/Game/Test/Character"
|
||||
PREFIX = "Character_Anim"
|
||||
RESULT = "/tmp/gigline/character-anim-import.txt"
|
||||
lines = []
|
||||
|
||||
|
||||
def log(msg):
|
||||
lines.append(str(msg))
|
||||
unreal.log(str(msg))
|
||||
|
||||
|
||||
def main():
|
||||
if not os.path.exists(SRC):
|
||||
log(f"missing {SRC}; run Tools/character_anim_build.py first")
|
||||
return
|
||||
|
||||
task = unreal.AssetImportTask()
|
||||
task.filename = SRC
|
||||
task.destination_path = DEST
|
||||
task.destination_name = PREFIX
|
||||
task.automated = True
|
||||
task.save = True
|
||||
task.replace_existing = True
|
||||
|
||||
ui = unreal.FbxImportUI()
|
||||
ui.import_mesh = True
|
||||
ui.import_as_skeletal = True
|
||||
ui.import_animations = True
|
||||
ui.import_materials = True
|
||||
ui.import_textures = False
|
||||
# UE 5.8 has no SKELETON_ONLY content type (FBXICT_ALL/GEOMETRY/SKINNING_WEIGHTS
|
||||
# only), so the clips come in with the mesh and the verification below decides
|
||||
# whether that worked. The mesh is replaced at the same path by design.
|
||||
ui.skeletal_mesh_import_data.import_uniform_scale = 1.0
|
||||
task.options = ui
|
||||
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
|
||||
|
||||
# Verify: the clips are the deliverable, not the mesh.
|
||||
registry = unreal.AssetRegistryHelpers.get_asset_registry()
|
||||
found = []
|
||||
for asset in registry.get_assets_by_path(DEST, recursive=False):
|
||||
name = str(asset.asset_name)
|
||||
if not name.startswith(PREFIX):
|
||||
continue
|
||||
obj = unreal.EditorAssetLibrary.load_asset(f"{DEST}/{name}")
|
||||
if isinstance(obj, unreal.AnimSequence):
|
||||
length = obj.get_play_length()
|
||||
# UE 5.8 exposes only play length and sequence length here; the sampled
|
||||
# frame count is derived so the report still states the clip duration.
|
||||
frames = round(length * 30.0)
|
||||
found.append(name)
|
||||
log(f"AnimSequence {name:34} {length:.3f} s (~{frames} frames at 30 fps)")
|
||||
elif isinstance(obj, unreal.SkeletalMesh):
|
||||
skeleton = obj.get_editor_property("skeleton")
|
||||
log(f"SkeletalMesh {name:34} skeleton={'ok' if skeleton else 'MISSING'}")
|
||||
|
||||
if not found:
|
||||
log("FAILED: no AnimSequence was created — the FBX carried no baked animation")
|
||||
lines.append("VERDICT: FAIL")
|
||||
return
|
||||
log(f"{len(found)} AnimSequence(s) imported: {', '.join(sorted(found))}")
|
||||
lines.append("VERDICT: PASS")
|
||||
|
||||
|
||||
try:
|
||||
main()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
import traceback
|
||||
log(f"FAILED: {type(exc).__name__}: {exc}")
|
||||
log(traceback.format_exc())
|
||||
lines.append("VERDICT: FAIL")
|
||||
|
||||
unreal.EditorLoadingAndSavingUtils.save_dirty_packages(True, True)
|
||||
os.makedirs(os.path.dirname(RESULT), exist_ok=True)
|
||||
with open(RESULT, "w", encoding="utf-8") as handle:
|
||||
handle.write("\n".join(lines) + "\n")
|
||||
Reference in new issue
Block a user