From ed37f0969b567f7718efe90ba4aafe36098f2552 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 13 Apr 2026 00:40:52 +0200 Subject: [PATCH] feat(anim): carefully integrate AnimationSequencer into TickAnimations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surgical integration that ONLY replaces the frame interpolation path, preserving the exact transform composition pipeline that builds MeshRefs. The key insight from the reverted attempt: the sequencer's Advance(dt) returns raw (Origin, Orientation) per part — these feed into the SAME transform composition as before: CreateScale(defaultScale) * CreateFromQuat(orientation) * CreateTranslation(origin) then * scaleMat for entity ObjScale then → MeshRef with template GfxObjId + SurfaceOverrides What changed: - AnimatedEntity gains optional Sequencer field - DatCollectionLoader created once at dat-open time - Entity registration creates sequencer when MotionTable is loadable - TickAnimations: if sequencer exists, Advance(dt) produces per-part transforms; otherwise falls back to Phase 6.5 manual slerp - Transform composition + MeshRef building is UNCHANGED What was NOT changed (the previous attempt broke these): - Per-part defaultScale application - Entity ObjScale (scaleMat) multiplication - PartTemplate GfxObjId + SurfaceOverrides forwarding - The entire MeshRef construction block Co-Authored-By: Claude Opus 4.6 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 134 ++++++++++++++++-------- 1 file changed, 92 insertions(+), 42 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 283497f..4c739f1 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -91,8 +91,18 @@ public sealed class GameWindow : IDisposable /// public required IReadOnlyList<(uint GfxObjId, IReadOnlyDictionary? SurfaceOverrides)> PartTemplate; public float CurrFrame; // monotonically increasing within [LowFrame, HighFrame] + /// + /// Optional AnimationSequencer for transition-link-aware playback. + /// When non-null, TickAnimations uses Advance(dt) for frame data + /// instead of the manual slerp path. Null for entities whose + /// MotionTable couldn't be loaded. + /// + public AcDream.Core.Physics.AnimationSequencer? Sequencer; } + /// Shared animation dat loader for all AnimationSequencer instances. + private AcDream.Core.Physics.DatCollectionLoader? _animLoader; + // Phase B.2: player movement mode. private AcDream.App.Input.PlayerMovementController? _playerController; private AcDream.App.Rendering.ChaseCamera? _chaseCamera; @@ -323,6 +333,7 @@ public sealed class GameWindow : IDisposable _cameraController.ModeChanged += OnCameraModeChanged; _dats = new DatCollection(_datDir, DatAccessType.Read); + _animLoader = new AcDream.Core.Physics.DatCollectionLoader(_dats); uint centerLandblockId = 0xA9B4FFFFu; Console.WriteLine($"loading world view centered on 0x{centerLandblockId:X8}"); @@ -822,6 +833,25 @@ public sealed class GameWindow : IDisposable for (int i = 0; i < meshRefs.Count; i++) template[i] = (meshRefs[i].GfxObjId, meshRefs[i].SurfaceOverrides); + // Try to create an AnimationSequencer for transition-link-aware + // playback. Requires loading the MotionTable dat separately. + AcDream.Core.Physics.AnimationSequencer? sequencer = null; + if (_animLoader is not null) + { + uint mtableId = spawn.MotionTableId ?? (uint)setup.DefaultMotionTable; + if (mtableId != 0) + { + var mtable = _dats.Get(mtableId); + if (mtable is not null) + { + sequencer = new AcDream.Core.Physics.AnimationSequencer(setup, mtable, _animLoader); + uint style = stanceOverride is > 0 ? (uint)stanceOverride.Value : (uint)mtable.DefaultStyle; + uint motion = commandOverride is > 0 ? (uint)commandOverride.Value : 0x41000003u; // Ready + sequencer.SetCycle(style, motion); + } + } + } + _animatedEntities[entity.Id] = new AnimatedEntity { Entity = entity, @@ -833,6 +863,7 @@ public sealed class GameWindow : IDisposable Scale = scale, PartTemplate = template, CurrFrame = idleCycle.LowFrame, + Sequencer = sequencer, }; } @@ -1837,40 +1868,32 @@ public sealed class GameWindow : IDisposable foreach (var kv in _animatedEntities) { var ae = kv.Value; - int span = ae.HighFrame - ae.LowFrame; - if (span <= 0) continue; - ae.CurrFrame += dt * ae.Framerate; - // Wrap into [LowFrame, HighFrame]. Use a guarded modulo so - // big dts (first frame after a stall) don't blow the loop. - if (ae.CurrFrame > ae.HighFrame) + // ── Compute per-part (origin, orientation) ────────────────────── + // Two paths: if the entity has an AnimationSequencer, use it (gets + // transition links + retail slerp). Otherwise fall back to the + // Phase 6.5 manual slerp path. + IReadOnlyList? seqFrames = null; + if (ae.Sequencer is not null) { - float over = ae.CurrFrame - ae.LowFrame; - ae.CurrFrame = ae.LowFrame + (over % (span + 1)); + seqFrames = ae.Sequencer.Advance(dt); } - else if (ae.CurrFrame < ae.LowFrame) + else { - ae.CurrFrame = ae.LowFrame; + // Legacy fallback: manual frame advancement + slerp. + int span = ae.HighFrame - ae.LowFrame; + if (span <= 0) continue; + + ae.CurrFrame += dt * ae.Framerate; + if (ae.CurrFrame > ae.HighFrame) + { + float over = ae.CurrFrame - ae.LowFrame; + ae.CurrFrame = ae.LowFrame + (over % (span + 1)); + } + else if (ae.CurrFrame < ae.LowFrame) + ae.CurrFrame = ae.LowFrame; } - // 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); var scaleMat = ae.Scale == 1.0f @@ -1879,25 +1902,52 @@ public sealed class GameWindow : IDisposable for (int i = 0; i < partCount; i++) { - // 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) + + if (seqFrames is not null) { - 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); + // Sequencer path: per-part transforms already interpolated. + if (i < seqFrames.Count) + { + origin = seqFrames[i].Origin; + orientation = seqFrames[i].Orientation; + } + else + { + origin = System.Numerics.Vector3.Zero; + orientation = System.Numerics.Quaternion.Identity; + } } else { - origin = System.Numerics.Vector3.Zero; - orientation = System.Numerics.Quaternion.Identity; + // Legacy slerp path. + 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; + 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; + + if (i < partFrames.Count) + { + 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 + { + 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. @@ -1907,8 +1957,8 @@ public sealed class GameWindow : IDisposable var partTransform = System.Numerics.Matrix4x4.CreateScale(defaultScale) * - System.Numerics.Matrix4x4.CreateFromQuaternion(frame.Orientation) * - System.Numerics.Matrix4x4.CreateTranslation(frame.Origin); + System.Numerics.Matrix4x4.CreateFromQuaternion(orientation) * + System.Numerics.Matrix4x4.CreateTranslation(origin); // Bake the entity's ObjScale on top, matching the hydration // order (PartTransform * scaleMat) — see comment in OnLiveEntitySpawned.