perf(pipeline): MP-Alloc Task 3 - pool InteriorEntityPartition.Result
InteriorEntityPartition.Partition allocated a fresh Result (a Dictionary plus 2 Lists) and a new List<WorldEntity> 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<WorldEntity> 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 <noreply@anthropic.com>
This commit is contained in:
parent
a4b42a7417
commit
76b8b4e9fc
2 changed files with 81 additions and 2 deletions
|
|
@ -35,8 +35,55 @@ public static class InteriorEntityPartition
|
||||||
public Dictionary<uint, List<WorldEntity>> ByCell { get; } = new();
|
public Dictionary<uint, List<WorldEntity>> ByCell { get; } = new();
|
||||||
public List<WorldEntity> OutdoorStatic { get; } = new();
|
public List<WorldEntity> OutdoorStatic { get; } = new();
|
||||||
public List<WorldEntity> Dynamics { get; } = new();
|
public List<WorldEntity> Dynamics { get; } = new();
|
||||||
|
|
||||||
|
// MP-Alloc: scratch for PruneEmptyCellBuckets — reused across frames
|
||||||
|
// so pruning itself doesn't allocate.
|
||||||
|
private readonly List<uint> _emptyCellScratch = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MP-Alloc (2026-07-05): clear every collection in place for reuse
|
||||||
|
/// by <see cref="Partition(Result, HashSet{uint}, IEnumerable{ValueTuple})"/>.
|
||||||
|
/// The per-cell lists inside <see cref="ByCell"/> 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.
|
||||||
|
/// </summary>
|
||||||
|
internal void ClearForReuse()
|
||||||
|
{
|
||||||
|
foreach (var list in ByCell.Values)
|
||||||
|
list.Clear();
|
||||||
|
OutdoorStatic.Clear();
|
||||||
|
Dynamics.Clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Allocating overload — always returns a brand-new <see cref="Result"/>.
|
||||||
|
/// Kept for tests and any one-shot caller; the per-frame render path
|
||||||
|
/// uses the <see cref="Partition(Result, HashSet{uint}, IEnumerable{ValueTuple})"/>
|
||||||
|
/// reuse overload instead (see <see cref="RetailPViewRenderer"/>).
|
||||||
|
/// </summary>
|
||||||
public static Result Partition(
|
public static Result Partition(
|
||||||
HashSet<uint> visibleCells,
|
HashSet<uint> visibleCells,
|
||||||
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
|
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
|
||||||
|
|
@ -44,6 +91,28 @@ public static class InteriorEntityPartition
|
||||||
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> landblockEntries)
|
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> landblockEntries)
|
||||||
{
|
{
|
||||||
var result = new Result();
|
var result = new Result();
|
||||||
|
Partition(result, visibleCells, landblockEntries);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MP-Alloc (2026-07-05): reuse overload. Clears <paramref name="result"/>
|
||||||
|
/// in place (see <see cref="Result.ClearForReuse"/>) and refills it,
|
||||||
|
/// reusing each cell's existing <c>List<WorldEntity></c> 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.
|
||||||
|
/// </summary>
|
||||||
|
public static void Partition(
|
||||||
|
Result result,
|
||||||
|
HashSet<uint> visibleCells,
|
||||||
|
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
|
||||||
|
IReadOnlyList<WorldEntity> Entities,
|
||||||
|
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> landblockEntries)
|
||||||
|
{
|
||||||
|
result.ClearForReuse();
|
||||||
foreach (var entry in landblockEntries)
|
foreach (var entry in landblockEntries)
|
||||||
{
|
{
|
||||||
foreach (var e in entry.Entities)
|
foreach (var e in entry.Entities)
|
||||||
|
|
@ -70,7 +139,8 @@ public static class InteriorEntityPartition
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result;
|
|
||||||
|
result.PruneEmptyCellBuckets();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Shared indoor classification — keep DrawDynamicsLast, the
|
/// <summary>Shared indoor classification — keep DrawDynamicsLast, the
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,14 @@ public sealed class RetailPViewRenderer
|
||||||
// (statics + outside-stage dynamics passing the slice cone).
|
// (statics + outside-stage dynamics passing the slice cone).
|
||||||
private readonly List<WorldEntity> _lateParticleOwnerScratch = new();
|
private readonly List<WorldEntity> _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<WorldEntity> 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
|
// 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
|
||||||
|
|
@ -137,7 +145,8 @@ public sealed class RetailPViewRenderer
|
||||||
centerLbY: ctx.RenderCenterLbY,
|
centerLbY: ctx.RenderCenterLbY,
|
||||||
renderRadius: ctx.RenderRadius);
|
renderRadius: ctx.RenderRadius);
|
||||||
|
|
||||||
var partition = InteriorEntityPartition.Partition(prepareCells, ctx.LandblockEntries);
|
InteriorEntityPartition.Partition(_partitionResult, prepareCells, ctx.LandblockEntries);
|
||||||
|
var partition = _partitionResult;
|
||||||
var result = new RetailPViewFrameResult
|
var result = new RetailPViewFrameResult
|
||||||
{
|
{
|
||||||
PortalFrame = pvFrame,
|
PortalFrame = pvFrame,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue