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>
This commit is contained in:
parent
0b125830fe
commit
bf2e559369
8 changed files with 797 additions and 1 deletions
181
tests/AcDream.App.Tests/Rendering/ClipFrameLayoutTests.cs
Normal file
181
tests/AcDream.App.Tests/Rendering/ClipFrameLayoutTests.cs
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue