feat(app): Phase 6.5 — slerp between adjacent keyframes for smooth animation

Phase 6.4 advanced CurrFrame as a float but only sampled the integer
floor, so animations stepped instead of flowing. Phase 6.5 takes the
fractional part as a blend factor t and slerps each part's
orientation + lerps its origin between PartFrames[floor] and
PartFrames[floor+1] (wrapping back to LowFrame at HighFrame). The
result is smooth motion at any framerate without changing the cycle
length or frame indices.

Defensive: if a part index exceeds the keyframe's bone list (rare,
typically when AnimPartChanges grew the part count above the
animation's NumParts) it falls back to identity instead of throwing.

160 tests still green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-11 19:09:36 +02:00
parent f0fa067566
commit 225e75b8b4

View file

@ -1002,10 +1002,23 @@ public sealed class GameWindow : IDisposable
ae.CurrFrame = ae.LowFrame;
}
int frameIdx = (int)ae.CurrFrame;
if (frameIdx < 0 || frameIdx >= ae.Animation.PartFrames.Count)
// Phase 6.5: blend between adjacent keyframes using the fractional
// part of CurrFrame so the animation is smooth at any framerate
// instead of snapping to integer frame indices.
int frameIdx = (int)Math.Floor(ae.CurrFrame);
if (frameIdx < ae.LowFrame || frameIdx > ae.HighFrame
|| frameIdx >= ae.Animation.PartFrames.Count)
frameIdx = ae.LowFrame;
int nextIdx = frameIdx + 1;
if (nextIdx > ae.HighFrame || nextIdx >= ae.Animation.PartFrames.Count)
nextIdx = ae.LowFrame; // cycle wraps within [LowFrame, HighFrame]
float t = ae.CurrFrame - frameIdx;
if (t < 0f) t = 0f; else if (t > 1f) t = 1f;
var partFrames = ae.Animation.PartFrames[frameIdx].Frames;
var partFramesNext = ae.Animation.PartFrames[nextIdx].Frames;
int partCount = ae.PartTemplate.Count;
var newMeshRefs = new List<AcDream.Core.World.MeshRef>(partCount);
@ -1015,18 +1028,25 @@ public sealed class GameWindow : IDisposable
for (int i = 0; i < partCount; i++)
{
// Frame source: the animation's per-part transform when
// the index is in range, otherwise identity (parts that
// outnumber the animation's bone list — rare but defensive).
DatReaderWriter.Types.Frame frame;
// Slerp between the current and next keyframe per part.
// Out-of-range parts get an identity transform — defensive
// for setups whose part count exceeds the animation's bone
// count.
System.Numerics.Vector3 origin;
System.Numerics.Quaternion orientation;
if (i < partFrames.Count)
frame = partFrames[i];
{
var f0 = partFrames[i];
var f1 = i < partFramesNext.Count ? partFramesNext[i] : f0;
origin = System.Numerics.Vector3.Lerp(f0.Origin, f1.Origin, t);
orientation = System.Numerics.Quaternion.Slerp(f0.Orientation, f1.Orientation, t);
}
else
frame = new DatReaderWriter.Types.Frame
{
Origin = System.Numerics.Vector3.Zero,
Orientation = System.Numerics.Quaternion.Identity,
};
{
origin = System.Numerics.Vector3.Zero;
orientation = System.Numerics.Quaternion.Identity;
}
var frame = new DatReaderWriter.Types.Frame { Origin = origin, Orientation = orientation };
// Per-part default scale from the Setup, matching SetupMesh.Flatten's
// composition order: scale → rotate → translate.