acdream/src/AcDream.App/Rendering/InteriorEntityPartition.cs
Erik b2b67341ac feat(rendering): establish current-path scene referee
Capture deterministic, landblock-aware fingerprints from the accepted partition only during lifecycle automation. This gives the F/G shadow scene an independent oracle without changing normal draw decisions or adding disabled-path per-entity cost.
2026-07-24 21:13:11 +02:00

250 lines
9.6 KiB
C#

using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.World;
namespace AcDream.App.Rendering;
/// <summary>
/// Splits a frame's landblock entities into the draw buckets used by the
/// retail-style DrawInside flood.
///
/// <para>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).</para>
///
/// <list type="bullet">
/// <item><see cref="Result.ByCell"/> — indoor STATICS (dat-baked, ServerGuid==0)
/// per visible cell, drawn with their cell.</item>
/// <item><see cref="Result.OutdoorStatic"/> — outdoor statics (building
/// shells, scenery stabs), drawn with the world/landscape pass.</item>
/// <item><see cref="Result.Dynamics"/> — ALL server-spawned entities
/// (ServerGuid != 0) regardless of cell, plus unresolved-cell live entities;
/// drawn in the frame's single LAST entity pass.</item>
/// </list>
/// </summary>
public static class InteriorEntityPartition
{
internal enum ProjectionClass : byte
{
OutdoorStatic,
CellStatic,
Dynamic,
}
internal interface IObserver
{
void BeginFrame();
void Observe(
uint landblockId,
WorldEntity entity,
ProjectionClass projectionClass);
void Complete(Result result);
void AbortFrame();
}
public sealed class Result
{
public Dictionary<uint, List<WorldEntity>> ByCell { get; } = new();
public List<WorldEntity> OutdoorStatic { 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&lt;WorldEntity&gt; 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(
HashSet<uint> visibleCells,
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> landblockEntries)
{
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&lt;WorldEntity&gt;</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 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<WorldEntity>();
list.Add(e);
}
else
{
result.OutdoorStatic.Add(e);
}
}
}
result.PruneEmptyCellBuckets();
}
/// <summary>
/// Current-path referee overload. The observer sees the exact branch that
/// populated each accepted partition entry, including its owning
/// landblock. A null observer is the production fast path and performs no
/// diagnostic allocation or hashing.
/// </summary>
internal static void Partition(
Result result,
HashSet<uint> visibleCells,
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> landblockEntries,
IObserver? observer)
{
if (observer is null)
{
Partition(result, visibleCells, landblockEntries);
return;
}
observer.BeginFrame();
try
{
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, regardless of cell.
if (e.ServerGuid != 0)
{
result.Dynamics.Add(e);
observer.Observe(
entry.LandblockId,
e,
ProjectionClass.Dynamic);
}
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<WorldEntity>();
list.Add(e);
observer.Observe(
entry.LandblockId,
e,
ProjectionClass.CellStatic);
}
else
{
result.OutdoorStatic.Add(e);
observer.Observe(
entry.LandblockId,
e,
ProjectionClass.OutdoorStatic);
}
}
}
result.PruneEmptyCellBuckets();
observer.Complete(result);
}
catch
{
observer.AbortFrame();
throw;
}
}
/// <summary>Shared indoor classification — keep DrawDynamicsLast, the
/// outside-stage assignment (#118), and the partition in lockstep.</summary>
public static bool IsIndoorCellId(uint cellId)
{
uint low = cellId & 0xFFFFu;
return low >= 0x0100u && low != 0xFFFFu;
}
/// <inheritdoc cref="IsIndoorCellId(uint)"/>
public static bool IsIndoorCellId(uint? cellId) => cellId is uint c && IsIndoorCellId(c);
}