refactor(render): Campaign V slice V6i-2 commit 3 — the mesh pipeline stops naming a backend

Plan §5.5.10 recorded the blocker as a fact about types: "WbMeshAdapter owns an
OpenGLGraphicsDevice, so it is not constructible on Vulkan until slice V4t" —
which is the entire reason NullWbMeshAdapter exists. §5.5.12 item 6 then measured
how wide that dependency really is, and the answer is seven members out of a
760-line class: a GL context, the retirement queue, the shared instance VBO, and
two capability flags.

IMeshPipelineDevice is exactly that surface. OpenGLGraphicsDevice declares it and
every member already existed under a GL-specific name, so the shipping backend
executes not one changed statement — these are aliases, not behaviour.

Two casts moved, and they are what actually blocked construction:

- ObjectMeshManager downcast IGpuDevice to GlGpuDevice in its CONSTRUCTOR, so a
  Vulkan-composed pipeline threw before running a statement. V4t put it there
  because the class registered bindless handles itself; commit 2 moved that into
  the array, leaving the field a pass-through for the raw-GL renderers' handle
  table. The cast now lives on that one property and names the backend it was
  composed against instead of reporting a failed cast.
- The atlas array factory is selected by IWorldTextureArrayFactory.For, which is
  the one place the texture stack branches on a backend.

MeshPipelineDeviceSeamTests proves the decoupling rather than describing it: it
builds ObjectMeshManager against a device whose Gl is null, asserts it constructs,
asserts construction built no GL object, asserts the handle table refuses by name,
and asserts the factory picks the RHI arm. A reflection test pins the seam's
member set so a later slice cannot quietly widen it back out — the whole value
here is that it is narrow.

What this does NOT claim: the mesh pipeline does not RUN on Vulkan. Its upload
bodies are still raw GL — GlobalMeshBuffer, the VAO/IBO construction, the layer
transfers — and they now fail through one RequireGl() accessor that names the
slice that owns porting them, instead of failing at construction. WbMeshAdapter
still creates an OpenGLGraphicsDevice in its GL constructor, because there is no
second implementation to create yet. Those bodies are items 3–5 of §5.5.12's
remainder list, along with RetailPViewPassExecutor and the three world renderers'
submission arms.

§5.5.13 reports the whole of V6i-2 and the slice table gains its V6i row.

Gates: Release build; App tests 4,109 / 3 skips (the 4,086 baseline plus 23 across
the three commits); complete Release suite 9,172 / 5; strict GL offline pixel gate
vs 0ca802cd 1.60e-05 (9 px of 563,200 — the low end of the documented 9–31 px
control band, and fewer than a same-commit control has measured); GL connected
tools/run-repeat-connected-gate.ps1 -Runs 3 at 3/3 RENDERED on the desktop witness
and 3/3 on the client capture; one Vulkan composition-host run with
VK_LAYER_KHRONOS_validation proven inserted by the loader at zero errors, zero
warnings, no [shutdown] diagnostic, and a captured frame.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-28 14:09:48 +02:00
parent c8d0f70bbe
commit 5b3d72a90c
7 changed files with 459 additions and 18 deletions

View file

