fix(animation): close #61 + smooth stop from backward/sidestep-left/turn-left
Two related AnimationSequencer fixes for visible animation glitches at motion-cycle boundaries. 1. Link-tail blend hold (closes #61). BuildBlendedFrame was wrapping nextIdx unconditionally to rangeLo at the high-frame boundary — correct for looping cyclic nodes (idle/run/walk loops), wrong for one-shot links and action overlays. During the ~30 ms fractional tail before the sequencer transitions to the next queue node, the blend mixed frame[end] with frame[0], producing a one-frame flash through the anim's starting pose. Symptoms: door swing-open flap (frame 0 = closed pose) and player run-stop twitch (frame 0 = mid-stride). Fix: gate the wrap on curr.IsLooping; non-looping nodes hold the boundary frame until AdvanceToNextAnimation fires. 2. Stop-anim direction fallback. Stopping from WalkBackward / SideStepLeft / TurnLeft hit a null linkData from GetLink (the dat authors a single forward/right stop link and reuses it for both directions). SetCycle then enqueued only the Ready cycle, snapping straight to idle with no leg-settle blend. Fix: when the primary GetLink lookup is null, retry with the substate's low byte remapped to its forward/right peer (0x06→0x05, 0x10→0x0F, 0x0E→0x0D). Both fixes are pinned by new regression tests in AnimationSequencerTests that fail against the prior code (Y=5.02 for the link tail wrap → frame 0 blend; Y=0 for the backward stop snapping to Ready cycle). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6c4f6be1b4
commit
9f069e14c9
2 changed files with 149 additions and 2 deletions
|
|
@ -479,6 +479,29 @@ public sealed class AnimationSequencer
|
||||||
? null
|
? null
|
||||||
: GetLink(style, CurrentMotion, CurrentSpeedMod, adjustedMotion, adjustedSpeed);
|
: GetLink(style, CurrentMotion, CurrentSpeedMod, adjustedMotion, adjustedSpeed);
|
||||||
|
|
||||||
|
// Stop-anim fallback: dat-authored leg-settle / turn-stop links are
|
||||||
|
// keyed under the FORWARD/RIGHT variant only. Stopping from
|
||||||
|
// WalkBackward / SideStepLeft / TurnLeft hits a null linkData and
|
||||||
|
// would visibly snap to Ready. The settle anim is direction-agnostic
|
||||||
|
// (legs come to standing the same way regardless of which way you
|
||||||
|
// were walking), so retry GetLink with the substate's low-byte
|
||||||
|
// remapped to its forward/right peer.
|
||||||
|
if (linkData is null && !skipTransitionLink && CurrentMotion != 0)
|
||||||
|
{
|
||||||
|
uint substateLow = CurrentMotion & 0xFFu;
|
||||||
|
uint adjustedSubstate = substateLow switch
|
||||||
|
{
|
||||||
|
0x06u => (CurrentMotion & 0xFFFFFF00u) | 0x05u, // WalkBackward → WalkForward
|
||||||
|
0x10u => (CurrentMotion & 0xFFFFFF00u) | 0x0Fu, // SideStepLeft → SideStepRight
|
||||||
|
0x0Eu => (CurrentMotion & 0xFFFFFF00u) | 0x0Du, // TurnLeft → TurnRight
|
||||||
|
_ => CurrentMotion,
|
||||||
|
};
|
||||||
|
if (adjustedSubstate != CurrentMotion)
|
||||||
|
{
|
||||||
|
linkData = GetLink(style, adjustedSubstate, CurrentSpeedMod, adjustedMotion, adjustedSpeed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Resolve target cycle using the ADJUSTED motion (TurnRight not TurnLeft).
|
// Resolve target cycle using the ADJUSTED motion (TurnRight not TurnLeft).
|
||||||
int cycleKey = (int)(((style & 0xFFFFu) << 16) | (adjustedMotion & 0xFFFFFFu));
|
int cycleKey = (int)(((style & 0xFFFFu) << 16) | (adjustedMotion & 0xFFFFFFu));
|
||||||
_mtable.Cycles.TryGetValue(cycleKey, out var cycleData);
|
_mtable.Cycles.TryGetValue(cycleKey, out var cycleData);
|
||||||
|
|
@ -1419,18 +1442,24 @@ public sealed class AnimationSequencer
|
||||||
frameIdx = Math.Clamp(frameIdx, rangeLo, rangeHi);
|
frameIdx = Math.Clamp(frameIdx, rangeLo, rangeHi);
|
||||||
|
|
||||||
// Next frame for interpolation: step in the playback direction.
|
// Next frame for interpolation: step in the playback direction.
|
||||||
|
// Wrap to opposite end ONLY for looping cyclic nodes. For one-shot
|
||||||
|
// nodes (link transitions, action overlays), hold the boundary
|
||||||
|
// frame instead — otherwise the fractional tail of the anim
|
||||||
|
// blends frame[end] with frame[0], producing a brief flash through
|
||||||
|
// the anim's starting pose at the link→cycle boundary (issue #61:
|
||||||
|
// door swing-open flap; run-stop twitch).
|
||||||
int nextIdx;
|
int nextIdx;
|
||||||
if (curr.Framerate >= 0.0)
|
if (curr.Framerate >= 0.0)
|
||||||
{
|
{
|
||||||
nextIdx = frameIdx + 1;
|
nextIdx = frameIdx + 1;
|
||||||
if (nextIdx > rangeHi || nextIdx >= numPartFrames)
|
if (nextIdx > rangeHi || nextIdx >= numPartFrames)
|
||||||
nextIdx = rangeLo; // wrap forward
|
nextIdx = curr.IsLooping ? rangeLo : frameIdx;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
nextIdx = frameIdx - 1;
|
nextIdx = frameIdx - 1;
|
||||||
if (nextIdx < rangeLo)
|
if (nextIdx < rangeLo)
|
||||||
nextIdx = rangeHi; // wrap backward
|
nextIdx = curr.IsLooping ? rangeHi : frameIdx;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fractional blend weight (always in [0, 1]).
|
// Fractional blend weight (always in [0, 1]).
|
||||||
|
|
|
||||||
|
|
@ -371,6 +371,124 @@ public sealed class AnimationSequencerTests
|
||||||
$"Expected link-anim Y({transforms[0].Origin.Y}) > cycle X({transforms[0].Origin.X})");
|
$"Expected link-anim Y({transforms[0].Origin.Y}) > cycle X({transforms[0].Origin.X})");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Advance_LinkTailDoesNotBlendIntoLinkFrame0()
|
||||||
|
{
|
||||||
|
// Issue #61 regression: the fractional tail of a non-looping LINK
|
||||||
|
// (the ~30 ms between the last integer frame and the wrap boundary)
|
||||||
|
// must hold the link's END pose instead of blending into the link's
|
||||||
|
// FRAME 0 pose. Old behaviour: BuildBlendedFrame wrapped nextIdx to
|
||||||
|
// rangeLo unconditionally, producing a one-frame flash through the
|
||||||
|
// link's starting pose at the link→cycle boundary. Symptoms: door
|
||||||
|
// swing-open flap (frame 0 = closed); player run-stop twitch
|
||||||
|
// (frame 0 = mid-stride).
|
||||||
|
const uint Style = 0x003Du;
|
||||||
|
const uint IdleMotion = 0x0003u;
|
||||||
|
const uint WalkMotion = 0x0005u;
|
||||||
|
const uint CycleAnim = 0x03000080u;
|
||||||
|
const uint LinkAnim = 0x03000081u;
|
||||||
|
|
||||||
|
// Link anim: 3 frames, distinct Y so we can tell which frame is being
|
||||||
|
// sampled. Frame 0 Y=10 (link's starting pose — e.g. closed door),
|
||||||
|
// frame 2 Y=0 (link's end pose — e.g. open door).
|
||||||
|
var linkAnim = new Animation();
|
||||||
|
for (int f = 0; f < 3; f++)
|
||||||
|
{
|
||||||
|
var pf = new AnimationFrame(1);
|
||||||
|
float y = 10f - 5f * f; // 10, 5, 0
|
||||||
|
pf.Frames.Add(new Frame { Origin = new Vector3(0, y, 0), Orientation = Quaternion.Identity });
|
||||||
|
linkAnim.PartFrames.Add(pf);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cycle anim: single frame at Y=0 (the "open" / "idle" rest pose).
|
||||||
|
var cycleAnim = Fixtures.MakeAnim(1, 1, new Vector3(0, 0, 0), Quaternion.Identity);
|
||||||
|
|
||||||
|
var setup = Fixtures.MakeSetup(1);
|
||||||
|
var mt = Fixtures.MakeMtable(
|
||||||
|
style: Style,
|
||||||
|
motion: WalkMotion,
|
||||||
|
cycleAnimId: CycleAnim,
|
||||||
|
fromMotion: IdleMotion,
|
||||||
|
toMotion: WalkMotion,
|
||||||
|
linkAnimId: LinkAnim,
|
||||||
|
framerate: 30f);
|
||||||
|
|
||||||
|
var loader = new FakeLoader();
|
||||||
|
loader.Register(CycleAnim, cycleAnim);
|
||||||
|
loader.Register(LinkAnim, linkAnim);
|
||||||
|
|
||||||
|
var seq = new AnimationSequencer(setup, mt, loader);
|
||||||
|
SetCurrentMotion(seq, Style, IdleMotion);
|
||||||
|
seq.SetCycle(Style, WalkMotion);
|
||||||
|
|
||||||
|
// Advance to _framePosition ≈ 2.5 — past the last integer frame (2)
|
||||||
|
// but before maxBoundary - epsilon (≈ 3). At 30 fps, 2.5/30 = 0.0833s.
|
||||||
|
seq.Advance(0.0833f);
|
||||||
|
double pos = GetFramePosition(seq);
|
||||||
|
Assert.InRange(pos, 2.4, 2.7);
|
||||||
|
|
||||||
|
var transforms = seq.Advance(0.0001f); // tiny extra dt to trigger blend read
|
||||||
|
|
||||||
|
// Pre-fix: nextIdx would wrap to rangeLo (0), so transforms[0].Origin.Y
|
||||||
|
// would land near 0.5 × 0 + 0.5 × 10 = 5 (mid-blend with link frame 0).
|
||||||
|
// Post-fix: nextIdx = frameIdx (2), so transforms[0].Origin.Y = 0 (held).
|
||||||
|
Assert.True(transforms[0].Origin.Y < 1f,
|
||||||
|
$"Link tail should hold last-frame pose Y=0; got Y={transforms[0].Origin.Y} "
|
||||||
|
+ "(would be ~5 if nextIdx still wrapped to link frame 0)");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SetCycle_StopFromWalkBackward_FallsBackToWalkForwardStopLink()
|
||||||
|
{
|
||||||
|
// Stop-anim asymmetry: the Humanoid motion table only authors a
|
||||||
|
// "stop walking" link under WalkForward (low byte 0x05). Stopping
|
||||||
|
// from WalkBackward (0x06) without a fallback returns null linkData
|
||||||
|
// and the cycle snaps to Ready with no settle blend. Fix: when the
|
||||||
|
// primary GetLink lookup fails, retry with WalkBackward's low byte
|
||||||
|
// remapped to WalkForward.
|
||||||
|
const uint Style = 0x003Du;
|
||||||
|
const uint WalkForwardCmd = 0x0005u;
|
||||||
|
const uint WalkBackCmd = 0x0006u;
|
||||||
|
const uint ReadyCmd = 0x0003u;
|
||||||
|
const uint CycleAnim = 0x03000090u; // Ready cycle (Y=0)
|
||||||
|
const uint LinkAnim = 0x03000091u; // stop-link (Y=7)
|
||||||
|
|
||||||
|
var cycleAnim = Fixtures.MakeAnim(1, 1, new Vector3(0, 0, 0), Quaternion.Identity);
|
||||||
|
var linkAnim = Fixtures.MakeAnim(4, 1, new Vector3(0, 7, 0), Quaternion.Identity);
|
||||||
|
|
||||||
|
var setup = Fixtures.MakeSetup(1);
|
||||||
|
// Table: Ready cycle + WalkForward→Ready link. NO WalkBackward→Ready link.
|
||||||
|
var mt = Fixtures.MakeMtable(
|
||||||
|
style: Style,
|
||||||
|
motion: ReadyCmd,
|
||||||
|
cycleAnimId: CycleAnim,
|
||||||
|
fromMotion: WalkForwardCmd,
|
||||||
|
toMotion: ReadyCmd,
|
||||||
|
linkAnimId: LinkAnim,
|
||||||
|
framerate: 30f);
|
||||||
|
|
||||||
|
var loader = new FakeLoader();
|
||||||
|
loader.Register(CycleAnim, cycleAnim);
|
||||||
|
loader.Register(LinkAnim, linkAnim);
|
||||||
|
|
||||||
|
var seq = new AnimationSequencer(setup, mt, loader);
|
||||||
|
// Simulate "we were walking backward" — substate = WalkBackward,
|
||||||
|
// substateSpeed = +1 (the original speedMod stored by SetCycle).
|
||||||
|
SetCurrentMotion(seq, Style, WalkBackCmd);
|
||||||
|
|
||||||
|
seq.SetCycle(Style, ReadyCmd);
|
||||||
|
|
||||||
|
// Advance a tiny dt — should land on link frame 0 (Y=7), not the
|
||||||
|
// cycle (Y=0). Without the fallback, linkData is null, only the
|
||||||
|
// Ready cycle is enqueued, and we read Y=0 immediately.
|
||||||
|
var transforms = seq.Advance(0.001f);
|
||||||
|
Assert.Single(transforms);
|
||||||
|
Assert.True(transforms[0].Origin.Y > 5f,
|
||||||
|
$"Stop-from-backward should fall back to WalkForward→Ready link "
|
||||||
|
+ $"(expect Y≈7 from link); got Y={transforms[0].Origin.Y} "
|
||||||
|
+ "(Y=0 means linkData was null and we snapped to Ready cycle).");
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SetCycle_NoLinkInTable_DirectCycleSwitch()
|
public void SetCycle_NoLinkInTable_DirectCycleSwitch()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue