test(render): Phase W Stage 4/5 — assembler OutsideView AABB + PView BFS + entity-clip tests
ClipFrameAssemblerTests (3 new): - Assemble_OutsideViewWithExitPortal_HasOutsideViewTrue_AabbMatchesBounds - Assemble_OutsideViewMultiPolygon_ScissorMode_HasOutsideViewTrue_AabbValid - Assemble_EmptyOutsideView_HasOutsideViewFalse_AabbZero PortalVisibilityBuilderTests (3 new): - Build_ExitPortalVisible_OutsideViewNonEmpty - Build_NoExitPortal_OutsideViewEmpty - Build_RootCellAlwaysFirstInOrderedVisibleCells EntityClipTests (new file, 5 tests): - EntityClip_ParentInVisibleSet_Included - EntityClip_ParentNotInVisibleSet_Excluded - EntityClip_NullVisibleSet_IncludesAll - EntityClip_NullParentCell_NullVisibleSet_Included - EntityClip_NullParentCell_NonNullVisibleSet_Included WbDrawDispatcher.EntityPassesVisibleCellGate changed private → internal static (AcDream.App already has InternalsVisibleTo AcDream.App.Tests; no new seam needed). 160 → 171 tests, all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ce2edad66a
commit
a8b831c23b
4 changed files with 231 additions and 1 deletions
|
|
@ -1736,7 +1736,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
/// </summary>
|
||||
private static bool EntityMatchesSet(WorldEntity entity, EntitySet set) => true;
|
||||
|
||||
private static bool EntityPassesVisibleCellGate(
|
||||
internal static bool EntityPassesVisibleCellGate(
|
||||
WorldEntity entity,
|
||||
HashSet<uint>? visibleCellIds,
|
||||
EntitySet set)
|
||||
|
|
|
|||
|
|
@ -159,6 +159,80 @@ public class ClipFrameAssemblerTests
|
|||
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.
|
||||
|
||||
var frame = ClipFrame.NoClip();
|
||||
var asm = ClipFrameAssembler.Assemble(frame, pv);
|
||||
|
||||
Assert.True(asm.HasOutsideView);
|
||||
Assert.Equal(TerrainClipMode.Planes, asm.TerrainMode);
|
||||
|
||||
// The OutsideViewNdcAabb must equal the CellView's accumulated Min/Max.
|
||||
var expected = new System.Numerics.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()
|
||||
{
|
||||
// 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);
|
||||
|
||||
Assert.True(asm.HasOutsideView);
|
||||
Assert.Equal(TerrainClipMode.Scissor, asm.TerrainMode);
|
||||
|
||||
// Union bounds from the CellView (spans both squares).
|
||||
var expectedAabb = new System.Numerics.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);
|
||||
}
|
||||
|
||||
[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);
|
||||
|
||||
Assert.False(asm.HasOutsideView);
|
||||
Assert.Equal(TerrainClipMode.Skip, asm.TerrainMode);
|
||||
Assert.Equal(System.Numerics.Vector4.Zero, asm.OutsideViewNdcAabb);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reset_ReusesFrame_NoSlotLeakAcrossAssemblies()
|
||||
{
|
||||
|
|
|
|||
101
tests/AcDream.App.Tests/Rendering/EntityClipTests.cs
Normal file
101
tests/AcDream.App.Tests/Rendering/EntityClipTests.cs
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
// EntityClipTests.cs
|
||||
//
|
||||
// Phase W Stage 4/5: unit-test WbDrawDispatcher.EntityPassesVisibleCellGate.
|
||||
// The gate is internal static (AcDream.App is InternalsVisibleTo AcDream.App.Tests)
|
||||
// and pure — tests it without a GL context. Covers: ParentCellId in the visible
|
||||
// set → included; ParentCellId NOT in the set → excluded; null visibleCellIds →
|
||||
// everything included (outdoor / unconstrained root).
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering.Wb;
|
||||
using AcDream.Core.World;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
public sealed class EntityClipTests
|
||||
{
|
||||
// Minimal WorldEntity factory. EntityPassesVisibleCellGate only reads
|
||||
// ParentCellId and IsBuildingShell/BuildingShellAnchorCellId from the entity;
|
||||
// the other required fields are set to safe sentinel values.
|
||||
private static WorldEntity Entity(uint? parentCellId, bool isShell = false, uint? shellAnchor = null) =>
|
||||
new WorldEntity
|
||||
{
|
||||
Id = 1u,
|
||||
SourceGfxObjOrSetupId = 0u,
|
||||
Position = Vector3.Zero,
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = System.Array.Empty<MeshRef>(),
|
||||
ParentCellId = parentCellId,
|
||||
IsBuildingShell = isShell,
|
||||
BuildingShellAnchorCellId = shellAnchor,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void EntityClip_ParentInVisibleSet_Included()
|
||||
{
|
||||
// Entity whose ParentCellId is in the visible set must pass the gate.
|
||||
const uint cellId = 0xA9B40170u;
|
||||
var visibleCellIds = new HashSet<uint> { cellId };
|
||||
var entity = Entity(parentCellId: cellId);
|
||||
|
||||
bool result = WbDrawDispatcher.EntityPassesVisibleCellGate(
|
||||
entity, visibleCellIds, WbDrawDispatcher.EntitySet.All);
|
||||
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EntityClip_ParentNotInVisibleSet_Excluded()
|
||||
{
|
||||
// Entity whose ParentCellId is NOT in the visible set must fail the gate.
|
||||
const uint visibleCell = 0xA9B40170u;
|
||||
const uint entityCell = 0xA9B40172u;
|
||||
var visibleCellIds = new HashSet<uint> { visibleCell };
|
||||
var entity = Entity(parentCellId: entityCell);
|
||||
|
||||
bool result = WbDrawDispatcher.EntityPassesVisibleCellGate(
|
||||
entity, visibleCellIds, WbDrawDispatcher.EntitySet.All);
|
||||
|
||||
Assert.False(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EntityClip_NullVisibleSet_IncludesAll()
|
||||
{
|
||||
// Null visibleCellIds means the outdoor root — no culling, all entities pass.
|
||||
var entity = Entity(parentCellId: 0xA9B40172u);
|
||||
|
||||
bool result = WbDrawDispatcher.EntityPassesVisibleCellGate(
|
||||
entity, visibleCellIds: null, WbDrawDispatcher.EntitySet.All);
|
||||
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EntityClip_NullParentCell_NullVisibleSet_Included()
|
||||
{
|
||||
// An outdoor entity (ParentCellId == null) with null visibleCellIds passes.
|
||||
var entity = Entity(parentCellId: null);
|
||||
|
||||
bool result = WbDrawDispatcher.EntityPassesVisibleCellGate(
|
||||
entity, visibleCellIds: null, WbDrawDispatcher.EntitySet.All);
|
||||
|
||||
Assert.True(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EntityClip_NullParentCell_NonNullVisibleSet_Included()
|
||||
{
|
||||
// An outdoor entity (ParentCellId == null) with a non-null visibleCellIds
|
||||
// falls through to the final return-true (not a shell, not shell-scoped);
|
||||
// outdoor scenery is not gated by the indoor cell filter.
|
||||
var visibleCellIds = new HashSet<uint> { 0xA9B40170u };
|
||||
var entity = Entity(parentCellId: null);
|
||||
|
||||
bool result = WbDrawDispatcher.EntityPassesVisibleCellGate(
|
||||
entity, visibleCellIds, WbDrawDispatcher.EntitySet.All);
|
||||
|
||||
Assert.True(result);
|
||||
}
|
||||
}
|
||||
|
|
@ -378,6 +378,61 @@ public class PortalVisibilityBuilderTests
|
|||
// see docs/research/2026-05-31-u4c-flap-characterization.md.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Phase W Stage 4/5: BFS exit-portal + root-cell ordering guarantees
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Build_ExitPortalVisible_OutsideViewNonEmpty()
|
||||
{
|
||||
// A cell with a single exit portal (OtherCellId == 0xFFFF) whose polygon
|
||||
// projects in front of the camera. The BFS must union the clipped region
|
||||
// into OutsideView, making it non-empty.
|
||||
var cam = Cell(0x0001, new CellPortalInfo(0xFFFF, 0, 0, 0));
|
||||
cam.PortalPolygons.Add(Quad(0f, 0f, 0.5f, 0.5f, -4f)); // in front, in frustum
|
||||
var all = new Dictionary<uint, LoadedCell> { [0x0001] = cam };
|
||||
|
||||
var frame = Build(cam, all);
|
||||
|
||||
Assert.True(frame.OutsideView.Polygons.Count > 0,
|
||||
"An exit portal (OtherCellId=0xFFFF) in front of the camera must populate OutsideView");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_NoExitPortal_OutsideViewEmpty()
|
||||
{
|
||||
// A cell whose only portal leads to another interior cell (OtherCellId != 0xFFFF).
|
||||
// With no exit portal in any reachable cell the OutsideView must remain empty.
|
||||
var cam = Cell(0x0001, new CellPortalInfo(0x0002, 0, 0, 0));
|
||||
cam.PortalPolygons.Add(Quad(0f, 0f, 0.5f, 0.5f, -3f));
|
||||
var inner = Cell(0x0002); // no portals — no exit
|
||||
var all = new Dictionary<uint, LoadedCell> { [0x0001] = cam, [0x0002] = inner };
|
||||
|
||||
var frame = Build(cam, all);
|
||||
|
||||
Assert.True(frame.OutsideView.Polygons.Count == 0,
|
||||
"No exit portal in any reachable cell must leave OutsideView empty");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Build_RootCellAlwaysFirstInOrderedVisibleCells()
|
||||
{
|
||||
// The camera cell seeds the BFS at distance 0 so it must always pop
|
||||
// first → OrderedVisibleCells[0] == cameraCell.CellId.
|
||||
// Use the three-cell chain: A→B→C with exit window on C.
|
||||
var (cells, lookup) = SyntheticChain();
|
||||
var f = PortalVisibilityBuilder.Build(
|
||||
cells[0], Vector3.Zero, id => lookup.TryGetValue(id, out var c) ? c : null, ViewProj());
|
||||
|
||||
Assert.NotEmpty(f.OrderedVisibleCells);
|
||||
Assert.Equal(cells[0].CellId, f.OrderedVisibleCells[0]);
|
||||
}
|
||||
|
||||
// Note: Build_CyclicPortalGraph_Terminates is already covered by
|
||||
// Builder_CyclicGraph_TerminatesWithBoundedPolys and Build_CyclicHub_TerminatesAndBounds.
|
||||
// The dedup invariant (each cell appears at most once) is validated by
|
||||
// Build_CyclicHub_TerminatesAndBounds via OrderedVisibleCells.Distinct().Count().
|
||||
|
||||
// Signed-area-magnitude (shoelace) sum over a CellView's polygons in NDC.
|
||||
private static float CellViewArea(CellView view)
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue