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:
parent
bff1955066
commit
1405dd8e90
27 changed files with 3635 additions and 814 deletions
|
|
@ -17,6 +17,13 @@ public sealed class InteriorRenderContext
|
|||
/// membership filter; <see cref="OrderedVisibleCells"/> supplies the draw ORDER.</summary>
|
||||
public required IReadOnlySet<uint> DrawableCells { get; init; }
|
||||
|
||||
/// <summary>Per-cell portal_view slots, in the same order retail setup_view(cell, i)
|
||||
/// selects them inside PView::DrawCells.</summary>
|
||||
public required IReadOnlyDictionary<uint, int[]> CellClipSlots { get; init; }
|
||||
|
||||
public required int OutdoorSlot { get; init; }
|
||||
public required bool OutdoorVisible { get; init; }
|
||||
|
||||
/// <summary>The 3-bucket entity split (<see cref="InteriorEntityPartition"/>). Only ByCell +
|
||||
/// LiveDynamic are used here; Outdoor scenery is drawn by the caller's landscape-through-door
|
||||
/// step (clipped to OutsideView).</summary>
|
||||
|
|
@ -34,12 +41,11 @@ public sealed class InteriorRenderContext
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// The per-cell interior render flood — a faithful port of retail PView::DrawCells' per-cell loops
|
||||
/// (decomp 0x5a4840). Iterates the visible cells closest-first; per cell draws the closed shell +
|
||||
/// that cell's static objects (portal-clipped via the clip routing the caller installed), then the
|
||||
/// live-dynamics unclipped, then the transparent shells. The landscape-through-door (sky/terrain/
|
||||
/// scenery) + the conditional Z-clear are the caller's responsibility, run BEFORE this. GL state is
|
||||
/// self-contained inside each renderer (EnvCellRenderer / WbDrawDispatcher set their own).
|
||||
/// The interior render flood, matching retail PView::DrawCells @ 0x005a4840:
|
||||
/// after the caller handles outside_view terrain + the depth-only clear, DrawCells
|
||||
/// walks cell_draw_list from the end back to zero in separate stages: cell shells,
|
||||
/// then each cell's object_list. The transparent shell pass is split out because
|
||||
/// the modern renderer batches opaque/transparent surfaces separately.
|
||||
/// </summary>
|
||||
public sealed class InteriorRenderer
|
||||
{
|
||||
|
|
@ -48,7 +54,6 @@ public sealed class InteriorRenderer
|
|||
|
||||
// Reused single-cell filter set — cleared + repopulated per cell to avoid per-frame allocs.
|
||||
private readonly HashSet<uint> _oneCell = new(1);
|
||||
|
||||
public InteriorRenderer(EnvCellRenderer envCells, WbDrawDispatcher entities)
|
||||
{
|
||||
_envCells = envCells;
|
||||
|
|
@ -57,54 +62,103 @@ public sealed class InteriorRenderer
|
|||
|
||||
public void DrawInside(InteriorRenderContext ctx)
|
||||
{
|
||||
// Loop A — per-cell OPAQUE shell + that cell's static objects (closest-first).
|
||||
foreach (uint cellId in ctx.OrderedVisibleCells)
|
||||
// Retail Loop 2: DrawEnvCell for each drawable cell, farthest-to-nearest
|
||||
// (cell_draw_list[cell_draw_num - 1] down to 0).
|
||||
for (int i = ctx.OrderedVisibleCells.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (!ctx.DrawableCells.Contains(cellId)) continue; // no clip slot ⇒ assembler culled it
|
||||
uint cellId = ctx.OrderedVisibleCells[i];
|
||||
if (!TryBeginCell(ctx, cellId, out _)) continue;
|
||||
_oneCell.Clear();
|
||||
_oneCell.Add(cellId);
|
||||
ApplyMembershipOnlyRouting();
|
||||
_envCells.Render(WbRenderPass.Opaque, _oneCell);
|
||||
|
||||
if (ctx.Partition.ByCell.TryGetValue(cellId, out var cellEntities) && cellEntities.Count > 0)
|
||||
DrawEntityBucket(ctx, cellEntities, visibleCellIds: _oneCell);
|
||||
}
|
||||
|
||||
// Live-dynamics (player / NPCs): unclipped (serverGuid != 0 → clip slot 0), depth-tested.
|
||||
// Drawn AFTER opaque shells so wall depth occludes them correctly.
|
||||
if (ctx.Partition.LiveDynamic.Count > 0)
|
||||
DrawEntityBucket(ctx, ctx.Partition.LiveDynamic, visibleCellIds: null);
|
||||
|
||||
// Loop B — per-cell TRANSPARENT shells (stained glass / additive cell surfaces).
|
||||
foreach (uint cellId in ctx.OrderedVisibleCells)
|
||||
// Retail Loop 3: Render::PortalList = cell->portal_view; DrawObjCellForDummies(cell).
|
||||
for (int i = ctx.OrderedVisibleCells.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (!ctx.DrawableCells.Contains(cellId)) continue;
|
||||
uint cellId = ctx.OrderedVisibleCells[i];
|
||||
if (!TryBeginCell(ctx, cellId, out _)) continue;
|
||||
_oneCell.Clear();
|
||||
_oneCell.Add(cellId);
|
||||
if (ctx.Partition.ByCell.TryGetValue(cellId, out var cellEntities) && cellEntities.Count > 0)
|
||||
{
|
||||
ApplyMembershipOnlyRouting();
|
||||
DrawEntityBucket(ctx, cellEntities, visibleCellIds: _oneCell);
|
||||
}
|
||||
}
|
||||
|
||||
// Modern split of DrawEnvCell's transparent/additive batches, same reverse cell order.
|
||||
for (int i = ctx.OrderedVisibleCells.Count - 1; i >= 0; i--)
|
||||
{
|
||||
uint cellId = ctx.OrderedVisibleCells[i];
|
||||
if (!TryBeginCell(ctx, cellId, out _)) continue;
|
||||
_oneCell.Clear();
|
||||
_oneCell.Add(cellId);
|
||||
ApplyMembershipOnlyRouting();
|
||||
_envCells.Render(WbRenderPass.Transparent, _oneCell);
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryBeginCell(InteriorRenderContext ctx, uint cellId, out int[] slots)
|
||||
{
|
||||
if (ctx.DrawableCells.Contains(cellId))
|
||||
{
|
||||
ctx.CellClipSlots.TryGetValue(cellId, out slots!);
|
||||
slots ??= System.Array.Empty<int>();
|
||||
return true;
|
||||
}
|
||||
|
||||
slots = System.Array.Empty<int>();
|
||||
return false;
|
||||
}
|
||||
|
||||
private void ApplyMembershipOnlyRouting()
|
||||
{
|
||||
// PView membership controls which cell shell/object bucket is visited.
|
||||
// Do not turn the 2D portal view into gl_ClipDistance for indoor meshes:
|
||||
// that slices avatars and shell triangles at stairs/doorways instead of
|
||||
// matching retail's DrawMesh view-check-then-draw behavior.
|
||||
_envCells.SetClipRouting(null);
|
||||
_entities.ClearClipRouting();
|
||||
}
|
||||
|
||||
// Draws one bucket of entities via the existing dispatcher, scoped to a synthetic single-entry
|
||||
// landblock list. visibleCellIds gates which entities pass the cell-membership walk (a single-cell
|
||||
// set for per-cell statics; null for live-dynamics — they pass the gate and resolve to slot 0).
|
||||
// set for per-cell objects; null only for fallback/outdoor buckets where clip-slot routing owns cull).
|
||||
// The clip slot per entity comes from the SetClipRouting the caller installed (cellIdToSlot +
|
||||
// outdoorSlot + outdoorVisible) via ResolveEntitySlot.
|
||||
private void DrawEntityBucket(
|
||||
InteriorRenderContext ctx, IReadOnlyList<WorldEntity> bucket, HashSet<uint>? visibleCellIds)
|
||||
=> DrawEntityBucket(
|
||||
ctx.Camera,
|
||||
ctx.Frustum,
|
||||
ctx.PlayerLandblockId,
|
||||
ctx.AnimatedEntityIds,
|
||||
bucket,
|
||||
visibleCellIds);
|
||||
|
||||
public void DrawEntityBucket(
|
||||
ICamera camera,
|
||||
FrustumPlanes? frustum,
|
||||
uint? playerLandblockId,
|
||||
HashSet<uint>? animatedEntityIds,
|
||||
IReadOnlyList<WorldEntity> bucket,
|
||||
HashSet<uint>? visibleCellIds)
|
||||
{
|
||||
// LandblockId == neverCullLandblockId (PlayerLandblockId) ⇒ the degenerate (zero) AABB is
|
||||
// never landblock-frustum-culled; per-entity AABB culling inside Draw still applies.
|
||||
uint lbId = ctx.PlayerLandblockId ?? 0u;
|
||||
uint lbId = playerLandblockId ?? 0u;
|
||||
var entry = (lbId, Vector3.Zero, Vector3.Zero,
|
||||
(IReadOnlyList<WorldEntity>)bucket,
|
||||
(IReadOnlyDictionary<uint, WorldEntity>?)null);
|
||||
|
||||
_entities.Draw(
|
||||
ctx.Camera,
|
||||
camera,
|
||||
new[] { entry },
|
||||
ctx.Frustum,
|
||||
neverCullLandblockId: ctx.PlayerLandblockId,
|
||||
frustum,
|
||||
neverCullLandblockId: playerLandblockId,
|
||||
visibleCellIds: visibleCellIds,
|
||||
animatedEntityIds: ctx.AnimatedEntityIds);
|
||||
animatedEntityIds: animatedEntityIds);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue