First-fix from the visual-gate-failure handoff: an empty OutsideView means
"no outdoors visible from here," not "all outdoors." When inside a building
with an empty clipped mask, Step 4 now draws NO terrain/scenery instead of
disabling the stencil and flooding ungated terrain over the cell interior
(the Step-3 walls already occupy the framebuffer). Visual-confirmed: Holtburg
cottage cellar walls are solid now, no terrain bleed-through.
Also adds portal diagnostics that root-caused so-called "Bug B":
- PortalVisibilityBuilder: per-camera-cell CAMPORTAL census (polyLen +
side-test result) emitted BEFORE the BFS guards, so an empty OUTSIDEVIEW
can be traced to the exact gate.
- A8CellAudit `portals`: replicate BuildLoadedCell's polygon-vertex
resolution so PortalPolygons[i] validity is checkable offline.
Finding: the builder is largely CORRECT — it produces narrowed clipped
OutsideView regions for most cells (0172/0173/0162/015E/0165/016F). The
empty cases are mostly legitimate (windowless cellar can't see out; the
3rd-person camera eye on the outdoor side of a front-door plane culls that
exit). The handoff's Finding 2 ("under-produces, never narrows") is
substantially not real. Remaining wall-missing regressions in OTHER
buildings live in the cross-building Step-5 enforcement, escalated separately.
All gated behind ACDREAM_A8_INDOOR_BRANCH=1; default play unaffected.
App tests 108/108.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
222 lines
12 KiB
C#
222 lines
12 KiB
C#
// PortalVisibilityBuilder.cs
|
|
//
|
|
// Phase A8.F: recursive portal-clip visibility (the builder). Port of retail
|
|
// PView::ConstructView (decomp:433750) -> ClipPortals (433572) -> AddViewToPortals
|
|
// (433446). Walks the portal graph from the camera cell, accumulating a per-cell
|
|
// screen-space CellView; exit portals union their clipped region into OutsideView.
|
|
// GL-free; unit-tested without a GPU context.
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Numerics;
|
|
|
|
namespace AcDream.App.Rendering;
|
|
|
|
/// <summary>Per-frame output of the portal-frame BFS.</summary>
|
|
public sealed class PortalVisibilityFrame
|
|
{
|
|
/// <summary>Screen region (NDC) where outdoor terrain/scenery may draw — exit portals
|
|
/// recursively clipped to their portal chain. The cellar-flap fix.</summary>
|
|
public CellView OutsideView { get; } = new();
|
|
|
|
/// <summary>Per-cell accumulated clip region, keyed by full cell id (wire-in #2).</summary>
|
|
public Dictionary<uint, CellView> CellViews { get; } = new();
|
|
|
|
/// <summary>Entry clip regions for other buildings reached through our portals, keyed by the
|
|
/// neighbour cell id that left the camera building's cell set (wire-in #3 / Step 5).</summary>
|
|
public Dictionary<uint, CellView> CrossBuildingViews { get; } = new();
|
|
}
|
|
|
|
public static class PortalVisibilityBuilder
|
|
{
|
|
// Per-cell re-processing bound. NOTE: this cap is the actual termination mechanism for
|
|
// cyclic portal graphs, NOT merely a safety net — the re-enqueue-on-growth guard below is
|
|
// a near-no-op because CellView.Add never dedupes, so a cell almost always "grows". Retail
|
|
// instead converges via an update_count / set_view(...,i) slice watermark (decomp: AddToCell
|
|
// 433050 esi[0x11], InitCell timestamp, AddViewToPortals 433446). Consequences vs retail:
|
|
// (a) a cell reachable through >4 contributing portals under-counts; (b) duplicate polygons
|
|
// accumulate on cyclic/multi-path graphs (correctness survives — stencil marks are idempotent
|
|
// — but it is per-frame cost). Both bite only on dungeon-scale cyclic/hub graphs; a cottage
|
|
// cellar is a short chain where each cell is visited once. The faithful fixpoint port is filed
|
|
// as a fast-follow (docs/ISSUES.md) before A8.F is relied on for dungeons.
|
|
private const int MaxReprocessPerCell = 4;
|
|
private const float PortalSideEpsilon = 0.01f; // matches CellVisibility.PointInCellEpsilon
|
|
|
|
// TEMP diagnostic (Phase A8.F visual-gate triage; strip after): ACDREAM_A8_DUMP_PV=1 dumps the
|
|
// local→NDC→clipped portal geometry for the first 2 Build calls per distinct camera cell.
|
|
private static readonly bool s_pvDump =
|
|
Environment.GetEnvironmentVariable("ACDREAM_A8_DUMP_PV") == "1";
|
|
private static readonly Dictionary<uint, int> s_pvDumpCount = new();
|
|
|
|
/// <param name="lookup">Resolve a full cell id to its LoadedCell, or null if not loaded.</param>
|
|
/// <param name="buildingMembership">Optional: true if a cell id is in the camera building's cell
|
|
/// set. When provided, a neighbour OUTSIDE the set routes to CrossBuildingViews instead of
|
|
/// continuing the in-building BFS. Pass null to treat all reachable cells as in-building.</param>
|
|
public static PortalVisibilityFrame Build(
|
|
LoadedCell cameraCell,
|
|
Vector3 cameraPos,
|
|
Func<uint, LoadedCell?> lookup,
|
|
Matrix4x4 viewProj,
|
|
Func<uint, bool>? buildingMembership = null)
|
|
{
|
|
var frame = new PortalVisibilityFrame();
|
|
if (cameraCell == null) return frame;
|
|
|
|
// Interior portals never cross landblocks (same invariant as CellVisibility.GetVisibleCells);
|
|
// building-boundary crossings are handled separately via the buildingMembership escape hatch.
|
|
uint lbMask = cameraCell.CellId & 0xFFFF0000u;
|
|
|
|
frame.CellViews[cameraCell.CellId] = CellView.FullScreen();
|
|
var processCount = new Dictionary<uint, int>();
|
|
var queue = new Queue<LoadedCell>();
|
|
queue.Enqueue(cameraCell);
|
|
|
|
bool pvDump = false;
|
|
if (s_pvDump)
|
|
{
|
|
lock (s_pvDumpCount)
|
|
{
|
|
s_pvDumpCount.TryGetValue(cameraCell.CellId, out int dc);
|
|
if (dc < 2) { s_pvDumpCount[cameraCell.CellId] = dc + 1; pvDump = true; }
|
|
}
|
|
if (pvDump)
|
|
{
|
|
Console.WriteLine($"[pv-dump] camCell=0x{cameraCell.CellId:X8} portals={cameraCell.Portals.Count} polyLists={cameraCell.PortalPolygons.Count} vp[M11={viewProj.M11:F3} M22={viewProj.M22:F3} M33={viewProj.M33:F3} M34={viewProj.M34:F3} M43={viewProj.M43:F3} M44={viewProj.M44:F3}]");
|
|
// Camera-cell portal census (A8.F triage 2026-05-29): report, for EVERY
|
|
// portal, the exact inputs the BFS guards read — BEFORE the guards run, so
|
|
// a portal the loop silently `continue`s past is still visible here. An
|
|
// empty OUTSIDEVIEW can then be traced to the precise gate: polyLen<3 (empty
|
|
// polygon from BuildLoadedCell), interiorSide=false (camera back-facing the
|
|
// portal — a legitimately-empty result, not a bug), or (if both OK) a
|
|
// downstream projection/clip failure shown by the EXIT-PROJ/EXIT-CLIP lines.
|
|
for (int ci = 0; ci < cameraCell.Portals.Count; ci++)
|
|
{
|
|
int plen = ci < cameraCell.PortalPolygons.Count
|
|
? (cameraCell.PortalPolygons[ci]?.Length ?? -1) : -2;
|
|
bool hasPlane = ci < cameraCell.ClipPlanes.Count;
|
|
bool interiorSide = !hasPlane || CameraOnInteriorSide(cameraCell, ci, cameraPos);
|
|
var n = hasPlane ? cameraCell.ClipPlanes[ci].Normal : Vector3.Zero;
|
|
Console.WriteLine($"[pv-dump] CAMPORTAL[{ci}] other=0x{cameraCell.Portals[ci].OtherCellId:X4} polyLen={plen} hasPlane={hasPlane} interiorSide={interiorSide} planeN=({n.X:F3},{n.Y:F3},{n.Z:F3})");
|
|
}
|
|
}
|
|
}
|
|
|
|
while (queue.Count > 0)
|
|
{
|
|
var cell = queue.Dequeue();
|
|
if (!frame.CellViews.TryGetValue(cell.CellId, out var currentView) || currentView.IsEmpty)
|
|
continue;
|
|
|
|
processCount.TryGetValue(cell.CellId, out int pc);
|
|
if (pc >= MaxReprocessPerCell) continue;
|
|
processCount[cell.CellId] = pc + 1;
|
|
|
|
for (int i = 0; i < cell.Portals.Count; i++)
|
|
{
|
|
if (i >= cell.PortalPolygons.Count) continue;
|
|
var poly = cell.PortalPolygons[i];
|
|
if (poly == null || poly.Length < 3) continue;
|
|
|
|
bool dx = pvDump && cell.Portals[i].OtherCellId == 0xFFFF;
|
|
|
|
// Portal-side test: only traverse a portal the camera is on the interior side of
|
|
// (mirrors CellVisibility.GetVisibleCells + retail's 'seen' flag). Culls back-facing
|
|
// portals so we never feed a degenerate/wrong-facing projection downstream.
|
|
if (i < cell.ClipPlanes.Count && !CameraOnInteriorSide(cell, i, cameraPos))
|
|
{
|
|
if (dx) Console.WriteLine($"[pv-dump] EXIT-CULLED(side) cell=0x{cell.CellId:X8} p{i} localN={poly.Length} hasClipPlane={(i < cell.ClipPlanes.Count)}");
|
|
continue;
|
|
}
|
|
|
|
// Project to NDC, then normalize to CCW for the CCW-only ScreenPolygonClip
|
|
// (ProjectToNdc preserves input winding; portal dat polygons may be CW).
|
|
Vector2[] portalNdc = PortalProjection.ProjectToNdc(poly, cell.WorldTransform, viewProj);
|
|
if (dx) Console.WriteLine($"[pv-dump] EXIT-PROJ cell=0x{cell.CellId:X8} p{i} localN={poly.Length} ndcN={portalNdc.Length} local0=({poly[0].X:F2},{poly[0].Y:F2},{poly[0].Z:F2}) ndc=[{string.Join(" ", System.Array.ConvertAll(portalNdc, v => $"({v.X:F2},{v.Y:F2})"))}]");
|
|
if (portalNdc.Length < 3) continue;
|
|
EnsureCcw(portalNdc);
|
|
|
|
// Intersect the portal opening with every polygon of the current cell's view.
|
|
var clippedRegion = new List<ViewPolygon>();
|
|
foreach (var vp in currentView.Polygons)
|
|
{
|
|
var clipped = ScreenPolygonClip.Intersect(portalNdc, vp.Vertices);
|
|
if (clipped.Length >= 3) clippedRegion.Add(new ViewPolygon(clipped));
|
|
}
|
|
if (dx) Console.WriteLine($"[pv-dump] EXIT-CLIP cell=0x{cell.CellId:X8} p{i} currentViewPolys={currentView.Polygons.Count} clipResult={clippedRegion.Count}");
|
|
if (clippedRegion.Count == 0) continue; // portal not visible through this chain
|
|
|
|
var portal = cell.Portals[i];
|
|
|
|
if (portal.OtherCellId == 0xFFFF)
|
|
{
|
|
if (pvDump)
|
|
{
|
|
Console.WriteLine($"[pv-dump] EXIT cell=0x{cell.CellId:X8} p{i} localN={poly.Length} ndcN={portalNdc.Length} clipPolys={clippedRegion.Count}");
|
|
Console.WriteLine($"[pv-dump] local=[{string.Join(" ", System.Array.ConvertAll(poly, v => $"({v.X:F2},{v.Y:F2},{v.Z:F2})"))}]");
|
|
Console.WriteLine($"[pv-dump] ndc=[{string.Join(" ", System.Array.ConvertAll(portalNdc, v => $"({v.X:F3},{v.Y:F3})"))}]");
|
|
foreach (var cp in clippedRegion)
|
|
Console.WriteLine($"[pv-dump] clipped({cp.Vertices.Length})=[{string.Join(" ", System.Array.ConvertAll((Vector2[])cp.Vertices, v => $"({v.X:F3},{v.Y:F3})"))}]");
|
|
}
|
|
// Exit portal -> outdoors visible through this (clipped) opening.
|
|
foreach (var cp in clippedRegion) frame.OutsideView.Add(cp);
|
|
continue;
|
|
}
|
|
|
|
// TODO(A8.F): neighbour-side OtherPortalClip (decomp:433524) — also clip the
|
|
// interior portal against the neighbour's matching portal polygon. Not implemented
|
|
// here; add if multi-cell conformance shows over-inclusion.
|
|
uint neighbourId = lbMask | portal.OtherCellId;
|
|
|
|
// Cross-building boundary: route to CrossBuildingViews, don't continue in-building BFS.
|
|
if (buildingMembership != null && !buildingMembership(neighbourId))
|
|
{
|
|
var xview = GetOrCreate(frame.CrossBuildingViews, neighbourId);
|
|
foreach (var cp in clippedRegion) xview.Add(cp);
|
|
continue;
|
|
}
|
|
|
|
var neighbour = lookup(neighbourId);
|
|
if (neighbour == null) continue;
|
|
|
|
// Union the clipped region into the neighbour's view; (re)enqueue if it grew.
|
|
var nview = GetOrCreate(frame.CellViews, neighbourId);
|
|
int before = nview.Polygons.Count;
|
|
foreach (var cp in clippedRegion) nview.Add(cp);
|
|
if (nview.Polygons.Count > before)
|
|
queue.Enqueue(neighbour);
|
|
}
|
|
}
|
|
|
|
if (pvDump)
|
|
Console.WriteLine($"[pv-dump] OUTSIDEVIEW polys={frame.OutsideView.Polygons.Count} bfsCellViews={frame.CellViews.Count} crossBldg={frame.CrossBuildingViews.Count}");
|
|
|
|
return frame;
|
|
}
|
|
|
|
// Mirrors CellVisibility's portal-side test (InsideSide convention).
|
|
private static bool CameraOnInteriorSide(LoadedCell cell, int portalIndex, Vector3 cameraPos)
|
|
{
|
|
var plane = cell.ClipPlanes[portalIndex];
|
|
if (plane.Normal.LengthSquared() < 1e-8f) return true; // no usable plane → allow
|
|
var localCam = Vector3.Transform(cameraPos, cell.InverseWorldTransform);
|
|
float dot = Vector3.Dot(plane.Normal, localCam) + plane.D;
|
|
return plane.InsideSide == 0 ? dot >= -PortalSideEpsilon : dot <= PortalSideEpsilon;
|
|
}
|
|
|
|
// Reverse vertex order in place if the polygon is wound clockwise (signed area < 0).
|
|
private static void EnsureCcw(Vector2[] poly)
|
|
{
|
|
float area2 = 0f;
|
|
for (int i = 0; i < poly.Length; i++)
|
|
{
|
|
var p = poly[i]; var q = poly[(i + 1) % poly.Length];
|
|
area2 += p.X * q.Y - q.X * p.Y;
|
|
}
|
|
if (area2 < 0f) Array.Reverse(poly);
|
|
}
|
|
|
|
private static CellView GetOrCreate(Dictionary<uint, CellView> map, uint key)
|
|
{
|
|
if (!map.TryGetValue(key, out var v)) { v = new CellView(); map[key] = v; }
|
|
return v;
|
|
}
|
|
}
|