feat(render): draw the retained UI on Vulkan, and fix the pass it exposed

Campaign V slice V6d, commit 3 of 3 — the evidence commit, which turned out to also be a bug-fix commit.

VulkanBringUpHost now builds a real UiHost and DebugLineRenderer on the Vulkan device and draws them after the V6c verification scene, in their own single-sampled load/store passes against the backbuffer — the same shape the GL client's HUD phase has. Nothing in the retained stack is backend-aware: UiRoot walks a real widget tree, each widget draws through UiRenderContext, and UiHost.Draw brackets it with TextRenderer.Begin/Flush. What it cannot be is the game's own UI, because the retail tree is built from LayoutDesc and DAT chrome by TextureCache, which stays a GL type until V4t; the sprites here are generated instead. The widget rectangles are authored at known pixel offsets from the top-left and nothing is mirror-symmetric, so a wrong Y flip would put the title bar at the bottom.

The frame this produced was wrong, and usefully so. Whole runs of the debug-line figure were missing. Vulkan's rasterization-order guarantees are scoped to one render-pass instance; between two instances writing the same attachment there is no implicit ordering, and that includes a multisample RESOLVE, which is part of the render pass and therefore equally unordered against what follows. TransitionBackbufferForRendering emitted its acquire barrier once per frame and returned for every pass after the first, so the second and third passes raced the first one's resolve. V6c's frame had exactly one backbuffer pass and could not see this; V6d's has three. A later backbuffer pass now gets a colour-attachment dependency instead of nothing, and keeps ColorAttachmentOptimal as its old layout rather than Undefined, which would have licensed discarding everything drawn so far. Every line renders continuously afterwards.

Inspection of artifacts/vk-ui/vulkan-bringup.png against the authored layout, by pixel probe:

The header panel is authored at (24,18), 420x96. Its tiled chrome fills exactly x 24..443 and y 18..113 — one pixel outside on any edge is the clear colour. The tile's lit edge appears at the top and left of every cell, so texture row 0 lands at the top and the V axis is not flipped. Both labels read left to right, right side up, through the font-coverage branch. The nested panel's border samples exactly (153,191,255) against an authored (0.6,0.75,1.0), unblended — the untextured branch is bit-exact. The badge sprite is authored at (460,58), 64x64, and its gradient starts at x=460 with the clear colour at 455 — the RGBA-modulate branch, sampling a table slot. The two debug-line segments land on their computed screen coordinates. All three fragment branches, the pixel-to-NDC mapping, the top-left origin, straight-alpha blending and table sampling are therefore all confirmed on Vulkan, which is everything the offline GL gate confirms about the same code on GL.

The plan's V6 milestone is amended rather than claimed: "full game frame on Vulkan" is not reachable while V4c/V4d are parked and the world renderers and TextureCache are still raw GL, so V6 delivers the backend plus the two renderers that can use it today. The accumulated user-gate table gains a V6d row for the paperdoll/appraisal viewport sprite — the one retained-UI texture the offline scene never draws, on a slice that changed how every UI texture is sampled.

App tests 4,057 passed / 3 skipped, unchanged. Offline pixel gate against f6f58a12: differing fraction 3.20e-05, 18 pixels of 563,200, inside the documented 15-23 pixel noise band — as expected, since this commit touches only Vulkan files and the campaign doc.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-28 09:03:58 +02:00
parent f6f58a12db
commit 95f8c25f31
4 changed files with 323 additions and 12 deletions

View file

@ -74,6 +74,7 @@ internal sealed unsafe class VulkanBringUpHost : IDisposable
// ── Campaign V slice V6c: the RHI backend and the scene that proves it ──
private VulkanGpuDevice? _gpuDevice;
private VulkanRhiScene? _scene;
private VulkanRetainedUiScene? _ui;
private VulkanDebugNames _debugNames = VulkanDebugNames.Disabled;
private VulkanDeviceFeatureSupport? _features;
private VulkanDeviceLimitSupport? _limits;
@ -394,6 +395,16 @@ internal sealed unsafe class VulkanBringUpHost : IDisposable
$"{sampleCount}x MSAA, pipeline cache " +
(_gpuDevice.PipelineCacheLoadedFromDisk ? "reused" : "cold") +
$", debug names {(_debugNames.IsEnabled ? "on" : "off")}");
// Campaign V slice V6d: the first production renderers on Vulkan. The
// retained UI and the debug lines draw here through exactly the classes
// the GL client uses — the scene only supplies a widget tree and its
// sprites, because the retail tree's chrome still comes from a GL-only
// TextureCache until V4t.
_ui = new VulkanRetainedUiScene(_gpuDevice, ShaderSpirvDirectory());
_log(
"vulkan: retained UI up — TextRenderer and DebugLineRenderer on the Vulkan device" +
(_ui.HasFont ? string.Empty : " (no system font found; glyph draws are skipped)"));
}
/// <summary>Where the committed SPIR-V lives beside the binary.</summary>
@ -479,13 +490,15 @@ internal sealed unsafe class VulkanBringUpHost : IDisposable
}
VulkanSwapchainConfiguration configuration = _swapchain!.Configuration!;
double elapsed = (DateTimeOffset.UtcNow - started).TotalSeconds;
using (frame)
{
scene.Render(
frame,
configuration.Width,
configuration.Height,
(DateTimeOffset.UtcNow - started).TotalSeconds);
scene.Render(frame, configuration.Width, configuration.Height, elapsed);
// After the 3-D scene, in its own single-sampled load/store pass
// against the backbuffer — the same shape the GL client's HUD
// phase has, and the reason the multisampled pass must resolve
// rather than store.
_ui?.Render(frame, configuration.Width, configuration.Height, elapsed);
}
_frameSerial = (ulong)frame.Serial;
@ -570,6 +583,8 @@ internal sealed unsafe class VulkanBringUpHost : IDisposable
// Scene before device: the scene owns buffers, textures, render
// targets and pipelines whose release routes through the device's
// retirement queue, so the device has to still be alive to drain it.
_ui?.Dispose();
_ui = null;
_scene?.Dispose();
_scene = null;
_gpuDevice?.Dispose();
@ -583,6 +598,8 @@ internal sealed unsafe class VulkanBringUpHost : IDisposable
}
else
{
_ui?.Dispose();
_ui = null;
_scene?.Dispose();
_scene = null;
_gpuDevice?.Dispose();