feat(render): indoor render WORKS — terminating portal flood + every-cell seal + look-in FPS
Checkpoint of the unified retail-faithful indoor render. The two-week HANG/grey is fixed and the interior seals (live-verified by the user). Commits the session render-rewrite foundation together with the fixes that made it functional. - HANG fix: PortalVisibilityBuilder.Build portal flood did not terminate (the faithful ProjectToClip near-side clip drifts per round, defeating the CellView dedup; the BFS had no bound after U.2a removed MaxReprocessPerCell). Fix = drift-tolerant snapped/canonical CellView.Add dedup (PortalView.cs) plus restored MaxReprocessPerCell=16 bounded re-enqueue (PortalVisibilityBuilder.cs). Re-enqueue is kept (load-bearing for late-slice propagation, Build_ViewGrowthAfterDoneCell_PropagatesNewSlicesToExit); only its count is capped. CellViewDedupTests added. - Seal (DrawCells Task 2): RetailPViewRenderer.DrawEnvCellShells draws EVERY visible cell via IndoorDrawPlan.ShellPass (was gated on the ClipFrameAssembler slot filter, leaving slot-less cells grey). - Look-in FPS: GameWindow exterior look-in candidates limited to the player landblock +-1 (was all ~81 loaded LBs iterated every outdoor frame). No behaviour change (far cells were >48m, already culled). Remaining dominant issue = the FLAP at transitions: viewer-cell metastability (render roots at the camera-eye cell, which oscillates outdoor-indoor as the 3rd-person boom drifts across the doorway, confirmed in render-sig). SEPARATE fix, NOT the DrawCells port. Full handoff + flap fix plan + tracked follow-ups (#78 terrain, look-in-from-inside, look-in FPS, L-spotlight): docs/research/2026-06-07-indoor-render-session-handoff.md. Baselines: build 0 err; App.Tests 210/210; Core.Tests 1331 pass / 4 fail (pre-existing) / 1 skip. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
bff1955066
commit
1405dd8e90
27 changed files with 3635 additions and 814 deletions
|
|
@ -37,6 +37,19 @@ public static class PortalVisibilityBuilder
|
|||
{
|
||||
private const float PortalSideEpsilon = 0.01f; // matches CellVisibility.PointInCellEpsilon
|
||||
|
||||
// Bounded re-enqueue cap (restored 2026-06-07). The distance-priority portal flood re-enqueues a
|
||||
// cell whenever its accumulated view GROWS, which is load-bearing — it propagates a late-discovered
|
||||
// portal_view slice to that cell's exit portals (Build_ViewGrowthAfterDoneCell_PropagatesNewSlicesToExit).
|
||||
// But the faithful near-side clip (ProjectToClip) drifts per round, so re-clipping a cell's view yields
|
||||
// ever-smaller distinct sub-regions / drifted near-duplicates the dedup can't always collapse -> the
|
||||
// grow flag never settles and the flood spins forever (the indoor hang). This cap bounds each cell to
|
||||
// at most this many pops, so the flood terminates in <= N*cap pops regardless of drift while still
|
||||
// allowing the few re-processes that legitimate late-slice propagation needs. The old hard cap removed
|
||||
// in U.2a was 4; widened here because ProjectToClip drifts more than the old ProjectToNdc and Option A's
|
||||
// CellView dedup already collapses most spurious growth, so the cap rarely binds. Tune from the visual
|
||||
// gate if an interior view under-includes a slice.
|
||||
private const int MaxReprocessPerCell = 16;
|
||||
|
||||
// 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 =
|
||||
|
|
@ -81,7 +94,11 @@ public static class PortalVisibilityBuilder
|
|||
// the instant a cell is popped). Enqueue-once across the cell set is the hard termination
|
||||
// guarantee for cyclic / hub / diamond graphs: at most N cells are ever processed. The
|
||||
// camera cell is pre-marked so a portal looping back to it can never re-enqueue it.
|
||||
var seen = new HashSet<uint> { cameraCell.CellId };
|
||||
var queued = new HashSet<uint> { cameraCell.CellId };
|
||||
var drawListed = new HashSet<uint>();
|
||||
var processedViewCounts = new Dictionary<uint, int>();
|
||||
var popCounts = new Dictionary<uint, int>(); // per-cell pop count for the MaxReprocessPerCell cap
|
||||
var trace = PortalBuildTrace.Start(cameraCell, cameraPos);
|
||||
|
||||
bool pvDump = false;
|
||||
if (s_pvDump)
|
||||
|
|
@ -116,46 +133,81 @@ public static class PortalVisibilityBuilder
|
|||
while (todo.Count > 0)
|
||||
{
|
||||
var cell = todo.PopNearest();
|
||||
queued.Remove(cell.CellId);
|
||||
// Bounded re-enqueue (2026-06-07 termination fix): count this pop. The re-enqueue gate below
|
||||
// refuses to re-add a cell already popped MaxReprocessPerCell times, so the flood terminates
|
||||
// even when ProjectToClip drift keeps a view growing forever. Re-enqueue itself is KEPT — it
|
||||
// propagates late-discovered slices to exit portals (see MaxReprocessPerCell); only its count
|
||||
// is capped.
|
||||
popCounts.TryGetValue(cell.CellId, out int popsSoFar);
|
||||
popCounts[cell.CellId] = popsSoFar + 1;
|
||||
if (!frame.CellViews.TryGetValue(cell.CellId, out var currentView) || currentView.IsEmpty)
|
||||
{
|
||||
trace?.Add($"pop cell=0x{cell.CellId:X8} skip=no-view");
|
||||
continue;
|
||||
}
|
||||
|
||||
// `seen` guarantees each cell is inserted into the todo list exactly once, so this single
|
||||
// pop IS the cell's closest-first draw position (retail appends to cell_draw_list once per
|
||||
// pop, 433783) — no per-pop dedup needed, OrderedVisibleCells stays distinct by construction.
|
||||
frame.OrderedVisibleCells.Add(cell.CellId);
|
||||
if (drawListed.Add(cell.CellId))
|
||||
frame.OrderedVisibleCells.Add(cell.CellId);
|
||||
|
||||
processedViewCounts.TryGetValue(cell.CellId, out int processedCount);
|
||||
int endCount = currentView.Polygons.Count;
|
||||
if (processedCount >= endCount)
|
||||
{
|
||||
trace?.Add($"pop cell=0x{cell.CellId:X8} skip=processed processed={processedCount} views={endCount}");
|
||||
continue;
|
||||
}
|
||||
trace?.Add($"pop cell=0x{cell.CellId:X8} processed={processedCount}->{endCount} drawPos={frame.OrderedVisibleCells.Count - 1}");
|
||||
|
||||
var activeViewPolygons = currentView.Polygons.GetRange(processedCount, endCount - processedCount);
|
||||
processedViewCounts[cell.CellId] = endCount;
|
||||
|
||||
for (int i = 0; i < cell.Portals.Count; i++)
|
||||
{
|
||||
if (i >= cell.PortalPolygons.Count) continue;
|
||||
var portal = cell.Portals[i];
|
||||
if (i >= cell.PortalPolygons.Count)
|
||||
{
|
||||
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{portal.OtherCellId:X4} skip=no-poly-slot");
|
||||
continue;
|
||||
}
|
||||
var poly = cell.PortalPolygons[i];
|
||||
if (poly == null || poly.Length < 3) continue;
|
||||
if (poly == null || poly.Length < 3)
|
||||
{
|
||||
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{portal.OtherCellId:X4} skip=degenerate-poly len={(poly?.Length ?? -1)}");
|
||||
continue;
|
||||
}
|
||||
|
||||
bool dx = pvDump && cell.Portals[i].OtherCellId == 0xFFFF;
|
||||
bool eyeInsideOpening = EyeInsidePortalOpening(poly, cell.WorldTransform, cameraPos);
|
||||
bool sideAllowed = true;
|
||||
|
||||
// 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 (i < cell.ClipPlanes.Count
|
||||
&& !CameraOnInteriorSide(cell, i, cameraPos)
|
||||
&& !eyeInsideOpening)
|
||||
{
|
||||
sideAllowed = false;
|
||||
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{portal.OtherCellId:X4} skip=side eyeIn={eyeInsideOpening}");
|
||||
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})"))}]");
|
||||
var clippedRegion = new List<ViewPolygon>();
|
||||
if (portalNdc.Length >= 3)
|
||||
{
|
||||
EnsureCcw(portalNdc);
|
||||
// Intersect the portal opening with every polygon of the current cell's view.
|
||||
foreach (var vp in currentView.Polygons)
|
||||
{
|
||||
var clipped = ScreenPolygonClip.Intersect(portalNdc, vp.Vertices);
|
||||
if (clipped.Length >= 3) clippedRegion.Add(new ViewPolygon(clipped));
|
||||
}
|
||||
}
|
||||
// Retail PView::ClipPortals calls GetClip(..., finish=1): transform to
|
||||
// homogeneous clip space, clip at the eye, then clip against the current
|
||||
// portal_view region before the divide. Do the same here; the old early
|
||||
// ProjectToNdc + 2D intersect path is too unstable for near/grazing doorways.
|
||||
var clippedRegion = ClipPortalAgainstView(
|
||||
poly,
|
||||
cell.WorldTransform,
|
||||
viewProj,
|
||||
activeViewPolygons,
|
||||
out int clipVerts);
|
||||
if (dx) Console.WriteLine($"[pv-dump] EXIT-PROJ cell=0x{cell.CellId:X8} p{i} localN={poly.Length} clipN={clipVerts} local0=({poly[0].X:F2},{poly[0].Y:F2},{poly[0].Z:F2})");
|
||||
if (dx) Console.WriteLine($"[pv-dump] EXIT-CLIP cell=0x{cell.CellId:X8} p{i} currentViewPolys={currentView.Polygons.Count} clipResult={clippedRegion.Count}");
|
||||
|
||||
// R1 void fix (2026-06-05): the projected+clipped region is empty — normally we cull the
|
||||
|
|
@ -171,25 +223,26 @@ public static class PortalVisibilityBuilder
|
|||
if (clippedRegion.Count == 0)
|
||||
{
|
||||
if (!EyeInsidePortalOpening(poly, cell.WorldTransform, cameraPos))
|
||||
{
|
||||
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{portal.OtherCellId:X4} skip=clip-empty side={sideAllowed} eyeIn={eyeInsideOpening} clipVerts={clipVerts}");
|
||||
continue; // portal not visible through this chain, and the eye is not standing in it
|
||||
foreach (var vp in currentView.Polygons)
|
||||
}
|
||||
foreach (var vp in activeViewPolygons)
|
||||
clippedRegion.Add(new ViewPolygon((Vector2[])vp.Vertices.Clone()));
|
||||
}
|
||||
|
||||
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] EXIT cell=0x{cell.CellId:X8} p{i} localN={poly.Length} clipVerts={clipVerts} 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);
|
||||
AddRegion(frame.OutsideView, clippedRegion);
|
||||
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->EXIT addOutside={clippedRegion.Count} clipVerts={clipVerts} eyeIn={eyeInsideOpening}");
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -202,12 +255,17 @@ public static class PortalVisibilityBuilder
|
|||
if (buildingMembership != null && !buildingMembership(neighbourId))
|
||||
{
|
||||
var xview = GetOrCreate(frame.CrossBuildingViews, neighbourId);
|
||||
foreach (var cp in clippedRegion) xview.Add(cp);
|
||||
bool grewCross = AddRegion(xview, clippedRegion);
|
||||
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{neighbourId:X8} crossBldg polys={clippedRegion.Count} grew={grewCross}");
|
||||
continue;
|
||||
}
|
||||
|
||||
var neighbour = lookup(neighbourId);
|
||||
if (neighbour == null) continue;
|
||||
if (neighbour == null)
|
||||
{
|
||||
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{neighbourId:X8} skip=lookup-miss polys={clippedRegion.Count}");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Phase U.2b — neighbour-side OtherPortalClip (retail PView::OtherPortalClip
|
||||
// decomp:433524). The portal opening seen from THIS cell may be wider than the
|
||||
|
|
@ -222,12 +280,24 @@ public static class PortalVisibilityBuilder
|
|||
// direct index is what lets a cell with TWO portals to the same neighbour clip each
|
||||
// opening against its OWN reciprocal instead of the first one. Mutates clippedRegion
|
||||
// in place before the union below.
|
||||
var preReciprocalClip = eyeInsideOpening ? CloneViewPolygons(clippedRegion) : null;
|
||||
int preReciprocalCount = clippedRegion.Count;
|
||||
ApplyReciprocalClip(clippedRegion, portal.OtherPortalId, neighbour, viewProj);
|
||||
if (clippedRegion.Count == 0) continue; // reciprocal opening doesn't overlap → not visible
|
||||
if (clippedRegion.Count == 0)
|
||||
{
|
||||
if (preReciprocalClip is null)
|
||||
{
|
||||
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{neighbourId:X8} skip=reciprocal-empty pre={preReciprocalCount} otherPortal={portal.OtherPortalId}");
|
||||
continue;
|
||||
}
|
||||
clippedRegion.AddRange(preReciprocalClip);
|
||||
}
|
||||
|
||||
// Union the clipped region into the neighbour's accumulated view.
|
||||
var nview = GetOrCreate(frame.CellViews, neighbourId);
|
||||
foreach (var cp in clippedRegion) nview.Add(cp);
|
||||
bool grew = AddRegion(nview, clippedRegion);
|
||||
bool inserted = false;
|
||||
float dist = float.NaN;
|
||||
|
||||
// Insert the neighbour into the distance-priority list — but ONLY on first discovery
|
||||
// (retail enqueues via InsCellTodoList solely in the ecx_5==0 branch; growth into an
|
||||
|
|
@ -237,11 +307,13 @@ public static class PortalVisibilityBuilder
|
|||
// portal-opening vertex in world space (retail InitCell min-vertex distance,
|
||||
// 432988-433004); derived from the portal geometry, so it works even when the cell's
|
||||
// WorldPosition was never populated.
|
||||
if (seen.Add(neighbourId))
|
||||
if (grew && popCounts.GetValueOrDefault(neighbourId) < MaxReprocessPerCell && queued.Add(neighbourId))
|
||||
{
|
||||
float dist = NearestPortalVertexDistance(poly, cell.WorldTransform, cameraPos);
|
||||
dist = NearestPortalVertexDistance(poly, cell.WorldTransform, cameraPos);
|
||||
todo.Insert(neighbour, dist);
|
||||
inserted = true;
|
||||
}
|
||||
trace?.Add($"portal cell=0x{cell.CellId:X8} p{i}->0x{neighbourId:X8} addCell polys={clippedRegion.Count} clipVerts={clipVerts} recip={preReciprocalCount}->{clippedRegion.Count} grew={grew} queued={inserted} dist={(float.IsNaN(dist) ? "na" : dist.ToString("F2"))}");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -252,6 +324,161 @@ public static class PortalVisibilityBuilder
|
|||
// root cell's per-portal side-test + projection + the frame's exit/visible counts.
|
||||
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled)
|
||||
EmitFlapProbe(cameraCell, cameraPos, viewProj, frame);
|
||||
trace?.Emit(frame);
|
||||
|
||||
return frame;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build a portal visibility frame for an OUTDOOR viewer looking into one or more
|
||||
/// outside-facing cell portals. This is the reciprocal of <see cref="Build"/>:
|
||||
/// the seed view is the projected exit-portal opening instead of a full-screen
|
||||
/// camera cell. It keeps the same retail distance-priority traversal and
|
||||
/// neighbour reciprocal clipping once inside the building.
|
||||
/// </summary>
|
||||
public static PortalVisibilityFrame BuildFromExterior(
|
||||
IEnumerable<LoadedCell> candidateCells,
|
||||
Vector3 cameraPos,
|
||||
Func<uint, LoadedCell?> lookup,
|
||||
Matrix4x4 viewProj,
|
||||
float maxSeedDistance = float.PositiveInfinity)
|
||||
{
|
||||
var frame = new PortalVisibilityFrame();
|
||||
var todo = new CellTodoList();
|
||||
var queued = new HashSet<uint>();
|
||||
var drawListed = new HashSet<uint>();
|
||||
var processedViewCounts = new Dictionary<uint, int>();
|
||||
var popCounts = new Dictionary<uint, int>(); // per-cell pop count for the MaxReprocessPerCell cap
|
||||
|
||||
foreach (var cell in candidateCells)
|
||||
{
|
||||
if (cell is null) continue;
|
||||
|
||||
for (int i = 0; i < cell.Portals.Count; i++)
|
||||
{
|
||||
var portal = cell.Portals[i];
|
||||
if (portal.OtherCellId != 0xFFFF)
|
||||
continue;
|
||||
if (i >= cell.PortalPolygons.Count)
|
||||
continue;
|
||||
|
||||
var poly = cell.PortalPolygons[i];
|
||||
if (poly == null || poly.Length < 3)
|
||||
continue;
|
||||
|
||||
// Exterior peering starts from the OUTSIDE face of an exit portal.
|
||||
// If the camera is on the cell-interior side, the normal indoor
|
||||
// DrawInside path owns this portal instead.
|
||||
if (i < cell.ClipPlanes.Count && CameraOnInteriorSide(cell, i, cameraPos))
|
||||
continue;
|
||||
|
||||
float seedDistance = NearestPortalVertexDistance(poly, cell.WorldTransform, cameraPos);
|
||||
if (seedDistance > maxSeedDistance)
|
||||
continue;
|
||||
|
||||
var clippedRegion = ClipPortalAgainstView(
|
||||
poly,
|
||||
cell.WorldTransform,
|
||||
viewProj,
|
||||
FullScreenRegion,
|
||||
out _);
|
||||
|
||||
if (clippedRegion.Count == 0)
|
||||
{
|
||||
if (!EyeInsidePortalOpening(poly, cell.WorldTransform, cameraPos))
|
||||
continue;
|
||||
clippedRegion.Add(new ViewPolygon((Vector2[])FullScreenQuad.Clone()));
|
||||
}
|
||||
|
||||
var seedView = GetOrCreate(frame.CellViews, cell.CellId);
|
||||
bool grew = AddRegion(seedView, clippedRegion);
|
||||
|
||||
if (grew && queued.Add(cell.CellId))
|
||||
todo.Insert(cell, seedDistance);
|
||||
}
|
||||
}
|
||||
|
||||
while (todo.Count > 0)
|
||||
{
|
||||
var cell = todo.PopNearest();
|
||||
queued.Remove(cell.CellId);
|
||||
// Bounded re-enqueue — see the matching note in Build(). Count this pop; the gate below caps
|
||||
// re-enqueues at MaxReprocessPerCell so the look-in flood terminates under ProjectToClip drift.
|
||||
popCounts.TryGetValue(cell.CellId, out int popsSoFar);
|
||||
popCounts[cell.CellId] = popsSoFar + 1;
|
||||
if (!frame.CellViews.TryGetValue(cell.CellId, out var currentView) || currentView.IsEmpty)
|
||||
continue;
|
||||
|
||||
if (drawListed.Add(cell.CellId))
|
||||
frame.OrderedVisibleCells.Add(cell.CellId);
|
||||
|
||||
processedViewCounts.TryGetValue(cell.CellId, out int processedCount);
|
||||
int endCount = currentView.Polygons.Count;
|
||||
if (processedCount >= endCount)
|
||||
continue;
|
||||
|
||||
var activeViewPolygons = currentView.Polygons.GetRange(processedCount, endCount - processedCount);
|
||||
processedViewCounts[cell.CellId] = endCount;
|
||||
uint lbMask = cell.CellId & 0xFFFF0000u;
|
||||
|
||||
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;
|
||||
|
||||
var portal = cell.Portals[i];
|
||||
if (portal.OtherCellId == 0xFFFF)
|
||||
continue; // already outdoors; exterior terrain was drawn by the caller.
|
||||
|
||||
bool eyeInsideOpening = EyeInsidePortalOpening(poly, cell.WorldTransform, cameraPos);
|
||||
if (i < cell.ClipPlanes.Count
|
||||
&& !CameraOnInteriorSide(cell, i, cameraPos)
|
||||
&& !eyeInsideOpening)
|
||||
continue;
|
||||
|
||||
var clippedRegion = ClipPortalAgainstView(
|
||||
poly,
|
||||
cell.WorldTransform,
|
||||
viewProj,
|
||||
activeViewPolygons,
|
||||
out _);
|
||||
|
||||
if (clippedRegion.Count == 0)
|
||||
{
|
||||
if (!eyeInsideOpening)
|
||||
continue;
|
||||
foreach (var vp in activeViewPolygons)
|
||||
clippedRegion.Add(new ViewPolygon((Vector2[])vp.Vertices.Clone()));
|
||||
}
|
||||
|
||||
uint neighbourId = lbMask | portal.OtherCellId;
|
||||
var neighbour = lookup(neighbourId);
|
||||
if (neighbour == null)
|
||||
continue;
|
||||
|
||||
var preReciprocalClip = eyeInsideOpening ? CloneViewPolygons(clippedRegion) : null;
|
||||
ApplyReciprocalClip(clippedRegion, portal.OtherPortalId, neighbour, viewProj);
|
||||
if (clippedRegion.Count == 0)
|
||||
{
|
||||
if (preReciprocalClip is null)
|
||||
continue;
|
||||
clippedRegion.AddRange(preReciprocalClip);
|
||||
}
|
||||
|
||||
var nview = GetOrCreate(frame.CellViews, neighbourId);
|
||||
bool grew = AddRegion(nview, clippedRegion);
|
||||
|
||||
if (grew && popCounts.GetValueOrDefault(neighbourId) < MaxReprocessPerCell && queued.Add(neighbourId))
|
||||
{
|
||||
float dist = NearestPortalVertexDistance(poly, cell.WorldTransform, cameraPos);
|
||||
todo.Insert(neighbour, dist);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return frame;
|
||||
}
|
||||
|
|
@ -260,6 +487,117 @@ public static class PortalVisibilityBuilder
|
|||
private static readonly Vector2[] FullScreenQuad =
|
||||
{ new Vector2(-1f, -1f), new Vector2(1f, -1f), new Vector2(1f, 1f), new Vector2(-1f, 1f) };
|
||||
|
||||
private static readonly ViewPolygon[] FullScreenRegion =
|
||||
{ new ViewPolygon(FullScreenQuad) };
|
||||
|
||||
private static List<ViewPolygon> ClipPortalAgainstView(
|
||||
Vector3[] localPoly,
|
||||
Matrix4x4 cellToWorld,
|
||||
Matrix4x4 viewProj,
|
||||
IReadOnlyList<ViewPolygon> viewPolygons,
|
||||
out int clipVertexCount)
|
||||
{
|
||||
var portalClip = PortalProjection.ProjectToClip(localPoly, cellToWorld, viewProj);
|
||||
clipVertexCount = portalClip.Length;
|
||||
var clippedRegion = new List<ViewPolygon>();
|
||||
if (portalClip.Length < 3)
|
||||
return clippedRegion;
|
||||
|
||||
foreach (var vp in viewPolygons)
|
||||
{
|
||||
if (vp.IsEmpty)
|
||||
continue;
|
||||
|
||||
var clipped = PortalProjection.ClipToRegion(portalClip, vp.Vertices);
|
||||
if (clipped.Length >= 3)
|
||||
clippedRegion.Add(new ViewPolygon(clipped));
|
||||
}
|
||||
|
||||
return clippedRegion;
|
||||
}
|
||||
|
||||
private const int PortalTraceEmitLimit = 160;
|
||||
private static readonly object s_portalTraceLock = new();
|
||||
private static readonly Dictionary<uint, string> s_portalTraceLastSignature = new();
|
||||
private static int s_portalTraceEmits;
|
||||
|
||||
private sealed class PortalBuildTrace
|
||||
{
|
||||
private readonly uint _rootCellId;
|
||||
private readonly Vector3 _eye;
|
||||
private readonly List<string> _lines = new();
|
||||
|
||||
private PortalBuildTrace(uint rootCellId, Vector3 eye)
|
||||
{
|
||||
_rootCellId = rootCellId;
|
||||
_eye = eye;
|
||||
}
|
||||
|
||||
public static PortalBuildTrace? Start(LoadedCell root, Vector3 eye)
|
||||
{
|
||||
if (!AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled)
|
||||
return null;
|
||||
if (!IsHoltburgIndoorProbeCell(root.CellId))
|
||||
return null;
|
||||
return new PortalBuildTrace(root.CellId, eye);
|
||||
}
|
||||
|
||||
public void Add(string line)
|
||||
{
|
||||
if (_lines.Count < 96)
|
||||
_lines.Add(line);
|
||||
}
|
||||
|
||||
public void Emit(PortalVisibilityFrame frame)
|
||||
{
|
||||
string signature = BuildSignature(frame);
|
||||
lock (s_portalTraceLock)
|
||||
{
|
||||
if (s_portalTraceEmits >= PortalTraceEmitLimit)
|
||||
return;
|
||||
if (s_portalTraceLastSignature.TryGetValue(_rootCellId, out var last) &&
|
||||
string.Equals(last, signature, StringComparison.Ordinal))
|
||||
return;
|
||||
s_portalTraceLastSignature[_rootCellId] = signature;
|
||||
s_portalTraceEmits++;
|
||||
}
|
||||
|
||||
Console.WriteLine($"[pv-trace] root=0x{_rootCellId:X8} eye=({_eye.X:F2},{_eye.Y:F2},{_eye.Z:F2}) {signature}");
|
||||
foreach (var line in _lines)
|
||||
Console.WriteLine("[pv-trace] " + line);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsHoltburgIndoorProbeCell(uint cellId)
|
||||
{
|
||||
if ((cellId & 0xFFFF0000u) != 0xA9B40000u)
|
||||
return false;
|
||||
uint low = cellId & 0xFFFFu;
|
||||
return low >= 0x016F && low <= 0x0175;
|
||||
}
|
||||
|
||||
private static string BuildSignature(PortalVisibilityFrame frame)
|
||||
{
|
||||
var sb = new System.Text.StringBuilder(160);
|
||||
sb.Append("outPolys=").Append(frame.OutsideView.Polygons.Count);
|
||||
sb.Append(" cells=[");
|
||||
for (int i = 0; i < frame.OrderedVisibleCells.Count; i++)
|
||||
{
|
||||
if (i != 0) sb.Append(',');
|
||||
sb.Append("0x").Append((frame.OrderedVisibleCells[i] & 0xFFFFu).ToString("X4"));
|
||||
}
|
||||
sb.Append("] views=[");
|
||||
bool first = true;
|
||||
foreach (var kvp in frame.CellViews)
|
||||
{
|
||||
if (!first) sb.Append(',');
|
||||
first = false;
|
||||
sb.Append("0x").Append((kvp.Key & 0xFFFFu).ToString("X4")).Append(':').Append(kvp.Value.Polygons.Count);
|
||||
}
|
||||
sb.Append(']');
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
// Phase U.4c flap probe. One [flap] line per Build: the root cell's per-portal
|
||||
// signed distance D (eye→portal plane), traverse/cull decision, and NDC projection
|
||||
// vertex count, plus the frame's OutsideView polygon count + visible-cell count.
|
||||
|
|
@ -288,10 +626,10 @@ public static class PortalVisibilityBuilder
|
|||
d = Vector3.Dot(pl.Normal, localEye) + pl.D;
|
||||
side = CameraOnInteriorSide(cameraCell, i, cameraPos);
|
||||
}
|
||||
// Replicate the walk's project → EnsureCcw → Intersect(FullScreen) exactly, so a
|
||||
// portal that PROJECTS (proj>=3) but still fails to ADD its neighbour shows WHY:
|
||||
// clip=0 with ndc inside [-1,1] ⇒ winding/self-intersection degeneracy; clip=0 with
|
||||
// ndc outside [-1,1] ⇒ genuinely off-screen. The ndc coords expose a near-plane bowtie.
|
||||
// Replicate the walk's faithful path exactly (ProjectToClip → ClipToRegion(FullScreen)) so
|
||||
// proj/clip mean the same as production: proj = clip-space verts in front of the eye,
|
||||
// clip = verts surviving the screen-region clip. clip=0 with proj>=3 ⇒ the portal is
|
||||
// genuinely off-screen; the ndc coords (post-clip, bounded) show where on screen it lands.
|
||||
int projN = -1, clipN = -1;
|
||||
string ndcText = "";
|
||||
if (i < cameraCell.PortalPolygons.Count)
|
||||
|
|
@ -299,12 +637,12 @@ public static class PortalVisibilityBuilder
|
|||
var poly = cameraCell.PortalPolygons[i];
|
||||
if (poly != null && poly.Length >= 3)
|
||||
{
|
||||
var ndc = PortalProjection.ProjectToNdc(poly, cameraCell.WorldTransform, viewProj);
|
||||
projN = ndc.Length;
|
||||
if (ndc.Length >= 3)
|
||||
var clip = PortalProjection.ProjectToClip(poly, cameraCell.WorldTransform, viewProj);
|
||||
projN = clip.Length;
|
||||
if (clip.Length >= 3)
|
||||
{
|
||||
EnsureCcw(ndc);
|
||||
clipN = ScreenPolygonClip.Intersect(ndc, FullScreenQuad).Length;
|
||||
var ndc = PortalProjection.ClipToRegion(clip, FullScreenQuad);
|
||||
clipN = ndc.Length;
|
||||
var ns = new System.Text.StringBuilder(48);
|
||||
foreach (var v in ndc) ns.Append('(').Append(v.X.ToString("F1")).Append(',').Append(v.Y.ToString("F1")).Append(')');
|
||||
ndcText = ns.ToString();
|
||||
|
|
@ -376,6 +714,13 @@ public static class PortalVisibilityBuilder
|
|||
|
||||
// Project the reciprocal opening through the NEIGHBOUR's transform (retail positionPush(3,
|
||||
// &other_cell_ptr->pos) at 005a54d2), then normalize winding for the CCW-only clipper.
|
||||
// NOTE: this stays on the divide-then-clip ProjectToNdc path on purpose. The reciprocal is a
|
||||
// back-portal one hop away — never near the eye — so the homogeneous clip buys nothing here,
|
||||
// and ProjectToNdc is float-stable across the BFS re-enqueue rounds. Routing it through
|
||||
// ProjectToClip+ClipToRegion produced per-round float drift that defeated the CellView
|
||||
// SamePolygon dedup, inflating a tight A<->B reciprocal view to ~4x its area
|
||||
// (Build_AppliesReciprocalOtherPortalClip). The near-side clip (ClipPortalAgainstView) IS the
|
||||
// homogeneous path; this secondary tightening is not.
|
||||
Vector2[] reciprocalNdc = PortalProjection.ProjectToNdc(reciprocalPoly, neighbour.WorldTransform, viewProj);
|
||||
if (reciprocalNdc.Length < 3) return; // reciprocal entirely behind camera / degenerate → no-op
|
||||
EnsureCcw(reciprocalNdc);
|
||||
|
|
@ -395,11 +740,27 @@ public static class PortalVisibilityBuilder
|
|||
return v;
|
||||
}
|
||||
|
||||
private static bool AddRegion(CellView view, List<ViewPolygon> region)
|
||||
{
|
||||
bool grew = false;
|
||||
foreach (var poly in region)
|
||||
grew |= view.Add(poly);
|
||||
return grew;
|
||||
}
|
||||
|
||||
// Camera→nearest-vertex distance for a portal polygon, in world space. Mirrors the per-portal
|
||||
// min-distance loop retail runs in PView::InitCell (decomp:432988-433004) to key the todo list:
|
||||
// it walks the portal's vertices, transforms each to world space, and keeps the smallest
|
||||
// straight-line distance to the camera viewpoint. Keying on the portal opening (not the cell
|
||||
// origin) is both retail-faithful and robust to cells whose WorldPosition was never populated.
|
||||
private static List<ViewPolygon> CloneViewPolygons(List<ViewPolygon> source)
|
||||
{
|
||||
var clone = new List<ViewPolygon>(source.Count);
|
||||
foreach (var poly in source)
|
||||
clone.Add(new ViewPolygon((Vector2[])poly.Vertices.Clone()));
|
||||
return clone;
|
||||
}
|
||||
|
||||
private static float NearestPortalVertexDistance(Vector3[] localPoly, Matrix4x4 worldTransform, Vector3 cameraPos)
|
||||
{
|
||||
float best = float.MaxValue;
|
||||
|
|
@ -413,10 +774,11 @@ public static class PortalVisibilityBuilder
|
|||
}
|
||||
|
||||
// "Eye standing in the opening": the eye is within this perpendicular distance of a portal's
|
||||
// plane. At the cottage doorway the live capture measured D=0.16 m for the portal the chase
|
||||
// camera was standing in; 0.5 m comfortably covers a doorway-standing eye while excluding portals
|
||||
// the eye is merely facing from across a room (their projection is non-degenerate anyway).
|
||||
private const float EyeStandingPerpDist = 0.5f;
|
||||
// plane. Live captures hit two retail-valid degenerate cases: cottage doorway D=0.16 m and
|
||||
// cellar->stair portal D=1.41 m, both traversable but ProjectToNdc returned zero vertices. We still
|
||||
// require the perpendicular projection to land inside the opening, so side/offscreen portals stay
|
||||
// culled; this only covers active portals whose 2D projection collapses near the chase camera.
|
||||
private const float EyeStandingPerpDist = 1.75f;
|
||||
|
||||
/// <summary>
|
||||
/// True when the camera eye is "standing in" <paramref name="localPoly"/>'s opening: within
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue