fix(render) Campaign FW3.4a: rebind sections per DrawOrderedRange - the device-lost fix

The dense-Arwic re-measure crashed with VK_ERROR_DEVICE_LOST: between
ordered ranges the walk leaf draws (terrain, shells, sky, punch fans)
and RetailAlphaQueue flushes rebind the SAME set-0 storage slots to
their own sections, so the bind-once latch made the next range draw
against foreign buffers - out-of-bounds instance reads and a GPU
fault. Sections now re-bind on every DrawOrderedRange call, exactly
like the proven DrawPreparedAlphaBatchRhi; the once-per-frame ring
WRITES in PrepareOrderedStream (the actual measured cost) are
unchanged. The bind-once referee test flips to assert per-range
rebinds with unchanged draw coverage.

Suites: full Release build 0 warnings; hermetic 6,758/0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-30 17:07:29 +02:00
parent 212f5a12e5
commit 1da178d9ba
2 changed files with 28 additions and 20 deletions

View file

@ -297,7 +297,6 @@ public sealed unsafe partial class WbDrawDispatcher
private OrderedDrawStream? _orderedStream; private OrderedDrawStream? _orderedStream;
private List<OrderedMergeRun> _orderedRuns = new(); private List<OrderedMergeRun> _orderedRuns = new();
private int _orderedPreparedCount; private int _orderedPreparedCount;
private bool _orderedSectionsBound;
// Caller-supplied, exactly like SubmitOrderedStream's own frame/encoder // Caller-supplied, exactly like SubmitOrderedStream's own frame/encoder
// parameters were (see this file's type doc comment: the walk submitter // parameters were (see this file's type doc comment: the walk submitter
// draws into whatever pass its caller has open, never pulled from // draws into whatever pass its caller has open, never pulled from
@ -355,7 +354,6 @@ public sealed unsafe partial class WbDrawDispatcher
_orderedStream = stream; _orderedStream = stream;
_orderedFrame = frame; _orderedFrame = frame;
_orderedSectionsBound = false;
_orderedPreparedCount = 0; _orderedPreparedCount = 0;
// Fail loud before any GPU work: a PortalPunch command has no // Fail loud before any GPU work: a PortalPunch command has no
@ -443,11 +441,12 @@ public sealed unsafe partial class WbDrawDispatcher
/// <summary> /// <summary>
/// Draws commands <c>[firstCommand, firstCommand + commandCount)</c> of /// Draws commands <c>[firstCommand, firstCommand + commandCount)</c> of
/// the payload the most recent <see cref="PrepareOrderedStream"/> call /// the payload the most recent <see cref="PrepareOrderedStream"/> call
/// uploaded. The FIRST call in a frame also binds the pipeline, push /// uploaded. Every call re-binds the pipeline, push constants, and the
/// constants, and all nine per-instance sections — <see cref="_orderedSectionsBound"/> /// per-instance sections — leaf draws and alpha flushes between ranges
/// gates that so every later call in the same frame is just the merge-run /// rebind the same set-0 slots to THEIR buffers, so a latched skip draws
/// walk-and-draw loop, never a rebind (the whole point of the FW3.4a /// against foreign sections (the dense-Arwic device-lost). The FW3.4a
/// split: what used to be ~40 full <c>SubmitOrderedStream</c> rebinds per /// win is the once-per-frame ring WRITES in <see cref="PrepareOrderedStream"/>
/// (what used to be ~40 full <c>SubmitOrderedStream</c> uploads per
/// frame at a town becomes one bind plus ~40 cheap /// frame at a town becomes one bind plus ~40 cheap
/// <see cref="DrawIndirectRangeRhi"/> calls). Section binds survive /// <see cref="DrawIndirectRangeRhi"/> calls). Section binds survive
/// pipeline switches (every mesh pipeline shares one layout — the same /// pipeline switches (every mesh pipeline shares one layout — the same
@ -505,18 +504,23 @@ public sealed unsafe partial class WbDrawDispatcher
ParamB = 0f, ParamB = 0f,
}; };
if (!_orderedSectionsBound)
{ {
IGpuFrame frame = _orderedFrame IGpuFrame frame = _orderedFrame
?? throw new InvalidOperationException( ?? throw new InvalidOperationException(
"DrawOrderedRange's first call this frame has no frame to bind clip-region/" "DrawOrderedRange has no frame to bind clip-region/"
+ "scene-lighting sections against — PrepareOrderedStream must run first."); + "scene-lighting sections against — PrepareOrderedStream must run first.");
// Bind the opaque variant first so the storage/uniform binds // Bind the SECTIONS on EVERY range call — never latch them
// below land on a live program (SubmitRhi's own rationale) — // across calls. Between ordered ranges the walk's leaf draws run
// every mesh pipeline shares one layout, so these bindings // (terrain, cell shells, sky, punch fans) and RetailAlphaQueue
// survive every per-run pipeline switch below, across every // flushes rebind the SAME set-0 storage bindings to THEIR
// DrawOrderedRange call this frame. // sections; a latched skip here draws the next range against the
// alpha path's buffers — out-of-bounds instance reads and a
// VK_ERROR_DEVICE_LOST at dense Arwic (the FW3.4a re-measure
// crash). The expensive part — the ring WRITES — already happens
// once per frame in PrepareOrderedStream; these are descriptor
// binds only, the same per-batch rebinding the proven
// DrawPreparedAlphaBatchRhi does for the same reason.
BindPipelineWithMesh(encoder, pipelines.Opaque, global); BindPipelineWithMesh(encoder, pipelines.Opaque, global);
encoder.SetPushConstants(in pushConstants); encoder.SetPushConstants(in pushConstants);
BindSection(encoder, GpuBindingModel.StorageInstances, _orderedInstances); BindSection(encoder, GpuBindingModel.StorageInstances, _orderedInstances);
@ -534,8 +538,6 @@ public sealed unsafe partial class WbDrawDispatcher
encoder, _scope!.Sections, frame); encoder, _scope!.Sections, frame);
AcDream.App.Rendering.WorldFrameSectionBinding.BindSceneLighting( AcDream.App.Rendering.WorldFrameSectionBinding.BindSceneLighting(
encoder, _scope!.Sections, frame); encoder, _scope!.Sections, frame);
_orderedSectionsBound = true;
} }
IGpuBuffer commandBuffer = _orderedCommands.Buffer!; IGpuBuffer commandBuffer = _orderedCommands.Buffer!;

