#118: outdoor dynamics draw in the outside stage under interior roots - the house-exit clip+vanish was the SEAL z-killing the player

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>
This commit is contained in:
Erik 2026-06-11 16:49:29 +02:00
parent acaaeae434
commit 5a80a2ee24
5 changed files with 628 additions and 10 deletions

View file

@ -73,9 +73,14 @@ public static class InteriorEntityPartition
return result;
}
private static bool IsIndoorCellId(uint cellId)
/// <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);
}

View file

@ -109,12 +109,39 @@ public sealed class RetailPViewRenderer
// assembled slices + this frame's view-projection.
var viewcone = ViewconeCuller.Build(clipAssembly, ctx.ViewProjection);
// #118: stage assignment for dynamics under an INTERIOR root. Retail
// draws the OUTSIDE world's objects inside the landscape stage —
// PView::DrawCells runs LScape::draw FIRST (pc:432719), then the gated
// full depth clear (pc:432731-432732) and the exit-portal SEALS
// (pc:432785-432786); DrawBlock draws every landcell's objects via
// DrawSortCell (0x005a17c0, pc:430124). A dynamic deferred to our
// single last pass instead z-fails against the seal's true-depth stamp
// the moment it stands beyond the door plane — the house-exit
// clip+vanish (pinned by HouseExitWalkReplayTests). So under an
// interior root: outdoor-classified dynamics draw in the outside
// stage; an indoor dynamic whose sphere STRADDLES an exit portal
// draws in BOTH stages (retail's per-overlapped-cell shadow-part
// draw, DrawBlock pc:430056-430064) so neither body half clips at the
// plane. Outdoor roots keep ALL dynamics in the last pass — our
// z-buffered equivalent of retail's painter-ordered outdoor pass (the
// BR-2 punch-after-dynamics lesson, reverted 88be519).
_outsideStageDynamics.Clear();
if (!ctx.RootCell.IsOutdoorNode)
{
foreach (var e in partition.Dynamics)
{
EntitySphere(e, out var c, out float r);
if (DynamicDrawsInOutsideStage(e.ParentCellId, c, r, drawableCells, ctx.CellLookup))
_outsideStageDynamics.Add(e);
}
}
DrawLandscapeThroughOutsideView(ctx, clipAssembly, partition, viewcone);
UseIndoorMembershipOnlyRouting();
DrawExitPortalMasks(ctx, pvFrame, clipAssembly, drawableCells);
DrawEnvCellShells(pvFrame);
DrawCellObjectLists(ctx, pvFrame, clipAssembly, drawableCells, partition, viewcone);
DrawDynamicsLast(ctx, partition, viewcone);
DrawDynamicsLast(ctx, partition, viewcone, ctx.RootCell.IsOutdoorNode);
return result;
}
@ -216,6 +243,17 @@ public sealed class RetailPViewRenderer
if (viewcone.SphereVisibleInOutsideSlice(probeSliceIndex, c, r))
_outdoorStaticScratch.Add(e);
}
// #118: outside-stage dynamics ride the landscape pass like retail's
// per-landcell DrawSortCell (DrawBlock 0x005a17c0, pc:430124) — drawn
// BEFORE the depth clear + seals so the seal PROTECTS their pixels in
// the aperture instead of z-killing them. Same per-slice cone test as
// the statics above. Empty under outdoor roots (see DrawInside).
foreach (var e in _outsideStageDynamics)
{
EntitySphere(e, out var c, out float r);
if (viewcone.SphereVisibleInOutsideSlice(probeSliceIndex, c, r))
_outdoorStaticScratch.Add(e);
}
probeSliceIndex++;
ctx.DrawLandscapeSlice(new RetailPViewLandscapeSliceContext(slice, _outdoorStaticScratch));
}
@ -375,7 +413,8 @@ public sealed class RetailPViewRenderer
private void DrawDynamicsLast(
IRetailPViewCellDrawContext ctx,
InteriorEntityPartition.Result partition,
ViewconeCuller viewcone)
ViewconeCuller viewcone,
bool rootIsOutdoor)
{
if (partition.Dynamics.Count == 0)
return;
@ -384,8 +423,16 @@ public sealed class RetailPViewRenderer
foreach (var e in partition.Dynamics)
{
EntitySphere(e, out var c, out float r);
bool indoor = e.ParentCellId is uint cell
&& (cell & 0xFFFFu) >= 0x0100u && (cell & 0xFFFFu) != 0xFFFFu;
bool indoor = InteriorEntityPartition.IsIndoorCellId(e.ParentCellId);
// #118: under an interior root, outdoor-classified dynamics drew in
// the outside stage (pre-clear, seal-protected) — retail draws them
// via LScape::draw's per-landcell DrawSortCell, never in the
// post-seal cell-object epilogue (PView::DrawCells pc:432719 vs
// pc:432878). Drawing them here instead z-fails them against the
// seal. Indoor dynamics (incl. exit-portal straddlers, which drew
// in BOTH stages) stay — this pass is retail's loop C.
if (!rootIsOutdoor && !indoor)
continue;
bool visible = indoor
? viewcone.SphereVisibleInCell(e.ParentCellId!.Value, c, r)
: viewcone.SphereVisibleOutside(c, r);
@ -459,6 +506,57 @@ public sealed class RetailPViewRenderer
private readonly List<WorldEntity> _outdoorStaticScratch = new();
private readonly List<WorldEntity> _cellStaticScratch = new();
private readonly List<WorldEntity> _dynamicsScratch = new();
// #118: dynamics assigned to the OUTSIDE stage this frame (interior roots
// only) — outdoor-classified + exit-portal straddlers. Cleared per frame.
private readonly List<WorldEntity> _outsideStageDynamics = new();
/// <summary>
/// #118 stage assignment for a dynamic under an INTERIOR root: does it draw
/// in the OUTSIDE (landscape) stage — before the gated depth clear and the
/// exit-portal seals — like retail's per-landcell object draw
/// (LScape::draw → DrawBlock 0x005a17c0 → DrawSortCell pc:430124, run at
/// the top of PView::DrawCells pc:432719)?
///
/// True for outdoor-classified dynamics (their fragments lie beyond the
/// door plane and would z-fail the seal in the last pass), and for INDOOR
/// dynamics whose sphere straddles an exit-portal plane of their flood-
/// visible cell — retail draws an object once per overlapped shadow cell
/// (DrawBlock pc:430056-430064), so a threshold-straddling body draws in
/// both stages and neither half clips at the plane. Pure — also driven
/// headlessly by HouseExitWalkReplayTests as the ordering contract.
/// </summary>
public static bool DynamicDrawsInOutsideStage(
uint? parentCellId,
Vector3 sphereCenter,
float sphereRadius,
HashSet<uint> drawableCells,
Func<uint, LoadedCell?> cellLookup)
{
if (!InteriorEntityPartition.IsIndoorCellId(parentCellId))
return true;
uint cellId = parentCellId!.Value;
if (!drawableCells.Contains(cellId))
return false; // not in the flood — the last-pass cone cull owns it
var cell = cellLookup(cellId);
if (cell is null)
return false;
var localC = Vector3.Transform(sphereCenter, cell.InverseWorldTransform);
int n = Math.Min(cell.Portals.Count, cell.ClipPlanes.Count);
for (int i = 0; i < n; i++)
{
if (cell.Portals[i].OtherCellId != 0xFFFF)
continue;
var plane = cell.ClipPlanes[i];
if (plane.Normal.LengthSquared() < 1e-8f)
continue;
float dist = Vector3.Dot(plane.Normal, localC) + plane.D;
if (MathF.Abs(dist) < sphereRadius)
return true; // sphere straddles the exit-portal plane
}
return false;
}
// Conservative bounding sphere from the entity's cached AABB — the same
// bounds source the dispatcher's frustum cull uses.