feat(render): indoor render WORKS — terminating portal flood + every-cell seal + look-in FPS

Checkpoint of the unified retail-faithful indoor render. The two-week HANG/grey is fixed and the
interior seals (live-verified by the user). Commits the session render-rewrite foundation together
with the fixes that made it functional.

- HANG fix: PortalVisibilityBuilder.Build portal flood did not terminate (the faithful ProjectToClip
  near-side clip drifts per round, defeating the CellView dedup; the BFS had no bound after U.2a removed
  MaxReprocessPerCell). Fix = drift-tolerant snapped/canonical CellView.Add dedup (PortalView.cs) plus
  restored MaxReprocessPerCell=16 bounded re-enqueue (PortalVisibilityBuilder.cs). Re-enqueue is kept
  (load-bearing for late-slice propagation, Build_ViewGrowthAfterDoneCell_PropagatesNewSlicesToExit);
  only its count is capped. CellViewDedupTests added.
- Seal (DrawCells Task 2): RetailPViewRenderer.DrawEnvCellShells draws EVERY visible cell via
  IndoorDrawPlan.ShellPass (was gated on the ClipFrameAssembler slot filter, leaving slot-less cells grey).
- Look-in FPS: GameWindow exterior look-in candidates limited to the player landblock +-1 (was all ~81
  loaded LBs iterated every outdoor frame). No behaviour change (far cells were >48m, already culled).

Remaining dominant issue = the FLAP at transitions: viewer-cell metastability (render roots at the
camera-eye cell, which oscillates outdoor-indoor as the 3rd-person boom drifts across the doorway,
confirmed in render-sig). SEPARATE fix, NOT the DrawCells port. Full handoff + flap fix plan + tracked
follow-ups (#78 terrain, look-in-from-inside, look-in FPS, L-spotlight):
docs/research/2026-06-07-indoor-render-session-handoff.md.

Baselines: build 0 err; App.Tests 210/210; Core.Tests 1331 pass / 4 fail (pre-existing) / 1 skip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-07 10:14:43 +02:00
parent bff1955066
commit 1405dd8e90
27 changed files with 3635 additions and 814 deletions

View file

@ -10,6 +10,8 @@ public class InteriorEntityPartitionTests
{
private const uint CellA = 0xA9B40170;
private const uint CellB = 0xA9B40171;
private const uint HiddenCell = 0xA9B40199;
private const uint OutdoorCell = 0xA9B40020;
private static WorldEntity Ent(uint id, uint serverGuid, uint? parentCell) => new()
{
@ -28,39 +30,44 @@ public class InteriorEntityPartitionTests
(IReadOnlyDictionary<uint, WorldEntity>?)null) };
[Fact]
public void Partitions_ByServerGuidThenParentCell_IntoThreeBuckets()
public void Partitions_LiveAndStaticEntities_ByCellOutdoorAndFallback()
{
var livePlayer = Ent(1, serverGuid: 0x5000000A, parentCell: null); // live-dynamic
var liveNpcInCell = Ent(2, serverGuid: 0x80001234, parentCell: CellA); // live-dynamic WINS over cell
var staticA = Ent(3, serverGuid: 0, parentCell: CellA); // per-cell static
var staticB = Ent(4, serverGuid: 0, parentCell: CellB); // per-cell static
var scenery = Ent(5, serverGuid: 0, parentCell: null); // outdoor scenery
var unresolvedLive = Ent(1, serverGuid: 0x5000000A, parentCell: null);
var liveNpcInCell = Ent(2, serverGuid: 0x80001234, parentCell: CellA);
var staticA = Ent(3, serverGuid: 0, parentCell: CellA);
var staticB = Ent(4, serverGuid: 0, parentCell: CellB);
var scenery = Ent(5, serverGuid: 0, parentCell: null);
var liveOutdoor = Ent(6, serverGuid: 0x80005678, parentCell: OutdoorCell);
var visible = new HashSet<uint> { CellA, CellB };
var result = InteriorEntityPartition.Partition(
visible, OneLb(0xA9B4FFFF, livePlayer, liveNpcInCell, staticA, staticB, scenery));
visible, OneLb(0xA9B4FFFF, unresolvedLive, liveNpcInCell, staticA, staticB, scenery, liveOutdoor));
Assert.Equal(2, result.LiveDynamic.Count); // player + npc (serverGuid != 0)
Assert.Contains(livePlayer, result.LiveDynamic);
Assert.Contains(liveNpcInCell, result.LiveDynamic);
Assert.Single(result.LiveDynamic);
Assert.Contains(unresolvedLive, result.LiveDynamic);
Assert.Single(result.ByCell[CellA]); // only staticA (npc went live-dynamic)
Assert.Equal(2, result.ByCell[CellA].Count);
Assert.Contains(liveNpcInCell, result.ByCell[CellA]);
Assert.Contains(staticA, result.ByCell[CellA]);
Assert.Single(result.ByCell[CellB]);
Assert.Contains(staticB, result.ByCell[CellB]);
Assert.Single(result.Outdoor);
Assert.Equal(2, result.Outdoor.Count);
Assert.Contains(scenery, result.Outdoor);
Assert.Contains(liveOutdoor, result.Outdoor);
}
[Fact]
public void Static_InNonVisibleCell_IsDropped()
public void IndoorEntity_InNonVisibleCell_IsDropped()
{
var staticHidden = Ent(3, serverGuid: 0, parentCell: 0xA9B40199); // not in the visible set
var staticHidden = Ent(3, serverGuid: 0, parentCell: HiddenCell);
var liveHidden = Ent(4, serverGuid: 0x80001234, parentCell: HiddenCell);
var visible = new HashSet<uint> { CellA };
var result = InteriorEntityPartition.Partition(visible, OneLb(0xA9B4FFFF, staticHidden));
Assert.False(result.ByCell.ContainsKey(0xA9B40199));
var result = InteriorEntityPartition.Partition(
visible, OneLb(0xA9B4FFFF, staticHidden, liveHidden));
Assert.False(result.ByCell.ContainsKey(HiddenCell));
Assert.Empty(result.Outdoor);
Assert.Empty(result.LiveDynamic);
}