feat(render): Phase 3 (Task 2) — build the outdoor node each frame (additive, unconsumed)

Builds the synthetic outdoor cell node (OutdoorCellNode.Build) every outdoor frame
from the nearby building-entrance portals (Chebyshev <=1 landblocks), stored in
_outdoorNode. NOT yet rooted — clipRoot/viewerRoot unchanged, so behaviour is
identical this commit. [outdoor-node] probe (ACDREAM_PROBE_FLAP) reports the live
portal count so the next (cutover) step can confirm real building entrances were
found before flipping the render root. App.Tests 214/214, build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-07 18:35:25 +02:00
parent 1e9485532f
commit d01fe30ac0

View file

@ -184,6 +184,14 @@ public sealed class GameWindow : IDisposable
private readonly HashSet<uint> _outdoorRootNoCells = new(0);
private readonly HashSet<uint> _exteriorPortalLandblocks = new();
private readonly List<LoadedCell> _exteriorPortalCandidateCells = new();
// Phase 3 (render unification, 2026-06-07): the synthetic outdoor cell node — the outdoor
// world as a flood-graph cell (spec 2026-06-07-render-unification-outdoor-as-cell). Rebuilt
// each outdoor frame from nearby building-entrance portals. ADDITIVE for now (built but not
// yet rooted; the clipRoot flip + OutsideView terrain integration is the cutover step).
private LoadedCell? _outdoorNode;
private readonly List<LoadedCell> _outdoorNodeBuildingCells = new();
private readonly HashSet<uint> _outdoorNodeSeenLbs = new();
private readonly HashSet<uint> _outdoorSceneParticleEntityIds = new();
private readonly HashSet<uint> _visibleSceneParticleEntityIds = new();
private string? _lastRenderSignature;
@ -7339,6 +7347,39 @@ public sealed class GameWindow : IDisposable
// (mesh SSBO) + binding=2 (terrain UBO); each renderer re-binds its
// binding=2 defensively from the ids we hand it.
_clipFrame ??= ClipFrame.NoClip();
// Phase 3 (render unification, additive): build the synthetic outdoor cell node when
// the eye is outdoors (no interior viewerRoot). Stored in _outdoorNode but NOT yet
// rooted — behaviour is unchanged this commit. The nearby-building enumeration mirrors
// the look-in candidate gather in the OUTDOOR branch below (Chebyshev <=1 landblocks);
// OutdoorCellNode.Build filters to exit portals internally. The clipRoot flip +
// OutsideView terrain integration that consumes this is the next (cutover) step.
_outdoorNode = null;
if (viewerRoot is null && viewerCellId != 0u)
{
_outdoorNodeBuildingCells.Clear();
_outdoorNodeSeenLbs.Clear();
int onGridX = playerLb.HasValue ? (int)((playerLb.Value >> 24) & 0xFFu) : -1;
int onGridY = playerLb.HasValue ? (int)((playerLb.Value >> 16) & 0xFFu) : -1;
foreach (var onEntry in _worldState.LandblockEntries)
{
uint onLb = (onEntry.LandblockId >> 16) & 0xFFFFu;
if (playerLb.HasValue)
{
int gX = (int)((onLb >> 8) & 0xFFu);
int gY = (int)(onLb & 0xFFu);
if (Math.Max(Math.Abs(gX - onGridX), Math.Abs(gY - onGridY)) > 1) continue;
}
if (!_outdoorNodeSeenLbs.Add(onLb)) continue;
foreach (var onCell in _cellVisibility.GetCellsForLandblock(onLb))
_outdoorNodeBuildingCells.Add(onCell);
}
_outdoorNode = AcDream.App.Rendering.OutdoorCellNode.Build(viewerCellId, _outdoorNodeBuildingCells);
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled)
Console.WriteLine(System.FormattableString.Invariant(
$"[outdoor-node] cell=0x{viewerCellId:X8} nearbyCells={_outdoorNodeBuildingCells.Count} portals={_outdoorNode.Portals.Count}"));
}
uint playerCellId = _physicsEngine.DataCache?.CellGraph.CurrCell?.Id ?? 0u;
bool playerIndoorGate = AcDream.Core.Rendering.RenderingDiagnostics.ShouldRenderIndoor(
playerCellId,