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:
Erik 2026-06-08 18:44:43 +02:00
parent 7fe98098f5
commit c62663d7cb
8 changed files with 251 additions and 198 deletions

View file

@ -1,64 +1,31 @@
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.App.Rendering;
/// <summary>
/// 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 <c>viewer_cell</c> (SmartBox::RenderNormalMode →
/// DrawInside(viewer_cell), decomp:92635); when outdoors that is a <c>CLandCell</c>. acdream models it
/// as a portal-less <see cref="LoadedCell"/> carrying only <see cref="LoadedCell.IsOutdoorNode"/> (so
/// <see cref="PortalVisibilityBuilder.Build"/> seeds OutsideView FULL-SCREEN → terrain/sky/scenery draw
/// as the root's shell) and <see cref="LoadedCell.SeenOutside"/>.
///
/// <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>
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,
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,
};
}