// ClipFrame.cs // // Phase U.3: the per-frame container 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. The GL upload machinery this file used to carry // alongside the packing (a per-flight-slot region SSBO + terrain UBO arena, // reservation/upload-once bookkeeping, and their disposal) was deleted at // Campaign V slice V11: the RHI arm (see RhiWorldPassSurface.PrepareClipFrame / // SetTerrainClip in WorldPassSurface.cs) reads RegionBytes/TerrainBytes below and // copies them into a frame ring allocation instead, so nothing here owns a GPU // resource anymore. The byte layout is asserted by ClipFrameLayoutTests so a // silent std430/std140 drift can't reach the GPU. using System; using System.Numerics; namespace AcDream.App.Rendering; /// /// Per-frame container 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 /// UBO binding index for the terrain OutsideView clip region /// (terrain_modern.vert binding=2). Read directly by both the RHI world-pass /// section binder (WorldFrameSectionBinding.BindTerrainClip) and /// PortalDepthMaskRenderer, so unlike the mesh SSBO binding (which the /// RHI arm addresses through its own GpuBindingModel.StorageClipRegions /// instead) this one is still genuinely shared. 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]; /// /// The GL arm's per-flight-slot region/terrain buffer rings this used to /// report on were deleted at Campaign V slice V11: every publication now /// comes from the current frame's own ring allocator (see /// RhiWorldPassSurface.Publish), which lives until its frame retires, /// so there is no persistent dynamic-buffer pool left to size. /// internal int DynamicBufferSetCount => 0; 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; /// /// Phase U.4: reset this frame back to the NoClip state — exactly slot 0 /// (no-clip, count 0) and a terrain count of 0 — WITHOUT allocating a new /// frame. The single long-lived _clipFrame in GameWindow is reset + /// re-packed every frame by , then published /// through one frame-ring allocation per section (see /// RhiWorldPassSurface.PrepareClipFrame). /// public void Reset() { // Slot 0 = no-clip (count 0). Zero just the slot-0 region; the tail beyond // _slotCount is never uploaded, so it needn't be cleared. AppendSlot writes // each new slot's count + planes in full, so stale bytes there are // overwritten before they can be uploaded. if (_regionBytes.Length < CellClipStrideBytes) EnsureRegionCapacity(CellClipStrideBytes); Array.Clear(_regionBytes, 0, CellClipStrideBytes); _slotCount = 1; // Terrain back to count 0 (ungated) until SetTerrainClip is called again. Array.Clear(_terrainBytes); } /// /// Per-frame hook kept for the shared render-frame-begin ordering — it is /// called once per GPU-fenced frame slot alongside every other renderer's /// BeginFrame, regardless of backend. The GL arm's frame-slot-scoped /// region SSBO + terrain UBO arena this used to select was deleted at /// Campaign V slice V11: the RHI arm takes a fresh ring allocation from the /// current GPU frame for every publication instead, so there is no /// frame-slot state left here to reset — only the argument to validate. /// public void BeginFrame(int frameSlot) { ArgumentOutOfRangeException.ThrowIfNegative(frameSlot); } /// /// 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]); } /// /// No-op: this container owns no GPU resource of its own on the RHI arm — /// the deleted GL arm's per-frame-slot region SSBO + terrain UBO arena was /// the only thing to release. Kept as a method (and /// kept as ) so GameWindowLifetime's ordered /// shutdown doesn't need a special case for this one resource. /// public void Dispose() { } // ---- 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); } // ---- Packed bytes -------------------------------------------------------- /// /// The packed std430 region table for slots 0..-1. /// /// The raw-GL arm this used to feed via glBufferSubData against /// a renderer-owned SSBO was deleted at Campaign V slice V11; the RHI arm /// (RhiWorldPassSurface.PrepareClipFrame) now copies these bytes /// straight into a frame ring slice and publishes the range. The packing /// above stays backend-neutral regardless. /// internal ReadOnlySpan RegionBytes => _regionBytes.AsSpan(0, _slotCount * CellClipStrideBytes); /// The packed std140 terrain-clip block. See . internal ReadOnlySpan TerrainBytes => _terrainBytes; // ---- 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; /// Test seam: the packed std140 terrain UBO bytes. internal ReadOnlySpan TerrainBytesForTest => TerrainBytes; }