@ -574,6 +574,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 35 — the submission arms, `RetailPViewPassExecutor`, and the pass-structure merge — are the next slice's.** | pixel gate per commit (1.60e-05, 3.20e-05, 3.02e-05 vs `0ca802cd`), App tests, 3/3 desktop-witness connected run at commits 2 and 3, one validation-layer Vulkan run per commit |
| **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 |
@ -1609,6 +1610,134 @@ honest state and it is why the hazard was closed as its own commit: when the
world arm lands and a frame first holds three renderers' buffers, a blank or
corrupt result cannot be this defect.
#### 5.5.13 V6i-2 (2026-07-28): world texture creation reaches `IGpuTexture`, and the mesh pipeline stops naming a backend
§5.5.12's ordered remainder list had six items. This slice is items 1, 2 and 6 —
the exercisable prerequisites the world arm cannot be written without — in three
separately gated commits. Items 35 are the next slice's and are unchanged.
**1. The terrain clip block was in the wrong descriptor set, and so was the whole
uniform layout** (`f7344758`). `terrain_modern.vert` declared `TerrainClip` with
no `ACDREAM_UBO_SET`, so the Vulkan dialect put it at set 0 binding 2 — which set
0 declares as a STORAGE buffer. `spirv-dis` on the committed `.spv`, before and
after:
```
before %372 = OpVariable %_ptr_Uniform__struct_370 Uniform
OpDecorate %372 DescriptorSet 0 / Binding 2
after OpDecorate %372 DescriptorSet 1 / Binding 2
```
with `%_struct_370 = OpTypeStruct %int %_arr_v4float_uint_8` — the block's
`{ int; vec4[8]; }` — unchanged in both. The same commit closed §5.5.8's recorded
`UniformSkyParams` gap, because set 1's layout declared only bindings 1 and 3 and
was therefore missing BOTH. All four are now declared and dynamic, which is half
Vulkan's guaranteed `maxDescriptorSetUniformBuffersDynamic`.
Membership AND order now come from one predicate — `IsDeclaredUniformBinding`
that the layout, the descriptor writes and `vkCmdBindDescriptorSets`'s
dynamic-offset array are all derived from, the shape V6g gave set 0. The three had
been restated separately, which is how a fifth binding would have gone wrong the
same way.
**Both gaps were found by hand, months apart, and neither could fail on the
shipping backend.** `VulkanShaderDescriptorContractTests` now reads every
committed `.spv` and asserts the partition instead: every uniform block at a
declared set-1 binding, every storage block inside set 0's range, every sampled
resource in the one texture table. Checked out against the pre-fix `.spv`, two of
its four tests fail — so it is a gate, not a description.
**2. World texture CREATION crosses to `IGpuTexture`** (`c8d0f70b`), which is what
V4t explicitly deferred and §5.5.12 item 1 handed forward. `IWorldTextureArray` is
the seam, and **the slot is what crosses it**: `ObjectMeshManager` used to read
`BindlessWrapHandle`/`BindlessClampHandle` off the concrete GL array and intern
them itself, and a 64-bit `ARB_bindless_texture` handle has no Vulkan spelling. The
array now answers `ResolveSlot(wrapping)` — the GL arm makes the same idempotent
interning call one level down, the RHI arm returns a pair registered at
construction — and `ReleaseTextureSlots` replaces the snapshot dictionary the
manager kept, still running only once physical retirement completes.
Which implementation exists is decided ONCE, by the factory composition builds.
Everything above the seam is written once: capacity policy, slot allocation, ref
counting, layer retirement, empty-atlas eviction, and the whole of
`ObjectMeshManager`'s atlas policy.
Three deliberate differences, each because the backends genuinely differ. BC mip
chains are CPU-built through V6b's `BlockCompressionMipChain`, since Vulkan cannot
blit into a compressed image, while RGBA8 uses the device's blit. Filtering lives
in an immutable sampler rather than a texture parameter, so both address modes are
registered up front — the same reason the GL array holds two resident handles. And
**RGB8, A8 and Rgba32f are refused at creation with the reason named.** A8 is the
one worth recording: the GL array serves it by swizzling R into A, and a Vulkan
swizzle lives in the image VIEW, which the pinned `GpuTextureDescription` does not
describe. A silent substitution would render wrong and look like a shader bug.
Whoever draws world materials on Vulkan either meets a real A8 atlas and extends
the contract, or proves none exists.
`TerrainAtlas` gained the second construction path V6i drafted and reverted, with
the decode factored out and shared so both arms read the same DATs in the same
order with the same resize-to-max policy. `ICompositeTextureArrayBackend` gained
its RHI arm, which is four small methods because that seam was already a seam.
**The arm is EXERCISED, and that is the point.** The V6i draft was reverted
precisely because nothing exercised it, and §5.5.12 measured the same failure twice
over in the descriptor layouts. So the composition host now builds the real terrain
atlas through `IGpuDevice.CreateTexture` on the arm with no GL context, and creates
and releases one shared array of each format family plus one composite array at
startup. Creation only; nothing draws them. Releasing them in the same statement
covers what a retained bundle would not — that both slot pairs come back and the
images route through the retirement queue.
**3. The mesh pipeline stops naming a backend** (§5.5.12 item 6). That item
measured the dependency and found it seven members wide out of a 760-line class: a
GL context, the retirement queue, the instance VBO, and two capability flags.
`IMeshPipelineDevice` is exactly that surface, `OpenGLGraphicsDevice` declares it,
and every member already existed — so the GL arm executes not one changed
statement. Two casts moved: the `GlGpuDevice` downcast left the constructor for the
one property that genuinely needs it (the raw-GL renderers' handle table), and the
atlas factory is selected by `IWorldTextureArrayFactory.For`. **`ObjectMeshManager`
now constructs against a device with no GL context at all**, which is what
`MeshPipelineDeviceSeamTests` proves; before this commit the constructor threw a
cast before running a statement, and that is why `NullWbMeshAdapter` exists.
**What this does NOT claim.** The mesh pipeline does not RUN on Vulkan. Its upload
bodies are still raw GL — `GlobalMeshBuffer`, the VAO/IBO construction, the layer
transfers — and they now fail at the site that needs GL, naming the slice that owns
them, instead of failing at construction. `WbMeshAdapter` still creates an
`OpenGLGraphicsDevice` in its GL constructor, because there is no second
implementation to create yet. A reflection test pins the seam's member set so a
later slice cannot quietly widen it back out.
**Gate results.** Strict GL offline pixel gate against `0ca802cd`: **1.60e-05**,
**3.20e-05** and **3.02e-05** at the three commits (9, 18 and 17 differing pixels
of 563,200), every one inside the documented 931 px control band and at least 31x
under the 0.001 threshold. GL connected `tools/run-repeat-connected-gate.ps1
-Runs 3` at commits 2 and 3: **3/3 RENDERED** on the desktop witness and 3/3 on the
client capture, both times. One Vulkan composition-host run per commit with
`VK_LAYER_KHRONOS_validation` **proven inserted by the loader**: zero validation
errors, zero warnings, no `[shutdown]` diagnostic on either stream, captured frame.
App tests 4,109 / 3 skips — the 4,086 baseline plus 23. The Vulkan run at commit 2
built `terrain-atlas` 512x512x33 with 10 mip levels, `terrain-alpha-atlas`
512x512x8 (4 corner, 1 side, 3 road), RGBA8 64x64x32 at slots 3/4 with 174,720 mip
bytes blitted, BC1 64x64x32 at slots 5/6 with 696 mip bytes encoded, and a
composite 32x32x8 at slot 7.
**Two intermittent test failures, filed rather than attributed.** A whole-suite run
failed `Issue181WallPressEquilibriumTests` once; it passed alone and did not recur
in five further runs. Seven test classes mutate the same process-global
`CameraDiagnostics` switches with no xUnit collection isolation, and this slice
touches no camera, visibility or physics code. Separately, a run of the UNCHANGED
parent tree failed a zero-allocation test — `#250`'s documented class, which fired
on three different tests across these runs.
**What the world arm still needs, and it is now exactly items 35.** §5.5.12 item
3's flat-versus-PView question is unchanged and still decides how much of the world
arm is one slice: the offline gate's scene has buildings, so
`WorldRenderFrame.ClipRoot` takes the PView path, and `RetailPViewPassExecutor`
(685 lines) is on the critical path to the first Vulkan Dereth PNG. Beyond that:
the three world renderers' submission arms, and the mesh pipeline's raw-GL upload
bodies this slice deliberately left in place.
### 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