fix(character): the arms hang at the sides — and why that took so long

The character now stands naturally: arms at its sides with a soft elbow bend, hands
at the hips, feet flat. Both character shots confirm it, front and back.

The root cause was not the pose. The FBX importer had been silently refusing to
replace the existing assets: after changing the arm base from 1.55 rad to 0.55 rad
and rebuilding, the imported clip returned the IDENTICAL bone quaternion
(+0.006, +0.002, -0.179, -0.984). replace_existing=True was set and delete_asset
returned False even after chmod u+w, because the pawn's constructor hard-references
the clips. Importing under a new name (CharacterV2) created fresh assets and the
same probe then returned (+0.439, +0.898) — the rotation had landed. Every earlier
attempt had been invisible for this reason.

The rotation itself is measured, not inherited: sweeping it in Blender gives 0.55 rad
for arms down and clear of the body, 0.65 for straight down against the hip, 0.75 for
past vertical and into the torso. It lives in the clamp keys, not in the bind: every
bind experiment was impossible on this shape-keyed MPFB mesh or left Unreal showing
the old bind.

Remaining, for 0.2.2: the fingers splay from the bind, and the walk clip has still
not been captured in motion.
This commit is contained in:
zwitschi committed 2026-11-09 11:42:36 +01:00
1 parent 8993fd83c4
commit 46b84884b8
45 files changed
+131 -67

