fix(#193): clear InstanceGroup.Opacities each frame — stop the ~1 GB/min LOH leak

Root cause (measured, not guessed): the OOM was a MANAGED Large-Object-Heap leak
(~5 GB in 6 min of roaming; working set 1.6 GB -> 7.6 GB), dominated by ~3.8 GB of
live System.Single[]. A heap-retention analysis of a captured gcdump traced 241 of
242 giant float arrays to a single List<float>: WbDrawDispatcher.InstanceGroup.Opacities.

#188 (2026-07-09, fading doors) added Opacities as a 5th per-instance parallel list
alongside Matrices/Slots/LightSets/IndoorFlags, but left it out of the per-frame
clear loop (WbDrawDispatcher.cs:959). So Opacities.Add(1.0f) fired once per drawn
instance per frame and the list never reset — growing forever; List<float> capacity
doubling produced the observed ~128 MB / ~512 MB power-of-two arrays and OOM after
~50 min. Hit both UI builds because it's core render, and started only recently
because Opacities did not exist before #188.

Fix: extract the per-frame reset into InstanceGroup.ClearPerInstanceData() (promoted
InstanceGroup private->internal) that clears ALL FIVE parallel lists in one place, so
a future 6th list can't silently drift out of the frame lifecycle again. Line 959 now
calls it. TDD: InstanceGroupClearTests asserts every parallel list resets (RED with
the old 4-list clear, GREEN after adding Opacities.Clear()).

Static-audit note: this is exactly why we measured before fixing — the audit ranked
GlobalMeshBuffer (GPU) #1 and the entity-dict leak #3; both were wrong about the
dominant cause. The dotnet-counters (managed vs native) + gcdump retention graph
named the real one-line culprit.

Secondary (NOT this commit): _groups dictionary is never pruned (bounded by distinct
group keys, empty groups are cheap) — filed as a follow-up, not the multi-GB driver.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-10 14:15:10 +02:00
parent 02487da0ce
commit 119a2326be
2 changed files with 56 additions and 2 deletions

View file

@ -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<float> Opacities = new();
/// <summary>
/// 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
/// <see cref="Opacities"/> 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 <c>float[]</c> as its backing array doubled.
/// </summary>
public void ClearPerInstanceData()
{
Matrices.Clear();
Slots.Clear();
LightSets.Clear();
IndoorFlags.Clear();
Opacities.Clear();
}
}
}