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:
parent
864fc5f94e
commit
7993e064a0
6 changed files with 748 additions and 67 deletions
|
|
@ -114,6 +114,30 @@ public sealed class ClipFrame : IDisposable
|
|||
/// the reserved no-clip slot).</summary>
|
||||
public int SlotCount => _slotCount;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>_clipFrame</c> in
|
||||
/// GameWindow is reset + re-packed every frame by <see cref="ClipFrameAssembler"/>,
|
||||
/// then re-uploaded via <see cref="UploadShared"/> (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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>The shared mesh-clip SSBO id, or 0 before the first
|
||||
/// <see cref="UploadShared"/>. Renderers may bind this directly if they don't
|
||||
/// receive it via a parameter; <see cref="UploadShared"/> already binds it to
|
||||
|
|
|
|||
222
src/AcDream.App/Rendering/ClipFrameAssembler.cs
Normal file
222
src/AcDream.App/Rendering/ClipFrameAssembler.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <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; }
|
||||
|
||||
// ---- 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++;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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<uint, int>? _cellIdToSlot;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <see cref="Render(WbRenderPass, HashSet{uint}?)"/>. Pass null to revert to
|
||||
/// the U.3 no-clip behavior (every shell instance → slot 0).
|
||||
/// </summary>
|
||||
public void SetClipRouting(IReadOnlyDictionary<uint, int>? 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);
|
||||
|
|
|
|||
|
|
@ -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<uint, int>? _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;
|
||||
|
||||
/// <summary>
|
||||
/// Phase U.4: install the per-frame clip-slot routing for an INDOOR root.
|
||||
/// Call once per frame BEFORE <see cref="Draw"/> when the camera's root cell is
|
||||
/// non-null; the next <see cref="Draw"/> 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 <see cref="ClearClipRouting"/> on outdoor-root frames so the
|
||||
/// dispatcher reverts to the U.3 no-clip-everything behavior.
|
||||
/// </summary>
|
||||
/// <param name="cellIdToSlot">cellId → CellClip slot. A cell absent from the map
|
||||
/// is NOT visible → its cell-static instances are culled.</param>
|
||||
/// <param name="outdoorSlot">Slot for outdoor scenery / building shells while
|
||||
/// indoors (the OutsideView slot, or 0 for no-clip over-include).</param>
|
||||
/// <param name="outdoorVisible">False ⇒ cull outdoor scenery / shells this frame
|
||||
/// (the OutsideView is empty).</param>
|
||||
public void SetClipRouting(IReadOnlyDictionary<uint, int> cellIdToSlot, int outdoorSlot, bool outdoorVisible)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(cellIdToSlot);
|
||||
_clipRoutingActive = true;
|
||||
_cellIdToSlot = cellIdToSlot;
|
||||
_outdoorSlot = outdoorSlot;
|
||||
_outdoorVisible = outdoorVisible;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Phase U.4: resolve the clip slot for one entity per the slot/gate policy.
|
||||
/// Returns <see cref="ClipSlotCull"/> to drop the entity's instances entirely.
|
||||
/// <list type="bullet">
|
||||
/// <item>ServerGuid != 0 (live dynamic: player / NPC / items / doors) ⇒ slot 0
|
||||
/// (UNCLIPPED — retail draws live-dynamic unclipped; depth only).</item>
|
||||
/// <item>ParentCellId != null (cell static) ⇒ the cell's slot, or CULL when the
|
||||
/// cell isn't in <c>cellIdToSlot</c> (not visible / nothing-visible).</item>
|
||||
/// <item>ParentCellId == null (outdoor scenery / building shell) ⇒ the OutsideView
|
||||
/// slot when <c>outdoorVisible</c>, else CULL.</item>
|
||||
/// </list>
|
||||
/// Only called when <c>_clipRoutingActive</c> (indoor root). On the U.3 / outdoor
|
||||
/// path every instance is slot 0 and nothing is culled.
|
||||
/// </summary>
|
||||
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<Matrix4x4> 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<uint> Slots = new();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
191
tests/AcDream.App.Tests/Rendering/ClipFrameAssemblerTests.cs
Normal file
191
tests/AcDream.App.Tests/Rendering/ClipFrameAssemblerTests.cs
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// Phase U.4: GL-free proof that <see cref="ClipFrameAssembler"/> 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 <see cref="PortalVisibilityFrame"/>s
|
||||
/// drive the assembler directly (no portal BFS needed) so each disposition is
|
||||
/// exercised in isolation.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue