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
|
|
@ -5,36 +5,27 @@ using Xunit;
|
|||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// Phase U.4: GL-free proof that <see cref="ClipFrameAssembler"/> implements the
|
||||
/// slot/gate policy — slot 0 no-clip, one CellClip slot per visible cell with a
|
||||
/// convex region, OutsideView routed to the terrain decision + the outdoor mesh
|
||||
/// slot, and the three Count==0 dispositions (nothing-visible cull, scissor
|
||||
/// fallback → no-clip, planes). Hand-built <see cref="PortalVisibilityFrame"/>s
|
||||
/// drive the assembler directly (no portal BFS needed) so each disposition is
|
||||
/// exercised in isolation.
|
||||
/// </summary>
|
||||
public class ClipFrameAssemblerTests
|
||||
{
|
||||
// A convex NDC square → ClipPlaneSet.From yields 4 planes (Count > 0).
|
||||
private static ViewPolygon Square(float cx, float cy, float half) => new(new[]
|
||||
{
|
||||
new Vector2(cx - half, cy - half), new Vector2(cx + half, cy - half),
|
||||
new Vector2(cx + half, cy + half), new Vector2(cx - half, cy + half),
|
||||
new Vector2(cx - half, cy - half),
|
||||
new Vector2(cx + half, cy - half),
|
||||
new Vector2(cx + half, cy + half),
|
||||
new Vector2(cx - half, cy + half),
|
||||
});
|
||||
|
||||
private static CellView ViewOf(params ViewPolygon[] polys)
|
||||
{
|
||||
var v = new CellView();
|
||||
foreach (var p in polys) v.Add(p);
|
||||
return v;
|
||||
var view = new CellView();
|
||||
foreach (var p in polys)
|
||||
view.Add(p);
|
||||
return view;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TwoVisibleCells_PlusOutsideView_ProducesCorrectSlotMapAndCounts()
|
||||
{
|
||||
// Two cells with single convex regions (→ planes, mapped to slots 1 and 2)
|
||||
// and a single-convex OutsideView (→ planes, the outdoor slot 3).
|
||||
const uint cellA = 0xA9B40100;
|
||||
const uint cellB = 0xA9B40101;
|
||||
|
||||
|
|
@ -45,199 +36,154 @@ public class ClipFrameAssemblerTests
|
|||
pv.OrderedVisibleCells.Add(cellB);
|
||||
pv.OutsideView.Add(Square(0f, 0.5f, 0.25f));
|
||||
|
||||
var frame = ClipFrame.NoClip();
|
||||
var asm = ClipFrameAssembler.Assemble(frame, pv);
|
||||
var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv);
|
||||
|
||||
// slot 0 reserved (no-clip) + 2 cells + 1 outdoor = 4 slots.
|
||||
Assert.Equal(4, asm.Frame.SlotCount);
|
||||
|
||||
// Both cells mapped to NON-zero slots (real plane regions), distinct.
|
||||
Assert.True(asm.CellIdToSlot.ContainsKey(cellA));
|
||||
Assert.True(asm.CellIdToSlot.ContainsKey(cellB));
|
||||
Assert.Equal(4, asm.Frame.SlotCount); // slot 0 + two cells + one outside slice
|
||||
Assert.Contains(cellA, asm.CellIdToSlot.Keys);
|
||||
Assert.Contains(cellB, asm.CellIdToSlot.Keys);
|
||||
Assert.NotEqual(0, asm.CellIdToSlot[cellA]);
|
||||
Assert.NotEqual(0, asm.CellIdToSlot[cellB]);
|
||||
Assert.NotEqual(asm.CellIdToSlot[cellA], asm.CellIdToSlot[cellB]);
|
||||
|
||||
// Per-cell plane counts recorded (a convex square reduces to 4 planes).
|
||||
Assert.Equal(4, asm.PerCellPlaneCounts[cellA]);
|
||||
Assert.Equal(4, asm.PerCellPlaneCounts[cellB]);
|
||||
|
||||
// OutsideView: visible, convex → planes, a non-zero outdoor slot, terrain
|
||||
// gated via planes.
|
||||
Assert.True(asm.OutdoorVisible);
|
||||
Assert.NotEqual(0, asm.OutdoorSlot);
|
||||
Assert.Single(asm.OutsideViewSlices);
|
||||
Assert.Equal(asm.OutdoorSlot, asm.OutsideViewSlices[0].Slot);
|
||||
Assert.Equal(TerrainClipMode.Planes, asm.TerrainMode);
|
||||
Assert.Equal(4, asm.OutsidePlaneCount);
|
||||
Assert.Equal(0, asm.ScissorFallbacks);
|
||||
|
||||
// The outdoor slot differs from both cell slots and from slot 0.
|
||||
Assert.NotEqual(asm.CellIdToSlot[cellA], asm.OutdoorSlot);
|
||||
Assert.NotEqual(asm.CellIdToSlot[cellB], asm.OutdoorSlot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NothingVisibleCell_IsExcludedFromSlotMap_AndNotAppended()
|
||||
{
|
||||
// cellA is a real convex region; cellB's CellView is EMPTY → ClipPlaneSet
|
||||
// .IsNothingVisible → it must NOT be mapped and NOT consume a slot.
|
||||
const uint cellA = 0xA9B40100;
|
||||
const uint cellB = 0xA9B40101;
|
||||
|
||||
var pv = new PortalVisibilityFrame();
|
||||
pv.CellViews[cellA] = ViewOf(Square(0f, 0f, 0.3f));
|
||||
pv.CellViews[cellB] = new CellView(); // empty ⇒ nothing visible
|
||||
pv.CellViews[cellB] = new CellView();
|
||||
pv.OrderedVisibleCells.Add(cellA);
|
||||
pv.OrderedVisibleCells.Add(cellB);
|
||||
// OutsideView empty ⇒ terrain Skip (the bleed fix), outdoor not visible.
|
||||
|
||||
var frame = ClipFrame.NoClip();
|
||||
var asm = ClipFrameAssembler.Assemble(frame, pv);
|
||||
var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv);
|
||||
|
||||
// slot 0 + cellA only = 2 slots. cellB consumed none.
|
||||
Assert.Equal(2, asm.Frame.SlotCount);
|
||||
Assert.True(asm.CellIdToSlot.ContainsKey(cellA));
|
||||
Assert.False(asm.CellIdToSlot.ContainsKey(cellB)); // culled — not drawable
|
||||
|
||||
// Empty OutsideView ⇒ outdoor culled + terrain skipped (the bleed fix).
|
||||
Assert.Contains(cellA, asm.CellIdToSlot.Keys);
|
||||
Assert.DoesNotContain(cellB, asm.CellIdToSlot.Keys);
|
||||
Assert.False(asm.OutdoorVisible);
|
||||
Assert.Empty(asm.OutsideViewSlices);
|
||||
Assert.Equal(TerrainClipMode.Skip, asm.TerrainMode);
|
||||
Assert.Equal(0, asm.OutsidePlaneCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OutsideViewScissorFallback_MapsTerrainToScissor_AndCountsFallback()
|
||||
public void OutsideViewMultiPolygon_PreservesRetailSlices()
|
||||
{
|
||||
// A MULTI-polygon OutsideView is a union (non-convex) ⇒ ClipPlaneSet falls
|
||||
// back to the union AABB scissor: mesh outdoor slot 0 (no-clip over-include),
|
||||
// terrain → Scissor, one fallback counted.
|
||||
const uint cellA = 0xA9B40100;
|
||||
|
||||
var pv = new PortalVisibilityFrame();
|
||||
pv.CellViews[cellA] = ViewOf(Square(0f, 0f, 0.3f));
|
||||
pv.OrderedVisibleCells.Add(cellA);
|
||||
pv.OutsideView.Add(Square(-0.5f, 0f, 0.1f));
|
||||
pv.OutsideView.Add(Square(0.5f, 0f, 0.1f)); // 2 polys ⇒ scissor fallback
|
||||
pv.OutsideView.Add(Square(0.5f, 0f, 0.1f));
|
||||
|
||||
var frame = ClipFrame.NoClip();
|
||||
var asm = ClipFrameAssembler.Assemble(frame, pv);
|
||||
var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv);
|
||||
|
||||
Assert.True(asm.OutdoorVisible);
|
||||
Assert.Equal(0, asm.OutdoorSlot); // no-clip over-include
|
||||
Assert.Equal(TerrainClipMode.Scissor, asm.TerrainMode);
|
||||
Assert.Equal(0, asm.OutsidePlaneCount); // scissor ⇒ 0 planes
|
||||
Assert.Equal(1, asm.ScissorFallbacks);
|
||||
|
||||
// The terrain scissor AABB is a valid (min <= max) NDC box spanning both
|
||||
// OutsideView squares: minX <= -0.6, maxX >= 0.6.
|
||||
Assert.True(asm.TerrainScissorNdcAabb.X <= asm.TerrainScissorNdcAabb.Z);
|
||||
Assert.True(asm.TerrainScissorNdcAabb.Y <= asm.TerrainScissorNdcAabb.W);
|
||||
Assert.True(asm.TerrainScissorNdcAabb.X <= -0.59f);
|
||||
Assert.True(asm.TerrainScissorNdcAabb.Z >= 0.59f);
|
||||
Assert.NotEqual(0, asm.OutdoorSlot);
|
||||
Assert.Equal(2, asm.OutsideViewSlices.Length);
|
||||
Assert.NotEqual(asm.OutsideViewSlices[0].Slot, asm.OutsideViewSlices[1].Slot);
|
||||
Assert.Equal(TerrainClipMode.Planes, asm.TerrainMode);
|
||||
Assert.Equal(4, asm.OutsidePlaneCount);
|
||||
Assert.Equal(0, asm.ScissorFallbacks);
|
||||
Assert.Equal(4, asm.Frame.SlotCount); // slot 0 + cell + two outside slices
|
||||
Assert.Equal(Vector4.Zero, asm.TerrainScissorNdcAabb);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CellScissorFallback_MapsCellToSlotZero_AndCountsFallback()
|
||||
public void CellMultiPolygonView_PreservesRetailViewSlices()
|
||||
{
|
||||
// A cell with a MULTI-polygon region → scissor fallback → mapped to slot 0
|
||||
// (no-clip over-include), recorded with 0 planes, one fallback counted. The
|
||||
// OutsideView is a single convex region (planes) so only the CELL counts.
|
||||
const uint cellA = 0xA9B40100;
|
||||
|
||||
var pv = new PortalVisibilityFrame();
|
||||
pv.CellViews[cellA] = ViewOf(Square(-0.4f, 0f, 0.1f), Square(0.4f, 0f, 0.1f));
|
||||
pv.CellViews[cellA] = ViewOf(
|
||||
Square(-0.4f, 0f, 0.1f),
|
||||
Square(0.4f, 0f, 0.1f));
|
||||
pv.OrderedVisibleCells.Add(cellA);
|
||||
pv.OutsideView.Add(Square(0f, 0f, 0.3f));
|
||||
|
||||
var frame = ClipFrame.NoClip();
|
||||
var asm = ClipFrameAssembler.Assemble(frame, pv);
|
||||
var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv);
|
||||
|
||||
// cellA → slot 0 (no-clip). slot 0 + the OutsideView's planes slot = 2.
|
||||
Assert.Equal(0, asm.CellIdToSlot[cellA]);
|
||||
Assert.Equal(0, asm.PerCellPlaneCounts[cellA]);
|
||||
Assert.Equal(2, asm.Frame.SlotCount); // slot0 + OutsideView
|
||||
Assert.Equal(1, asm.ScissorFallbacks); // the cell fallback
|
||||
Assert.True(asm.CellIdToSlot[cellA] > 0);
|
||||
Assert.Equal(2, asm.CellIdToViewSlots[cellA].Length);
|
||||
Assert.Equal(2, asm.CellIdToViewSlices[cellA].Length);
|
||||
Assert.NotEqual(asm.CellIdToViewSlots[cellA][0], asm.CellIdToViewSlots[cellA][1]);
|
||||
Assert.Equal(4, asm.PerCellPlaneCounts[cellA]);
|
||||
Assert.Single(asm.OutsideViewSlices);
|
||||
Assert.Equal(4, asm.Frame.SlotCount); // slot 0 + two cell slices + outside slice
|
||||
Assert.Equal(0, asm.ScissorFallbacks);
|
||||
Assert.Equal(TerrainClipMode.Planes, asm.TerrainMode);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Phase W Stage 4: HasOutsideView + OutsideViewNdcAabb
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Assemble_OutsideViewWithExitPortal_HasOutsideViewTrue_AabbMatchesBounds()
|
||||
{
|
||||
// A single convex quad in OutsideView reduces to Planes. HasOutsideView must
|
||||
// be true and OutsideViewNdcAabb must match the polygon's own Min/Max values.
|
||||
var pv = new PortalVisibilityFrame();
|
||||
var poly = Square(-0.3f, 0.2f, 0.25f);
|
||||
pv.OutsideView.Add(poly);
|
||||
// No interior cells needed for this assertion.
|
||||
pv.OutsideView.Add(Square(-0.3f, 0.2f, 0.25f));
|
||||
|
||||
var frame = ClipFrame.NoClip();
|
||||
var asm = ClipFrameAssembler.Assemble(frame, pv);
|
||||
var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv);
|
||||
|
||||
Assert.True(asm.HasOutsideView);
|
||||
Assert.Equal(TerrainClipMode.Planes, asm.TerrainMode);
|
||||
Assert.Single(asm.OutsideViewSlices);
|
||||
|
||||
// The OutsideViewNdcAabb must equal the CellView's accumulated Min/Max.
|
||||
var expected = new System.Numerics.Vector4(
|
||||
var expected = new Vector4(
|
||||
pv.OutsideView.MinX, pv.OutsideView.MinY,
|
||||
pv.OutsideView.MaxX, pv.OutsideView.MaxY);
|
||||
Assert.Equal(expected, asm.OutsideViewNdcAabb);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Assemble_OutsideViewMultiPolygon_ScissorMode_HasOutsideViewTrue_AabbValid()
|
||||
public void Assemble_OutsideViewMultiPolygon_PreservesSlicesAndUnionAabb()
|
||||
{
|
||||
// Two polygons in OutsideView → union (non-convex) → ClipPlaneSet forces
|
||||
// scissor fallback. HasOutsideView must still be true, TerrainMode must be
|
||||
// Scissor, and OutsideViewNdcAabb must equal the union bounds (same values
|
||||
// as TerrainScissorNdcAabb in this mode).
|
||||
var pv = new PortalVisibilityFrame();
|
||||
pv.OutsideView.Add(Square(-0.6f, 0f, 0.15f));
|
||||
pv.OutsideView.Add(Square(0.6f, 0f, 0.15f));
|
||||
|
||||
var frame = ClipFrame.NoClip();
|
||||
var asm = ClipFrameAssembler.Assemble(frame, pv);
|
||||
var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv);
|
||||
|
||||
Assert.True(asm.HasOutsideView);
|
||||
Assert.Equal(TerrainClipMode.Scissor, asm.TerrainMode);
|
||||
Assert.Equal(TerrainClipMode.Planes, asm.TerrainMode);
|
||||
Assert.Equal(2, asm.OutsideViewSlices.Length);
|
||||
|
||||
// Union bounds from the CellView (spans both squares).
|
||||
var expectedAabb = new System.Numerics.Vector4(
|
||||
var expected = new Vector4(
|
||||
pv.OutsideView.MinX, pv.OutsideView.MinY,
|
||||
pv.OutsideView.MaxX, pv.OutsideView.MaxY);
|
||||
Assert.Equal(expectedAabb, asm.OutsideViewNdcAabb);
|
||||
|
||||
// In Scissor mode OutsideViewNdcAabb and TerrainScissorNdcAabb are the same
|
||||
// value (both are the union CellView bounds).
|
||||
Assert.Equal(asm.TerrainScissorNdcAabb, asm.OutsideViewNdcAabb);
|
||||
Assert.Equal(expected, asm.OutsideViewNdcAabb);
|
||||
Assert.Equal(Vector4.Zero, asm.TerrainScissorNdcAabb);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Assemble_EmptyOutsideView_HasOutsideViewFalse_AabbZero()
|
||||
{
|
||||
// An empty OutsideView means no exit portal was in view → TerrainMode.Skip,
|
||||
// HasOutsideView false, OutsideViewNdcAabb degenerate zero.
|
||||
const uint cellA = 0xA9B40100;
|
||||
var pv = new PortalVisibilityFrame();
|
||||
pv.CellViews[cellA] = ViewOf(Square(0f, 0f, 0.3f));
|
||||
pv.OrderedVisibleCells.Add(cellA);
|
||||
// OutsideView left empty (no exit portal).
|
||||
|
||||
var frame = ClipFrame.NoClip();
|
||||
var asm = ClipFrameAssembler.Assemble(frame, pv);
|
||||
var asm = ClipFrameAssembler.Assemble(ClipFrame.NoClip(), pv);
|
||||
|
||||
Assert.False(asm.HasOutsideView);
|
||||
Assert.Equal(TerrainClipMode.Skip, asm.TerrainMode);
|
||||
Assert.Equal(System.Numerics.Vector4.Zero, asm.OutsideViewNdcAabb);
|
||||
Assert.Equal(Vector4.Zero, asm.OutsideViewNdcAabb);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_ReusesFrame_NoSlotLeakAcrossAssemblies()
|
||||
{
|
||||
// First assembly packs 2 cells + outdoor (4 slots). Re-assembling the SAME
|
||||
// frame from a smaller pvFrame must Reset back to slot 0 — no leak.
|
||||
var frame = ClipFrame.NoClip();
|
||||
|
||||
var pv1 = new PortalVisibilityFrame();
|
||||
|
|
@ -249,17 +195,15 @@ public class ClipFrameAssemblerTests
|
|||
var asm1 = ClipFrameAssembler.Assemble(frame, pv1);
|
||||
Assert.Equal(4, asm1.Frame.SlotCount);
|
||||
|
||||
// Second assembly: a single cell, no OutsideView.
|
||||
var pv2 = new PortalVisibilityFrame();
|
||||
pv2.CellViews[0xA9B40200] = ViewOf(Square(0f, 0f, 0.25f));
|
||||
pv2.OrderedVisibleCells.Add(0xA9B40200);
|
||||
var asm2 = ClipFrameAssembler.Assemble(frame, pv2);
|
||||
|
||||
// slot 0 + 1 cell = 2 — the prior 4-slot state did not leak.
|
||||
Assert.Equal(2, asm2.Frame.SlotCount);
|
||||
Assert.True(asm2.CellIdToSlot.ContainsKey(0xA9B40200));
|
||||
Assert.False(asm2.CellIdToSlot.ContainsKey(0xA9B40100)); // gone after Reset
|
||||
Assert.False(asm2.OutdoorVisible); // no OutsideView this time
|
||||
Assert.Contains(0xA9B40200, asm2.CellIdToSlot.Keys);
|
||||
Assert.DoesNotContain(0xA9B40100, asm2.CellIdToSlot.Keys);
|
||||
Assert.False(asm2.OutdoorVisible);
|
||||
Assert.Equal(TerrainClipMode.Skip, asm2.TerrainMode);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue