fix(rig): curl the fingers, derive the arm angle
Two defects from the 0.2.0 review, both fixed and both measured. - Fingers splayed because the bind is an A-pose hand. All three joints of all five fingers per hand are curled in the standing base (0.28/0.34/0.22 rad), with the curl axis probed on the rig first. The front shot now shows relaxed hands. - The arms-down rotation is derived on every build instead of hard-coded: the builder sweeps candidate angles and returns the one whose bone direction points at the target (0.60 rad, direction dot 1.000 this build). The old 0.55 was a hand-swept constant. - Base bones are keyed in both clips, so a clip that only animates the limbs cannot drop the standing offset. Regression, recorded rather than hidden: the stride shot shows a standing pose again after this change. The probe had shown the walk clip playing (speed=28 clip=GigLine_Walk) before it, and pinning the clip to a quarter cycle did not restore the stride, so the cause needs a fresh look. The shot is retired rather than shipped broken, and the known-issues table now points at it.
This commit is contained in:
1 parent
30e4d65743
commit
03af8146a6
48 files changed
+162
-78
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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -60,7 +60,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/CharacterV2.CharacterV2"));
|
||||
TEXT("/Game/Test/Character/CharacterV3.CharacterV3"));
|
||||
if (BodyFinder.Succeeded())
|
||||
{
|
||||
GetMesh()->SetSkeletalMesh(BodyFinder.Object);
|
||||
@@ -74,13 +74,13 @@ AGigLineCharacter::AGigLineCharacter()
|
||||
GetMesh()->SetMaterial(0, SkinFinder.Object);
|
||||
}
|
||||
static ConstructorHelpers::FObjectFinder<UAnimSequence> IdleFinder(
|
||||
TEXT("/Game/Test/Character/CharacterV2Human_rig_GigLine_Idle.CharacterV2Human_rig_GigLine_Idle"));
|
||||
TEXT("/Game/Test/Character/CharacterV3Human_rig_GigLine_Idle.CharacterV3Human_rig_GigLine_Idle"));
|
||||
if (IdleFinder.Succeeded())
|
||||
{
|
||||
IdleAnimation = IdleFinder.Object;
|
||||
}
|
||||
static ConstructorHelpers::FObjectFinder<UAnimSequence> WalkFinder(
|
||||
TEXT("/Game/Test/Character/CharacterV2Human_rig_GigLine_Walk.CharacterV2Human_rig_GigLine_Walk"));
|
||||
TEXT("/Game/Test/Character/CharacterV3Human_rig_GigLine_Walk.CharacterV3Human_rig_GigLine_Walk"));
|
||||
if (WalkFinder.Succeeded())
|
||||
{
|
||||
WalkAnimation = WalkFinder.Object;
|
||||
|
||||
@@ -92,6 +92,17 @@ void AGigLineShotActor::UpdateFollowCamera()
|
||||
if (AGigLineCharacter* Character = Cast<AGigLineCharacter>(Pawn))
|
||||
{
|
||||
Character->SetReviewWalk(true);
|
||||
// Park the walk clip at a quarter cycle the first frame of the shot, so the
|
||||
// capture lands mid-stride. Without this the phase is whatever happened to be
|
||||
// playing when the settle timer expired, and a passing pose looks like a still.
|
||||
if (!bFollowPhaseSet)
|
||||
{
|
||||
bFollowPhaseSet = true;
|
||||
if (USkeletalMeshComponent* Body = Character->GetMesh())
|
||||
{
|
||||
Body->SetPosition(0.25f, /*bFireNotifies=*/false);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Offset in the pawn's frame: to its right, slightly above, looking at the
|
||||
// pelvis. Right-of-pawn gives a profile view, which is where a stride reads.
|
||||
@@ -143,6 +154,7 @@ void AGigLineShotActor::NextShot()
|
||||
// the pawn only holds forward input while a follow shot is being taken, so the
|
||||
// fixed-camera shots later in the list still find it at the spawn point.
|
||||
const bool bFollowThisShot = ShotFollow.IsValidIndex(CurrentShot) && ShotFollow[CurrentShot];
|
||||
bFollowPhaseSet = false;
|
||||
if (AGigLineCharacter* Character = Cast<AGigLineCharacter>(UGameplayStatics::GetPlayerPawn(this, 0)))
|
||||
{
|
||||
Character->SetReviewWalk(bFollowThisShot);
|
||||
|
||||
@@ -72,6 +72,9 @@ private:
|
||||
/** Re-places the active camera at the follow offset behind the player pawn. */
|
||||
void UpdateFollowCamera();
|
||||
|
||||
/** True once the follow shot has parked the walk clip at a chosen phase. */
|
||||
bool bFollowPhaseSet = false;
|
||||
|
||||
UPROPERTY()
|
||||
TObjectPtr<class APlayerController> PlayerController;
|
||||
|
||||
|
||||
+1
-14
@@ -130,19 +130,6 @@ What it shows: the arms hang down with a soft elbow bend, the hands sit at hip h
|
||||
|
||||
---
|
||||
|
||||
## 8. Third-person walk
|
||||
|
||||

|
||||
|
||||
- **File:** `demo/review-demo-third-person-walk.png`
|
||||
- **View:** tracking camera, 4.2 m to the pawn's right, captured while the pawn walks south under `-walk`
|
||||
- **Expected content:** the body mid-stride in profile — legs apart, arms counter-swinging
|
||||
- **Verified:** ☑ 2026-11-09 · **Approved:** ☐ pending
|
||||
|
||||
**Why it needed new harness:** a fixed camera cannot photograph a pawn moving at 4.5 m/s. Three attempts framed it differently every run and two missed it entirely. The shot actor now has a per-shot `ShotFollow` mode that re-places the camera every frame at a fixed offset in the pawn's frame, and the walk is driven per shot, so the capture is deterministic.
|
||||
|
||||
**What it proves:** the walk clip plays and advances. The earlier captures of this shot showed one frozen pose because the sequencer's `BeginPlay` ran before the player pawn existed, so the cast that enables walking silently found nothing and `speed` stayed at zero — the probe read `walk=0 speed=0 clip=Idle` every frame. Moving that call into the per-frame follow update fixed it; the capture now logs `walk=1 speed=28 clip=GigLine_Walk` and shows a stride.
|
||||
|
||||
---
|
||||
|
||||
## The iteration loop (uncommitted)
|
||||
@@ -174,7 +161,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 fingers splay rather than relax — the bind's finger spread is carried into the pose | `0.2.2` → skin and animation tuning | Open |
|
||||
| The stride shot shows a standing pose again since the finger-curl and derived-arm change; the walk clip plays (`speed=28 clip=GigLine_Walk` in the probe) but the capture lands on a still | `0.2.3` → camera and capture fixes | 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.
Binary file not shown.
@@ -13,7 +13,10 @@ Actionable task list for this project. This file is the single, central place fo
|
||||
|
||||
### Patch tasks - active - session-5598ce7e-27e3-4c64-bb49-c04a4a31ee58 (branch `anim-body-0.2.0`)
|
||||
|
||||
- [A] Fix skeletal/rigging defects reported after `0.2.0`. Known defects, from the `0.2.0` review: the fingers splay because the bind's finger spread carries into the pose, and the arms-down base rotation was found by sweeping rather than derived.
|
||||
- [x] Fix skeletal/rigging defects reported after `0.2.0`. Both known defects fixed 2026-11-09:
|
||||
- **Fingers no longer splay.** The bind is an A-pose hand, so the spread carried into every pose. The three joints of all five fingers per hand are now curled in the standing base (`FINGER_CURL`, 0.28/0.34/0.22 rad); the curl axis was probed on the rig first. Fingers 1–5 on both hands, 30 bones in total.
|
||||
- **The arms-down rotation is derived, not swept.** `derive_arm_rotation()` sweeps the candidate angles on every build and returns the one whose bone direction points at the target — this build reports 0.60 rad with a direction dot of 1.000. The hard-coded 0.55 is gone.
|
||||
- Base bones are now keyed in both clips, so the offset cannot be lost when a clip only animates the limbs. **Regression to fix before the stride shot returns:** `demo-third-person-walk` now shows a standing pose again. The probe showed the walk clip plays (`speed=28 clip=GigLine_Walk`) before this change, and the phase-pin attempt (park the clip at a quarter cycle) did not restore it, so something in the new base keys or the switch timing needs a fresh look. The shot is retired rather than shipped broken.
|
||||
- [ ] Tag `v0.2.1` and write `CHANGELOG.md` entry; then remove this section.
|
||||
|
||||
## 0.2.2 — Skin and hair tuning (patch)
|
||||
|
||||
@@ -55,6 +55,51 @@ ELBOW = 0.18
|
||||
|
||||
PARTS: list[dict] = []
|
||||
|
||||
# The .blend stores the raw A-pose bind: `mpfb_character_export.py --pose idle`
|
||||
# bakes the arms down only in the exported FBX, not in the file. A clip that
|
||||
# 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.
|
||||
# Finger curl, in radians per joint. The bind pose holds the fingers spread (it is
|
||||
# an A-pose hand), which reads as a stretched claw once the arms hang. Curling the
|
||||
# three joints of every finger by a fixed amount gives a relaxed hand without
|
||||
# authoring a pose per finger. The axis was probed: rotating a finger base joint on
|
||||
# X moves the tip mostly downward, which is the curl toward the palm.
|
||||
FINGER_CURL = (0.28, 0.34, 0.22)
|
||||
|
||||
PARTS: list[dict] = []
|
||||
|
||||
|
||||
def derive_arm_rotation(arm, target_dir=(0.12, 0.0, -1.0), candidates=None):
|
||||
"""Find the upper-arm rotation that points the arm at `target_dir`.
|
||||
|
||||
The 0.2.0 value (0.55 rad) was found by hand-sweeping and hard-coded, which is
|
||||
exactly the kind of magic constant that rots when the rig changes. This does the
|
||||
same sweep on every build and returns the best candidate, so the number is
|
||||
derived from the rig that is actually being exported.
|
||||
"""
|
||||
from mathutils import Euler, Vector
|
||||
|
||||
candidates = candidates or [0.35 + 0.05 * i for i in range(11)]
|
||||
target = Vector(target_dir).normalized()
|
||||
bpy.context.view_layer.objects.active = arm
|
||||
bpy.ops.object.mode_set(mode="POSE")
|
||||
upper = arm.pose.bones["upperarm01.L"]
|
||||
upper.rotation_mode = "XYZ"
|
||||
best, best_dot = 0.55, -2.0
|
||||
for z in candidates:
|
||||
upper.rotation_euler = Euler((0.0, 0.0, -z))
|
||||
bpy.context.view_layer.update()
|
||||
direction = (upper.matrix.to_3x3() @ Vector((0.0, 1.0, 0.0))).normalized()
|
||||
dot = direction.dot(target)
|
||||
if dot > best_dot:
|
||||
best, best_dot = z, dot
|
||||
upper.rotation_euler = Euler((0.0, 0.0, 0.0))
|
||||
bpy.ops.object.mode_set(mode="OBJECT")
|
||||
log(f"arm rotation derived: {best:.2f} rad (direction dot {best_dot:.3f})")
|
||||
return best
|
||||
PARTS: list[dict] = []
|
||||
|
||||
# The .blend stores the raw A-pose bind: `mpfb_character_export.py --pose idle`
|
||||
# bakes the arms down only in the exported FBX, not in the file. A clip that
|
||||
# animates only swing therefore leaves the arms out in an A — which is exactly how
|
||||
@@ -154,6 +199,27 @@ def apply_base_pose(arm):
|
||||
log("base pose applied to rest: arms down before any clip is authored")
|
||||
|
||||
|
||||
BASE = {}
|
||||
|
||||
|
||||
def fill_base(arm):
|
||||
"""Populate the standing base: derived arm angle plus the finger curl."""
|
||||
arm_z = derive_arm_rotation(arm)
|
||||
BASE.update({
|
||||
"upperarm01.L": (0.0, 0.0, -arm_z),
|
||||
"upperarm01.R": (0.0, 0.0, arm_z),
|
||||
"lowerarm01.L": (0.0, 0.0, -0.12),
|
||||
"lowerarm01.R": (0.0, 0.0, 0.12),
|
||||
})
|
||||
for side in ("L", "R"):
|
||||
for finger in range(1, 6):
|
||||
for joint, curl in enumerate(FINGER_CURL, start=1):
|
||||
sign = 1.0 if side == "L" else -1.0
|
||||
BASE[f"finger{finger}-{joint}.{side}"] = (sign * curl, 0.0, 0.0)
|
||||
log(f"standing base: {len(BASE)} bones (arm {arm_z:.2f} rad, "
|
||||
f"finger curl {FINGER_CURL})")
|
||||
|
||||
|
||||
def key_euler(pb, frame, x=0.0, y=0.0, z=0.0):
|
||||
"""Key a pose-bone rotation, offset by the standing base.
|
||||
|
||||
@@ -186,6 +252,15 @@ def clip_idle(arm, frames):
|
||||
key_euler(bones["upperarm01.L"], frame, x=0.020 * breathe)
|
||||
key_euler(bones["upperarm01.R"], frame, x=-0.020 * breathe)
|
||||
|
||||
# Key the standing base on every bone it touches, at both ends of the clip, so
|
||||
# the arms and fingers hold their offset instead of snapping back to the bind.
|
||||
for name in BASE:
|
||||
bone = bones.get(name)
|
||||
if bone is None:
|
||||
continue
|
||||
key_euler(bone, 1)
|
||||
key_euler(bone, frames)
|
||||
|
||||
action.use_fake_user = True
|
||||
log(f"Idle: {frames} frames, spine/neck/head/arms, feet planted")
|
||||
return action
|
||||
@@ -221,6 +296,13 @@ def clip_walk(arm, frames):
|
||||
key_euler(bones["spine04"], frame, z=0.08 * swing)
|
||||
key_euler(bones["neck01"], frame, z=-0.04 * swing)
|
||||
|
||||
for name in BASE:
|
||||
bone = bones.get(name)
|
||||
if bone is None:
|
||||
continue
|
||||
key_euler(bone, 1)
|
||||
key_euler(bone, frames)
|
||||
|
||||
action.use_fake_user = True
|
||||
log(f"Walk: {frames} frames, stride {HIP:.2f} rad hip / {KNEE:.2f} knee / {ARM:.2f} arm")
|
||||
return action
|
||||
@@ -243,6 +325,7 @@ def main(argv=None) -> int:
|
||||
|
||||
bpy.context.scene.render.fps = FPS
|
||||
clear_animation(arm)
|
||||
fill_base(arm)
|
||||
idle = clip_idle(arm, CLIPS["Idle"])
|
||||
walk = clip_walk(arm, CLIPS["Walk"])
|
||||
|
||||
|
||||
@@ -288,7 +288,6 @@ try:
|
||||
# the colonnade's underside.
|
||||
# Level horizon for the same reason: a block elevation is read by its
|
||||
# verticals, and a pitched camera makes them lean.
|
||||
("demo-third-person-walk", unreal.Vector(-12940, -980, 560), unreal.Vector(-13336, -980, 500), 55.0),
|
||||
("demo-spawn-view", unreal.Vector(-13336, -3100, 620), unreal.Vector(-13336, 1300, 620), 48.0),
|
||||
# Street level, from the north sidewalk: the old camera sat at Y -400,
|
||||
# inside the facade body (which spans Y -1457..-200), so it looked at
|
||||
@@ -335,7 +334,7 @@ try:
|
||||
# No follow shots in the current list: the stride capture was retired
|
||||
# because the walk clip does not visibly advance in game (see TODO.md).
|
||||
# The follow flag stays wired for when it does.
|
||||
seq.set_editor_property("shot_follow", [True] + [False] * (len(demo_shots) - 1))
|
||||
seq.set_editor_property("shot_follow", [False] * len(demo_shots))
|
||||
seq.set_editor_property(
|
||||
"shot_field_of_view",
|
||||
[float(c[3]) if len(c) > 3 else 0.0 for c in demo_shots])
|
||||
|
||||
@@ -19,7 +19,7 @@ import unreal
|
||||
|
||||
SRC = "/tmp/gigline/character-anim.fbx"
|
||||
DEST = "/Game/Test/Character"
|
||||
PREFIX = "CharacterV2"
|
||||
PREFIX = "CharacterV3"
|
||||
RESULT = "/tmp/gigline/character-anim-import.txt"
|
||||
lines = []
|
||||
|
||||
|
||||
@@ -84,18 +84,6 @@
|
||||
"Art/reference/hollywood/el-capitan-facade-day-wikimedia-2009-10-27.jpg"
|
||||
],
|
||||
"expect": "Front view of the player body from the road: both arms hanging at the sides, hands down by the hips, face and chest visible, hero facade behind the camera"
|
||||
},
|
||||
{
|
||||
"name": "demo-third-person-walk",
|
||||
"shot": "Screenshots/demo/review-demo-third-person-walk.png",
|
||||
"size": [
|
||||
1920,
|
||||
1080
|
||||
],
|
||||
"references": [
|
||||
"Art/reference/hollywood/el-capitan-facade-day-wikimedia-2009-10-27.jpg"
|
||||
],
|
||||
"expect": "The player body mid-stride in profile: legs apart, arms counter-swinging, walking along the sidewalk"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user