diff --git a/src/AcDream.App/Rendering/Gpu/Gl/GlDirtySlotRuns.cs b/src/AcDream.App/Rendering/Gpu/Gl/GlDirtySlotRuns.cs
new file mode 100644
index 00000000..bc0766a4
--- /dev/null
+++ b/src/AcDream.App/Rendering/Gpu/Gl/GlDirtySlotRuns.cs
@@ -0,0 +1,108 @@
+namespace AcDream.App.Rendering.Gpu.Gl;
+
+///
+/// Tracks which texture-table slots changed since the last flush and gives them
+/// back as maximal runs of consecutive dirty slots.
+///
+/// Why runs, and not one merged range. The ring can flush a single
+/// span covering everything written since the last draw, because every byte in
+/// that span was allocated this frame and nothing in flight reads it. The
+/// texture table cannot: it is a long-lived array of bindless handles, and two
+/// registrations in one frame can land on slots 5 and 50 with forty-four live
+/// slots in between. Those live slots are read by draws already submitted this
+/// frame, so a single mapped write over [5, 51) — mapped with
+/// GL_MAP_INVALIDATE_RANGE_BIT, which lets the driver discard the whole
+/// range's contents while it is mapped, and GL_MAP_UNSYNCHRONIZED_BIT,
+/// which stops it from waiting first — would expose those draws to torn
+/// handles. Splitting on the gaps keeps every mapped range made only of slots
+/// that no submitted draw can be reading.
+///
+/// Why a dirty slot is safe to write unsynchronized. Two producers
+/// touch a slot: GlGpuDevice.RegisterTexture, which writes a slot fresh
+/// from and therefore one no batch has ever
+/// indexed; and ReleaseTextureSlot's zeroing write, which runs inside a
+/// retirement callback, i.e. after the fence covering every frame that could
+/// still have referenced it. Both are already past the point where the GPU can
+/// read the old value — which is exactly the assertion
+/// GL_MAP_UNSYNCHRONIZED_BIT makes.
+///
+/// GL-free by design, like and
+/// , so the bookkeeping is unit tested
+/// without a context. Enumeration is allocation-free: the tracker keeps the
+/// min/max window that has been dirtied so a clean table costs one comparison,
+/// and a dirty one scans only that window.
+///
+internal sealed class GlDirtySlotRuns
+{
+ private readonly bool[] _dirty;
+ private int _windowStart = -1;
+ private int _windowEnd = -1;
+
+ public GlDirtySlotRuns(uint capacity)
+ {
+ ArgumentOutOfRangeException.ThrowIfZero(capacity);
+ _dirty = new bool[capacity];
+ }
+
+ public uint Capacity => (uint)_dirty.Length;
+
+ public bool HasDirtySlots => _windowStart >= 0;
+
+ public void Mark(uint slot)
+ {
+ if (slot >= (uint)_dirty.Length)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(slot),
+ slot,
+ $"The tracked table holds {_dirty.Length} slots.");
+ }
+
+ _dirty[slot] = true;
+ _windowStart = _windowStart < 0 ? (int)slot : Math.Min(_windowStart, (int)slot);
+ _windowEnd = Math.Max(_windowEnd, (int)slot + 1);
+ }
+
+ ///
+ /// Takes the lowest remaining run of consecutive dirty slots, clearing it,
+ /// and reports its first slot and length. Returns false once none
+ /// remain, so a caller drains with a while loop.
+ ///
+ public bool TryTakeNextRun(out uint firstSlot, out uint slotCount)
+ {
+ firstSlot = 0;
+ slotCount = 0;
+ if (_windowStart < 0)
+ return false;
+
+ int index = _windowStart;
+ while (index < _windowEnd && !_dirty[index])
+ index++;
+ if (index >= _windowEnd)
+ {
+ CloseWindow();
+ return false;
+ }
+
+ int start = index;
+ while (index < _windowEnd && _dirty[index])
+ {
+ _dirty[index] = false;
+ index++;
+ }
+
+ firstSlot = (uint)start;
+ slotCount = (uint)(index - start);
+ if (index >= _windowEnd)
+ CloseWindow();
+ else
+ _windowStart = index;
+ return true;
+ }
+
+ private void CloseWindow()
+ {
+ _windowStart = -1;
+ _windowEnd = -1;
+ }
+}
diff --git a/src/AcDream.App/Rendering/Gpu/Gl/GlGpuBuffer.cs b/src/AcDream.App/Rendering/Gpu/Gl/GlGpuBuffer.cs
index e37a8628..63ce8249 100644
--- a/src/AcDream.App/Rendering/Gpu/Gl/GlGpuBuffer.cs
+++ b/src/AcDream.App/Rendering/Gpu/Gl/GlGpuBuffer.cs
@@ -15,9 +15,13 @@ namespace AcDream.App.Rendering.Gpu.Gl;
/// surface without forking per usage.
///
/// Allocated once at the description's size via glBufferData with null
-/// data; every write after that is glBufferSubData — no persistent
-/// mapping, matching the campaign's "GL backend is deliberately behaviour-
-/// preserving" rule for this slice.
+/// data. There are two write paths after that, and which one applies is a
+/// property of the buffer's role rather than of the buffer object:
+/// is an ordinary synchronized glBufferSubData, used
+/// for one-off and streaming writes the driver must order for us (the mesh
+/// arena, texture staging); maps the
+/// range and is used only by the per-frame upload ring, whose non-overlap
+/// invariant the caller can state. Neither path holds a persistent mapping.
///
/// The glBufferData usage hint follows
/// :
@@ -136,6 +140,89 @@ internal sealed class GlGpuBuffer : IGpuBuffer
_gl.BindBuffer(GLEnum.CopyWriteBuffer, 0);
}
+ ///
+ /// Writes at through
+ /// glMapBufferRange with
+ /// GL_MAP_WRITE_BIT | GL_MAP_UNSYNCHRONIZED_BIT | GL_MAP_INVALIDATE_RANGE_BIT
+ /// — the canonical GL upload-ring idiom, and the only write path the
+ /// per-frame ring uses.
+ ///
+ /// Why not . A partial glBufferSubData
+ /// into a buffer object that already-submitted draws are still reading leaves
+ /// the driver to guess how to keep both truths alive: it can stall, it can
+ /// rename the whole data store and copy the untouched remainder forward, or
+ /// it can route the write through an internal staging copy. Which one it
+ /// picks is a heuristic, and the heuristic is fed by the update pattern —
+ /// Campaign V's V4c revert (plan §5.5) saw a world that rendered blank
+ /// roughly one launch in three, with no GL error at any point, on the
+ /// connected path that issues 10–40 such partial updates per frame, and zero
+ /// times on the offline path that issues 2–4. Mapping the range instead
+ /// removes the guess: the three bits together say "I am writing this range,
+ /// I am overwriting all of it, and nothing in flight reads it," which is
+ /// exactly the ring's actual invariant.
+ ///
+ /// The precondition is the caller's to prove.
+ /// GL_MAP_UNSYNCHRONIZED_BIT means the driver inserts no wait, so the
+ /// caller asserts that no GL command already submitted and not yet complete
+ /// reads any byte of [offsetBytes, offsetBytes + data.Length). The ring
+ /// has that invariant on both axes: within a frame the allocation cursor only
+ /// moves forward and refuses a write below the
+ /// flushed high-water mark, and across frames a slot is only rewritten after
+ /// GpuFrameFlightController.BeginFrame has waited on the fence for the
+ /// last frame that used it. GL_MAP_INVALIDATE_RANGE_BIT is likewise
+ /// only sound because every byte of the mapped range is then written.
+ ///
+ /// A false return from glUnmapBuffer means the data store
+ /// was lost while mapped (a GPU reset or display-mode change), so the write
+ /// did not land. That throws rather than being retried: the frame's uploads
+ /// are already partly gone, and silently continuing would draw from a
+ /// half-written ring.
+ ///
+ internal unsafe void WriteRangeUnsynchronized(long offsetBytes, ReadOnlySpan data, string context)
+ {
+ ThrowIfDisposed();
+ if (offsetBytes < 0 || offsetBytes + data.Length > SizeBytes)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(offsetBytes),
+ $"Mapped write of {data.Length} bytes at offset {offsetBytes} exceeds buffer '{Name}' ({SizeBytes} bytes).");
+ }
+ if (data.IsEmpty)
+ return;
+
+ _gl.BindBuffer(GLEnum.CopyWriteBuffer, _name);
+ void* mapped = _gl.MapBufferRange(
+ GLEnum.CopyWriteBuffer,
+ (nint)offsetBytes,
+ (nuint)data.Length,
+ MapBufferAccessMask.WriteBit
+ | MapBufferAccessMask.UnsynchronizedBit
+ | MapBufferAccessMask.InvalidateRangeBit);
+ if (mapped is null)
+ {
+ // A null return always sets a GL error, so surface that first — it
+ // names the actual rejection instead of the symptom.
+ GLHelpers.ThrowOnResourceError(
+ _gl,
+ $"map {data.Length} bytes of buffer '{Name}' at offset {offsetBytes} ({context})");
+ throw new InvalidOperationException(
+ $"glMapBufferRange returned no pointer for {data.Length} bytes of buffer '{Name}' " +
+ $"at offset {offsetBytes} ({context}).");
+ }
+
+ data.CopyTo(new Span(mapped, data.Length));
+
+ bool unmapped = _gl.UnmapBuffer(GLEnum.CopyWriteBuffer);
+ GLHelpers.ThrowOnResourceError(_gl, $"unmap buffer '{Name}' after writing {context}");
+ _gl.BindBuffer(GLEnum.CopyWriteBuffer, 0);
+ if (!unmapped)
+ {
+ throw new InvalidOperationException(
+ $"Buffer '{Name}' lost its data store while {data.Length} bytes at offset " +
+ $"{offsetBytes} ({context}) were mapped; the write did not land.");
+ }
+ }
+
public void CopyTo(IGpuBuffer destination, long sourceOffsetBytes, long destinationOffsetBytes, long byteCount)
{
ThrowIfDisposed();
diff --git a/src/AcDream.App/Rendering/Gpu/Gl/GlGpuDevice.cs b/src/AcDream.App/Rendering/Gpu/Gl/GlGpuDevice.cs
index dd329df8..aeeafba4 100644
--- a/src/AcDream.App/Rendering/Gpu/Gl/GlGpuDevice.cs
+++ b/src/AcDream.App/Rendering/Gpu/Gl/GlGpuDevice.cs
@@ -33,10 +33,21 @@ namespace AcDream.App.Rendering.Gpu.Gl;
/// piece that enforces this.
///
/// Flush discipline. Ring bytes and the texture-handle table are
-/// both flushed with one glBufferSubData each, immediately before
-/// every Draw/DrawIndexed/MultiDrawIndexedIndirect — see
+/// both flushed immediately before every
+/// Draw/DrawIndexed/MultiDrawIndexedIndirect — see
/// — never at bind time, so a renderer that
-/// writes after binding still uploads correctly.
+/// writes after binding still uploads correctly. Both flushes go through
+/// , i.e.
+/// glMapBufferRange with
+/// GL_MAP_WRITE_BIT | GL_MAP_UNSYNCHRONIZED_BIT | GL_MAP_INVALIDATE_RANGE_BIT,
+/// rather than glBufferSubData: these are the only two buffers this
+/// backend rewrites while the frame's own draws are still in flight, and that
+/// is precisely the case where a partial glBufferSubData leaves the
+/// result to a driver heuristic. See that method's remarks and campaign plan
+/// §5.5 for the failure it produced. The two flushes differ in shape because
+/// their non-overlap proofs differ: the ring writes one span (its cursor only
+/// moves forward within a frame), the table writes one span per run of
+/// consecutive dirty slots (see ).
///
internal sealed class GlGpuDevice : IGpuDevice
{
@@ -64,8 +75,8 @@ internal sealed class GlGpuDevice : IGpuDevice
new(GpuBindingModel.TextureTableCapacity);
private readonly ulong[] _textureHandleTable = new ulong[GpuBindingModel.TextureTableCapacity];
private readonly GlGpuBuffer _textureTableBuffer;
- private int _textureTableDirtyStart = -1;
- private int _textureTableDirtyEnd = -1;
+ private readonly GlDirtySlotRuns _textureTableDirtySlots =
+ new(GpuBindingModel.TextureTableCapacity);
private readonly GlRenderStateCache _renderState = new();
private readonly GlGpuPushConstantBinder _pushConstants;
@@ -260,28 +271,37 @@ internal sealed class GlGpuDevice : IGpuDevice
}
///
- /// Uploads this slot's dirty ring bytes and the texture table's dirty
- /// range (if any) with one glBufferSubData each, then clears both
- /// watermarks. Called immediately before every draw — never at bind time,
- /// because a renderer may still write into a ring allocation after
- /// binding it.
+ /// Writes this slot's dirty ring bytes and every dirty run of the texture
+ /// table into their GL buffers, then clears both watermarks. Called
+ /// immediately before every draw — never at bind time, because a renderer
+ /// may still write into a ring allocation after binding it.
+ ///
+ /// Both writes are mapped and unsynchronized (see the class remarks and
+ /// ). The ring's dirty
+ /// span may include alignment padding between allocations; those bytes are
+ /// below the cursor, were allocated this frame, and are read by nothing, so
+ /// covering them in one map is sound and saves a call. The table has no
+ /// equivalent licence, which is why it drains run by run.
///
internal void FlushBeforeDraw(int slotIndex)
{
(int start, int length) = _ringStates[slotIndex].TakeDirtyRange();
if (length > 0)
- _ringBuffers[slotIndex].Upload(start, _ringStaging[slotIndex].AsSpan(start, length));
-
- if (_textureTableDirtyStart >= 0)
{
- int tableStart = _textureTableDirtyStart;
- int tableEnd = _textureTableDirtyEnd;
- _textureTableDirtyStart = -1;
- _textureTableDirtyEnd = -1;
+ _ringBuffers[slotIndex].WriteRangeUnsynchronized(
+ start,
+ _ringStaging[slotIndex].AsSpan(start, length),
+ $"ring slot {slotIndex}");
+ }
+ while (_textureTableDirtySlots.TryTakeNextRun(out uint firstSlot, out uint slotCount))
+ {
ReadOnlySpan bytes = MemoryMarshal.AsBytes(
- _textureHandleTable.AsSpan(tableStart, tableEnd - tableStart));
- _textureTableBuffer.Upload((long)tableStart * sizeof(ulong), bytes);
+ _textureHandleTable.AsSpan((int)firstSlot, (int)slotCount));
+ _textureTableBuffer.WriteRangeUnsynchronized(
+ (long)firstSlot * sizeof(ulong),
+ bytes,
+ $"texture table slots {firstSlot}..{firstSlot + slotCount - 1}");
}
}
@@ -531,10 +551,7 @@ internal sealed class GlGpuDevice : IGpuDevice
private void WriteHandle(uint slot, ulong handle)
{
_textureHandleTable[slot] = handle;
- _textureTableDirtyStart = _textureTableDirtyStart < 0
- ? (int)slot
- : Math.Min(_textureTableDirtyStart, (int)slot);
- _textureTableDirtyEnd = Math.Max(_textureTableDirtyEnd, (int)slot + 1);
+ _textureTableDirtySlots.Mark(slot);
}
private GpuCapabilityRecord CaptureCapabilities()
@@ -568,9 +585,9 @@ internal sealed class GlGpuDevice : IGpuDevice
SupportsDrawParameters = _gl.IsExtensionPresent("GL_ARB_shader_draw_parameters"),
SupportsTextureCompressionBc = _gl.IsExtensionPresent("GL_EXT_texture_compression_s3tc"),
SupportsTimestampQueries = timerQuery,
- // The GL backend deliberately never writes into mapped memory —
- // it keeps BufferSubData uploads so every renderer port slice's
- // pixel gate is a strict identity check. Only Vulkan reports true.
+ // The ring maps and unmaps per flush; it never holds a persistent
+ // mapping a renderer could write into between frames, which is what
+ // this capability advertises. Only Vulkan reports true.
SupportsPersistentlyMappedRings = false,
};
}
diff --git a/src/AcDream.App/Rendering/Gpu/Gl/GlRingBufferState.cs b/src/AcDream.App/Rendering/Gpu/Gl/GlRingBufferState.cs
index 1c7caca6..1771013d 100644
--- a/src/AcDream.App/Rendering/Gpu/Gl/GlRingBufferState.cs
+++ b/src/AcDream.App/Rendering/Gpu/Gl/GlRingBufferState.cs
@@ -2,22 +2,38 @@ namespace AcDream.App.Rendering.Gpu.Gl;
///
/// Pure bookkeeping for one flight slot's upload ring: an allocation cursor
-/// that only grows across a frame, plus a separate "dirty" watermark that
-/// tracks the byte range written since the last flush.
+/// that only grows across a frame, a "dirty" watermark tracking the byte range
+/// written since the last flush, and the flushed high-water mark that makes the
+/// ring's write path safe to issue unsynchronized.
///
/// This is deliberately GL-free so it can be unit tested without a live
-/// context. owns one instance per flight slot and
-/// pairs it with a managed staging byte[] and a real GL buffer; the
-/// staging array receives every write,
-/// and immediately before each Draw/DrawIndexed/
-/// MultiDrawIndexedIndirect the device uploads exactly the dirty range
-/// via one glBufferSubData call and calls
-/// to reset the watermark. The allocation cursor itself only resets at
-/// (once per BeginFrame) — writes made after a
-/// flush simply extend the dirty range again, to be picked up by the next
-/// flush. This is what makes "flush before every draw, not at bind time"
-/// correct: a renderer that writes after binding still gets uploaded before
-/// its draw call runs.
+/// context. 's device owns one instance per flight slot
+/// and pairs it with a managed staging byte[] and a real GL buffer; the
+/// staging array receives every write, and
+/// immediately before each Draw/DrawIndexed/
+/// MultiDrawIndexedIndirect the device writes exactly the dirty range
+/// into the GL buffer through
+/// and calls
+/// to reset the watermark. The allocation cursor
+/// itself only resets at (once per BeginFrame) —
+/// writes made after a flush simply extend the dirty range again, to be picked
+/// up by the next flush. This is what makes "flush before every draw, not at
+/// bind time" correct: a renderer that writes after binding still gets uploaded
+/// before its draw call runs.
+///
+/// The forward-only invariant is load-bearing, not incidental. The
+/// device's per-flush write is mapped with GL_MAP_UNSYNCHRONIZED_BIT,
+/// which means the driver inserts no wait and the caller is asserting that no
+/// submitted-and-unfinished draw reads the range. Within a frame that holds
+/// because only ever hands out bytes at or above the
+/// cursor, so each flush covers a range strictly above every range already
+/// flushed. Rather than leave that as an emergent property of the arithmetic,
+/// refuses a write below :
+/// a future change that reused ring bytes mid-frame would fail loudly here
+/// instead of producing an undefined read on the GPU. Across frames the
+/// invariant belongs to GpuFrameFlightController, which waits on a
+/// slot's fence in BeginFrame before this state is .
+///
///
internal sealed class GlRingBufferState
{
@@ -25,6 +41,7 @@ internal sealed class GlRingBufferState
private uint _cursor;
private int _dirtyStart = -1;
private int _dirtyEnd = -1;
+ private int _flushedEnd;
public GlRingBufferState(int capacityBytes)
{
@@ -37,6 +54,12 @@ internal sealed class GlRingBufferState
/// Bytes handed out since the last .
public uint AllocatedBytes => _cursor;
+ ///
+ /// The exclusive end of the bytes already handed to the GPU this frame.
+ /// Every subsequent write must start at or above it.
+ ///
+ public int FlushedEndBytes => _flushedEnd;
+
///
/// Reserves bytes aligned to
/// , returning the aligned offset.
@@ -69,11 +92,13 @@ internal sealed class GlRingBufferState
_cursor = 0;
_dirtyStart = -1;
_dirtyEnd = -1;
+ _flushedEnd = 0;
}
///
/// Returns the byte range written since the last flush (start, length),
- /// or (0, 0) when nothing is dirty, and clears the watermark.
+ /// or (0, 0) when nothing is dirty, and clears the watermark while
+ /// advancing over it.
///
public (int Start, int Length) TakeDirtyRange()
{
@@ -81,6 +106,7 @@ internal sealed class GlRingBufferState
return (0, 0);
(int start, int length) = (_dirtyStart, _dirtyEnd - _dirtyStart);
+ _flushedEnd = Math.Max(_flushedEnd, _dirtyEnd);
_dirtyStart = -1;
_dirtyEnd = -1;
return (start, length);
@@ -88,8 +114,23 @@ internal sealed class GlRingBufferState
public bool HasDirtyBytes => _dirtyStart >= 0;
- private void MarkDirty(int start, int end)
+ ///
+ /// Records that [start, end) was written. Internal rather than
+ /// private for the same reason is: the forward-only
+ /// guard below is unreachable through by
+ /// construction, so proving it fires at all needs a direct call.
+ ///
+ internal void MarkDirty(int start, int end)
{
+ if (start < _flushedEnd)
+ {
+ throw new InvalidOperationException(
+ $"Ring write [{start}, {end}) reaches below the {_flushedEnd} bytes already " +
+ "uploaded this frame. Ring uploads are issued with GL_MAP_UNSYNCHRONIZED_BIT, " +
+ "so rewriting bytes a submitted draw may still be reading is undefined — the " +
+ "allocation cursor must only move forward until Reset.");
+ }
+
_dirtyStart = _dirtyStart < 0 ? start : Math.Min(_dirtyStart, start);
_dirtyEnd = Math.Max(_dirtyEnd, end);
}
diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/Gl/GlDirtySlotRunsTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/Gl/GlDirtySlotRunsTests.cs
new file mode 100644
index 00000000..1e67e2da
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/Gpu/Gl/GlDirtySlotRunsTests.cs
@@ -0,0 +1,198 @@
+using AcDream.App.Rendering.Gpu.Gl;
+
+namespace AcDream.App.Tests.Rendering.Gpu.Gl;
+
+///
+/// The texture table's flush is mapped with GL_MAP_UNSYNCHRONIZED_BIT and
+/// GL_MAP_INVALIDATE_RANGE_BIT, so a run this tracker reports must contain
+/// nothing but slots that were actually written. A run that swallowed a clean
+/// slot in between would let the driver discard a live bindless handle while a
+/// submitted draw was reading it — invisible in any pixel gate, and exactly the
+/// class of fault the ring rewrite exists to remove. These tests pin that.
+///
+public sealed class GlDirtySlotRunsTests
+{
+ [Fact]
+ public void NothingMarkedYieldsNoRuns()
+ {
+ var runs = new GlDirtySlotRuns(16);
+
+ Assert.False(runs.HasDirtySlots);
+ Assert.False(runs.TryTakeNextRun(out _, out _));
+ }
+
+ [Fact]
+ public void OneMarkedSlotIsOneRunOfOne()
+ {
+ var runs = new GlDirtySlotRuns(16);
+ runs.Mark(7);
+
+ Assert.True(runs.HasDirtySlots);
+ Assert.True(runs.TryTakeNextRun(out uint first, out uint count));
+ Assert.Equal(7u, first);
+ Assert.Equal(1u, count);
+ Assert.False(runs.TryTakeNextRun(out _, out _));
+ }
+
+ [Fact]
+ public void ConsecutiveSlotsMergeIntoOneRun()
+ {
+ var runs = new GlDirtySlotRuns(16);
+ runs.Mark(4);
+ runs.Mark(5);
+ runs.Mark(6);
+
+ Assert.True(runs.TryTakeNextRun(out uint first, out uint count));
+ Assert.Equal(4u, first);
+ Assert.Equal(3u, count);
+ Assert.False(runs.TryTakeNextRun(out _, out _));
+ }
+
+ [Fact]
+ public void AGapBetweenMarkedSlotsSplitsTheRuns()
+ {
+ var runs = new GlDirtySlotRuns(64);
+ runs.Mark(5);
+ runs.Mark(50);
+
+ Assert.True(runs.TryTakeNextRun(out uint firstStart, out uint firstCount));
+ Assert.Equal(5u, firstStart);
+ Assert.Equal(1u, firstCount);
+
+ Assert.True(runs.TryTakeNextRun(out uint secondStart, out uint secondCount));
+ Assert.Equal(50u, secondStart);
+ Assert.Equal(1u, secondCount);
+
+ Assert.False(runs.TryTakeNextRun(out _, out _));
+ }
+
+ [Fact]
+ public void RunsComeBackInAscendingSlotOrderRegardlessOfMarkOrder()
+ {
+ var runs = new GlDirtySlotRuns(64);
+ runs.Mark(40);
+ runs.Mark(1);
+ runs.Mark(41);
+ runs.Mark(20);
+ runs.Mark(0);
+
+ Assert.True(runs.TryTakeNextRun(out uint start, out uint count));
+ Assert.Equal(0u, start);
+ Assert.Equal(2u, count);
+
+ Assert.True(runs.TryTakeNextRun(out start, out count));
+ Assert.Equal(20u, start);
+ Assert.Equal(1u, count);
+
+ Assert.True(runs.TryTakeNextRun(out start, out count));
+ Assert.Equal(40u, start);
+ Assert.Equal(2u, count);
+
+ Assert.False(runs.TryTakeNextRun(out _, out _));
+ }
+
+ [Fact]
+ public void MarkingTheSameSlotTwiceStillYieldsOneRun()
+ {
+ var runs = new GlDirtySlotRuns(16);
+ runs.Mark(3);
+ runs.Mark(3);
+
+ Assert.True(runs.TryTakeNextRun(out uint start, out uint count));
+ Assert.Equal(3u, start);
+ Assert.Equal(1u, count);
+ Assert.False(runs.TryTakeNextRun(out _, out _));
+ }
+
+ [Fact]
+ public void DrainingLeavesTheTrackerCleanForTheNextFlush()
+ {
+ var runs = new GlDirtySlotRuns(16);
+ runs.Mark(2);
+ runs.Mark(9);
+ while (runs.TryTakeNextRun(out _, out _))
+ {
+ }
+
+ Assert.False(runs.HasDirtySlots);
+
+ runs.Mark(11);
+ Assert.True(runs.TryTakeNextRun(out uint start, out uint count));
+ Assert.Equal(11u, start);
+ Assert.Equal(1u, count);
+ Assert.False(runs.TryTakeNextRun(out _, out _));
+ }
+
+ [Fact]
+ public void SlotsMarkedAfterAPartialDrainAreStillReported()
+ {
+ var runs = new GlDirtySlotRuns(32);
+ runs.Mark(4);
+ runs.Mark(20);
+
+ Assert.True(runs.TryTakeNextRun(out uint start, out uint count));
+ Assert.Equal(4u, start);
+ Assert.Equal(1u, count);
+
+ // A draw between two flushes can register another texture.
+ runs.Mark(21);
+
+ Assert.True(runs.TryTakeNextRun(out start, out count));
+ Assert.Equal(20u, start);
+ Assert.Equal(2u, count);
+ Assert.False(runs.TryTakeNextRun(out _, out _));
+ }
+
+ [Fact]
+ public void TheLastSlotInTheTableIsAddressable()
+ {
+ var runs = new GlDirtySlotRuns(8);
+ runs.Mark(7);
+
+ Assert.True(runs.TryTakeNextRun(out uint start, out uint count));
+ Assert.Equal(7u, start);
+ Assert.Equal(1u, count);
+ }
+
+ [Fact]
+ public void MarkingBeyondCapacityThrows()
+ {
+ var runs = new GlDirtySlotRuns(8);
+ Assert.Throws(() => runs.Mark(8));
+ }
+
+ [Fact]
+ public void ZeroCapacityIsRejected()
+ {
+ Assert.Throws(() => new GlDirtySlotRuns(0));
+ }
+
+ ///
+ /// The whole point, stated as one property: every slot a run covers was
+ /// marked, and every marked slot is covered exactly once.
+ ///
+ [Fact]
+ public void EveryReportedSlotWasMarkedAndEveryMarkedSlotIsReportedOnce()
+ {
+ const int Capacity = 200;
+ var random = new Random(20260727);
+ var expected = new HashSet();
+ var runs = new GlDirtySlotRuns(Capacity);
+ for (int i = 0; i < 60; i++)
+ {
+ uint slot = (uint)random.Next(Capacity);
+ expected.Add(slot);
+ runs.Mark(slot);
+ }
+
+ var reported = new List();
+ while (runs.TryTakeNextRun(out uint start, out uint count))
+ {
+ for (uint slot = start; slot < start + count; slot++)
+ reported.Add(slot);
+ }
+
+ Assert.Equal(reported.Count, reported.Distinct().Count());
+ Assert.Equal(expected.OrderBy(slot => slot), reported);
+ }
+}
diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/Gl/GlRingBufferStateTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/Gl/GlRingBufferStateTests.cs
index 163aa76a..4bf01f18 100644
--- a/tests/AcDream.App.Tests/Rendering/Gpu/Gl/GlRingBufferStateTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/Gpu/Gl/GlRingBufferStateTests.cs
@@ -63,6 +63,76 @@ public sealed class GlRingBufferStateTests
Assert.Equal(8, length);
}
+ ///
+ /// The precondition for issuing the ring's writes with
+ /// GL_MAP_UNSYNCHRONIZED_BIT: within one frame, no flush may cover a byte an
+ /// earlier flush already handed to the GPU. This walks a frame's worth of
+ /// mixed-alignment allocations and asserts the ranges are disjoint and
+ /// ascending.
+ ///
+ [Fact]
+ public void SuccessiveDirtyRangesWithinAFrameNeverOverlap()
+ {
+ var state = new GlRingBufferState(64 * 1024);
+ int previousEnd = 0;
+
+ foreach (int size in new[] { 100, 4, 1024, 36, 7, 512, 3 })
+ {
+ state.Allocate(size, alignmentBytes: 256);
+ state.Allocate(size, alignmentBytes: 4);
+
+ (int start, int length) = state.TakeDirtyRange();
+ Assert.True(
+ start >= previousEnd,
+ $"flush [{start}, {start + length}) reaches below the already-flushed {previousEnd} bytes");
+ previousEnd = start + length;
+ Assert.Equal(previousEnd, state.FlushedEndBytes);
+ }
+ }
+
+ [Fact]
+ public void AWriteBelowTheFlushedHighWaterMarkIsRefused()
+ {
+ var state = new GlRingBufferState(1024);
+ state.Allocate(128, alignmentBytes: 4);
+ state.TakeDirtyRange();
+
+ Assert.Equal(128, state.FlushedEndBytes);
+ InvalidOperationException error = Assert.Throws(
+ () => state.MarkDirty(64, 96));
+ Assert.Contains("GL_MAP_UNSYNCHRONIZED_BIT", error.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void FlushedHighWaterMarkOnlyAdvancesWhenSomethingWasFlushed()
+ {
+ var state = new GlRingBufferState(1024);
+ state.Allocate(48, alignmentBytes: 4);
+ state.TakeDirtyRange();
+ Assert.Equal(48, state.FlushedEndBytes);
+
+ // A draw with nothing newly written must not move the mark, or the next
+ // allocation's guard would compare against a number no flush produced.
+ state.TakeDirtyRange();
+ Assert.Equal(48, state.FlushedEndBytes);
+ }
+
+ [Fact]
+ public void ResetClearsTheFlushedHighWaterMarkSoTheSlotIsWritableAgain()
+ {
+ var state = new GlRingBufferState(1024);
+ state.Allocate(256, alignmentBytes: 4);
+ state.TakeDirtyRange();
+ Assert.Equal(256, state.FlushedEndBytes);
+
+ // BeginFrame has waited on this slot's fence by the time Reset runs, so
+ // the whole slot is writable from zero again.
+ state.Reset();
+
+ Assert.Equal(0, state.FlushedEndBytes);
+ Assert.Equal(0u, state.Allocate(64, alignmentBytes: 4));
+ }
+
[Fact]
public void ResetRewindsTheCursorAndClearsDirtyState()
{