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

+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 = []