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>
145 lines
6.4 KiB
C#
145 lines
6.4 KiB
C#
// PortalView.cs
|
|
//
|
|
// Phase A8.F: GL-free 2D screen-space (NDC) clip-region data model.
|
|
// Mirrors retail view_poly (acclient.h:32465) and view_type (acclient.h:32338):
|
|
// a cell's clip region is a SET of convex polygons in normalized device coords.
|
|
using System.Collections.Generic;
|
|
using System.Numerics;
|
|
using System.Text;
|
|
|
|
namespace AcDream.App.Rendering;
|
|
|
|
/// <summary>One convex polygon in NDC screen space (xy in [-1,1]), plus its bounding rect.</summary>
|
|
public readonly struct ViewPolygon
|
|
{
|
|
public readonly Vector2[] Vertices;
|
|
public readonly float MinX, MinY, MaxX, MaxY;
|
|
|
|
public ViewPolygon(Vector2[] vertices)
|
|
{
|
|
Vertices = vertices;
|
|
if (vertices is null || vertices.Length < 3)
|
|
{
|
|
MinX = MinY = MaxX = MaxY = 0f;
|
|
return;
|
|
}
|
|
float minX = float.MaxValue, minY = float.MaxValue, maxX = float.MinValue, maxY = float.MinValue;
|
|
foreach (var v in vertices)
|
|
{
|
|
if (v.X < minX) minX = v.X;
|
|
if (v.X > maxX) maxX = v.X;
|
|
if (v.Y < minY) minY = v.Y;
|
|
if (v.Y > maxY) maxY = v.Y;
|
|
}
|
|
MinX = minX; MinY = minY; MaxX = maxX; MaxY = maxY;
|
|
}
|
|
|
|
public bool IsEmpty => Vertices is null || Vertices.Length < 3;
|
|
}
|
|
|
|
/// <summary>A cell's accumulated clip region: a set of convex view polygons + the union bounding rect.</summary>
|
|
public sealed class CellView
|
|
{
|
|
public readonly List<ViewPolygon> Polygons = new();
|
|
|
|
// Canonical (snapped) keys of the polygons in <see cref="Polygons"/>, backing the drift-tolerant
|
|
// dedup in <see cref="Add"/>. One entry per stored polygon; HashSet membership IS the dedup.
|
|
private readonly HashSet<string> _polygonKeys = new();
|
|
public float MinX { get; private set; } = float.MaxValue;
|
|
public float MinY { get; private set; } = float.MaxValue;
|
|
public float MaxX { get; private set; } = float.MinValue;
|
|
public float MaxY { get; private set; } = float.MinValue;
|
|
|
|
public bool IsEmpty => Polygons.Count == 0;
|
|
|
|
/// <summary>A region covering the entire NDC viewport — the camera cell's seed region
|
|
/// (mirrors retail PView::DrawInside copy_view(..., 4) at decomp:433814).</summary>
|
|
public static CellView FullScreen()
|
|
{
|
|
var v = new CellView();
|
|
v.Add(new ViewPolygon(new[]
|
|
{
|
|
new Vector2(-1f, -1f), new Vector2(1f, -1f), new Vector2(1f, 1f), new Vector2(-1f, 1f),
|
|
}));
|
|
return v;
|
|
}
|
|
|
|
public bool Add(ViewPolygon p)
|
|
{
|
|
if (p.IsEmpty) return false;
|
|
|
|
// Drift-tolerant, rotation-invariant dedup (2026-06-06 hang fix). PortalVisibilityBuilder.Build
|
|
// re-queues a cell every time its CellView GROWS, so the flood only terminates when Add
|
|
// recognises a re-clipped region as a duplicate. Across BFS rounds the SAME region returns
|
|
// float-drifted, vertex-rotated, and/or with a ±1 vertex count (homogeneous Sutherland-Hodgman +
|
|
// EnsureCcw); the old exact index-by-index match (eps 1e-4) caught none of those, so the region
|
|
// grew without bound -> O(n^2) CPU-spin hang in this method. We instead key each polygon by its
|
|
// vertices SNAPPED to a small NDC grid, consecutive snap-duplicates removed, rotated to a
|
|
// canonical start. The snapped key space is finite, so a monotonically-growing CellView is
|
|
// bounded and the flood is GUARANTEED to converge. The stored polygon keeps full precision (only
|
|
// the key is snapped), so downstream clip geometry is unchanged, and the grid (1e-3 NDC ~ sub-
|
|
// pixel) is far finer than the gap between genuinely distinct openings, so real regions never merge.
|
|
string? key = CanonicalKey(p.Vertices);
|
|
if (key is null) return false; // degenerate after snap (< 3 distinct vertices)
|
|
if (!_polygonKeys.Add(key)) return false; // duplicate region (drift / rotation / count tolerant)
|
|
|
|
Polygons.Add(p);
|
|
if (p.MinX < MinX) MinX = p.MinX;
|
|
if (p.MinY < MinY) MinY = p.MinY;
|
|
if (p.MaxX > MaxX) MaxX = p.MaxX;
|
|
if (p.MaxY > MaxY) MaxY = p.MaxY;
|
|
return true;
|
|
}
|
|
|
|
// NDC dedup grid. 1e-3 is ~0.5 px at 1080p — finer than the gap between distinct portal openings
|
|
// (so real regions stay distinct) yet far coarser than the per-round float drift of a re-clipped
|
|
// region (so a drifted duplicate snaps onto its predecessor). The finite grid is what bounds growth.
|
|
private const float DedupGridNdc = 1e-3f;
|
|
|
|
// Canonical key for a view polygon: vertices snapped to the NDC grid, consecutive snap-duplicates
|
|
// removed (including wrap-around), then rotated to start at the lexicographically smallest vertex so
|
|
// a rotated emission of the same cycle yields the same key. Returns null when fewer than 3 distinct
|
|
// snapped vertices survive (a degenerate sliver, not a real region). Winding is already CCW for every
|
|
// builder input (ClipToRegion / EnsureCcw), so the cyclic order is canonical without a reversal step.
|
|
private static string? CanonicalKey(Vector2[]? verts)
|
|
{
|
|
if (verts is null || verts.Length < 3) return null;
|
|
|
|
var pts = new List<(int X, int Y)>(verts.Length);
|
|
foreach (var v in verts)
|
|
{
|
|
var q = ((int)System.MathF.Round(v.X / DedupGridNdc), (int)System.MathF.Round(v.Y / DedupGridNdc));
|
|
if (pts.Count == 0 || pts[^1] != q) pts.Add(q);
|
|
}
|
|
if (pts.Count >= 2 && pts[^1] == pts[0]) pts.RemoveAt(pts.Count - 1);
|
|
if (pts.Count < 3) return null;
|
|
|
|
int n = pts.Count;
|
|
int best = 0;
|
|
for (int s = 1; s < n; s++)
|
|
if (RotationLess(pts, s, best, n)) best = s;
|
|
|
|
var sb = new StringBuilder(n * 10);
|
|
for (int i = 0; i < n; i++)
|
|
{
|
|
var q = pts[(best + i) % n];
|
|
sb.Append(q.X).Append(',').Append(q.Y).Append(';');
|
|
}
|
|
return sb.ToString();
|
|
}
|
|
|
|
// True when the rotation of `pts` starting at index a is lexicographically less than the rotation
|
|
// starting at b (compare X then Y, vertex by vertex around the cycle). Gives a unique canonical
|
|
// start even when two vertices share the minimum snapped coordinate.
|
|
private static bool RotationLess(List<(int X, int Y)> pts, int a, int b, int n)
|
|
{
|
|
for (int i = 0; i < n; i++)
|
|
{
|
|
var pa = pts[(a + i) % n];
|
|
var pb = pts[(b + i) % n];
|
|
if (pa.X != pb.X) return pa.X < pb.X;
|
|
if (pa.Y != pb.Y) return pa.Y < pb.Y;
|
|
}
|
|
return false;
|
|
}
|
|
}
|