diff --git a/docs/plans/2026-07-27-vulkan-campaign.md b/docs/plans/2026-07-27-vulkan-campaign.md index a6194a0d..54dc7bf6 100644 --- a/docs/plans/2026-07-27-vulkan-campaign.md +++ b/docs/plans/2026-07-27-vulkan-campaign.md @@ -575,6 +575,7 @@ 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 | | **V6h** ✅ | **The Vulkan composition host**, specified by §5.5.9 and reported in §5.5.10. `ACDREAM_RENDER_BACKEND=vulkan` runs the real `GameWindow` composition — DAT load, streaming, camera, entity table, session, and the real retained `UiHost` through the RHI — with no world renderers. Three seams: the already-generic platform acquisition now publishes a `GameWindowGraphics`; `VulkanHostInputCameraCompositionFactory` is the host-phase fork; the frame root gains a Vulkan arm. `VulkanBringUpHost` is reduced to the capability-probe harness over the extracted `VulkanGraphicsContext`. | offline Vulkan launch reaching the real composition with the client's own UI captured, one validation-layer run at 0 errors / 0 warnings, converging ownership ledger, App tests 4,075/3, complete Release suite 9,138/5, GL offline pixel gate 1.78e-05 | | **V6i** ✅ | **The world arm's prerequisites**, in two parts. **V6i-1** (`df6e2a79`, reported in §5.5.12) closed §5.5.8's one-binding-two-buffers hazard with one descriptor-set pair per renderer scope, derived from the descriptor state rather than declared, and measured the ordered remainder list the world arm still needed. **V6i-2** (reported in §5.5.13) took items 1, 2 and 6 of that list in three gated commits: **1** the `TerrainClip` descriptor-set fix plus set 1's missing bindings 2 and 4, proven by `spirv-dis` and now gated by a SPIR-V-reading contract test (`f7344758`); **2** world texture CREATION crosses to `IGpuTexture` — `IWorldTextureArray` over `TextureAtlasManager`/`ManagedGLTextureArray`, `TerrainAtlas`'s second construction path, and `ICompositeTextureArrayBackend`'s RHI arm — with the Vulkan arm exercised at startup (`c8d0f70b`); **3** `IMeshPipelineDevice` decouples `ObjectMeshManager`/`WbMeshAdapter` from `OpenGLGraphicsDevice`. **Items 3–5 — the submission arms, `RetailPViewPassExecutor`, and the pass-structure merge — are the next slice's.** | pixel gate per commit (3.02e-05, 3.20e-05, 1.60e-05 vs `0ca802cd`), App tests 4,109/3 and complete Release suite 9,172/5, 3/3 desktop-witness connected run at commits 2 and 3, one validation-layer Vulkan run per commit | +| **V6i-3** | **The mesh pipeline runs on both arms, and the Vulkan frame gets a world pass**, reported in §5.5.14. Two commits: **1** `GlobalMeshBuffer` takes `GL?` and publishes `VertexStore`/`IndexStore`, `ObjectMeshManager`'s `RequireGl` narrows to the unreachable legacy upload, `VulkanMeshPipelineDevice` is `IMeshPipelineDevice`'s second implementation, and `NullWbMeshAdapter` is deleted (`fe8abacf`); **2** the clear merges into the world pass with `Store = Resolve` and descriptor sets bind at draw time (`887de4ae`). **The world renderers' submission arms and `RetailPViewPassExecutor` did NOT land** — §5.5.14 enumerates what they still need. | pixel gate per commit (4.44e-05, 3.73e-05 vs `579e0b7f`), App tests 4,112/3, one 3/3 desktop-witness connected run at HEAD, one validation-layer Vulkan run per commit, and a bit-identical (0/921,600) Vulkan capture across the pass merge | | **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 | @@ -1740,6 +1741,122 @@ arm is one slice: the offline gate's scene has buildings, so the three world renderers' submission arms, and the mesh pipeline's raw-GL upload bodies this slice deliberately left in place. +#### 5.5.14 V6i-3 (2026-07-28): the mesh pipeline runs, and the frame has a world pass + +§5.5.13's remainder was items 3–5 — the three world renderers' submission arms, +`RetailPViewPassExecutor`, and the pass-structure merge — plus the mesh +pipeline's raw-GL upload bodies. **This slice delivered the two prerequisites and +did NOT deliver the world arm.** Stating that first, because the slice's brief +was a Dereth PNG and there is not one. + +**1. The mesh pipeline's upload bodies cross the seam** (`fe8abacf`). V6i-2 cut +`IMeshPipelineDevice` and proved the pipeline could be CONSTRUCTED without naming +a backend; it said plainly it did not RUN. `GlobalMeshBuffer` now takes `GL?`. +Its two backing stores were already `IGpuBuffer` (V4b); what still needed a +context was the vertex array and its attribute pointers, which have no RHI verb +because Vulkan bakes vertex input into the pipeline. A backend with none builds +the stores and nothing else, publishes 0 for `VAO`/`VBO`/`IBO`, and publishes +`VertexStore`/`IndexStore` — the same buffers, named the way a pass encoder binds +them — plus `HasStores`, the backend-neutral form of the `VAO != 0` readiness +test the raw-GL draw paths make. Two bodies fork on the context: `InitBuffers` +skips the vertex array, and `CommitMigration` skips the rebind, because on the +encoder arm the field swap IS the atomic publication. + +`ObjectMeshManager`'s `RequireGl` narrowed to the LEGACY per-mesh upload — every +GL statement it guarded sits inside `if (!_useModernRendering)`, which the N.5 +ship amendment makes unreachable, so the accessor survives as the guard on dead +code rather than as a blocker. `VulkanMeshPipelineDevice` is the second +implementation and is four properties and two no-ops; `WbMeshAdapter` selects +between them once. **`NullWbMeshAdapter` is deleted** — it existed for exactly +this gap — and composition builds the mesh pipeline on both arms, so streaming's +publication into GPU state stops being a no-op there. + +**2. The clear merges into the world pass** (`887de4ae`), which is §5.5.12 +item 5. V6h's clear phase opened a backbuffer pass of its own; under MSAA that +pass resolves into the swapchain image and stores `DONT_CARE` into the +multisampled scratch, so any world pass that followed would `Load` undefined +contents. The clear phase now publishes only the COLOUR and +`VulkanWorldScenePhase` opens the one backbuffer pass, clearing as its load op +with `Store = Resolve`. **This is why the world renderers cannot each open their +own pass on this backend**, and it is the whole reason the arm is shaped +differently from V4c's. + +The same commit made descriptor sets bind at DRAW time rather than at bind time. +V6i-1's arena derives a scope from the descriptor state, and the encoder resolved +after every bind — so a renderer binding ten buffers materialised up to ten +scopes per draw, nine of them PARTIAL states no draw uses, each claiming a +descriptor-set pair and a round of `vkUpdateDescriptorSets`. Legal to defer +because acdream has one pipeline layout by design (§4.4). + +**Gates.** Release build green. App tests **4,112 / 3 skips** (the 4,109 baseline +plus three). Strict GL offline pixel gate against `579e0b7f`: **4.44e-05** at +commit 1 and **3.73e-05** at commit 2 (25 and 21 differing pixels of 563,200), +both inside the documented 9–31 px control band. GL connected +`tools/run-repeat-connected-gate.ps1 -Runs 3` at HEAD: **3/3 RENDERED** on the +desktop witness and 3/3 on the client capture. One offline Vulkan run per commit +with `VK_LAYER_KHRONOS_validation` **proven inserted by the loader**: zero +errors, zero warnings, no `[shutdown]` diagnostic. The Vulkan capture at commit 2 +is compared against commit 1's rather than eyeballed — **0 differing pixels of +921,600, maximum channel delta 0**, bit-identical across the merge. + +**What the world arm still needs, measured rather than estimated.** Every item +below was designed and, where noted, written and then withdrawn because nothing +exercised it — §7.1 rule 3. The design decisions are the expensive part and they +are recorded here so the next slice does not re-derive them. + +1. **One pass, shared by every world renderer.** V4c's renderers each opened + their own `Load`/`Store` pass. On this backend they must record into the pass + `VulkanWorldScenePhase` opens, for the resolve reason above. The seam that + fits is a `VulkanWorldPassScope` the phase publishes the encoder on and the + renderers borrow, with the phase bracketing `WorldSceneRenderer`. +2. **Three sections are frame-global on GL and cannot be on Vulkan**: the + SceneLighting UBO (set 1 binding 1), the per-cell clip regions (set 0 binding + 2) and the terrain clip block (set 1 binding 2). GL binds each globally and + every consumer inherits it. `SceneLightingUboBinding` and `ClipFrame` need + ring-writing arms that PUBLISH their sections on the scope; each renderer then + binds them inside the pass, after its own binds, because its own binds are + what select the descriptor scope they have to land in. +3. **Retail's interior depth clear has no RHI verb.** `RetailPViewPassExecutor` + issues `glClear(GL_DEPTH_BUFFER_BIT)` between the landscape slice and the + interior cells. Splitting the world pass to get a depth load-op is exactly + what the resolve forbids, so the Vulkan arm must record + `vkCmdClearAttachments` — reached through the scope, so the pinned contract + stays frozen and the backend-only verb stays inside the backend. +4. **Clip distances need no enable on Vulkan, and that is safe rather than a + divergence.** GL requires `glEnable(GL_CLIP_DISTANCE0 + i)`; Vulkan activates + every element the shader declares. All three vertex shaders already write + `1.0` — keep everything — into every plane slot past the active count, so a + frame with no clip planes clips nothing on either backend. + `EnableClipDistances`/`DisableClipDistances` are no-ops on the Vulkan arm. +5. **Only FOUR storage bindings can be dynamic, and the world arm re-points + eight per draw.** §5.5.8 offered to promote bindings 6–8 back to dynamic and + said there were "four unused dynamic slots"; there are not. Vulkan's + guaranteed `maxDescriptorSetStorageBuffersDynamic` is **4**, which is exactly + what V6g spends (bindings 0, 1, 3, 5). Bindings 4, 6, 7 and 8 are per-draw + ring allocations too, so on Vulkan each moves the descriptor's own offset and + therefore costs a descriptor write and a distinct arena entry per draw. + Correct, bounded (the arena recycles entries across frames), and the reason + the draw-time bind above matters: without it the cost is ten scopes per draw + instead of one. A slice that wants it cheaper has to change the shaders' + indexing, not the layout. +6. **Pipeline `SampleCount` is load-bearing on Vulkan.** V4c created every world + pipeline with `SampleCount = 1` because the GL backend ignores it. Vulkan + requires the pipeline's `rasterizationSamples` to match the pass, and + alpha-to-coverage requires MSAA, so the world pipelines must be created with + the device's sample count. +7. **The collision-wireframe debug lines would nest a pass.** + `WorldSceneDiagnosticsController.DrawAndPublish` flushes `DebugLineRenderer` + INSIDE the world phase, and that renderer opens its own pass. On the Vulkan + arm the diagnostics controller must be composed with a null debug-line + renderer: the toggle is DevTools-only and DevTools is not composed there, so + nothing is lost, but composing it would throw on the first wireframe frame + rather than silently misdraw. + +Beyond that the remainder is unchanged from §5.5.13: the three renderers' +submission arms and `RetailPViewPassExecutor`, which §5.5.12 item 3 established +is on the critical path because the offline gate's scene has buildings and +therefore takes the PView route. + ### 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