diff --git a/src/AcDream.App/Rendering/CellVisibility.cs b/src/AcDream.App/Rendering/CellVisibility.cs index 80477c0e..7cabee61 100644 --- a/src/AcDream.App/Rendering/CellVisibility.cs +++ b/src/AcDream.App/Rendering/CellVisibility.cs @@ -104,6 +104,16 @@ public sealed class LoadedCell /// grab_visible_cells decomp:311878). The stable anchor for the terrain-draw test. /// public bool SeenOutside; + + /// + /// Render unification (2026-06-07): true for the synthetic OUTDOOR cell node built by + /// — the outdoor world modelled as a flood-graph cell whose + /// shell is the landscape. seeds OutsideView + /// full-screen when the root carries this flag (so terrain/sky/scenery draw as the node's shell). + /// An explicit flag, not a cell-id heuristic: interior EnvCell ids are >= 0x100 in production but + /// test fixtures use low ids for interior cells, so keying on the id would misfire. + /// + public bool IsOutdoorNode; } /// diff --git a/src/AcDream.App/Rendering/OutdoorCellNode.cs b/src/AcDream.App/Rendering/OutdoorCellNode.cs index 0830f096..6ce08e3a 100644 --- a/src/AcDream.App/Rendering/OutdoorCellNode.cs +++ b/src/AcDream.App/Rendering/OutdoorCellNode.cs @@ -20,6 +20,7 @@ public static class OutdoorCellNode { 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 461d1145..e421e3a4 100644 --- a/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs +++ b/src/AcDream.App/Rendering/PortalVisibilityBuilder.cs @@ -76,6 +76,18 @@ public static class PortalVisibilityBuilder frame.CellViews[cameraCell.CellId] = CellView.FullScreen(); + // Render unification (outdoor-as-cell, 2026-06-07): when the root IS the synthetic outdoor + // node, the landscape is visible FULL-SCREEN, so seed OutsideView with the full-screen NDC + // quad. ClipFrameAssembler turns that into a full-screen OutsideView slice, so DrawInside's + // DrawLandscapeThroughOutsideView draws terrain/sky/scenery/weather as the node's "shell" — + // the very same callback that already draws the doorway slice when an INTERIOR root reaches + // outdoors. Keyed on the explicit 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 would misfire. An interior root never sets this flag, so the indoor + // exit-portal path (OtherCellId==0xFFFF below) still owns the doorway OutsideView region. + if (cameraCell.IsOutdoorNode) + frame.OutsideView.Add(new ViewPolygon((Vector2[])FullScreenQuad.Clone())); + // Distance-priority work list (retail PView::cell_todo_list). Cells pop closest-first; // each cell carries the camera→nearest-portal-vertex distance that put it on the list // (retail keys on InitCell's per-portal min-vertex distance, decomp 432988-433004). The diff --git a/tests/AcDream.App.Tests/Rendering/OutdoorCellNodeTests.cs b/tests/AcDream.App.Tests/Rendering/OutdoorCellNodeTests.cs index 1129b295..17faa55e 100644 --- a/tests/AcDream.App.Tests/Rendering/OutdoorCellNodeTests.cs +++ b/tests/AcDream.App.Tests/Rendering/OutdoorCellNodeTests.cs @@ -33,6 +33,7 @@ public class OutdoorCellNodeTests Assert.Equal(outdoorId, node.CellId); 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); @@ -48,5 +49,6 @@ public class OutdoorCellNodeTests var node = OutdoorCellNode.Build(0xA9B40031, System.Array.Empty()); Assert.Empty(node.Portals); Assert.True(node.SeenOutside); + Assert.True(node.IsOutdoorNode); } } diff --git a/tests/AcDream.App.Tests/Rendering/UnifiedFloodTests.cs b/tests/AcDream.App.Tests/Rendering/UnifiedFloodTests.cs index 75323d17..4beede61 100644 --- a/tests/AcDream.App.Tests/Rendering/UnifiedFloodTests.cs +++ b/tests/AcDream.App.Tests/Rendering/UnifiedFloodTests.cs @@ -60,4 +60,50 @@ public class UnifiedFloodTests 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); + } }