V6f ran the bring-up host once under VK_LAYER_KHRONOS_validation and found
seven VUIDs, every one of them on the path any world frame takes (plan
§5.5.7). This closes all of them, plus a fourth defect in the same log that
§5.5.7 did not call out. The host now runs validation-clean: zero errors and
zero warnings over 39,855 frames.
Nothing outside Gpu/Vk/ is touched, so the GL backend executes not one changed
statement. The offline pixel gate says so too — 4.08e-05 differing fraction
against f8dbe2ee, which is exactly the value the campaign recorded as its own
same-commit control (§5.1's 15–23 pixel band).
The dynamic-descriptor limit was a decision, not a patch. V6b declared all ten
of set 0's bindings STORAGE_BUFFER_DYNAMIC on the reasoning that the contract
lets a renderer bind any range per draw. That is true and still cost nothing to
honour for four of them: a dynamic descriptor buys exactly one thing, the
ability to address the SAME buffer at a DIFFERENT offset without a descriptor
write, which is the shape of a ring allocation and of nothing else. So the
ring-fed bindings — instances, batches, clip slots, instance light sets — stay
dynamic, and the ones pointing at a long-lived buffer written whole and bound
once per pass carry their offset in the descriptor instead. Binding 9 is the
clearest of those: it is the GL-only uvec2 handle table, which the Vulkan
backend never binds at all.
That lands on four dynamic storage descriptors. The RX 9070 XT allows eight, so
eight would have worked here — but four is Vulkan's GUARANTEED minimum, which
means no conformant device can fail this layout, and V9's lavapipe row and the
deferred physical Linux row both depend on that. The count is asserted against
maxDescriptorSetStorageBuffersDynamic in the capability record, so a device that
cannot serve it is rejected at startup in the report under the same exit-code-4
contract as every other requirement, rather than failing silently at
vkCreatePipelineLayout the way this one did.
Depth-off pipelines were malformed in any pass that has depth. Dynamic rendering
bakes the depth/stencil attachment format into the pipeline and requires it to
equal the pass's; V6c set it only when the pipeline itself tested or wrote
depth. Debug lines, the retained UI and the sky are all depth-off and all
composite over the main pass, so this was not an edge case. The same
GpuPipelineDescription is legitimately used both ways — ui-text opens its own
depth-less pass — so the description cannot answer the question and the backend
builds both variants, binding whichever matches what vkCmdBeginRendering was
actually handed rather than what the pass asked for. Both are built at startup
against the persisted cache, so no frame compiles one. A slice entitled to
change the contract should add a depth-format field the way V6d added
ColorFormat; this is the honest expression of the gap until then.
vk-backbuffer-depth and vk-backbuffer-msaa-color were created UNDEFINED and
never moved. Both now barrier on every backbuffer pass — from UNDEFINED on the
first use after Configure, from attachment-optimal with a write-after-write
dependency thereafter. The dependency matters on its own account, not just the
layout: two passes in one frame write both images and so does the next frame,
and Vulkan orders nothing between render-pass instances.
The fourth defect is the one worth reading twice. CaptureBackbuffer transitioned
the LAST PRESENTED swapchain image to TRANSFER_SRC and copied out of it. After
vkQueuePresentKHR that image belongs to the presentation engine and its contents
are not ours to read — and the pixels were usually right, which is precisely the
problem. This campaign spent three sections of its own plan (§5.5.1–§5.5.3)
discovering how much a capture instrument that is "usually right" can cost, and
shipping that shape on the new backend would have made every Vulkan PNG, and the
V7 differential built on them, formally undefined. The frame now copies its own
output into a host-readable buffer while it still owns the image, and the
capture reads that. Retention is opt-in, armed when an artifact directory
exists: one full-resolution copy per frame is worth nothing to a player and is
the entire instrument to a gate. The old one-shot command pool, device-idle wait
and per-capture readback buffer go with it.
Two gaps found and recorded in §5.5.8 rather than fixed, both outside this
slice's brief. UniformSkyParams (set 1, binding 4) is not in the uniform set
layout, so whoever first draws sky on Vulkan must add it. And a binding pointed
at two different buffers within one frame silently corrupts the earlier draws,
on dynamic and plain descriptors alike, because descriptor contents are read at
execution time — no consumer does that today, but WbDrawDispatcher and
EnvCellRenderer each own their own instance and batch buffers and both bind
bindings 0, 1, 3, 4 and 5 in one frame, so the Vulkan world arm has to know
before it is written.
Gates: Release build; App tests 4,075 passed / 3 skipped (baseline 4,073 + the
two new capability cases); GL offline pixel gate PASS at 4.08e-05; one
validation-layer Vulkan run, clean, with the captured PNG inspected and correct
in orientation, colour and glyph coverage.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
295 lines
13 KiB
C#
295 lines
13 KiB
C#
using Silk.NET.Vulkan;
|
|
|
|
namespace AcDream.App.Rendering.Gpu.Vk;
|
|
|
|
/// <summary>
|
|
/// Campaign V, plan §3.4 and §4.4: the three descriptor set layouts and the ONE
|
|
/// pipeline layout every acdream pipeline shares.
|
|
///
|
|
/// <para>Extracted from slice V5's active capability probe at V6b so the probe
|
|
/// and the live backend build the same objects from the same code. The probe's
|
|
/// whole value is that it proves the production layouts can be created on this
|
|
/// device; a second, similar-looking definition would quietly destroy that
|
|
/// property the first time one of them changed.</para>
|
|
///
|
|
/// <para><b>One pipeline layout is a decision, not an economy.</b> Because every
|
|
/// pipeline shares it, switching pipelines mid-pass does not invalidate bound
|
|
/// descriptor sets or push constants — which is what lets the world dispatcher
|
|
/// bind the texture table once per frame and then change pipeline per bucket.
|
|
/// The single 96-byte push-constant block exists for the same reason.</para>
|
|
/// </summary>
|
|
internal static unsafe class VulkanPipelineLayouts
|
|
{
|
|
/// <summary>The three sets plus the shared layout, owned together and destroyed together.</summary>
|
|
internal sealed class Created(
|
|
DescriptorSetLayout storage,
|
|
DescriptorSetLayout uniform,
|
|
DescriptorSetLayout textureTable,
|
|
PipelineLayout pipelineLayout) : IDisposable
|
|
{
|
|
private bool _disposed;
|
|
|
|
internal DescriptorSetLayout Storage { get; } = storage;
|
|
internal DescriptorSetLayout Uniform { get; } = uniform;
|
|
internal DescriptorSetLayout TextureTable { get; } = textureTable;
|
|
internal PipelineLayout PipelineLayout { get; } = pipelineLayout;
|
|
|
|
internal void Destroy(Silk.NET.Vulkan.Vk vk, Device device)
|
|
{
|
|
if (_disposed)
|
|
return;
|
|
_disposed = true;
|
|
if (PipelineLayout.Handle != 0)
|
|
vk.DestroyPipelineLayout(device, PipelineLayout, null);
|
|
if (TextureTable.Handle != 0)
|
|
vk.DestroyDescriptorSetLayout(device, TextureTable, null);
|
|
if (Uniform.Handle != 0)
|
|
vk.DestroyDescriptorSetLayout(device, Uniform, null);
|
|
if (Storage.Handle != 0)
|
|
vk.DestroyDescriptorSetLayout(device, Storage, null);
|
|
}
|
|
|
|
/// <summary>Destruction needs the device, so <see cref="Destroy"/> is the real disposer.</summary>
|
|
public void Dispose() => _disposed = true;
|
|
}
|
|
|
|
/// <summary>Creates all four objects, cleaning up whatever succeeded if a later one fails.</summary>
|
|
internal static Created Create(Silk.NET.Vulkan.Vk vk, Device device)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(vk);
|
|
|
|
DescriptorSetLayout storage = default;
|
|
DescriptorSetLayout uniform = default;
|
|
DescriptorSetLayout table = default;
|
|
try
|
|
{
|
|
storage = CreateStorageSetLayout(vk, device);
|
|
uniform = CreateUniformSetLayout(vk, device);
|
|
table = CreateTextureTableSetLayout(vk, device);
|
|
PipelineLayout layout = CreatePipelineLayout(vk, device, storage, uniform, table);
|
|
return new Created(storage, uniform, table, layout);
|
|
}
|
|
catch
|
|
{
|
|
if (table.Handle != 0)
|
|
vk.DestroyDescriptorSetLayout(device, table, null);
|
|
if (uniform.Handle != 0)
|
|
vk.DestroyDescriptorSetLayout(device, uniform, null);
|
|
if (storage.Handle != 0)
|
|
vk.DestroyDescriptorSetLayout(device, storage, null);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Campaign V slice V6g: which of the ten storage bindings gets a DYNAMIC
|
|
/// descriptor, and why not all of them.
|
|
///
|
|
/// <para>V6b declared all ten <c>STORAGE_BUFFER_DYNAMIC</c>, on the reasoning
|
|
/// that the contract lets a renderer bind an arbitrary range per draw. That
|
|
/// met a real device limit the first time a validation layer looked at it:
|
|
/// <c>maxDescriptorSetStorageBuffersDynamic</c> is 8 on the RX 9070 XT and
|
|
/// only <b>4</b> at Vulkan's guaranteed minimum, so ten was never portable —
|
|
/// see plan §5.5.7 defect 1.</para>
|
|
///
|
|
/// <para><b>The rule.</b> A dynamic descriptor buys exactly one thing: the
|
|
/// ability to address the SAME buffer at a DIFFERENT offset without a
|
|
/// descriptor write. That is the shape of a per-frame ring allocation, so
|
|
/// the bindings a renderer feeds from the ring stay dynamic and the offset
|
|
/// travels in <c>vkCmdBindDescriptorSets</c> for free. Bindings that point at
|
|
/// a long-lived, renderer-owned buffer written whole and bound once per pass
|
|
/// buy nothing from it, and each one costs a scarce device resource.</para>
|
|
///
|
|
/// <para>Four dynamic descriptors is not merely under the RX 9070 XT's 8 — it
|
|
/// is exactly Vulkan's guaranteed minimum, so no device that can run acdream
|
|
/// at all can fail this layout. That matters for slice V9's lavapipe row and
|
|
/// for whatever Linux driver the deferred physical row eventually uses.</para>
|
|
///
|
|
/// <para><b>Binding 9 is the clearest case.</b> The texture table is the
|
|
/// GL-only <c>uvec2</c> handle-buffer emulation; the Vulkan backend binds set
|
|
/// 2 instead and never touches binding 9 at all, so a dynamic descriptor for
|
|
/// it would be a device resource spent on a binding that is provably never
|
|
/// bound.</para>
|
|
///
|
|
/// <para><b>What to do if V4c disagrees.</b> Bindings 6, 7 and 8 are
|
|
/// per-instance arrays grouped here with the frame-global tables because
|
|
/// their owner writes them whole once per frame. If the Vulkan world path
|
|
/// turns out to re-point one of them at a moving ring offset per draw,
|
|
/// promoting it back is one line here plus one in
|
|
/// <see cref="VulkanFrameBindings"/> — and there are four unused dynamic
|
|
/// slots to promote into before the guaranteed minimum is exceeded.</para>
|
|
/// </summary>
|
|
internal static bool IsDynamicStorageBinding(uint binding) => binding switch
|
|
{
|
|
// Per-frame ring uploads: the instance transform array, the per-draw
|
|
// batch table, and the two arrays the world dispatcher chunks alongside
|
|
// instances.
|
|
GpuBindingModel.StorageInstances => true,
|
|
GpuBindingModel.StorageBatches => true,
|
|
GpuBindingModel.StorageClipSlots => true,
|
|
GpuBindingModel.StorageInstanceLightSets => true,
|
|
_ => false,
|
|
};
|
|
|
|
/// <summary>
|
|
/// How many of set 0's bindings are dynamic. Asserted against
|
|
/// <c>maxDescriptorSetStorageBuffersDynamic</c> by the capability gate, so a
|
|
/// device that cannot serve the layout is rejected at startup with the
|
|
/// exit-code-4 contract rather than at <c>vkCreatePipelineLayout</c>.
|
|
/// </summary>
|
|
internal static uint DynamicStorageBindingCount { get; } = CountDynamicStorageBindings();
|
|
|
|
private static uint CountDynamicStorageBindings()
|
|
{
|
|
uint count = 0;
|
|
for (uint binding = 0; binding < GpuBindingModel.StorageBindingCount; binding++)
|
|
{
|
|
if (IsDynamicStorageBinding(binding))
|
|
count++;
|
|
}
|
|
|
|
return count;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Set 0 — the ten storage bindings <see cref="GpuBindingModel"/> pins, split
|
|
/// between dynamic and plain by <see cref="IsDynamicStorageBinding"/>.
|
|
/// </summary>
|
|
internal static DescriptorSetLayout CreateStorageSetLayout(Silk.NET.Vulkan.Vk vk, Device device)
|
|
{
|
|
int count = (int)GpuBindingModel.StorageBindingCount;
|
|
DescriptorSetLayoutBinding* bindings = stackalloc DescriptorSetLayoutBinding[count];
|
|
for (int i = 0; i < count; i++)
|
|
{
|
|
bindings[i] = new DescriptorSetLayoutBinding
|
|
{
|
|
Binding = (uint)i,
|
|
DescriptorType = IsDynamicStorageBinding((uint)i)
|
|
? DescriptorType.StorageBufferDynamic
|
|
: DescriptorType.StorageBuffer,
|
|
DescriptorCount = 1,
|
|
StageFlags = ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit,
|
|
};
|
|
}
|
|
|
|
var create = new DescriptorSetLayoutCreateInfo
|
|
{
|
|
SType = StructureType.DescriptorSetLayoutCreateInfo,
|
|
BindingCount = (uint)count,
|
|
PBindings = bindings,
|
|
};
|
|
VulkanInterop.Check(
|
|
vk.CreateDescriptorSetLayout(device, &create, null, out DescriptorSetLayout layout),
|
|
"vkCreateDescriptorSetLayout (set 0, storage)");
|
|
return layout;
|
|
}
|
|
|
|
/// <summary>Set 1 — the SceneLighting and terrain-tiling uniform blocks.</summary>
|
|
internal static DescriptorSetLayout CreateUniformSetLayout(Silk.NET.Vulkan.Vk vk, Device device)
|
|
{
|
|
DescriptorSetLayoutBinding* bindings = stackalloc DescriptorSetLayoutBinding[2];
|
|
bindings[0] = new DescriptorSetLayoutBinding
|
|
{
|
|
Binding = GpuBindingModel.UniformSceneLighting,
|
|
DescriptorType = DescriptorType.UniformBufferDynamic,
|
|
DescriptorCount = 1,
|
|
StageFlags = ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit,
|
|
};
|
|
bindings[1] = new DescriptorSetLayoutBinding
|
|
{
|
|
Binding = GpuBindingModel.UniformTerrainTiling,
|
|
DescriptorType = DescriptorType.UniformBufferDynamic,
|
|
DescriptorCount = 1,
|
|
StageFlags = ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit,
|
|
};
|
|
|
|
var create = new DescriptorSetLayoutCreateInfo
|
|
{
|
|
SType = StructureType.DescriptorSetLayoutCreateInfo,
|
|
BindingCount = 2,
|
|
PBindings = bindings,
|
|
};
|
|
VulkanInterop.Check(
|
|
vk.CreateDescriptorSetLayout(device, &create, null, out DescriptorSetLayout layout),
|
|
"vkCreateDescriptorSetLayout (set 1, uniform)");
|
|
return layout;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Set 2 — the production texture table exactly as §4.4 specifies it: one
|
|
/// combined-image-sampler binding of
|
|
/// <see cref="GpuBindingModel.TextureTableCapacity"/>, partially bound,
|
|
/// update-after-bind, update-unused-while-pending, variable count.
|
|
/// </summary>
|
|
internal static DescriptorSetLayout CreateTextureTableSetLayout(Silk.NET.Vulkan.Vk vk, Device device)
|
|
{
|
|
var binding = new DescriptorSetLayoutBinding
|
|
{
|
|
Binding = GpuBindingModel.TextureTableBinding,
|
|
DescriptorType = DescriptorType.CombinedImageSampler,
|
|
DescriptorCount = GpuBindingModel.TextureTableCapacity,
|
|
StageFlags = ShaderStageFlags.FragmentBit,
|
|
};
|
|
DescriptorBindingFlags flags =
|
|
DescriptorBindingFlags.PartiallyBoundBit
|
|
| DescriptorBindingFlags.UpdateAfterBindBit
|
|
| DescriptorBindingFlags.UpdateUnusedWhilePendingBit
|
|
| DescriptorBindingFlags.VariableDescriptorCountBit;
|
|
|
|
var bindingFlags = new DescriptorSetLayoutBindingFlagsCreateInfo
|
|
{
|
|
SType = StructureType.DescriptorSetLayoutBindingFlagsCreateInfo,
|
|
BindingCount = 1,
|
|
PBindingFlags = &flags,
|
|
};
|
|
var create = new DescriptorSetLayoutCreateInfo
|
|
{
|
|
SType = StructureType.DescriptorSetLayoutCreateInfo,
|
|
PNext = &bindingFlags,
|
|
Flags = DescriptorSetLayoutCreateFlags.UpdateAfterBindPoolBit,
|
|
BindingCount = 1,
|
|
PBindings = &binding,
|
|
};
|
|
VulkanInterop.Check(
|
|
vk.CreateDescriptorSetLayout(device, &create, null, out DescriptorSetLayout layout),
|
|
"vkCreateDescriptorSetLayout (set 2, texture table)");
|
|
return layout;
|
|
}
|
|
|
|
/// <summary>
|
|
/// One shared pipeline layout: three sets plus the single 96-byte
|
|
/// push-constant block. Creating it proves <c>maxBoundDescriptorSets</c> and
|
|
/// <c>maxPushConstantsSize</c> for real rather than by reading a limit.
|
|
/// </summary>
|
|
internal static PipelineLayout CreatePipelineLayout(
|
|
Silk.NET.Vulkan.Vk vk,
|
|
Device device,
|
|
DescriptorSetLayout storage,
|
|
DescriptorSetLayout uniform,
|
|
DescriptorSetLayout table)
|
|
{
|
|
DescriptorSetLayout* sets = stackalloc DescriptorSetLayout[3];
|
|
sets[0] = storage;
|
|
sets[1] = uniform;
|
|
sets[2] = table;
|
|
|
|
var pushConstants = new PushConstantRange
|
|
{
|
|
StageFlags = ShaderStageFlags.VertexBit | ShaderStageFlags.FragmentBit,
|
|
Offset = 0,
|
|
Size = GpuBindingModel.PushConstantBytes,
|
|
};
|
|
var create = new PipelineLayoutCreateInfo
|
|
{
|
|
SType = StructureType.PipelineLayoutCreateInfo,
|
|
SetLayoutCount = 3,
|
|
PSetLayouts = sets,
|
|
PushConstantRangeCount = 1,
|
|
PPushConstantRanges = &pushConstants,
|
|
};
|
|
VulkanInterop.Check(
|
|
vk.CreatePipelineLayout(device, &create, null, out PipelineLayout layout),
|
|
"vkCreatePipelineLayout");
|
|
return layout;
|
|
}
|
|
}
|