refactor(anim): rewrite AnimationSequencer as faithful decompiled-client port

Complete ground-up rewrite of AnimationSequencer.cs using the retail AC client
pseudocode (docs/research/acclient_animation_pseudocode.md) as the direct
translation guide. Every key algorithmic difference from the previous patched
implementation is addressed:

1. _framePosition is now double (64-bit), matching Sequence+0x30 in the retail
   client binary. Previously float, which accumulated rounding error over long
   sessions.

2. FUN_005267E0 (multiply_framerate) is now correctly applied at node load time:
   negative speedScale swaps startFrame↔endFrame so the advance loop counts DOWN
   from (EndFrame+1)-epsilon toward EndFrame, exactly matching the retail layout.

3. update_internal (FUN_005261D0) is faithfully ported: one loop handles both
   forward and reverse; boundary detection uses EndFrame as the lower bound for
   reverse playback (matching the post-swap field semantics); remainder time
   propagates correctly across node boundaries for large dt values.

4. GetStartFramePosition (FUN_00526880) and GetEndFramePosition (FUN_005268B0)
   formulas are now correct: negative speed starts at (EndFrame+1)-epsilon,
   ends at StartFrame; positive speed starts at StartFrame, ends at (EndFrame+1)-epsilon.

5. advance_to_next_animation (FUN_00525EB0) wraps to _firstCyclic when the
   linked list is exhausted, matching the retail loop-forever semantics.

6. adjust_motion (ACE MotionInterp.cs:394-428) remapping is unchanged and
   correct: TurnLeft→TurnRight, SideStepLeft→SideStepRight (negate speed),
   WalkBackward→WalkForward (negate×0.65 BackwardsFactor).

7. SlerpRetailClient (FUN_005360d0) is unchanged — the pseudocode confirms the
   existing implementation is correct.

AnimationSequencerTests grows from 9 to 17 tests:
- Negative-speed playback: TurnLeft remaps and cursor initializes near EndFrame+1
- Reverse frame position decreases (not increases) over time
- Reverse wrap at start boundary recovers and loops
- advance_to_next_animation: link node drains then enters cycle
- Cycle loops repeatedly without crash or position drift

All 431 tests green (109 net + 322 core).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-13 12:59:32 +02:00
parent 8402aee703
commit 78aef6d575
2 changed files with 478 additions and 145 deletions

View file

