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
64
tests/AcDream.App.Tests/Rendering/CellViewDedupTests.cs
Normal file
64
tests/AcDream.App.Tests/Rendering/CellViewDedupTests.cs
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
// Regression tests for the indoor-render HANG (2026-06-06): the portal-visibility flood
|
||||
// re-queues a cell whenever its CellView grows, so it only terminates when CellView.Add's
|
||||
// dedup catches a duplicate. Across BFS rounds the same region comes back float-drifted,
|
||||
// vertex-rotated, or with a ±1 vertex count; the old exact index-by-index SamePolygon
|
||||
// (eps 1e-4) missed all three, so the region grew forever -> CPU-spin hang in CellView.Add.
|
||||
// A drift-tolerant, rotation-invariant dedup makes the key space finite, so the flood
|
||||
// converges (and these duplicates collapse).
|
||||
public class CellViewDedupTests
|
||||
{
|
||||
private static ViewPolygon Quad(float ox, float oy) => new(new[]
|
||||
{
|
||||
new Vector2(ox - 0.5f, oy - 0.5f), new Vector2(ox + 0.5f, oy - 0.5f),
|
||||
new Vector2(ox + 0.5f, oy + 0.5f), new Vector2(ox - 0.5f, oy + 0.5f),
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public void Add_DropsSubGridDriftDuplicate()
|
||||
{
|
||||
var v = new CellView();
|
||||
Assert.True(v.Add(Quad(0f, 0f)));
|
||||
// Same quad, every vertex nudged 3e-4 — beyond the old 1e-4 SamePolygon eps,
|
||||
// within the 1e-3 dedup grid. This is the per-round float drift that caused the hang.
|
||||
var drifted = new ViewPolygon(new[]
|
||||
{
|
||||
new Vector2(-0.5f + 3e-4f, -0.5f - 3e-4f), new Vector2(0.5f + 3e-4f, -0.5f - 3e-4f),
|
||||
new Vector2(0.5f + 3e-4f, 0.5f - 3e-4f), new Vector2(-0.5f + 3e-4f, 0.5f - 3e-4f),
|
||||
});
|
||||
Assert.False(v.Add(drifted));
|
||||
Assert.Single(v.Polygons);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Add_DropsRotatedStartDuplicate()
|
||||
{
|
||||
var v = new CellView();
|
||||
Assert.True(v.Add(Quad(0f, 0f)));
|
||||
// Same 4 corners (same CCW cycle), but emitted starting at the 2nd vertex — what
|
||||
// Sutherland-Hodgman can do when the subject order differs across rounds.
|
||||
var rotated = new ViewPolygon(new[]
|
||||
{
|
||||
new Vector2(0.5f, -0.5f), new Vector2(0.5f, 0.5f),
|
||||
new Vector2(-0.5f, 0.5f), new Vector2(-0.5f, -0.5f),
|
||||
});
|
||||
Assert.False(v.Add(rotated));
|
||||
Assert.Single(v.Polygons);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Add_KeepsGenuinelyDistinctPolygons()
|
||||
{
|
||||
// The fix must NOT over-merge: two regions 0.4 NDC apart (far beyond the 1e-3 grid)
|
||||
// remain distinct, so a real second portal opening is not silently dropped.
|
||||
var v = new CellView();
|
||||
Assert.True(v.Add(Quad(0f, 0f)));
|
||||
Assert.True(v.Add(Quad(0.4f, 0f)));
|
||||
Assert.Equal(2, v.Polygons.Count);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -252,4 +252,5 @@ public class ClipPlaneSetTests
|
|||
// need a real AABB; a zero-area line has none).
|
||||
Assert.True(cps.IsNothingVisible);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ public class InteriorEntityPartitionTests
|
|||
{
|
||||
private const uint CellA = 0xA9B40170;
|
||||
private const uint CellB = 0xA9B40171;
|
||||
private const uint HiddenCell = 0xA9B40199;
|
||||
private const uint OutdoorCell = 0xA9B40020;
|
||||
|
||||
private static WorldEntity Ent(uint id, uint serverGuid, uint? parentCell) => new()
|
||||
{
|
||||
|
|
@ -28,39 +30,44 @@ public class InteriorEntityPartitionTests
|
|||
(IReadOnlyDictionary<uint, WorldEntity>?)null) };
|
||||
|
||||
[Fact]
|
||||
public void Partitions_ByServerGuidThenParentCell_IntoThreeBuckets()
|
||||
public void Partitions_LiveAndStaticEntities_ByCellOutdoorAndFallback()
|
||||
{
|
||||
var livePlayer = Ent(1, serverGuid: 0x5000000A, parentCell: null); // live-dynamic
|
||||
var liveNpcInCell = Ent(2, serverGuid: 0x80001234, parentCell: CellA); // live-dynamic WINS over cell
|
||||
var staticA = Ent(3, serverGuid: 0, parentCell: CellA); // per-cell static
|
||||
var staticB = Ent(4, serverGuid: 0, parentCell: CellB); // per-cell static
|
||||
var scenery = Ent(5, serverGuid: 0, parentCell: null); // outdoor scenery
|
||||
var unresolvedLive = Ent(1, serverGuid: 0x5000000A, parentCell: null);
|
||||
var liveNpcInCell = Ent(2, serverGuid: 0x80001234, parentCell: CellA);
|
||||
var staticA = Ent(3, serverGuid: 0, parentCell: CellA);
|
||||
var staticB = Ent(4, serverGuid: 0, parentCell: CellB);
|
||||
var scenery = Ent(5, serverGuid: 0, parentCell: null);
|
||||
var liveOutdoor = Ent(6, serverGuid: 0x80005678, parentCell: OutdoorCell);
|
||||
|
||||
var visible = new HashSet<uint> { CellA, CellB };
|
||||
var result = InteriorEntityPartition.Partition(
|
||||
visible, OneLb(0xA9B4FFFF, livePlayer, liveNpcInCell, staticA, staticB, scenery));
|
||||
visible, OneLb(0xA9B4FFFF, unresolvedLive, liveNpcInCell, staticA, staticB, scenery, liveOutdoor));
|
||||
|
||||
Assert.Equal(2, result.LiveDynamic.Count); // player + npc (serverGuid != 0)
|
||||
Assert.Contains(livePlayer, result.LiveDynamic);
|
||||
Assert.Contains(liveNpcInCell, result.LiveDynamic);
|
||||
Assert.Single(result.LiveDynamic);
|
||||
Assert.Contains(unresolvedLive, result.LiveDynamic);
|
||||
|
||||
Assert.Single(result.ByCell[CellA]); // only staticA (npc went live-dynamic)
|
||||
Assert.Equal(2, result.ByCell[CellA].Count);
|
||||
Assert.Contains(liveNpcInCell, result.ByCell[CellA]);
|
||||
Assert.Contains(staticA, result.ByCell[CellA]);
|
||||
Assert.Single(result.ByCell[CellB]);
|
||||
Assert.Contains(staticB, result.ByCell[CellB]);
|
||||
|
||||
Assert.Single(result.Outdoor);
|
||||
Assert.Equal(2, result.Outdoor.Count);
|
||||
Assert.Contains(scenery, result.Outdoor);
|
||||
Assert.Contains(liveOutdoor, result.Outdoor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Static_InNonVisibleCell_IsDropped()
|
||||
public void IndoorEntity_InNonVisibleCell_IsDropped()
|
||||
{
|
||||
var staticHidden = Ent(3, serverGuid: 0, parentCell: 0xA9B40199); // not in the visible set
|
||||
var staticHidden = Ent(3, serverGuid: 0, parentCell: HiddenCell);
|
||||
var liveHidden = Ent(4, serverGuid: 0x80001234, parentCell: HiddenCell);
|
||||
var visible = new HashSet<uint> { CellA };
|
||||
var result = InteriorEntityPartition.Partition(visible, OneLb(0xA9B4FFFF, staticHidden));
|
||||
|
||||
Assert.False(result.ByCell.ContainsKey(0xA9B40199));
|
||||
var result = InteriorEntityPartition.Partition(
|
||||
visible, OneLb(0xA9B4FFFF, staticHidden, liveHidden));
|
||||
|
||||
Assert.False(result.ByCell.ContainsKey(HiddenCell));
|
||||
Assert.Empty(result.Outdoor);
|
||||
Assert.Empty(result.LiveDynamic);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -169,4 +169,175 @@ public class PortalProjectionTests
|
|||
Assert.True(onScreen.Length >= 3,
|
||||
"the cell behind a doorway you're standing in must stay visible (the void bug)");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Faithful homogeneous (w-space) portal clip — port of retail PView::GetClip +
|
||||
// PrimD3DRender::xformStart + ACRender::polyClipFinish (decomp 432344 / 424310 /
|
||||
// 702749). The early divide + fixed side-plane clamp (ProjectToNdc) collapsed
|
||||
// grazing/near portals to zero-area edge slivers (-> the flap) and near doorways
|
||||
// to empty (-> the void/fallback). The faithful pipeline keeps homogeneous clip
|
||||
// coords (ProjectToClip — eye-plane clip only, no divide) and runs Sutherland-
|
||||
// Hodgman against the view region with w-aware edge tests, dividing the survivors
|
||||
// only after they are bounded to the region (ClipToRegion). 2026-06-06.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private static Vector2[] FullScreenCcw() => new[]
|
||||
{
|
||||
new Vector2(-1f, -1f), new Vector2(1f, -1f), new Vector2(1f, 1f), new Vector2(-1f, 1f),
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void ProjectToClip_QuadInFront_KeepsVertsWithPositiveW()
|
||||
{
|
||||
var poly = new[]
|
||||
{
|
||||
new Vector3(-1, -1, -5), new Vector3(1, -1, -5), new Vector3(1, 1, -5), new Vector3(-1, 1, -5),
|
||||
};
|
||||
var clip = PortalProjection.ProjectToClip(poly, Matrix4x4.Identity, ViewProj());
|
||||
Assert.True(clip.Length >= 3);
|
||||
foreach (var v in clip)
|
||||
Assert.True(v.W > 0f, $"an in-front portal vertex must keep w>0 (homogeneous), got w={v.W}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProjectToClip_QuadFullyBehind_ReturnsEmpty()
|
||||
{
|
||||
var poly = new[]
|
||||
{
|
||||
new Vector3(-1, -1, 5), new Vector3(1, -1, 5), new Vector3(1, 1, 5), new Vector3(-1, 1, 5),
|
||||
};
|
||||
Assert.True(PortalProjection.ProjectToClip(poly, Matrix4x4.Identity, ViewProj()).Length < 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClipToRegion_OnScreenQuad_ReturnsBoundedNdc()
|
||||
{
|
||||
// A small 2x2 quad at z=-5 is fully on-screen; clipping against the full screen
|
||||
// returns it bounded to [-1,1].
|
||||
var poly = new[]
|
||||
{
|
||||
new Vector3(-1, -1, -5), new Vector3(1, -1, -5), new Vector3(1, 1, -5), new Vector3(-1, 1, -5),
|
||||
};
|
||||
var clip = PortalProjection.ProjectToClip(poly, Matrix4x4.Identity, ViewProj());
|
||||
var ndc = PortalProjection.ClipToRegion(clip, FullScreenCcw());
|
||||
Assert.True(ndc.Length >= 3);
|
||||
foreach (var v in ndc) { Assert.InRange(v.X, -1.001f, 1.001f); Assert.InRange(v.Y, -1.001f, 1.001f); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClipToRegion_FullyOffScreen_ReturnsEmpty_NotSliver()
|
||||
{
|
||||
// The flap's root: a portal entirely off one screen edge. The old early-divide +
|
||||
// side-plane clamp collapsed it to a zero-area sliver pinned to x=1.0 (proj=3) that
|
||||
// propagated a degenerate region one hop and then died; the faithful clip returns
|
||||
// EMPTY so the flood stops cleanly. Quad at z=-5, x in [3,5] -> ndc x ~[1.1,1.8], off right.
|
||||
var poly = new[]
|
||||
{
|
||||
new Vector3(3, -1, -5), new Vector3(5, -1, -5), new Vector3(5, 1, -5), new Vector3(3, 1, -5),
|
||||
};
|
||||
var clip = PortalProjection.ProjectToClip(poly, Matrix4x4.Identity, ViewProj());
|
||||
var ndc = PortalProjection.ClipToRegion(clip, FullScreenCcw());
|
||||
Assert.True(ndc.Length < 3, $"fully off-screen portal must clip to empty, got {ndc.Length} verts (sliver)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClipToRegion_PartlyOffScreen_ClipsToBoundedNonEmpty()
|
||||
{
|
||||
// A quad straddling the right edge -> clipped to the on-screen part, bounded, non-empty.
|
||||
var poly = new[]
|
||||
{
|
||||
new Vector3(0, -1, -5), new Vector3(4, -1, -5), new Vector3(4, 1, -5), new Vector3(0, 1, -5),
|
||||
};
|
||||
var clip = PortalProjection.ProjectToClip(poly, Matrix4x4.Identity, ViewProj());
|
||||
var ndc = PortalProjection.ClipToRegion(clip, FullScreenCcw());
|
||||
Assert.True(ndc.Length >= 3, "a partly-on-screen portal must produce a non-empty clipped region");
|
||||
foreach (var v in ndc) { Assert.InRange(v.X, -1.001f, 1.001f); Assert.InRange(v.Y, -1.001f, 1.001f); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClipToRegion_DoorwayEyeLooksThrough_CoversScreen_WithoutFallback()
|
||||
{
|
||||
// The void frame: chase eye 0.28 m from a 2x2 m doorway, looking through it. The doorway
|
||||
// subtends the whole screen. The faithful clip keeps the homogeneous verts (no early-divide
|
||||
// blow-up) and clips to the screen quad -> covers the screen (non-empty). This is what makes
|
||||
// the EyeInsidePortalOpening *fallback* unnecessary for an in-front doorway.
|
||||
var view = Matrix4x4.CreateLookAt(Vector3.Zero, new Vector3(0, 0, -1), Vector3.UnitY);
|
||||
var proj = Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 3f, 16f / 9f, 1.0f, 5000f);
|
||||
var viewProj = view * proj;
|
||||
var doorway = new[]
|
||||
{
|
||||
new Vector3(-1f, -1f, -0.28f), new Vector3(1f, -1f, -0.28f),
|
||||
new Vector3(1f, 1f, -0.28f), new Vector3(-1f, 1f, -0.28f),
|
||||
};
|
||||
var clip = PortalProjection.ProjectToClip(doorway, Matrix4x4.Identity, viewProj);
|
||||
var ndc = PortalProjection.ClipToRegion(clip, FullScreenCcw());
|
||||
Assert.True(ndc.Length >= 3, "a doorway the eye looks through must cover the screen, not collapse to the void");
|
||||
foreach (var v in ndc) { Assert.InRange(v.X, -1.001f, 1.001f); Assert.InRange(v.Y, -1.001f, 1.001f); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClipToRegion_StraddlingEye_OnScreenBounded_NoBlowup()
|
||||
{
|
||||
// A portal spanning from behind (z=+2) to in front (z=-5). The faithful clip keeps the
|
||||
// in-front part and bounds it to the screen — no perspective-inversion blow-up, non-empty.
|
||||
var poly = new[]
|
||||
{
|
||||
new Vector3(-1, -1, 2), new Vector3(1, -1, -5), new Vector3(1, 1, -5), new Vector3(-1, 1, 2),
|
||||
};
|
||||
var clip = PortalProjection.ProjectToClip(poly, Matrix4x4.Identity, ViewProj());
|
||||
var ndc = PortalProjection.ClipToRegion(clip, FullScreenCcw());
|
||||
Assert.True(ndc.Length >= 3);
|
||||
foreach (var v in ndc) { Assert.InRange(v.X, -1.001f, 1.001f); Assert.InRange(v.Y, -1.001f, 1.001f); }
|
||||
}
|
||||
|
||||
private static float AbsArea(Vector2[] p)
|
||||
{
|
||||
if (p == null || p.Length < 3) return 0f;
|
||||
float a2 = 0f;
|
||||
for (int i = 0; i < p.Length; i++) { var u = p[i]; var w = p[(i + 1) % p.Length]; a2 += u.X * w.Y - w.X * u.Y; }
|
||||
return MathF.Abs(a2) * 0.5f;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClipToRegion_SubjectFullyInsideRegion_ReturnsSubjectNotRegion()
|
||||
{
|
||||
// Regression for Build_AppliesReciprocalOtherPortalClip: a NARROW subject fully inside a WIDE
|
||||
// region must return the narrow (subject ∩ region = subject), NOT the wide region. The builder's
|
||||
// reciprocal clip is exactly this shape (reciprocal opening ∩ near-side region).
|
||||
var view = Matrix4x4.CreateLookAt(Vector3.Zero, new Vector3(0, 0, -1), Vector3.UnitY);
|
||||
var proj = Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1.0f, 0.1f, 1000f);
|
||||
var vp = view * proj;
|
||||
var narrow = new[] { new Vector3(-0.3f, -0.9f, -3f), new Vector3(0.3f, -0.9f, -3f), new Vector3(0.3f, 0.9f, -3f), new Vector3(-0.3f, 0.9f, -3f) };
|
||||
var wide = new[] { new Vector3(-0.9f, -0.9f, -3f), new Vector3(0.9f, -0.9f, -3f), new Vector3(0.9f, 0.9f, -3f), new Vector3(-0.9f, 0.9f, -3f) };
|
||||
|
||||
var narrowClip = PortalProjection.ProjectToClip(narrow, Matrix4x4.Identity, vp);
|
||||
// Build the region exactly as the builder does (clip wide against the full screen → CCW region).
|
||||
var wideRegion = PortalProjection.ClipToRegion(PortalProjection.ProjectToClip(wide, Matrix4x4.Identity, vp), FullScreenCcw());
|
||||
|
||||
var clipped = PortalProjection.ClipToRegion(narrowClip, wideRegion);
|
||||
float narrowArea = AbsArea(PortalProjection.ClipToRegion(narrowClip, FullScreenCcw()));
|
||||
float wideArea = AbsArea(wideRegion);
|
||||
float clippedArea = AbsArea(clipped);
|
||||
Assert.True(clippedArea <= narrowArea + 1e-3f,
|
||||
$"subject∩region must be the narrow subject (area {narrowArea}), not the wide region (area {wideArea}); got {clippedArea}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClipToRegion_AgainstSubRegion_TightensToIntersection()
|
||||
{
|
||||
// The region clip is the propagation step: clipping a wide on-screen portal against a
|
||||
// narrower view region must yield the intersection (the narrow region), not the wide portal.
|
||||
var wide = new[]
|
||||
{
|
||||
new Vector3(-2, -2, -5), new Vector3(2, -2, -5), new Vector3(2, 2, -5), new Vector3(-2, 2, -5),
|
||||
};
|
||||
var clip = PortalProjection.ProjectToClip(wide, Matrix4x4.Identity, ViewProj());
|
||||
var narrow = new[]
|
||||
{
|
||||
new Vector2(-0.3f, -0.3f), new Vector2(0.3f, -0.3f), new Vector2(0.3f, 0.3f), new Vector2(-0.3f, 0.3f),
|
||||
};
|
||||
var ndc = PortalProjection.ClipToRegion(clip, narrow);
|
||||
Assert.True(ndc.Length >= 3);
|
||||
foreach (var v in ndc) { Assert.InRange(v.X, -0.301f, 0.301f); Assert.InRange(v.Y, -0.301f, 0.301f); }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,6 +98,61 @@ public class PortalVisibilityBuilderTests
|
|||
"a degenerate portal the eye is NOT standing in must stay culled (no over-inclusion / #95 blowup)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_CollapsedInteriorPortalNearEyeBeyondHalfMeter_FloodsNeighbour()
|
||||
{
|
||||
// Live cellar capture (2026-06-06): 0174->0175 was traversable, but the portal projected to
|
||||
// zero vertices while the chase camera was about 1.4 m from the opening plane. The flood must
|
||||
// still reach the stair connector; otherwise the main-floor shell/floor disappears.
|
||||
var cam = Cell(0x0001, new CellPortalInfo(0x0002, 0, 0, 0));
|
||||
cam.PortalPolygons.Add(Quad(0f, 0f, 0.35f, 0.35f, 1.4f)); // behind eye: ProjectToNdc collapses
|
||||
var stairs = Cell(0x0002);
|
||||
var all = new Dictionary<uint, LoadedCell> { [0x0001] = cam, [0x0002] = stairs };
|
||||
var vp = ViewProj();
|
||||
|
||||
Assert.True(PortalProjection.ProjectToNdc(cam.PortalPolygons[0], Matrix4x4.Identity, vp).Length < 3);
|
||||
|
||||
var frame = PortalVisibilityBuilder.Build(
|
||||
cam, Vector3.Zero, id => all.TryGetValue(id, out var c) ? c : null, vp);
|
||||
|
||||
Assert.Contains(0x0002u, frame.OrderedVisibleCells);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_ViewGrowthAfterDoneCell_PropagatesNewSlicesToExit()
|
||||
{
|
||||
// Retail PView tracks update_count vs view_count. If B is processed through a LEFT slice, then
|
||||
// a later path reaches B through a RIGHT slice, B must propagate that new RIGHT slice to its
|
||||
// exit portal. Enqueue-once builders flap here: OutsideView stays empty until the camera moves
|
||||
// enough to discover B in the other order.
|
||||
const uint A = 0x0001, B = 0x0002, D = 0x0003;
|
||||
|
||||
var a = Cell(A,
|
||||
new CellPortalInfo((ushort)B, PolygonId: 0, Flags: 0, OtherPortalId: 0),
|
||||
new CellPortalInfo((ushort)D, PolygonId: 1, Flags: 0, OtherPortalId: 0));
|
||||
a.PortalPolygons.Add(QuadX(-0.9f, -0.3f, -2f)); // nearer LEFT path to B
|
||||
a.PortalPolygons.Add(QuadX(0.3f, 0.9f, -5f)); // farther RIGHT path to D
|
||||
|
||||
var b = Cell(B,
|
||||
new CellPortalInfo((ushort)A, PolygonId: 0, Flags: 0, OtherPortalId: 0),
|
||||
new CellPortalInfo(0xFFFF, PolygonId: 1, Flags: 0, OtherPortalId: 0),
|
||||
new CellPortalInfo((ushort)D, PolygonId: 2, Flags: 0, OtherPortalId: 0));
|
||||
b.PortalPolygons.Add(QuadX(-0.9f, -0.3f, -2f)); // reciprocal LEFT back to A
|
||||
b.PortalPolygons.Add(QuadX(0.3f, 0.9f, -3f)); // RIGHT exit, invisible from LEFT slice
|
||||
b.PortalPolygons.Add(QuadX(0.3f, 0.9f, -5f)); // reciprocal RIGHT back to D
|
||||
|
||||
var d = Cell(D, new CellPortalInfo((ushort)B, PolygonId: 0, Flags: 0, OtherPortalId: 2));
|
||||
d.PortalPolygons.Add(QuadX(0.3f, 0.9f, -5f)); // later RIGHT path into B
|
||||
|
||||
var all = new Dictionary<uint, LoadedCell> { [A] = a, [B] = b, [D] = d };
|
||||
|
||||
var frame = Build(a, all);
|
||||
|
||||
Assert.Contains(B, frame.OrderedVisibleCells);
|
||||
Assert.Contains(D, frame.OrderedVisibleCells);
|
||||
Assert.False(frame.OutsideView.IsEmpty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Builder_SealedCellar_NoExitPortal_OutsideViewEmpty()
|
||||
{
|
||||
|
|
@ -454,6 +509,117 @@ public class PortalVisibilityBuilderTests
|
|||
"No exit portal in any reachable cell must leave OutsideView empty");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildFromExterior_SeedsInteriorCellThroughOutsidePortal()
|
||||
{
|
||||
var room = Cell(0x0001, new CellPortalInfo(0xFFFF, 0, 0, 0));
|
||||
room.PortalPolygons.Add(Quad(0f, 0f, 0.5f, 0.5f, -4f));
|
||||
room.ClipPlanes.Add(new PortalClipPlane
|
||||
{
|
||||
Normal = new Vector3(0f, 0f, 1f),
|
||||
D = 3f,
|
||||
InsideSide = 1,
|
||||
});
|
||||
|
||||
var frame = PortalVisibilityBuilder.BuildFromExterior(
|
||||
new[] { room },
|
||||
Vector3.Zero,
|
||||
id => id == room.CellId ? room : null,
|
||||
ViewProj());
|
||||
|
||||
Assert.Contains(room.CellId, frame.OrderedVisibleCells);
|
||||
Assert.True(frame.CellViews.TryGetValue(room.CellId, out var view));
|
||||
Assert.False(view!.IsEmpty);
|
||||
Assert.True(view.MaxX - view.MinX < 1.0f,
|
||||
"exterior seed should be clipped to the door opening, not full-screen");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildFromExterior_DoesNotSeedWhenCameraIsOnInteriorSide()
|
||||
{
|
||||
var room = Cell(0x0001, new CellPortalInfo(0xFFFF, 0, 0, 0));
|
||||
room.PortalPolygons.Add(Quad(0f, 0f, 0.5f, 0.5f, -4f));
|
||||
room.ClipPlanes.Add(new PortalClipPlane
|
||||
{
|
||||
Normal = new Vector3(0f, 0f, 1f),
|
||||
D = -1f,
|
||||
InsideSide = 1,
|
||||
});
|
||||
|
||||
var frame = PortalVisibilityBuilder.BuildFromExterior(
|
||||
new[] { room },
|
||||
Vector3.Zero,
|
||||
id => id == room.CellId ? room : null,
|
||||
ViewProj());
|
||||
|
||||
Assert.Empty(frame.OrderedVisibleCells);
|
||||
Assert.Empty(frame.CellViews);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildFromExterior_TraversesDeeperInteriorPortals()
|
||||
{
|
||||
var entry = Cell(0x0001,
|
||||
new CellPortalInfo(0xFFFF, 0, 0, 0),
|
||||
new CellPortalInfo(0x0002, 1, 0, 0));
|
||||
entry.PortalPolygons.Add(Quad(0f, 0f, 0.6f, 0.6f, -3f));
|
||||
entry.PortalPolygons.Add(Quad(0f, 0f, 0.35f, 0.35f, -5f));
|
||||
entry.ClipPlanes.Add(new PortalClipPlane
|
||||
{
|
||||
Normal = new Vector3(0f, 0f, 1f),
|
||||
D = 2f,
|
||||
InsideSide = 1,
|
||||
});
|
||||
|
||||
var backRoom = Cell(0x0002);
|
||||
var all = new Dictionary<uint, LoadedCell>
|
||||
{
|
||||
[entry.CellId] = entry,
|
||||
[backRoom.CellId] = backRoom,
|
||||
};
|
||||
|
||||
var frame = PortalVisibilityBuilder.BuildFromExterior(
|
||||
new[] { entry },
|
||||
Vector3.Zero,
|
||||
id => all.TryGetValue(id, out var c) ? c : null,
|
||||
ViewProj());
|
||||
|
||||
Assert.Equal(new uint[] { 0x0001, 0x0002 }, frame.OrderedVisibleCells.ToArray());
|
||||
Assert.True(frame.CellViews.ContainsKey(0x0002));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildFromExterior_MaxSeedDistanceSkipsDistantExitPortal()
|
||||
{
|
||||
var nearby = Cell(0x0001, new CellPortalInfo(0xFFFF, 0, 0, 0));
|
||||
nearby.PortalPolygons.Add(Quad(0f, 0f, 0.5f, 0.5f, -4f));
|
||||
nearby.ClipPlanes.Add(new PortalClipPlane
|
||||
{
|
||||
Normal = new Vector3(0f, 0f, 1f),
|
||||
D = 3f,
|
||||
InsideSide = 1,
|
||||
});
|
||||
|
||||
var distant = Cell(0x0002, new CellPortalInfo(0xFFFF, 0, 0, 0));
|
||||
distant.PortalPolygons.Add(Quad(0f, 0f, 0.5f, 0.5f, -200f));
|
||||
distant.ClipPlanes.Add(new PortalClipPlane
|
||||
{
|
||||
Normal = new Vector3(0f, 0f, 1f),
|
||||
D = 199f,
|
||||
InsideSide = 1,
|
||||
});
|
||||
|
||||
var frame = PortalVisibilityBuilder.BuildFromExterior(
|
||||
new[] { nearby, distant },
|
||||
Vector3.Zero,
|
||||
id => id == nearby.CellId ? nearby : id == distant.CellId ? distant : null,
|
||||
ViewProj(),
|
||||
maxSeedDistance: 48f);
|
||||
|
||||
Assert.Contains(nearby.CellId, frame.OrderedVisibleCells);
|
||||
Assert.DoesNotContain(distant.CellId, frame.OrderedVisibleCells);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_RootCellAlwaysFirstInOrderedVisibleCells()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,20 +1,3 @@
|
|||
// Tests for WbDrawDispatcher's Phase U.4 per-instance clip-slot resolution
|
||||
// (ResolveEntitySlot / ResolveSlotForFrame). Code review of the U.4 commit
|
||||
// (7993e06) flagged this gate-critical routing as untested: if it breaks,
|
||||
// every indoor instance is sent to the wrong clip slot (or wrongly culled),
|
||||
// producing total visual garbage at the portal-visibility gate. The logic is
|
||||
// a pure function of (ServerGuid, ParentCellId, the clip-routing state), so we
|
||||
// extract it to internal static helpers and test the branches directly — no GL
|
||||
// context required.
|
||||
//
|
||||
// Branch map (ResolveSlotForFrame, the call-site policy):
|
||||
// routing inactive (outdoor root) → slot 0, NOT culled (≡ U.3)
|
||||
// ServerGuid != 0 (live dynamic) → slot 0, NOT culled (unclipped)
|
||||
// ParentCellId in cellIdToSlot → that cell's slot
|
||||
// ParentCellId NOT in cellIdToSlot → CULL
|
||||
// ParentCellId == null, outdoorVisible → outdoorSlot
|
||||
// ParentCellId == null, !outdoorVisible → CULL
|
||||
|
||||
using System.Collections.Generic;
|
||||
using AcDream.App.Rendering.Wb;
|
||||
using Xunit;
|
||||
|
|
@ -23,13 +6,10 @@ namespace AcDream.App.Tests.Rendering.Wb;
|
|||
|
||||
public sealed class WbDrawDispatcherClipSlotTests
|
||||
{
|
||||
// Full cell-id space keys (lbMask | OtherCellId). 0xA9B4 is the Holtburg
|
||||
// landblock prefix used throughout the indoor-walking work; the low word is
|
||||
// the EnvCell index. ParentCellId on a cell static is the SAME full id — see
|
||||
// the L.2e bare-low-byte finding (a 0x29 low-byte key would cull everything).
|
||||
private const uint VisibleCellA = 0xA9B4_0164u;
|
||||
private const uint VisibleCellB = 0xA9B4_0165u;
|
||||
private const uint NotVisibleCell = 0xA9B4_0999u;
|
||||
private const uint OutdoorCell = 0xA9B4_0020u;
|
||||
|
||||
private const int SlotA = 3;
|
||||
private const int SlotB = 7;
|
||||
|
|
@ -41,30 +21,44 @@ public sealed class WbDrawDispatcherClipSlotTests
|
|||
[VisibleCellB] = SlotB,
|
||||
};
|
||||
|
||||
// ── Raw resolver (ResolveEntitySlot): only reached when routing is active ──
|
||||
|
||||
[Fact]
|
||||
public void RawResolve_LiveEntity_IsUnclippedSlot0_WhenParentCellNull()
|
||||
public void RawResolve_LiveEntity_WithVisibleIndoorParent_GetsThatCellSlot()
|
||||
{
|
||||
// ServerGuid != 0 ⇒ unclipped (slot 0) regardless of cell state.
|
||||
int slot = WbDrawDispatcher.ResolveEntitySlot(
|
||||
serverGuid: 0x5000_000Au, parentCellId: null,
|
||||
cellIdToSlot: Routing(), outdoorSlot: OutsideViewSlot, outdoorVisible: true);
|
||||
|
||||
Assert.Equal(0, slot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RawResolve_LiveEntity_IsUnclippedSlot0_EvenWhenParentCellVisible()
|
||||
{
|
||||
// A live entity whose ParentCellId IS a visible cell still goes to slot 0,
|
||||
// NOT SlotA — the live-dynamic check must precede the cell lookup.
|
||||
int slot = WbDrawDispatcher.ResolveEntitySlot(
|
||||
serverGuid: 0x5000_000Au, parentCellId: VisibleCellA,
|
||||
cellIdToSlot: Routing(), outdoorSlot: OutsideViewSlot, outdoorVisible: true);
|
||||
|
||||
Assert.Equal(0, slot);
|
||||
Assert.NotEqual(SlotA, slot); // guards against ordering regression
|
||||
Assert.Equal(SlotA, slot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RawResolve_LiveEntity_WithHiddenIndoorParent_IsCulled()
|
||||
{
|
||||
int slot = WbDrawDispatcher.ResolveEntitySlot(
|
||||
serverGuid: 0x5000_000Au, parentCellId: NotVisibleCell,
|
||||
cellIdToSlot: Routing(), outdoorSlot: OutsideViewSlot, outdoorVisible: true);
|
||||
|
||||
Assert.Equal(WbDrawDispatcher.ClipSlotCull, slot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RawResolve_LiveEntity_WithOutdoorParent_UsesOutsideViewWhenVisible()
|
||||
{
|
||||
int slot = WbDrawDispatcher.ResolveEntitySlot(
|
||||
serverGuid: 0x5000_000Au, parentCellId: OutdoorCell,
|
||||
cellIdToSlot: Routing(), outdoorSlot: OutsideViewSlot, outdoorVisible: true);
|
||||
|
||||
Assert.Equal(OutsideViewSlot, slot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RawResolve_LiveEntity_WithParentNull_IsCulledWhenRoutingActive()
|
||||
{
|
||||
int slot = WbDrawDispatcher.ResolveEntitySlot(
|
||||
serverGuid: 0x5000_000Au, parentCellId: null,
|
||||
cellIdToSlot: Routing(), outdoorSlot: OutsideViewSlot, outdoorVisible: true);
|
||||
|
||||
Assert.Equal(WbDrawDispatcher.ClipSlotCull, slot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
@ -107,19 +101,9 @@ public sealed class WbDrawDispatcherClipSlotTests
|
|||
Assert.Equal(WbDrawDispatcher.ClipSlotCull, slot);
|
||||
}
|
||||
|
||||
// ── Call-site policy (ResolveSlotForFrame): adds the clipRoutingActive gate ──
|
||||
// Cases mirror the raw resolver but return the (slot, culled) pair the loop
|
||||
// body consumes, and add the routing-inactive (outdoor-root) branch.
|
||||
|
||||
[Fact]
|
||||
public void ForFrame_RoutingInactive_EveryEntityIsSlot0AndNotCulled()
|
||||
{
|
||||
// The bit-identical-to-U.3 property: when the camera is at an outdoor root
|
||||
// (ClearClipRouting), ResolveEntitySlot is never consulted — every entity
|
||||
// maps to slot 0 and nothing is clip-culled. Exercised here for BOTH a
|
||||
// live entity and a cell static that would otherwise cull, with a null
|
||||
// routing map to prove the resolver is bypassed entirely.
|
||||
|
||||
var live = WbDrawDispatcher.ResolveSlotForFrame(
|
||||
clipRoutingActive: false, serverGuid: 0x5000_000Au, parentCellId: null,
|
||||
cellIdToSlot: null, outdoorSlot: OutsideViewSlot, outdoorVisible: true);
|
||||
|
|
@ -134,16 +118,27 @@ public sealed class WbDrawDispatcherClipSlotTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void ForFrame_RoutingActive_LiveEntity_Slot0NotCulled()
|
||||
public void ForFrame_RoutingActive_LiveEntityVisible_GetsCellSlotNotCulled()
|
||||
{
|
||||
var r = WbDrawDispatcher.ResolveSlotForFrame(
|
||||
clipRoutingActive: true, serverGuid: 0x5000_000Au, parentCellId: VisibleCellA,
|
||||
cellIdToSlot: Routing(), outdoorSlot: OutsideViewSlot, outdoorVisible: true);
|
||||
|
||||
Assert.Equal(0u, r.Slot);
|
||||
Assert.Equal((uint)SlotA, r.Slot);
|
||||
Assert.False(r.Culled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForFrame_RoutingActive_LiveEntityParentNull_Culled()
|
||||
{
|
||||
var r = WbDrawDispatcher.ResolveSlotForFrame(
|
||||
clipRoutingActive: true, serverGuid: 0x5000_000Au, parentCellId: null,
|
||||
cellIdToSlot: Routing(), outdoorSlot: OutsideViewSlot, outdoorVisible: true);
|
||||
|
||||
Assert.True(r.Culled);
|
||||
Assert.Equal(0u, r.Slot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ForFrame_RoutingActive_CellStaticVisible_GetsCellSlotNotCulled()
|
||||
{
|
||||
|
|
@ -163,7 +158,6 @@ public sealed class WbDrawDispatcherClipSlotTests
|
|||
cellIdToSlot: Routing(), outdoorSlot: OutsideViewSlot, outdoorVisible: true);
|
||||
|
||||
Assert.True(r.Culled);
|
||||
// When culled the loop body forces slot 0 (the value is never emitted).
|
||||
Assert.Equal(0u, r.Slot);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -96,40 +96,40 @@ public sealed class RenderingDiagnosticsTests
|
|||
// PLAYER's cell, 0x451e80), NOT the camera cell. acdream previously branched on the
|
||||
// camera cell, so a chase camera lagging in a doorway while the player was already
|
||||
// outside took the DrawInside path and degenerated to a grey world + entities showing
|
||||
// through walls. These pin the player-keyed branch (DrawInside root stays the viewer cell).
|
||||
// through walls. These pin the player-keyed branch and loaded player-root requirement.
|
||||
|
||||
[Fact]
|
||||
public void ShouldRenderIndoor_PlayerOutside_CameraInside_ReturnsFalse()
|
||||
{
|
||||
// THE doorway-grey regression: the player stepped onto a landcell (0x...0031) but the
|
||||
// chase camera still resolves an interior EnvCell. Branch on the PLAYER → outdoor.
|
||||
Assert.False(RenderingDiagnostics.ShouldRenderIndoor(playerCellId: 0xA9B40031u, viewerCellResolved: true));
|
||||
Assert.False(RenderingDiagnostics.ShouldRenderIndoor(playerCellId: 0xA9B40031u, renderRootResolved: true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShouldRenderIndoor_PlayerInside_CameraInside_ReturnsTrue()
|
||||
{
|
||||
Assert.True(RenderingDiagnostics.ShouldRenderIndoor(playerCellId: 0xA9B40171u, viewerCellResolved: true));
|
||||
Assert.True(RenderingDiagnostics.ShouldRenderIndoor(playerCellId: 0xA9B40171u, renderRootResolved: true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShouldRenderIndoor_PlayerInside_NoViewerCell_ReturnsFalse()
|
||||
public void ShouldRenderIndoor_PlayerInside_RootNotLoaded_ReturnsFalse()
|
||||
{
|
||||
// Opposite lag (camera pulled outside while the player is inside): no viewer cell to
|
||||
// root DrawInside at → outdoor. Defensive; matches prior null-CameraCell behavior.
|
||||
Assert.False(RenderingDiagnostics.ShouldRenderIndoor(playerCellId: 0xA9B40171u, viewerCellResolved: false));
|
||||
Assert.False(RenderingDiagnostics.ShouldRenderIndoor(playerCellId: 0xA9B40171u, renderRootResolved: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShouldRenderIndoor_PlayerOutside_CameraOutside_ReturnsFalse()
|
||||
{
|
||||
Assert.False(RenderingDiagnostics.ShouldRenderIndoor(playerCellId: 0xA9B40031u, viewerCellResolved: false));
|
||||
Assert.False(RenderingDiagnostics.ShouldRenderIndoor(playerCellId: 0xA9B40031u, renderRootResolved: false));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShouldRenderIndoor_UnknownPlayerCell_TreatedAsOutside_ReturnsFalse()
|
||||
{
|
||||
// playerCellId == 0 (unresolved) → treat as outside (safe default: outdoor render).
|
||||
Assert.False(RenderingDiagnostics.ShouldRenderIndoor(playerCellId: 0u, viewerCellResolved: true));
|
||||
Assert.False(RenderingDiagnostics.ShouldRenderIndoor(playerCellId: 0u, renderRootResolved: true));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue