feat(render): R-A2 — per-building floods (the flap fix)

Replace the outdoor root's single unified reverse-portal flood (whose root-level
portal-side test oscillated as the chase eye grazed a doorway — the measured
flood 2<->6) with retail's per-building floods.

- OutdoorCellNode.Build(uint): portal-less land root; floods only itself ->
  full-screen OutsideView -> terrain (PortalVisibilityBuilder IsOutdoorNode seed).
- PortalVisibilityBuilder.ConstructViewBuilding: per-building flood seeded at a
  building's own finite entrance (retail ConstructView(CBldPortal) 0x5a59a0 via
  DrawPortal 0x5a5ab0 / portal_draw_portals_only 0x53d870). Entrance-bounded ->
  consistent ~2-cell depth (measured retail cell_draw_num, handoff OPTION-A 3.4).
- RetailPViewRenderer.DrawInside: when the root is the outdoor node, group nearby
  cells by BuildingId and merge each per-building flood into the frame before
  assembly; existing shells/object-list draw path unchanged. 48 m seed cutoff.
- GameWindow: pass flat NearbyBuildingCells only on outdoor-node frames.

Tests: +3 PortalVisibilityRobustnessTests (per-building touches ~2 cells, membership
stable under the measured 36 um eye jitter). UnifiedFloodTests retired (its subject,
the unified flood from the outdoor node, is removed); surviving full-screen-OutsideView
coverage moved to OutdoorCellNodeTests. App Rendering 207/207, Core movement 14/14.

Conformance-verified sound; the grazing-doorway flap is the visual acceptance test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-08 18:44:43 +02:00
parent 7fe98098f5
commit c62663d7cb
8 changed files with 251 additions and 198 deletions

View file

@ -1,54 +1,52 @@
using System;
using System.Numerics;
using AcDream.App.Rendering;
using Xunit;
namespace AcDream.App.Tests.Rendering;
/// <summary>
/// The OUTDOOR render root (R-A2, 2026-06-08): a portal-less <see cref="LoadedCell"/> whose only job is
/// to root the render outdoors so the flood seeds OutsideView FULL-SCREEN (terrain draws). Buildings are
/// flooded SEPARATELY per-building (<see cref="PortalVisibilityBuilder.ConstructViewBuilding"/>), so the
/// node carries NO reverse portals — the pre-R-A2 reverse-portal unified flood (which oscillated at the
/// doorway) is gone. Its tests moved here; the old <c>UnifiedFloodTests</c> was retired.
/// </summary>
public class OutdoorCellNodeTests
{
// A building cell at world-translate (10,0,0) with one exit portal (OtherCellId=0xFFFF)
// whose local plane faces +X (InsideSide=0). The outdoor node must expose ONE portal
// back into that building cell, with the entrance polygon moved to world space and the
// inside-side flipped (so the outdoor half-space is "inside" the node).
private static LoadedCell BuildingWithOneExit(uint cellId)
{
var cell = new LoadedCell { CellId = cellId };
cell.WorldTransform = Matrix4x4.CreateTranslation(10f, 0f, 0f);
cell.InverseWorldTransform = Matrix4x4.CreateTranslation(-10f, 0f, 0f);
cell.Portals.Add(new CellPortalInfo(OtherCellId: 0xFFFF, PolygonId: 0, Flags: 0, OtherPortalId: 0));
cell.ClipPlanes.Add(new PortalClipPlane { Normal = new Vector3(1, 0, 0), D = 0f, InsideSide = 0 });
cell.PortalPolygons.Add(new[]
{
new Vector3(0, -1, 0), new Vector3(0, 1, 0), new Vector3(0, 1, 2), new Vector3(0, -1, 2)
});
return cell;
}
[Fact]
public void Build_FromBuildingExit_AddsReversePortalIntoBuilding()
public void Build_ReturnsPortallessOutdoorRoot()
{
uint outdoorId = 0xA9B40031;
var building = BuildingWithOneExit(0xA9B40170);
var node = OutdoorCellNode.Build(outdoorId, new[] { building });
var node = OutdoorCellNode.Build(outdoorId);
Assert.Equal(outdoorId, node.CellId);
Assert.True(node.IsOutdoorNode); // the flag PortalVisibilityBuilder keys the full-screen OutsideView on
Assert.True(node.SeenOutside);
Assert.True(node.IsOutdoorNode); // the flag PortalVisibilityBuilder keys the full-screen OutsideView on
Assert.Equal(Matrix4x4.Identity, node.WorldTransform);
Assert.Single(node.Portals);
Assert.Equal((ushort)(0xA9B40170 & 0xFFFF), node.Portals[0].OtherCellId);
// Reversed inside-side: the building's exit was InsideSide=0, the node's is 1.
Assert.Equal(1, node.ClipPlanes[0].InsideSide);
// Entrance polygon moved to world space (building translated +10 X): first vert x≈10.
Assert.Equal(10f, node.PortalPolygons[0][0].X, 3);
Assert.Empty(node.Portals); // R-A2: no reverse portals into buildings (buildings flood per-building)
}
[Fact]
public void Build_NoBuildings_ReturnsEmptyPortalNode()
public void Build_OutdoorRoot_SeedsFullScreenOutsideView_OnlyNodeVisible()
{
var node = OutdoorCellNode.Build(0xA9B40031, System.Array.Empty<LoadedCell>());
Assert.Empty(node.Portals);
Assert.True(node.SeenOutside);
Assert.True(node.IsOutdoorNode);
// The load-bearing outdoor-terrain behavior: rooting at the IsOutdoorNode node seeds OutsideView
// with the FULL-SCREEN NDC quad, so ClipFrameAssembler yields TerrainMode != Skip and
// DrawLandscapeThroughOutsideView draws terrain/sky/scenery everywhere. The portal-less node
// resolves to exactly {node} — no interior cells flooded from the root (those come per-building).
var node = OutdoorCellNode.Build(0xA9B40031);
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.MinY, 3);
Assert.Equal(1f, frame.OutsideView.MaxX, 3);
Assert.Equal(1f, frame.OutsideView.MaxY, 3);
}
}

View file

@ -0,0 +1,96 @@
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.Rendering;
using Xunit;
namespace AcDream.App.Tests.Rendering;
/// <summary>
/// R-A2 conformance: the per-building flood (<see cref="PortalVisibilityBuilder.ConstructViewBuilding"/>)
/// is the robustness mechanism for the doorway flap. These are SOUNDNESS gates against the measured
/// retail values (handoff 2026-06-08 OPTION-A §3.4): a per-building flood touches ≈2 cells and its
/// membership is stable under the eye's ~36 µm rest jitter. They prove the per-building path is built
/// correctly and does not regress; the live doorway is the visual acceptance test for "the flap is
/// gone" (a synthetic fixture cannot reproduce the cottage's exact knife-edge geometry).
/// </summary>
public class PortalVisibilityRobustnessTests
{
private static Matrix4x4 ViewProj()
{
var view = Matrix4x4.CreateLookAt(Vector3.Zero, new Vector3(0, 0, -1), Vector3.UnitY);
var proj = Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1.0f, 0.1f, 1000f);
return view * proj;
}
private static Vector3[] Quad(float cx, float cy, float halfW, float halfH, float z) => new[]
{
new Vector3(cx - halfW, cy - halfH, z), new Vector3(cx + halfW, cy - halfH, z),
new Vector3(cx + halfW, cy + halfH, z), new Vector3(cx - halfW, cy + halfH, z),
};
private static LoadedCell Cell(uint id, params CellPortalInfo[] portals) => new LoadedCell
{
CellId = id, WorldTransform = Matrix4x4.Identity, InverseWorldTransform = Matrix4x4.Identity,
Portals = new List<CellPortalInfo>(portals),
};
// A realistic 2-cell building viewed from OUTSIDE: a vestibule (0x0170) with an entrance portal to
// the outdoors plus an interior portal to a back room (0x0171). The entrance clip plane puts the
// origin eye on the EXTERIOR side (D=3, InsideSide=1 → dot=3 > ε), so the exterior seed fires —
// exactly the BuildFromExterior seeding contract (mirrors the existing BuildFromExterior_* fixtures).
private static (LoadedCell[] cells, Dictionary<uint, LoadedCell> lookup) TwoCellBuilding()
{
const uint VEST = 0x0170, ROOM = 0x0171;
var vest = Cell(VEST,
new CellPortalInfo(0xFFFF, PolygonId: 0, Flags: 0, OtherPortalId: 0), // entrance to outdoors
new CellPortalInfo((ushort)ROOM, PolygonId: 1, Flags: 0, OtherPortalId: 0)); // interior to room
vest.PortalPolygons.Add(Quad(0f, 0f, 0.5f, 0.5f, -2f)); // entrance opening, in front of eye
vest.PortalPolygons.Add(Quad(0f, 0f, 0.6f, 0.6f, -4f)); // vestibule -> room, deeper
vest.ClipPlanes.Add(new PortalClipPlane { Normal = new Vector3(0, 0, 1), D = 3f, InsideSide = 1 });
var room = Cell(ROOM, new CellPortalInfo((ushort)VEST, PolygonId: 0, Flags: 0, OtherPortalId: 1));
room.PortalPolygons.Add(Quad(0f, 0f, 0.6f, 0.6f, -4f)); // reciprocal back to vestibule
var all = new Dictionary<uint, LoadedCell> { [VEST] = vest, [ROOM] = room };
return (new[] { vest, room }, all);
}
[Fact]
public void ConstructViewBuilding_FloodsTheBuildingFromItsEntrance()
{
var (cells, lookup) = TwoCellBuilding();
var frame = PortalVisibilityBuilder.ConstructViewBuilding(
cells, Vector3.Zero, id => lookup.TryGetValue(id, out var c) ? c : null, ViewProj());
Assert.Contains(0x0170u, frame.OrderedVisibleCells); // entrance cell seeded
Assert.Contains(0x0171u, frame.OrderedVisibleCells); // back room reached through the interior portal
}
[Fact]
public void ConstructViewBuilding_TouchesAboutTwoCells()
{
// Conformance to §3.4: each retail per-building flood has cell_draw_num ≈ 2.
var (cells, lookup) = TwoCellBuilding();
var frame = PortalVisibilityBuilder.ConstructViewBuilding(
cells, Vector3.Zero, id => lookup.TryGetValue(id, out var c) ? c : null, ViewProj());
Assert.InRange(frame.OrderedVisibleCells.Count, 1, 3);
}
[Fact]
public void ConstructViewBuilding_MembershipStableUnderMicrometreEyeJitter()
{
// Conformance to §3.4: retail's per-building membership is stable while the eye jitters ~36 µm
// at rest (measured X≈15 µm, Y≈36 µm, Z≈8 µm). The entrance-bounded seed must return the SAME
// OrderedVisibleCells for the eye and the eye + that per-axis jitter — no flap.
var (cells, lookup) = TwoCellBuilding();
var vp = ViewProj();
System.Func<uint, LoadedCell?> lk = id => lookup.TryGetValue(id, out var c) ? c : null;
var a = PortalVisibilityBuilder.ConstructViewBuilding(cells, Vector3.Zero, lk, vp);
var b = PortalVisibilityBuilder.ConstructViewBuilding(
cells, new Vector3(15e-6f, 36e-6f, 8e-6f), lk, vp);
Assert.Equal(a.OrderedVisibleCells, b.OrderedVisibleCells);
}
}

View file

@ -1,109 +0,0 @@
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);
}
}