diff --git a/src/AcDream.App/Rendering/ClipFrame.cs b/src/AcDream.App/Rendering/ClipFrame.cs
new file mode 100644
index 0000000..19a071f
--- /dev/null
+++ b/src/AcDream.App/Rendering/ClipFrame.cs
@@ -0,0 +1,276 @@
+// ClipFrame.cs
+//
+// Phase U.3: the GPU-side container + uploader for the SHARED per-frame clip
+// data consumed by mesh_modern.vert (SSBO binding=2) and terrain_modern.vert
+// (UBO binding=2). This is the "shared" half of the U.3 clip mechanism; the
+// per-instance slot index buffer (SSBO binding=3) is PER-RENDERER and owned by
+// each renderer (WbDrawDispatcher / EnvCellRenderer), parallel to its instance
+// buffer — it is NOT here.
+//
+// === The contract (both shader sides obey) ===================================
+// binding=2 mesh SSBO holds an array of CellClip, one per "slot":
+// struct CellClip { uint count; uint _p0; uint _p1; uint _p2; vec4 planes[8]; };
+// std430 layout: count at byte 0, three pad uints at 4/8/12, planes[8] at 16
+// (vec4 stride 16) → 144 bytes per slot. Slot 0 is RESERVED = no-clip (count 0).
+// binding=2 terrain UBO holds the single OutsideView region:
+// layout(std140) { int uTerrainClipCount; vec4 uTerrainClipPlanes[8]; };
+// std140 layout: count at byte 0 (padded to 16), planes[8] at 16 → 144 bytes.
+//
+// In U.3 a ClipFrame is built via NoClip(): one slot (slot 0, count 0) and a
+// terrain count of 0. Everything renders exactly as before. U.4 populates real
+// slots from a PortalVisibilityFrame (one CellClip per visible cell) and sets the
+// terrain OutsideView planes, then points each renderer's per-instance slot
+// buffer at the right slots.
+//
+// Pure CPU byte-packing + a thin GL upload. NO GL types appear except in
+// UploadShared. The byte layout is asserted by ClipFrameLayoutTests so a silent
+// std430/std140 drift can't reach the GPU.
+using System;
+using System.Collections.Generic;
+using System.Numerics;
+using Silk.NET.OpenGL;
+
+namespace AcDream.App.Rendering;
+
+///
+/// Per-frame container + uploader for the SHARED clip data: the binding=2 mesh
+/// SSBO (one CellClip per slot, slot 0 reserved no-clip) and the binding=2
+/// terrain UBO (the single OutsideView region). See the file header for the exact
+/// std430 / std140 byte layout. Per-instance slot buffers (binding=3) are owned by
+/// each renderer, not here.
+///
+public sealed class ClipFrame : IDisposable
+{
+ // ---- Layout constants (mirror mesh_modern.vert + terrain_modern.vert) ----
+
+ /// Max planes per clip region — matches the shader's planes[8]
+ /// and GL's guaranteed GL_MAX_CLIP_DISTANCES >= 8.
+ public const int MaxPlanes = 8;
+
+ /// std430 stride of one CellClip: 16 (count + 3 pad uints) +
+ /// 8 × 16 (vec4 planes) = 144 bytes.
+ public const int CellClipStrideBytes = 16 + MaxPlanes * 16; // 144
+
+ /// Byte offset of planes[0] within a CellClip (after the
+ /// count + 3 pad uints).
+ public const int CellClipPlanesOffset = 16;
+
+ /// std140 size of the terrain UBO block: int count padded to 16, then
+ /// 8 × 16 (vec4 planes) = 144 bytes. Same number as the SSBO stride by
+ /// coincidence of the 16-byte vec4 rule, but a DIFFERENT layout family.
+ public const int TerrainUboBytes = 16 + MaxPlanes * 16; // 144
+
+ /// SSBO binding index for the shared per-cell clip regions
+ /// (mesh_modern.vert binding=2).
+ public const uint MeshClipSsboBinding = 2;
+
+ /// UBO binding index for the terrain OutsideView clip region
+ /// (terrain_modern.vert binding=2). UBO namespace — distinct from the SSBO
+ /// binding=2 above.
+ public const uint TerrainClipUboBinding = 2;
+
+ // ---- CPU-side state ------------------------------------------------------
+
+ // Packed std430 bytes for clipRegions[]. Always holds at least slot 0.
+ private byte[] _regionBytes;
+ private int _slotCount;
+
+ // Packed std140 bytes for the terrain UBO (always TerrainUboBytes long).
+ private readonly byte[] _terrainBytes = new byte[TerrainUboBytes];
+
+ // ---- GL-side state (lazily created on first UploadShared) ----------------
+
+ private uint _regionSsbo;
+ private uint _terrainUbo;
+ private bool _glInitialized;
+ private bool _disposed;
+
+ private ClipFrame(byte[] regionBytes, int slotCount)
+ {
+ _regionBytes = regionBytes;
+ _slotCount = slotCount;
+ // Terrain defaults to count 0 (ungated). _terrainBytes is already all
+ // zeros, which encodes count=0 + zeroed (unused) planes.
+ }
+
+ ///
+ /// The U.3 default frame: exactly slot 0 (no-clip, count 0) and a terrain
+ /// count of 0. The whole scene renders ungated — identical to pre-U.3. U.4
+ /// replaces this with a frame built from real portal visibility.
+ ///
+ public static ClipFrame NoClip()
+ {
+ // One slot, all zeros: count=0 ⇒ shader passes every plane.
+ var bytes = new byte[CellClipStrideBytes];
+ return new ClipFrame(bytes, slotCount: 1);
+ }
+
+ /// Number of clip slots currently packed (always >= 1 — slot 0 is
+ /// the reserved no-clip slot).
+ public int SlotCount => _slotCount;
+
+ /// 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
+ /// .
+ public uint RegionSsbo => _regionSsbo;
+
+ /// The terrain-clip UBO id, or 0 before the first
+ /// . Handed to
+ /// so it can re-bind binding=2 (UBO namespace) before its draw.
+ public uint TerrainUbo => _terrainUbo;
+
+ ///
+ /// Append one clip region (becomes the next slot index) from a
+ /// . Only the convex-plane case is supported in
+ /// U.3 — Count > 0 packs that many planes; Count == 0 packs a
+ /// no-clip region (pass-all). The scissor / nothing-visible fallbacks that
+ /// can carry are deferred to U.4 (which will draw
+ /// the AABB box or skip the cell on the CPU side, not via this slot). Returns
+ /// the new slot's index.
+ ///
+ public int AppendSlot(ClipPlaneSet set)
+ {
+ int count = Math.Min(set.Count, MaxPlanes);
+ if (count == 0)
+ return AppendSlot(ReadOnlySpan.Empty);
+
+ Span planes = stackalloc Vector4[count];
+ for (int i = 0; i < count; i++)
+ planes[i] = set.Planes[i];
+ return AppendSlot(planes);
+ }
+
+ ///
+ /// Append one clip region from a raw plane list.
+ /// length 0 packs a no-clip (pass-all) region; otherwise up to
+ /// planes are packed (extras ignored). Each plane is
+ /// (nx, ny, 0, dw) in clip space; a clip-space vertex is inside iff
+ /// dot(plane, gl_Position) >= 0 for every plane. Returns the new
+ /// slot index.
+ ///
+ public int AppendSlot(ReadOnlySpan planes)
+ {
+ int count = Math.Min(planes.Length, MaxPlanes);
+
+ int slot = _slotCount;
+ int byteOffset = slot * CellClipStrideBytes;
+ EnsureRegionCapacity(byteOffset + CellClipStrideBytes);
+
+ // count (uint) at byteOffset; the 3 pad uints stay zero.
+ WriteUInt(_regionBytes, byteOffset, (uint)count);
+
+ for (int i = 0; i < count; i++)
+ {
+ int po = byteOffset + CellClipPlanesOffset + i * 16;
+ WriteVec4(_regionBytes, po, planes[i]);
+ }
+
+ _slotCount++;
+ return slot;
+ }
+
+ ///
+ /// Set the terrain OutsideView clip region (the single region the terrain
+ /// shader gates against). length 0 ungates terrain
+ /// (count 0). U.3 callers never touch this — leaves it
+ /// at count 0. U.4 calls it with the OutsideView planes.
+ ///
+ public void SetTerrainClip(ReadOnlySpan planes)
+ {
+ int count = Math.Min(planes.Length, MaxPlanes);
+ Array.Clear(_terrainBytes);
+ WriteInt(_terrainBytes, 0, count);
+ for (int i = 0; i < count; i++)
+ WriteVec4(_terrainBytes, CellClipPlanesOffset + i * 16, planes[i]);
+ }
+
+ ///
+ /// Upload the shared mesh-clip SSBO (binding=2) and the terrain-clip UBO
+ /// (binding=2, UBO namespace) and bind both to their binding points. Idempotent
+ /// to call once per frame. Creates the GL buffers lazily on first call.
+ ///
+ public unsafe void UploadShared(GL gl)
+ {
+ ArgumentNullException.ThrowIfNull(gl);
+ ObjectDisposedException.ThrowIf(_disposed, this);
+
+ if (!_glInitialized)
+ {
+ _regionSsbo = gl.GenBuffer();
+ _terrainUbo = gl.GenBuffer();
+ _glInitialized = true;
+ }
+
+ int regionByteCount = _slotCount * CellClipStrideBytes;
+ gl.BindBuffer(BufferTargetARB.ShaderStorageBuffer, _regionSsbo);
+ fixed (byte* p = _regionBytes)
+ {
+ gl.BufferData(BufferTargetARB.ShaderStorageBuffer,
+ (nuint)regionByteCount, p, BufferUsageARB.DynamicDraw);
+ }
+ gl.BindBufferBase(BufferTargetARB.ShaderStorageBuffer, MeshClipSsboBinding, _regionSsbo);
+
+ gl.BindBuffer(BufferTargetARB.UniformBuffer, _terrainUbo);
+ fixed (byte* p = _terrainBytes)
+ {
+ gl.BufferData(BufferTargetARB.UniformBuffer,
+ (nuint)TerrainUboBytes, p, BufferUsageARB.DynamicDraw);
+ }
+ gl.BindBufferBase(BufferTargetARB.UniformBuffer, TerrainClipUboBinding, _terrainUbo);
+ }
+
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _disposed = true;
+ // GL buffers are deleted by the owner's GL context teardown; ClipFrame
+ // is a per-frame transient in U.3 (NoClip() each frame). We do not hold a
+ // GL handle to delete here because UploadShared may not have run. If a
+ // future phase makes ClipFrame long-lived, add buffer deletion guarded by
+ // _glInitialized + a captured GL reference.
+ }
+
+ // ---- byte helpers (little-endian; matches x86/x64 GPU upload) ------------
+
+ private void EnsureRegionCapacity(int requiredBytes)
+ {
+ if (_regionBytes.Length >= requiredBytes) return;
+ int newLen = Math.Max(requiredBytes, _regionBytes.Length * 2);
+ Array.Resize(ref _regionBytes, newLen);
+ }
+
+ private static void WriteUInt(byte[] dst, int offset, uint value)
+ {
+ dst[offset + 0] = (byte)(value & 0xFF);
+ dst[offset + 1] = (byte)((value >> 8) & 0xFF);
+ dst[offset + 2] = (byte)((value >> 16) & 0xFF);
+ dst[offset + 3] = (byte)((value >> 24) & 0xFF);
+ }
+
+ private static void WriteInt(byte[] dst, int offset, int value)
+ => WriteUInt(dst, offset, unchecked((uint)value));
+
+ private static void WriteVec4(byte[] dst, int offset, Vector4 v)
+ {
+ WriteFloat(dst, offset + 0, v.X);
+ WriteFloat(dst, offset + 4, v.Y);
+ WriteFloat(dst, offset + 8, v.Z);
+ WriteFloat(dst, offset + 12, v.W);
+ }
+
+ private static void WriteFloat(byte[] dst, int offset, float value)
+ {
+ uint bits = BitConverter.SingleToUInt32Bits(value);
+ WriteUInt(dst, offset, bits);
+ }
+
+ // ---- Test seams ----------------------------------------------------------
+
+ /// Test seam: the packed std430 region bytes (slot 0..SlotCount-1).
+ /// Read-only snapshot used by ClipFrameLayoutTests to assert the byte layout.
+ internal ReadOnlySpan RegionBytesForTest => _regionBytes.AsSpan(0, _slotCount * CellClipStrideBytes);
+
+ /// Test seam: the packed std140 terrain UBO bytes.
+ internal ReadOnlySpan TerrainBytesForTest => _terrainBytes;
+}
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 375f54c..32f473f 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -166,6 +166,14 @@ public sealed class GameWindow : IDisposable
private AcDream.App.Rendering.Wb.EnvCellRenderer? _envCellRenderer;
private AcDream.App.Rendering.Wb.WbFrustum? _envCellFrustum;
+ // Phase U.3: the shared per-frame clip data (binding=2 mesh SSBO + terrain
+ // UBO). In U.3 this is rebuilt as ClipFrame.NoClip() every frame and uploaded
+ // once before terrain/entities draw, so the whole scene renders ungated
+ // (identical to pre-U.3). The buffer ids are handed to the three renderers so
+ // each re-binds binding=2 immediately before its own draw. U.4 replaces the
+ // NoClip() frame with one built from the portal-visibility result.
+ private ClipFrame? _clipFrame;
+
///
/// Phase 6.4: per-entity animation playback state for entities whose
/// MotionTable resolved to a real cycle. The render loop ticks each
@@ -1079,6 +1087,17 @@ public sealed class GameWindow : IDisposable
_gl.ClearColor(0.05f, 0.10f, 0.18f, 1.0f);
_gl.Enable(EnableCap.DepthTest);
+ // Phase U.3: enable all 8 hardware clip planes once at startup. The mesh
+ // and terrain vertex shaders write gl_ClipDistance[0..7] every frame;
+ // unused planes are set to +1.0 (pass-all) when a region's plane count is
+ // below 8, so leaving all 8 enabled permanently is correct and avoids
+ // per-draw glEnable/glDisable thrash. In U.3 every region is no-clip, so
+ // nothing is actually clipped — the running game renders identically. U.4
+ // populates real clip regions; the enables stay as-is.
+ // (EnableCap.ClipDistance0 == GL_CLIP_DISTANCE0 0x3000; +i selects plane i.)
+ for (int _cp = 0; _cp < ClipFrame.MaxPlanes; _cp++)
+ _gl.Enable(EnableCap.ClipDistance0 + _cp);
+
string shadersDir = Path.Combine(AppContext.BaseDirectory, "Rendering", "Shaders");
// Phase N.5b: terrain_modern shader pair — bindless texture handles +
@@ -7261,6 +7280,21 @@ 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.
+ _clipFrame ??= ClipFrame.NoClip();
+ _clipFrame.UploadShared(_gl);
+ _wbDrawDispatcher?.SetClipRegionSsbo(_clipFrame.RegionSsbo);
+ _envCellRenderer?.SetClipRegionSsbo(_clipFrame.RegionSsbo);
+ _terrain?.SetClipUbo(_clipFrame.TerrainUbo);
+
// 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.
@@ -10659,6 +10693,7 @@ public sealed class GameWindow : IDisposable
_audioEngine?.Dispose(); // Phase E.2: stop all voices, close AL context
_wbDrawDispatcher?.Dispose();
_envCellRenderer?.Dispose(); // Phase A8
+ _clipFrame?.Dispose(); // Phase U.3
_skyRenderer?.Dispose(); // depends on sampler cache; dispose first
_samplerCache?.Dispose();
_textureCache?.Dispose();
diff --git a/src/AcDream.App/Rendering/Shaders/mesh_modern.vert b/src/AcDream.App/Rendering/Shaders/mesh_modern.vert
index 2b6131f..ce4378a 100644
--- a/src/AcDream.App/Rendering/Shaders/mesh_modern.vert
+++ b/src/AcDream.App/Rendering/Shaders/mesh_modern.vert
@@ -37,6 +37,48 @@ layout(std430, binding = 1) readonly buffer BatchBuffer {
BatchData Batches[];
};
+// === Phase U.3: per-cell screen-space clip gate (gl_ClipDistance) =============
+// Two SSBOs add the clip mechanism without disturbing binding=0/1 above.
+//
+// binding=2 — SHARED per-frame clip regions, one CellClip per "slot". Uploaded
+// ONCE per frame by ClipFrame.UploadShared (shared across WbDrawDispatcher +
+// EnvCellRenderer). Slot 0 is RESERVED = no-clip (count 0 ⇒ every plane passes).
+//
+// binding=3 — PER-RENDERER per-instance slot index, parallel to the binding=0
+// instance buffer and indexed by the IDENTICAL per-instance index
+// (gl_BaseInstanceARB + gl_InstanceID). instanceClipSlot[i] selects which
+// CellClip region instance i is clipped against. Default all-zeros in U.3 ⇒
+// every instance maps to slot 0 ⇒ no clipping ⇒ identical render to pre-U.3.
+//
+// CellClip std430 layout (144 bytes/slot): a uint count + 3 pad uints (16 bytes)
+// then vec4 planes[8] (8 × 16 = 128 bytes). vec4 array stride is 16 under std430.
+// ClipFrame on the CPU side lays out the bytes to match exactly (verified by
+// ClipFrameLayoutTests). A clip-space vertex is INSIDE iff dot(plane, gl_Position)
+// >= 0 for every active plane (see ClipPlaneSet for the plane convention).
+struct CellClip {
+ uint count;
+ uint _p0;
+ uint _p1;
+ uint _p2;
+ vec4 planes[8];
+};
+layout(std430, binding = 2) readonly buffer ClipRegionBuf {
+ CellClip clipRegions[];
+};
+layout(std430, binding = 3) readonly buffer ClipSlotBuf {
+ uint instanceClipSlot[];
+};
+
+// Core profile: redeclare gl_PerVertex so writing gl_ClipDistance[] is legal
+// alongside gl_Position. The array is sized 8 to match the CellClip plane budget
+// and the GL guarantee (GL_MAX_CLIP_DISTANCES >= 8). The host enables
+// GL_CLIP_DISTANCE0..7 once at startup; unused planes are set to +1.0 below so
+// they pass everything (no clipping) when the slot's count < 8.
+out gl_PerVertex {
+ vec4 gl_Position;
+ float gl_ClipDistance[8];
+};
+
uniform mat4 uViewProjection;
// Phase Post-A.5 (ISSUE #52, 2026-05-10): per-pass offset into Batches[].
@@ -67,6 +109,18 @@ void main() {
vec4 worldPos = model * vec4(aPosition, 1.0);
gl_Position = uViewProjection * worldPos;
+ // Phase U.3: per-instance clip gate. instanceClipSlot is indexed by the
+ // SAME instanceIndex used for the binding=0 transform above, so the slot
+ // travels with the instance through the MDI BaseInstance offsets. Slot 0
+ // (the U.3 default) has count 0 ⇒ the second loop sets all 8 distances to
+ // +1.0 ⇒ nothing is clipped.
+ uint _slot = instanceClipSlot[instanceIndex];
+ CellClip _c = clipRegions[_slot];
+ for (uint i = 0u; i < _c.count; ++i)
+ gl_ClipDistance[i] = dot(_c.planes[i], gl_Position);
+ for (uint i = _c.count; i < 8u; ++i)
+ gl_ClipDistance[i] = 1.0;
+
vWorldPos = worldPos.xyz;
vNormal = normalize(mat3(model) * aNormal);
vTexCoord = aTexCoord;
diff --git a/src/AcDream.App/Rendering/Shaders/terrain_modern.vert b/src/AcDream.App/Rendering/Shaders/terrain_modern.vert
index 9427a55..66565f9 100644
--- a/src/AcDream.App/Rendering/Shaders/terrain_modern.vert
+++ b/src/AcDream.App/Rendering/Shaders/terrain_modern.vert
@@ -30,6 +30,28 @@ layout(std140, binding = 1) uniform SceneLighting {
vec4 uCameraAndTime;
};
+// === Phase U.3: terrain screen-space clip gate (OutsideView region) ===========
+// Terrain is a single global region (the OutsideView), so it needs one set of
+// clip planes, not a per-instance slot table like the mesh shader. A std140 UBO
+// at binding=2 carries it. The UBO binding namespace is distinct from the SSBO
+// binding namespace, so this does NOT collide with the mesh shader's SSBO
+// binding=2 — and within THIS shader binding=1 (SceneLighting) is the only other
+// UBO, leaving binding=2 free. uTerrainClipCount == 0 (the U.3 default) ungates
+// terrain entirely (the second loop sets all 8 distances to +1.0). Uploaded by
+// ClipFrame.UploadShared each frame; TerrainModernRenderer binds it before draw.
+layout(std140, binding = 2) uniform TerrainClip {
+ int uTerrainClipCount;
+ vec4 uTerrainClipPlanes[8];
+};
+
+// Core profile: redeclare gl_PerVertex so writing gl_ClipDistance[] is legal.
+// Sized 8 to match GL_MAX_CLIP_DISTANCES >= 8. Host enables GL_CLIP_DISTANCE0..7
+// once at startup; unused planes are set to +1.0 below so they pass everything.
+out gl_PerVertex {
+ vec4 gl_Position;
+ float gl_ClipDistance[8];
+};
+
out vec2 vBaseUV;
out vec3 vWorldNormal;
out vec3 vWorldPos;
@@ -144,4 +166,12 @@ void main() {
// Closes issue #100; supersedes the hiddenTerrainCells cell-collapse hack.
vec3 terrainPos = vec3(aPos.xy, aPos.z - 0.01);
gl_Position = uProjection * uView * vec4(terrainPos, 1.0);
+
+ // Phase U.3: terrain clip gate against the single OutsideView region. With
+ // uTerrainClipCount == 0 (U.3 default) the first loop is skipped and the
+ // second sets all 8 distances to +1.0 ⇒ no clipping ⇒ identical terrain.
+ for (int i = 0; i < uTerrainClipCount; ++i)
+ gl_ClipDistance[i] = dot(uTerrainClipPlanes[i], gl_Position);
+ for (int i = uTerrainClipCount; i < 8; ++i)
+ gl_ClipDistance[i] = 1.0;
}
diff --git a/src/AcDream.App/Rendering/TerrainModernRenderer.cs b/src/AcDream.App/Rendering/TerrainModernRenderer.cs
index 0145ce9..006e5d8 100644
--- a/src/AcDream.App/Rendering/TerrainModernRenderer.cs
+++ b/src/AcDream.App/Rendering/TerrainModernRenderer.cs
@@ -54,6 +54,13 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
private uint _indirectBuffer;
private int _indirectCapacity;
+ // Phase U.3: terrain clip UBO (binding=2, terrain_modern.vert TerrainClip).
+ // The shared one is created + uploaded by the GameWindow-level ClipFrame and
+ // handed in via SetClipUbo. When 0, we bind a lazily-created no-clip fallback
+ // (count 0 = ungated) so the shader never reads an unbound UBO at binding=2.
+ private uint _sharedClipUbo;
+ private uint _fallbackClipUbo;
+
// Cached uvec2-handle uniform locations (matrix uniforms are set by name via Shader.SetMatrix4).
private int _uTerrainHandleLoc;
private int _uAlphaHandleLoc;
@@ -93,6 +100,14 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
_indirectBuffer = _gl.GenBuffer();
}
+ ///
+ /// Phase U.3: hand the renderer the SHARED terrain-clip UBO (binding=2)
+ /// created by . The renderer binds it to
+ /// binding=2 before its draw. Pass 0 to fall back to the internal no-clip UBO
+ /// (count 0 = ungated terrain).
+ ///
+ public void SetClipUbo(uint sharedClipUbo) => _sharedClipUbo = sharedClipUbo;
+
///
/// Two-tier streaming entry point. Accepts a prebuilt mesh from
/// built on the worker
@@ -258,6 +273,10 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
_gl.ProgramUniform2(_shader.Program, _uAlphaHandleLoc,
(uint)(alphaHandle & 0xFFFFFFFFu), (uint)(alphaHandle >> 32));
+ // Phase U.3: bind the terrain clip UBO (binding=2). Shared ClipFrame UBO
+ // when wired, else the no-clip fallback (count 0 = ungated terrain).
+ BindClipUboBinding2();
+
_gl.BindVertexArray(_globalVao);
_gl.MemoryBarrier(MemoryBarrierMask.CommandBarrierBit);
_gl.MultiDrawElementsIndirect(
@@ -275,12 +294,42 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
_gl.DeleteBuffer(_globalVbo);
_gl.DeleteBuffer(_globalEbo);
_gl.DeleteBuffer(_indirectBuffer);
+ if (_fallbackClipUbo != 0) { _gl.DeleteBuffer(_fallbackClipUbo); _fallbackClipUbo = 0; } // Phase U.3
}
// ----------------------------------------------------------------
// Private helpers
// ----------------------------------------------------------------
+ ///
+ /// Phase U.3: bind the terrain clip UBO to binding=2. Prefers the shared
+ /// UBO (); otherwise lazily
+ /// creates + binds a no-clip fallback (count 0 = ungated) so the shader never
+ /// reads an unbound UBO. The fallback is std140-sized to
+ /// and zero-filled (count 0).
+ ///
+ private void BindClipUboBinding2()
+ {
+ if (_sharedClipUbo != 0)
+ {
+ _gl.BindBufferBase(BufferTargetARB.UniformBuffer,
+ ClipFrame.TerrainClipUboBinding, _sharedClipUbo);
+ return;
+ }
+
+ if (_fallbackClipUbo == 0)
+ {
+ _fallbackClipUbo = _gl.GenBuffer();
+ var zero = stackalloc byte[ClipFrame.TerrainUboBytes];
+ for (int i = 0; i < ClipFrame.TerrainUboBytes; i++) zero[i] = 0;
+ _gl.BindBuffer(BufferTargetARB.UniformBuffer, _fallbackClipUbo);
+ _gl.BufferData(BufferTargetARB.UniformBuffer,
+ (nuint)ClipFrame.TerrainUboBytes, zero, BufferUsageARB.DynamicDraw);
+ }
+ _gl.BindBufferBase(BufferTargetARB.UniformBuffer,
+ ClipFrame.TerrainClipUboBinding, _fallbackClipUbo);
+ }
+
private void AllocateGpuBuffers(int capacitySlots)
{
nuint vboBytes = (nuint)(capacitySlots * VertsPerLandblock * VertexSize);
diff --git a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs
index e902b0f..cf15e8e 100644
--- a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs
+++ b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs
@@ -74,6 +74,19 @@ public sealed unsafe class EnvCellRenderer : IDisposable
// packed transform array instead of the 80-byte CPU struct.
private Matrix4x4[] _gpuInstanceTransforms = Array.Empty();
+ // Phase U.3: per-instance clip-slot SSBO (binding=3), parallel to
+ // _modernInstanceBuffer. One uint per instance selecting its CellClip slot,
+ // indexed by the same BaseInstance + gl_InstanceID the shader uses for
+ // binding=0. ALL ZEROS in U.3 ⇒ slot 0 ⇒ no-clip. U.4 populates real slots.
+ private uint _clipSlotBuffer;
+ private uint[] _clipSlotData = Array.Empty();
+
+ // Phase U.3: SHARED per-cell clip-region SSBO (binding=2) handed in via
+ // SetClipRegionSsbo (the GameWindow-level ClipFrame buffer). When 0, we bind
+ // our own one-slot no-clip fallback so the shader never reads an unbound SSBO.
+ private uint _sharedClipRegionSsbo;
+ private uint _fallbackClipRegionSsbo;
+
// Reusable scratch arrays — avoid per-frame allocation.
// WB BaseObjectRenderManager.cs:58-59: private DrawElementsIndirectCommand[] _commands = Array.Empty<...>()
private DrawElementsIndirectCommand[] _commands = Array.Empty();
@@ -204,10 +217,26 @@ public sealed unsafe class EnvCellRenderer : IDisposable
_gl.BufferData(GLEnum.ShaderStorageBuffer,
(nuint)(_modernBatchCapacity * sizeof(ModernBatchData)), null, GLEnum.DynamicDraw);
+ // Phase U.3: per-instance clip-slot SSBO (binding=3), sized to the
+ // instance capacity. Uploaded all-zeros each frame in RenderModernMDIInternal.
+ _gl.GenBuffers(1, out _clipSlotBuffer);
+ _gl.BindBuffer(GLEnum.ShaderStorageBuffer, _clipSlotBuffer);
+ _gl.BufferData(GLEnum.ShaderStorageBuffer,
+ (nuint)(_modernInstanceCapacity * sizeof(uint)), null, GLEnum.DynamicDraw);
+
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, 0);
_gl.BindBuffer(GLEnum.DrawIndirectBuffer, 0);
}
+ ///
+ /// Phase U.3: hand the renderer the SHARED per-cell clip-region SSBO
+ /// (binding=2) created by . The renderer
+ /// re-binds it to binding=2 immediately before its MDI. Pass 0 to fall back to
+ /// the internal one-slot no-clip region buffer.
+ ///
+ public void SetClipRegionSsbo(uint sharedClipRegionSsbo)
+ => _sharedClipRegionSsbo = sharedClipRegionSsbo;
+
// ---------------------------------------------------------------------------
// GetEnvCellGeomId
// Verbatim copy of WB EnvCellRenderManager.cs:94-103.
@@ -947,6 +976,13 @@ public sealed unsafe class EnvCellRenderer : IDisposable
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _modernInstanceBuffer);
_gl.BufferData(GLEnum.ShaderStorageBuffer,
(nuint)(_modernInstanceCapacity * sizeof(Matrix4x4)), null, GLEnum.DynamicDraw);
+
+ // Phase U.3: keep the clip-slot buffer (binding=3) sized to the
+ // instance buffer so instanceClipSlot[BaseInstance + gl_InstanceID]
+ // is always in range.
+ _gl.BindBuffer(GLEnum.ShaderStorageBuffer, _clipSlotBuffer);
+ _gl.BufferData(GLEnum.ShaderStorageBuffer,
+ (nuint)(_modernInstanceCapacity * sizeof(uint)), null, GLEnum.DynamicDraw);
}
// WB BaseObjectRenderManager.cs:761-762: grow scratch arrays.
@@ -1011,6 +1047,23 @@ 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.
+ if (_clipSlotData.Length < uniqueInstanceCount)
+ _clipSlotData = new uint[Math.Max(_clipSlotData.Length * 2, uniqueInstanceCount)];
+ Array.Clear(_clipSlotData, 0, uniqueInstanceCount);
+ _gl.BindBuffer(GLEnum.ShaderStorageBuffer, _clipSlotBuffer);
+ _gl.BufferData(GLEnum.ShaderStorageBuffer,
+ (nuint)(uniqueInstanceCount * sizeof(uint)), null, GLEnum.DynamicDraw);
+ fixed (uint* ptr = _clipSlotData)
+ {
+ _gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0,
+ (nuint)(uniqueInstanceCount * sizeof(uint)), ptr);
+ }
+
// WB BaseObjectRenderManager.cs:807-818: bind VAO + SSBOs + barrier.
var globalVao = _meshManager.GlobalBuffer?.VAO ?? 0u;
if (globalVao == 0) return;
@@ -1022,6 +1075,10 @@ public sealed unsafe class EnvCellRenderer : IDisposable
_gl.BindBufferBase(GLEnum.ShaderStorageBuffer, 0, _modernInstanceBuffer);
_gl.BindBufferBase(GLEnum.ShaderStorageBuffer, 1, _modernBatchBuffer);
+ // Phase U.3: per-instance clip slots (binding=3) + shared clip regions
+ // (binding=2, via the GameWindow ClipFrame or our no-clip fallback).
+ _gl.BindBufferBase(GLEnum.ShaderStorageBuffer, 3, _clipSlotBuffer);
+ BindClipRegionBinding2();
_gl.BindBuffer(GLEnum.DrawIndirectBuffer, _mdiCommandBuffer);
_gl.MemoryBarrier(MemoryBarrierMask.ShaderStorageBarrierBit | MemoryBarrierMask.CommandBarrierBit);
@@ -1095,6 +1152,41 @@ public sealed unsafe class EnvCellRenderer : IDisposable
}
}
+ // ---------------------------------------------------------------------------
+ // BindClipRegionBinding2 (Phase U.3)
+ // ---------------------------------------------------------------------------
+
+ ///
+ /// Bind the per-cell clip-region SSBO to binding=2. Prefers the shared
+ /// buffer (); otherwise
+ /// lazily creates + binds a one-slot no-clip fallback (count 0 = pass-all) so
+ /// the shader never reads an unbound SSBO.
+ ///
+ private void BindClipRegionBinding2()
+ {
+ if (_sharedClipRegionSsbo != 0)
+ {
+ _gl.BindBufferBase(GLEnum.ShaderStorageBuffer,
+ AcDream.App.Rendering.ClipFrame.MeshClipSsboBinding, _sharedClipRegionSsbo);
+ return;
+ }
+
+ if (_fallbackClipRegionSsbo == 0)
+ {
+ _gl.GenBuffers(1, out _fallbackClipRegionSsbo);
+ // One CellClip slot, all zeros: count 0 ⇒ shader passes every plane.
+ var zero = new byte[AcDream.App.Rendering.ClipFrame.CellClipStrideBytes];
+ _gl.BindBuffer(GLEnum.ShaderStorageBuffer, _fallbackClipRegionSsbo);
+ fixed (byte* p = zero)
+ {
+ _gl.BufferData(GLEnum.ShaderStorageBuffer,
+ (nuint)AcDream.App.Rendering.ClipFrame.CellClipStrideBytes, p, GLEnum.DynamicDraw);
+ }
+ }
+ _gl.BindBufferBase(GLEnum.ShaderStorageBuffer,
+ AcDream.App.Rendering.ClipFrame.MeshClipSsboBinding, _fallbackClipRegionSsbo);
+ }
+
// ---------------------------------------------------------------------------
// List pool (GetPooledList)
// Copied from WB ObjectRenderManagerBase (pattern).
@@ -1194,5 +1286,7 @@ public sealed unsafe class EnvCellRenderer : IDisposable
if (_mdiCommandBuffer != 0) { _gl.DeleteBuffer(_mdiCommandBuffer); _mdiCommandBuffer = 0; }
if (_modernInstanceBuffer != 0){ _gl.DeleteBuffer(_modernInstanceBuffer); _modernInstanceBuffer = 0; }
if (_modernBatchBuffer != 0) { _gl.DeleteBuffer(_modernBatchBuffer); _modernBatchBuffer = 0; }
+ if (_clipSlotBuffer != 0) { _gl.DeleteBuffer(_clipSlotBuffer); _clipSlotBuffer = 0; } // Phase U.3
+ if (_fallbackClipRegionSsbo != 0) { _gl.DeleteBuffer(_fallbackClipRegionSsbo); _fallbackClipRegionSsbo = 0; } // Phase U.3
}
}
diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs
index af84b25..984065f 100644
--- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs
+++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs
@@ -125,6 +125,21 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
private uint _batchSsbo;
private uint _indirectBuffer;
+ // Phase U.3: per-instance clip-slot SSBO (binding=3), parallel to
+ // _instanceSsbo. One uint per instance selecting its CellClip slot. In U.3
+ // this is ALL ZEROS (every instance → slot 0 → no-clip), so the render is
+ // identical to pre-U.3. U.4 populates real slot indices.
+ private uint _clipSlotSsbo;
+ private uint[] _clipSlotData = new uint[256];
+
+ // Phase U.3: the SHARED per-cell clip-region SSBO (binding=2), owned by the
+ // GameWindow-level ClipFrame and handed to us via SetClipRegionSsbo. When 0
+ // (not yet wired), we bind our OWN fallback no-clip region buffer below so the
+ // shader never reads an unbound SSBO. The fallback holds exactly slot 0
+ // (count 0 = pass-all), matching ClipFrame.NoClip's slot 0.
+ private uint _sharedClipRegionSsbo;
+ private uint _fallbackClipRegionSsbo;
+
// 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];
@@ -255,8 +270,19 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
_instanceSsbo = _gl.GenBuffer();
_batchSsbo = _gl.GenBuffer();
_indirectBuffer = _gl.GenBuffer();
+ _clipSlotSsbo = _gl.GenBuffer(); // Phase U.3 binding=3
}
+ ///
+ /// Phase U.3: hand the dispatcher the SHARED per-cell clip-region SSBO
+ /// (binding=2) that created. The
+ /// dispatcher re-binds it to binding=2 immediately before each MDI so a
+ /// consumer that touched binding=2 in between can't leave it pointing
+ /// elsewhere. Pass 0 to fall back to the internal no-clip region buffer.
+ ///
+ public void SetClipRegionSsbo(uint sharedClipRegionSsbo)
+ => _sharedClipRegionSsbo = sharedClipRegionSsbo;
+
public static Matrix4x4 ComposePartWorldMatrix(
Matrix4x4 entityWorld,
Matrix4x4 animOverride,
@@ -975,13 +1001,25 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
_transparentDrawCount,
totalTriangles);
- // ── Phase 5: upload three buffers ───────────────────────────────────
+ // ── Phase 5: upload four buffers ────────────────────────────────────
fixed (float* ip = _instanceData)
UploadSsbo(_instanceSsbo, 0, ip, totalInstances * 16 * sizeof(float));
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);
+ fixed (uint* sp = _clipSlotData)
+ UploadSsbo(_clipSlotSsbo, 3, sp, totalInstances * sizeof(uint));
+
fixed (DrawElementsIndirectCommand* cp = _indirectCommands)
{
_gl.BindBuffer(BufferTargetARB.DrawIndirectBuffer, _indirectBuffer);
@@ -989,6 +1027,13 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
(nuint)(totalDraws * sizeof(DrawElementsIndirectCommand)), cp, BufferUsageARB.DynamicDraw);
}
+ // Phase U.3: bind the SHARED per-cell clip-region SSBO (binding=2). The
+ // GameWindow-level ClipFrame already uploaded + bound it this frame; we
+ // re-bind defensively in case another consumer touched binding=2 since.
+ // When no shared id is set (0), bind our own no-clip fallback so the
+ // shader never reads an unbound SSBO at binding=2.
+ BindClipRegionBinding2();
+
// ── Phase 6: bind global VAO once ───────────────────────────────────
_gl.BindVertexArray(anyVao);
@@ -1228,6 +1273,36 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
_gl.BindBufferBase(BufferTargetARB.ShaderStorageBuffer, binding, ssbo);
}
+ ///
+ /// Phase U.3: bind the per-cell clip-region SSBO to binding=2. Prefers the
+ /// shared buffer (set via );
+ /// otherwise lazily creates + binds a one-slot no-clip fallback so the shader
+ /// never reads an unbound SSBO. The fallback's single slot has count 0
+ /// (pass-all), matching 's slot 0.
+ ///
+ private unsafe void BindClipRegionBinding2()
+ {
+ if (_sharedClipRegionSsbo != 0)
+ {
+ _gl.BindBufferBase(BufferTargetARB.ShaderStorageBuffer,
+ ClipFrame.MeshClipSsboBinding, _sharedClipRegionSsbo);
+ return;
+ }
+
+ if (_fallbackClipRegionSsbo == 0)
+ {
+ _fallbackClipRegionSsbo = _gl.GenBuffer();
+ // One CellClip slot, all zeros: count 0 ⇒ shader passes every plane.
+ var zero = stackalloc byte[ClipFrame.CellClipStrideBytes];
+ for (int i = 0; i < ClipFrame.CellClipStrideBytes; i++) zero[i] = 0;
+ _gl.BindBuffer(BufferTargetARB.ShaderStorageBuffer, _fallbackClipRegionSsbo);
+ _gl.BufferData(BufferTargetARB.ShaderStorageBuffer,
+ (nuint)ClipFrame.CellClipStrideBytes, zero, BufferUsageARB.DynamicDraw);
+ }
+ _gl.BindBufferBase(BufferTargetARB.ShaderStorageBuffer,
+ ClipFrame.MeshClipSsboBinding, _fallbackClipRegionSsbo);
+ }
+
private void MaybeFlushDiag()
{
long now = Environment.TickCount64;
@@ -1517,6 +1592,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
_gl.DeleteBuffer(_instanceSsbo);
_gl.DeleteBuffer(_batchSsbo);
_gl.DeleteBuffer(_indirectBuffer);
+ if (_clipSlotSsbo != 0) _gl.DeleteBuffer(_clipSlotSsbo); // Phase U.3
+ if (_fallbackClipRegionSsbo != 0) _gl.DeleteBuffer(_fallbackClipRegionSsbo); // Phase U.3
if (_gpuQueriesInitialized)
{
for (int i = 0; i < GpuQueryRingDepth; i++)
diff --git a/tests/AcDream.App.Tests/Rendering/ClipFrameLayoutTests.cs b/tests/AcDream.App.Tests/Rendering/ClipFrameLayoutTests.cs
new file mode 100644
index 0000000..46090dd
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/ClipFrameLayoutTests.cs
@@ -0,0 +1,181 @@
+using System.Numerics;
+using AcDream.App.Rendering;
+using Xunit;
+
+namespace AcDream.App.Tests.Rendering;
+
+///
+/// Phase U.3: CPU-side proof that 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.
+///
+public class ClipFrameLayoutTests
+{
+ private static float ReadFloat(System.ReadOnlySpan b, int offset)
+ => System.BitConverter.ToSingle(b.Slice(offset, 4));
+
+ private static uint ReadUInt(System.ReadOnlySpan b, int offset)
+ => System.BitConverter.ToUInt32(b.Slice(offset, 4));
+
+ private static int ReadInt(System.ReadOnlySpan 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.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 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);
+ }
+}