acdream/src/AcDream.App/Rendering/ClipFrameAssembler.cs
Erik ce2edad66a feat(render): Phase W Stage 4 — sky/weather portal-clip seal (LScape through the doorway)
The sky + weather (rain cylinder) are retail's LScape — 'the outside seen through the exit portal.' Retail PView::DrawCells (pseudo_c:432709) draws LScape clipped to the OutsideView when outside_view.view_count>0, then does a conditional Z-buffer-ONLY clear (432731) before the indoor cells. acdream now does the same:

- sky.vert writes gl_ClipDistance against the SAME binding=2 TerrainClip UBO the terrain reads. The OutsideView planes are screen-space (NDC) half-spaces encoded as clip-space planes (nx,ny,0,dw); the test dot(plane,gl_Position)>=0 reduces after perspective divide to nx*ndcX+ny*ndcY+dw>=0 — projection-INDEPENDENT — so the same plane set clips the sky EXACTLY despite its separate dome projection. count==0 (outdoor) → all distances +1 → full-screen, bit-identical. Lighting/fog math untouched.

- GameWindow: relocated the sky pre-scene + weather post-scene draws to their retail LScape positions, each in a local 8-plane clip bracket so sky.vert confines them to the doorway indoors / full-screen outdoors. Added the conditional doorway depth-ONLY Z-clear (no color → no blue hole), scissored to the OutsideView AABB. drawSkyThisFrame = seen_outside policy AND (outdoor OR exit-portal-in-view) — a sealed interior with no exit portal in view draws no sky (kills the full-screen-sky interim regression). Sky pre/post particle passes (particle.vert has no gl_ClipDistance) scissored to the doorway bbox.

- ClipFrameAssembly gains HasOutsideView + OutsideViewNdcAabb (the doorway NDC AABB, computed for BOTH Planes and Scissor terrain modes — unlike TerrainScissorNdcAabb which is Scissor-only).

- The pre-login goto SkipWorldGeometry moved BELOW the sky draw so the live sky still renders during the EnterWorld handshake (clipAssembly is null/no-clip pre-login → full-screen).

Build green; App tests 160/160. Stage 4 tests + verify-annotations follow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 16:15:08 +02:00

251 lines
12 KiB
C#

// 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.
//
// 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.
//
// === 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.
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.App.Rendering;
/// <summary>
/// How the terrain (single OutsideView region) should be drawn this frame.
/// </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>
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>
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>
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.
/// </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>
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>
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>
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
var cellIdToSlot = new Dictionary<uint, int>();
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;
}
if (cps.Count > 0)
{
int slot = frame.AppendSlot(cps);
cellIdToSlot[cellId] = slot;
perCellPlaneCounts[cellId] = cps.Count;
}
else // UseScissorFallback (Count == 0, not nothing-visible)
{
// 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;
scissorFallbacks++;
}
}
// ── OutsideView ──────────────────────────────────────────────────────
var ov = ClipPlaneSet.From(pvFrame.OutsideView);
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)
? new Vector4(pvFrame.OutsideView.MinX, pvFrame.OutsideView.MinY,
pvFrame.OutsideView.MaxX, pvFrame.OutsideView.MaxY)
: Vector4.Zero;
return new ClipFrameAssembly
{
Frame = frame,
CellIdToSlot = cellIdToSlot,
OutdoorSlot = outdoorSlot,
OutdoorVisible = outdoorVisible,
TerrainMode = terrainMode,
TerrainScissorNdcAabb = terrainScissor,
HasOutsideView = hasOutsideView,
OutsideViewNdcAabb = outsideViewNdcAabb,
OutsidePlaneCount = ov.Count,
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 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];
return planes;
}
}