acdream/tests/AcDream.App.Tests/Rendering/ClipFrameAssemblerTests.cs
Erik 7993e064a0 feat(render): Phase U.4 — unified gated draw pass (indoor root)
Wire the portal-visibility result through the clip pipeline: build a per-frame
ClipFrame (slot 0 no-clip, slot 1 OutsideView, slot 2..N per visible cell) +
cellIdToSlot from PortalVisibilityBuilder; call the (previously dormant)
EnvCellRenderer.Render for cell shells inside the clip bracket; assign per-instance
clip slots in WbDrawDispatcher (live-dynamic unclipped per retail, cell statics to
their cell slot, outdoor scenery to OutsideView, non-visible culled); gate/scissor/
skip terrain per OutsideView (empty ⇒ no terrain — the bleed fix). Emit ACDREAM_PROBE_VIS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 17:59:21 +02:00

191 lines
8.4 KiB
C#

using System.Collections.Generic;
using System.Numerics;
using AcDream.App.Rendering;
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),
});
private static CellView ViewOf(params ViewPolygon[] polys)
{
var v = new CellView();
foreach (var p in polys) v.Add(p);
return v;
}
[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;
var pv = new PortalVisibilityFrame();
pv.CellViews[cellA] = ViewOf(Square(-0.3f, 0f, 0.3f));
pv.CellViews[cellB] = ViewOf(Square(0.3f, 0f, 0.2f));
pv.OrderedVisibleCells.Add(cellA);
pv.OrderedVisibleCells.Add(cellB);
pv.OutsideView.Add(Square(0f, 0.5f, 0.25f));
var frame = ClipFrame.NoClip();
var asm = ClipFrameAssembler.Assemble(frame, 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.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.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.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);
// 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.False(asm.OutdoorVisible);
Assert.Equal(TerrainClipMode.Skip, asm.TerrainMode);
Assert.Equal(0, asm.OutsidePlaneCount);
}
[Fact]
public void OutsideViewScissorFallback_MapsTerrainToScissor_AndCountsFallback()
{
// 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
var frame = ClipFrame.NoClip();
var asm = ClipFrameAssembler.Assemble(frame, 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);
}
[Fact]
public void CellScissorFallback_MapsCellToSlotZero_AndCountsFallback()
{
// 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.OrderedVisibleCells.Add(cellA);
pv.OutsideView.Add(Square(0f, 0f, 0.3f));
var frame = ClipFrame.NoClip();
var asm = ClipFrameAssembler.Assemble(frame, 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.Equal(TerrainClipMode.Planes, asm.TerrainMode);
}
[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();
pv1.CellViews[0xA9B40100] = ViewOf(Square(-0.3f, 0f, 0.2f));
pv1.CellViews[0xA9B40101] = ViewOf(Square(0.3f, 0f, 0.2f));
pv1.OrderedVisibleCells.Add(0xA9B40100);
pv1.OrderedVisibleCells.Add(0xA9B40101);
pv1.OutsideView.Add(Square(0f, 0.4f, 0.2f));
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.Equal(TerrainClipMode.Skip, asm2.TerrainMode);
}
}