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

@ -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;
/// <summary>
/// Enumerate every live particle across every active emitter as
/// (emitter, particle-index) pairs, in emitter-spawn order.
///
/// <para>
/// 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. <see cref="ParticleRenderer.Draw"/>
/// 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 <see cref="LiveParticleEnumerable"/> struct
/// instead: `foreach` over it uses the struct enumerator directly (no
/// allocation), while LINQ / test callers that need
/// <see cref="IEnumerable{T}"/> (`.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.
/// </para>
/// </summary>
public LiveParticleEnumerable EnumerateLive() => new(this);
for (int i = 0; i < em.Particles.Length; i++)
/// <summary>
/// Struct enumerable returned by <see cref="EnumerateLive"/>. Wraps the
/// owning <see cref="ParticleSystem"/> so <c>foreach</c> gets a
/// zero-allocation struct enumerator; falls back to a boxed iterator
/// only when consumed through the <see cref="IEnumerable{T}"/> surface
/// (LINQ, test helpers).
/// </summary>
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);
}
}
}
/// <summary>Zero-allocation struct enumerator for the `foreach` fast path.</summary>
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;
}
}
}
}