fix(anim): the clip now drives the body — two causes, both measured
The body stood in an A-pose while the log said the clip was playing. Two independent defects, found by querying the imported asset instead of guessing: 1. PlayAnimation needs the component in AnimationSingleNode with a live single-node instance. The body is hidden at construction and only unhidden by the camera toggle, so the first call could land before the component was ready, and the Wanted == PlayingAnimation guard then returned forever. UpdateBodyAnimation now sets the mode, calls SetAnimation + Play, and re-asserts while the instance is missing. 2. The arms-down offsets lived only in the clip keys. The exporter writes clip data relative to the rest pose, so they arrived as a ±20 degree swing around an A-posed bind - which is exactly what the pose probe measured. apply_base_pose now bakes them into the rest pose before any clip is authored, the same trick mpfb_character_export.py --pose idle uses. The table is applied with inverted signs: as written, the FBX axis mapping produced arms raised in a V. Shot 6 confirms it: the arms hang at the sides. Still open and recorded - the idle pose reads awkwardly (arms forward, hands splayed) and the walk clip has no shot in motion; both are marked in TODO.md and Screenshots/README.md. Instrumentation removed; the diagnostics that remain are behind gigline.SetupLogs.
This commit is contained in:
1 parent
d0f94dff4e
commit
a1f5989cbb
39 files changed
+115
-59
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.
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.
@@ -727,6 +727,7 @@ void AGigLineCharacter::Tick(float DeltaSeconds)
|
||||
// Speed-driven animation state: correct whether the player walked, was pushed,
|
||||
// or fell, and independent of which input produced the movement.
|
||||
UpdateBodyAnimation();
|
||||
|
||||
}
|
||||
|
||||
void AGigLineCharacter::ToggleCamera()
|
||||
@@ -771,16 +772,34 @@ void AGigLineCharacter::UpdateBodyAnimation()
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (Wanted == PlayingAnimation)
|
||||
|
||||
// PlayAnimation is only an intent: it needs the component in single-node mode and
|
||||
// a live single-node instance. The body is hidden at construction and only
|
||||
// unhidden by the camera toggle, so the first call could land on a component that
|
||||
// was not ready and never retried - the clip name logged, the pose left at bind.
|
||||
// Driving the mode and the instance explicitly, and re-asserting every frame while
|
||||
// the instance is missing, makes that failure self-correcting.
|
||||
if (GetMesh()->GetAnimationMode() != EAnimationMode::AnimationSingleNode)
|
||||
{
|
||||
return;
|
||||
GetMesh()->SetAnimationMode(EAnimationMode::AnimationSingleNode);
|
||||
}
|
||||
PlayingAnimation = Wanted;
|
||||
if (CVarGigLineSetupLogs.GetValueOnGameThread())
|
||||
if (Wanted != PlayingAnimation || GetMesh()->GetSingleNodeInstance() == nullptr)
|
||||
{
|
||||
UE_LOG(LogTemp, Log, TEXT("GigLine: playing body clip %s"), *Wanted->GetName());
|
||||
PlayingAnimation = Wanted;
|
||||
GetMesh()->SetAnimation(Wanted);
|
||||
GetMesh()->Play(true);
|
||||
if (CVarGigLineSetupLogs.GetValueOnGameThread())
|
||||
{
|
||||
static bool bLoggedAnimState = false;
|
||||
if (!bLoggedAnimState)
|
||||
{
|
||||
bLoggedAnimState = true;
|
||||
UE_LOG(LogTemp, Log, TEXT("GigLine: body clip %s, mode=%d, instance=%s"),
|
||||
*Wanted->GetName(), static_cast<int32>(GetMesh()->GetAnimationMode()),
|
||||
GetMesh()->GetSingleNodeInstance() ? TEXT("ok") : TEXT("nil"));
|
||||
}
|
||||
}
|
||||
}
|
||||
GetMesh()->PlayAnimation(Wanted, true);
|
||||
}
|
||||
|
||||
void AGigLineCharacter::ResetToStart()
|
||||
|
||||
@@ -474,3 +474,7 @@ Scope: facts and traps that cost time to establish. Planned work lives in [TODO.
|
||||
- **The material was a real but separate defect**: `M_Character_Export` had no `SkeletalMesh` usage flag, so Unreal substituted the default grey with only a `LogMaterial` warning. Fixed in place (`used_with_skeletal_mesh = True`).
|
||||
- **`PlayAnimation` alone is not enough.** `USkeletalMeshComponent::PlayAnimation` needs the component already in `AnimationSingleNode` mode; the body is hidden at construction (`SetHiddenInGame(true)`) and only unhidden by the camera toggle, so the first call can land on a component that is not yet in the right mode and never retries — the guard `Wanted == PlayingAnimation` then returns forever and the pose stays at bind.
|
||||
- **What to log next time**: animation mode and `GetSingleNodeInstance()` together with the clip name. A clip name alone shows the intent, not the effect.
|
||||
- **Fixed 2026-11-09, two independent causes.** (1) `PlayAnimation` needs `AnimationSingleNode` plus a live single-node instance; the body is hidden at construction and only unhidden by the camera toggle, so the first call could land before the component was ready and the `Wanted == PlayingAnimation` guard then returned forever. `UpdateBodyAnimation` now sets the mode, calls `SetAnimation` + `Play`, and re-asserts while `GetSingleNodeInstance()` is null. (2) The arms-down offsets lived only in the clip keys — the exporter writes clip data **relative to the rest pose**, so they arrived in Unreal as a ±20° swing around an A-posed bind. `apply_base_pose` now bakes them into the rest pose before any clip is authored, which is what `mpfb_character_export.py --pose idle` already did.
|
||||
- **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`.
|
||||
+11
-6
@@ -96,19 +96,23 @@ Known gaps in this first pass, recorded rather than hidden:
|
||||
|
||||
## 6. Third-person body
|
||||
|
||||

|
||||

|
||||
|
||||
- **File:** `demo/review-demo-third-person.png`
|
||||
- **View:** third-person boom, ~6 m behind the pawn, captured with `-thirdperson` so the pawn starts in the third-person rig
|
||||
- **Expected content:** the player's own visible body on the north sidewalk, seen from behind, with the hero façade across the street
|
||||
- **Verified:** ☑ 2026-11-09 · **Approved:** ☐ pending
|
||||
|
||||
What this shot is evidence for, and what it is not:
|
||||
What this shot is evidence for:
|
||||
|
||||
- **Implemented and visible:** 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`.
|
||||
- **Not working:** the figure is in **bind pose, arms out**. The idle clip is bound and `PlayAnimation` is called — the run logs `GigLine: playing body clip Character_AnimHuman_rig_GigLine_Idle` — so the call succeeds and the pose does not apply. Tracked in `TODO.md` under "Verify animation plays correctly on the imported rig" with that evidence.
|
||||
- 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 now drives the body.** The arms hang at the sides rather than standing out in a bind A-pose, which is what the first three captures of this shot showed.
|
||||
|
||||
What is still not right, and is tracked:
|
||||
|
||||
- **The arms hang too far forward and the hands splay outward**, so the pose reads as a person standing awkwardly rather than at rest. The base pose is applied to the rest pose with inverted signs (the FBX axis mapping flips it); the elbow and hand orientation still need work, and that belongs to the `0.2.2` skin/animation tuning pass rather than to this rig.
|
||||
- **The walk clip has no shot of its own.** It imports and its curves carry a ±18° arm swing and a 0.45 rad hip swing, but nothing has captured it in motion; a walking shot needs a camera that follows a moving pawn.
|
||||
- **Camera collision is implemented but unproven:** the spawn point has open space behind it, so no shot exercises the arm shortening.
|
||||
- The skin material needed `used_with_skeletal_mesh=True`; without it Unreal silently substituted the default grey, which is what the first capture of this shot showed.
|
||||
|
||||
---
|
||||
|
||||
@@ -141,8 +145,9 @@ 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 body renders in bind pose: the clip is bound and `PlayAnimation` is called, but the pose does not apply | `0.2.0` → verify animation plays correctly | Open (blocks `0.2.0`) |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
Accepted by the owner, not a defect:
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -17,7 +17,7 @@ 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).
|
||||
- [A] Verify animation plays correctly on the imported rig. **Playback is broken and now measurable**: the pawn's mesh is bound, both clips resolve, and the run logs `GigLine: playing body clip Character_AnimHuman_rig_GigLine_Idle`, but shot 5 still renders the body in bind pose (arms out). So `PlayAnimation` is called and the pose is not applied — next step is to check whether the imported clip carries bone curves at all (the FBX bake may be sampling only keyed bones) or whether the component needs `SetAnimationMode(AnimationSingleNode)` before `SetAnimation`/`Play`.
|
||||
- [~] Verify animation plays correctly on the imported rig. **Fixed 2026-11-09**: the clip now drives the body — shot 6 shows the arms hanging at the sides instead of an A-pose. Two causes, both proven by measurement rather than guessed: (1) `PlayAnimation` needs the component in `AnimationSingleNode` with a live single-node instance, and the body is hidden at construction, so the first call could land before the component was ready and never retry; the code now sets the mode, calls `SetAnimation` + `Play`, and re-asserts while the instance is missing. (2) the arms-down offsets were carried only in the clip keys, which the exporter writes relative to the rest pose — so they arrived as a ±20° swing around an A-posed bind; they are now baked into the rest pose before the clips are authored (`apply_base_pose`). **Still open:** the idle pose reads awkwardly (arms hang forward, hands splay) because the base is applied with inverted signs to compensate for the FBX axis flip, and the walk clip has never been captured in motion. Earlier evidence: the pawn's mesh is bound, both clips resolve, and the run logs `GigLine: playing body clip Character_AnimHuman_rig_GigLine_Idle`, but shot 5 still renders the body in bind pose (arms out). So `PlayAnimation` is called and the pose is not applied — next step is to check whether the imported clip carries bone curves at all (the FBX bake may be sampling only keyed bones) or whether the component needs `SetAnimationMode(AnimationSingleNode)` before `SetAnimation`/`Play`.
|
||||
|
||||
### Camera and controls
|
||||
|
||||
|
||||
@@ -91,10 +91,37 @@ def pose_bones(arm):
|
||||
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")
|
||||
# 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])
|
||||
bpy.ops.pose.armature_apply()
|
||||
bpy.ops.object.mode_set(mode="OBJECT")
|
||||
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 on top of the arms-down base."""
|
||||
bx, by, bz = BASE.get(pb.name, (0.0, 0.0, 0.0))
|
||||
pb.rotation_euler = (bx + x, by + y, bz + z)
|
||||
"""Key a pose-bone rotation; the arms-down base is already in the rest pose."""
|
||||
pb.rotation_euler = (x, y, z)
|
||||
pb.keyframe_insert(data_path="rotation_euler", frame=frame)
|
||||
|
||||
|
||||
@@ -174,6 +201,7 @@ 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"])
|
||||
|
||||
|
||||
Reference in new issue
Block a user