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>
This commit is contained in:
Erik 2026-06-07 19:06:13 +02:00
parent 9bc0db9351
commit 5379f6ecd3
5 changed files with 71 additions and 0 deletions

View file

@ -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<LoadedCell>());
Assert.Empty(node.Portals);
Assert.True(node.SeenOutside);
Assert.True(node.IsOutdoorNode);
}
}

View file

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