View file

@ -403,8 +403,15 @@ public sealed class OrderPreservingSubmitterTests
/// (the old SubmitOrderedStream rebound everything on every call). /// (the old SubmitOrderedStream rebound everything on every call).
/// </summary> /// </summary>
[Fact] [Fact]
public void DrawOrderedRange_SecondCallInTheSameFrame_BindsNoFurtherStorageSections() public void DrawOrderedRange_EveryCallRebindsTheStorageSections()
{ {
// The corrected FW3.4a contract (the dense-Arwic device-lost fix):
// between ordered ranges the walk's leaf draws and RetailAlphaQueue
// flushes rebind the SAME set-0 slots to THEIR sections, so every
// DrawOrderedRange call must re-bind its own — a latched skip draws
// the next range against foreign buffers. Only the ring WRITES are
// once-per-frame (PrepareOrderedStream); binds repeat per range,
// exactly like DrawPreparedAlphaBatchRhi.
using var fx = new DispatcherFixture(); using var fx = new DispatcherFixture();
using DrawScope draw = fx.BeginDraw(); using DrawScope draw = fx.BeginDraw();
@ -420,9 +427,8 @@ public sealed class OrderPreservingSubmitterTests
fx.Dispatcher.DrawOrderedRange(draw.Pass, 1, 1); fx.Dispatcher.DrawOrderedRange(draw.Pass, 1, 1);
int boundAfterSecond = fx.Device.Calls.OfType<GpuRecordedStorageBind>().Count(); int boundAfterSecond = fx.Device.Calls.OfType<GpuRecordedStorageBind>().Count();
Assert.Equal(boundAfterFirst, boundAfterSecond); Assert.Equal(boundAfterFirst * 2, boundAfterSecond);
// Both commands still drew — the bind-once optimization changed // Both commands drew; the rebinds changed nothing about coverage.
// nothing about draw coverage.
Assert.Equal([(0, 1), (1, 1)], DecodeDrawRanges(fx.Device)); Assert.Equal([(0, 1), (1, 1)], DecodeDrawRanges(fx.Device));
} }