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
|
|
@ -7374,10 +7374,10 @@ public sealed class GameWindow : IDisposable
|
|||
foreach (var onCell in _cellVisibility.GetCellsForLandblock(onLb))
|
||||
_outdoorNodeBuildingCells.Add(onCell);
|
||||
}
|
||||
_outdoorNode = AcDream.App.Rendering.OutdoorCellNode.Build(viewerCellId, _outdoorNodeBuildingCells);
|
||||
_outdoorNode = AcDream.App.Rendering.OutdoorCellNode.Build(viewerCellId);
|
||||
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled)
|
||||
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;
|
||||
|
|
@ -7503,6 +7503,10 @@ public sealed class GameWindow : IDisposable
|
|||
var pviewResult = _retailPViewRenderer.DrawInside(new AcDream.App.Rendering.RetailPViewDrawContext
|
||||
{
|
||||
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,
|
||||
ViewProjection = envCellViewProj,
|
||||
CellLookup = id => _cellVisibility.TryGetCell(id, out var c) ? c : null,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -517,6 +517,25 @@ public static class PortalVisibilityBuilder
|
|||
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.
|
||||
private static readonly Vector2[] FullScreenQuad =
|
||||
{ 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 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(
|
||||
GL gl,
|
||||
ClipFrame clipFrame,
|
||||
|
|
@ -46,6 +51,15 @@ public sealed class RetailPViewRenderer
|
|||
ctx.CellLookup,
|
||||
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);
|
||||
UploadClipFrame(ctx.SetTerrainClipUbo);
|
||||
|
||||
|
|
@ -85,6 +99,52 @@ public sealed class RetailPViewRenderer
|
|||
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)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ctx);
|
||||
|
|
@ -310,6 +370,11 @@ public interface IRetailPViewCellDrawContext : IRetailPViewCellDrawCallbacks
|
|||
public sealed class RetailPViewDrawContext : IRetailPViewCellDrawContext
|
||||
{
|
||||
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 Matrix4x4 ViewProjection { get; init; }
|
||||
public required Func<uint, LoadedCell?> CellLookup { get; init; }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue