// 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. 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 AcDream.App.Rendering.Wb; 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 upload) ---------------------- private uint _regionSsbo; private uint _terrainUbo; private RetryableResourceReleaseLedger? _disposeResources; private bool _disposing; private bool _disposed; internal const int FrameSlotCount = GpuFrameFlightController.DefaultMaximumFramesInFlight; private sealed class RegionBuffer { public uint Name; public int CapacityBytes; public ClipBufferCapacityPolicy CapacityPolicy; } private sealed class TerrainBufferArena { public uint Name; public int CapacityBytes; public ClipBufferCapacityPolicy CapacityPolicy; } // A full clip-region table is immutable once ClipFrameAssembler has packed // the frame. Keep exactly one SSBO per GPU-fenced frame slot. Terrain clip // state changes between outside-view slices, so each frame slot owns one // UBO arena and each draw binds a distinct aligned range within that arena. // This bounds native object retention at six buffers total instead of one // region/terrain pair per outside-view slice. private readonly ClipFrameResourceRing _regionBuffers = new(FrameSlotCount); private readonly ClipFrameResourceRing _terrainBuffers = new(FrameSlotCount); private readonly ClipFrameUploadState _uploadState = new(); private int _dynamicFrameSlot; private bool _dynamicFrameStarted; private int _terrainRecordStrideBytes; private TerrainClipBufferBinding _terrainBinding; internal int DynamicBufferSetCount => Math.Max(_regionBuffers.Count, _terrainBuffers.Count); internal int RegionBufferCount => _regionBuffers.Count; internal int TerrainBufferCount => _terrainBuffers.Count; internal int TerrainRecordStrideBytes => _terrainRecordStrideBytes; // GL reference captured on the first upload so Dispose can delete each // frame-slot buffer. ClipFrame is long-lived and owns their teardown. private GL? _gl; 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 or new GL buffers. The single long-lived _clipFrame in /// GameWindow is reset + re-packed every frame by , /// then uploaded through one SSBO and one terrain arena per fenced frame slot. /// 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); } /// 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 /// . The buffer alone does not identify the /// active range; consumers should use . public uint TerrainUbo => _terrainUbo; /// The currently installed terrain-clip range. The buffer name is /// stable for the current GPU-fenced frame slot; the offset advances once /// for every outside-view draw. public TerrainClipBufferBinding TerrainBinding => _terrainBinding; /// /// Selects one GPU-fenced frame slot. Its region SSBO and terrain arena are /// safe to reuse because the frame-flight controller already waited for the /// slot's preceding submission. /// public void BeginFrame(int frameSlot) { if ((uint)frameSlot >= FrameSlotCount) throw new ArgumentOutOfRangeException(nameof(frameSlot)); _dynamicFrameSlot = frameSlot; _dynamicFrameStarted = true; _terrainBinding = default; _uploadState.BeginFrame(); } /// /// 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]); } /// /// Compatibility entry point for one-region/one-terrain callers. Complex /// PView frames should reserve their complete terrain sequence, upload the /// regions once, then call per slice. /// public unsafe void UploadShared(GL gl) { ReserveTerrainUploads(gl, 1); UploadRegions(gl); UploadTerrainClip(gl); } /// /// Allocates the current frame slot's terrain arena before any draw uses it. /// The arena has one alignment-safe range per subsequent /// call, so later slices never overwrite /// uniform data referenced by earlier GPU commands. /// public void ReserveTerrainUploads(GL gl, int uploadCount) { ValidateGlContext(gl); ArgumentOutOfRangeException.ThrowIfLessThan(uploadCount, 1); _uploadState.ValidateTerrainReservation(uploadCount); if (_terrainRecordStrideBytes == 0) { GLHelpers.ThrowOnResourceError(gl, "ClipFrame terrain alignment query (precondition)"); gl.GetInteger(GetPName.UniformBufferOffsetAlignment, out int alignment); GLHelpers.ThrowOnResourceError(gl, "ClipFrame terrain alignment query"); _terrainRecordStrideBytes = ClipFrameArenaLayout.RecordStride(alignment); } TerrainBufferArena arena = GetOrCreateTerrainArena(gl); int requiredBytes = ClipFrameArenaLayout.RequiredBytes( uploadCount, _terrainRecordStrideBytes); int targetCapacity = arena.CapacityPolicy.SelectCapacity( arena.CapacityBytes, requiredBytes); if (targetCapacity != arena.CapacityBytes) { ClipBufferCapacityTransaction.Resize( ref arena.CapacityBytes, targetCapacity, (previousBytes, newBytes) => TrackedGlResource.AllocateBufferStorage( gl, GLEnum.UniformBuffer, arena.Name, previousBytes, newBytes, GLEnum.DynamicDraw, "ClipFrame terrain UBO arena resize")); } _terrainUbo = arena.Name; _uploadState.CommitTerrainReservation(uploadCount); } /// Uploads and binds the immutable clip-region table once for the /// assembled frame. public unsafe void UploadRegions(GL gl) { ValidateGlContext(gl); _uploadState.ValidateRegionsNotUploaded(); RegionBuffer region = GetOrCreateRegionBuffer(gl); int regionByteCount = checked(_slotCount * CellClipStrideBytes); int targetCapacity = region.CapacityPolicy.SelectCapacity( region.CapacityBytes, regionByteCount); if (targetCapacity != region.CapacityBytes) { ClipBufferCapacityTransaction.Resize( ref region.CapacityBytes, targetCapacity, (previousBytes, newBytes) => TrackedGlResource.AllocateBufferStorage( gl, GLEnum.ShaderStorageBuffer, region.Name, previousBytes, newBytes, GLEnum.DynamicDraw, "ClipFrame region SSBO resize")); } fixed (byte* p = _regionBytes) { TrackedGlResource.UpdateBufferSubData( gl, GLEnum.ShaderStorageBuffer, region.Name, 0, regionByteCount, p, "ClipFrame region SSBO update"); } gl.BindBufferBase( BufferTargetARB.ShaderStorageBuffer, MeshClipSsboBinding, region.Name); GLHelpers.ThrowOnResourceError(gl, "ClipFrame region SSBO binding"); _regionSsbo = region.Name; _uploadState.MarkRegionsUploaded(); } /// Uploads the current terrain clip bytes into the next unique /// aligned arena record and installs that range at UBO binding 2. public unsafe TerrainClipBufferBinding UploadTerrainClip(GL gl) { ValidateGlContext(gl); int recordIndex = _uploadState.NextTerrainRecord(); TerrainBufferArena arena = _terrainBuffers.GetRequired(_dynamicFrameSlot); int offsetBytes = ClipFrameArenaLayout.RecordOffset( recordIndex, _terrainRecordStrideBytes); fixed (byte* p = _terrainBytes) { TrackedGlResource.UpdateBufferSubData( gl, GLEnum.UniformBuffer, arena.Name, offsetBytes, TerrainUboBytes, p, "ClipFrame terrain UBO range update"); } _terrainBinding = new TerrainClipBufferBinding( arena.Name, offsetBytes, TerrainUboBytes); _terrainBinding.Bind(gl); _terrainUbo = arena.Name; return _terrainBinding; } public void BindTerrainClip(GL gl) { ValidateGlContext(gl); if (!_terrainBinding.IsValid) throw new InvalidOperationException("No terrain clip range has been uploaded for this frame."); _terrainBinding.Bind(gl); } private RegionBuffer GetOrCreateRegionBuffer(GL gl) { if (_regionBuffers.TryGet(_dynamicFrameSlot, out RegionBuffer? existing)) return existing!; uint name = TrackedGlResource.CreateBuffer(gl, "ClipFrame region SSBO creation"); var created = new RegionBuffer { Name = name }; try { _regionBuffers.Set(_dynamicFrameSlot, created); return created; } catch { TrackedGlResource.DeleteBuffer(gl, name, 0, "ClipFrame region SSBO rollback"); throw; } } private TerrainBufferArena GetOrCreateTerrainArena(GL gl) { if (_terrainBuffers.TryGet(_dynamicFrameSlot, out TerrainBufferArena? existing)) return existing!; uint name = TrackedGlResource.CreateBuffer(gl, "ClipFrame terrain UBO creation"); var created = new TerrainBufferArena { Name = name }; try { _terrainBuffers.Set(_dynamicFrameSlot, created); return created; } catch { TrackedGlResource.DeleteBuffer(gl, name, 0, "ClipFrame terrain UBO rollback"); throw; } } private void ValidateGlContext(GL gl) { ArgumentNullException.ThrowIfNull(gl); ObjectDisposedException.ThrowIf(_disposed, this); if (!_dynamicFrameStarted) throw new InvalidOperationException("BeginFrame must be called before uploading clip data."); if (_gl is null) _gl = gl; else if (!ReferenceEquals(_gl, gl)) throw new InvalidOperationException("ClipFrame cannot span OpenGL contexts."); } public void Dispose() { if (_disposed || _disposing) return; _disposing = true; try { if (_disposeResources is null) { var releases = new List<(string Name, Action Release)>(); if (_gl is not null) { int regionIndex = 0; foreach (RegionBuffer set in _regionBuffers.Values) { RetryableGpuResourceRelease release = TrackedGlResource.CreateRetryableBufferDeletion( _gl, set.Name, set.CapacityBytes, "ClipFrame region SSBO disposal"); releases.Add(($"region-ssbo-{regionIndex++}", release.Run)); } int terrainIndex = 0; foreach (TerrainBufferArena arena in _terrainBuffers.Values) { RetryableGpuResourceRelease release = TrackedGlResource.CreateRetryableBufferDeletion( _gl, arena.Name, arena.CapacityBytes, "ClipFrame terrain UBO disposal"); releases.Add(($"terrain-ubo-{terrainIndex++}", release.Run)); } } _disposeResources = new RetryableResourceReleaseLedger(releases); } ResourceReleaseAttempt attempt = _disposeResources.Advance(); if (!_disposeResources.IsComplete) { throw attempt.ToException( "One or more ClipFrame GPU buffers could not be released."); } _regionSsbo = 0; _terrainUbo = 0; _terrainBinding = default; _dynamicFrameStarted = false; _disposeResources = null; _disposed = true; if (attempt.HasFailures) { throw attempt.ToException( "ClipFrame GPU buffers released with exceptional committed outcomes."); } } finally { _disposing = false; } } // ---- 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; } /// A single std140 terrain-clip record within a frame-slot UBO arena. public readonly record struct TerrainClipBufferBinding( uint Buffer, int OffsetBytes, int SizeBytes) { public bool IsValid => Buffer != 0 && OffsetBytes >= 0 && SizeBytes > 0; public void Bind(GL gl) { ArgumentNullException.ThrowIfNull(gl); if (!IsValid) throw new InvalidOperationException("Cannot bind an empty terrain clip range."); gl.BindBufferRange( BufferTargetARB.UniformBuffer, ClipFrame.TerrainClipUboBinding, Buffer, (nint)OffsetBytes, (nuint)SizeBytes); GLHelpers.ThrowOnResourceError(gl, "ClipFrame terrain UBO range binding"); } } internal static class ClipFrameArenaLayout { public static int RecordStride(int uniformBufferOffsetAlignment) { ArgumentOutOfRangeException.ThrowIfLessThan(uniformBufferOffsetAlignment, 1); long stride = ((long)ClipFrame.TerrainUboBytes + uniformBufferOffsetAlignment - 1L) / uniformBufferOffsetAlignment * uniformBufferOffsetAlignment; if (stride > int.MaxValue) throw new OverflowException("Terrain clip UBO record stride exceeds Int32.MaxValue."); return (int)stride; } public static int RecordOffset(int recordIndex, int recordStrideBytes) { ArgumentOutOfRangeException.ThrowIfNegative(recordIndex); ArgumentOutOfRangeException.ThrowIfLessThan(recordStrideBytes, ClipFrame.TerrainUboBytes); return checked(recordIndex * recordStrideBytes); } public static int RequiredBytes(int recordCount, int recordStrideBytes) { ArgumentOutOfRangeException.ThrowIfLessThan(recordCount, 1); ArgumentOutOfRangeException.ThrowIfLessThan(recordStrideBytes, ClipFrame.TerrainUboBytes); return checked(recordCount * recordStrideBytes); } } internal static class ClipBufferCapacityTransaction { /// Publishes capacity only after BufferData succeeds, and before /// any later upload/bind operation can throw. public static void Resize( ref int publishedCapacityBytes, int targetCapacityBytes, Action allocate) { ArgumentOutOfRangeException.ThrowIfNegative(publishedCapacityBytes); ArgumentOutOfRangeException.ThrowIfLessThan(targetCapacityBytes, 1); ArgumentNullException.ThrowIfNull(allocate); int previousCapacityBytes = publishedCapacityBytes; allocate(previousCapacityBytes, targetCapacityBytes); publishedCapacityBytes = targetCapacityBytes; } } /// /// Growth is immediate; shrinking requires repeated severe under-use. This /// avoids reallocating during ordinary portal-count jitter while ensuring a /// one-off pathological flood does not permanently pin its peak GPU storage. /// internal struct ClipBufferCapacityPolicy { internal const int ShrinkAfterUnderusedFrames = 3; internal const int ShrinkUtilizationDivisor = 4; private int _underusedFrames; public int SelectCapacity(int currentBytes, int requiredBytes) { ArgumentOutOfRangeException.ThrowIfNegative(currentBytes); ArgumentOutOfRangeException.ThrowIfLessThan(requiredBytes, 1); if (requiredBytes > currentBytes) { _underusedFrames = 0; return DynamicBufferCapacity.Grow(currentBytes, requiredBytes); } if ((long)requiredBytes * ShrinkUtilizationDivisor <= currentBytes) { _underusedFrames++; if (_underusedFrames >= ShrinkAfterUnderusedFrames) { _underusedFrames = 0; return DynamicBufferCapacity.Grow(0, requiredBytes); } } else { _underusedFrames = 0; } return currentBytes; } } /// Fixed-cardinality resource ownership indexed by the same frame /// slots protected by . internal sealed class ClipFrameResourceRing(int slotCount) where T : class { private readonly T?[] _slots = new T?[slotCount > 0 ? slotCount : throw new ArgumentOutOfRangeException(nameof(slotCount))]; public int Count { get; private set; } public bool TryGet(int slot, out T? resource) { ValidateSlot(slot); resource = _slots[slot]; return resource is not null; } public T GetRequired(int slot) { ValidateSlot(slot); return _slots[slot] ?? throw new InvalidOperationException($"Frame slot {slot} has no resource."); } public void Set(int slot, T resource) { ValidateSlot(slot); ArgumentNullException.ThrowIfNull(resource); if (_slots[slot] is not null) throw new InvalidOperationException($"Frame slot {slot} already owns a resource."); _slots[slot] = resource; Count++; } public IEnumerable Values { get { for (int i = 0; i < _slots.Length; i++) if (_slots[i] is T value) yield return value; } } private void ValidateSlot(int slot) { if ((uint)slot >= (uint)_slots.Length) throw new ArgumentOutOfRangeException(nameof(slot)); } } /// Pure sequencing state for one ClipFrame render submission. internal sealed class ClipFrameUploadState { private bool _frameStarted; private bool _regionsUploaded; private int _terrainReserved; private int _terrainCursor; public int TerrainReserved => _terrainReserved; public int TerrainUploaded => _terrainCursor; public bool RegionsUploaded => _regionsUploaded; public void BeginFrame() { _frameStarted = true; _regionsUploaded = false; _terrainReserved = 0; _terrainCursor = 0; } public void ValidateRegionsNotUploaded() { EnsureFrameStarted(); if (_regionsUploaded) throw new InvalidOperationException("Clip regions may be uploaded only once per frame."); } public void MarkRegionsUploaded() { ValidateRegionsNotUploaded(); _regionsUploaded = true; } public void ValidateTerrainReservation(int uploadCount) { EnsureFrameStarted(); ArgumentOutOfRangeException.ThrowIfLessThan(uploadCount, 1); if (_terrainReserved != 0) throw new InvalidOperationException("Terrain uploads have already been reserved for this frame."); } public void CommitTerrainReservation(int uploadCount) { ValidateTerrainReservation(uploadCount); _terrainReserved = uploadCount; } public int NextTerrainRecord() { EnsureFrameStarted(); if (_terrainReserved == 0) throw new InvalidOperationException("ReserveTerrainUploads must run before terrain clip uploads."); if (_terrainCursor >= _terrainReserved) throw new InvalidOperationException("Terrain clip uploads exceeded the reserved arena record count."); return _terrainCursor++; } private void EnsureFrameStarted() { if (!_frameStarted) throw new InvalidOperationException("BeginFrame must be called before uploading clip data."); } }