fix(G.3): register portals-only connector cells for visibility (#133 ramp grey)

The grey "barrier" at a dungeon ramp was a one-cell registration gap. The ramp's
connector cell (0x0007014D) is a portals-only pass-through — CellMesh.Build yields
0 drawable sub-meshes for it (you walk through it on adjacent floors). But the whole
registration block — including the portal-VISIBILITY registration (BuildLoadedCell ->
_cellVisibility) — was gated behind `if (cellSubMeshes.Count > 0)`. So that cell was
never added to the visibility graph; the flood lookup-missed it (PortalVisibilityBuilder
:369), couldn't traverse it to the room below, and the grey clear color showed through.

Confirmed live via two added probes: [cellreg] registered=204/205 (only 0x014D missing)
+ [pv-trace] p4->0x0007014D skip=lookup-miss. After the fix: registered=205,
hasRamp=True, skip=lookup-miss gone, the room below renders.

Fix: compute the cell transforms and call BuildLoadedCell (visibility) for EVERY cell
with a valid cellStruct, regardless of drawable sub-meshes — matching retail, which
keeps the whole landblock cell array resident before the flood runs. Drawing
(RegisterCell, _pendingCellMeshes) and the physics BSP (CacheCellStruct) stay gated on
drawable geometry (a portals-only connector has nothing to draw and no collision
surface). Not a regression from the FPS-collapse work — a pre-existing gate the
now-navigable dungeon exposed (every ramp/stair/cellar mouth would show it).

TEMP diagnostics retained for the residual angle-grey investigation (strip after):
[cellreg] (GameWindow), the 0x0007 [pv-trace] gate widen + raw-NDC bbox (PortalVisibility-
Builder). Three earlier render-math theories (portal_side, on-screen clip, near-eye
projection) were each refuted by apparatus/probe before shipping — this is the verified one.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-14 13:49:02 +02:00
parent 7d8da99f79
commit d90c5385d2
2 changed files with 77 additions and 33 deletions

View file

@ -61,6 +61,7 @@ public sealed class GameWindow : IDisposable
// though the title-bar FPS is only updated every 0.5s. // though the title-bar FPS is only updated every 0.5s.
private double _lastFps = 60.0; private double _lastFps = 60.0;
private double _lastFrameMs = 16.7; private double _lastFrameMs = 16.7;
private string _lastCellRegSig = ""; // TEMP #133 ramp-flood-collapse [cellreg] dedup
// Phase I.2: per-frame counters surfaced through the ImGui DebugPanel // Phase I.2: per-frame counters surfaced through the ImGui DebugPanel
// VM closures. Computed once per render pass alongside the frustum // VM closures. Computed once per render pass alongside the frustum
@ -5664,26 +5665,42 @@ public sealed class GameWindow : IDisposable
// Static objects inside the cell continue to flow through the dispatcher // Static objects inside the cell continue to flow through the dispatcher
// as WorldEntity records below — they have real GfxObj MeshRefs that work // as WorldEntity records below — they have real GfxObj MeshRefs that work
// fine; EnvCellRenderer.RegisterCell receives an empty staticObjects list. // fine; EnvCellRenderer.RegisterCell receives an empty staticObjects list.
// Transforms — needed by the portal-visibility cell (unlifted) AND the
// render/physics path. Computed for EVERY cell with a valid cellStruct,
// not just drawable ones. Keep the small render lift out of physics; retail
// BSP contact planes use the EnvCell origin verbatim. The lift constant is
// shared with every draw-space consumer of portal polygons (OutsideView
// gate, seal/punch fans) — PortalVisibilityBuilder.ShellDrawLiftZ (#130).
var physicsCellOrigin = envCell.Position.Origin + lbOffset;
var cellOrigin = physicsCellOrigin + new System.Numerics.Vector3(
0f, 0f, AcDream.App.Rendering.PortalVisibilityBuilder.ShellDrawLiftZ);
var cellTransform =
System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
System.Numerics.Matrix4x4.CreateTranslation(cellOrigin);
var physicsCellTransform =
System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
System.Numerics.Matrix4x4.CreateTranslation(physicsCellOrigin);
// PORTAL VISIBILITY: register EVERY cell with a valid cellStruct, regardless
// of whether CellMesh.Build produced drawable sub-meshes. A portals-only
// pass-through connector (a ramp / stair / cellar mouth) yields 0 render
// sub-meshes but MUST be in the visibility graph so the flood can traverse it
// to the cells beyond — otherwise the flood lookup-misses the unregistered
// neighbour and the grey clear shows through the opening (#133: ramp
// neighbour 0x0007014D had 0 sub-meshes → unregistered → vis=1 grey barrier
// at the ramp; confirmed via [cellreg] registered=204/205 + [pv-trace]
// skip=lookup-miss). Retail keeps the whole landblock cell array resident
// before the flood runs; BuildLoadedCell reads the cellStruct portals, NOT
// the render sub-meshes. The +0.02 m render lift is a DRAW concern only and
// is intentionally NOT fed into the visibility transform (#119-residual: the
// lift shifted horizontal portal planes 2 cm, side-culling deck/stair cells).
BuildLoadedCell(envCellId, envCell, cellStruct, physicsCellOrigin, physicsCellTransform);
var cellSubMeshes = AcDream.Core.Meshing.CellMesh.Build(envCell, cellStruct, _dats); var cellSubMeshes = AcDream.Core.Meshing.CellMesh.Build(envCell, cellStruct, _dats);
if (cellSubMeshes.Count > 0) if (cellSubMeshes.Count > 0)
{ {
_pendingCellMeshes[envCellId] = cellSubMeshes; _pendingCellMeshes[envCellId] = cellSubMeshes;
// Keep the small render lift out of physics; retail BSP
// contact planes use the EnvCell origin verbatim. The lift
// constant is shared with every draw-space consumer of
// portal polygons (OutsideView gate, seal/punch fans) —
// see PortalVisibilityBuilder.ShellDrawLiftZ (#130).
var physicsCellOrigin = envCell.Position.Origin + lbOffset;
var cellOrigin = physicsCellOrigin + new System.Numerics.Vector3(
0f, 0f, AcDream.App.Rendering.PortalVisibilityBuilder.ShellDrawLiftZ);
var cellTransform =
System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
System.Numerics.Matrix4x4.CreateTranslation(cellOrigin);
var physicsCellTransform =
System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
System.Numerics.Matrix4x4.CreateTranslation(physicsCellOrigin);
// Phase A8: register the cell with EnvCellRenderer for rendering. // Phase A8: register the cell with EnvCellRenderer for rendering.
// staticObjects is empty — cell stabs continue as separate WorldEntity // staticObjects is empty — cell stabs continue as separate WorldEntity
// records via the dispatcher (see lines below for the unchanged stab path). // records via the dispatcher (see lines below for the unchanged stab path).
@ -5697,23 +5714,8 @@ public sealed class GameWindow : IDisposable
cellRotation: envCell.Position.Orientation, cellRotation: envCell.Position.Orientation,
staticObjects: System.Array.Empty<(uint, System.Numerics.Vector3, System.Numerics.Quaternion, bool, System.Numerics.Matrix4x4)>()); staticObjects: System.Array.Empty<(uint, System.Numerics.Vector3, System.Numerics.Quaternion, bool, System.Numerics.Matrix4x4)>());
// Step 4: build LoadedCell for portal visibility — with the // Cache CellStruct physics BSP for indoor collision (UNCHANGED — gated
// PHYSICS (unlifted) transform. The +0.02 m render lift above // on drawable cells; a portals-only connector has no collision surface).
// is a DRAW concern (shell z-fighting vs terrain); feeding it
// into the visibility graph shifted every HORIZONTAL portal
// plane 2 cm up, putting an eye standing on a deck/landing
// 1020 mm BELOW the lifted plane — outside the side test's
// ±10 mm in-plane window — so the cell behind the portal was
// side-culled: the tower-top staircase vanish + roof flap
// (#119-residual; captured live at eye z=126.803 vs the
// 010A→0107 plane at 126.80, reproduced ONLY with the lift in
// TowerAscentReplayTests.CapturedTopOfStairs_*). Vertical
// doorways were immune (the lift slides their planes along
// themselves), which is why this hit exactly stairs, decks,
// and cellar mouths.
BuildLoadedCell(envCellId, envCell, cellStruct, physicsCellOrigin, physicsCellTransform);
// Cache CellStruct physics BSP for indoor collision (UNCHANGED).
_physicsDataCache.CacheCellStruct(envCellId, envCell, cellStruct, physicsCellTransform); _physicsDataCache.CacheCellStruct(envCellId, envCell, cellStruct, physicsCellTransform);
} }
} }
@ -7689,6 +7691,25 @@ public sealed class GameWindow : IDisposable
playerCellId: playerRoot?.CellId ?? 0u, playerCellId: playerRoot?.CellId ?? 0u,
lights: Lighting); lights: Lighting);
// TEMP (#133 ramp-flood-collapse): cell-registration completeness for the
// player's dungeon landblock. If the ramp neighbour (0x....014D in 0x0007)
// is absent from _cellVisibility, the portal flood can't admit it (lookup-miss
// at PortalVisibilityBuilder.cs:369) and the grey clear shows through. Logs only
// when the count or ramp-presence changes (dedup) — pairs with [pv-trace] skip=.
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled && playerRoot is not null)
{
uint plb = playerRoot.CellId >> 16;
int reg = _cellVisibility.GetCellsForLandblock(plb).Count;
uint rampId = (plb << 16) | 0x014Du;
bool hasRamp = _cellVisibility.TryGetCell(rampId, out _);
string sig = plb.ToString("X4") + ":" + reg + ":" + hasRamp;
if (sig != _lastCellRegSig)
{
_lastCellRegSig = sig;
Console.WriteLine($"[cellreg] lb=0x{plb:X4} registered={reg} hasRamp0x{rampId:X8}={hasRamp} playerCell=0x{playerRoot.CellId:X8}");
}
}
// Never cull the landblock the player is currently on. // Never cull the landblock the player is currently on.
uint? playerLb = null; uint? playerLb = null;
if (_playerMode && _playerController is not null) if (_playerMode && _playerController is not null)

