perf(pipeline): MP-Alloc Task 4 - reuse per-frame animatedIds + drawableCells sets

Completes the safe-batch (implementer finished this but died before
committing; coordinator independently verified bit-identity + safety
post-interruption). Two per-frame HashSet<uint> allocations replaced by
reused cleared-in-place fields:
- GameWindow._animatedIdsScratch: WalkEntitiesInto treats null and empty
  identically (line 682 `is null || Count == 0`), so an always-non-null
  empty set == the old null-when-empty local. Verified.
- RetailPViewRenderer._drawableCellsScratch: flows into
  RetailPViewFrameResult.DrawableCells (live ref) but every consumer reads
  it same-frame; sigDrawableCells is a per-frame OnRender local that
  EmitRenderSignatureIfChanged only FORMATS to a string (no reference
  retention, no _lastSig set field). Built frame N -> read frame N ->
  cleared frame N+1. No cross-frame corruption.

Build green, full suite 4116 passed / 4 skipped.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-06 00:09:03 +02:00
parent 76b8b4e9fc
commit 91afea24b4
2 changed files with 26 additions and 8 deletions

View file

@ -252,6 +252,13 @@ public sealed class GameWindow : IDisposable
/// </summary> /// </summary>
private readonly Dictionary<uint, AnimatedEntity> _animatedEntities = new(); private readonly Dictionary<uint, AnimatedEntity> _animatedEntities = new();
// MP-Alloc (2026-07-05): reusable per-frame snapshot of _animatedEntities.Keys.
// WbDrawDispatcher.WalkEntitiesInto treats null and an empty set identically
// (`animatedEntityIds is null || animatedEntityIds.Count == 0`), so this can
// always be a live (possibly empty) HashSet instead of `new`ing one every
// frame or passing null.
private readonly HashSet<uint> _animatedIdsScratch = new();
/// <summary> /// <summary>
/// Tier 1 cache (#53): per-entity classification results for static /// Tier 1 cache (#53): per-entity classification results for static
/// entities (those NOT in <see cref="_animatedEntities"/>). Conceptually /// entities (those NOT in <see cref="_animatedEntities"/>). Conceptually
@ -9104,13 +9111,15 @@ public sealed class GameWindow : IDisposable
var envCellViewProj = camera.View * camera.Projection; var envCellViewProj = camera.View * camera.Projection;
_envCellFrustum?.Update(envCellViewProj); _envCellFrustum?.Update(envCellViewProj);
HashSet<uint>? animatedIds = null; // MP-Alloc: reuse _animatedIdsScratch instead of `new`ing a
if (_animatedEntities.Count > 0) // HashSet<uint> every frame. Downstream consumers (WbDrawDispatcher.
{ // WalkEntitiesInto) treat null and an empty set identically, so an
animatedIds = new HashSet<uint>(_animatedEntities.Count); // always-non-null (possibly empty) set is behaviorally the same as
foreach (var k in _animatedEntities.Keys) // the old null-when-nothing-animated local.
animatedIds.Add(k); _animatedIdsScratch.Clear();
} foreach (var k in _animatedEntities.Keys)
_animatedIdsScratch.Add(k);
HashSet<uint>? animatedIds = _animatedIdsScratch;
// Phase G.1: sky renderer — draws the far-plane-infinity // Phase G.1: sky renderer — draws the far-plane-infinity
// celestial meshes FIRST so the rest of the scene z-tests // celestial meshes FIRST so the rest of the scene z-tests

View file

@ -50,6 +50,13 @@ public sealed class RetailPViewRenderer
// visible. // visible.
private readonly InteriorEntityPartition.Result _partitionResult = new(); private readonly InteriorEntityPartition.Result _partitionResult = new();
// MP-Alloc (2026-07-05): DrawInside's drawable-cell set, reused across
// frames instead of `new HashSet<uint>(pvFrame.OrderedVisibleCells)` every
// call. Every consumer (DrawEntityBucket, DrawExitPortalMasks,
// DrawCellObjectLists, RetailPViewFrameResult.DrawableCells) reads it
// synchronously within the same frame it was built.
private readonly HashSet<uint> _drawableCellsScratch = new();
// T2 (BR-4): retail has NO distance constant on the flood-admission chain // T2 (BR-4): retail has NO distance constant on the flood-admission chain
// (DrawBuilding → portal walk → ConstructView: viewconeCheck + side test + // (DrawBuilding → portal walk → ConstructView: viewconeCheck + side test +
// GetClip + GetVisible only). The old 48 m seed cap is replaced by the // GetClip + GetVisible only). The old 48 m seed cap is replaced by the
@ -118,7 +125,9 @@ public sealed class RetailPViewRenderer
// so every visible cell's shell has a prepared batch and seals — killing the grey // so every visible cell's shell has a prepared batch and seals — killing the grey
// (the old clipAssembly.CellIdToSlot.Keys filter silently dropped slot-less cells). // (the old clipAssembly.CellIdToSlot.Keys filter silently dropped slot-less cells).
// Per-slice trim still applies in DrawEnvCellShells (Task 4 makes it self-contained). // Per-slice trim still applies in DrawEnvCellShells (Task 4 makes it self-contained).
var drawableCells = new HashSet<uint>(pvFrame.OrderedVisibleCells); _drawableCellsScratch.Clear();
_drawableCellsScratch.UnionWith(pvFrame.OrderedVisibleCells);
var drawableCells = _drawableCellsScratch;
UseIndoorMembershipOnlyRouting(); UseIndoorMembershipOnlyRouting();
// #124: look-in cells need prepared shell batches + their statics routed // #124: look-in cells need prepared shell batches + their statics routed