@ -30,16 +30,17 @@ public sealed class DatCollectionLoader : IAnimationLoader
}
// ─────────────────────────────────────────────────────────────────────────────
// AnimationSequencer — per-entity animation playback with transition links.
// AnimationSequencer — faithful port of the decompiled retail AC client
// animation system.
//
// 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).
// Primary references (pseudocode at docs/research/acclient_animation_pseudocode.md):
// FUN_005267E0 — multiply_framerate: swaps startFrame↔endFrame for negative speed
// FUN_005261D0 — update_internal: the core per-frame advance loop
// FUN_00525EB0 — advance_to_next_animation: node transition + wrap to firstCyclic
// FUN_00526880 — GetStartFramePosition: double start pos (speed-dependent)
// FUN_005268B0 — GetEndFramePosition: double end pos (speed-dependent)
// FUN_005360d0 — quaternion slerp with dot-product sign-flip
// MotionInterp.cs:394-428 (ACE) — adjust_motion: left→right remapping
//
// DatReaderWriter types used:
// MotionTable.Links : Dictionary<int, MotionCommandData>
@ -71,32 +72,84 @@ public readonly struct PartTransform
/// <summary>
/// One entry in the animation queue (link transition or looping cycle).
///
/// Faithfully models the retail client AnimNode struct at +0x0C..+0x18.
/// When speedScale &lt; 0, startFrame and endFrame are swapped at construction
/// time (FUN_005267E0 / multiply_framerate) so the advance loop always has:
/// forward: startFrame ≤ endFrame (framePosition counts up)
/// reverse: startFrame ≥ endFrame (framePosition counts down)
/// </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 double Framerate; // signed; negative means reverse playback
public int StartFrame; // inclusive start frame (post-swap for negative speed)
public int EndFrame; // inclusive end frame (post-swap for negative speed)
public bool IsLooping; // true only for the tail cyclic node
public AnimNode(Animation anim, float framerate, int lowFrame, int highFrame, bool isLooping)
public AnimNode(Animation anim, double framerate, int startFrame, int endFrame, bool isLooping)
{
Anim = anim;
Framerate = framerate;
LowFrame = lowFrame;
HighFrame = highFrame;
StartFrame = startFrame;
EndFrame = endFrame;
IsLooping = isLooping;
}
public float StartingFrame => Framerate >= 0f ? LowFrame : HighFrame + 1 - 1e-5f;
public float EndingFrame => Framerate >= 0f ? HighFrame + 1 - 1e-5f : LowFrame;
// ── FUN_00526880 — GetStartFramePosition ──────────────────────────────
// Returns the initial framePosition cursor for this node.
// speedScale >= 0 → (double)startFrame
// speedScale < 0 → (double)(endFrame + 1) - EPSILON
// EPSILON = _DAT_007c92b4 (a tiny float just below the boundary)
public double GetStartFramePosition()
{
if (Framerate >= 0.0)
return (double)StartFrame;
else
return (double)(EndFrame + 1) - FrameEpsilon;
}
// ── FUN_005268B0 — GetEndFramePosition ───────────────────────────────
// Returns where the cursor sits when this node is exhausted.
// speedScale >= 0 → (double)(endFrame + 1) - EPSILON
// speedScale < 0 → (double)startFrame
public double GetEndFramePosition()
{
if (Framerate >= 0.0)
return (double)(EndFrame + 1) - FrameEpsilon;
else
return (double)StartFrame;
}
// Small double constant matching _DAT_007c92b4 in the retail binary.
// Used to position the cursor just before a frame boundary.
private const double FrameEpsilon = 1e-5;
}
/// <summary>
/// Full animation playback engine for one entity.
///
/// <para>
/// This is a faithful port of the retail AC client's Sequence object
/// (docs/research/acclient_animation_pseudocode.md, sections 57).
/// Key invariants:
/// <list type="bullet">
/// <item><description>
/// <c>_framePosition</c> is a <c>double</c> matching the retail client's
/// 64-bit field at Sequence+0x30.
/// </description></item>
/// <item><description>
/// Negative framerate means reverse playback; startFrame/endFrame are
/// swapped at node construction time (FUN_005267E0).
/// </description></item>
/// <item><description>
/// When a node's frames are exhausted, <c>advance_to_next_animation</c>
/// wraps to <c>_firstCyclic</c> (the looping tail of the queue).
/// </description></item>
/// </list>
/// </para>
///
/// <para>
/// Usage pattern:
/// <code>
/// var seq = new AnimationSequencer(setup, motionTable, dats);
@ -106,13 +159,6 @@ internal sealed class AnimNode
/// // 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
{
@ -134,9 +180,13 @@ public sealed class AnimationSequencer
private readonly LinkedList<AnimNode> _queue = new();
private LinkedListNode<AnimNode>? _currNode;
private LinkedListNode<AnimNode>? _firstCyclic;
private float _frameNum;
private const float Epsilon = 1e-5f;
// 64-bit fractional frame position — matches Sequence+0x30 in the retail client.
// Named _framePosition to distinguish it from the old float _frameNum.
private double _framePosition;
private const double FrameEpsilon = 1e-5;
private const double RateEpsilon = 1e-6;
// ── Constructor ──────────────────────────────────────────────────────────
@ -166,33 +216,39 @@ public sealed class AnimationSequencer
/// 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).
/// switches immediately.
///
/// <para>
/// Implements <c>adjust_motion</c> (ACE MotionInterp.cs:394-428): the AC
/// MotionTable has NO cycles for TurnLeft, SideStepLeft, or WalkBackward.
/// These are played as their right-side / forward equivalents with a
/// negated framerate so the animation runs in reverse.
/// </para>
/// </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)
{
// ── adjust_motion: remap left→right variants with negative speed ───
// The AC client's MotionTable has NO cycles for TurnLeft, SideStepLeft,
// or WalkBackward. These are played as their right-side / forward
// equivalents with negative framerate (animation runs backward).
// ACE: MotionInterp.cs:394-428
// ── adjust_motion: remap left→right / backward→forward variants ───
// ACE MotionInterp.cs:394-428. The MotionTable never stores TurnLeft,
// SideStepLeft, or WalkBackward cycles; the client plays the mirror
// animation with a negated speed so it runs backward.
uint adjustedMotion = motion;
float adjustedSpeed = speedMod;
switch (motion & 0xFFFFu)
{
case 0x000E: // TurnLeft → TurnRight
case 0x000E: // TurnLeft → TurnRight (negate speed)
adjustedMotion = (motion & 0xFFFF0000u) | 0x000Du;
adjustedSpeed *= -1f;
adjustedSpeed = -speedMod;
break;
case 0x0010: // SideStepLeft → SideStepRight
case 0x0010: // SideStepLeft → SideStepRight (negate speed)
adjustedMotion = (motion & 0xFFFF0000u) | 0x000Fu;
adjustedSpeed *= -1f;
adjustedSpeed = -speedMod;
break;
case 0x0006: // WalkBackward → WalkForward
case 0x0006: // WalkBackward → WalkForward (negate + BackwardsFactor)
adjustedMotion = (motion & 0xFFFF0000u) | 0x0005u;
adjustedSpeed *= -0.65f; // BackwardsFactor from ACE
adjustedSpeed = -speedMod * 0.65f; // BackwardsFactor from ACE
break;
}
@ -206,7 +262,7 @@ public sealed class AnimationSequencer
? GetLink(style, CurrentMotion, adjustedMotion)
: null;
// 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));
_mtable.Cycles.TryGetValue(cycleKey, out var cycleData);
@ -228,15 +284,13 @@ public sealed class AnimationSequencer
// No cycle and no link — nothing to play; reset fully.
_currNode = null;
_firstCyclic = null;
_frameNum = 0f;
_framePosition = 0.0;
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.
// Mark the first cyclic node (the looping tail after all link frames).
_firstCyclic = null;
for (var n = _queue.First; n != null; n = n.Next)
{
@ -251,7 +305,7 @@ public sealed class AnimationSequencer
if (_currNode == null)
{
_currNode = _queue.First;
_frameNum = _currNode?.Value.StartingFrame ?? 0f;
_framePosition = _currNode?.Value.GetStartFramePosition() ?? 0.0;
}
CurrentStyle = style;
@ -263,10 +317,15 @@ public sealed class AnimationSequencer
/// per-part transforms for the current blended keyframe.
///
/// <para>
/// Implements <c>Sequence::update_internal</c> (FUN_005261D0) in a
/// simplified form: no frame-trigger events (PhysicsObject not modelled
/// here), but correct boundary detection, remainder propagation, and
/// advance_to_next_animation wrapping.
/// </para>
///
/// <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.
/// <c>FUN_005360d0</c> (chunk_00530000.c:4799).
/// </para>
/// </summary>
/// <param name="dt">Elapsed time in seconds since the last call.</param>
@ -281,42 +340,73 @@ public sealed class AnimationSequencer
if (_currNode == null || dt <= 0f)
return BuildIdentityFrame(partCount);
var curr = _currNode.Value;
float framerate = curr.Framerate;
float frametime = framerate * dt;
// ── update_internal (FUN_005261D0) ───────────────────────────────
// Loop because a large dt can exhaust multiple nodes sequentially.
double timeRemaining = (double)dt;
bool animDone = false;
float timeRemainder = 0f;
_frameNum += frametime;
if (frametime > 0f)
while (timeRemaining > 0.0 && _currNode != null)
{
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;
}
}
var curr = _currNode.Value;
double rate = curr.Framerate; // signed (negative = reverse)
double delta = rate * timeRemaining;
if (animDone)
if (Math.Abs(delta) < RateEpsilon)
break; // rate ≈ 0 — nothing to do
double newPos = _framePosition + delta;
bool wrapped = false;
double overflow = 0.0;
if (delta > 0.0)
{
// ── FORWARD PLAYBACK ──────────────────────────────────────
// End boundary = endFrame + 1. Pseudocode: floor(newPos) > maxFrame.
double maxBoundary = (double)(curr.EndFrame + 1);
if (newPos >= maxBoundary - FrameEpsilon)
{
// How much time spilled past the boundary?
overflow = (newPos - maxBoundary) / rate;
if (overflow < 0.0) overflow = 0.0;
_framePosition = maxBoundary - FrameEpsilon; // clamp to last valid pos
wrapped = true;
}
else
{
_framePosition = newPos;
}
}
else
{
// ── REVERSE PLAYBACK ─────────────────────────────────────
// After FUN_005267E0 swaps low↔high for negative speed:
// StartFrame = high (e.g. 3), EndFrame = low (e.g. 0)
// GetStartFramePosition placed cursor at (EndFrame+1)-eps ≈ 0.99999.
// The cursor counts DOWN toward EndFrame. Boundary = EndFrame.
double minBoundary = (double)curr.EndFrame;
if (newPos <= minBoundary)
{
// How much time spilled past the lower boundary?
overflow = (newPos - minBoundary) / rate;
if (overflow < 0.0) overflow = 0.0;
_framePosition = minBoundary; // clamp to lower boundary
wrapped = true;
}
else
{
_framePosition = newPos;
}
}
if (!wrapped)
break; // consumed all dt without hitting boundary — done
// ── advance_to_next_animation (FUN_00525EB0) ─────────────────
AdvanceToNextAnimation();
timeRemaining = overflow; // continue with leftover time
}
// Build the blended frame.
return BuildBlendedFrame();
}
@ -329,7 +419,7 @@ public sealed class AnimationSequencer
_queue.Clear();
_currNode = null;
_firstCyclic = null;
_frameNum = 0f;
_framePosition = 0.0;
CurrentStyle = 0;
CurrentMotion = 0;
}
@ -340,7 +430,7 @@ public sealed class AnimationSequencer
/// 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):
/// Port of ACE's MotionTable.get_link:
/// 1. Try Links[(style&lt;&lt;16)|(fromMotion&amp;0xFFFFFF)][toMotion]
/// 2. Fallback: try Links[style&lt;&lt;16][toMotion]
///
@ -370,7 +460,10 @@ public sealed class AnimationSequencer
/// <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.
///
/// Implements <c>FUN_005267E0</c> (multiply_framerate): when
/// <c>fr &lt; 0</c>, startFrame and endFrame are swapped so the advance
/// loop's boundary logic works uniformly for both directions.
/// </summary>
private AnimNode? LoadAnimNode(AnimData ad, float speedMod, bool isLooping)
{
@ -381,32 +474,34 @@ public sealed class AnimationSequencer
if (anim is null || anim.PartFrames.Count == 0) return null;
int numFrames = anim.PartFrames.Count;
int low = ad.LowFrame;
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 < 0) high = numFrames - 1;
if (low >= numFrames) low = numFrames - 1;
if (high >= numFrames) high = numFrames - 1;
if (low < 0) low = 0;
if (low < 0) low = 0;
float fr = ad.Framerate * speedMod;
double fr = (double)ad.Framerate * (double)speedMod;
// multiply_framerate: when speed is negative (TurnLeft, SideStepLeft),
// swap Low↔High so the animation plays backward. This is exactly what
// the decompiled FUN_005267E0 does. ACE: AnimData.GetFramerate(speed).
// After swap, LowFrame > HighFrame — the Advance loop handles this
// by checking negative frametime against LowFrame (the higher value).
if (fr < 0f)
// ── FUN_005267E0 multiply_framerate ──────────────────────────────
// When speed is negative (TurnLeft→TurnRight, SideStepLeft→SideStepRight),
// swap Low↔High so the advance loop counts DOWN from the swapped EndFrame
// toward the swapped StartFrame. The pseudocode says:
// if speedScale < 0: swap startFrame ↔ endFrame
if (fr < 0.0)
{
(low, high) = (high, low);
// After swap: StartFrame > EndFrame (the loop detects delta < 0 and
// uses StartFrame as the lower boundary to count down toward).
}
else
{
if (low > high) high = low; // only clamp for positive-speed case
if (low > high) high = low; // clamp for positive-speed case only
}
return new AnimNode(anim, fr, low, high, isLooping);
return new AnimNode(anim, fr, startFrame: low, endFrame: high, isLooping);
}
/// <summary>
@ -425,8 +520,9 @@ public sealed class AnimationSequencer
}
/// <summary>
/// Remove all cyclic (looping) nodes from the tail of the queue, starting
/// from <see cref="_firstCyclic"/>. Non-cyclic link frames remain.
/// Remove all cyclic (looping) nodes from the tail of the queue starting
/// from <see cref="_firstCyclic"/>. Non-cyclic link frames remain so they
/// can drain naturally.
/// </summary>
private void ClearCyclicTail()
{
@ -436,14 +532,15 @@ public sealed class AnimationSequencer
while (node != null)
{
var next = node.Next;
// If CurrAnim is being removed, jump it to the previous non-cyclic node.
// If the active node is being removed, jump it to the preceding
// non-cyclic node (or reset if there is none).
if (_currNode == node)
{
_currNode = node.Previous;
if (_currNode != null)
_frameNum = _currNode.Value.EndingFrame;
_framePosition = _currNode.Value.GetEndFramePosition();
else
_frameNum = 0f;
_framePosition = 0.0;
}
_queue.Remove(node);
node = next;
@ -453,28 +550,42 @@ public sealed class AnimationSequencer
}
/// <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>.
/// Move <see cref="_currNode"/> to the next node in the queue, or wrap
/// back to <see cref="_firstCyclic"/> when the queue is exhausted.
///
/// Implements <c>FUN_00525EB0</c> (Sequence::advance_to_next_animation).
/// The retail client walks a doubly-linked list; we mirror that with
/// LinkedList.Next plus the _firstCyclic wrap sentinel.
/// </summary>
private void AdvanceToNextAnimation()
{
if (_currNode == null) return;
if (_currNode.Next != null)
_currNode = _currNode.Next;
LinkedListNode<AnimNode>? next = _currNode.Next;
if (next != null)
{
_currNode = next;
}
else if (_firstCyclic != null)
{
// Wrap to first cyclic node — this is the loop that keeps idle/walk
// animations playing forever.
_currNode = _firstCyclic;
// else: end of non-looping sequence — stay on last frame.
}
// else: end of a finite non-looping sequence; stay on last node.
if (_currNode != null)
_frameNum = _currNode.Value.StartingFrame;
_framePosition = _currNode.Value.GetStartFramePosition();
}
/// <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.
/// Blends between floor(_framePosition) and floor(_framePosition)+1 using
/// the fractional part of _framePosition.
///
/// Uses the retail-client slerp (<see cref="SlerpRetailClient"/>) for
/// quaternion interpolation and linear lerp for position.
/// </summary>
private IReadOnlyList<PartTransform> BuildBlendedFrame()
{
@ -486,31 +597,32 @@ public sealed class AnimationSequencer
var curr = _currNode.Value;
int numPartFrames = curr.Anim.PartFrames.Count;
int frameIdx = (int)Math.Floor(_frameNum);
// For backward playback, LowFrame > HighFrame. Use actual min/max
// of the two to get a valid range for clamping.
int rangeLo = Math.Min(curr.LowFrame, curr.HighFrame);
int rangeHi = Math.Min(Math.Max(curr.LowFrame, curr.HighFrame), numPartFrames - 1);
// Clamp frameIndex to valid range.
int rangeLo = Math.Min(curr.StartFrame, curr.EndFrame);
int rangeHi = Math.Max(curr.StartFrame, curr.EndFrame);
rangeHi = Math.Min(rangeHi, numPartFrames - 1);
int frameIdx = (int)Math.Floor(_framePosition);
frameIdx = Math.Clamp(frameIdx, rangeLo, rangeHi);
// Next frame for interpolation: step in the playback direction.
int nextIdx;
if (curr.Framerate >= 0f)
if (curr.Framerate >= 0.0)
{
nextIdx = frameIdx + 1;
if (nextIdx > rangeHi || nextIdx >= numPartFrames)
nextIdx = rangeLo; // wrap forward
nextIdx = rangeLo; // wrap forward
}
else
{
nextIdx = frameIdx - 1;
if (nextIdx < rangeLo)
nextIdx = rangeHi; // wrap backward
nextIdx = rangeHi; // wrap backward
}
float t = _frameNum - (float)Math.Floor(_frameNum);
if (t < 0f) t = 0f;
if (t > 1f) t = 1f;
// Fractional blend weight (always in [0, 1]).
double rawT = _framePosition - Math.Floor(_framePosition);
float t = (float)Math.Clamp(rawT, 0.0, 1.0);
var f0Parts = curr.Anim.PartFrames[frameIdx].Frames;
var f1Parts = curr.Anim.PartFrames[nextIdx].Frames;
@ -585,9 +697,9 @@ public sealed class AnimationSequencer
}
else
{
float omega = MathF.Acos(dot);
float omega = MathF.Acos(dot);
float sinOmega = MathF.Sin(omega);
float invSin = 1f / sinOmega;
float invSin = 1f / sinOmega;
float candidate1 = MathF.Sin((1f - t) * omega) * invSin;
float candidate2 = MathF.Sin(t * omega) * invSin;