feat(render): Campaign V slice V6i-1 - one descriptor set per renderer scope

Plan section 5.5.8 recorded, and deliberately did not fix, that pointing one
binding at a second buffer within a frame silently corrupts the draws already
recorded against it: the backend rewrote the descriptor in place, and a
descriptor set's contents are read when the command buffer EXECUTES, not when it
was recorded. Nothing fired it while the Vulkan frame held only the retained UI.
Section 5.5.11 handed it forward as the first thing the world arm would hit,
because WbDrawDispatcher, EnvCellRenderer and TerrainModernRenderer each own
their own instance, batch and indirect buffers and all three bind set 0 in one
frame.

It is closed here, as its own commit and BEFORE the world arm, so that a blank or
corrupt first Vulkan world frame cannot be this defect wearing another face. That
sequencing is the point: sections 5.5.1 to 5.5.3 cost this campaign three days
because an instrument that was "usually right" sat underneath the thing being
measured.

What changed. There is no longer one (set 0, set 1) pair per flight slot; there
is an arena of them. VulkanBindingScopeArena - pure bookkeeping, no Vulkan
handles, nine unit tests - answers two questions per bind: which pair, and do its
descriptors need writing. VulkanFrameBindings keeps the Vulkan half: allocating
pairs from a growable pool list and writing the twelve descriptors when told to.

The scope key is the descriptor state itself - the ten storage buffer identities
and ranges, the plain bindings' offsets, and the two uniform buffer identities
and ranges. Deriving it is a decision, not an economy. The pinned contract has
nowhere to name a scope: BindStorageBuffer takes a buffer, an offset and a size,
and section 3.3 is frozen. Deriving also gives two properties a declared scope
would not: a renderer cannot forget to declare one, and two renderers that
genuinely share every buffer correctly share one pair rather than being told to
differ. A renderer's buffers are stable for its lifetime, so "distinct descriptor
state" is exactly "renderer scope".

Dynamic offsets stay free. A ring allocation moving between draws rides
vkCmdBindDescriptorSets's dynamic-offset array, so it costs neither a new pair
nor a descriptor write - section 4.4's "zero descriptor writes per frame"
property survives a frame having more than one binding state in it. Entries are
not invalidated at BeginFrame either, because the slot's previous submission has
retired and its descriptors still say what this frame is about to say; a steady
frame therefore rewrites nothing at all. An entry matched from the previous frame
is swapped below the live cursor so the rest of the frame cannot take it for a
different state - the ordering property the sixth test pins, where two renderers
swap submission order between frames.

What this does NOT do is draw a world. The captured Vulkan frame is still V6h's
retained UI over the fog clear, so the arena's multi-scope path is exercised by
its tests and not yet by a frame. That is recorded in the plan rather than
implied.

The plan's section 5.5.12 also records two blockers measured while scoping the
world arm and not fixed here: terrain_modern.vert declares TerrainClip without
ACDREAM_UBO_SET, so under the Vulkan dialect it lands at set 0 binding 2 where
the layout declares a storage buffer - the same class of gap 5.5.8 recorded for
UniformSkyParams, invisible until a terrain pipeline is created; and the offline
gate's scene takes the retail PView path rather than the flat safety path,
because ClipRoot falls back to Buildings.OutdoorNode, which puts
RetailPViewPassExecutor on the critical path to the first Vulkan Dereth frame and
makes the "terrain only" intermediate no cheaper than the whole arm.

Gates. Strict GL offline pixel gate against b9ab5890: 1.60e-05, 9 differing
pixels of 563,200, at the low end of the documented 9-31 px band and 62x under
the threshold - expected, since no GL file is touched. GL connected
run-repeat-connected-gate.ps1 -Runs 3: 3/3 RENDERED on the desktop witness and
3/3 on the client capture. One offline Vulkan run with VK_LAYER_KHRONOS_validation
proven inserted by the loader: zero errors, zero warnings, captured frame, no
[shutdown] diagnostic on either stream. App tests 4,086 / 3 skips (baseline 4,077
plus nine); complete Release suite 9,149 / 5. Issue #250's
SurfaceOverrideFingerprint_DictionaryHotPathAllocatesNothing failed once in a
whole-suite run and passed run alone, as that issue documents.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-28 13:17:25 +02:00
parent b9ab5890af
commit df6e2a7918
6 changed files with 759 additions and 100 deletions

