feat(render): Phase U.4 — unified gated draw pass (indoor root)

Wire the portal-visibility result through the clip pipeline: build a per-frame
ClipFrame (slot 0 no-clip, slot 1 OutsideView, slot 2..N per visible cell) +
cellIdToSlot from PortalVisibilityBuilder; call the (previously dormant)
EnvCellRenderer.Render for cell shells inside the clip bracket; assign per-instance
clip slots in WbDrawDispatcher (live-dynamic unclipped per retail, cell statics to
their cell slot, outdoor scenery to OutsideView, non-visible culled); gate/scissor/
skip terrain per OutsideView (empty ⇒ no terrain — the bleed fix). Emit ACDREAM_PROBE_VIS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-30 17:59:21 +02:00
parent 864fc5f94e
commit 7993e064a0
6 changed files with 748 additions and 67 deletions

View file

@ -7130,43 +7130,12 @@ public sealed class GameWindow : IDisposable
var visibility = _cellVisibility.ComputeVisibility(camPos);
bool cameraInsideCell = visibility?.CameraCell is not null;
// SPIKE 2026-05-26: A8 transition investigation. Lights up the
// dormant RenderingDiagnostics.ProbeVisibilityEnabled flag (added
// by Task 6 of the original A8 plan). Per-frame state captures:
// camera position, lenient + strict inside flags side-by-side,
// CameraCell id, VisibleCellIds list. Branch markers inside
// indoor + outdoor branches complete the trace.
// Enable via ACDREAM_PROBE_VIS=1.
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeVisibilityEnabled)
{
bool reallyInsideProbe = visibility?.CameraCell is not null
&& CellVisibility.PointInCell(camPos, visibility.CameraCell);
int visCount = visibility?.VisibleCellIds?.Count ?? 0;
string visList;
if (visibility?.VisibleCellIds is null || visCount == 0)
{
visList = "[]";
}
else
{
var sb = new System.Text.StringBuilder("[");
int shown = 0;
foreach (var id in visibility.VisibleCellIds)
{
if (shown >= 8) { sb.Append(",..."); break; }
if (shown > 0) sb.Append(',');
sb.Append($"0x{id:X8}");
shown++;
}
sb.Append(']');
visList = sb.ToString();
}
string cellId = visibility?.CameraCell?.CellId.ToString("X8") ?? "null";
Console.WriteLine(
$"[vis] pos=({camPos.X:F2},{camPos.Y:F2},{camPos.Z:F2}) " +
$"inside={cameraInsideCell} really={reallyInsideProbe} " +
$"cell=0x{cellId} visN={visCount} {visList}");
}
// Phase U.4 (2026-05-30): the [vis] probe moved DOWN to the unified
// gated-draw block (after envCellViewProj exists) where it can report
// the real PortalVisibilityFrame — OutsideView polygon/plane counts and
// per-cell slot plane counts — via RenderingDiagnostics.EmitVis, instead
// of the old camera-state-only spike. See the U.4 ClipFrame assembly
// below (gated on ACDREAM_PROBE_VIS=1, cell-change-throttled).
// Lighting decisions (sun zeroed, indoor ambient applied) must
// track the PLAYER's cell, not the camera's. In third-person
@ -7281,16 +7250,70 @@ public sealed class GameWindow : IDisposable
goto SkipWorldGeometry;
}
// Phase U.3: build + upload the SHARED per-frame clip data once,
// ahead of both terrain and entity draws. In U.3 this is the no-clip
// frame (slot 0 only, terrain count 0) so the whole scene renders
// ungated — bit-identical to pre-U.3. UploadShared binds binding=2
// (mesh SSBO) + binding=2 (terrain UBO); each renderer below re-binds
// its binding=2 defensively from the ids we hand it. The single
// _clipFrame instance reuses its GL buffers across frames (NoClip is
// cheap CPU-only state we copy into it). U.4 swaps NoClip() for the
// real portal-visibility frame here.
// Phase U.4: build the SHARED per-frame clip data from the portal-
// visibility result, ahead of both terrain and entity draws.
//
// Root: a non-null CameraCell means the camera is INSIDE a cell (indoor
// root) — run the portal-frame BFS (PortalVisibilityBuilder) and assemble
// a real ClipFrame (slot 0 no-clip, slot 1.. per visible cell + the
// OutsideView) + a cellId→slot map. A null CameraCell is the OUTDOOR root:
// no pvFrame, the frame stays no-clip, every instance is slot 0 and terrain
// draws normally — bit-identical to U.3 (outdoor→building peering is U.5).
//
// The single _clipFrame instance is RESET + repacked in place each frame
// (ClipFrameAssembler.Assemble → ClipFrame.Reset) so its SSBO/UBO ids are
// reused — no per-frame GL buffer churn. UploadShared binds binding=2
// (mesh SSBO) + binding=2 (terrain UBO); each renderer re-binds its
// binding=2 defensively from the ids we hand it.
_clipFrame ??= ClipFrame.NoClip();
var clipRoot = visibility?.CameraCell;
ClipFrameAssembly? clipAssembly = null;
var terrainClipMode = TerrainClipMode.Planes; // overwritten below for indoor root
System.Numerics.Vector4 terrainScissorNdc = default;
HashSet<uint>? envCellShellFilter = null; // drawable visible cells (cellIdToSlot keys)
if (clipRoot is not null)
{
var pvFrame = PortalVisibilityBuilder.Build(
clipRoot,
camPos,
id => _cellVisibility.TryGetCell(id, out var c) ? c : null,
envCellViewProj);
clipAssembly = ClipFrameAssembler.Assemble(_clipFrame, pvFrame);
terrainClipMode = clipAssembly.TerrainMode;
terrainScissorNdc = clipAssembly.TerrainScissorNdcAabb;
// Per-instance routing for the entity dispatcher + the cell shells.
_wbDrawDispatcher?.SetClipRouting(
clipAssembly.CellIdToSlot, clipAssembly.OutdoorSlot, clipAssembly.OutdoorVisible);
_envCellRenderer?.SetClipRouting(clipAssembly.CellIdToSlot);
// The cell SHELLS render only for drawable visible cells (the slot
// map's keys; IsNothingVisible cells were excluded by the assembler).
envCellShellFilter = new HashSet<uint>(clipAssembly.CellIdToSlot.Keys);
// [vis] probe (ACDREAM_PROBE_VIS=1) — the real PortalVisibilityFrame
// numbers, replacing the old camera-state-only spike. Cell-change
// throttled inside EmitVis so launch.log stays readable under motion.
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeVisibilityEnabled)
AcDream.Core.Rendering.RenderingDiagnostics.EmitVis(
clipRoot.CellId,
pvFrame.OrderedVisibleCells,
pvFrame.OutsideView.Polygons.Count,
clipAssembly.OutsidePlaneCount,
clipAssembly.PerCellPlaneCounts,
clipAssembly.ScissorFallbacks);
}
else
{
// Outdoor root: no portal frame. Keep the frame no-clip and revert the
// renderers to U.3 behavior (every instance slot 0, nothing culled,
// terrain ungated). Reset so a prior indoor frame's slots don't leak.
_clipFrame.Reset();
_wbDrawDispatcher?.ClearClipRouting();
_envCellRenderer?.SetClipRouting(null);
}
_clipFrame.UploadShared(_gl);
_wbDrawDispatcher?.SetClipRegionSsbo(_clipFrame.RegionSsbo);
_envCellRenderer?.SetClipRegionSsbo(_clipFrame.RegionSsbo);
@ -7312,8 +7335,45 @@ public sealed class GameWindow : IDisposable
// Phase N.5b: wrap Draw in CPU stopwatch for [TERRAIN-DIAG] rollup
// (gated on ACDREAM_WB_DIAG=1, same env var as [WB-DIAG]). Stopwatch
// is cheap; only the periodic Console.WriteLine is gated.
//
// Phase U.4 OutsideView gating (indoor root only; outdoor root uses
// TerrainClipMode.Planes with a count-0 UBO = ungated, the U.3 path):
// Skip ⇒ the camera sees no outdoors through any portal chain →
// draw NO terrain. THIS is the bleed fix (empty OutsideView
// ⇒ outdoor terrain stops leaking into interiors).
// Scissor ⇒ OutsideView exceeded the convex-plane budget → glScissor
// around ONLY the terrain draw (NDC AABB → framebuffer px),
// UBO left ungated. Disabled again immediately after so the
// rest of the frame is unscissored.
// Planes ⇒ UBO carries the OutsideView planes (already set by the
// assembler) → terrain gated per-vertex, draw normally.
_terrainCpuStopwatch.Restart();
_terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb);
if (terrainClipMode == TerrainClipMode.Skip)
{
// No terrain this frame — bleed fix.
}
else if (terrainClipMode == TerrainClipMode.Scissor)
{
var fb = _window!.FramebufferSize;
// NDC [-1,1] → window pixels. Clamp to the framebuffer so a portal
// opening that extends past the screen edge yields a valid box.
float nx0 = System.Math.Clamp(terrainScissorNdc.X, -1f, 1f);
float ny0 = System.Math.Clamp(terrainScissorNdc.Y, -1f, 1f);
float nx1 = System.Math.Clamp(terrainScissorNdc.Z, -1f, 1f);
float ny1 = System.Math.Clamp(terrainScissorNdc.W, -1f, 1f);
int px = (int)System.MathF.Floor((nx0 * 0.5f + 0.5f) * fb.X);
int py = (int)System.MathF.Floor((ny0 * 0.5f + 0.5f) * fb.Y);
int pw = (int)System.MathF.Ceiling((nx1 - nx0) * 0.5f * fb.X);
int ph = (int)System.MathF.Ceiling((ny1 - ny0) * 0.5f * fb.Y);
_gl.Enable(EnableCap.ScissorTest);
_gl.Scissor(px, py, (uint)System.Math.Max(0, pw), (uint)System.Math.Max(0, ph));
_terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb);
_gl.Disable(EnableCap.ScissorTest);
}
else
{
_terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb);
}
_terrainCpuStopwatch.Stop();
// Multiply by 100 then divide by 100 in the diag print to keep
// 0.01 µs precision in the long-typed sample buffer. Terrain Draw
@ -7339,20 +7399,36 @@ public sealed class GameWindow : IDisposable
animatedIds.Add(k);
}
// Phase U.4: render the indoor cell SHELLS (walls / floors / ceilings)
// — previously DORMANT (EnvCellRenderer.Render was never called in the
// live loop). Inside the clip bracket so each cell's instances are gated
// to its CellClip slot via the binding=3 map we installed above. Opaque
// pass BEFORE the entity dispatcher (front-to-back, depth writes on);
// Transparent pass AFTER. Filter = the drawable visible cells. Only when
// there's an indoor root (clipAssembly != null) — outdoor frames draw no
// shells. PrepareRenderBatches already ran earlier this frame.
if (clipAssembly is not null && envCellShellFilter is not null)
_envCellRenderer?.Render(AcDream.App.Rendering.Wb.WbRenderPass.Opaque, envCellShellFilter);
// Scene entity draw. N.5: WbDrawDispatcher is always non-null
// (modern path mandatory). Default EntitySet.All — every entity
// walked, gated only by the ParentCellId ∈ visibleCellIds filter.
// Phase U: unified gated draw wired in U.4a
// Phase U.4: per-instance clip slots come from SetClipRouting above
// (indoor root) or ClearClipRouting (outdoor root → every instance slot 0).
_wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum,
neverCullLandblockId: playerLb,
visibleCellIds: visibility?.VisibleCellIds,
animatedEntityIds: animatedIds);
// Phase U.4: cell shells transparent pass (additive / alpha-blend cell
// surfaces, e.g. stained glass). Still inside the clip bracket.
if (clipAssembly is not null && envCellShellFilter is not null)
_envCellRenderer?.Render(AcDream.App.Rendering.Wb.WbRenderPass.Transparent, envCellShellFilter);
// Phase U.3: close the world-geometry clip bracket opened above. From
// here down (particles, weather, debug lines, UI) the vertex shaders do
// NOT write gl_ClipDistance, so the planes must be OFF to avoid the
// undefined-behavior clip. U.4's EnvCellRenderer.Render, when added,
// belongs ABOVE this line (it writes gl_ClipDistance like the others).
// undefined-behavior clip.
for (int _cp = 0; _cp < ClipFrame.MaxPlanes; _cp++)
_gl.Disable(EnableCap.ClipDistance0 + _cp);