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);
}
}