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:
parent
b8c05e2bbe
commit
a4b42a7417
2 changed files with 115 additions and 11 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue