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:
parent
9bc0db9351
commit
5379f6ecd3
5 changed files with 71 additions and 0 deletions
|
|
@ -104,6 +104,16 @@ public sealed class LoadedCell
|
|||
/// grab_visible_cells decomp:311878). The stable anchor for the terrain-draw test.
|
||||
/// </summary>
|
||||
public bool SeenOutside;
|
||||
|
||||
/// <summary>
|
||||
/// Render unification (2026-06-07): true for the synthetic OUTDOOR cell node built by
|
||||
/// <see cref="OutdoorCellNode.Build"/> — the outdoor world modelled as a flood-graph cell whose
|
||||
/// shell is the landscape. <see cref="PortalVisibilityBuilder.Build"/> 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.
|
||||
/// </summary>
|
||||
public bool IsOutdoorNode;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ public static class OutdoorCellNode
|
|||
{
|
||||
CellId = outdoorCellId,
|
||||
SeenOutside = true,
|
||||
IsOutdoorNode = true,
|
||||
WorldTransform = Matrix4x4.Identity,
|
||||
InverseWorldTransform = Matrix4x4.Identity,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue