diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index 8fb2a90c..f1ede5bf 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -956,7 +956,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable camPos = invView.Translation; // ── Phase 1: clear groups, walk entities, build groups ────────────── - foreach (var grp in _groups.Values) { grp.Matrices.Clear(); grp.Slots.Clear(); grp.LightSets.Clear(); grp.IndoorFlags.Clear(); } + foreach (var grp in _groups.Values) grp.ClearPerInstanceData(); var metaTable = _meshAdapter.MetadataTable; uint anyVao = 0; @@ -2556,7 +2556,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable } } - private sealed class InstanceGroup + internal sealed class InstanceGroup { public uint Ibo; public uint FirstIndex; @@ -2598,5 +2598,24 @@ public sealed unsafe class WbDrawDispatcher : IDisposable // layout time the dispatcher writes Opacities[i] into _alphaData at // the same cursor, so the binding=7 instanceAlpha[] tracks binding=0. public readonly List Opacities = new(); + + /// + /// Resets every per-instance parallel list for a new frame. These lists are + /// appended in lockstep (one entry per drawn instance) during group build, so + /// they MUST all be cleared together each frame. Keeping the reset in one + /// method stops a newly-added parallel list from silently drifting out of the + /// frame lifecycle — which is exactly the #193 OOM: #188 added + /// alongside the others but left it out of the old + /// inline clear loop, so it grew one float per instance per frame forever and + /// leaked ~1 GB/min of LOH float[] as its backing array doubled. + /// + public void ClearPerInstanceData() + { + Matrices.Clear(); + Slots.Clear(); + LightSets.Clear(); + IndoorFlags.Clear(); + Opacities.Clear(); + } } } diff --git a/tests/AcDream.App.Tests/Rendering/Wb/InstanceGroupClearTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/InstanceGroupClearTests.cs new file mode 100644 index 00000000..967392e8 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Wb/InstanceGroupClearTests.cs @@ -0,0 +1,35 @@ +using System.Numerics; +using AcDream.App.Rendering.Wb; +using Xunit; + +namespace AcDream.App.Tests.Rendering.Wb; + +public class InstanceGroupClearTests +{ + // #193 (regression from #188, 2026-07-09): WbDrawDispatcher's InstanceGroup holds + // five per-instance parallel lists — Matrices, Slots, LightSets, IndoorFlags, + // Opacities — appended in lockstep (one entry per drawn instance) every frame. The + // per-frame reset must clear ALL of them. #188 added Opacities but left it out of + // the inline clear loop, so it grew one float per instance per frame forever; as + // List's backing array doubled it produced ~128 MB / ~512 MB LOH float[] + // arrays and leaked ~1 GB/min -> OOM after ~50 min of play. This test pins that + // every parallel list is reset, so a newly-added list can't silently drift again. + [Fact] + public void ClearPerInstanceData_ClearsEveryParallelPerInstanceList() + { + var grp = new WbDrawDispatcher.InstanceGroup(); + grp.Matrices.Add(Matrix4x4.Identity); + grp.Slots.Add(1u); + grp.LightSets.Add(-1); + grp.IndoorFlags.Add(0u); + grp.Opacities.Add(1.0f); + + grp.ClearPerInstanceData(); + + Assert.Empty(grp.Matrices); + Assert.Empty(grp.Slots); + Assert.Empty(grp.LightSets); + Assert.Empty(grp.IndoorFlags); + Assert.Empty(grp.Opacities); // #193 — the list that leaked + } +}