T1 (fused BR-2/3): retail frame order - dynamics last, punch+seal, shell chop deleted

The complete retail drawing order in one installment (per the amended plan:
every installment is a COMPLETE retail behavior - the half-ported punch of
88be519 is re-landed here WITH the ordering that makes it correct):

  static world (sky/terrain/weather/shells/scenery)
    -> aperture depth writes (interior SEAL at true depth / outdoor+look-in
       PUNCH to far-Z; PortalDepthMaskRenderer, DrawPortalPolyInternal
       Ghidra 0x0059bc90)
    -> interior cells WHOLE, far-to-near, drawn once (DrawCells Loop 2,
       Ghidra 0x005a4840; use_built_mesh pc:427905)
    -> per-cell STATIC object lists
    -> ALL dynamics LAST (DrawDynamicsLast), depth-tested, never hard-clipped

InteriorEntityPartition: new contract - every ServerGuid != 0 entity goes
to Dynamics regardless of cell (indoor/outdoor/unresolved/hidden); ByCell
carries only dat-baked indoor statics of visible cells; Outdoor renamed
OutdoorStatic. Fixes the audit's livedynamic-invisible-under-interior-roots
divergence as a side effect (live entities are never dropped by the
visibility set; culling is T3's viewcone).

DELETED (retail has no counterpart): the gl_ClipDistance shell chop
(927fd8f enable + 9ce335e outdoor scoping + UseShellClipRouting + the
per-slice shell loop + clipShells param) - retail never clips cell
geometry; aperture exactness = punch/seal + z-buffer + this order. The
old per-slice scissored AABB depth clear is replaced by retail's single
gated full clear (ClearDepthForInterior). The interior-root LiveDynamic
top-up draw and the look-in's dynamics involvement are gone (one last
pass, no double-draws).

Closes at the T5 gate (expected): #114 (chop deleted), the char-eaten-by-
doorway regression (ordering), outdoor interiors-through-doorways (punch);
#108's render half (seal) - its membership half stays re-attributed.

Suites: build green, App 226 green (partition tests rewritten to the T1
contract), Core 1398 + 4 pre-existing #99-era + 1 skip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-11 11:16:27 +02:00
parent 1e5db94f0e
commit 579c8b06bc
4 changed files with 234 additions and 200 deletions

View file

@ -6,17 +6,35 @@ namespace AcDream.App.Rendering;
/// <summary>
/// Splits a frame's landblock entities into the draw buckets used by the
/// retail-style DrawInside flood. Indoor ownership wins for live dynamics too:
/// a player, NPC, door, or item with a current indoor ParentCellId belongs to
/// that cell's portal-clipped object list, not a global overlay pass.
/// 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
{
public sealed class Result
{
public Dictionary<uint, List<WorldEntity>> ByCell { get; } = new();
public List<WorldEntity> Outdoor { get; } = new();
public List<WorldEntity> LiveDynamic { get; } = new();
public List<WorldEntity> OutdoorStatic { get; } = new();
public List<WorldEntity> Dynamics { get; } = new();
}
public static Result Partition(
@ -32,46 +50,29 @@ public static class InteriorEntityPartition
{
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)
{
if (e.ParentCellId is uint liveCell)
AddByCellOrOutdoor(e, liveCell, visibleCells, result);
else
result.LiveDynamic.Add(e);
result.Dynamics.Add(e);
}
else if (e.ParentCellId is uint cell)
else if (e.ParentCellId is uint cell && IsIndoorCellId(cell))
{
AddByCellOrOutdoor(e, cell, visibleCells, result);
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.Outdoor.Add(e);
result.OutdoorStatic.Add(e);
}
}
}
return result;
}
private static void AddByCellOrOutdoor(
WorldEntity entity,
uint cellId,
HashSet<uint> visibleCells,
Result result)
{
if (!IsIndoorCellId(cellId))
{
result.Outdoor.Add(entity);
return;
}
if (!visibleCells.Contains(cellId))
return;
if (!result.ByCell.TryGetValue(cellId, out var list))
result.ByCell[cellId] = list = new List<WorldEntity>();
list.Add(entity);
}
private static bool IsIndoorCellId(uint cellId)
{
uint low = cellId & 0xFFFFu;