Root cause (pinned by the new deterministic exit-walk harness, NOT guessed):
under an interior render root, the exit-portal SEAL stamps the door fan at
TRUE depth after the gated full depth clear, and T1's "ALL dynamics last"
pass then drew the outdoor-classified player depth-tested - every fragment
beyond the door plane z-failed against the seal across the whole aperture.
Harness measured the full window: from the moment the sphere center crosses
the plane until the eye follows (~2.6 m of camera lag, ~2.2 s at walk speed)
the player is invisible; while straddling, the beyond-plane body half clips
at the plane. The handoff's three cone-level candidates are all EXONERATED:
the cone walk passes every step; (eye, ViewerCellId) come from the same
SweepEye call with camera-update-before-visibility-read in the same frame;
the side-test window is sub-epsilon under healthy resolution.
Retail oracle (grep-named-first): PView::DrawCells 0x005a4840 runs
LScape::draw FIRST (pc:432719), then the gated depth clear (pc:432731-32)
and the exit-portal seals (pc:432785-86); outdoor cell objects draw inside
the landscape stage (DrawBlock 0x005a17c0 -> DrawSortCell pc:430124), and
an object draws once per overlapped shadow cell (pc:430056-64) - the
straddling body composes from both stages, neither half clips.
Fix: RetailPViewRenderer assigns dynamics to the OUTSIDE stage under an
interior root when outdoor-classified OR sphere-straddling an exit-portal
plane of their flood-visible cell (DynamicDrawsInOutsideStage - pure, the
harness drives it as the ordering contract); they ride the landscape slice
draw (pre-clear, seal-protected) with the same per-slice cone test as
outdoor statics. Indoor dynamics keep the last pass (retail loop C);
straddlers draw in both (retail shadow dual-draw). Outdoor roots keep
all-dynamics-last - the BR-2 punch-after-dynamics lesson (88be519) stands.
Apparatus: HouseExitWalkReplayTests - dat-backed corner-building exit walk
driving the production stack headlessly (RetailChaseCamera damping ->
healthy-sweep viewer resolution -> PortalVisibilityBuilder.Build ->
ClipFrameAssembler -> ViewconeCuller -> the DrawDynamicsLast predicate +
a CPU seal-depth model). 5 tests: cone pin, seal-depth pin, straddle
dual-draw pin, per-step table, stale-root window quantifier (#118 cand 2).
Suites: App 232 (227+5), Core 1416+2skip, UI 420, Net 294.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
86 lines
3.5 KiB
C#
86 lines
3.5 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
|
|
{
|
|
public sealed class Result
|
|
{
|
|
public Dictionary<uint, List<WorldEntity>> ByCell { get; } = new();
|
|
public List<WorldEntity> OutdoorStatic { get; } = new();
|
|
public List<WorldEntity> Dynamics { get; } = new();
|
|
}
|
|
|
|
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();
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/// <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);
|
|
}
|