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:
Erik 2026-07-28 10:47:52 +02:00
parent f8dbe2ee4a
commit 24834a6478
12 changed files with 653 additions and 175 deletions

View file

@ -572,6 +572,7 @@ dialect slice. **V6f closed all three** (the third was the frag's GL-only
`sampler2DArray(handle)` construction), so 8/9 pairs now compile. `mesh` is a
tenth pair with no consumer at all; see the V6e report.
| **V6g** | The four Vulkan validation defects §5.5.7 and its log left open: the dynamic-descriptor split (an architect decision, §5.5.8 item 1), per-pass depth-format pipeline variants, first-use backbuffer attachment layout transitions, and a backbuffer capture that no longer reads a presented swapchain image. Confined to `Gpu/Vk/`; the GL backend executes not one changed statement. | validation-clean bring-up run (0 errors / 0 warnings over 39,855 frames, against 7 VUIDs + 1 UNASSIGNED at the parent), App tests, GL offline pixel gate 4.08e-05 — its own same-commit control value |
| **V7** | GL-versus-Vulkan differential: `tools/run-backend-differential-gate.ps1`, strict paired-PNG compare, divergences fixed in the Vulkan backend only, then lifecycle + R6 soak natively on Vulkan, one validation-layer-clean run, one RenderDoc capture. **Milestone: parity.** | every differential checkpoint passes; both connected routes green on VK |
| **V8** | Perf gate on the RX 9070 XT, uncapped, both backends, same route. | §2 acceptance table; parity is the floor |
| **V9** | Linux + CI: X11/Wayland surfaces; a `linux-vulkan` job on lavapipe (probe accepts on a real 1.3 software device, a short real render under xvfb, forced-unsupported → exit 4, `.spv` freshness). Physical Linux GPU row deferred post-cutover, as for Slice L. | CI green including the new job |
@ -1116,6 +1117,84 @@ and sky with no scenery or statics, and would be the first real evidence the
Vulkan world path works. V6f's shader work is the whole of that path's shader
prerequisite.
#### 5.5.8 V6g (2026-07-28): the validation defects are closed, and a fourth was found
§5.5.7's step 2 — "the three validation defects, since every one of them is on
the path any world frame takes" — is done, and the Vulkan bring-up host now runs
**validation-clean**: zero errors and zero warnings across a 39,855-frame run
with `VK_LAYER_KHRONOS_validation` loaded, against the same run that produced
seven of them at `f8dbe2ee`.
**1. The dynamic-descriptor limit (`VUID-VkPipelineLayoutCreateInfo-descriptorType-03032`
/ `-pSetLayouts-03040`).** Resolved by decision rather than patch, as §5.5.7 asked.
V6b declared all ten of set 0's bindings `STORAGE_BUFFER_DYNAMIC`; the rule now is
that **a dynamic descriptor is for ring-fed data whose offset moves, and nothing
else**. Instances (0), batches (1), clip slots (3) and instance light sets (5) stay
dynamic; global lights (4), clip regions (2), instance indoor (6), alpha (7),
selection lighting (8) and the GL-only texture table (9) become plain
`STORAGE_BUFFER` carrying their offset in the descriptor. That is **four** dynamic
storage descriptors — not merely under the RX 9070 XT's 8 but exactly Vulkan's
guaranteed minimum, so no conformant device can fail the layout, which is what V9's
lavapipe row and the deferred physical Linux row depend on. The count is asserted
against `maxDescriptorSetStorageBuffersDynamic` in the capability record, so a
device that cannot serve it is rejected at startup under the exit-code-4 contract
instead of failing at `vkCreatePipelineLayout`. Bindings 68 are per-instance
arrays grouped with the frame-global tables because their owner writes them whole
once per frame; if the Vulkan world path needs one re-pointed per draw, promoting
it back is one line, with four unused dynamic slots to promote into.
**2. Depth-off pipelines in depth-carrying passes
(`VUID-vkCmdDraw-dynamicRenderingUnusedAttachments-08914` / `-08917`).** The
backend now builds **two variants of every pipeline** — one declaring the pass's
depth/stencil format, one declaring `UNDEFINED` — and binds whichever matches what
`vkCmdBeginRendering` was actually handed. The same `GpuPipelineDescription` is
legitimately used in both kinds of pass (`ui-text` opens its own depth-less pass;
the world pass it composites over has depth), so the description genuinely cannot
answer the question. **A later slice entitled to change the contract should add a
depth-format field the way V6d added `ColorFormat`**; until then, materialising
both at startup against the persisted cache is the honest expression of the gap and
no frame ever compiles one.
**3. Missing first-use layout transitions
(`VUID-vkCmdBeginRendering-pRenderingInfo-09588` / `-09590` / `-09592`).**
`vk-backbuffer-depth` and `vk-backbuffer-msaa-color` are created UNDEFINED and were
never moved. Both now get a barrier on every backbuffer pass: from UNDEFINED on the
first use after `Configure`, and from the attachment-optimal layout with a
write-after-write dependency thereafter — the same shape the swapchain image
already had. The dependency matters independently of the layout: two passes in one
frame write both images, and so does the next frame, with no implicit ordering
between render-pass instances.
**4. The capture path read an image it did not own
(`UNASSIGNED-non-acquired-swapchain-image-used`).** Present in V6f's log and not
called out there. `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 the application's to
read. The pixels were usually right — which is exactly what makes it
unacceptable. **This campaign spent §5.5.1§5.5.3 discovering what a capture
instrument that is "usually right" costs**, and shipping the same 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 `CaptureBackbuffer` reads that. Retention is
opt-in (armed when an artifact directory exists) because it costs one full-res
image-to-buffer copy per frame: worth nothing to a player, and the entire
instrument to a gate.
**Two gaps recorded, not fixed** — both outside this slice's brief, both real:
- **`UniformSkyParams` (set 1, binding 4) is not in the uniform set layout**, which
declares only bindings 1 and 3, and `VulkanFrameBindings.UniformBindingCount` is
4, so `SetUniform(4, …)` throws before it can be wrong. The `sky` pair compiles to
SPIR-V declaring that binding, so whoever first draws sky on Vulkan must add it.
- **A binding pointed at two different buffers within one frame silently corrupts
the earlier draws**, on dynamic and plain descriptors alike: `SetStorage` rewrites
the descriptor when the buffer changes, and descriptor contents are read at
execution time, not record time. No consumer does this today. The world path
will: `WbDrawDispatcher` and `EnvCellRenderer` each own their own instance and
batch buffers and both bind bindings 0, 1, 3, 4 and 5 in one frame. **The Vulkan
world arm needs one descriptor set per renderer, or per-renderer sub-ranges of one
buffer, and it needs to know that before it is written.**
### 5.4 The null-target `BeginPass` divergence (V4c) — must be undone at V6
V4c had to stop GL's `BeginPass` from binding framebuffer 0 when a pass declares