acdream/tests/AcDream.App.Tests/Rendering/UnifiedFloodTests.cs
Erik 5379f6ecd3 feat(render): Phase 3 (Step A) — outdoor-root seeds full-screen OutsideView
Render unification cutover, Step A (additive, behavior-neutral until Step B). When PortalVisibilityBuilder.Build roots at the synthetic outdoor node, seed OutsideView with the full-screen NDC quad so ClipFrameAssembler yields a full-screen OutsideView slice and DrawInside's DrawLandscapeThroughOutsideView draws terrain/sky/scenery/weather as the node's shell — the same callback that already draws the doorway slice for an interior root looking out.

Keyed on a new explicit LoadedCell.IsOutdoorNode flag (set by OutdoorCellNode.Build), NOT a cell-id heuristic: production EnvCell ids are >= 0x100 but test fixtures use low interior ids, so an id test misfired on 4 existing PortalVisibilityBuilderTests.

Nothing roots at the node until Step B, so this is behavior-neutral. Tests: App 216/0 (2 new UnifiedFloodTests incl. the spec section 10 pure-outdoor regression guard + 2 OutdoorCellNode flag assertions).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 19:06:13 +02:00

109 lines
5.3 KiB
C#

using System;
using System.Numerics;
using AcDream.App.Rendering;
using Xunit;
namespace AcDream.App.Tests.Rendering;
public class UnifiedFloodTests
{
// Shared building fixture: a building cell whose interior is at Y >= 5.
// The exit portal faces -Y (Normal=(0,-1,0)); interior is where dot<=0 -> Y>=5 (InsideSide=1).
// The outdoor camera is at Y=-3, which is the OUTDOOR side (Y<5).
// OutdoorCellNode.Build flips InsideSide to 0 so the outdoor camera (dot>=0 i.e. Y<5) passes
// the side test and the flood reaches the building.
private static LoadedCell MakeBuildingCell(uint cellId)
{
var building = new LoadedCell { CellId = cellId, SeenOutside = true };
building.WorldTransform = Matrix4x4.Identity;
building.InverseWorldTransform = Matrix4x4.Identity;
building.Portals.Add(new CellPortalInfo(0xFFFF, 0, 0, 0));
// InsideSide=1: interior where (0,-1,0)·p+5 <= 0 -> Y>=5 (the building body is at Y>=5).
building.ClipPlanes.Add(new PortalClipPlane { Normal = new Vector3(0, -1, 0), D = 5f, InsideSide = 1 });
building.PortalPolygons.Add(new[]
{
new Vector3(-1, 5, 0), new Vector3(1, 5, 0), new Vector3(1, 5, 2), new Vector3(-1, 5, 2)
});
return building;
}
[Fact]
public void Build_RootedAtOutdoorNode_FloodsIntoBuilding()
{
var building = MakeBuildingCell(0xA9B40170);
var node = OutdoorCellNode.Build(0xA9B40031, new[] { building });
LoadedCell? Lookup(uint id) => (id & 0xFFFFu) == 0x0170 ? building : null;
var eye = new Vector3(0, -3, 1);
var view = Matrix4x4.CreateLookAt(eye, new Vector3(0, 5, 1), Vector3.UnitZ);
var proj = Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 3f, 16f / 9f, 1f, 5000f);
var frame = PortalVisibilityBuilder.Build(node, eye, Lookup, view * proj);
Assert.Contains(0xA9B40031u, frame.OrderedVisibleCells); // the outdoor node itself
Assert.Contains(0xA9B40170u, frame.OrderedVisibleCells); // flooded into the building
}
[Fact]
public void Build_OutdoorBuildingCycle_Terminates()
{
// Building's exit portal reciprocally points back near the node; assert Build
// returns (does not hang) and the visible set is bounded/small.
var building = MakeBuildingCell(0xA9B40170);
var node = OutdoorCellNode.Build(0xA9B40031, new[] { building });
LoadedCell? Lookup(uint id) => (id & 0xFFFFu) == 0x0170 ? building
: (id & 0xFFFFu) == 0x0031 ? node : null;
var eye = new Vector3(0, -3, 1);
var view = Matrix4x4.CreateLookAt(eye, new Vector3(0, 5, 1), Vector3.UnitZ);
var proj = Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 3f, 16f / 9f, 1f, 5000f);
var frame = PortalVisibilityBuilder.Build(node, eye, Lookup, view * proj);
Assert.True(frame.OrderedVisibleCells.Count < 10); // bounded, no runaway
}
// Step A (cutover): rooting at the outdoor node must seed OutsideView with the FULL-SCREEN NDC
// quad so DrawLandscapeThroughOutsideView draws the landscape as the node's shell. Without this
// the outdoor-root frame would have OutsideViewSlices.Length==0 -> TerrainMode.Skip -> no terrain.
[Fact]
public void Build_RootedAtOutdoorNode_SeedsFullScreenOutsideView()
{
var building = MakeBuildingCell(0xA9B40170);
var node = OutdoorCellNode.Build(0xA9B40031, new[] { building });
LoadedCell? Lookup(uint id) => (id & 0xFFFFu) == 0x0170 ? building : null;
var eye = new Vector3(0, -3, 1);
var view = Matrix4x4.CreateLookAt(eye, new Vector3(0, 5, 1), Vector3.UnitZ);
var proj = Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 3f, 16f / 9f, 1f, 5000f);
var frame = PortalVisibilityBuilder.Build(node, eye, Lookup, view * proj);
Assert.False(frame.OutsideView.IsEmpty);
// The union bounds of OutsideView must cover the whole NDC viewport (a full-screen quad),
// so ClipFrameAssembler yields TerrainMode != Skip and the terrain draws everywhere.
Assert.Equal(-1f, frame.OutsideView.MinX, 3);
Assert.Equal(-1f, frame.OutsideView.MinY, 3);
Assert.Equal(1f, frame.OutsideView.MaxX, 3);
Assert.Equal(1f, frame.OutsideView.MaxY, 3);
}
// Pure-outdoor regression guard (spec section 10): an outdoor node with NO nearby buildings must
// resolve to exactly {node} with a full-screen OutsideView -> full-screen terrain, no interior
// cells, byte-for-byte today's open-world draw. Visual-gate the open field, not just the cottage.
[Fact]
public void Build_EmptyOutdoorNode_FullScreenOutsideView_OnlyNodeVisible()
{
var node = OutdoorCellNode.Build(0xA9B40031, Array.Empty<LoadedCell>());
Assert.Empty(node.Portals); // no doorways
var eye = new Vector3(0, -3, 1);
var view = Matrix4x4.CreateLookAt(eye, new Vector3(0, 5, 1), Vector3.UnitZ);
var proj = Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 3f, 16f / 9f, 1f, 5000f);
var frame = PortalVisibilityBuilder.Build(node, eye, _ => null, view * proj);
Assert.Equal(new[] { 0xA9B40031u }, frame.OrderedVisibleCells); // only the node
Assert.False(frame.OutsideView.IsEmpty);
Assert.Equal(-1f, frame.OutsideView.MinX, 3);
Assert.Equal(1f, frame.OutsideView.MaxX, 3);
}
}