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
|
|
@ -1,251 +1,224 @@
|
|||
// ClipFrameAssembler.cs
|
||||
//
|
||||
// Phase U.4: assemble a per-frame ClipFrame (the GPU-side shared clip data) +
|
||||
// a cellId→slot map from a PortalVisibilityFrame. This is the CPU policy that
|
||||
// turns the portal-visibility BFS result into the slot indices the mesh shader
|
||||
// (binding=2 CellClip + binding=3 per-instance slot) and the terrain UBO read.
|
||||
// Retail PView assembly policy. PortalVisibilityBuilder produces a retail-like
|
||||
// view graph: one portal_view list per visible cell plus an outside_view list.
|
||||
// This assembler packs each visible polygon as an individual GPU clip slot so
|
||||
// the renderer can draw the exact PView order:
|
||||
//
|
||||
// GL-free: ClipFrame's CPU byte-packing (AppendSlot / SetTerrainClip) runs here;
|
||||
// the GL upload (ClipFrame.UploadShared) happens at the call site. That keeps the
|
||||
// whole slot/gate policy unit-testable without a GPU context — see
|
||||
// ClipFrameAssemblerTests.
|
||||
// outside_view landscape slices
|
||||
// reverse cell_draw_list exit masks
|
||||
// reverse cell_draw_list EnvCell shells
|
||||
// reverse cell_draw_list object lists
|
||||
//
|
||||
// === The slot/gate policy (implemented EXACTLY as the U.4 spec dictates) ======
|
||||
// slot 0 = no-clip (count 0). ALWAYS present (ClipFrame.NoClip seeds it).
|
||||
//
|
||||
// Per visible interior cell (PortalVisibilityFrame.OrderedVisibleCells):
|
||||
// ClipPlaneSet.From(CellView) has THREE Count==0 meanings (see ClipPlaneSet):
|
||||
// • IsNothingVisible ⇒ DO NOT map the cell. Its instances/shell won't draw
|
||||
// (the cull is deliberate — retail culls it too).
|
||||
// • Count > 0 ⇒ append a real planes slot; cellIdToSlot[cell] = slot.
|
||||
// • UseScissorFallback⇒ cellIdToSlot[cell] = 0 (no-clip / over-include).
|
||||
// Per-cell glScissor would break MDI batching, and
|
||||
// over-inclusion is the SAFE direction; counted in
|
||||
// ScissorFallbacks for the probe.
|
||||
//
|
||||
// OutsideView feeds TWO consumers:
|
||||
// • mesh "outdoor slot" (outdoor scenery / building shells drawn while the
|
||||
// camera is indoors): Count>0 ⇒ planes slot (OutdoorSlot); scissor ⇒ slot 0
|
||||
// (no-clip, counted); IsNothingVisible ⇒ OutdoorVisible=false (CULL these
|
||||
// instances — the camera can't see outdoors through any portal chain).
|
||||
// • terrain UBO: Count>0 ⇒ SetTerrainClip(planes); scissor ⇒ TerrainScissor
|
||||
// (the call site sets glScissor around ONLY the terrain draw) + UBO count 0;
|
||||
// IsNothingVisible ⇒ SKIP the terrain draw entirely (THIS is the bleed fix).
|
||||
//
|
||||
// Outdoor root (pvFrame == null) is handled by the caller, not here: terrain
|
||||
// draws normally (UBO count 0, no scissor), every instance is slot 0. The caller
|
||||
// only invokes Assemble when there IS an indoor root.
|
||||
// Slot 0 is always no-clip. A slice whose polygon cannot be represented by the
|
||||
// <=8 plane budget uses slot 0 and its NDC AABB; the renderer uses scissor for
|
||||
// passes that need that fallback. Empty regions are omitted entirely.
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// How the terrain (single OutsideView region) should be drawn this frame.
|
||||
/// How the landscape-through-outside_view pass should be interpreted.
|
||||
/// </summary>
|
||||
public enum TerrainClipMode
|
||||
{
|
||||
/// <summary>OutsideView reduced to convex planes — terrain gated via the UBO
|
||||
/// (<see cref="ClipFrame.SetTerrainClip"/> already applied by the assembler).</summary>
|
||||
/// <summary>All outside_view slices have convex plane clips.</summary>
|
||||
Planes,
|
||||
|
||||
/// <summary>OutsideView exceeded the convex budget — the call site sets a
|
||||
/// glScissor to <see cref="ClipFrameAssembly.TerrainScissorNdcAabb"/> around ONLY
|
||||
/// the terrain draw; the UBO is left at count 0 (ungated).</summary>
|
||||
/// <summary>At least one outside_view slice requires scissor fallback.</summary>
|
||||
Scissor,
|
||||
|
||||
/// <summary>OutsideView is empty (no exit portal visible through any chain) —
|
||||
/// the call site SKIPS the terrain draw entirely. This is the bleed fix: an
|
||||
/// interior with no view outdoors draws no terrain.</summary>
|
||||
/// <summary>No outside_view slice is visible; skip landscape indoors.</summary>
|
||||
Skip,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of <see cref="ClipFrameAssembler.Assemble"/>: the populated
|
||||
/// <see cref="ClipFrame"/> (CPU bytes ready; caller does <c>UploadShared</c>) plus
|
||||
/// the per-instance routing data the renderers + the terrain draw consume.
|
||||
/// One retail portal_view slice mapped to a GPU clip slot. The AABB is retained
|
||||
/// for passes that cannot write gl_ClipDistance and must use scissor.
|
||||
/// </summary>
|
||||
public readonly record struct ClipViewSlice(int Slot, Vector4 NdcAabb, Vector4[] Planes);
|
||||
|
||||
/// <summary>
|
||||
/// Result of <see cref="ClipFrameAssembler.Assemble"/>: populated clip buffers
|
||||
/// plus routing data consumed by the render orchestration.
|
||||
/// </summary>
|
||||
public sealed class ClipFrameAssembly
|
||||
{
|
||||
/// <summary>The per-frame clip data. Caller uploads it via
|
||||
/// <see cref="ClipFrame.UploadShared"/> then hands its
|
||||
/// <see cref="ClipFrame.RegionSsbo"/> / <see cref="ClipFrame.TerrainUbo"/> to the
|
||||
/// renderers.</summary>
|
||||
public required ClipFrame Frame { get; init; }
|
||||
|
||||
/// <summary>Maps a visible cell id to its CellClip slot index. A cell that is
|
||||
/// NOT a key (IsNothingVisible, or never visible) must NOT be drawn — its mesh
|
||||
/// instances / shell are culled. A scissor-fallback cell maps to slot 0.</summary>
|
||||
/// <summary>First drawable slice slot per visible cell. Compatibility map
|
||||
/// for renderer APIs that can accept only one slot at a time.</summary>
|
||||
public required Dictionary<uint, int> CellIdToSlot { get; init; }
|
||||
|
||||
/// <summary>Slot for outdoor scenery / building-shell instances (ParentCellId
|
||||
/// == null) while the camera is indoors. Meaningful only when
|
||||
/// <see cref="OutdoorVisible"/> is true. 0 ⇒ no-clip (scissor fallback or trivial).</summary>
|
||||
/// <summary>Slot-only cell slices, retained for older renderer APIs.</summary>
|
||||
public required Dictionary<uint, int[]> CellIdToViewSlots { get; init; }
|
||||
|
||||
/// <summary>Full retail portal_view slices per visible cell.</summary>
|
||||
public required Dictionary<uint, ClipViewSlice[]> CellIdToViewSlices { get; init; }
|
||||
|
||||
/// <summary>Full retail outside_view slices.</summary>
|
||||
public required ClipViewSlice[] OutsideViewSlices { get; init; }
|
||||
|
||||
public required int OutdoorSlot { get; init; }
|
||||
|
||||
/// <summary>False ⇒ the OutsideView is empty; outdoor scenery / shells are
|
||||
/// CULLED this frame (camera sees no outdoors through any portal chain).</summary>
|
||||
public required bool OutdoorVisible { get; init; }
|
||||
|
||||
/// <summary>How to draw terrain (planes already applied to the UBO / scissor /
|
||||
/// skip). See <see cref="TerrainClipMode"/>.</summary>
|
||||
public required TerrainClipMode TerrainMode { get; init; }
|
||||
|
||||
/// <summary>NDC AABB (minX,minY,maxX,maxY) for the terrain glScissor when
|
||||
/// <see cref="TerrainMode"/> is <see cref="TerrainClipMode.Scissor"/>. Unused otherwise.</summary>
|
||||
public required Vector4 TerrainScissorNdcAabb { get; init; }
|
||||
|
||||
/// <summary>True ⇒ the OutsideView (the exit-portal screen region) is meaningfully visible this
|
||||
/// frame — the camera can see outdoors through a portal chain (<see cref="TerrainMode"/> is
|
||||
/// <see cref="TerrainClipMode.Planes"/> or <see cref="TerrainClipMode.Scissor"/>). False ⇒ a
|
||||
/// sealed interior with no exit portal in view (<see cref="TerrainClipMode.Skip"/>). Drives the
|
||||
/// Stage 4 sky/weather draw + the conditional doorway Z-clear. Always false on the outdoor root
|
||||
/// (the caller does not invoke <see cref="ClipFrameAssembler.Assemble"/> there).</summary>
|
||||
public required bool HasOutsideView { get; init; }
|
||||
|
||||
/// <summary>NDC AABB (minX,minY,maxX,maxY) of the OutsideView screen region — the doorway
|
||||
/// opening's bounding box. Computed whenever <see cref="HasOutsideView"/> is true, for BOTH the
|
||||
/// Planes and Scissor terrain modes (unlike <see cref="TerrainScissorNdcAabb"/>, which is valid
|
||||
/// only in Scissor mode). Stage 4 scissors the conditional doorway depth-only Z-clear (retail
|
||||
/// PView::DrawCells:432731) and the sky/weather particle passes to this region. Degenerate
|
||||
/// (<see cref="Vector4.Zero"/>) when <see cref="HasOutsideView"/> is false.</summary>
|
||||
public required Vector4 OutsideViewNdcAabb { get; init; }
|
||||
|
||||
// ---- Probe data (ACDREAM_PROBE_VIS / RenderingDiagnostics.EmitVis) --------
|
||||
|
||||
/// <summary>Plane count the OutsideView reduced to (0 ⇒ scissor or empty).</summary>
|
||||
// Probe data.
|
||||
public required int OutsidePlaneCount { get; init; }
|
||||
|
||||
/// <summary>Per-cell clip-plane count (cell id → plane count) for the probe.
|
||||
/// A scissor-fallback cell records 0 here (it maps to slot 0).</summary>
|
||||
public required Dictionary<uint, int> PerCellPlaneCounts { get; init; }
|
||||
|
||||
/// <summary>Number of regions (cells + OutsideView) that fell back to a scissor
|
||||
/// AABB → no-clip this frame.</summary>
|
||||
public required int ScissorFallbacks { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a <see cref="ClipFrameAssembly"/> from a <see cref="PortalVisibilityFrame"/>.
|
||||
/// Pure CPU; no GL. The single entry point <see cref="Assemble"/> implements the U.4
|
||||
/// slot/gate policy (file header).
|
||||
/// </summary>
|
||||
public static class ClipFrameAssembler
|
||||
{
|
||||
/// <summary>
|
||||
/// Assemble the per-frame clip data + routing from a portal-visibility frame
|
||||
/// INTO an existing <see cref="ClipFrame"/> — the long-lived GameWindow frame is
|
||||
/// <see cref="ClipFrame.Reset"/>-and-repacked here every frame so its GL buffers
|
||||
/// are reused (no per-frame buffer churn). The returned assembly's
|
||||
/// <see cref="ClipFrameAssembly.Frame"/> is the same instance passed in.
|
||||
/// </summary>
|
||||
public static ClipFrameAssembly Assemble(ClipFrame frame, PortalVisibilityFrame pvFrame)
|
||||
{
|
||||
System.ArgumentNullException.ThrowIfNull(frame);
|
||||
System.ArgumentNullException.ThrowIfNull(pvFrame);
|
||||
|
||||
frame.Reset(); // slot 0 = no-clip
|
||||
frame.Reset();
|
||||
|
||||
var cellIdToSlot = new Dictionary<uint, int>();
|
||||
var cellIdToViewSlots = new Dictionary<uint, int[]>();
|
||||
var cellIdToViewSlices = new Dictionary<uint, ClipViewSlice[]>();
|
||||
var perCellPlaneCounts = new Dictionary<uint, int>();
|
||||
int scissorFallbacks = 0;
|
||||
|
||||
// ── Interior cells ───────────────────────────────────────────────────
|
||||
foreach (uint cellId in pvFrame.OrderedVisibleCells)
|
||||
{
|
||||
if (!pvFrame.CellViews.TryGetValue(cellId, out var view))
|
||||
continue; // defensive — OrderedVisibleCells is derived from CellViews
|
||||
|
||||
var cps = ClipPlaneSet.From(view);
|
||||
|
||||
if (cps.IsNothingVisible)
|
||||
{
|
||||
// Cell culled — do NOT map it; its instances/shell won't draw.
|
||||
continue;
|
||||
|
||||
var slices = new List<ClipViewSlice>(view.Polygons.Count);
|
||||
int maxPlaneCount = 0;
|
||||
|
||||
foreach (var poly in view.Polygons)
|
||||
{
|
||||
var cps = ClipPlaneSet.From(ViewOf(poly));
|
||||
if (cps.IsNothingVisible)
|
||||
continue;
|
||||
|
||||
int slot;
|
||||
Vector4[] planes;
|
||||
if (cps.Count > 0)
|
||||
{
|
||||
planes = ToPlaneSpan(cps);
|
||||
slot = frame.AppendSlot(planes);
|
||||
if (cps.Count > maxPlaneCount)
|
||||
maxPlaneCount = cps.Count;
|
||||
}
|
||||
else
|
||||
{
|
||||
planes = System.Array.Empty<Vector4>();
|
||||
slot = 0;
|
||||
scissorFallbacks++;
|
||||
}
|
||||
|
||||
slices.Add(new ClipViewSlice(slot, AabbOf(poly), planes));
|
||||
}
|
||||
|
||||
if (slices.Count == 0)
|
||||
continue;
|
||||
|
||||
var sliceArray = slices.ToArray();
|
||||
cellIdToViewSlices[cellId] = sliceArray;
|
||||
cellIdToViewSlots[cellId] = ToSlots(sliceArray);
|
||||
cellIdToSlot[cellId] = sliceArray[0].Slot;
|
||||
perCellPlaneCounts[cellId] = maxPlaneCount;
|
||||
}
|
||||
|
||||
var outsideSlicesList = new List<ClipViewSlice>(pvFrame.OutsideView.Polygons.Count);
|
||||
int outsideMaxPlaneCount = 0;
|
||||
bool outsideHasScissorFallback = false;
|
||||
|
||||
foreach (var poly in pvFrame.OutsideView.Polygons)
|
||||
{
|
||||
var cps = ClipPlaneSet.From(ViewOf(poly));
|
||||
if (cps.IsNothingVisible)
|
||||
continue;
|
||||
|
||||
int slot;
|
||||
Vector4[] planes;
|
||||
if (cps.Count > 0)
|
||||
{
|
||||
int slot = frame.AppendSlot(cps);
|
||||
cellIdToSlot[cellId] = slot;
|
||||
perCellPlaneCounts[cellId] = cps.Count;
|
||||
planes = ToPlaneSpan(cps);
|
||||
slot = frame.AppendSlot(planes);
|
||||
if (cps.Count > outsideMaxPlaneCount)
|
||||
outsideMaxPlaneCount = cps.Count;
|
||||
}
|
||||
else // UseScissorFallback (Count == 0, not nothing-visible)
|
||||
else
|
||||
{
|
||||
// Over-include via no-clip (slot 0). Per-cell glScissor would break
|
||||
// MDI batching; over-inclusion is the safe direction for M1.5.
|
||||
cellIdToSlot[cellId] = 0;
|
||||
perCellPlaneCounts[cellId] = 0;
|
||||
planes = System.Array.Empty<Vector4>();
|
||||
slot = 0;
|
||||
outsideHasScissorFallback = true;
|
||||
scissorFallbacks++;
|
||||
}
|
||||
|
||||
outsideSlicesList.Add(new ClipViewSlice(slot, AabbOf(poly), planes));
|
||||
}
|
||||
|
||||
// ── OutsideView ──────────────────────────────────────────────────────
|
||||
var ov = ClipPlaneSet.From(pvFrame.OutsideView);
|
||||
var outsideViewSlices = outsideSlicesList.ToArray();
|
||||
bool outdoorVisible = outsideViewSlices.Length > 0;
|
||||
int outdoorSlot = outdoorVisible ? outsideViewSlices[0].Slot : 0;
|
||||
TerrainClipMode terrainMode = !outdoorVisible
|
||||
? TerrainClipMode.Skip
|
||||
: (outsideHasScissorFallback ? TerrainClipMode.Scissor : TerrainClipMode.Planes);
|
||||
|
||||
int outdoorSlot;
|
||||
bool outdoorVisible;
|
||||
TerrainClipMode terrainMode;
|
||||
Vector4 terrainScissor = Vector4.Zero;
|
||||
|
||||
if (ov.IsNothingVisible)
|
||||
{
|
||||
// No outdoors visible through any portal chain.
|
||||
outdoorSlot = 0;
|
||||
outdoorVisible = false; // mesh: CULL outdoor scenery / shells.
|
||||
terrainMode = TerrainClipMode.Skip; // terrain: the bleed fix.
|
||||
}
|
||||
else if (ov.Count > 0)
|
||||
{
|
||||
// Convex planes — gate both the outdoor mesh slot and the terrain UBO.
|
||||
outdoorSlot = frame.AppendSlot(ov);
|
||||
outdoorVisible = true;
|
||||
frame.SetTerrainClip(ToPlaneSpan(ov));
|
||||
terrainMode = TerrainClipMode.Planes;
|
||||
}
|
||||
else // UseScissorFallback
|
||||
{
|
||||
// Mesh: no-clip over-include (slot 0), still visible. Terrain: scissor
|
||||
// around the single terrain batch + UBO ungated (count 0 left as-is).
|
||||
outdoorSlot = 0;
|
||||
outdoorVisible = true;
|
||||
terrainMode = TerrainClipMode.Scissor;
|
||||
terrainScissor = ov.ScissorNdcAabb;
|
||||
scissorFallbacks++;
|
||||
}
|
||||
|
||||
// Stage 4: the doorway screen-space AABB (the OutsideView union bounds), available for
|
||||
// BOTH Planes and Scissor modes — the sky/weather particle scissor + the conditional
|
||||
// doorway Z-clear need it regardless of how the OutsideView reduced to a gate.
|
||||
// TerrainScissorNdcAabb above is only valid in Scissor mode; the OutsideView CellView
|
||||
// always tracks its Min/Max as polygons accumulate, so it is the single source here.
|
||||
bool hasOutsideView = terrainMode != TerrainClipMode.Skip;
|
||||
Vector4 outsideViewNdcAabb = (hasOutsideView && !pvFrame.OutsideView.IsEmpty)
|
||||
Vector4 outsideViewNdcAabb = outdoorVisible
|
||||
? new Vector4(pvFrame.OutsideView.MinX, pvFrame.OutsideView.MinY,
|
||||
pvFrame.OutsideView.MaxX, pvFrame.OutsideView.MaxY)
|
||||
: Vector4.Zero;
|
||||
Vector4 terrainScissor = terrainMode == TerrainClipMode.Scissor
|
||||
? outsideViewNdcAabb
|
||||
: Vector4.Zero;
|
||||
|
||||
return new ClipFrameAssembly
|
||||
{
|
||||
Frame = frame,
|
||||
CellIdToSlot = cellIdToSlot,
|
||||
CellIdToViewSlots = cellIdToViewSlots,
|
||||
CellIdToViewSlices = cellIdToViewSlices,
|
||||
OutsideViewSlices = outsideViewSlices,
|
||||
OutdoorSlot = outdoorSlot,
|
||||
OutdoorVisible = outdoorVisible,
|
||||
TerrainMode = terrainMode,
|
||||
TerrainScissorNdcAabb = terrainScissor,
|
||||
HasOutsideView = hasOutsideView,
|
||||
HasOutsideView = outdoorVisible,
|
||||
OutsideViewNdcAabb = outsideViewNdcAabb,
|
||||
OutsidePlaneCount = ov.Count,
|
||||
OutsidePlaneCount = terrainMode == TerrainClipMode.Planes ? outsideMaxPlaneCount : 0,
|
||||
PerCellPlaneCounts = perCellPlaneCounts,
|
||||
ScissorFallbacks = scissorFallbacks,
|
||||
};
|
||||
}
|
||||
|
||||
// Copy a ClipPlaneSet's planes into a heap array for SetTerrainClip's span
|
||||
// parameter (the set exposes IReadOnlyList, not a contiguous span).
|
||||
private static CellView ViewOf(ViewPolygon poly)
|
||||
{
|
||||
var view = new CellView();
|
||||
view.Add(poly);
|
||||
return view;
|
||||
}
|
||||
|
||||
private static Vector4 AabbOf(ViewPolygon poly) =>
|
||||
new(poly.MinX, poly.MinY, poly.MaxX, poly.MaxY);
|
||||
|
||||
private static int[] ToSlots(ClipViewSlice[] slices)
|
||||
{
|
||||
var slots = new int[slices.Length];
|
||||
for (int i = 0; i < slices.Length; i++)
|
||||
slots[i] = slices[i].Slot;
|
||||
return slots;
|
||||
}
|
||||
|
||||
private static Vector4[] ToPlaneSpan(ClipPlaneSet set)
|
||||
{
|
||||
int n = set.Count;
|
||||
var planes = new Vector4[n];
|
||||
for (int i = 0; i < n; i++) planes[i] = set.Planes[i];
|
||||
for (int i = 0; i < n; i++)
|
||||
planes[i] = set.Planes[i];
|
||||
return planes;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue