From 225e75b8b4101e4f343018b1743e8ceb4e94d221 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 11 Apr 2026 19:09:36 +0200 Subject: [PATCH] =?UTF-8?q?feat(app):=20Phase=206.5=20=E2=80=94=20slerp=20?= =?UTF-8?q?between=20adjacent=20keyframes=20for=20smooth=20animation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/AcDream.App/Rendering/GameWindow.cs | 44 ++++++++++++++++++------- 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 4578f20..ba1c021 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -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(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.