diff --git a/src/AcDream.App/Rendering/ClipFrame.cs b/src/AcDream.App/Rendering/ClipFrame.cs index ba2a0cf..75414fe 100644 --- a/src/AcDream.App/Rendering/ClipFrame.cs +++ b/src/AcDream.App/Rendering/ClipFrame.cs @@ -114,6 +114,30 @@ public sealed class ClipFrame : IDisposable /// the reserved no-clip slot). public int SlotCount => _slotCount; + /// + /// Phase U.4: reset this frame back to the NoClip state — exactly slot 0 + /// (no-clip, count 0) and a terrain count of 0 — WITHOUT allocating a new + /// frame or new GL buffers. The single long-lived _clipFrame in + /// GameWindow is reset + re-packed every frame by , + /// then re-uploaded via (which reuses the same SSBO / + /// UBO ids). This keeps the per-frame cost at one BufferData per buffer instead + /// of leaking a fresh pair of GL buffers each frame. + /// + public void Reset() + { + // Slot 0 = no-clip (count 0). Zero just the slot-0 region; the tail beyond + // _slotCount is never uploaded, so it needn't be cleared. AppendSlot writes + // each new slot's count + planes in full, so stale bytes there are + // overwritten before they can be uploaded. + if (_regionBytes.Length < CellClipStrideBytes) + EnsureRegionCapacity(CellClipStrideBytes); + Array.Clear(_regionBytes, 0, CellClipStrideBytes); + _slotCount = 1; + + // Terrain back to count 0 (ungated) until SetTerrainClip is called again. + Array.Clear(_terrainBytes); + } + /// The shared mesh-clip SSBO id, or 0 before the first /// . Renderers may bind this directly if they don't /// receive it via a parameter; already binds it to diff --git a/src/AcDream.App/Rendering/ClipFrameAssembler.cs b/src/AcDream.App/Rendering/ClipFrameAssembler.cs new file mode 100644 index 0000000..4077938 --- /dev/null +++ b/src/AcDream.App/Rendering/ClipFrameAssembler.cs @@ -0,0 +1,222 @@ +// 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; + } +} diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 276aa01..2a8947f 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -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? 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(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); diff --git a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs index cf15e8e..136ffcd 100644 --- a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs +++ b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs @@ -237,6 +237,24 @@ public sealed unsafe class EnvCellRenderer : IDisposable public void SetClipRegionSsbo(uint sharedClipRegionSsbo) => _sharedClipRegionSsbo = sharedClipRegionSsbo; + // Phase U.4: per-frame cellId→CellClip-slot map for the cell shells. When + // non-null, RenderModernMDIInternal writes instanceClipSlot[i] = + // _cellIdToSlot[allInstances[i].CellId] so each cell's shell instances are + // gated to that cell's portal-clip region. When null (U.3 path), every + // instance maps to slot 0 (no-clip). A cell absent from the map writes slot 0 + // (no-clip) — but the caller's Render filter already restricts the draw to the + // map's keys, so that fallback should not fire in practice. + private IReadOnlyDictionary? _cellIdToSlot; + + /// + /// Phase U.4: install the per-frame cellId→slot map used to gate cell shells + /// to their portal-clip regions. Call once per frame BEFORE + /// . Pass null to revert to + /// the U.3 no-clip behavior (every shell instance → slot 0). + /// + public void SetClipRouting(IReadOnlyDictionary? cellIdToSlot) + => _cellIdToSlot = cellIdToSlot; + // --------------------------------------------------------------------------- // GetEnvCellGeomId // Verbatim copy of WB EnvCellRenderManager.cs:94-103. @@ -1047,14 +1065,25 @@ public sealed unsafe class EnvCellRenderer : IDisposable (nuint)(totalDraws * sizeof(ModernBatchData)), ptr); } - // Phase U.3: upload the per-instance clip-slot buffer (binding=3), all - // zeros ⇒ every instance maps to slot 0 ⇒ no-clip. Re-zero the reused head - // each frame so stale U.4 slot indices can't leak. Sized to - // uniqueInstanceCount; the buffer was already grown above with the - // instance buffer when capacity increased. + // Phase U.4: upload the per-instance clip-slot buffer (binding=3). When + // _cellIdToSlot is set (indoor routing), each cell shell instance is gated + // to its cell's CellClip slot via allInstances[i].CellId; cells absent from + // the map (shouldn't happen — the Render filter is the map's keys) and the + // U.3 path both map to slot 0 (no-clip). allInstances is laid out in the + // SAME order as the binding=0 transforms (_gpuInstanceTransforms below), so + // instanceClipSlot[i] tracks Instances[i] through the MDI BaseInstance. if (_clipSlotData.Length < uniqueInstanceCount) _clipSlotData = new uint[Math.Max(_clipSlotData.Length * 2, uniqueInstanceCount)]; - Array.Clear(_clipSlotData, 0, uniqueInstanceCount); + if (_cellIdToSlot is null) + { + Array.Clear(_clipSlotData, 0, uniqueInstanceCount); + } + else + { + for (int i = 0; i < uniqueInstanceCount; i++) + _clipSlotData[i] = _cellIdToSlot.TryGetValue(allInstances[i].CellId, out int slot) + ? (uint)slot : 0u; + } _gl.BindBuffer(GLEnum.ShaderStorageBuffer, _clipSlotBuffer); _gl.BufferData(GLEnum.ShaderStorageBuffer, (nuint)(uniqueInstanceCount * sizeof(uint)), null, GLEnum.DynamicDraw); diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index 984065f..3159025 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -140,6 +140,29 @@ public sealed unsafe class WbDrawDispatcher : IDisposable private uint _sharedClipRegionSsbo; private uint _fallbackClipRegionSsbo; + // Phase U.4: per-frame clip-slot routing handed in via SetClipRouting before + // each Draw. When _clipRoutingActive is false (the U.3 path / outdoor root / + // no portal frame), every instance maps to slot 0 (no-clip) and no instance is + // culled — identical to U.3. When active, each instance's slot is resolved by + // ResolveEntitySlot per the U.4 policy (live-dynamic unclipped; cell statics to + // their cell slot; outdoor scenery to the OutsideView slot; non-visible culled). + private bool _clipRoutingActive; + private IReadOnlyDictionary? _cellIdToSlot; + private int _outdoorSlot; + private bool _outdoorVisible; + + // Phase U.4: the clip slot of the entity currently being classified in Draw's + // per-entity loop. Set once per entity (before ClassifyBatches / ApplyCacheHit), + // read by the two matrix-append sites (AppendInstanceToGroup + ClassifyBatches) + // so every group's Slots[] stays in lockstep with its Matrices[]. Defaults to 0 + // (no-clip) on the U.3 / outdoor path. + private uint _currentEntitySlot; + + // Phase U.4: true when the current entity resolved to the CULL sentinel + // (cell not visible, or outdoor stab while no outdoors is visible). Persisted + // across the entity's tuples; the per-tuple body skips all instance emission. + private bool _currentEntityCulled; + // Per-frame scratch arrays — Tasks 9-10 fully wire these. private float[] _instanceData = new float[256 * 16]; // mat4 floats per instance private BatchData[] _batchData = new BatchData[256]; @@ -283,6 +306,78 @@ public sealed unsafe class WbDrawDispatcher : IDisposable public void SetClipRegionSsbo(uint sharedClipRegionSsbo) => _sharedClipRegionSsbo = sharedClipRegionSsbo; + /// + /// Phase U.4: install the per-frame clip-slot routing for an INDOOR root. + /// Call once per frame BEFORE when the camera's root cell is + /// non-null; the next resolves each instance's binding=3 + /// clip slot via the U.4 policy (live-dynamic unclipped, cell statics to their + /// cell slot, outdoor scenery to the OutsideView slot, non-visible culled). + /// Pair with on outdoor-root frames so the + /// dispatcher reverts to the U.3 no-clip-everything behavior. + /// + /// cellId → CellClip slot. A cell absent from the map + /// is NOT visible → its cell-static instances are culled. + /// Slot for outdoor scenery / building shells while + /// indoors (the OutsideView slot, or 0 for no-clip over-include). + /// False ⇒ cull outdoor scenery / shells this frame + /// (the OutsideView is empty). + public void SetClipRouting(IReadOnlyDictionary cellIdToSlot, int outdoorSlot, bool outdoorVisible) + { + ArgumentNullException.ThrowIfNull(cellIdToSlot); + _clipRoutingActive = true; + _cellIdToSlot = cellIdToSlot; + _outdoorSlot = outdoorSlot; + _outdoorVisible = outdoorVisible; + } + + /// + /// Phase U.4: revert to U.3 behavior — every instance maps to slot 0 (no-clip), + /// nothing is culled by clip routing. Call on outdoor-root frames (camera + /// outdoors) and any frame without a portal-visibility result. + /// + public void ClearClipRouting() + { + _clipRoutingActive = false; + _cellIdToSlot = null; + _outdoorSlot = 0; + _outdoorVisible = false; + } + + // Phase U.4 CULL sentinel returned by ResolveEntitySlot: the entity's instances + // are dropped entirely (not emitted into the binding=0 instance buffer NOR the + // binding=3 slot buffer), matching the existing frustum / visible-cell cull. + private const int ClipSlotCull = -1; + + /// + /// Phase U.4: resolve the clip slot for one entity per the slot/gate policy. + /// Returns to drop the entity's instances entirely. + /// + /// ServerGuid != 0 (live dynamic: player / NPC / items / doors) ⇒ slot 0 + /// (UNCLIPPED — retail draws live-dynamic unclipped; depth only). + /// ParentCellId != null (cell static) ⇒ the cell's slot, or CULL when the + /// cell isn't in cellIdToSlot (not visible / nothing-visible). + /// ParentCellId == null (outdoor scenery / building shell) ⇒ the OutsideView + /// slot when outdoorVisible, else CULL. + /// + /// Only called when _clipRoutingActive (indoor root). On the U.3 / outdoor + /// path every instance is slot 0 and nothing is culled. + /// + private int ResolveEntitySlot(WorldEntity entity) + { + // Live-dynamic entities render unclipped regardless of cell — retail draws + // the player / NPCs / dropped items through the depth buffer without portal + // clipping. ServerGuid is the live-dynamic marker (0 for dat-hydrated). + if (entity.ServerGuid != 0) + return 0; + + if (entity.ParentCellId is uint parentCell) + return _cellIdToSlot!.TryGetValue(parentCell, out int slot) ? slot : ClipSlotCull; + + // Outdoor scenery / building shell (no ParentCellId). Indoor root: gate to + // the OutsideView slot, or cull when nothing outdoors is visible. + return _outdoorVisible ? _outdoorSlot : ClipSlotCull; + } + public static Matrix4x4 ComposePartWorldMatrix( Matrix4x4 entityWorld, Matrix4x4 animOverride, @@ -533,7 +628,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable camPos = invView.Translation; // ── Phase 1: clear groups, walk entities, build groups ────────────── - foreach (var grp in _groups.Values) grp.Matrices.Clear(); + foreach (var grp in _groups.Values) { grp.Matrices.Clear(); grp.Slots.Clear(); } var metaTable = _meshAdapter.MetadataTable; uint anyVao = 0; @@ -676,15 +771,41 @@ public sealed unsafe class WbDrawDispatcher : IDisposable populateEntityId = null; } currentEntityIncomplete = false; + + // Phase U.4: resolve this entity's clip slot ONCE per entity + // (constant across its tuples). On the U.3 / outdoor path + // (_clipRoutingActive false) every entity is slot 0, never culled. + if (_clipRoutingActive) + { + int resolved = ResolveEntitySlot(entity); + _currentEntityCulled = resolved == ClipSlotCull; + _currentEntitySlot = _currentEntityCulled ? 0u : (uint)resolved; + } + else + { + _currentEntityCulled = false; + _currentEntitySlot = 0u; + } } prevTupleEntityId = entity.Id; // Flush-on-entity-change: if the previous entity accumulated any // batches AND this iteration is for a different entity, populate - // its cache entry now and reset the scratch buffer. + // its cache entry now and reset the scratch buffer. Runs for ALL + // entities (including this-entity-culled) so the PREVIOUS entity's + // cache always flushes at the boundary. (populateEntityId, populateLandblockId) = MaybeFlushOnEntityChange( populateEntityId, populateLandblockId, entity.Id, _cache, _populateScratch); + // Phase U.4: a culled entity (cell not visible, or no outdoors visible + // for an outdoor stab) contributes NO instances. Skip after the + // boundary flush above so the previous entity still committed; the + // next entity's isNewEntity logic is unaffected (prevTupleEntityId is + // already updated). Matches the existing visible-cell / frustum cull: + // nothing enters _groups, so neither binding=0 nor binding=3 sees it. + if (_currentEntityCulled) + continue; + var entityWorld = Matrix4x4.CreateFromQuaternion(entity.Rotation) * Matrix4x4.CreateTranslation(entity.Position); @@ -912,6 +1033,13 @@ public sealed unsafe class WbDrawDispatcher : IDisposable if (_instanceData.Length < needed) _instanceData = new float[needed + 256 * 16]; + // Phase U.4: size the per-instance clip-slot buffer to match the instance + // count and lay it out in the SAME group order / cursor as _instanceData, + // so instanceClipSlot[i] (binding=3) tracks Instances[i] (binding=0). On + // the U.3 / outdoor path every Slots entry is 0 ⇒ identical to U.3. + if (_clipSlotData.Length < totalInstances) + _clipSlotData = new uint[totalInstances + 256]; + _opaqueDraws.Clear(); _translucentDraws.Clear(); @@ -934,6 +1062,9 @@ public sealed unsafe class WbDrawDispatcher : IDisposable for (int i = 0; i < grp.Matrices.Count; i++) { WriteMatrix(_instanceData, cursor * 16, grp.Matrices[i]); + // Slots[] is parallel to Matrices[] within the group; write the + // slot at the same cursor so binding=3 stays aligned with binding=0. + _clipSlotData[cursor] = grp.Slots[i]; cursor++; } @@ -1008,15 +1139,14 @@ public sealed unsafe class WbDrawDispatcher : IDisposable fixed (BatchData* bp = _batchData) UploadSsbo(_batchSsbo, 1, bp, totalDraws * sizeof(BatchData)); - // Phase U.3: per-instance clip-slot buffer (binding=3), one uint per - // instance, laid out parallel to _instanceData so the shader's - // instanceClipSlot[instanceIndex] tracks the same instance as - // Instances[instanceIndex]. ALL ZEROS in U.3 ⇒ slot 0 ⇒ no-clip. Grow + - // zero the scratch as needed (Array.Resize zero-fills the new tail; the - // reused head is re-zeroed below so stale U.4 slot indices can't leak). - if (_clipSlotData.Length < totalInstances) - _clipSlotData = new uint[totalInstances + 256]; - Array.Clear(_clipSlotData, 0, totalInstances); + // Phase U.4: per-instance clip-slot buffer (binding=3), one uint per + // instance, laid out parallel to _instanceData in Phase 3's group loop so + // instanceClipSlot[instanceIndex] tracks Instances[instanceIndex]. On the + // U.3 / outdoor path every entry is 0 ⇒ slot 0 ⇒ no-clip (identical to + // U.3); under indoor routing it holds the per-instance slot from + // ResolveEntitySlot. No clear here — Phase 3 wrote exactly totalInstances + // entries; only [0..totalInstances) is uploaded, so any stale tail is + // never read by the shader (BaseInstance + gl_InstanceID < totalInstances). fixed (uint* sp = _clipSlotData) UploadSsbo(_clipSlotSsbo, 3, sp, totalInstances * sizeof(uint)); @@ -1460,6 +1590,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable _groups[key] = grp; } grp.Matrices.Add(model); + grp.Slots.Add(_currentEntitySlot); // Phase U.4 — parallel to Matrices } private void ClassifyBatches( @@ -1516,6 +1647,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable _groups[key] = grp; } grp.Matrices.Add(model); + grp.Slots.Add(_currentEntitySlot); // Phase U.4 — parallel to Matrices collector?.Add(new CachedBatch(key, texHandle, restPose)); } } @@ -1772,5 +1904,12 @@ public sealed unsafe class WbDrawDispatcher : IDisposable public int InstanceCount; public float SortDistance; // squared distance from camera to first instance, for opaque sort public readonly List Matrices = new(); + + // Phase U.4: per-instance clip-slot index, parallel to Matrices (Slots[i] + // is the binding=2 CellClip slot for the instance whose matrix is + // Matrices[i]). At layout time the dispatcher writes Slots[i] into + // _clipSlotData at the same cursor it writes Matrices[i] into _instanceData, + // so the binding=3 instanceClipSlot[] tracks the binding=0 instance. + public readonly List Slots = new(); } } diff --git a/tests/AcDream.App.Tests/Rendering/ClipFrameAssemblerTests.cs b/tests/AcDream.App.Tests/Rendering/ClipFrameAssemblerTests.cs new file mode 100644 index 0000000..fea2dae --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/ClipFrameAssemblerTests.cs @@ -0,0 +1,191 @@ +using System.Collections.Generic; +using System.Numerics; +using AcDream.App.Rendering; +using Xunit; + +namespace AcDream.App.Tests.Rendering; + +/// +/// Phase U.4: GL-free proof that implements the +/// slot/gate policy — slot 0 no-clip, one CellClip slot per visible cell with a +/// convex region, OutsideView routed to the terrain decision + the outdoor mesh +/// slot, and the three Count==0 dispositions (nothing-visible cull, scissor +/// fallback → no-clip, planes). Hand-built s +/// drive the assembler directly (no portal BFS needed) so each disposition is +/// exercised in isolation. +/// +public class ClipFrameAssemblerTests +{ + // A convex NDC square → ClipPlaneSet.From yields 4 planes (Count > 0). + private static ViewPolygon Square(float cx, float cy, float half) => new(new[] + { + new Vector2(cx - half, cy - half), new Vector2(cx + half, cy - half), + new Vector2(cx + half, cy + half), new Vector2(cx - half, cy + half), + }); + + private static CellView ViewOf(params ViewPolygon[] polys) + { + var v = new CellView(); + foreach (var p in polys) v.Add(p); + return v; + } + + [Fact] + public void TwoVisibleCells_PlusOutsideView_ProducesCorrectSlotMapAndCounts() + { + // Two cells with single convex regions (→ planes, mapped to slots 1 and 2) + // and a single-convex OutsideView (→ planes, the outdoor slot 3). + const uint cellA = 0xA9B40100; + const uint cellB = 0xA9B40101; + + var pv = new PortalVisibilityFrame(); + pv.CellViews[cellA] = ViewOf(Square(-0.3f, 0f, 0.3f)); + pv.CellViews[cellB] = ViewOf(Square(0.3f, 0f, 0.2f)); + pv.OrderedVisibleCells.Add(cellA); + pv.OrderedVisibleCells.Add(cellB); + pv.OutsideView.Add(Square(0f, 0.5f, 0.25f)); + + var frame = ClipFrame.NoClip(); + var asm = ClipFrameAssembler.Assemble(frame, pv); + + // slot 0 reserved (no-clip) + 2 cells + 1 outdoor = 4 slots. + Assert.Equal(4, asm.Frame.SlotCount); + + // Both cells mapped to NON-zero slots (real plane regions), distinct. + Assert.True(asm.CellIdToSlot.ContainsKey(cellA)); + Assert.True(asm.CellIdToSlot.ContainsKey(cellB)); + Assert.NotEqual(0, asm.CellIdToSlot[cellA]); + Assert.NotEqual(0, asm.CellIdToSlot[cellB]); + Assert.NotEqual(asm.CellIdToSlot[cellA], asm.CellIdToSlot[cellB]); + + // Per-cell plane counts recorded (a convex square reduces to 4 planes). + Assert.Equal(4, asm.PerCellPlaneCounts[cellA]); + Assert.Equal(4, asm.PerCellPlaneCounts[cellB]); + + // OutsideView: visible, convex → planes, a non-zero outdoor slot, terrain + // gated via planes. + Assert.True(asm.OutdoorVisible); + Assert.NotEqual(0, asm.OutdoorSlot); + Assert.Equal(TerrainClipMode.Planes, asm.TerrainMode); + Assert.Equal(4, asm.OutsidePlaneCount); + Assert.Equal(0, asm.ScissorFallbacks); + + // The outdoor slot differs from both cell slots and from slot 0. + Assert.NotEqual(asm.CellIdToSlot[cellA], asm.OutdoorSlot); + Assert.NotEqual(asm.CellIdToSlot[cellB], asm.OutdoorSlot); + } + + [Fact] + public void NothingVisibleCell_IsExcludedFromSlotMap_AndNotAppended() + { + // cellA is a real convex region; cellB's CellView is EMPTY → ClipPlaneSet + // .IsNothingVisible → it must NOT be mapped and NOT consume a slot. + const uint cellA = 0xA9B40100; + const uint cellB = 0xA9B40101; + + var pv = new PortalVisibilityFrame(); + pv.CellViews[cellA] = ViewOf(Square(0f, 0f, 0.3f)); + pv.CellViews[cellB] = new CellView(); // empty ⇒ nothing visible + pv.OrderedVisibleCells.Add(cellA); + pv.OrderedVisibleCells.Add(cellB); + // OutsideView empty ⇒ terrain Skip (the bleed fix), outdoor not visible. + + var frame = ClipFrame.NoClip(); + var asm = ClipFrameAssembler.Assemble(frame, pv); + + // slot 0 + cellA only = 2 slots. cellB consumed none. + Assert.Equal(2, asm.Frame.SlotCount); + Assert.True(asm.CellIdToSlot.ContainsKey(cellA)); + Assert.False(asm.CellIdToSlot.ContainsKey(cellB)); // culled — not drawable + + // Empty OutsideView ⇒ outdoor culled + terrain skipped (the bleed fix). + Assert.False(asm.OutdoorVisible); + Assert.Equal(TerrainClipMode.Skip, asm.TerrainMode); + Assert.Equal(0, asm.OutsidePlaneCount); + } + + [Fact] + public void OutsideViewScissorFallback_MapsTerrainToScissor_AndCountsFallback() + { + // A MULTI-polygon OutsideView is a union (non-convex) ⇒ ClipPlaneSet falls + // back to the union AABB scissor: mesh outdoor slot 0 (no-clip over-include), + // terrain → Scissor, one fallback counted. + const uint cellA = 0xA9B40100; + + var pv = new PortalVisibilityFrame(); + pv.CellViews[cellA] = ViewOf(Square(0f, 0f, 0.3f)); + pv.OrderedVisibleCells.Add(cellA); + pv.OutsideView.Add(Square(-0.5f, 0f, 0.1f)); + pv.OutsideView.Add(Square(0.5f, 0f, 0.1f)); // 2 polys ⇒ scissor fallback + + var frame = ClipFrame.NoClip(); + var asm = ClipFrameAssembler.Assemble(frame, pv); + + Assert.True(asm.OutdoorVisible); + Assert.Equal(0, asm.OutdoorSlot); // no-clip over-include + Assert.Equal(TerrainClipMode.Scissor, asm.TerrainMode); + Assert.Equal(0, asm.OutsidePlaneCount); // scissor ⇒ 0 planes + Assert.Equal(1, asm.ScissorFallbacks); + + // The terrain scissor AABB is a valid (min <= max) NDC box spanning both + // OutsideView squares: minX <= -0.6, maxX >= 0.6. + Assert.True(asm.TerrainScissorNdcAabb.X <= asm.TerrainScissorNdcAabb.Z); + Assert.True(asm.TerrainScissorNdcAabb.Y <= asm.TerrainScissorNdcAabb.W); + Assert.True(asm.TerrainScissorNdcAabb.X <= -0.59f); + Assert.True(asm.TerrainScissorNdcAabb.Z >= 0.59f); + } + + [Fact] + public void CellScissorFallback_MapsCellToSlotZero_AndCountsFallback() + { + // A cell with a MULTI-polygon region → scissor fallback → mapped to slot 0 + // (no-clip over-include), recorded with 0 planes, one fallback counted. The + // OutsideView is a single convex region (planes) so only the CELL counts. + const uint cellA = 0xA9B40100; + + var pv = new PortalVisibilityFrame(); + pv.CellViews[cellA] = ViewOf(Square(-0.4f, 0f, 0.1f), Square(0.4f, 0f, 0.1f)); + pv.OrderedVisibleCells.Add(cellA); + pv.OutsideView.Add(Square(0f, 0f, 0.3f)); + + var frame = ClipFrame.NoClip(); + var asm = ClipFrameAssembler.Assemble(frame, pv); + + // cellA → slot 0 (no-clip). slot 0 + the OutsideView's planes slot = 2. + Assert.Equal(0, asm.CellIdToSlot[cellA]); + Assert.Equal(0, asm.PerCellPlaneCounts[cellA]); + Assert.Equal(2, asm.Frame.SlotCount); // slot0 + OutsideView + Assert.Equal(1, asm.ScissorFallbacks); // the cell fallback + Assert.Equal(TerrainClipMode.Planes, asm.TerrainMode); + } + + [Fact] + public void Reset_ReusesFrame_NoSlotLeakAcrossAssemblies() + { + // First assembly packs 2 cells + outdoor (4 slots). Re-assembling the SAME + // frame from a smaller pvFrame must Reset back to slot 0 — no leak. + var frame = ClipFrame.NoClip(); + + var pv1 = new PortalVisibilityFrame(); + pv1.CellViews[0xA9B40100] = ViewOf(Square(-0.3f, 0f, 0.2f)); + pv1.CellViews[0xA9B40101] = ViewOf(Square(0.3f, 0f, 0.2f)); + pv1.OrderedVisibleCells.Add(0xA9B40100); + pv1.OrderedVisibleCells.Add(0xA9B40101); + pv1.OutsideView.Add(Square(0f, 0.4f, 0.2f)); + var asm1 = ClipFrameAssembler.Assemble(frame, pv1); + Assert.Equal(4, asm1.Frame.SlotCount); + + // Second assembly: a single cell, no OutsideView. + var pv2 = new PortalVisibilityFrame(); + pv2.CellViews[0xA9B40200] = ViewOf(Square(0f, 0f, 0.25f)); + pv2.OrderedVisibleCells.Add(0xA9B40200); + var asm2 = ClipFrameAssembler.Assemble(frame, pv2); + + // slot 0 + 1 cell = 2 — the prior 4-slot state did not leak. + Assert.Equal(2, asm2.Frame.SlotCount); + Assert.True(asm2.CellIdToSlot.ContainsKey(0xA9B40200)); + Assert.False(asm2.CellIdToSlot.ContainsKey(0xA9B40100)); // gone after Reset + Assert.False(asm2.OutdoorVisible); // no OutsideView this time + Assert.Equal(TerrainClipMode.Skip, asm2.TerrainMode); + } +}