acdream/tests/AcDream.App.Tests/Rendering/ClipFrameLayoutTests.cs
Erik bf2e559369 feat(render): Phase U.3 — GPU clip-plane gate (gl_ClipDistance), no-clip default
Adds the GPU mechanism to clip drawing to a per-cell screen-space convex
region via gl_ClipDistance, consumed by the mesh + terrain vertex shaders.
This is the MECHANISM only — every instance defaults to slot 0 (no-clip /
pass-all) and terrain to count 0, so the running game renders IDENTICALLY to
pre-U.3 (verified: offline launch compiles both shaders and reaches steady
state; no GL errors). U.4 populates real clip data from portal visibility.

Binding contract (define once, both sides obey):
- mesh_modern.vert: SSBO binding=2 CellClip[] (shared per-frame regions, slot 0
  reserved no-clip) + SSBO binding=3 uint[] per-instance slot, indexed by the
  IDENTICAL gl_BaseInstanceARB+gl_InstanceID used for binding=0. binding=0/1
  untouched.
- terrain_modern.vert: UBO binding=2 TerrainClip { int count; vec4 planes[8]; }
  for the single OutsideView region (UBO namespace; SceneLighting is UBO
  binding=1, so binding=2 is free and does not collide with the mesh SSBO
  binding=2). count 0 = ungated.
- Both redeclare out gl_PerVertex { vec4 gl_Position; float gl_ClipDistance[8]; }
  and set unused planes (i >= count) to +1.0 so they pass everything.

CellClip std430 layout (144 bytes/slot): count@0, 3 pad uints@4/8/12,
planes[8]@16 (vec4 stride 16). Terrain UBO std140: count@0 (padded to 16),
planes[8]@16 → 144 bytes. Verified by ClipFrameLayoutTests (8 new tests).

Pieces:
- ClipFrame: per-frame container + uploader for the SHARED clip data (binding=2
  SSBO + terrain UBO). NoClip() = slot 0 + terrain count 0. AppendSlot /
  SetTerrainClip pack std430/std140 bytes for U.4. UploadShared binds both.
- WbDrawDispatcher + EnvCellRenderer: each owns its binding=3 zero buffer
  (all-zeros sized to its instance count → slot 0), re-binds binding=2 from the
  shared ClipFrame id (or an internal no-clip fallback if unwired) before MDI.
  gl_ClipDistance is per-vertex, so the single glMultiDrawElementsIndirect per
  group is preserved — no draw splitting.
- TerrainModernRenderer: binds the terrain clip UBO (shared or no-clip fallback)
  before its draw.
- GameWindow: glEnable(GL_CLIP_DISTANCE0..7) once at init (unused planes pass-all
  so always-on avoids per-draw thrash); per frame builds ClipFrame.NoClip(),
  UploadShared, and hands the buffer ids to the three renderers (tiny diff; U.4
  swaps NoClip() for the real portal-visibility frame).

Gate: dotnet build green; App suite 134/134; offline launch confirms both
shaders compile + link with no GL errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 17:27:30 +02:00

181 lines
6.7 KiB
C#

using System.Numerics;
using AcDream.App.Rendering;
using Xunit;
namespace AcDream.App.Tests.Rendering;
/// <summary>
/// Phase U.3: CPU-side proof that <see cref="ClipFrame"/> packs the shared clip
/// data in the EXACT std430 (mesh SSBO) / std140 (terrain UBO) byte layout the
/// shaders read. A silent layout drift here would mis-clip at U.4 with no build
/// error — these tests are the gate that catches it.
///
/// Layout under test (mesh CellClip, std430):
/// offset 0 : uint count
/// offset 4 : uint _p0 (pad)
/// offset 8 : uint _p1 (pad)
/// offset 12 : uint _p2 (pad)
/// offset 16 : vec4 planes[0] (16-byte vec4 stride)
/// ...
/// offset 16 + i*16 : vec4 planes[i]
/// stride 144 bytes per slot.
/// Terrain UBO (std140): int count at 0 (padded to 16), vec4 planes[8] at 16.
/// </summary>
public class ClipFrameLayoutTests
{
private static float ReadFloat(System.ReadOnlySpan<byte> b, int offset)
=> System.BitConverter.ToSingle(b.Slice(offset, 4));
private static uint ReadUInt(System.ReadOnlySpan<byte> b, int offset)
=> System.BitConverter.ToUInt32(b.Slice(offset, 4));
private static int ReadInt(System.ReadOnlySpan<byte> b, int offset)
=> System.BitConverter.ToInt32(b.Slice(offset, 4));
[Fact]
public void LayoutConstants_MatchShaderStruct()
{
// CellClip: 16 (count + 3 pad uints) + 8*16 (vec4 planes) = 144.
Assert.Equal(144, ClipFrame.CellClipStrideBytes);
Assert.Equal(16, ClipFrame.CellClipPlanesOffset);
Assert.Equal(8, ClipFrame.MaxPlanes);
Assert.Equal(144, ClipFrame.TerrainUboBytes);
// Binding contract: mesh clip regions on SSBO binding=2, terrain on UBO binding=2.
Assert.Equal(2u, ClipFrame.MeshClipSsboBinding);
Assert.Equal(2u, ClipFrame.TerrainClipUboBinding);
}
[Fact]
public void NoClip_HasExactlyOneSlot_AllZeros_Count0()
{
var frame = ClipFrame.NoClip();
Assert.Equal(1, frame.SlotCount);
var bytes = frame.RegionBytesForTest;
Assert.Equal(ClipFrame.CellClipStrideBytes, bytes.Length); // 144 — exactly one slot
// count == 0 ⇒ shader passes every plane (no-clip).
Assert.Equal(0u, ReadUInt(bytes, 0));
// Every byte of the reserved no-clip slot is zero.
foreach (var b in bytes)
Assert.Equal(0, b);
}
[Fact]
public void NoClip_TerrainBytes_Count0_AllZeros()
{
var frame = ClipFrame.NoClip();
var t = frame.TerrainBytesForTest;
Assert.Equal(ClipFrame.TerrainUboBytes, t.Length);
Assert.Equal(0, ReadInt(t, 0)); // count 0 ⇒ terrain ungated
foreach (var b in t)
Assert.Equal(0, b);
}
[Fact]
public void AppendSlot_WritesCountAndPlanes_AtStd430Offsets()
{
var frame = ClipFrame.NoClip();
// Three distinct planes so each lands at a verifiable offset.
var p0 = new Vector4(1f, 0f, 0f, 0.5f);
var p1 = new Vector4(0f, 1f, 0f, 0.25f);
var p2 = new Vector4(-1f, 0f, 0f, -0.75f);
int slot = frame.AppendSlot(new[] { p0, p1, p2 });
Assert.Equal(1, slot); // slot 0 is the reserved no-clip; this is slot 1
Assert.Equal(2, frame.SlotCount);
var bytes = frame.RegionBytesForTest;
Assert.Equal(2 * ClipFrame.CellClipStrideBytes, bytes.Length); // two slots now
int baseOff = slot * ClipFrame.CellClipStrideBytes; // 144
// count == 3 at offset 0 of the slot; the 3 pad uints stay zero.
Assert.Equal(3u, ReadUInt(bytes, baseOff + 0));
Assert.Equal(0u, ReadUInt(bytes, baseOff + 4));
Assert.Equal(0u, ReadUInt(bytes, baseOff + 8));
Assert.Equal(0u, ReadUInt(bytes, baseOff + 12));
// planes[0..2] at offset 16, 32, 48 (vec4 stride 16).
AssertPlaneAt(bytes, baseOff + 16, p0);
AssertPlaneAt(bytes, baseOff + 32, p1);
AssertPlaneAt(bytes, baseOff + 48, p2);
// Slot 0 (the reserved no-clip) is untouched: still count 0.
Assert.Equal(0u, ReadUInt(bytes, 0));
}
[Fact]
public void AppendSlot_EmptyPlaneList_PacksNoClipSlot_Count0()
{
var frame = ClipFrame.NoClip();
int slot = frame.AppendSlot(System.ReadOnlySpan<Vector4>.Empty);
Assert.Equal(1, slot);
var bytes = frame.RegionBytesForTest;
Assert.Equal(0u, ReadUInt(bytes, slot * ClipFrame.CellClipStrideBytes)); // count 0
}
[Fact]
public void AppendSlot_ClampsToEightPlanes()
{
var frame = ClipFrame.NoClip();
var planes = new Vector4[12];
for (int i = 0; i < planes.Length; i++)
planes[i] = new Vector4(i, 0f, 0f, 0f);
int slot = frame.AppendSlot(planes);
var bytes = frame.RegionBytesForTest;
// Only MaxPlanes (8) are recorded in the count.
Assert.Equal((uint)ClipFrame.MaxPlanes, ReadUInt(bytes, slot * ClipFrame.CellClipStrideBytes));
}
[Fact]
public void AppendSlot_FromClipPlaneSet_AxisAlignedSquare_PacksFourPlanes()
{
// A unit square in NDC → ClipPlaneSet with 4 convex planes.
var cv = new CellView();
cv.Add(new ViewPolygon(new[]
{
new Vector2(-0.5f, -0.5f), new Vector2(0.5f, -0.5f),
new Vector2(0.5f, 0.5f), new Vector2(-0.5f, 0.5f),
}));
var cps = ClipPlaneSet.From(cv);
Assert.Equal(4, cps.Count);
var frame = ClipFrame.NoClip();
int slot = frame.AppendSlot(cps);
var bytes = frame.RegionBytesForTest;
int baseOff = slot * ClipFrame.CellClipStrideBytes;
Assert.Equal(4u, ReadUInt(bytes, baseOff + 0));
// Each packed plane must match the ClipPlaneSet's plane bit-for-bit.
for (int i = 0; i < 4; i++)
AssertPlaneAt(bytes, baseOff + ClipFrame.CellClipPlanesOffset + i * 16, cps.Planes[i]);
}
[Fact]
public void SetTerrainClip_WritesCountAndPlanes_AtStd140Offsets()
{
var frame = ClipFrame.NoClip();
var p0 = new Vector4(0.3f, -0.4f, 0f, 0.1f);
var p1 = new Vector4(-0.6f, 0.8f, 0f, -0.2f);
frame.SetTerrainClip(new[] { p0, p1 });
var t = frame.TerrainBytesForTest;
Assert.Equal(2, ReadInt(t, 0)); // int count at offset 0
AssertPlaneAt(t, ClipFrame.CellClipPlanesOffset + 0, p0); // planes start at 16 under std140 too
AssertPlaneAt(t, ClipFrame.CellClipPlanesOffset + 16, p1);
}
private static void AssertPlaneAt(System.ReadOnlySpan<byte> bytes, int offset, Vector4 expected)
{
Assert.Equal(expected.X, ReadFloat(bytes, offset + 0), 6);
Assert.Equal(expected.Y, ReadFloat(bytes, offset + 4), 6);
Assert.Equal(expected.Z, ReadFloat(bytes, offset + 8), 6);
Assert.Equal(expected.W, ReadFloat(bytes, offset + 12), 6);
}
}