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,