View file

@ -30,24 +30,33 @@ namespace AcDream.App.Rendering.Gpu.Vk;
/// descriptor set layout and one pipeline layout rather than a permutation per
/// renderer — plan §4.4's requirement, and the thing that makes switching
/// pipelines mid-pass free.</para>
///
/// <para><b>Slice V6i: one set pair per renderer scope.</b> There is no longer a
/// single (set 0, set 1) pair per flight slot; there is an arena of them, and
/// <see cref="VulkanBindingScopeArena"/> decides which pair a bind belongs to and
/// whether its descriptors have to be written. That is what closes plan §5.5.8's
/// recorded one-binding-two-buffers hazard before the world arm fires it — see
/// the arena's own documentation for why the scope is derived from the
/// descriptor state rather than declared by the renderer.</para>
/// </summary>
internal sealed unsafe class VulkanFrameBindings : IDisposable
{
private readonly Silk.NET.Vulkan.Vk _vk;
private readonly Device _device;
private readonly DescriptorPool _pool;
private readonly DescriptorSet _storageSet;
private readonly DescriptorSet _uniformSet;
private readonly uint[] _storageOffsets = new uint[GpuBindingModel.StorageBindingCount];
private readonly uint[] _uniformOffsets = new uint[UniformBindingCount];
private readonly Silk.NET.Vulkan.Buffer[] _storageBuffers =
new Silk.NET.Vulkan.Buffer[GpuBindingModel.StorageBindingCount];
private readonly Silk.NET.Vulkan.Buffer[] _uniformBuffers =
new Silk.NET.Vulkan.Buffer[UniformBindingCount];
private readonly VulkanPipelineLayouts.Created _layouts;
private readonly VulkanBindingScopeArena _arena;
private readonly List<DescriptorPool> _pools = [];
private readonly List<(DescriptorSet Storage, DescriptorSet Uniform)> _sets = [];
private bool _disposed;
/// <summary>
/// Set pairs one descriptor pool serves. Distinct descriptor states in a
/// frame are the world renderers plus the retained UI, so this is generous;
/// exceeding it allocates another pool rather than failing.
/// </summary>
private const int PairsPerPool = 16;
/// <summary>
/// Set 0's dynamic-offset slots, in binding order — the order
/// <c>vkCmdBindDescriptorSets</c> requires. A plain binding has no slot.
@ -92,61 +101,24 @@ internal sealed unsafe class VulkanFrameBindings : IDisposable
{
_vk = vk ?? throw new ArgumentNullException(nameof(vk));
_device = device;
ArgumentNullException.ThrowIfNull(layouts);
_layouts = layouts ?? throw new ArgumentNullException(nameof(layouts));
ArgumentNullException.ThrowIfNull(ring);
ArgumentNullException.ThrowIfNull(dummy);
DescriptorPoolSize* sizes = stackalloc DescriptorPoolSize[3];
sizes[0] = new DescriptorPoolSize
{
Type = DescriptorType.StorageBufferDynamic,
DescriptorCount = VulkanPipelineLayouts.DynamicStorageBindingCount,
};
sizes[1] = new DescriptorPoolSize
{
Type = DescriptorType.StorageBuffer,
DescriptorCount =
GpuBindingModel.StorageBindingCount - VulkanPipelineLayouts.DynamicStorageBindingCount,
};
sizes[2] = new DescriptorPoolSize
{
Type = DescriptorType.UniformBufferDynamic,
DescriptorCount = UniformBindingCount,
};
var poolCreate = new DescriptorPoolCreateInfo
{
SType = StructureType.DescriptorPoolCreateInfo,
MaxSets = 2,
PoolSizeCount = 3,
PPoolSizes = sizes,
};
VulkanInterop.Check(
_vk.CreateDescriptorPool(_device, &poolCreate, null, out _pool),
"vkCreateDescriptorPool (frame bindings)");
_storageSet = Allocate(layouts.Storage);
_uniformSet = Allocate(layouts.Uniform);
for (uint binding = 0; binding < GpuBindingModel.StorageBindingCount; binding++)
{
_storageBuffers[binding] = dummy.Handle;
WriteStorage(
binding,
dummy.Handle,
offsetBytes: 0,
(uint)Math.Min(dummy.SizeBytes, MaxBindingRangeBytes),
VulkanPipelineLayouts.IsDynamicStorageBinding(binding));
}
// Only the two bindings the layout declares exist; the rest of the
// array is bookkeeping so the offsets stay index-aligned.
WriteUniform(GpuBindingModel.UniformSceneLighting, dummy.Handle, (uint)Math.Min(dummy.SizeBytes, 65536));
WriteUniform(GpuBindingModel.UniformTerrainTiling, dummy.Handle, (uint)Math.Min(dummy.SizeBytes, 65536));
_uniformBuffers[GpuBindingModel.UniformSceneLighting] = dummy.Handle;
_uniformBuffers[GpuBindingModel.UniformTerrainTiling] = dummy.Handle;
Ring = ring;
Dummy = dummy;
_arena = new VulkanBindingScopeArena(
(int)GpuBindingModel.StorageBindingCount,
UniformBindingCount,
VulkanPipelineLayouts.IsDynamicStorageBinding);
uint dummyStorageRange = (uint)Math.Min(dummy.SizeBytes, MaxBindingRangeBytes);
for (uint binding = 0; binding < GpuBindingModel.StorageBindingCount; binding++)
_arena.SeedStorage(binding, dummy.Handle.Handle, offsetBytes: 0, dummyStorageRange);
uint dummyUniformRange = (uint)Math.Min(dummy.SizeBytes, 65536);
for (uint binding = 0; binding < UniformBindingCount; binding++)
_arena.SeedUniform(binding, dummy.Handle.Handle, dummyUniformRange);
}
internal VulkanGpuBuffer Ring { get; }
@ -154,51 +126,57 @@ internal sealed unsafe class VulkanFrameBindings : IDisposable
internal VulkanGpuBuffer Dummy { get; }
/// <summary>
/// Points a storage binding at a range.
///
/// <para>A DYNAMIC binding re-writes its descriptor only when the BUFFER
/// changes; the offset rides the bind call. A PLAIN binding has no such
/// channel, so the descriptor itself carries the offset and is re-written
/// when either moves.</para>
/// Diagnostic: distinct descriptor states this flight slot materialised. One
/// per renderer scope in a steady frame, so a number that keeps climbing is
/// a renderer pointing a binding at a fresh buffer every draw.
/// </summary>
internal int ScopeCount => _arena.Count;
/// <summary>
/// Recycles the arena for a new frame on this slot. Safe because the slot's
/// previous submission has retired before <c>BeginFrame</c> returns, which is
/// the same guarantee that lets the ring rewind.
/// </summary>
internal void BeginFrame() => _arena.BeginFrame();
internal void SetStorage(uint binding, VulkanGpuBuffer buffer, uint offsetBytes, uint sizeBytes)
{
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(binding, GpuBindingModel.StorageBindingCount);
bool dynamic = VulkanPipelineLayouts.IsDynamicStorageBinding(binding);
bool bufferChanged = _storageBuffers[binding].Handle != buffer.Handle.Handle;
bool offsetChanged = _storageOffsets[binding] != offsetBytes;
if (bufferChanged || (!dynamic && offsetChanged))
{
_storageBuffers[binding] = buffer.Handle;
WriteStorage(
binding,
buffer.Handle,
dynamic ? 0 : offsetBytes,
ClampRange(buffer, sizeBytes, dynamic ? 0 : offsetBytes),
dynamic);
}
_storageOffsets[binding] = offsetBytes;
uint descriptorOffset = VulkanPipelineLayouts.IsDynamicStorageBinding(binding) ? 0 : offsetBytes;
_arena.SetStorage(
binding,
buffer.Handle.Handle,
offsetBytes,
ClampRange(buffer, sizeBytes, descriptorOffset));
}
internal void SetUniform(uint binding, VulkanGpuBuffer buffer, uint offsetBytes, uint sizeBytes)
{
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(binding, (uint)UniformBindingCount);
if (_uniformBuffers[binding].Handle != buffer.Handle.Handle)
{
_uniformBuffers[binding] = buffer.Handle;
WriteUniform(binding, buffer.Handle, Math.Min(ClampRange(buffer, sizeBytes, offsetBytes: 0), 65536));
}
_uniformOffsets[binding] = offsetBytes;
_arena.SetUniform(
binding,
buffer.Handle.Handle,
offsetBytes,
Math.Min(ClampRange(buffer, sizeBytes, offsetBytes: 0), 65536));
}
/// <summary>Binds all three sets with the current dynamic offsets.</summary>
internal void Bind(CommandBuffer commands, VulkanGpuDevice device)
{
(int index, int slot, bool needsWrite) = _arena.Resolve();
if (slot < 0)
{
slot = _sets.Count;
_sets.Add(AllocatePair());
_arena.AssignSlot(index, slot);
}
if (needsWrite)
WritePair(_sets[slot]);
DescriptorSet* sets = stackalloc DescriptorSet[3];
sets[0] = _storageSet;
sets[1] = _uniformSet;
sets[0] = _sets[slot].Storage;
sets[1] = _sets[slot].Uniform;
sets[2] = device.TextureTable.Set;
int dynamicCount = DynamicStorageBindings.Length + 2;
@ -206,9 +184,9 @@ internal sealed unsafe class VulkanFrameBindings : IDisposable
// Dynamic offsets are ordered by set, then by binding number, and only
// the DYNAMIC descriptors have a slot at all.
for (int i = 0; i < DynamicStorageBindings.Length; i++)
offsets[i] = _storageOffsets[DynamicStorageBindings[i]];
offsets[DynamicStorageBindings.Length + 0] = _uniformOffsets[GpuBindingModel.UniformSceneLighting];
offsets[DynamicStorageBindings.Length + 1] = _uniformOffsets[GpuBindingModel.UniformTerrainTiling];
offsets[i] = _arena.StorageOffset(DynamicStorageBindings[i]);
offsets[DynamicStorageBindings.Length + 0] = _arena.UniformOffset(GpuBindingModel.UniformSceneLighting);
offsets[DynamicStorageBindings.Length + 1] = _arena.UniformOffset(GpuBindingModel.UniformTerrainTiling);
_vk.CmdBindDescriptorSets(
commands,
@ -221,6 +199,74 @@ internal sealed unsafe class VulkanFrameBindings : IDisposable
offsets);
}
private void WritePair((DescriptorSet Storage, DescriptorSet Uniform) pair)
{
for (uint binding = 0; binding < GpuBindingModel.StorageBindingCount; binding++)
{
WriteStorage(
pair.Storage,
binding,
new Silk.NET.Vulkan.Buffer(_arena.StorageBuffer(binding)),
_arena.StorageDescriptorOffset(binding),
_arena.StorageRange(binding),
VulkanPipelineLayouts.IsDynamicStorageBinding(binding));
}
// Only the two bindings the layout declares exist; the rest of the array
// is bookkeeping so the offsets stay index-aligned.
WriteUniform(
pair.Uniform,
GpuBindingModel.UniformSceneLighting,
new Silk.NET.Vulkan.Buffer(_arena.UniformBuffer(GpuBindingModel.UniformSceneLighting)),
_arena.UniformRange(GpuBindingModel.UniformSceneLighting));
WriteUniform(
pair.Uniform,
GpuBindingModel.UniformTerrainTiling,
new Silk.NET.Vulkan.Buffer(_arena.UniformBuffer(GpuBindingModel.UniformTerrainTiling)),
_arena.UniformRange(GpuBindingModel.UniformTerrainTiling));
}
private (DescriptorSet Storage, DescriptorSet Uniform) AllocatePair()
{
if (_sets.Count % PairsPerPool == 0)
_pools.Add(CreatePool());
DescriptorPool pool = _pools[^1];
return (Allocate(pool, _layouts.Storage), Allocate(pool, _layouts.Uniform));
}
private DescriptorPool CreatePool()
{
DescriptorPoolSize* sizes = stackalloc DescriptorPoolSize[3];
sizes[0] = new DescriptorPoolSize
{
Type = DescriptorType.StorageBufferDynamic,
DescriptorCount = VulkanPipelineLayouts.DynamicStorageBindingCount * PairsPerPool,
};
sizes[1] = new DescriptorPoolSize
{
Type = DescriptorType.StorageBuffer,
DescriptorCount =
(GpuBindingModel.StorageBindingCount - VulkanPipelineLayouts.DynamicStorageBindingCount)
* PairsPerPool,
};
sizes[2] = new DescriptorPoolSize
{
Type = DescriptorType.UniformBufferDynamic,
DescriptorCount = DynamicUniformBindingCount * PairsPerPool,
};
var poolCreate = new DescriptorPoolCreateInfo
{
SType = StructureType.DescriptorPoolCreateInfo,
MaxSets = 2 * PairsPerPool,
PoolSizeCount = 3,
PPoolSizes = sizes,
};
VulkanInterop.Check(
_vk.CreateDescriptorPool(_device, &poolCreate, null, out DescriptorPool pool),
"vkCreateDescriptorPool (frame bindings)");
return pool;
}
private static uint ClampRange(VulkanGpuBuffer buffer, uint requested, uint offsetBytes)
{
long remaining = buffer.SizeBytes - offsetBytes;
@ -237,13 +283,13 @@ internal sealed unsafe class VulkanFrameBindings : IDisposable
return requested == 0 ? available : Math.Min(Math.Max(requested, 16), available);
}
private DescriptorSet Allocate(DescriptorSetLayout layout)
private DescriptorSet Allocate(DescriptorPool pool, DescriptorSetLayout layout)
{
DescriptorSetLayout handle = layout;
var allocate = new DescriptorSetAllocateInfo
{
SType = StructureType.DescriptorSetAllocateInfo,
DescriptorPool = _pool,
DescriptorPool = pool,
DescriptorSetCount = 1,
PSetLayouts = &handle,
};
@ -254,6 +300,7 @@ internal sealed unsafe class VulkanFrameBindings : IDisposable
}
private void WriteStorage(
DescriptorSet set,
uint binding,
Silk.NET.Vulkan.Buffer buffer,
uint offsetBytes,
@ -269,7 +316,7 @@ internal sealed unsafe class VulkanFrameBindings : IDisposable
var write = new WriteDescriptorSet
{
SType = StructureType.WriteDescriptorSet,
DstSet = _storageSet,
DstSet = set,
DstBinding = binding,
DescriptorCount = 1,
DescriptorType = dynamic
@ -280,7 +327,11 @@ internal sealed unsafe class VulkanFrameBindings : IDisposable
_vk.UpdateDescriptorSets(_device, 1, &write, 0, null);
}
private void WriteUniform(uint binding, Silk.NET.Vulkan.Buffer buffer, uint rangeBytes)
private void WriteUniform(
DescriptorSet set,
uint binding,
Silk.NET.Vulkan.Buffer buffer,
uint rangeBytes)
{
var info = new DescriptorBufferInfo
{
@ -291,7 +342,7 @@ internal sealed unsafe class VulkanFrameBindings : IDisposable
var write = new WriteDescriptorSet
{
SType = StructureType.WriteDescriptorSet,
DstSet = _uniformSet,
DstSet = set,
DstBinding = binding,
DescriptorCount = 1,
DescriptorType = DescriptorType.UniformBufferDynamic,
@ -305,7 +356,13 @@ internal sealed unsafe class VulkanFrameBindings : IDisposable
if (_disposed)
return;
_disposed = true;
if (_pool.Handle != 0)
_vk.DestroyDescriptorPool(_device, _pool, null);
foreach (DescriptorPool pool in _pools)
{
if (pool.Handle != 0)
_vk.DestroyDescriptorPool(_device, pool, null);
}
_pools.Clear();
_sets.Clear();
}
}