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:
parent
7fe98098f5
commit
c62663d7cb
8 changed files with 251 additions and 198 deletions
|
|
@ -128,6 +128,19 @@ Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
|
||||||
|
|
||||||
## Task R-A2: Per-building floods — the flap fix (remove D3, finish D2)
|
## 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.
|
**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.
|
**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.
|
||||||
|
|
|
||||||
|
|
@ -7374,10 +7374,10 @@ public sealed class GameWindow : IDisposable
|
||||||
foreach (var onCell in _cellVisibility.GetCellsForLandblock(onLb))
|
foreach (var onCell in _cellVisibility.GetCellsForLandblock(onLb))
|
||||||
_outdoorNodeBuildingCells.Add(onCell);
|
_outdoorNodeBuildingCells.Add(onCell);
|
||||||
}
|
}
|
||||||
_outdoorNode = AcDream.App.Rendering.OutdoorCellNode.Build(viewerCellId, _outdoorNodeBuildingCells);
|
_outdoorNode = AcDream.App.Rendering.OutdoorCellNode.Build(viewerCellId);
|
||||||
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled)
|
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled)
|
||||||
Console.WriteLine(System.FormattableString.Invariant(
|
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;
|
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
|
var pviewResult = _retailPViewRenderer.DrawInside(new AcDream.App.Rendering.RetailPViewDrawContext
|
||||||
{
|
{
|
||||||
RootCell = clipRoot,
|
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,
|
ViewerEyePos = viewerEyePos,
|
||||||
ViewProjection = envCellViewProj,
|
ViewProjection = envCellViewProj,
|
||||||
CellLookup = id => _cellVisibility.TryGetCell(id, out var c) ? c : null,
|
CellLookup = id => _cellVisibility.TryGetCell(id, out var c) ? c : null,
|
||||||
|
|
|
||||||
|
|
@ -1,64 +1,31 @@
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
|
|
||||||
namespace AcDream.App.Rendering;
|
namespace AcDream.App.Rendering;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds the synthetic outdoor cell node — the outdoor world as a flood-graph cell
|
/// Factory for the OUTDOOR render root — the cell the render roots at when the camera eye is outdoors.
|
||||||
/// (spec docs/superpowers/specs/2026-06-07-render-unification-outdoor-as-cell-design.md).
|
/// Retail roots every in-world frame at <c>viewer_cell</c> (SmartBox::RenderNormalMode →
|
||||||
/// Its "shell" is the landscape (drawn by the terrain renderer); its portals are the
|
/// DrawInside(viewer_cell), decomp:92635); when outdoors that is a <c>CLandCell</c>. acdream models it
|
||||||
/// reverse of each nearby building's exit portal (OtherCellId==0xFFFF). One node per
|
/// as a portal-less <see cref="LoadedCell"/> carrying only <see cref="LoadedCell.IsOutdoorNode"/> (so
|
||||||
/// frame, keyed by the viewer's outdoor landcell id. WorldTransform is identity
|
/// <see cref="PortalVisibilityBuilder.Build"/> seeds OutsideView FULL-SCREEN → terrain/sky/scenery draw
|
||||||
/// (portals stored in world space). Mirrors retail's outdoor landcell that
|
/// as the root's shell) and <see cref="LoadedCell.SeenOutside"/>.
|
||||||
/// DrawInside(viewer_cell) roots at (SmartBox::RenderNormalMode, decomp pc:92635).
|
///
|
||||||
|
/// <para>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 <see cref="PortalVisibilityBuilder.ConstructViewBuilding"/> per nearby
|
||||||
|
/// building inside <see cref="RetailPViewRenderer.DrawInside"/>. 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.</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class OutdoorCellNode
|
public static class OutdoorCellNode
|
||||||
{
|
{
|
||||||
public static LoadedCell Build(uint outdoorCellId, IReadOnlyList<LoadedCell> nearbyBuildingCells)
|
public static LoadedCell Build(uint outdoorCellId) => new LoadedCell
|
||||||
{
|
{
|
||||||
var node = new LoadedCell
|
CellId = outdoorCellId,
|
||||||
{
|
SeenOutside = true,
|
||||||
CellId = outdoorCellId,
|
IsOutdoorNode = true,
|
||||||
SeenOutside = true,
|
WorldTransform = Matrix4x4.Identity,
|
||||||
IsOutdoorNode = true,
|
InverseWorldTransform = Matrix4x4.Identity,
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -517,6 +517,25 @@ public static class PortalVisibilityBuilder
|
||||||
return frame;
|
return frame;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail per-building flood — <c>PView::ConstructView(CBldPortal*, …)</c> (decomp:433827),
|
||||||
|
/// reached from <c>BSPPORTAL::portal_draw_portals_only</c> (0x53d870) → <c>DrawPortal</c>
|
||||||
|
/// (0x5a5ab0) during the terrain BSP walk. Floods ONE building's cells from its outside-facing
|
||||||
|
/// entrance portal(s). Identical machinery to <see cref="BuildFromExterior"/>, 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.
|
||||||
|
/// </summary>
|
||||||
|
public static PortalVisibilityFrame ConstructViewBuilding(
|
||||||
|
IEnumerable<LoadedCell> buildingCells,
|
||||||
|
Vector3 cameraPos,
|
||||||
|
Func<uint, LoadedCell?> 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.
|
// The NDC [-1,1] viewport quad (CCW), reused by the flap probe's clip recompute.
|
||||||
private static readonly Vector2[] FullScreenQuad =
|
private static readonly Vector2[] FullScreenQuad =
|
||||||
{ new Vector2(-1f, -1f), new Vector2(1f, -1f), new Vector2(1f, 1f), new Vector2(-1f, 1f) };
|
{ new Vector2(-1f, -1f), new Vector2(1f, -1f), new Vector2(1f, 1f), new Vector2(-1f, 1f) };
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,11 @@ public sealed class RetailPViewRenderer
|
||||||
|
|
||||||
private readonly HashSet<uint> _oneCell = new(1);
|
private readonly HashSet<uint> _oneCell = new(1);
|
||||||
private readonly Dictionary<uint, int> _oneCellSlot = new(1);
|
private readonly Dictionary<uint, int> _oneCellSlot = new(1);
|
||||||
|
|
||||||
|
// R-A2: per-building flood grouping, reused across frames (inner lists cleared each frame).
|
||||||
|
private readonly Dictionary<uint, List<LoadedCell>> _buildingGroups = new();
|
||||||
|
private const float OutdoorBuildingSeedDistance = 48f;
|
||||||
|
|
||||||
public RetailPViewRenderer(
|
public RetailPViewRenderer(
|
||||||
GL gl,
|
GL gl,
|
||||||
ClipFrame clipFrame,
|
ClipFrame clipFrame,
|
||||||
|
|
@ -46,6 +51,15 @@ public sealed class RetailPViewRenderer
|
||||||
ctx.CellLookup,
|
ctx.CellLookup,
|
||||||
ctx.ViewProjection);
|
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);
|
var clipAssembly = ClipFrameAssembler.Assemble(_clipFrame, pvFrame);
|
||||||
UploadClipFrame(ctx.SetTerrainClipUbo);
|
UploadClipFrame(ctx.SetTerrainClipUbo);
|
||||||
|
|
||||||
|
|
@ -85,6 +99,52 @@ public sealed class RetailPViewRenderer
|
||||||
return result;
|
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<LoadedCell>();
|
||||||
|
_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)
|
public RetailPViewFrameResult? DrawPortal(RetailPViewPortalDrawContext ctx)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(ctx);
|
ArgumentNullException.ThrowIfNull(ctx);
|
||||||
|
|
@ -310,6 +370,11 @@ public interface IRetailPViewCellDrawContext : IRetailPViewCellDrawCallbacks
|
||||||
public sealed class RetailPViewDrawContext : IRetailPViewCellDrawContext
|
public sealed class RetailPViewDrawContext : IRetailPViewCellDrawContext
|
||||||
{
|
{
|
||||||
public required LoadedCell RootCell { get; init; }
|
public required LoadedCell RootCell { get; init; }
|
||||||
|
|
||||||
|
/// <summary>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 <see cref="DrawInside"/>.</summary>
|
||||||
|
public IReadOnlyList<LoadedCell>? NearbyBuildingCells { get; init; }
|
||||||
|
|
||||||
public required Vector3 ViewerEyePos { get; init; }
|
public required Vector3 ViewerEyePos { get; init; }
|
||||||
public required Matrix4x4 ViewProjection { get; init; }
|
public required Matrix4x4 ViewProjection { get; init; }
|
||||||
public required Func<uint, LoadedCell?> CellLookup { get; init; }
|
public required Func<uint, LoadedCell?> CellLookup { get; init; }
|
||||||
|
|
|
||||||
|
|
@ -1,54 +1,52 @@
|
||||||
|
using System;
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
using AcDream.App.Rendering;
|
using AcDream.App.Rendering;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace AcDream.App.Tests.Rendering;
|
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
|
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]
|
[Fact]
|
||||||
public void Build_FromBuildingExit_AddsReversePortalIntoBuilding()
|
public void Build_ReturnsPortallessOutdoorRoot()
|
||||||
{
|
{
|
||||||
uint outdoorId = 0xA9B40031;
|
uint outdoorId = 0xA9B40031;
|
||||||
var building = BuildingWithOneExit(0xA9B40170);
|
var node = OutdoorCellNode.Build(outdoorId);
|
||||||
var node = OutdoorCellNode.Build(outdoorId, new[] { building });
|
|
||||||
|
|
||||||
Assert.Equal(outdoorId, node.CellId);
|
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.SeenOutside);
|
||||||
Assert.True(node.IsOutdoorNode); // the flag PortalVisibilityBuilder keys the full-screen OutsideView on
|
|
||||||
Assert.Equal(Matrix4x4.Identity, node.WorldTransform);
|
Assert.Equal(Matrix4x4.Identity, node.WorldTransform);
|
||||||
Assert.Single(node.Portals);
|
Assert.Empty(node.Portals); // R-A2: no reverse portals into buildings (buildings flood per-building)
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Build_NoBuildings_ReturnsEmptyPortalNode()
|
public void Build_OutdoorRoot_SeedsFullScreenOutsideView_OnlyNodeVisible()
|
||||||
{
|
{
|
||||||
var node = OutdoorCellNode.Build(0xA9B40031, System.Array.Empty<LoadedCell>());
|
// The load-bearing outdoor-terrain behavior: rooting at the IsOutdoorNode node seeds OutsideView
|
||||||
Assert.Empty(node.Portals);
|
// with the FULL-SCREEN NDC quad, so ClipFrameAssembler yields TerrainMode != Skip and
|
||||||
Assert.True(node.SeenOutside);
|
// DrawLandscapeThroughOutsideView draws terrain/sky/scenery everywhere. The portal-less node
|
||||||
Assert.True(node.IsOutdoorNode);
|
// 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.App.Rendering;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.Rendering;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// R-A2 conformance: the per-building flood (<see cref="PortalVisibilityBuilder.ConstructViewBuilding"/>)
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
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<CellPortalInfo>(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<uint, LoadedCell> 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<uint, LoadedCell> { [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<uint, LoadedCell?> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<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