fix(render): map the GL frame ring instead of glBufferSubData

Campaign V's V4c and V4d were reverted because the connected world went blank
roughly one launch in three, with no GL error anywhere and every added CPU-GPU
sync point suppressing it. The plan's section 5.5 records the best-supported
cause and makes this change binding before either slice may re-land: the frame
ring performed 10-40 partial glBufferSubData updates per frame into a buffer
object that already-submitted same-frame draws were still reading, and the
offline path that passed every gate issues only 2-4. That is the offline versus
connected axis, stated exactly.

A partial glBufferSubData into an in-use buffer does not have one defined
implementation. The driver may stall, may rename the whole data store and copy
the untouched remainder forward, or may route the write through an internal
staging copy, and which one it picks is a heuristic fed by the update pattern.
glMapBufferRange with GL_MAP_WRITE_BIT, GL_MAP_UNSYNCHRONIZED_BIT and
GL_MAP_INVALIDATE_RANGE_BIT removes the guess. The three bits say "I am writing
this range", "I am overwriting all of it", and "nothing in flight reads it" -
which is the ring's actual invariant rather than something the driver has to
infer. GlGpuBuffer.WriteRangeUnsynchronized is that write, and the ring no
longer calls Upload at all. Upload itself stays, synchronized, for the writers
whose ordering really is the driver's job: the mesh arena and texture staging.

The unsynchronized bit is an assertion, so the two invariants behind it are now
enforced rather than merely true. Across frames it belongs to
GpuFrameFlightController, which waits on a slot's fence in BeginFrame before
GlRingBufferState.Reset rewinds that slot. Within a frame it belongs to the
allocation cursor, which only moves forward, so each flush covers bytes strictly
above every byte already flushed. GlRingBufferState now carries the flushed
high-water mark explicitly and refuses a write below it, so a future change that
reused ring bytes mid-frame fails loudly here instead of producing an undefined
read on the GPU. MarkDirty is internal for the same reason AlignUp already was:
the guard is unreachable through Allocate by construction, and proving it fires
needs a direct call.

The texture handle table moved too, because it is the only other buffer this
backend rewrites while the frame's own draws are in flight, and leaving one
partial glBufferSubData in the pre-draw flush would have left a live instance of
the same mechanism sitting inside the very function this change exists to fix.
It cannot use the ring's single merged span: two registrations in one frame can
land on slots 5 and 50 with forty-four live slots between them, and a mapped
invalidating write over that whole span would let the driver discard live
bindless handles a submitted draw is reading. GlDirtySlotRuns therefore drains
the table one run of consecutive dirty slots at a time. Every slot in a run is
safe on its own terms: RegisterTexture writes a slot fresh from the allocator
that no batch has ever indexed, and ReleaseTextureSlot's zeroing write already
runs inside a retirement callback, after the fence covering every frame that
could still reference it.

Nothing about renderer-visible behaviour changes. No renderer, no shader and no
CPU data layout is touched; only how the same bytes reach the same buffers.
SupportsPersistentlyMappedRings stays false, since a map-per-flush is not a
persistent mapping - its comment was rewritten because it claimed the backend
never writes into mapped memory, which is no longer true.

Gates. Release build green. App tests 3,862 passed / 3 skipped, against a
3,846 / 3 baseline measured on this tree plus the 16 tests added here (one
full-suite baseline run failed WorldRenderFrameBuilder's runtime-root-source
test, which passes alone and passed on the rerun - a pre-existing ordering
flake, not a regression). Offline pixel gate against 61f3c5d8: 30 differing
pixels of 563,200 compared, a fraction of 5.33e-05, nineteen times under the
0.001 threshold. Four captures were taken to bound the noise rather than assume
it: two same-commit control pairs differ by 15 and 12 pixels, and the three
cross-capture pairs by 30, 27 and 30, with comparable maximum channel deltas
throughout. The difference is capture noise in the animated surfaces, not a
rendering change.

This commit is the precondition, not the re-land. V4c follows as a
revert-of-its-revert on top of this ring, gated by the repeat-run connected gate
at ten of ten rendered.

No divergence-register row: this changes no retail-facing behaviour.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-27 23:00:28 +02:00
parent 61f3c5d803
commit 8dec163fd8
6 changed files with 566 additions and 45 deletions

View file

@ -0,0 +1,198 @@
using AcDream.App.Rendering.Gpu.Gl;
namespace AcDream.App.Tests.Rendering.Gpu.Gl;
/// <summary>
/// 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.
/// </summary>
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<ArgumentOutOfRangeException>(() => runs.Mark(8));
}
[Fact]
public void ZeroCapacityIsRejected()
{
Assert.Throws<ArgumentOutOfRangeException>(() => new GlDirtySlotRuns(0));
}
/// <summary>
/// The whole point, stated as one property: every slot a run covers was
/// marked, and every marked slot is covered exactly once.
/// </summary>
[Fact]
public void EveryReportedSlotWasMarkedAndEveryMarkedSlotIsReportedOnce()
{
const int Capacity = 200;
var random = new Random(20260727);
var expected = new HashSet<uint>();
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<uint>();
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);
}
}

View file

@ -63,6 +63,76 @@ public sealed class GlRingBufferStateTests
Assert.Equal(8, length);
}
/// <summary>
/// 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.
/// </summary>
[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<InvalidOperationException>(
() => 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()
{