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<MeshRef>(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<MeshRef> 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<MeshRef>(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 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-05 23:40:57 +02:00
parent 3e69241c64
commit b8c05e2bbe
2 changed files with 50 additions and 4 deletions

View file

@ -282,6 +282,24 @@ public sealed class GameWindow : IDisposable
public required IReadOnlyList<(uint GfxObjId, IReadOnlyDictionary<uint, uint>? SurfaceOverrides)> PartTemplate;
public float CurrFrame; // monotonically increasing within [LowFrame, HighFrame]
public AcDream.Core.Physics.AnimationSequencer? Sequencer;
/// <summary>
/// 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&lt;MeshRef&gt;(partCount)` reassigned to
/// <see cref="AcDream.Core.World.WorldEntity.MeshRefs"/> every tick —
/// the old list became garbage every frame. This buffer is cleared
/// and refilled in place instead; <c>Entity.MeshRefs</c> 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&lt;MeshRef&gt;(pe.MeshRefs)` VALUE copy — MeshRef is a
/// readonly record struct, so that copy is unaffected by later
/// in-place mutation of this list.
/// </summary>
public readonly List<AcDream.Core.World.MeshRef> MeshRefsScratch = new();
}
private AcDream.Core.Physics.DatCollectionLoader? _animLoader;
@ -10720,7 +10738,13 @@ public sealed class GameWindow : IDisposable
}
}
var newMeshRefs = new List<AcDream.Core.World.MeshRef>(partCount);
// MP-Alloc (2026-07-05): reuse the entity's cached MeshRefs list
// instead of allocating a fresh List<MeshRef>(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;
}
}