View file

@ -759,7 +759,13 @@ public static class PortalVisibilityBuilder
private static bool IsHoltburgIndoorProbeCell(uint cellId) private static bool IsHoltburgIndoorProbeCell(uint cellId)
{ {
if ((cellId & 0xFFFF0000u) != 0xA9B40000u) uint lb = cellId & 0xFFFF0000u;
// TEMP (#133 ramp-flood-collapse diagnosis): widen the [pv-trace] gate to the
// 0x0007 Town Network dungeon so the per-portal skip= reason (lookup-miss /
// clip-empty / reciprocal-empty / side) is emitted for the ramp neighbour.
if (lb == 0x00070000u)
return true;
if (lb != 0xA9B40000u)
return false; return false;
uint low = cellId & 0xFFFFu; uint low = cellId & 0xFFFFu;
return low >= 0x016F && low <= 0x0175; return low >= 0x016F && low <= 0x0175;
@ -821,6 +827,7 @@ public static class PortalVisibilityBuilder
// genuinely off-screen; the ndc coords (post-clip, bounded) show where on screen it lands. // genuinely off-screen; the ndc coords (post-clip, bounded) show where on screen it lands.
int projN = -1, clipN = -1; int projN = -1, clipN = -1;
string ndcText = ""; string ndcText = "";
string rawText = "";
if (i < cameraCell.PortalPolygons.Count) if (i < cameraCell.PortalPolygons.Count)
{ {
var poly = cameraCell.PortalPolygons[i]; var poly = cameraCell.PortalPolygons[i];
@ -830,6 +837,21 @@ public static class PortalVisibilityBuilder
projN = clip.Length; projN = clip.Length;
if (clip.Length >= 3) if (clip.Length >= 3)
{ {
// Raw projected-NDC bbox (pre-screen-clip): WHERE the portal lands on screen,
// even when ClipToRegion drops it to empty. A clip=0 portal whose raw bbox is
// inside [-1,1] is on-screen-but-wrongly-dropped (the bug); a bbox outside
// [-1,1] is genuinely off-screen (correct). Distinguishes the two.
float rminX = float.MaxValue, rminY = float.MaxValue, rmaxX = -float.MaxValue, rmaxY = -float.MaxValue;
foreach (var cv in clip)
{
if (cv.W <= 1e-6f) continue;
float nx = cv.X / cv.W, ny = cv.Y / cv.W;
rminX = MathF.Min(rminX, nx); rmaxX = MathF.Max(rmaxX, nx);
rminY = MathF.Min(rminY, ny); rmaxY = MathF.Max(rmaxY, ny);
}
if (rminX <= rmaxX)
rawText = FormattableString.Invariant($" raw=[{rminX:F1},{rminY:F1}..{rmaxX:F1},{rmaxY:F1}]");
var ndc = PortalProjection.ClipToRegion(clip, FullScreenQuad); var ndc = PortalProjection.ClipToRegion(clip, FullScreenQuad);
clipN = ndc.Length; clipN = ndc.Length;
var ns = new System.Text.StringBuilder(48); var ns = new System.Text.StringBuilder(48);
@ -842,6 +864,7 @@ public static class PortalVisibilityBuilder
sb.Append(" D=").Append(float.IsNaN(d) ? "na" : d.ToString("F2")); sb.Append(" D=").Append(float.IsNaN(d) ? "na" : d.ToString("F2"));
sb.Append(side ? " TRV" : " CULL"); sb.Append(side ? " TRV" : " CULL");
sb.Append(" proj=").Append(projN).Append(" clip=").Append(clipN); sb.Append(" proj=").Append(projN).Append(" clip=").Append(clipN);
if (rawText.Length > 0) sb.Append(rawText);
if (ndcText.Length > 0) sb.Append(" ndc=").Append(ndcText); if (ndcText.Length > 0) sb.Append(" ndc=").Append(ndcText);
} }
sb.Append(" || outPolys=").Append(frame.OutsideView.Polygons.Count); sb.Append(" || outPolys=").Append(frame.OutsideView.Polygons.Count);