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; ae.CurrFrame = ae.LowFrame;
} }
int frameIdx = (int)ae.CurrFrame; // Phase 6.5: blend between adjacent keyframes using the fractional
if (frameIdx < 0 || frameIdx >= ae.Animation.PartFrames.Count) // 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; 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 partFrames = ae.Animation.PartFrames[frameIdx].Frames;
var partFramesNext = ae.Animation.PartFrames[nextIdx].Frames;
int partCount = ae.PartTemplate.Count; int partCount = ae.PartTemplate.Count;
var newMeshRefs = new List<AcDream.Core.World.MeshRef>(partCount); 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++) for (int i = 0; i < partCount; i++)
{ {
// Frame source: the animation's per-part transform when // Slerp between the current and next keyframe per part.
// the index is in range, otherwise identity (parts that // Out-of-range parts get an identity transform — defensive
// outnumber the animation's bone list — rare but defensive). // for setups whose part count exceeds the animation's bone
DatReaderWriter.Types.Frame frame; // count.
System.Numerics.Vector3 origin;
System.Numerics.Quaternion orientation;
if (i < partFrames.Count) 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 else
frame = new DatReaderWriter.Types.Frame {
{ origin = System.Numerics.Vector3.Zero;
Origin = System.Numerics.Vector3.Zero, orientation = System.Numerics.Quaternion.Identity;
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 // Per-part default scale from the Setup, matching SetupMesh.Flatten's
// composition order: scale → rotate → translate. // composition order: scale → rotate → translate.