// 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; /// /// How the terrain (single OutsideView region) should be drawn this frame. /// public enum TerrainClipMode { /// OutsideView reduced to convex planes — terrain gated via the UBO /// ( already applied by the assembler). Planes, /// OutsideView exceeded the convex budget — the call site sets a /// glScissor to around ONLY /// the terrain draw; the UBO is left at count 0 (ungated). Scissor, /// 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. Skip, } /// /// Result of : the populated /// (CPU bytes ready; caller does UploadShared) plus /// the per-instance routing data the renderers + the terrain draw consume. /// public sealed class ClipFrameAssembly { /// The per-frame clip data. Caller uploads it via /// then hands its /// / to the /// renderers. public required ClipFrame Frame { get; init; } /// 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. public required Dictionary CellIdToSlot { get; init; } /// Slot for outdoor scenery / building-shell instances (ParentCellId /// == null) while the camera is indoors. Meaningful only when /// is true. 0 ⇒ no-clip (scissor fallback or trivial). public required int OutdoorSlot { get; init; } /// False ⇒ the OutsideView is empty; outdoor scenery / shells are /// CULLED this frame (camera sees no outdoors through any portal chain). public required bool OutdoorVisible { get; init; } /// How to draw terrain (planes already applied to the UBO / scissor / /// skip). See . public required TerrainClipMode TerrainMode { get; init; } /// NDC AABB (minX,minY,maxX,maxY) for the terrain glScissor when /// is . Unused otherwise. public required Vector4 TerrainScissorNdcAabb { get; init; } // ---- Probe data (ACDREAM_PROBE_VIS / RenderingDiagnostics.EmitVis) -------- /// Plane count the OutsideView reduced to (0 ⇒ scissor or empty). public required int OutsidePlaneCount { get; init; } /// Per-cell clip-plane count (cell id → plane count) for the probe. /// A scissor-fallback cell records 0 here (it maps to slot 0). public required Dictionary PerCellPlaneCounts { get; init; } /// Number of regions (cells + OutsideView) that fell back to a scissor /// AABB → no-clip this frame. public required int ScissorFallbacks { get; init; } } /// /// Builds a from a . /// Pure CPU; no GL. The single entry point implements the U.4 /// slot/gate policy (file header). /// public static class ClipFrameAssembler { /// /// Assemble the per-frame clip data + routing from a portal-visibility frame /// INTO an existing — the long-lived GameWindow frame is /// -and-repacked here every frame so its GL buffers /// are reused (no per-frame buffer churn). The returned assembly's /// is the same instance passed in. /// 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(); var perCellPlaneCounts = new Dictionary(); 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++; } return new ClipFrameAssembly { Frame = frame, CellIdToSlot = cellIdToSlot, OutdoorSlot = outdoorSlot, OutdoorVisible = outdoorVisible, TerrainMode = terrainMode, TerrainScissorNdcAabb = terrainScissor, 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; } }