using System.Collections.Generic; using System.Numerics; using AcDream.Core.World; namespace AcDream.App.Rendering; /// /// Splits a frame's landblock entities into the draw buckets used by the /// retail-style DrawInside flood. /// /// T1 (fused BR-2/3, 2026-06-11) — retail draw-order contract: the /// frame draws STATIC world first (terrain, building shells, scenery, then /// flooded interior cells + their static object lists), and every DYNAMIC /// (server-spawned: player, NPCs, doors, items) draws LAST, depth-tested, /// never hard-clipped. This is what makes the aperture depth punch safe — /// when the punch erases depth inside a doorway, no dynamic has been drawn /// yet, so nothing visible is destroyed (retail: objects draw per cell AFTER /// cells, PView::DrawCells epilogue Ghidra 0x005a4840; the first BR-2 attempt /// punched after dynamics and erased the player, reverted 88be519). /// /// /// — indoor STATICS (dat-baked, ServerGuid==0) /// per visible cell, drawn with their cell. /// — outdoor statics (building /// shells, scenery stabs), drawn with the world/landscape pass. /// — ALL server-spawned entities /// (ServerGuid != 0) regardless of cell, plus unresolved-cell live entities; /// drawn in the frame's single LAST entity pass. /// /// public static class InteriorEntityPartition { public sealed class Result { 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, IReadOnlyList Entities, 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) { if (e.MeshRefs.Count == 0) continue; // Retail contract: every server-spawned entity is a DYNAMIC // and draws in the last pass — indoor, outdoor, or unresolved. if (e.ServerGuid != 0) { result.Dynamics.Add(e); } else if (e.ParentCellId is uint cell && IsIndoorCellId(cell)) { if (!visibleCells.Contains(cell)) continue; if (!result.ByCell.TryGetValue(cell, out var list)) result.ByCell[cell] = list = new List(); list.Add(e); } else { result.OutdoorStatic.Add(e); } } } result.PruneEmptyCellBuckets(); } /// Shared indoor classification — keep DrawDynamicsLast, the /// outside-stage assignment (#118), and the partition in lockstep. public static bool IsIndoorCellId(uint cellId) { uint low = cellId & 0xFFFFu; return low >= 0x0100u && low != 0xFFFFu; } /// public static bool IsIndoorCellId(uint? cellId) => cellId is uint c && IsIndoorCellId(c); }