No files matched your search

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -58,7 +58,7 @@ AGigLineCharacter::AGigLineCharacter()
// clips resolve without a retarget step; it is hidden while the first-person
// camera is active and shown in third person.
static ConstructorHelpers::FObjectFinder<USkeletalMesh> BodyFinder(
TEXT("/Game/Test/Character/Character_Anim.Character_Anim"));
TEXT("/Game/Test/Character/CharacterV2.CharacterV2"));
if (BodyFinder.Succeeded())
{
GetMesh()->SetSkeletalMesh(BodyFinder.Object);
@@ -72,13 +72,13 @@ AGigLineCharacter::AGigLineCharacter()
GetMesh()->SetMaterial(0, SkinFinder.Object);
}
static ConstructorHelpers::FObjectFinder<UAnimSequence> IdleFinder(
TEXT("/Game/Test/Character/Character_AnimHuman_rig_GigLine_Idle.Character_AnimHuman_rig_GigLine_Idle"));
TEXT("/Game/Test/Character/CharacterV2Human_rig_GigLine_Idle.CharacterV2Human_rig_GigLine_Idle"));
if (IdleFinder.Succeeded())
{
IdleAnimation = IdleFinder.Object;
}
static ConstructorHelpers::FObjectFinder<UAnimSequence> WalkFinder(
TEXT("/Game/Test/Character/Character_AnimHuman_rig_GigLine_Walk.Character_AnimHuman_rig_GigLine_Walk"));
TEXT("/Game/Test/Character/CharacterV2Human_rig_GigLine_Walk.CharacterV2Human_rig_GigLine_Walk"));
if (WalkFinder.Succeeded())
{
WalkAnimation = WalkFinder.Object;
+8
View File
@@ -478,3 +478,11 @@ Scope: facts and traps that cost time to establish. Planned work lives in [TODO.
- **The base pose needs inverted signs when baked to rest.** Applied as written, the FBX axis mapping produced arms raised in a V; negating the table gives arms down. Both states were verified on the imported asset, not assumed.
- **Unreal bone names use underscores**: `upperarm01_L`, not Blender's `upperarm01.L`. `GetBoneIndex(TEXT("upperarm01.L"))` returns INDEX_NONE, `GetBoneTransform` then returns identity, and a probe reading it reports a constant 0.0000 — which looks like "animation not applied" and is actually "bone not found". Check `AnimPose.get_bone_names()` before trusting any bone probe.
- **`Log` verbosity does not reach the capture log, `Warning` does.** Two diagnostic runs produced zero lines because the messages were `UE_LOG(LogTemp, Log, ...)`; the same lines at `Warning` appeared. Instrument with Warning when the evidence has to survive into `/tmp/gigline/render-*.log`.
## 2026-11-09 — Unreal silently refuses to replace an imported asset
- **The real reason the character pose never changed across a dozen rebuilds:** the FBX importer reported success every time, but `Character_Anim` and its two AnimSequences kept their original data. Proof: after changing the arm base from 1.55 rad to 0.55 rad and rebuilding, `AnimPose.get_bone_pose("upperarm01_L")` returned the **identical** quaternion `(+0.006, +0.002, -0.179, -0.984)` as before. An asset that does not change when its source does is not being written.
- `AssetImportTask.replace_existing = True` was set, and `EditorAssetLibrary.delete_asset` returned **False** for both clips, even after `chmod u+w` (the `.uasset` files are LFS-lockable and check out read-only). Deleting is blocked because the pawn's C++ constructor hard-references the clips.
- **Workaround that works: import under a new name.** `Tools/ue_character_anim_import.py` now uses `PREFIX = "CharacterV2"`, which creates `CharacterV2`, `CharacterV2_Skeleton` and `CharacterV2Human_rig_GigLine_{Idle,Walk}` fresh; the same quaternion probe then returned `(+0.439, +0.898)`, i.e. the 0.55 rad arm rotation had finally landed.
- **Check the asset, not the importer.** A pose or material change that does not alter the probed asset data did not happen, whatever the log says. Probing a clip's bone quaternion takes one commandlet and settles it in seconds.
- **The arms-down rotation belongs in the clips, not in the bind.** Every bind experiment (bake pose to rest, clear shape keys, apply the armature modifier, re-bind) was either impossible on this shape-keyed MPFB mesh or left Unreal showing the old bind. `Tools/character_anim_build.py` now carries a measured base offset in the clip keys (`BASE`: 0.55 rad on `upperarm01`, chosen by sweeping the rotation in Blender and reading the bone direction), and the character stands with its arms at its sides.
+4 -4
View File
@@ -106,11 +106,11 @@ Known gaps in this first pass, recorded rather than hidden:
What this shot is evidence for:
- The pawn carries a visible skeletal body with the warm skin material, and a spring-arm camera with collision testing. The **V** toggle swaps first and third person; the shipped default stays first person and the review passes `-thirdperson`.
- **The idle clip drives the body and the arms hang at the sides.** Earlier captures of this shot showed a bind A-pose.
- **The idle clip drives the body and the arms hang at the sides**, with the hands at the hips and the feet flat. Earlier captures of this shot showed a bind A-pose.
Still open, tracked in `TODO.md`:
- The arms hang close enough to clip into the torso at the shoulder, and the fingers splay slightly. That is the `0.2.2` pose-and-skin tuning pass, not this rig.
- The fingers splay slightly, which is the bind's finger spread carried into the pose. That is the `0.2.2` pose-and-skin tuning pass, not this rig.
- **Camera collision is implemented but unproven:** the spawn has open space behind it, so no shot exercises the arm shortening.
---
@@ -126,7 +126,7 @@ Still open, tracked in `TODO.md`:
**Why the shot exists:** the third-person shot only ever shows the pawn's back, so a pose defect on the front — arms out, hands splayed, a face pointing the wrong way — was invisible in review. This angle is the check for it.
What it shows: the arms hang down with a soft elbow bend and the hands sit at hip height. What it still shows wrong: the upper arms sit close enough to the torso to clip at the shoulder, and the fingers point outward-forward rather than hanging relaxed.
What it shows: the arms hang down with a soft elbow bend, the hands sit at hip height, and the upper arms clear the torso. What it still shows wrong: the fingers are spread rather than relaxed, carried over from the bind pose.
---
@@ -159,7 +159,7 @@ Every issue is tracked in `TODO.md` under the version that will fix it; an issue
| Nothing may stand in front of the hero façade that blocks it — no awning, no temporary structure | `0.9.0` → per-building models, hero façade | Open |
| Retail parapet has no moulded profile, and wall lettering built as a plate does not read at 40 m | `0.9.0` → 6800 Lee Drug and 6806–6820, second pass | Open (moved out of `0.1.6`) |
| 6822 Budget is a bare blockout volume — no photograph exists to model it from | `0.9.0` → photography gap | Deferred (moved out of `0.1.6`) |
| Third-person idle pose reads awkwardly: arms hang forward and the hands splay, because the base pose is applied with inverted signs to compensate for the FBX axis flip | `0.2.2` → skin and animation tuning | Open |
| Third-person fingers splay rather than relax — the bind's finger spread is carried into the pose | `0.2.2` → skin and animation tuning | Open |
| Camera collision is implemented but never exercised against a wall | `0.2.0` → camera collision | Open |
| The walk clip has never been captured in motion — it imports and its curves carry the stride, but no shot shows it | `0.2.0` → verify animation plays correctly | Open |
| Between kerb and building line the walkable surface is the terrain mesh, not a modelled pavement | `0.6.0` → model the pavement between kerb and building line | Open |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+4 -1
View File
@@ -17,7 +17,10 @@ Actionable task list for this project. This file is the single, central place fo
- [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.
- [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).
- [~] Verify animation plays correctly on the imported rig. **Playback works since 2026-11-09**: the clip drives the body and the arms hang at the sides (shots 6 and 7). Three defects were found and fixed, each by measuring rather than guessing: (1) `PlayAnimation` on a hidden component never took effect, so the code now sets `AnimationSingleNode`, calls `SetAnimation` + `Play`, and re-asserts while the single-node instance is missing; (2) the arms-down offsets lived only in the clip keys and the exporter writes clip data relative to the rest pose, so they arrived as a ±20° swing around an A-posed bind — they are baked into the rest pose before the clips are authored; (3) a rest-pose bake alone does **not** move the mesh, because the MPFB mesh carries shape keys, the armature modifier cannot be applied while they exist, and the vertices stayed in the A shape (measured: X span unchanged at 1.051 m) — baking the deformation into the vertices, clearing the shape keys and re-binding is what made the bind match the clips. **Still open — the pose is not settled:** the base pose is applied with inverted signs to compensate for the FBX axis mapping, the upper arms clip into the torso, and the fingers splay. An aim-based pose module (`Tools/character_pose.py`) was attempted and removed: applied without the vertex bake it produced arms raised in a V, and a half-working shared module is worse than none. The proper solve belongs to `0.2.2`: build the character with the standing pose applied at creation (the `mpfb_character_export.py --pose idle` path), then author clips against that bind.
- [x] Verify animation plays correctly on the imported rig. **Fixed 2026-11-09; the character stands with its arms at its sides** (shots 6 and 7). Three defects, each proven by measurement:
- `PlayAnimation` on a hidden component never took effect — the code sets `AnimationSingleNode`, calls `SetAnimation` + `Play`, and re-asserts while the single-node instance is missing.
- The arms-down rotation had to live in the **clips**, not the bind: every bind experiment was impossible on the shape-keyed MPFB mesh or left Unreal showing the old bind.
- **The importer had been silently refusing to replace the existing assets.** Rebuilding with a different arm angle returned the identical clip quaternion every time; importing under a new name (`CharacterV2`) finally landed it. The rotation itself was measured by sweeping it in Blender and reading the bone direction: 0.55 rad gives arms down and clear of the body, 0.75 rad drives them into the torso. Remaining polish, for `0.2.2`: the fingers splay slightly, and the walk clip has still not been captured in motion.
- [x] Capture the body from a second angle so a front-facing pose defect is visible. `demo-third-person-front` added to the shot list and to `Screenshots/README.md`; the third-person shot alone only showed the pawn's back.
### Camera and controls
+55 -14
View File
@@ -60,13 +60,20 @@ PARTS: list[dict] = []
# 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 = {
"shoulder01.L": (0.0, -0.15, -0.03),
"shoulder01.R": (0.0, 0.15, 0.03),
"upperarm01.L": (0.0, 0.0, -1.55),
"upperarm01.R": (0.0, 0.0, 1.55),
"lowerarm01.L": (0.0, 0.0, -0.10),
"lowerarm01.R": (0.0, 0.0, 0.10),
"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),
}
@@ -104,24 +111,59 @@ def apply_base_pose(arm):
"""
bpy.context.view_layer.objects.active = arm
bpy.ops.object.mode_set(mode="POSE")
# The sign is inverted relative to the original `--pose idle` table: the first
# attempt applied BASE as written and the body came out with its arms raised in
# a V, i.e. the FBX axis mapping flips this rotation. Verified on the imported
# asset rather than guessed - see MEMORY.md, 2026-11-09.
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[0], -eul[1], -eul[2])
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")
def key_euler(pb, frame, x=0.0, y=0.0, z=0.0):
"""Key a pose-bone rotation; the arms-down base is already in the rest pose."""
pb.rotation_euler = (x, y, z)
"""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)
@@ -201,7 +243,6 @@ def main(argv=None) -> int:
bpy.context.scene.render.fps = FPS
clear_animation(arm)
apply_base_pose(arm)
idle = clip_idle(arm, CLIPS["Idle"])
walk = clip_walk(arm, CLIPS["Walk"])
+1 -1
View File
@@ -19,7 +19,7 @@ import unreal
SRC = "/tmp/gigline/character-anim.fbx"
DEST = "/Game/Test/Character"
PREFIX = "Character_Anim"
PREFIX = "CharacterV2"
RESULT = "/tmp/gigline/character-anim-import.txt"
lines = []