perf(pipeline): MP-Alloc Task 2 - reuse particle draw-list + iterator

ParticleRenderer.Draw is called up to ~11 times per frame (sky pre/post,
scene, per-visible-cell, dynamics, unattached passes). Each call
allocated a fresh List<ParticleDraw> (BuildDrawList) and a fresh
List<ParticleInstance> (the per-batch `run` list), and ParticleSystem.
EnumerateLive was a `yield return` iterator block - a heap-allocated
state machine allocated fresh on every Draw call regardless of particle
count.

Fix: reuse _drawListScratch/_runScratch fields (Clear() + refill) on
ParticleRenderer. Replace EnumerateLive's iterator with a struct
enumerable/enumerator pair (ParticleSystem.LiveParticleEnumerable): the
foreach fast path uses the struct enumerator directly (zero allocation),
while LINQ/test callers (.ToList(), .Single(), etc.) still work via the
explicit IEnumerable<T> interface implementation, which falls back to a
boxed iterator only when that surface is used - those call sites are
test-only, not the per-frame render path this task targets.

Verified safe: all 11 Draw() call sites in GameWindow.cs are plain
synchronous invocations from the single-threaded OnRender chain (no
Task.Run/Parallel dispatch), and each call fully drains its lists
before returning - no call ever overlaps another's use of the reused
buffers. Per-cell filtering semantics unchanged (same predicate, same
traversal order).

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:43:14 +02:00
parent b8c05e2bbe
commit a4b42a7417
2 changed files with 115 additions and 11 deletions

View file

@ -48,6 +48,19 @@ public sealed unsafe class ParticleRenderer : IDisposable
private float[] _instanceScratch = new float[256 * 16];
// MP-Alloc (2026-07-05): Draw() is called up to ~11 times per frame
// (sky pre/post, scene, per-visible-cell, dynamics, unattached passes),
// each previously `new`ing a List<ParticleDraw> (BuildDrawList) and a
// List<ParticleInstance> (the per-batch `run` list) that became garbage
// as soon as the call returned. All Draw() calls happen sequentially on
// the render thread (verified: every call site in GameWindow.cs is a
// plain synchronous invocation from the single-threaded OnRender chain,
// none dispatched via Task.Run/Parallel) and each call fully drains its
// lists before returning, so a single pair of reused fields is safe -
// no call overlaps another's use of these buffers.
private readonly List<ParticleDraw> _drawListScratch = new(64);
private readonly List<ParticleInstance> _runScratch = new(64);
public ParticleRenderer(GL gl, string shadersDir, TextureCache? textures = null, DatCollection? dats = null)
{
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
@ -133,6 +146,9 @@ public sealed unsafe class ParticleRenderer : IDisposable
if (draws.Count == 0)
return;
draws.Sort(static (a, b) => b.Instance.DistanceSq.CompareTo(a.Instance.DistanceSq));
// draws IS _drawListScratch (see BuildDrawList) - sorting it in place
// is fine, nothing else reads it between BuildDrawList's return and
// this call.
_shader.Use();
_shader.SetMatrix4("uViewProjection", camera.View * camera.Projection);
@ -144,7 +160,7 @@ public sealed unsafe class ParticleRenderer : IDisposable
_gl.Disable(EnableCap.CullFace);
_gl.ActiveTexture(TextureUnit.Texture0);
var run = new List<ParticleInstance>(64);
var run = _runScratch;
for (int i = 0; i < draws.Count;)
{
var key = draws[i].Key;
@ -178,7 +194,8 @@ public sealed unsafe class ParticleRenderer : IDisposable
Vector3 cameraUp,
Func<AcDream.Core.Vfx.ParticleEmitter, bool>? emitterFilter)
{
var draws = new List<ParticleDraw>(Math.Max(64, particles.ActiveParticleCount));
var draws = _drawListScratch;
draws.Clear();
foreach (var (em, idx) in particles.EnumerateLive())
{
if (em.RenderPass != renderPass)