diff --git a/docs/superpowers/plans/2026-06-08-full-retail-render-port-option-a.md b/docs/superpowers/plans/2026-06-08-full-retail-render-port-option-a.md index cc3792df..c893480f 100644 --- a/docs/superpowers/plans/2026-06-08-full-retail-render-port-option-a.md +++ b/docs/superpowers/plans/2026-06-08-full-retail-render-port-option-a.md @@ -128,6 +128,19 @@ Co-Authored-By: Claude Opus 4.8 (1M context) " ## Task R-A2: Per-building floods — the flap fix (remove D3, finish D2) +**AS-BUILT (2026-06-08, conformance-green, pending visual gate):** `OutdoorCellNode.Build(uint)` is now +portal-less (reverse portals removed → the land root floods only itself → full-screen OutsideView for +terrain). `PortalVisibilityBuilder.ConstructViewBuilding` is the per-building contract (thin wrapper over +`BuildFromExterior`). `RetailPViewRenderer.DrawInside` groups the nearby building cells by `BuildingId` +(owned by the render layer — a reused dict, keeps GameWindow thin) and merges each small per-building +flood into the frame before assembly (`MergeNearbyBuildingFloods` / `MergeBuildingFrame`; 48 m seed +cutoff); the existing draw path (assemble → shells → object lists) is unchanged. `GameWindow` passes the +flat `NearbyBuildingCells` only on outdoor-node frames. `UnifiedFloodTests` retired (its subject — the +unified flood from the outdoor node — is removed); its surviving full-screen-OutsideView coverage moved +to `OutdoorCellNodeTests`. Conformance + render suites green (App Rendering 207, Core movement 14, +incl. +3 `PortalVisibilityRobustnessTests`). The detailed steps below are the original design rationale; +this note is the as-built. **Visual gate (grazing doorway) is the acceptance test for "flap gone."** + **Intent:** Replace the single unified flood from the outdoor land root (which reaches buildings through reverse portals gated by a root-level portal-side knife-edge → the oscillation) with retail's **per-building** floods: for each building near the camera, run a small `ConstructView` seeded at that building's entrance portal, touching ≈2 cells. The land-cell root then floods **nothing** into buildings — it is a pure terrain root (full-screen `OutsideView`). This makes building membership robust to the eye's ~36 µm jitter → the flap dies. **Retail oracle:** `BSPPORTAL::portal_draw_portals_only` (`0x53d870`, decomp:326881) → `DrawPortal` (`0x5a5ab0`, decomp:433895) → `ConstructView(CBldPortal*, …)` (`0x5a59a0`, decomp:433827): viewpoint side-test vs the building portal plane (0.0002 epsilon), `GetClip`, `CEnvCell::GetVisible(other_cell_id)`, `copy_view`, recurse into the building's cells. acdream's `BuildFromExterior` (`PortalVisibilityBuilder.cs:373`) already implements this shape (seed from an exit portal, flood inward); R-A2 calls it **per building** instead of once over all candidates, and removes the root-level building reverse-portals. diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 7c15c9a5..4f1cc79d 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -7374,10 +7374,10 @@ public sealed class GameWindow : IDisposable foreach (var onCell in _cellVisibility.GetCellsForLandblock(onLb)) _outdoorNodeBuildingCells.Add(onCell); } - _outdoorNode = AcDream.App.Rendering.OutdoorCellNode.Build(viewerCellId, _outdoorNodeBuildingCells); + _outdoorNode = AcDream.App.Rendering.OutdoorCellNode.Build(viewerCellId); if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled) Console.WriteLine(System.FormattableString.Invariant( - $"[outdoor-node] cell=0x{viewerCellId:X8} nearbyCells={_outdoorNodeBuildingCells.Count} portals={_outdoorNode.Portals.Count}")); + $"[outdoor-node] cell=0x{viewerCellId:X8} nearbyCells={_outdoorNodeBuildingCells.Count} (R-A2 per-building floods)")); } uint playerCellId = _physicsEngine.DataCache?.CellGraph.CurrCell?.Id ?? 0u; @@ -7503,6 +7503,10 @@ public sealed class GameWindow : IDisposable var pviewResult = _retailPViewRenderer.DrawInside(new AcDream.App.Rendering.RetailPViewDrawContext { RootCell = clipRoot, + // R-A2: outdoor root floods each nearby building per-building (not via the root). The + // gather above populates _outdoorNodeBuildingCells only on outdoor-node frames, so it + // is fresh here exactly when clipRoot.IsOutdoorNode; null for interior roots. + NearbyBuildingCells = clipRoot.IsOutdoorNode ? _outdoorNodeBuildingCells : null, ViewerEyePos = viewerEyePos, ViewProjection = envCellViewProj, CellLookup = id => _cellVisibility.TryGetCell(id, out var c) ? c : null, diff --git a/src/AcDream.App/Rendering/OutdoorCellNode.cs b/src/AcDream.App/Rendering/OutdoorCellNode.cs index 6ce08e3a..13ae4d0d 100644 --- a/src/AcDream.App/Rendering/OutdoorCellNode.cs +++ b/src/AcDream.App/Rendering/OutdoorCellNode.cs @@ -1,64 +1,31 @@ -using System.Collections.Generic; using System.Numerics; namespace AcDream.App.Rendering; /// -/// Builds the synthetic outdoor cell node — the outdoor world as a flood-graph cell -/// (spec docs/superpowers/specs/2026-06-07-render-unification-outdoor-as-cell-design.md). -/// Its "shell" is the landscape (drawn by the terrain renderer); its portals are the -/// reverse of each nearby building's exit portal (OtherCellId==0xFFFF). One node per -/// frame, keyed by the viewer's outdoor landcell id. WorldTransform is identity -/// (portals stored in world space). Mirrors retail's outdoor landcell that -/// DrawInside(viewer_cell) roots at (SmartBox::RenderNormalMode, decomp pc:92635). +/// Factory for the OUTDOOR render root — the cell the render roots at when the camera eye is outdoors. +/// Retail roots every in-world frame at viewer_cell (SmartBox::RenderNormalMode → +/// DrawInside(viewer_cell), decomp:92635); when outdoors that is a CLandCell. acdream models it +/// as a portal-less carrying only (so +/// seeds OutsideView FULL-SCREEN → terrain/sky/scenery draw +/// as the root's shell) and . +/// +/// R-A2 (2026-06-08): the node no longer carries reverse portals into nearby buildings. Retail +/// does NOT flood buildings from the land root — buildings flood SEPARATELY, per-building, during the +/// landscape draw (terrain BSP → DrawPortal → ConstructView(CBldPortal), decomp:326881/433895/433827). +/// acdream issues those via per nearby +/// building inside . The pre-R-A2 design flooded all +/// buildings from one root through reverse portals, coupling their interior membership to a single +/// root-level portal-side test that oscillated as the chase eye grazed a doorway — the indoor flap. /// public static class OutdoorCellNode { - public static LoadedCell Build(uint outdoorCellId, IReadOnlyList nearbyBuildingCells) + public static LoadedCell Build(uint outdoorCellId) => new LoadedCell { - var node = new LoadedCell - { - CellId = outdoorCellId, - SeenOutside = true, - IsOutdoorNode = true, - WorldTransform = Matrix4x4.Identity, - InverseWorldTransform = Matrix4x4.Identity, - }; - - foreach (var bcell in nearbyBuildingCells) - { - for (int i = 0; i < bcell.Portals.Count; i++) - { - if (bcell.Portals[i].OtherCellId != 0xFFFF) continue; // only exit-to-outdoors - if (i >= bcell.ClipPlanes.Count || i >= bcell.PortalPolygons.Count) continue; - - // Reverse portal: outdoor node -> this building cell. - node.Portals.Add(new CellPortalInfo( - OtherCellId: (ushort)(bcell.CellId & 0xFFFFu), - PolygonId: bcell.Portals[i].PolygonId, - Flags: bcell.Portals[i].Flags, - OtherPortalId: (ushort)i)); - - // Entrance polygon -> world space (node transform is identity). - var srcPoly = bcell.PortalPolygons[i]; - var worldPoly = new Vector3[srcPoly.Length]; - for (int v = 0; v < srcPoly.Length; v++) - worldPoly[v] = Vector3.Transform(srcPoly[v], bcell.WorldTransform); - node.PortalPolygons.Add(worldPoly); - - // Clip plane -> world space, inside-side flipped (outdoor half-space is "inside"). - var src = bcell.ClipPlanes[i]; - var worldNormal = Vector3.Normalize(Vector3.TransformNormal(src.Normal, bcell.WorldTransform)); - var pointOnPlane = Vector3.Transform(src.Normal * -src.D, bcell.WorldTransform); - node.ClipPlanes.Add(new PortalClipPlane - { - Normal = worldNormal, - D = -Vector3.Dot(worldNormal, pointOnPlane), - InsideSide = src.InsideSide == 0 ? 1 : 0, - }); - } - } - - return node; - } + CellId = outdoorCellId, + SeenOutside = true, + IsOutdoorNode = true, + WorldTransform = Matrix4x4.Identity, + InverseWorldTransform = Matrix4x4.Identity, + }; } diff --git a/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs b/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs index 52e5c364..f828abfb 100644 --- a/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs +++ b/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs @@ -517,6 +517,25 @@ public static class PortalVisibilityBuilder return frame; } + /// + /// Retail per-building flood — PView::ConstructView(CBldPortal*, …) (decomp:433827), + /// reached from BSPPORTAL::portal_draw_portals_only (0x53d870) → DrawPortal + /// (0x5a5ab0) during the terrain BSP walk. Floods ONE building's cells from its outside-facing + /// entrance portal(s). Identical machinery to , but the CONTRACT is + /// per-building: the caller passes exactly one building's cells, so the seed is that building's + /// FINITE entrance opening (bounded flood depth → the stable ~2-cell view retail draws per visible + /// building, measured live §3.4). This differs from the synthetic outdoor node's single unified + /// flood whose full-screen-ish seed reaches variable depth into a building as the eye moves — the + /// 2↔6 oscillation. Robustness is validated by the conformance test, not assumed. + /// + public static PortalVisibilityFrame ConstructViewBuilding( + IEnumerable buildingCells, + Vector3 cameraPos, + Func lookup, + Matrix4x4 viewProj, + float maxSeedDistance = float.PositiveInfinity) + => BuildFromExterior(buildingCells, cameraPos, lookup, viewProj, maxSeedDistance); + // The NDC [-1,1] viewport quad (CCW), reused by the flap probe's clip recompute. private static readonly Vector2[] FullScreenQuad = { new Vector2(-1f, -1f), new Vector2(1f, -1f), new Vector2(1f, 1f), new Vector2(-1f, 1f) }; diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs index 024ec3d9..e994a311 100644 --- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs +++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs @@ -24,6 +24,11 @@ public sealed class RetailPViewRenderer private readonly HashSet _oneCell = new(1); private readonly Dictionary _oneCellSlot = new(1); + + // R-A2: per-building flood grouping, reused across frames (inner lists cleared each frame). + private readonly Dictionary> _buildingGroups = new(); + private const float OutdoorBuildingSeedDistance = 48f; + public RetailPViewRenderer( GL gl, ClipFrame clipFrame, @@ -46,6 +51,15 @@ public sealed class RetailPViewRenderer ctx.CellLookup, ctx.ViewProjection); + // R-A2: outdoor root — flood each nearby building SEPARATELY from its own entrance and merge + // the small (~2-cell) per-building views into the frame. Retail reaches building interiors via + // the terrain BSP -> DrawPortal -> ConstructView(CBldPortal) (decomp:326881/433895/433827); the + // land root itself has no portals (it floods nothing into buildings). Per-building seeding is + // robust to the eye's ~36 µm rest jitter where the pre-R-A2 single reverse-portal flood + // oscillated as the chase eye grazed a doorway (the indoor flap). + if (ctx.RootCell.IsOutdoorNode && ctx.NearbyBuildingCells is not null) + MergeNearbyBuildingFloods(ctx, pvFrame); + var clipAssembly = ClipFrameAssembler.Assemble(_clipFrame, pvFrame); UploadClipFrame(ctx.SetTerrainClipUbo); @@ -85,6 +99,52 @@ public sealed class RetailPViewRenderer return result; } + // R-A2: group the nearby building cells by BuildingId and run one per-building flood per group + // (retail's per-building ConstructView(CBldPortal)), merging each small view into the frame. The + // grouping dict is reused across frames; inner lists are cleared each frame so a building that left + // the near set simply contributes an empty (skipped) group. + private void MergeNearbyBuildingFloods(RetailPViewDrawContext ctx, PortalVisibilityFrame pvFrame) + { + foreach (var group in _buildingGroups.Values) + group.Clear(); + + foreach (var cell in ctx.NearbyBuildingCells!) + { + if (cell.BuildingId is not uint buildingId) + continue; // outdoor surface cells (no building) don't flood + if (!_buildingGroups.TryGetValue(buildingId, out var group)) + { + group = new List(); + _buildingGroups[buildingId] = group; + } + group.Add(cell); + } + + foreach (var group in _buildingGroups.Values) + { + if (group.Count == 0) + continue; + var buildingFrame = PortalVisibilityBuilder.ConstructViewBuilding( + group, ctx.ViewerEyePos, ctx.CellLookup, ctx.ViewProjection, OutdoorBuildingSeedDistance); + MergeBuildingFrame(pvFrame, buildingFrame); + } + } + + // Append a per-building flood's cells + views into the frame. Each building cell belongs to exactly + // one building, so there is no cross-building overlap; ContainsKey is a safety dedup. OutsideView is + // NOT merged — the outdoor root already seeds full-screen terrain, and ConstructViewBuilding + // (BuildFromExterior) leaves OutsideView empty (it stops at exit portals once inside the building). + private static void MergeBuildingFrame(PortalVisibilityFrame target, PortalVisibilityFrame src) + { + foreach (uint cellId in src.OrderedVisibleCells) + { + if (target.CellViews.ContainsKey(cellId)) + continue; + target.CellViews[cellId] = src.CellViews[cellId]; + target.OrderedVisibleCells.Add(cellId); + } + } + public RetailPViewFrameResult? DrawPortal(RetailPViewPortalDrawContext ctx) { ArgumentNullException.ThrowIfNull(ctx); @@ -310,6 +370,11 @@ public interface IRetailPViewCellDrawContext : IRetailPViewCellDrawCallbacks public sealed class RetailPViewDrawContext : IRetailPViewCellDrawContext { public required LoadedCell RootCell { get; init; } + + /// R-A2: nearby building cells (BuildingId-tagged) flooded per-building when the root is the + /// outdoor node. Null for interior roots. Grouped by BuildingId inside . + public IReadOnlyList? NearbyBuildingCells { get; init; } + public required Vector3 ViewerEyePos { get; init; } public required Matrix4x4 ViewProjection { get; init; } public required Func CellLookup { get; init; } diff --git a/tests/AcDream.App.Tests/Rendering/OutdoorCellNodeTests.cs b/tests/AcDream.App.Tests/Rendering/OutdoorCellNodeTests.cs index 17faa55e..01996744 100644 --- a/tests/AcDream.App.Tests/Rendering/OutdoorCellNodeTests.cs +++ b/tests/AcDream.App.Tests/Rendering/OutdoorCellNodeTests.cs @@ -1,54 +1,52 @@ +using System; using System.Numerics; using AcDream.App.Rendering; using Xunit; namespace AcDream.App.Tests.Rendering; +/// +/// The OUTDOOR render root (R-A2, 2026-06-08): a portal-less whose only job is +/// to root the render outdoors so the flood seeds OutsideView FULL-SCREEN (terrain draws). Buildings are +/// flooded SEPARATELY per-building (), 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 UnifiedFloodTests was retired. +/// 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()); - 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); } } diff --git a/tests/AcDream.App.Tests/Rendering/PortalVisibilityRobustnessTests.cs b/tests/AcDream.App.Tests/Rendering/PortalVisibilityRobustnessTests.cs new file mode 100644 index 00000000..6bab3328 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/PortalVisibilityRobustnessTests.cs @@ -0,0 +1,96 @@ +using System.Collections.Generic; +using System.Numerics; +using AcDream.App.Rendering; +using Xunit; + +namespace AcDream.App.Tests.Rendering; + +/// +/// R-A2 conformance: the per-building flood () +/// 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). +/// +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(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 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 { [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 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); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/UnifiedFloodTests.cs b/tests/AcDream.App.Tests/Rendering/UnifiedFloodTests.cs deleted file mode 100644 index 4beede61..00000000 --- a/tests/AcDream.App.Tests/Rendering/UnifiedFloodTests.cs +++ /dev/null @@ -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()); - 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); - } -}