From b8c05e2bbe2d7da5bb808e649c5b6d377cd6d0ae Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 5 Jul 2026 23:40:57 +0200 Subject: [PATCH] perf(pipeline): MP-Alloc Task 1 - reuse animation pose buffers per entity AnimationSequencer.Advance() allocated a fresh PartTransform[partCount] every call (BuildBlendedFrame/BuildIdentityFrame), and GameWindow. TickAnimations reassigned entity.MeshRefs to a fresh List(partCount) every tick for every animated entity - including idle NPCs on a breathe cycle. Both fire every frame in steady state and both become garbage immediately after being consumed. Fix: cache a PartTransform[] sized once in the AnimationSequencer constructor (_setup is readonly and never reassigned, so partCount is fixed for the sequencer's lifetime) and overwrite it in place each Advance() call. Cache a List on the long-lived AnimatedEntity record (one per animated entity, held in GameWindow._animatedEntities) and Clear()+refill it each tick instead of allocating a new list. Verified safe: TickAnimations runs single-threaded to completion before any draw-side consumer reads MeshRefs (WbDrawDispatcher re-reads entity.MeshRefs fresh every frame, keyed by entity.Id - it never caches the list reference across frames). The only place that copies MeshRefs across an animation boundary (RefreshPaperdollDoll) takes an explicit `new List(pe.MeshRefs)` value copy; MeshRef is a readonly record struct so that copy is unaffected by later in-place mutation of the source list. No test holds two Advance() results simultaneously. dotnet build clean, dotnet test 4120 passed / 4 skipped (unchanged). Co-Authored-By: Claude Sonnet 5 --- src/AcDream.App/Rendering/GameWindow.cs | 29 ++++++++++++++++++- .../Physics/AnimationSequencer.cs | 25 ++++++++++++++-- 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index f45d7d2d..f3472e77 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -282,6 +282,24 @@ public sealed class GameWindow : IDisposable public required IReadOnlyList<(uint GfxObjId, IReadOnlyDictionary? SurfaceOverrides)> PartTemplate; public float CurrFrame; // monotonically increasing within [LowFrame, HighFrame] public AcDream.Core.Physics.AnimationSequencer? Sequencer; + + /// + /// MP-Alloc (2026-07-05): reusable per-entity MeshRefs buffer. Every + /// animated entity (including idle NPCs on a breathe cycle) used to + /// get a fresh `List<MeshRef>(partCount)` reassigned to + /// every tick — + /// the old list became garbage every frame. This buffer is cleared + /// and refilled in place instead; Entity.MeshRefs is set to + /// this SAME list reference once it's populated (assigning the + /// unchanged reference back is a no-op cost-wise). Safe because + /// TickAnimations runs single-threaded to completion before any + /// draw-side consumer reads MeshRefs, and the only cross-frame + /// consumer (RefreshPaperdollDoll) takes an explicit + /// `new List<MeshRef>(pe.MeshRefs)` VALUE copy — MeshRef is a + /// readonly record struct, so that copy is unaffected by later + /// in-place mutation of this list. + /// + public readonly List MeshRefsScratch = new(); } private AcDream.Core.Physics.DatCollectionLoader? _animLoader; @@ -10720,7 +10738,13 @@ public sealed class GameWindow : IDisposable } } - var newMeshRefs = new List(partCount); + // MP-Alloc (2026-07-05): reuse the entity's cached MeshRefs list + // instead of allocating a fresh List(partCount) every + // tick (see AnimatedEntity.MeshRefsScratch for the safety + // argument — single-threaded tick-before-draw ordering, no + // consumer caches a stale reference or diffs list identity). + var newMeshRefs = ae.MeshRefsScratch; + newMeshRefs.Clear(); var scaleMat = ae.Scale == 1.0f ? System.Numerics.Matrix4x4.Identity : System.Numerics.Matrix4x4.CreateScale(ae.Scale); @@ -10799,6 +10823,9 @@ public sealed class GameWindow : IDisposable }); } + // Re-assigning the SAME list reference every tick is cheap (a + // property store) and keeps this line's shape identical to the + // pre-MP-Alloc code for anyone grepping the history. ae.Entity.MeshRefs = newMeshRefs; } } diff --git a/src/AcDream.Core/Physics/AnimationSequencer.cs b/src/AcDream.Core/Physics/AnimationSequencer.cs index c74f846d..4ee17977 100644 --- a/src/AcDream.Core/Physics/AnimationSequencer.cs +++ b/src/AcDream.Core/Physics/AnimationSequencer.cs @@ -251,6 +251,18 @@ public sealed class AnimationSequencer // AdapterHookQueue seam below); drained via ConsumePendingHooks. private readonly List _pendingHooks = new(); + // MP-Alloc (2026-07-05): reusable per-part transform buffer. _setup is + // assigned once in the constructor and never reassigned, so + // _setup.Parts.Count is fixed for this sequencer's lifetime — one + // allocation instead of one `new PartTransform[partCount]` per Advance() + // call (every animated entity, every tick, including idle NPCs on a + // breathe cycle). BuildBlendedFrame/BuildIdentityFrame overwrite every + // slot before returning this array, so the values are bit-identical to + // the old fresh-array version; callers (GameWindow.TickAnimations) + // consume the returned IReadOnlyList synchronously within + // the same loop iteration and never cache it across Advance() calls. + private readonly PartTransform[] _partTransformScratch; + // R1-P6: root motion flows through the Advance(dt, Frame) overload // (retail CSequence::update(Frame*), 0x00525b80) — the old adapter // accumulator fields are gone with ConsumeRootMotionDelta. @@ -275,6 +287,7 @@ public sealed class AnimationSequencer _setup = setup; _mtable = motionTable; _loader = loader; + _partTransformScratch = new PartTransform[_setup.Parts.Count]; _core = new CSequence(loader); _core.HookObj = new AdapterHookQueue(this); _table = new CMotionTable(motionTable); @@ -744,7 +757,11 @@ public sealed class AnimationSequencer var f0Parts = curr.Anim.PartFrames[frameIdx].Frames; var f1Parts = curr.Anim.PartFrames[nextIdx].Frames; - var result = new PartTransform[partCount]; + // MP-Alloc: overwrite the reusable per-instance buffer in place + // instead of allocating a fresh PartTransform[partCount] every call. + // Sized once in the constructor to _setup.Parts.Count, which never + // changes, so partCount here always matches the buffer length. + var result = _partTransformScratch; for (int i = 0; i < partCount; i++) { if (i < f0Parts.Count) @@ -765,9 +782,11 @@ public sealed class AnimationSequencer return result; } - private static IReadOnlyList BuildIdentityFrame(int partCount) + private IReadOnlyList BuildIdentityFrame(int partCount) { - var result = new PartTransform[partCount]; + // MP-Alloc: same reusable buffer as BuildBlendedFrame (see + // _partTransformScratch) — overwritten in place, never reallocated. + var result = _partTransformScratch; for (int i = 0; i < partCount; i++) result[i] = new PartTransform(Vector3.Zero, Quaternion.Identity); return result;