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:
parent
3e69241c64
commit
b8c05e2bbe
2 changed files with 50 additions and 4 deletions
|
|
@ -282,6 +282,24 @@ public sealed class GameWindow : IDisposable
|
||||||
public required IReadOnlyList<(uint GfxObjId, IReadOnlyDictionary<uint, uint>? SurfaceOverrides)> PartTemplate;
|
public required IReadOnlyList<(uint GfxObjId, IReadOnlyDictionary<uint, uint>? SurfaceOverrides)> PartTemplate;
|
||||||
public float CurrFrame; // monotonically increasing within [LowFrame, HighFrame]
|
public float CurrFrame; // monotonically increasing within [LowFrame, HighFrame]
|
||||||
public AcDream.Core.Physics.AnimationSequencer? Sequencer;
|
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<MeshRef>(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<MeshRef>(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;
|
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
|
var scaleMat = ae.Scale == 1.0f
|
||||||
? System.Numerics.Matrix4x4.Identity
|
? System.Numerics.Matrix4x4.Identity
|
||||||
: System.Numerics.Matrix4x4.CreateScale(ae.Scale);
|
: 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;
|
ae.Entity.MeshRefs = newMeshRefs;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -251,6 +251,18 @@ public sealed class AnimationSequencer
|
||||||
// AdapterHookQueue seam below); drained via ConsumePendingHooks.
|
// AdapterHookQueue seam below); drained via ConsumePendingHooks.
|
||||||
private readonly List<AnimationHook> _pendingHooks = new();
|
private readonly List<AnimationHook> _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<PartTransform> 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
|
// R1-P6: root motion flows through the Advance(dt, Frame) overload
|
||||||
// (retail CSequence::update(Frame*), 0x00525b80) — the old adapter
|
// (retail CSequence::update(Frame*), 0x00525b80) — the old adapter
|
||||||
// accumulator fields are gone with ConsumeRootMotionDelta.
|
// accumulator fields are gone with ConsumeRootMotionDelta.
|
||||||
|
|
@ -275,6 +287,7 @@ public sealed class AnimationSequencer
|
||||||
_setup = setup;
|
_setup = setup;
|
||||||
_mtable = motionTable;
|
_mtable = motionTable;
|
||||||
_loader = loader;
|
_loader = loader;
|
||||||
|
_partTransformScratch = new PartTransform[_setup.Parts.Count];
|
||||||
_core = new CSequence(loader);
|
_core = new CSequence(loader);
|
||||||
_core.HookObj = new AdapterHookQueue(this);
|
_core.HookObj = new AdapterHookQueue(this);
|
||||||
_table = new CMotionTable(motionTable);
|
_table = new CMotionTable(motionTable);
|
||||||
|
|
@ -744,7 +757,11 @@ public sealed class AnimationSequencer
|
||||||
var f0Parts = curr.Anim.PartFrames[frameIdx].Frames;
|
var f0Parts = curr.Anim.PartFrames[frameIdx].Frames;
|
||||||
var f1Parts = curr.Anim.PartFrames[nextIdx].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++)
|
for (int i = 0; i < partCount; i++)
|
||||||
{
|
{
|
||||||
if (i < f0Parts.Count)
|
if (i < f0Parts.Count)
|
||||||
|
|
@ -765,9 +782,11 @@ public sealed class AnimationSequencer
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IReadOnlyList<PartTransform> BuildIdentityFrame(int partCount)
|
private IReadOnlyList<PartTransform> 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++)
|
for (int i = 0; i < partCount; i++)
|
||||||
result[i] = new PartTransform(Vector3.Zero, Quaternion.Identity);
|
result[i] = new PartTransform(Vector3.Zero, Quaternion.Identity);
|
||||||
return result;
|
return result;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue