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); + } +}