feat(anim): AnimationSequencer with transition links + retail slerp
Port the animation playback engine from the decompiled retail client
into AcDream.Core.Physics.AnimationSequencer.
## What this adds
**AnimationSequencer** (src/AcDream.Core/Physics/AnimationSequencer.cs):
- Frame advancer: `frameNum += framerate * dt`, bounds-checks against
AnimData.HighFrame/LowFrame (with sentinel resolution for HighFrame=-1),
wraps at cycle boundaries. Matches ACE's `Sequence.update_internal`.
- Quaternion slerp (`SlerpRetailClient`): ported from decompiled
`FUN_005360d0` (chunk_00530000.c:4799-4846):
1. dot-product sign-flip to take the shorter arc
2. fallback to linear blend when 1-dot <= 1e-4 (near-parallel)
3. sin-based slerp for all other cases
4. validate weights lie in [0,1] before using sin result (retail
client validation step that guards degenerate inputs)
- Transition link resolution: `GetLink(style, fromMotion, toMotion)`
mirrors ACE's `MotionTable.get_link` positive-speed path.
DatReaderWriter layout: `Links[style<<16|(from&0xFFFFFF)]` is a
`MotionCommandData` whose `.MotionData[toMotion]` is the transition
`MotionData`. Link frames are prepended before the cyclic tail, so
idle->walk plays the short transition clip then loops the walk cycle.
- `IAnimationLoader` / `DatCollectionLoader`: thin abstraction so the
sequencer is testable offline without opening dat files.
- Public API: `SetCycle(style, motion, speedMod)` + `Advance(dt)`
returning `IReadOnlyList<PartTransform>` (Origin+Orientation per part).
**AnimationSequencerTests** (tests/...Physics/AnimationSequencerTests.cs):
14 tests, all offline, covering slerp math, frame wrap, transition link
prepend, no-link direct switch, same-motion fast path, reset.
317 tests green, 0 warnings.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
14569558fb
commit
f48f2745c4
2 changed files with 1003 additions and 0 deletions
563
src/AcDream.Core/Physics/AnimationSequencer.cs
Normal file
563
src/AcDream.Core/Physics/AnimationSequencer.cs
Normal file
|
|
@ -0,0 +1,563 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Enums;
|
||||
using DatReaderWriter.Types;
|
||||
|
||||
namespace AcDream.Core.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal interface for resolving Animation objects by id.
|
||||
/// Abstracted so the sequencer can be unit-tested without a real DatCollection.
|
||||
/// </summary>
|
||||
public interface IAnimationLoader
|
||||
{
|
||||
/// <summary>Load an Animation by its dat id, or return null.</summary>
|
||||
Animation? LoadAnimation(uint id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Production implementation of <see cref="IAnimationLoader"/> backed by
|
||||
/// a <see cref="DatCollection"/>.
|
||||
/// </summary>
|
||||
public sealed class DatCollectionLoader : IAnimationLoader
|
||||
{
|
||||
private readonly DatCollection _dats;
|
||||
public DatCollectionLoader(DatCollection dats) => _dats = dats;
|
||||
public Animation? LoadAnimation(uint id) => _dats.Get<Animation>(id);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// AnimationSequencer — per-entity animation playback with transition links.
|
||||
//
|
||||
// Decompiled references:
|
||||
// FUN_005360d0 (chunk_00530000.c:4799) — quaternion slerp with dot-product
|
||||
// sign-flip and lerp fallback for near-parallel quaternions.
|
||||
// Sequence.update_internal (ACE Sequence.cs) — frame advance: frameNum +=
|
||||
// framerate*dt, test against high/low, fire hooks at each crossed
|
||||
// integer frame boundary, advance to next anim when done.
|
||||
// MotionTable.get_link (ACE MotionTable.cs:395) — transition lookup:
|
||||
// Links[(style<<16)|(fromSubstate&0xFFFFFF)].TryGetValue(toMotion).
|
||||
//
|
||||
// DatReaderWriter types used:
|
||||
// MotionTable.Links : Dictionary<int, MotionCommandData>
|
||||
// key = (style << 16) | (fromSubstate & 0xFFFFFF)
|
||||
// MotionCommandData.MotionData : Dictionary<int, MotionData>
|
||||
// key = target motion (int cast of MotionCommand)
|
||||
// MotionData.Anims : List<AnimData>
|
||||
// AnimData.AnimId : QualifiedDataId<Animation>
|
||||
// Animation.PartFrames : List<AnimationFrame>
|
||||
// AnimationFrame.Frames : List<Frame>
|
||||
// Frame.Origin : Vector3, Frame.Orientation : Quaternion
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Per-part world-local transform produced by <see cref="AnimationSequencer.Advance"/>.
|
||||
/// Caller (e.g. GameWindow.TickAnimations) consumes this to rebuild MeshRefs.
|
||||
/// </summary>
|
||||
public readonly struct PartTransform
|
||||
{
|
||||
public readonly Vector3 Origin;
|
||||
public readonly Quaternion Orientation;
|
||||
|
||||
public PartTransform(Vector3 origin, Quaternion orientation)
|
||||
{
|
||||
Origin = origin;
|
||||
Orientation = orientation;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One entry in the animation queue (link transition or looping cycle).
|
||||
/// </summary>
|
||||
internal sealed class AnimNode
|
||||
{
|
||||
public Animation Anim;
|
||||
public float Framerate; // signed; negative means reverse
|
||||
public int LowFrame;
|
||||
public int HighFrame;
|
||||
public bool IsLooping; // true only for the tail cyclic node
|
||||
|
||||
public AnimNode(Animation anim, float framerate, int lowFrame, int highFrame, bool isLooping)
|
||||
{
|
||||
Anim = anim;
|
||||
Framerate = framerate;
|
||||
LowFrame = lowFrame;
|
||||
HighFrame = highFrame;
|
||||
IsLooping = isLooping;
|
||||
}
|
||||
|
||||
public float StartingFrame => Framerate >= 0f ? LowFrame : HighFrame + 1 - 1e-5f;
|
||||
public float EndingFrame => Framerate >= 0f ? HighFrame + 1 - 1e-5f : LowFrame;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Full animation playback engine for one entity.
|
||||
///
|
||||
/// <para>
|
||||
/// Usage pattern:
|
||||
/// <code>
|
||||
/// var seq = new AnimationSequencer(setup, motionTable, dats);
|
||||
/// seq.SetCycle(style, motion, speedMod);
|
||||
/// // each frame:
|
||||
/// var transforms = seq.Advance(dt);
|
||||
/// // rebuild MeshRefs from transforms
|
||||
/// </code>
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// When <see cref="SetCycle"/> is called with a new motion, the sequencer
|
||||
/// looks up a transition link in the MotionTable and prepends those frames
|
||||
/// to the queue so the entity blends smoothly instead of snapping. The
|
||||
/// cyclic tail of the queue loops forever.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class AnimationSequencer
|
||||
{
|
||||
// ── Public state ─────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Current style (stance) command.</summary>
|
||||
public uint CurrentStyle { get; private set; }
|
||||
|
||||
/// <summary>Current cyclic motion command.</summary>
|
||||
public uint CurrentMotion { get; private set; }
|
||||
|
||||
// ── Private state ────────────────────────────────────────────────────────
|
||||
|
||||
private readonly Setup _setup;
|
||||
private readonly MotionTable _mtable;
|
||||
private readonly IAnimationLoader _loader;
|
||||
|
||||
// Animation queue: non-looping link frames followed by the looping cycle.
|
||||
private readonly LinkedList<AnimNode> _queue = new();
|
||||
private LinkedListNode<AnimNode>? _currNode;
|
||||
private LinkedListNode<AnimNode>? _firstCyclic;
|
||||
private float _frameNum;
|
||||
|
||||
private const float Epsilon = 1e-5f;
|
||||
|
||||
// ── Constructor ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Create a sequencer for one entity.
|
||||
/// </summary>
|
||||
/// <param name="setup">Entity's Setup dat (for part count / default scale).</param>
|
||||
/// <param name="motionTable">Loaded MotionTable dat for this entity.</param>
|
||||
/// <param name="loader">
|
||||
/// Animation loader. Use <see cref="DatCollectionLoader"/> for production,
|
||||
/// or inject a test double in unit tests.
|
||||
/// </param>
|
||||
public AnimationSequencer(Setup setup, MotionTable motionTable, IAnimationLoader loader)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(setup);
|
||||
ArgumentNullException.ThrowIfNull(motionTable);
|
||||
ArgumentNullException.ThrowIfNull(loader);
|
||||
|
||||
_setup = setup;
|
||||
_mtable = motionTable;
|
||||
_loader = loader;
|
||||
}
|
||||
|
||||
// ── Public API ───────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Switch to a new cyclic motion, prepending any transition link frames
|
||||
/// so the switch is smooth. If the motion table has no link for the
|
||||
/// (currentStyle, currentMotion) → newMotion transition, the cycle
|
||||
/// switches immediately (same as the old snap behaviour).
|
||||
/// </summary>
|
||||
/// <param name="style">MotionCommand style / stance (e.g. NonCombat 0x003D0000).</param>
|
||||
/// <param name="motion">Target motion command (e.g. WalkForward 0x45000005).</param>
|
||||
/// <param name="speedMod">Speed multiplier applied to framerates (1.0 = normal).</param>
|
||||
public void SetCycle(uint style, uint motion, float speedMod = 1f)
|
||||
{
|
||||
// Fast-path: already playing this exact motion at the same speed.
|
||||
if (CurrentStyle == style && CurrentMotion == motion
|
||||
&& _firstCyclic != null && _queue.Count > 0)
|
||||
return;
|
||||
|
||||
// Resolve transition link (currentSubstate → newMotion).
|
||||
MotionData? linkData = CurrentMotion != 0
|
||||
? GetLink(style, CurrentMotion, motion)
|
||||
: null;
|
||||
|
||||
// Resolve target cycle.
|
||||
int cycleKey = (int)(((style & 0xFFFFu) << 16) | (motion & 0xFFFFFFu));
|
||||
_mtable.Cycles.TryGetValue(cycleKey, out var cycleData);
|
||||
|
||||
// Clear the old cyclic tail; keep any non-cyclic head that hasn't
|
||||
// been played yet (ACE behaviour: non-cyclic anims drain naturally).
|
||||
ClearCyclicTail();
|
||||
|
||||
// Enqueue link frames.
|
||||
if (linkData is { Anims.Count: > 0 })
|
||||
EnqueueMotionData(linkData, speedMod, isLooping: false);
|
||||
|
||||
// Enqueue new cycle.
|
||||
if (cycleData is { Anims.Count: > 0 })
|
||||
{
|
||||
EnqueueMotionData(cycleData, speedMod, isLooping: true);
|
||||
}
|
||||
else if (_queue.Count == 0)
|
||||
{
|
||||
// No cycle and no link — nothing to play; reset fully.
|
||||
_currNode = null;
|
||||
_firstCyclic = null;
|
||||
_frameNum = 0f;
|
||||
CurrentStyle = style;
|
||||
CurrentMotion = motion;
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark the first cyclic node (the tail after all link frames).
|
||||
// If there were no link frames, the first enqueued node is cyclic.
|
||||
// Re-scan from the end to find the first IsLooping node.
|
||||
_firstCyclic = null;
|
||||
for (var n = _queue.First; n != null; n = n.Next)
|
||||
{
|
||||
if (n.Value.IsLooping)
|
||||
{
|
||||
_firstCyclic = n;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If we have no current anim, start at the beginning of the queue.
|
||||
if (_currNode == null)
|
||||
{
|
||||
_currNode = _queue.First;
|
||||
_frameNum = _currNode?.Value.StartingFrame ?? 0f;
|
||||
}
|
||||
|
||||
CurrentStyle = style;
|
||||
CurrentMotion = motion;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advance the animation by <paramref name="dt"/> seconds and return the
|
||||
/// per-part transforms for the current blended keyframe.
|
||||
///
|
||||
/// <para>
|
||||
/// The slerp algorithm mirrors the decompiled retail client's
|
||||
/// <c>FUN_005360d0</c> (chunk_00530000.c:4799):
|
||||
/// compute dot product; if negative, negate q2 and dot; if the angle is
|
||||
/// very small, fall back to linear (1-t, t) instead of sin-based slerp.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="dt">Elapsed time in seconds since the last call.</param>
|
||||
/// <returns>
|
||||
/// One <see cref="PartTransform"/> per part in the Setup, in part order.
|
||||
/// If no animation is loaded, all parts get identity transforms.
|
||||
/// </returns>
|
||||
public IReadOnlyList<PartTransform> Advance(float dt)
|
||||
{
|
||||
int partCount = _setup.Parts.Count;
|
||||
|
||||
if (_currNode == null || dt <= 0f)
|
||||
return BuildIdentityFrame(partCount);
|
||||
|
||||
var curr = _currNode.Value;
|
||||
float framerate = curr.Framerate;
|
||||
float frametime = framerate * dt;
|
||||
|
||||
bool animDone = false;
|
||||
float timeRemainder = 0f;
|
||||
|
||||
_frameNum += frametime;
|
||||
|
||||
if (frametime > 0f)
|
||||
{
|
||||
if (_frameNum > curr.HighFrame + 1 - Epsilon)
|
||||
{
|
||||
timeRemainder = Math.Abs(framerate) > Epsilon
|
||||
? (_frameNum - (curr.HighFrame + 1 - Epsilon)) / framerate
|
||||
: 0f;
|
||||
_frameNum = curr.HighFrame;
|
||||
animDone = true;
|
||||
}
|
||||
}
|
||||
else if (frametime < 0f)
|
||||
{
|
||||
if (_frameNum < curr.LowFrame)
|
||||
{
|
||||
timeRemainder = Math.Abs(framerate) > Epsilon
|
||||
? (_frameNum - curr.LowFrame) / framerate
|
||||
: 0f;
|
||||
_frameNum = curr.LowFrame;
|
||||
animDone = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (animDone)
|
||||
AdvanceToNextAnimation();
|
||||
|
||||
// Build the blended frame.
|
||||
return BuildBlendedFrame();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reset the sequencer to an unplaying state without clearing the
|
||||
/// motion table reference.
|
||||
/// </summary>
|
||||
public void Reset()
|
||||
{
|
||||
_queue.Clear();
|
||||
_currNode = null;
|
||||
_firstCyclic = null;
|
||||
_frameNum = 0f;
|
||||
CurrentStyle = 0;
|
||||
CurrentMotion = 0;
|
||||
}
|
||||
|
||||
// ── Private helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Look up the transition MotionData for going from <paramref name="fromMotion"/>
|
||||
/// to <paramref name="toMotion"/> within <paramref name="style"/>.
|
||||
///
|
||||
/// Port of ACE's MotionTable.get_link (positive-speed path):
|
||||
/// 1. Try Links[(style<<16)|(fromMotion&0xFFFFFF)][toMotion]
|
||||
/// 2. Fallback: try Links[style<<16][toMotion]
|
||||
///
|
||||
/// DatReaderWriter encodes Links as Dictionary<int, MotionCommandData>
|
||||
/// where MotionCommandData.MotionData is Dictionary<int, MotionData>.
|
||||
/// </summary>
|
||||
private MotionData? GetLink(uint style, uint fromMotion, uint toMotion)
|
||||
{
|
||||
int outerKey1 = (int)((style << 16) | (fromMotion & 0xFFFFFFu));
|
||||
if (_mtable.Links.TryGetValue(outerKey1, out var cmd1))
|
||||
{
|
||||
if (cmd1.MotionData.TryGetValue((int)toMotion, out var result1))
|
||||
return result1;
|
||||
}
|
||||
|
||||
// Fallback: style-level catch-all.
|
||||
int outerKey2 = (int)(style << 16);
|
||||
if (_mtable.Links.TryGetValue(outerKey2, out var cmd2))
|
||||
{
|
||||
if (cmd2.MotionData.TryGetValue((int)toMotion, out var result2))
|
||||
return result2;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Load an Animation from the dat by its <see cref="AnimData.AnimId"/>
|
||||
/// and resolve the sentinel frame bounds (HighFrame == -1 means "all frames").
|
||||
/// Mirrors ACE AnimSequenceNode.set_animation_id.
|
||||
/// </summary>
|
||||
private AnimNode? LoadAnimNode(AnimData ad, float speedMod, bool isLooping)
|
||||
{
|
||||
uint animId = (uint)ad.AnimId;
|
||||
if (animId == 0) return null;
|
||||
|
||||
var anim = _loader.LoadAnimation(animId);
|
||||
if (anim is null || anim.PartFrames.Count == 0) return null;
|
||||
|
||||
int numFrames = anim.PartFrames.Count;
|
||||
int low = ad.LowFrame;
|
||||
int high = ad.HighFrame;
|
||||
|
||||
// Sentinel resolution (same as MotionResolver.GetIdleCycle).
|
||||
if (high < 0) high = numFrames - 1;
|
||||
if (low >= numFrames) low = numFrames - 1;
|
||||
if (high >= numFrames) high = numFrames - 1;
|
||||
if (low < 0) low = 0;
|
||||
if (low > high) high = low;
|
||||
|
||||
float fr = ad.Framerate * speedMod;
|
||||
return new AnimNode(anim, fr, low, high, isLooping);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Append all AnimData entries from <paramref name="motionData"/> to the
|
||||
/// queue. Each AnimData becomes one AnimNode.
|
||||
/// </summary>
|
||||
private void EnqueueMotionData(MotionData motionData, float speedMod, bool isLooping)
|
||||
{
|
||||
for (int i = 0; i < motionData.Anims.Count; i++)
|
||||
{
|
||||
bool nodeCycling = isLooping && (i == motionData.Anims.Count - 1);
|
||||
var node = LoadAnimNode(motionData.Anims[i], speedMod, nodeCycling);
|
||||
if (node != null)
|
||||
_queue.AddLast(node);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remove all cyclic (looping) nodes from the tail of the queue, starting
|
||||
/// from <see cref="_firstCyclic"/>. Non-cyclic link frames remain.
|
||||
/// </summary>
|
||||
private void ClearCyclicTail()
|
||||
{
|
||||
if (_firstCyclic == null) return;
|
||||
|
||||
var node = _firstCyclic;
|
||||
while (node != null)
|
||||
{
|
||||
var next = node.Next;
|
||||
// If CurrAnim is being removed, jump it to the previous non-cyclic node.
|
||||
if (_currNode == node)
|
||||
{
|
||||
_currNode = node.Previous;
|
||||
if (_currNode != null)
|
||||
_frameNum = _currNode.Value.EndingFrame;
|
||||
else
|
||||
_frameNum = 0f;
|
||||
}
|
||||
_queue.Remove(node);
|
||||
node = next;
|
||||
}
|
||||
|
||||
_firstCyclic = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Move <see cref="_currNode"/> to the next node in the queue, or loop
|
||||
/// back to <see cref="_firstCyclic"/> if at the end. Mirrors ACE's
|
||||
/// <c>advance_to_next_animation</c>.
|
||||
/// </summary>
|
||||
private void AdvanceToNextAnimation()
|
||||
{
|
||||
if (_currNode == null) return;
|
||||
|
||||
if (_currNode.Next != null)
|
||||
_currNode = _currNode.Next;
|
||||
else if (_firstCyclic != null)
|
||||
_currNode = _firstCyclic;
|
||||
// else: end of non-looping sequence — stay on last frame.
|
||||
|
||||
if (_currNode != null)
|
||||
_frameNum = _currNode.Value.StartingFrame;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the per-part blended transform from the current animation frame.
|
||||
/// Blends between floor(frameNum) and floor(frameNum)+1 using the
|
||||
/// fractional part of frameNum.
|
||||
/// </summary>
|
||||
private IReadOnlyList<PartTransform> BuildBlendedFrame()
|
||||
{
|
||||
int partCount = _setup.Parts.Count;
|
||||
|
||||
if (_currNode == null)
|
||||
return BuildIdentityFrame(partCount);
|
||||
|
||||
var curr = _currNode.Value;
|
||||
int numPartFrames = curr.Anim.PartFrames.Count;
|
||||
|
||||
int frameIdx = (int)Math.Floor(_frameNum);
|
||||
frameIdx = Math.Clamp(frameIdx, curr.LowFrame, Math.Min(curr.HighFrame, numPartFrames - 1));
|
||||
|
||||
int nextIdx = frameIdx + 1;
|
||||
if (nextIdx > curr.HighFrame || nextIdx >= numPartFrames)
|
||||
nextIdx = curr.LowFrame;
|
||||
|
||||
float t = _frameNum - (float)Math.Floor(_frameNum);
|
||||
if (t < 0f) t = 0f;
|
||||
if (t > 1f) t = 1f;
|
||||
|
||||
var f0Parts = curr.Anim.PartFrames[frameIdx].Frames;
|
||||
var f1Parts = curr.Anim.PartFrames[nextIdx].Frames;
|
||||
|
||||
var result = new PartTransform[partCount];
|
||||
for (int i = 0; i < partCount; i++)
|
||||
{
|
||||
if (i < f0Parts.Count)
|
||||
{
|
||||
var p0 = f0Parts[i];
|
||||
var p1 = i < f1Parts.Count ? f1Parts[i] : p0;
|
||||
|
||||
result[i] = new PartTransform(
|
||||
Vector3.Lerp(p0.Origin, p1.Origin, t),
|
||||
SlerpRetailClient(p0.Orientation, p1.Orientation, t));
|
||||
}
|
||||
else
|
||||
{
|
||||
result[i] = new PartTransform(Vector3.Zero, Quaternion.Identity);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<PartTransform> BuildIdentityFrame(int partCount)
|
||||
{
|
||||
var result = new PartTransform[partCount];
|
||||
for (int i = 0; i < partCount; i++)
|
||||
result[i] = new PartTransform(Vector3.Zero, Quaternion.Identity);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Quaternion slerp matching the retail client's <c>FUN_005360d0</c>
|
||||
/// (chunk_00530000.c:4799-4846):
|
||||
/// <list type="number">
|
||||
/// <item>Compute dot product of q1 and q2.</item>
|
||||
/// <item>If dot < 0, negate q2 (choose the shorter arc).</item>
|
||||
/// <item>If 1 - dot <= epsilon, fall back to (1-t)*q1 + t*q2 (linear).</item>
|
||||
/// <item>Otherwise slerp: omega = acos(dot), blend = sin(s*omega)/sin(omega).</item>
|
||||
/// <item>Validate result lies in [0,1]²; if not, fall back to linear.</item>
|
||||
/// </list>
|
||||
/// The only difference from the standard formula is step 5: the retail
|
||||
/// client validates that both blend weights are in [0,1] before using the
|
||||
/// sin-based result; this handles degenerate inputs gracefully.
|
||||
/// </summary>
|
||||
public static Quaternion SlerpRetailClient(Quaternion q1, Quaternion q2, float t)
|
||||
{
|
||||
float dot = q1.W * q2.W + q1.X * q2.X + q1.Y * q2.Y + q1.Z * q2.Z;
|
||||
|
||||
// Step 2: choose the shorter arc.
|
||||
Quaternion q2s;
|
||||
if (dot < 0f)
|
||||
{
|
||||
dot = -dot;
|
||||
q2s = new Quaternion(-q2.X, -q2.Y, -q2.Z, -q2.W);
|
||||
}
|
||||
else
|
||||
{
|
||||
q2s = q2;
|
||||
}
|
||||
|
||||
const float SlerpEpsilon = 1e-4f;
|
||||
float w1, w2;
|
||||
|
||||
if (1f - dot <= SlerpEpsilon)
|
||||
{
|
||||
// Near-parallel: linear fallback (matches retail client's path).
|
||||
w1 = 1f - t;
|
||||
w2 = t;
|
||||
}
|
||||
else
|
||||
{
|
||||
float omega = MathF.Acos(dot);
|
||||
float sinOmega = MathF.Sin(omega);
|
||||
float invSin = 1f / sinOmega;
|
||||
|
||||
float candidate1 = MathF.Sin((1f - t) * omega) * invSin;
|
||||
float candidate2 = MathF.Sin(t * omega) * invSin;
|
||||
|
||||
// Step 5: validate (retail client check: both weights in [0,1]).
|
||||
if (candidate1 >= 0f && candidate1 <= 1f
|
||||
&& candidate2 >= 0f && candidate2 <= 1f)
|
||||
{
|
||||
w1 = candidate1;
|
||||
w2 = candidate2;
|
||||
}
|
||||
else
|
||||
{
|
||||
w1 = 1f - t;
|
||||
w2 = t;
|
||||
}
|
||||
}
|
||||
|
||||
return new Quaternion(
|
||||
w1 * q1.X + w2 * q2s.X,
|
||||
w1 * q1.Y + w2 * q2s.Y,
|
||||
w1 * q1.Z + w2 * q2s.Z,
|
||||
w1 * q1.W + w2 * q2s.W);
|
||||
}
|
||||
}
|
||||
440
tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs
Normal file
440
tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs
Normal file
|
|
@ -0,0 +1,440 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DatReaderWriter.Enums;
|
||||
using DatReaderWriter.Types;
|
||||
using Xunit;
|
||||
|
||||
// Alias the DatReaderWriter enum so it doesn't clash with
|
||||
// AcDream.Core.Physics.MotionCommand (which is a static class of uint constants).
|
||||
using DRWMotionCommand = DatReaderWriter.Enums.MotionCommand;
|
||||
|
||||
namespace AcDream.Core.Tests.Physics;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────<E29480><E29480>───
|
||||
// AnimationSequencerTests
|
||||
//
|
||||
// All tests run fully offline -- no DatCollection, no disk access.
|
||||
// We build in-memory Setup / MotionTable / Animation fixtures that drive
|
||||
// exactly the code paths we are testing.
|
||||
//
|
||||
// Covered:
|
||||
// 1. SlerpRetailClient matches System.Numerics slerp for standard cases.
|
||||
// 2. SlerpRetailClient handles dot < 0 (flips q2, takes shorter arc).
|
||||
// 3. SlerpRetailClient falls back to linear for near-parallel quaternions.
|
||||
// 4. Frame advancer wraps at HighFrame -> LowFrame (cycle loop).
|
||||
// 5. Advance at dt=0 returns identity frame (no motion table loaded).
|
||||
// 6. SetCycle transitions: link frames are prepended before the target cycle.
|
||||
// 7. GetLink returns null when MotionTable has no link for the transition.
|
||||
// 8. SetCycle with same motion twice is a no-op (fast path).
|
||||
// 9. Reset clears all state.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// In-memory IAnimationLoader test double. No filesystem access.
|
||||
/// </summary>
|
||||
file sealed class FakeLoader : IAnimationLoader
|
||||
{
|
||||
private readonly Dictionary<uint, Animation> _anims = new();
|
||||
|
||||
public void Register(uint id, Animation anim) => _anims[id] = anim;
|
||||
|
||||
public Animation? LoadAnimation(uint id) =>
|
||||
_anims.TryGetValue(id, out var a) ? a : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper to build minimal in-memory dat fixtures.
|
||||
/// </summary>
|
||||
file static class Fixtures
|
||||
{
|
||||
/// <summary>
|
||||
/// Build an Animation with <paramref name="numFrames"/> identical frames,
|
||||
/// each part having the supplied origin/orientation.
|
||||
/// </summary>
|
||||
public static Animation MakeAnim(int numFrames, int numParts,
|
||||
Vector3 origin, Quaternion orientation)
|
||||
{
|
||||
var anim = new Animation();
|
||||
for (int f = 0; f < numFrames; f++)
|
||||
{
|
||||
// AnimationFrame requires NumParts in its constructor.
|
||||
var pf = new AnimationFrame((uint)numParts);
|
||||
for (int p = 0; p < numParts; p++)
|
||||
pf.Frames.Add(new Frame { Origin = origin, Orientation = orientation });
|
||||
anim.PartFrames.Add(pf);
|
||||
}
|
||||
return anim;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build a two-frame animation: frame 0 has one origin/rotation, frame 1 another.
|
||||
/// Used to exercise slerp blending.
|
||||
/// </summary>
|
||||
public static Animation MakeTwoFrameAnim(
|
||||
int numParts,
|
||||
Vector3 fromOrigin, Quaternion fromRot,
|
||||
Vector3 toOrigin, Quaternion toRot)
|
||||
{
|
||||
var anim = new Animation();
|
||||
|
||||
var pf0 = new AnimationFrame((uint)numParts);
|
||||
var pf1 = new AnimationFrame((uint)numParts);
|
||||
for (int p = 0; p < numParts; p++)
|
||||
{
|
||||
pf0.Frames.Add(new Frame { Origin = fromOrigin, Orientation = fromRot });
|
||||
pf1.Frames.Add(new Frame { Origin = toOrigin, Orientation = toRot });
|
||||
}
|
||||
anim.PartFrames.Add(pf0);
|
||||
anim.PartFrames.Add(pf1);
|
||||
return anim;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build a minimal Setup with <paramref name="numParts"/> parts,
|
||||
/// each with a DefaultScale of (1,1,1).
|
||||
/// </summary>
|
||||
public static Setup MakeSetup(int numParts)
|
||||
{
|
||||
var setup = new Setup();
|
||||
for (int i = 0; i < numParts; i++)
|
||||
{
|
||||
setup.Parts.Add(0x01000000u + (uint)i); // synthetic GfxObj ids
|
||||
setup.DefaultScale.Add(Vector3.One);
|
||||
}
|
||||
return setup;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build a MotionTable with one cycle (style+motion) pointing to the
|
||||
/// given animation id, and optionally a link from (style, fromMotion)
|
||||
/// to (toMotion) pointing to <paramref name="linkAnimId"/>.
|
||||
/// </summary>
|
||||
public static MotionTable MakeMtable(
|
||||
uint style, uint motion, uint cycleAnimId,
|
||||
uint fromMotion = 0, uint toMotion = 0, uint linkAnimId = 0)
|
||||
{
|
||||
var mt = new MotionTable();
|
||||
mt.DefaultStyle = (DRWMotionCommand)style;
|
||||
mt.StyleDefaults[(DRWMotionCommand)style] = (DRWMotionCommand)motion;
|
||||
|
||||
int cycleKey = (int)((style << 16) | (motion & 0xFFFFFFu));
|
||||
mt.Cycles[cycleKey] = MakeMotionData(cycleAnimId, framerate: 30f);
|
||||
|
||||
if (fromMotion != 0 && toMotion != 0 && linkAnimId != 0)
|
||||
{
|
||||
int linkOuter = (int)((style << 16) | (fromMotion & 0xFFFFFFu));
|
||||
var cmd = new MotionCommandData();
|
||||
cmd.MotionData[(int)toMotion] = MakeMotionData(linkAnimId, framerate: 30f);
|
||||
mt.Links[linkOuter] = cmd;
|
||||
}
|
||||
|
||||
return mt;
|
||||
}
|
||||
|
||||
private static MotionData MakeMotionData(uint animId, float framerate)
|
||||
{
|
||||
var md = new MotionData();
|
||||
// QualifiedDataId<T> has an implicit conversion from uint.
|
||||
QualifiedDataId<Animation> qid = animId;
|
||||
md.Anims.Add(new AnimData
|
||||
{
|
||||
AnimId = qid,
|
||||
LowFrame = 0,
|
||||
HighFrame = -1, // sentinel -> resolve to numFrames-1
|
||||
Framerate = framerate,
|
||||
});
|
||||
return md;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class AnimationSequencerTests
|
||||
{
|
||||
// ── SlerpRetailClient ────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(0f)]
|
||||
[InlineData(0.25f)]
|
||||
[InlineData(0.5f)]
|
||||
[InlineData(0.75f)]
|
||||
[InlineData(1f)]
|
||||
public void SlerpRetailClient_MatchesNumerics_ForOrthogonalQuats(float t)
|
||||
{
|
||||
// Two quaternions 90 degrees apart (rotation around Z axis: 0 and 90 deg).
|
||||
var q1 = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0f);
|
||||
var q2 = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI / 2f);
|
||||
|
||||
var got = AnimationSequencer.SlerpRetailClient(q1, q2, t);
|
||||
var expected = Quaternion.Slerp(q1, q2, t);
|
||||
|
||||
Assert.Equal(expected.X, got.X, 4);
|
||||
Assert.Equal(expected.Y, got.Y, 4);
|
||||
Assert.Equal(expected.Z, got.Z, 4);
|
||||
Assert.Equal(expected.W, got.W, 4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SlerpRetailClient_HandlesNegativeDot_TakesShortArc()
|
||||
{
|
||||
// q2 is the antipodal of q1 (dot -> -1).
|
||||
var q1 = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.1f);
|
||||
var q2 = new Quaternion(-q1.X, -q1.Y, -q1.Z, -q1.W); // antipode
|
||||
|
||||
// At t=0 the result should be non-NaN (the sign-flip gives a valid quat).
|
||||
var got = AnimationSequencer.SlerpRetailClient(q1, q2, 0f);
|
||||
Assert.False(float.IsNaN(got.X));
|
||||
Assert.False(float.IsNaN(got.W));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SlerpRetailClient_NearParallel_LinearFallback()
|
||||
{
|
||||
// Two identical quaternions -> dot = 1 -> linear fallback path.
|
||||
var q = Quaternion.CreateFromAxisAngle(Vector3.UnitY, 0.3f);
|
||||
var got = AnimationSequencer.SlerpRetailClient(q, q, 0.5f);
|
||||
|
||||
Assert.Equal(q.X, got.X, 4);
|
||||
Assert.Equal(q.Y, got.Y, 4);
|
||||
Assert.Equal(q.Z, got.Z, 4);
|
||||
Assert.Equal(q.W, got.W, 4);
|
||||
}
|
||||
|
||||
// ── SetCycle / frame advance ────────────────────────────────────────<E29480><E29480>────
|
||||
|
||||
[Fact]
|
||||
public void Advance_NoCycleSet_ReturnsIdentityTransforms()
|
||||
{
|
||||
var setup = Fixtures.MakeSetup(3);
|
||||
var mt = new MotionTable();
|
||||
var loader = new FakeLoader();
|
||||
|
||||
var seq = new AnimationSequencer(setup, mt, loader);
|
||||
var transforms = seq.Advance(0.033f);
|
||||
|
||||
Assert.Equal(3, transforms.Count);
|
||||
foreach (var tr in transforms)
|
||||
{
|
||||
Assert.Equal(Vector3.Zero, tr.Origin);
|
||||
Assert.Equal(Quaternion.Identity, tr.Orientation);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetCycle_LoadsAnimation_AdvanceReturnsBoundedTransforms()
|
||||
{
|
||||
const uint Style = 0x003Du; // NonCombat
|
||||
const uint Motion = 0x0003u; // Ready
|
||||
const uint AnimId = 0x03000001u;
|
||||
|
||||
var origin = new Vector3(1f, 0f, 0f);
|
||||
var rot = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.5f);
|
||||
var anim = Fixtures.MakeTwoFrameAnim(2, origin, rot, origin * 2, rot);
|
||||
|
||||
var setup = Fixtures.MakeSetup(2);
|
||||
var mt = Fixtures.MakeMtable(Style, Motion, AnimId);
|
||||
var loader = new FakeLoader();
|
||||
loader.Register(AnimId, anim);
|
||||
|
||||
var seq = new AnimationSequencer(setup, mt, loader);
|
||||
seq.SetCycle(Style, Motion);
|
||||
|
||||
// Very small dt -> should be near the first frame's rotation.
|
||||
var transforms = seq.Advance(0.001f);
|
||||
|
||||
Assert.Equal(2, transforms.Count);
|
||||
// Orientation should be close to rot (first frame), not identity.
|
||||
Assert.True(Math.Abs(transforms[0].Orientation.Z - rot.Z) < 0.1f,
|
||||
$"Expected orientation near {rot.Z} but got {transforms[0].Orientation.Z}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Advance_FrameWrapsAtHighFrame()
|
||||
{
|
||||
const uint Style = 0x003Du;
|
||||
const uint Motion = 0x0003u;
|
||||
const uint AnimId = 0x03000002u;
|
||||
|
||||
// 4-frame animation; framerate=10fps, one full loop = 0.4s.
|
||||
var anim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity);
|
||||
var setup = Fixtures.MakeSetup(1);
|
||||
var mt = new MotionTable();
|
||||
mt.DefaultStyle = (DRWMotionCommand)Style;
|
||||
mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)Motion;
|
||||
int cycleKey = (int)((Style << 16) | (Motion & 0xFFFFFFu));
|
||||
mt.Cycles[cycleKey] = new MotionData();
|
||||
QualifiedDataId<Animation> qid = AnimId;
|
||||
mt.Cycles[cycleKey].Anims.Add(new AnimData
|
||||
{
|
||||
AnimId = qid,
|
||||
LowFrame = 0,
|
||||
HighFrame = 3,
|
||||
Framerate = 10f,
|
||||
});
|
||||
|
||||
var loader = new FakeLoader();
|
||||
loader.Register(AnimId, anim);
|
||||
|
||||
var seq = new AnimationSequencer(setup, mt, loader);
|
||||
seq.SetCycle(Style, Motion);
|
||||
|
||||
// Advance one full loop + a bit: 0.5s at 10fps = 5 frames.
|
||||
// After wrapping this should still return a valid transform.
|
||||
seq.Advance(0.5f);
|
||||
var transforms = seq.Advance(0.01f);
|
||||
|
||||
Assert.Single(transforms);
|
||||
// No exception = pass; the wrap produced a valid (non-crash) frame.
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetCycle_WithTransitionLink_PrependLinkFrames()
|
||||
{
|
||||
// Two animations: link (2 frames at Y=1) and cycle (4 frames at X=1).
|
||||
const uint Style = 0x003Du;
|
||||
const uint IdleMotion = 0x0003u;
|
||||
const uint WalkMotion = 0x0005u;
|
||||
const uint CycleAnim = 0x03000010u;
|
||||
const uint LinkAnim = 0x03000011u;
|
||||
|
||||
var cycleAnim = Fixtures.MakeAnim(4, 1, new Vector3(1, 0, 0), Quaternion.Identity);
|
||||
var linkAnim = Fixtures.MakeAnim(2, 1, new Vector3(0, 1, 0), Quaternion.Identity);
|
||||
|
||||
var setup = Fixtures.MakeSetup(1);
|
||||
// MotionTable: link Idle->Walk = 2-frame transition anim.
|
||||
var mt = Fixtures.MakeMtable(
|
||||
style: Style,
|
||||
motion: WalkMotion,
|
||||
cycleAnimId: CycleAnim,
|
||||
fromMotion: IdleMotion,
|
||||
toMotion: WalkMotion,
|
||||
linkAnimId: LinkAnim);
|
||||
|
||||
var loader = new FakeLoader();
|
||||
loader.Register(CycleAnim, cycleAnim);
|
||||
loader.Register(LinkAnim, linkAnim);
|
||||
|
||||
var seq = new AnimationSequencer(setup, mt, loader);
|
||||
|
||||
// Prime the sequencer as if it was already playing IdleMotion.
|
||||
SetCurrentMotion(seq, Style, IdleMotion);
|
||||
|
||||
seq.SetCycle(Style, WalkMotion);
|
||||
|
||||
// At t~0 we should be reading the link anim (Y=1), not the cycle (X=1).
|
||||
var transforms = seq.Advance(0.001f);
|
||||
Assert.Single(transforms);
|
||||
Assert.True(transforms[0].Origin.Y > transforms[0].Origin.X,
|
||||
$"Expected link-anim Y({transforms[0].Origin.Y}) > cycle X({transforms[0].Origin.X})");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetCycle_NoLinkInTable_DirectCycleSwitch()
|
||||
{
|
||||
const uint Style = 0x003Du;
|
||||
const uint Motion = 0x0003u;
|
||||
const uint AnimId = 0x03000020u;
|
||||
|
||||
var anim = Fixtures.MakeAnim(3, 1, new Vector3(5, 0, 0), Quaternion.Identity);
|
||||
var setup = Fixtures.MakeSetup(1);
|
||||
var mt = Fixtures.MakeMtable(Style, Motion, AnimId);
|
||||
var loader = new FakeLoader();
|
||||
loader.Register(AnimId, anim);
|
||||
|
||||
var seq = new AnimationSequencer(setup, mt, loader);
|
||||
seq.SetCycle(Style, Motion); // no link registered -> direct cycle
|
||||
|
||||
var transforms = seq.Advance(0.001f);
|
||||
Assert.Single(transforms);
|
||||
// Should get cycle origin X~5 since there's no link.
|
||||
Assert.True(transforms[0].Origin.X > 4f,
|
||||
$"Expected cycle origin X~5 but got {transforms[0].Origin.X}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetCycle_SameMotionTwice_NoStateChange()
|
||||
{
|
||||
const uint Style = 0x003Du;
|
||||
const uint Motion = 0x0003u;
|
||||
const uint AnimId = 0x03000030u;
|
||||
|
||||
var anim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity);
|
||||
var setup = Fixtures.MakeSetup(1);
|
||||
var mt = Fixtures.MakeMtable(Style, Motion, AnimId);
|
||||
var loader = new FakeLoader();
|
||||
loader.Register(AnimId, anim);
|
||||
|
||||
var seq = new AnimationSequencer(setup, mt, loader);
|
||||
seq.SetCycle(Style, Motion);
|
||||
|
||||
// Advance a bit to move the frame counter.
|
||||
seq.Advance(0.1f);
|
||||
|
||||
float frameBefore = GetFrameNum(seq);
|
||||
|
||||
// Call SetCycle again with identical args -- fast-path, no reset.
|
||||
seq.SetCycle(Style, Motion);
|
||||
|
||||
float frameAfter = GetFrameNum(seq);
|
||||
|
||||
Assert.Equal(frameBefore, frameAfter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_ClearsAllState()
|
||||
{
|
||||
const uint Style = 0x003Du;
|
||||
const uint Motion = 0x0003u;
|
||||
const uint AnimId = 0x03000040u;
|
||||
|
||||
var anim = Fixtures.MakeAnim(4, 1, Vector3.One, Quaternion.Identity);
|
||||
var setup = Fixtures.MakeSetup(1);
|
||||
var mt = Fixtures.MakeMtable(Style, Motion, AnimId);
|
||||
var loader = new FakeLoader();
|
||||
loader.Register(AnimId, anim);
|
||||
|
||||
var seq = new AnimationSequencer(setup, mt, loader);
|
||||
seq.SetCycle(Style, Motion);
|
||||
seq.Advance(0.2f);
|
||||
|
||||
seq.Reset();
|
||||
|
||||
Assert.Equal(0u, seq.CurrentStyle);
|
||||
Assert.Equal(0u, seq.CurrentMotion);
|
||||
|
||||
// After reset, Advance should return identity transforms.
|
||||
var transforms = seq.Advance(0.033f);
|
||||
foreach (var tr in transforms)
|
||||
{
|
||||
Assert.Equal(Vector3.Zero, tr.Origin);
|
||||
Assert.Equal(Quaternion.Identity, tr.Orientation);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Expose _frameNum via reflection (test-only).</summary>
|
||||
private static float GetFrameNum(AnimationSequencer seq)
|
||||
{
|
||||
var field = typeof(AnimationSequencer)
|
||||
.GetField("_frameNum",
|
||||
System.Reflection.BindingFlags.NonPublic |
|
||||
System.Reflection.BindingFlags.Instance);
|
||||
return field is null ? -1f : (float)field.GetValue(seq)!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Directly set CurrentStyle and CurrentMotion via reflection so the
|
||||
/// transition-link test can simulate "we were already playing IdleMotion".
|
||||
/// Both are auto-properties with private setters.
|
||||
/// </summary>
|
||||
private static void SetCurrentMotion(AnimationSequencer seq, uint style, uint motion)
|
||||
{
|
||||
var t = typeof(AnimationSequencer);
|
||||
t.GetProperty(nameof(AnimationSequencer.CurrentStyle))!
|
||||
.SetValue(seq, style);
|
||||
t.GetProperty(nameof(AnimationSequencer.CurrentMotion))!
|
||||
.SetValue(seq, motion);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue