From 76b8b4e9fc0720fe73eb7e066e061597b579bafa Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 5 Jul 2026 23:47:23 +0200 Subject: [PATCH] perf(pipeline): MP-Alloc Task 3 - pool InteriorEntityPartition.Result MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit InteriorEntityPartition.Partition allocated a fresh Result (a Dictionary plus 2 Lists) and a new List per newly-seen visible cell, every frame — the sole call site is RetailPViewRenderer.DrawInside, once per frame, single-threaded. Fix: add a Partition(Result, visibleCells, landblockEntries) overload that clears an existing Result in place (Result.ClearForReuse) and refills it, reusing each cell's List across frames when the cell key survives (the visible-cell set is normally stable frame to frame). RetailPViewRenderer now owns one _partitionResult instance (matching its existing _shellBatch/_buildingGroups/_lookInPrepareScratch reuse pattern) instead of allocating one per DrawInside call. The original allocating Partition(visibleCells, landblockEntries) overload is kept unchanged for tests and one-shot callers (it now delegates to the reuse overload against a fresh Result). Bit-identical output required pruning: without removing cell buckets that end a frame with zero entries, a cell that leaves visibility would leave a stale empty List sitting in ByCell, changing ByCell.Count and .Keys enumeration versus the old always-fresh-Dictionary behavior (two GameWindow diagnostics - sigSceneParticles and FormatPartitionCounts - read those directly, not just via TryGetValue). Result.PruneEmptyCellBuckets removes any zero-count bucket after each Partition call, keeping ByCell's key set exactly what a fresh dictionary would have held. Verified safe: the sole production consumer (RetailPViewRenderer. DrawInside) and every downstream reader (WbDrawDispatcher walks, GameWindow diagnostics) consume partition.ByCell/OutdoorStatic/Dynamics synchronously within the same frame that built them - no cached reference survives into the next frame's Partition() call (the one GameWindow field that stores the reference, _interiorPartition, is write-only, never read). dotnet build clean, dotnet test 4120 passed / 4 skipped (unchanged). Co-Authored-By: Claude Sonnet 5 --- .../Rendering/InteriorEntityPartition.cs | 72 ++++++++++++++++++- .../Rendering/RetailPViewRenderer.cs | 11 ++- 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/src/AcDream.App/Rendering/InteriorEntityPartition.cs b/src/AcDream.App/Rendering/InteriorEntityPartition.cs index 9ef43dda..150345da 100644 --- a/src/AcDream.App/Rendering/InteriorEntityPartition.cs +++ b/src/AcDream.App/Rendering/InteriorEntityPartition.cs @@ -35,8 +35,55 @@ public static class InteriorEntityPartition public Dictionary> ByCell { get; } = new(); public List OutdoorStatic { get; } = new(); public List Dynamics { get; } = new(); + + // MP-Alloc: scratch for PruneEmptyCellBuckets — reused across frames + // so pruning itself doesn't allocate. + private readonly List _emptyCellScratch = new(); + + /// + /// MP-Alloc (2026-07-05): clear every collection in place for reuse + /// by . + /// The per-cell lists inside are cleared and + /// KEPT (not removed) so a steady-state frame with the same visible + /// cell set reuses the same List<WorldEntity> instances instead + /// of reallocating one per cell every frame. + /// + internal void ClearForReuse() + { + foreach (var list in ByCell.Values) + list.Clear(); + OutdoorStatic.Clear(); + Dynamics.Clear(); + } + + /// + /// MP-Alloc: drop any cell bucket that ended this frame with zero + /// entries (either newly emptied, or a leftover key from a previous + /// frame's visible-cell set that this frame never touched). Keeps + /// ByCell.Count / .Keys bit-identical to the old always-fresh- + /// Dictionary behavior — callers that inspect key presence/count + /// directly (not just TryGetValue) must see exactly the cells that + /// actually received at least one static this frame. + /// + internal void PruneEmptyCellBuckets() + { + _emptyCellScratch.Clear(); + foreach (var (cellId, list) in ByCell) + { + if (list.Count == 0) + _emptyCellScratch.Add(cellId); + } + foreach (var cellId in _emptyCellScratch) + ByCell.Remove(cellId); + } } + /// + /// Allocating overload — always returns a brand-new . + /// Kept for tests and any one-shot caller; the per-frame render path + /// uses the + /// reuse overload instead (see ). + /// public static Result Partition( HashSet visibleCells, IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, @@ -44,6 +91,28 @@ public static class InteriorEntityPartition IReadOnlyDictionary? AnimatedById)> landblockEntries) { var result = new Result(); + Partition(result, visibleCells, landblockEntries); + return result; + } + + /// + /// MP-Alloc (2026-07-05): reuse overload. Clears + /// in place (see ) and refills it, + /// reusing each cell's existing List<WorldEntity> when the + /// cell key survives from the previous frame instead of allocating a new + /// one — the per-cell dictionary entries persist across frames (cleared, + /// never removed) since the visible-cell set is usually stable frame to + /// frame. Identical partitioning output to the allocating overload; only + /// the backing storage is reused. + /// + public static void Partition( + Result result, + HashSet visibleCells, + IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, + IReadOnlyList Entities, + IReadOnlyDictionary? AnimatedById)> landblockEntries) + { + result.ClearForReuse(); foreach (var entry in landblockEntries) { foreach (var e in entry.Entities) @@ -70,7 +139,8 @@ public static class InteriorEntityPartition } } } - return result; + + result.PruneEmptyCellBuckets(); } /// Shared indoor classification — keep DrawDynamicsLast, the diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs index 30db299f..cfa83e9a 100644 --- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs +++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs @@ -42,6 +42,14 @@ public sealed class RetailPViewRenderer // (statics + outside-stage dynamics passing the slice cone). private readonly List _lateParticleOwnerScratch = new(); + // MP-Alloc (2026-07-05): the frame's entity partition (ByCell/OutdoorStatic/ + // Dynamics), reused across frames instead of `new`ing a Result (a Dictionary + // + 2 Lists, plus one List per visible cell) every DrawInside + // call. See InteriorEntityPartition.Partition(Result, ...) — clears in + // place and reuses each cell's list across frames when the cell stays + // visible. + private readonly InteriorEntityPartition.Result _partitionResult = new(); + // T2 (BR-4): retail has NO distance constant on the flood-admission chain // (DrawBuilding → portal walk → ConstructView: viewconeCheck + side test + // GetClip + GetVisible only). The old 48 m seed cap is replaced by the @@ -137,7 +145,8 @@ public sealed class RetailPViewRenderer centerLbY: ctx.RenderCenterLbY, renderRadius: ctx.RenderRadius); - var partition = InteriorEntityPartition.Partition(prepareCells, ctx.LandblockEntries); + InteriorEntityPartition.Partition(_partitionResult, prepareCells, ctx.LandblockEntries); + var partition = _partitionResult; var result = new RetailPViewFrameResult { PortalFrame = pvFrame,