diff --git a/Docs/Architecture/05_building_block_view.md b/Docs/Architecture/05_building_block_view.md index 0493a9a..d0b0815 100644 --- a/Docs/Architecture/05_building_block_view.md +++ b/Docs/Architecture/05_building_block_view.md @@ -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 | diff --git a/Docs/Architecture/architecture.md b/Docs/Architecture/architecture.md index 6986eb0..1521ec0 100644 --- a/Docs/Architecture/architecture.md +++ b/Docs/Architecture/architecture.md @@ -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 | diff --git a/Game/GigLine/Content/Test/Character/Character_Anim.uasset b/Game/GigLine/Content/Test/Character/Character_Anim.uasset new file mode 100644 index 0000000..c2147e7 --- /dev/null +++ b/Game/GigLine/Content/Test/Character/Character_Anim.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6fc559d3591bf393b3215f3d895f0334276b135c83d8083607b6f829a3eea4cd +size 3756275 diff --git a/Game/GigLine/Content/Test/Character/Character_AnimHuman_rig_GigLine_Idle.uasset b/Game/GigLine/Content/Test/Character/Character_AnimHuman_rig_GigLine_Idle.uasset new file mode 100644 index 0000000..a6addd0 --- /dev/null +++ b/Game/GigLine/Content/Test/Character/Character_AnimHuman_rig_GigLine_Idle.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:50c7c74d0bb736b3cbea7b6e89061d7a33c396f0af1f8171ee577ff14653aa65 +size 425026 diff --git a/Game/GigLine/Content/Test/Character/Character_AnimHuman_rig_GigLine_Walk.uasset b/Game/GigLine/Content/Test/Character/Character_AnimHuman_rig_GigLine_Walk.uasset new file mode 100644 index 0000000..ee37afc --- /dev/null +++ b/Game/GigLine/Content/Test/Character/Character_AnimHuman_rig_GigLine_Walk.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:99f775fb9d8440ce760a14c9b61f00aed9ad05288ad81f64a09a222f98a62ada +size 432706 diff --git a/Game/GigLine/Content/Test/Character/Character_Anim_Skeleton.uasset b/Game/GigLine/Content/Test/Character/Character_Anim_Skeleton.uasset new file mode 100644 index 0000000..497642e --- /dev/null +++ b/Game/GigLine/Content/Test/Character/Character_Anim_Skeleton.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:132ad9242bae52dc18cf98018a54a754b3737881da82431f4804c0ce854640ba +size 29892 diff --git a/TODO.md b/TODO.md index 45acd9c..3f38b99 100644 --- a/TODO.md +++ b/TODO.md @@ -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 diff --git a/Tools/README.md b/Tools/README.md index 0a54062..63b1b04 100644 --- a/Tools/README.md +++ b/Tools/README.md @@ -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 diff --git a/Tools/character_anim_build.py b/Tools/character_anim_build.py new file mode 100644 index 0000000..40b0be1 --- /dev/null +++ b/Tools/character_anim_build.py @@ -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()) diff --git a/Tools/fbx_export_preset.py b/Tools/fbx_export_preset.py index d36ede5..fc2f459 100644 --- a/Tools/fbx_export_preset.py +++ b/Tools/fbx_export_preset.py @@ -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} diff --git a/Tools/tests/test_fbx_export_preset.py b/Tools/tests/test_fbx_export_preset.py index e86013e..585f7be 100644 --- a/Tools/tests/test_fbx_export_preset.py +++ b/Tools/tests/test_fbx_export_preset.py @@ -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") diff --git a/Tools/ue_character_anim_import.py b/Tools/ue_character_anim_import.py new file mode 100644 index 0000000..f30bf91 --- /dev/null +++ b/Tools/ue_character_anim_import.py @@ -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/Binaries/Linux/UnrealEditor-Cmd -run=pythonscript \ +# -script=/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")