fix(render): Campaign V slice V6g — the Vulkan frame stops lying to the driver
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>
This commit is contained in:
parent
f8dbe2ee4a
commit
24834a6478
12 changed files with 653 additions and 175 deletions
|
|
@ -148,6 +148,8 @@ internal sealed unsafe partial class VulkanGpuDevice
|
|||
|
||||
_bindingDummy?.Dispose();
|
||||
_bindingDummy = null;
|
||||
_captureBuffer?.Dispose();
|
||||
_captureBuffer = null;
|
||||
_defaultTexture?.Dispose();
|
||||
_defaultTexture = null;
|
||||
|
||||
|
|
@ -192,8 +194,11 @@ internal sealed unsafe partial class VulkanGpuDevice
|
|||
/// extent and the requested sample count. Called by the host after a
|
||||
/// swapchain create or recreate, behind a device-idle wait.
|
||||
/// </summary>
|
||||
internal void ConfigureBackbufferAttachments(uint width, uint height, Format colorFormat, int sampleCount) =>
|
||||
internal void ConfigureBackbufferAttachments(uint width, uint height, Format colorFormat, int sampleCount)
|
||||
{
|
||||
BackbufferAttachments.Configure(width, height, colorFormat, sampleCount);
|
||||
ConfigureBackbufferCapture(width, height);
|
||||
}
|
||||
|
||||
public IGpuTexture CreateTexture(in GpuTextureDescription description)
|
||||
{
|
||||
|
|
@ -406,6 +411,7 @@ internal sealed unsafe partial class VulkanGpuDevice
|
|||
{
|
||||
colorView = attachments.ColorView;
|
||||
resolveView = _backbuffer.ViewAt(imageIndex);
|
||||
TransitionBackbufferScratchColor(commands, attachments);
|
||||
}
|
||||
else
|
||||
{
|
||||
|
|
@ -413,7 +419,10 @@ internal sealed unsafe partial class VulkanGpuDevice
|
|||
}
|
||||
|
||||
if (description.Depth is not null && attachments.HasDepth)
|
||||
{
|
||||
depthView = attachments.DepthView;
|
||||
TransitionBackbufferDepth(commands, attachments);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
|
|
@ -498,7 +507,8 @@ internal sealed unsafe partial class VulkanGpuDevice
|
|||
_frameBindings[frame.SlotIndex],
|
||||
description,
|
||||
width,
|
||||
height);
|
||||
height,
|
||||
hasDepthAttachment: depthView.Handle != 0);
|
||||
_openPass = encoder;
|
||||
return encoder;
|
||||
}
|
||||
|
|
@ -583,6 +593,60 @@ internal sealed unsafe partial class VulkanGpuDevice
|
|||
_vk.CmdPipelineBarrier2(commands, &dependency);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign V slice V6g: moves the multisampled colour scratch into
|
||||
/// <c>COLOR_ATTACHMENT_OPTIMAL</c> before the pass that names it there.
|
||||
///
|
||||
/// <para>The first use after (re)creation starts from UNDEFINED — the image
|
||||
/// genuinely has no contents, and saying so lets the driver skip a
|
||||
/// decompress. Every later use starts from the layout the previous pass left
|
||||
/// and needs the barrier for its write-after-write dependency instead: two
|
||||
/// passes in one frame both write this image, and so does the next frame,
|
||||
/// with no implicit ordering between render-pass instances.</para>
|
||||
/// </summary>
|
||||
private void TransitionBackbufferScratchColor(
|
||||
CommandBuffer commands,
|
||||
VulkanBackbufferAttachments attachments)
|
||||
{
|
||||
bool first = !attachments.ColorLayoutInitialized;
|
||||
attachments.MarkColorLayoutInitialized();
|
||||
TransitionImage(
|
||||
commands,
|
||||
attachments.ColorImage,
|
||||
ImageAspectFlags.ColorBit,
|
||||
first ? ImageLayout.Undefined : ImageLayout.ColorAttachmentOptimal,
|
||||
ImageLayout.ColorAttachmentOptimal,
|
||||
first ? PipelineStageFlags2.TopOfPipeBit : PipelineStageFlags2.ColorAttachmentOutputBit,
|
||||
first ? AccessFlags2.None : AccessFlags2.ColorAttachmentWriteBit,
|
||||
PipelineStageFlags2.ColorAttachmentOutputBit,
|
||||
AccessFlags2.ColorAttachmentWriteBit | AccessFlags2.ColorAttachmentReadBit);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The same, for the transient depth/stencil buffer. Both aspects move
|
||||
/// together because the image carries both and the pass names it as both a
|
||||
/// depth and a stencil attachment.
|
||||
/// </summary>
|
||||
private void TransitionBackbufferDepth(
|
||||
CommandBuffer commands,
|
||||
VulkanBackbufferAttachments attachments)
|
||||
{
|
||||
bool first = !attachments.DepthLayoutInitialized;
|
||||
attachments.MarkDepthLayoutInitialized();
|
||||
const PipelineStageFlags2 DepthStages =
|
||||
PipelineStageFlags2.EarlyFragmentTestsBit | PipelineStageFlags2.LateFragmentTestsBit;
|
||||
TransitionImage(
|
||||
commands,
|
||||
attachments.DepthImage,
|
||||
ImageAspectFlags.DepthBit | ImageAspectFlags.StencilBit,
|
||||
first ? ImageLayout.Undefined : ImageLayout.DepthStencilAttachmentOptimal,
|
||||
ImageLayout.DepthStencilAttachmentOptimal,
|
||||
first ? PipelineStageFlags2.TopOfPipeBit : DepthStages,
|
||||
first ? AccessFlags2.None : AccessFlags2.DepthStencilAttachmentWriteBit,
|
||||
DepthStages,
|
||||
AccessFlags2.DepthStencilAttachmentWriteBit | AccessFlags2.DepthStencilAttachmentReadBit);
|
||||
}
|
||||
|
||||
private void TransitionRenderTargetForRendering(CommandBuffer commands, VulkanGpuRenderTarget target)
|
||||
{
|
||||
TransitionImage(
|
||||
|
|
@ -670,12 +734,29 @@ internal sealed unsafe partial class VulkanGpuDevice
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the presented image back as tightly packed top-left-origin RGBA8.
|
||||
/// Reads the last presented frame back as tightly packed top-left-origin
|
||||
/// RGBA8.
|
||||
///
|
||||
/// <para>The swapchain is <c>B8G8R8A8_UNORM</c> (plan §4.9), so the channels
|
||||
/// are swizzled on the CPU to preserve <c>FrameScreenshotController</c>'s
|
||||
/// RGBA byte contract — the same seam every automated screenshot gate already
|
||||
/// uses, so the comparison tooling is unaffected by the backend swap.</para>
|
||||
///
|
||||
/// <para><b>Campaign V slice V6g: it reads a device-owned copy, not the
|
||||
/// swapchain image.</b> V6c transitioned the LAST PRESENTED swapchain image
|
||||
/// to <c>TRANSFER_SRC</c> and copied out of it, which the validation layer
|
||||
/// rejects as <c>UNASSIGNED-non-acquired-swapchain-image-used</c>: once
|
||||
/// <c>vkQueuePresentKHR</c> has taken an image, the presentation engine owns
|
||||
/// it and its contents are not the application's to read. The pixels were
|
||||
/// usually right, which is precisely what makes it dangerous — this campaign
|
||||
/// spent three sections (§5.5.1–§5.5.3) discovering how much a capture
|
||||
/// instrument that is "usually right" can cost. So the frame copies its own
|
||||
/// output into a host-readable buffer while it still owns the image, and this
|
||||
/// method reads that.</para>
|
||||
///
|
||||
/// <para>Retention is opt-in and off in production: it costs one full-res
|
||||
/// image-to-buffer copy per frame, which is worth nothing to a player and is
|
||||
/// the entire instrument to a gate.</para>
|
||||
/// </summary>
|
||||
public byte[] CaptureBackbuffer(int width, int height)
|
||||
{
|
||||
|
|
@ -684,19 +765,98 @@ internal sealed unsafe partial class VulkanGpuDevice
|
|||
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(height);
|
||||
if (_backbuffer is null)
|
||||
throw new InvalidOperationException("This device has no backbuffer to capture.");
|
||||
if (_captureBuffer is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Backbuffer capture was not retained by this device. Construct it with " +
|
||||
"retainBackbufferCapture: true — reading the presented swapchain image " +
|
||||
"instead is a Vulkan usage error (see this method's remarks).");
|
||||
}
|
||||
if (width != _captureWidth || height != _captureHeight)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"The retained capture is {_captureWidth}x{_captureHeight}; {width}x{height} was requested. " +
|
||||
"The capture buffer is sized with the swapchain, so a mismatch means the caller " +
|
||||
"and the backbuffer disagree about the frame that was just presented.");
|
||||
}
|
||||
|
||||
return CaptureImage(_backbuffer.ImageAt(_lastPresentedImageIndex), (uint)width, (uint)height);
|
||||
// Everything that could still be writing the buffer is a submitted frame.
|
||||
VulkanInterop.Check(_vk.DeviceWaitIdle(_device), "vkDeviceWaitIdle (capture)");
|
||||
var pixels = new byte[(long)_captureWidth * _captureHeight * 4];
|
||||
_captureBuffer.Read(0, pixels);
|
||||
// ToRgba, NOT ToGlOriginRgba: IGpuDevice.CaptureBackbuffer is documented
|
||||
// as top-left-origin, and a Vulkan image already is.
|
||||
return VulkanBackbufferSwizzle.ToRgba(pixels, width, height, width * 4);
|
||||
}
|
||||
|
||||
private uint _lastPresentedImageIndex;
|
||||
private bool _backbufferRenderingReady;
|
||||
|
||||
private byte[] CaptureImage(Image image, uint width, uint height)
|
||||
/// <summary>
|
||||
/// Records the acquired image's contents into the retained capture buffer,
|
||||
/// while the frame still owns the image. Returns the layout the image is
|
||||
/// left in, which the present barrier has to start from.
|
||||
/// </summary>
|
||||
internal ImageLayout RecordBackbufferCapture(CommandBuffer commands, Image image)
|
||||
{
|
||||
uint byteCount = width * height * 4;
|
||||
VulkanInterop.Check(_vk.DeviceWaitIdle(_device), "vkDeviceWaitIdle (capture)");
|
||||
if (_captureBuffer is null || _backbuffer is null)
|
||||
return ImageLayout.ColorAttachmentOptimal;
|
||||
if (_captureWidth != _backbuffer.Width || _captureHeight != _backbuffer.Height)
|
||||
return ImageLayout.ColorAttachmentOptimal;
|
||||
|
||||
var readback = new VulkanGpuBuffer(
|
||||
TransitionImage(
|
||||
commands,
|
||||
image,
|
||||
ImageAspectFlags.ColorBit,
|
||||
ImageLayout.ColorAttachmentOptimal,
|
||||
ImageLayout.TransferSrcOptimal,
|
||||
PipelineStageFlags2.ColorAttachmentOutputBit,
|
||||
AccessFlags2.ColorAttachmentWriteBit,
|
||||
PipelineStageFlags2.CopyBit,
|
||||
AccessFlags2.TransferReadBit);
|
||||
|
||||
var region = new BufferImageCopy
|
||||
{
|
||||
BufferOffset = 0,
|
||||
BufferRowLength = 0,
|
||||
BufferImageHeight = 0,
|
||||
ImageSubresource = new ImageSubresourceLayers
|
||||
{
|
||||
AspectMask = ImageAspectFlags.ColorBit,
|
||||
MipLevel = 0,
|
||||
BaseArrayLayer = 0,
|
||||
LayerCount = 1,
|
||||
},
|
||||
ImageOffset = new Offset3D(0, 0, 0),
|
||||
ImageExtent = new Extent3D(_captureWidth, _captureHeight, 1),
|
||||
};
|
||||
_vk.CmdCopyImageToBuffer(
|
||||
commands,
|
||||
image,
|
||||
ImageLayout.TransferSrcOptimal,
|
||||
_captureBuffer.Handle,
|
||||
1,
|
||||
®ion);
|
||||
return ImageLayout.TransferSrcOptimal;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sizes the retained capture buffer with the swapchain. Called from
|
||||
/// <see cref="ConfigureBackbufferAttachments"/>, which the host already
|
||||
/// drives behind a <c>vkDeviceWaitIdle</c> on resize.
|
||||
/// </summary>
|
||||
private void ConfigureBackbufferCapture(uint width, uint height)
|
||||
{
|
||||
if (!_retainBackbufferCapture)
|
||||
return;
|
||||
if (_captureBuffer is not null && _captureWidth == width && _captureHeight == height)
|
||||
return;
|
||||
|
||||
_captureBuffer?.Dispose();
|
||||
_captureBuffer = null;
|
||||
_captureWidth = width;
|
||||
_captureHeight = height;
|
||||
if (width == 0 || height == 0)
|
||||
return;
|
||||
|
||||
_captureBuffer = new VulkanGpuBuffer(
|
||||
_vk,
|
||||
_device,
|
||||
_allocator,
|
||||
|
|
@ -705,116 +865,14 @@ internal sealed unsafe partial class VulkanGpuDevice
|
|||
_debugNames,
|
||||
new GpuBufferDescription(
|
||||
"vk-backbuffer-capture",
|
||||
byteCount,
|
||||
width * height * 4,
|
||||
GpuBufferUsage.TransferDestination,
|
||||
GpuMemoryResidency.HostReadable));
|
||||
CommandPool pool = default;
|
||||
try
|
||||
{
|
||||
var poolCreate = new CommandPoolCreateInfo
|
||||
{
|
||||
SType = StructureType.CommandPoolCreateInfo,
|
||||
QueueFamilyIndex = _graphicsFamily,
|
||||
Flags = CommandPoolCreateFlags.TransientBit,
|
||||
};
|
||||
VulkanInterop.Check(
|
||||
_vk.CreateCommandPool(_device, &poolCreate, null, out pool),
|
||||
"vkCreateCommandPool (capture)");
|
||||
var allocate = new CommandBufferAllocateInfo
|
||||
{
|
||||
SType = StructureType.CommandBufferAllocateInfo,
|
||||
CommandPool = pool,
|
||||
Level = CommandBufferLevel.Primary,
|
||||
CommandBufferCount = 1,
|
||||
};
|
||||
VulkanInterop.Check(
|
||||
_vk.AllocateCommandBuffers(_device, &allocate, out CommandBuffer commands),
|
||||
"vkAllocateCommandBuffers (capture)");
|
||||
|
||||
var begin = new CommandBufferBeginInfo
|
||||
{
|
||||
SType = StructureType.CommandBufferBeginInfo,
|
||||
Flags = CommandBufferUsageFlags.OneTimeSubmitBit,
|
||||
};
|
||||
VulkanInterop.Check(_vk.BeginCommandBuffer(commands, &begin), "vkBeginCommandBuffer (capture)");
|
||||
|
||||
TransitionImage(
|
||||
commands,
|
||||
image,
|
||||
ImageAspectFlags.ColorBit,
|
||||
ImageLayout.PresentSrcKhr,
|
||||
ImageLayout.TransferSrcOptimal,
|
||||
PipelineStageFlags2.AllCommandsBit,
|
||||
AccessFlags2.None,
|
||||
PipelineStageFlags2.CopyBit,
|
||||
AccessFlags2.TransferReadBit);
|
||||
|
||||
var region = new BufferImageCopy
|
||||
{
|
||||
BufferOffset = 0,
|
||||
BufferRowLength = 0,
|
||||
BufferImageHeight = 0,
|
||||
ImageSubresource = new ImageSubresourceLayers
|
||||
{
|
||||
AspectMask = ImageAspectFlags.ColorBit,
|
||||
MipLevel = 0,
|
||||
BaseArrayLayer = 0,
|
||||
LayerCount = 1,
|
||||
},
|
||||
ImageOffset = new Offset3D(0, 0, 0),
|
||||
ImageExtent = new Extent3D(width, height, 1),
|
||||
};
|
||||
_vk.CmdCopyImageToBuffer(
|
||||
commands,
|
||||
image,
|
||||
ImageLayout.TransferSrcOptimal,
|
||||
readback.Handle,
|
||||
1,
|
||||
®ion);
|
||||
|
||||
TransitionImage(
|
||||
commands,
|
||||
image,
|
||||
ImageAspectFlags.ColorBit,
|
||||
ImageLayout.TransferSrcOptimal,
|
||||
ImageLayout.PresentSrcKhr,
|
||||
PipelineStageFlags2.CopyBit,
|
||||
AccessFlags2.TransferReadBit,
|
||||
PipelineStageFlags2.AllCommandsBit,
|
||||
AccessFlags2.None);
|
||||
|
||||
VulkanInterop.Check(_vk.EndCommandBuffer(commands), "vkEndCommandBuffer (capture)");
|
||||
|
||||
var commandSubmit = new CommandBufferSubmitInfo
|
||||
{
|
||||
SType = StructureType.CommandBufferSubmitInfo,
|
||||
CommandBuffer = commands,
|
||||
};
|
||||
var submit = new SubmitInfo2
|
||||
{
|
||||
SType = StructureType.SubmitInfo2,
|
||||
CommandBufferInfoCount = 1,
|
||||
PCommandBufferInfos = &commandSubmit,
|
||||
};
|
||||
VulkanInterop.Check(_vk.QueueSubmit2(_graphicsQueue, 1, &submit, default), "vkQueueSubmit2 (capture)");
|
||||
VulkanInterop.Check(_vk.QueueWaitIdle(_graphicsQueue), "vkQueueWaitIdle (capture)");
|
||||
|
||||
var pixels = new byte[byteCount];
|
||||
readback.Read(0, pixels);
|
||||
// ToRgba, NOT ToGlOriginRgba: IGpuDevice.CaptureBackbuffer is
|
||||
// documented as top-left-origin, and a Vulkan image already is.
|
||||
// (VulkanSwapchain.CaptureImage feeds FrameScreenshotController
|
||||
// instead, which flips again on the way to the PNG, so THAT path
|
||||
// flips here to cancel. Two consumers, two conventions, one
|
||||
// difference — worth stating because a single wrong choice produces
|
||||
// a perfectly plausible upside-down screenshot.)
|
||||
return VulkanBackbufferSwizzle.ToRgba(pixels, (int)width, (int)height, (int)width * 4);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (pool.Handle != 0)
|
||||
_vk.DestroyCommandPool(_device, pool, null);
|
||||
readback.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private readonly bool _retainBackbufferCapture;
|
||||
private VulkanGpuBuffer? _captureBuffer;
|
||||
private uint _captureWidth;
|
||||
private uint _captureHeight;
|
||||
private bool _backbufferRenderingReady;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue