From a4b42a7417f53bf9dd9b97778c38411d144673bd Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 5 Jul 2026 23:43:14 +0200 Subject: [PATCH] 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 (BuildDrawList) and a fresh List (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 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 --- src/AcDream.App/Rendering/ParticleRenderer.cs | 21 +++- src/AcDream.Core/Vfx/ParticleSystem.cs | 105 ++++++++++++++++-- 2 files changed, 115 insertions(+), 11 deletions(-) diff --git a/src/AcDream.App/Rendering/ParticleRenderer.cs b/src/AcDream.App/Rendering/ParticleRenderer.cs index e47fc338..d078e0c6 100644 --- a/src/AcDream.App/Rendering/ParticleRenderer.cs +++ b/src/AcDream.App/Rendering/ParticleRenderer.cs @@ -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 (BuildDrawList) and a + // List (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 _drawListScratch = new(64); + private readonly List _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(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? emitterFilter) { - var draws = new List(Math.Max(64, particles.ActiveParticleCount)); + var draws = _drawListScratch; + draws.Clear(); foreach (var (em, idx) in particles.EnumerateLive()) { if (em.RenderPass != renderPass) diff --git a/src/AcDream.Core/Vfx/ParticleSystem.cs b/src/AcDream.Core/Vfx/ParticleSystem.cs index 53c5d700..40090d92 100644 --- a/src/AcDream.Core/Vfx/ParticleSystem.cs +++ b/src/AcDream.Core/Vfx/ParticleSystem.cs @@ -158,17 +158,104 @@ public sealed class ParticleSystem : IParticleSystem } } - public IEnumerable<(ParticleEmitter Emitter, int Index)> EnumerateLive() - { - foreach (var handle in _handleOrder) - { - if (!_byHandle.TryGetValue(handle, out var em)) - continue; + /// + /// Enumerate every live particle across every active emitter as + /// (emitter, particle-index) pairs, in emitter-spawn order. + /// + /// + /// MP-Alloc (2026-07-05): this used to be a C# iterator block (a + /// compiler-generated heap-allocated state machine, `yield return`), + /// allocated fresh on every call. + /// calls this once per pass and there are up to ~11 passes per frame + /// (sky pre/post, scene, per-visible-cell, dynamics, unattached), so + /// this was 11 iterator allocations per frame even with zero particles + /// on screen. Returns a struct + /// instead: `foreach` over it uses the struct enumerator directly (no + /// allocation), while LINQ / test callers that need + /// (`.ToList()`, `.Single()`, etc.) still + /// work via the explicit interface implementation — those call sites + /// are test-only, not the per-frame render path this task targets. + /// + /// + public LiveParticleEnumerable EnumerateLive() => new(this); - for (int i = 0; i < em.Particles.Length; i++) + /// + /// Struct enumerable returned by . Wraps the + /// owning so foreach gets a + /// zero-allocation struct enumerator; falls back to a boxed iterator + /// only when consumed through the surface + /// (LINQ, test helpers). + /// + public readonly struct LiveParticleEnumerable : IEnumerable<(ParticleEmitter Emitter, int Index)> + { + private readonly ParticleSystem _owner; + internal LiveParticleEnumerable(ParticleSystem owner) => _owner = owner; + + public Enumerator GetEnumerator() => new(_owner); + + IEnumerator<(ParticleEmitter Emitter, int Index)> IEnumerable<(ParticleEmitter Emitter, int Index)>.GetEnumerator() + => EnumerateLiveBoxed(_owner).GetEnumerator(); + + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() + => EnumerateLiveBoxed(_owner).GetEnumerator(); + + private static IEnumerable<(ParticleEmitter Emitter, int Index)> EnumerateLiveBoxed(ParticleSystem owner) + { + foreach (var handle in owner._handleOrder) { - if (em.Particles[i].Alive) - yield return (em, i); + if (!owner._byHandle.TryGetValue(handle, out var em)) + continue; + + for (int i = 0; i < em.Particles.Length; i++) + { + if (em.Particles[i].Alive) + yield return (em, i); + } + } + } + + /// Zero-allocation struct enumerator for the `foreach` fast path. + public struct Enumerator + { + private readonly ParticleSystem _owner; + private int _handleIdx; + private ParticleEmitter? _currentEmitter; + private int _particleIdx; + + internal Enumerator(ParticleSystem owner) + { + _owner = owner; + _handleIdx = -1; + _currentEmitter = null; + _particleIdx = -1; + } + + public (ParticleEmitter Emitter, int Index) Current => (_currentEmitter!, _particleIdx); + + public bool MoveNext() + { + while (true) + { + if (_currentEmitter is not null) + { + for (_particleIdx++; _particleIdx < _currentEmitter.Particles.Length; _particleIdx++) + { + if (_currentEmitter.Particles[_particleIdx].Alive) + return true; + } + _currentEmitter = null; + } + + _handleIdx++; + if (_handleIdx >= _owner._handleOrder.Count) + return false; + + if (!_owner._byHandle.TryGetValue(_owner._handleOrder[_handleIdx], out var em)) + continue; + + _currentEmitter = em; + _particleIdx = -1; + } } } }