diff --git a/docs/plans/2026-07-27-vulkan-campaign.md b/docs/plans/2026-07-27-vulkan-campaign.md
index f4f4d03b..8df9f0fe 100644
--- a/docs/plans/2026-07-27-vulkan-campaign.md
+++ b/docs/plans/2026-07-27-vulkan-campaign.md
@@ -511,6 +511,7 @@ session, rather than one at a time:
| V4e | Particles (again) |
| V4f | Sky — deliberately masked for determinism |
| V4g | Paperdoll and appraisal viewports, portal transit |
+| V6d | The paperdoll/appraisal viewport sprite. It is the one retained-UI texture the gate's scene never draws, and V6d changed how every UI texture is sampled — from a bound texture unit to a table slot. The seam that registers it (`GlGpuDevice.RegisterExternalColorTexture`) is unchanged and its handle is now simply encoded rather than resolved back to a GL name, but that path is unproven by anything automated. Check it with the dungeon/portal pass above rather than on its own |
**The user confirmed on 2026-07-27 that the local ACE server is always available
and they will verify visually on request.** That converts this table from deferred
@@ -553,7 +554,7 @@ because sample positions are not specified across implementations.
| **V4g** ↻ **re-sequenced — §5.5.5** | `PrivateEntityViewportRenderer` → `IGpuRenderTarget`; `PortalDepthMaskRenderer` + `PortalTunnelPresentation` → stencil/depth-mask pipelines. | pixel gate incl. paperdoll and portal transit |
| **V4h** ↻ **re-sequenced — §5.5.5** | Frame-spine formalization: pass executors emit real declared `BeginPass`/`EndPass` (clears and framebuffer management move out of the spine and into pass load/store ops), flight/screenshot/resize/profiler move onto the RHI, `OpenGLGraphicsDevice`'s live role retires, Chorizite consumers are audited, and the architecture test lands. **Milestone: seam complete.** | pixel + connected lifecycle + R6 soak + complete Release suite + interim perf (RHI-on-GL CPU p50 ≤ 1.95 ms) |
| **V5** ✅ | Vulkan bring-up, dark: `ACDREAM_RENDER_BACKEND`, surface/instance/device/queues/swapchain, the capability record/probe/guard with the exit-4 contract, a clear-colour loop with screenshot and clean shutdown. | VK boots to clear on the RX 9070 XT; forced-unsupported knob → exit 4 |
-| **V6** | Vulkan RHI backend, dark, three sequential commits: **a** allocator/buffers/staging/rings/timeline; **b** textures/BC mips/samplers/descriptor table/render targets/MSAA resolve; **c** `.spv` toolchain, pipelines, pipeline cache, negative viewport, push constants, timestamps, readback, debug names. **Milestone: full game frame on Vulkan.** | per-commit build + tests; VK renders world, UI, paperdoll, portals |
+| **V6** | Vulkan RHI backend, dark, four sequential commits: **a** ✅ allocator/buffers/staging/rings/timeline (`fb9c6693`); **b** ✅ textures/BC mips/samplers/descriptor table/render targets/MSAA resolve (`9eae4963`); **c** ✅ `.spv` toolchain, pipelines, pipeline cache, negative viewport, push constants, timestamps, readback, debug names (`234fe91d`); **d** ✅ first production renderers — `TextRenderer` and `DebugLineRenderer` on both backends, the colour-format contract amendment, and the retained UI drawn on Vulkan. **Milestone deferred:** "full game frame on Vulkan" is not reachable while V4c/V4d are parked and the world renderers plus `TextureCache` are still raw GL, so V6 delivers the backend and the two renderers that can use it today. | per-commit build + tests; V6d additionally pixel-gates GL and captures a Vulkan UI frame |
| **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 |
diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanBringUpHost.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanBringUpHost.cs
index 34cbd376..2969fccb 100644
--- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanBringUpHost.cs
+++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanBringUpHost.cs
@@ -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)"));
}
/// Where the committed SPIR-V lives beside the binary.
@@ -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();
diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.Resources.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.Resources.cs
index 57ffd275..3ef4cbb9 100644
--- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.Resources.cs
+++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.Resources.cs
@@ -518,20 +518,49 @@ internal sealed unsafe partial class VulkanGpuDevice
_openPass = null;
}
+ ///
+ /// Prepares the acquired swapchain image for a backbuffer pass.
+ ///
+ /// The FIRST pass of a frame acquires it: undefined contents, no prior
+ /// access to wait on, layout moved to colour-attachment.
+ ///
+ /// Every pass AFTER that needs a dependency instead, and slice V6d is
+ /// where that started to matter. Vulkan's rasterization-order guarantees are
+ /// scoped to one render-pass instance; between two instances writing the same
+ /// attachment there is no implicit ordering at all, so the second one's draws
+ /// can land before or interleaved with the first one's colour writes — and
+ /// with the first one's multisample RESOLVE, which is part of the render pass
+ /// and therefore also unordered against what follows. V6c's frame had exactly
+ /// one backbuffer pass and could not see this. V6d's has three (world scene,
+ /// debug lines, retained UI), and the symptom was unmistakable once looked
+ /// at: whole runs of the debug-line figure missing where the earlier pass's
+ /// resolve had overwritten them, while the last pass's output survived
+ /// intact.
+ ///
+ ///
private void TransitionBackbufferForRendering(CommandBuffer commands, Image image)
{
- if (_backbufferRenderingReady)
- return;
+ bool first = !_backbufferRenderingReady;
_backbufferRenderingReady = true;
var barrier = new ImageMemoryBarrier2
{
SType = StructureType.ImageMemoryBarrier2,
- SrcStageMask = PipelineStageFlags2.TopOfPipeBit,
- SrcAccessMask = AccessFlags2.None,
+ SrcStageMask = first
+ ? PipelineStageFlags2.TopOfPipeBit
+ : PipelineStageFlags2.ColorAttachmentOutputBit,
+ SrcAccessMask = first
+ ? AccessFlags2.None
+ : AccessFlags2.ColorAttachmentWriteBit,
DstStageMask = PipelineStageFlags2.ColorAttachmentOutputBit,
- DstAccessMask = AccessFlags2.ColorAttachmentWriteBit,
- OldLayout = ImageLayout.Undefined,
+ DstAccessMask = first
+ ? AccessFlags2.ColorAttachmentWriteBit
+ : AccessFlags2.ColorAttachmentWriteBit | AccessFlags2.ColorAttachmentReadBit,
+ // Undefined for the acquire — the contents are genuinely undefined
+ // and saying so lets the driver skip a decompress. A later pass in
+ // the same frame must NOT say Undefined: that would license
+ // discarding everything drawn so far.
+ OldLayout = first ? ImageLayout.Undefined : ImageLayout.ColorAttachmentOptimal,
NewLayout = ImageLayout.ColorAttachmentOptimal,
SrcQueueFamilyIndex = Silk.NET.Vulkan.Vk.QueueFamilyIgnored,
DstQueueFamilyIndex = Silk.NET.Vulkan.Vk.QueueFamilyIgnored,
diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanRetainedUiScene.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanRetainedUiScene.cs
new file mode 100644
index 00000000..d7f27965
--- /dev/null
+++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanRetainedUiScene.cs
@@ -0,0 +1,264 @@
+using System.Numerics;
+using AcDream.App.Rendering;
+using AcDream.App.UI;
+
+namespace AcDream.App.Rendering.Gpu.Vk;
+
+///
+/// Campaign V slice V6d: the retained UI, drawn on Vulkan by the same
+/// the GL client draws it with.
+///
+/// What this proves and what it does not. The whole retained stack
+/// runs here — walks a real widget tree, each widget draws
+/// through , and brackets
+/// it with /.
+/// Nothing about that path is Vulkan-aware. What it cannot be is the game's own
+/// UI: the retail widget tree is built from LayoutDesc and DAT chrome by
+/// TextureCache, which is still a GL type until slice V4t, so the sprites
+/// here are generated rather than decoded. The geometry of the exercise is what
+/// matters — every branch of ui_text.frag, the pixel-to-NDC mapping, and
+/// the top-left origin — and all three are visible in one screenshot.
+///
+/// Deliberately asymmetric and labelled, for the same reason
+/// is: the one thing a symmetric layout could never
+/// prove is that the negative-viewport Y flip and the capture path agree. Widget
+/// rectangles are authored at known pixel coordinates from the TOP-LEFT corner,
+/// so a wrong flip puts the title bar at the bottom and is unmissable.
+///
+internal sealed class VulkanRetainedUiScene : IDisposable
+{
+ /// Where the header panel sits, in pixels from the top-left. Inspected in the capture.
+ internal const int HeaderLeft = 24;
+ internal const int HeaderTop = 18;
+ internal const int HeaderWidth = 420;
+ internal const int HeaderHeight = 96;
+
+ private readonly MutableFrameSource _frames = new();
+ private readonly UiHost _host;
+ private readonly DebugLineRenderer _lines;
+ private readonly BitmapFont? _font;
+ private readonly List _textures = [];
+
+ private bool _disposed;
+
+ internal VulkanRetainedUiScene(IGpuDevice device, string shaderDirectory)
+ {
+ ArgumentNullException.ThrowIfNull(device);
+
+ byte[]? ttf = BitmapFont.TryLoadSystemMonospaceFont();
+ _font = ttf is null ? null : new BitmapFont(device, ttf, pixelHeight: 18f);
+
+ _host = new UiHost(device, _frames, shaderDirectory, _font);
+ _lines = new DebugLineRenderer(device, _frames, shaderDirectory);
+
+ uint chrome = CreateTexture(device, "vk-ui-chrome", BuildChromeTile(), 16, 16, nearest: false);
+ uint icon = CreateTexture(device, "vk-ui-icon", BuildIcon(), 32, 32, nearest: true);
+
+ BuildTree(chrome, icon);
+ }
+
+ /// True when a system font was found; without one no glyph draws happen.
+ internal bool HasFont => _font is not null;
+
+ ///
+ /// Draws one frame of the retained UI, plus a debug-line figure so both
+ /// renderers ported this slice are exercised in the same frame. Each opens
+ /// its own load/store pass against the backbuffer, exactly as they do in the
+ /// GL client's HUD phase.
+ ///
+ internal void Render(IGpuFrame frame, uint width, uint height, double seconds)
+ {
+ ArgumentNullException.ThrowIfNull(frame);
+ _frames.CurrentFrame = frame;
+ try
+ {
+ // A 3-D figure through the second ported renderer. Identity view
+ // with a plain orthographic-ish projection keeps it in NDC, which is
+ // all this needs to demonstrate: the pipeline binds, the ring
+ // allocation lands, and the push-constant matrix arrives.
+ // Diagonal on purpose: an axis-aligned segment can land exactly on a
+ // pixel boundary and rasterize sparsely, which reads as a bug in a
+ // screenshot when it is only the diamond-exit rule. A slope makes
+ // the evidence unambiguous, and the two segments are different
+ // colours so the per-vertex attribute is visible too.
+ _lines.Begin();
+ float sweep = (float)Math.Sin(seconds) * 0.05f;
+ _lines.AddLine(new Vector3(-0.92f, -0.30f, 0f), new Vector3(-0.30f, -0.68f, 0f), new Vector3(1f, 0.85f, 0.2f));
+ _lines.AddLine(new Vector3(-0.30f, -0.68f, 0f), new Vector3(0.34f, -0.36f + sweep, 0f), new Vector3(0.2f, 1f, 0.6f));
+ _lines.Flush(Matrix4x4.Identity, Matrix4x4.Identity);
+
+ _host.Tick(0.016);
+ _host.Draw(new Vector2(width, height));
+ }
+ finally
+ {
+ _frames.CurrentFrame = null;
+ }
+ }
+
+ ///
+ /// A header panel with a tiled sprite background, a nested solid panel with
+ /// a border, two labels, and a loose icon. Between them these cover all
+ /// three fragment branches: sprite modulate, flat vertex colour (both the
+ /// rect bucket and DrawFill), and font coverage.
+ ///
+ private void BuildTree(uint chrome, uint icon)
+ {
+ var header = new UiPanel
+ {
+ Name = "vk-header",
+ Left = HeaderLeft,
+ Top = HeaderTop,
+ Width = HeaderWidth,
+ Height = HeaderHeight,
+ BackgroundSprite = 1,
+ SpriteResolve = _ => (chrome, 16, 16),
+ };
+ header.AddChild(new UiLabel
+ {
+ Name = "vk-title",
+ Left = 12,
+ Top = 10,
+ Width = 380,
+ Height = 22,
+ Text = "acdream - retained UI on Vulkan",
+ TextColor = new Vector4(1f, 0.94f, 0.72f, 1f),
+ });
+ header.AddChild(new UiLabel
+ {
+ Name = "vk-subtitle",
+ Left = 12,
+ Top = 34,
+ Width = 380,
+ Height = 22,
+ Text = "top-left origin - slice V6d",
+ TextColor = new Vector4(0.75f, 0.9f, 1f, 1f),
+ });
+
+ var inner = new UiPanel
+ {
+ Name = "vk-inner",
+ Left = 12,
+ Top = 58,
+ Width = 260,
+ Height = 28,
+ BackgroundColor = new Vector4(0.05f, 0.08f, 0.16f, 0.85f),
+ BorderColor = new Vector4(0.6f, 0.75f, 1f, 1f),
+ BorderThickness = 2f,
+ };
+ inner.AddChild(new UiLabel
+ {
+ Left = 8,
+ Top = 6,
+ Width = 240,
+ Height = 18,
+ Text = "fill + border + glyphs",
+ TextColor = new Vector4(1f, 1f, 1f, 1f),
+ });
+ header.AddChild(inner);
+
+ // Off to one side and near the bottom, so nothing about the layout is
+ // mirror-symmetric in either axis.
+ var badge = new UiTextureElement
+ {
+ Name = "vk-icon",
+ Left = HeaderLeft + HeaderWidth + 16,
+ Top = HeaderTop + 40,
+ Width = 64,
+ Height = 64,
+ Texture = icon,
+ };
+
+ _host.Root.AddChild(header);
+ _host.Root.AddChild(badge);
+ }
+
+ private uint CreateTexture(IGpuDevice device, string name, byte[] rgba, int width, int height, bool nearest)
+ {
+ IGpuTexture texture = device.CreateTexture(new GpuTextureDescription(
+ name,
+ GpuTextureKind.Texture2D,
+ GpuTextureFormat.Rgba8Unorm,
+ width,
+ height,
+ LayerCount: 1,
+ MipLevelCount: 1));
+ _textures.Add(texture);
+ texture.Upload(0, 0, rgba);
+ IGpuSampler sampler = device.CreateSampler(nearest
+ ? GpuSamplerDescription.UiNearest
+ : GpuSamplerDescription.WorldRepeat);
+ return UiTextureTableHandle.FromSlot(device.RegisterTexture(texture, sampler));
+ }
+
+ /// A 16x16 tile with a lit top-left corner, so tiling and orientation both read.
+ private static byte[] BuildChromeTile()
+ {
+ const int extent = 16;
+ var pixels = new byte[extent * extent * 4];
+ for (int y = 0; y < extent; y++)
+ {
+ for (int x = 0; x < extent; x++)
+ {
+ bool edge = x == 0 || y == 0;
+ byte r = edge ? (byte)0xC8 : (byte)0x2A;
+ byte g = edge ? (byte)0xA0 : (byte)0x24;
+ byte b = edge ? (byte)0x40 : (byte)0x1C;
+ int offset = ((y * extent) + x) * 4;
+ pixels[offset + 0] = r;
+ pixels[offset + 1] = g;
+ pixels[offset + 2] = b;
+ pixels[offset + 3] = 0xF0;
+ }
+ }
+
+ return pixels;
+ }
+
+ /// A 32x32 badge: red at the top, blue at the bottom, opaque ring.
+ private static byte[] BuildIcon()
+ {
+ const int extent = 32;
+ var pixels = new byte[extent * extent * 4];
+ for (int y = 0; y < extent; y++)
+ {
+ for (int x = 0; x < extent; x++)
+ {
+ float dx = (x - 15.5f) / 15.5f;
+ float dy = (y - 15.5f) / 15.5f;
+ bool inside = (dx * dx) + (dy * dy) <= 1f;
+ int offset = ((y * extent) + x) * 4;
+ pixels[offset + 0] = (byte)(inside ? 255 - (y * 6) : 0);
+ pixels[offset + 1] = (byte)(inside ? 60 : 0);
+ pixels[offset + 2] = (byte)(inside ? y * 7 : 0);
+ pixels[offset + 3] = (byte)(inside ? 255 : 0);
+ }
+ }
+
+ return pixels;
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ return;
+ _disposed = true;
+ _lines.Dispose();
+ _host.Dispose();
+ _font?.Dispose();
+ for (int i = _textures.Count - 1; i >= 0; i--)
+ _textures[i].Dispose();
+ _textures.Clear();
+ }
+
+ ///
+ /// The host drives frames itself (it owns swapchain acquire and the
+ /// out-of-date policy), so it hands the open frame to the renderers rather
+ /// than the other way round. Same contract as
+ /// GpuDeviceFrameLifetime, without owning the begin/end.
+ ///
+ private sealed class MutableFrameSource : ICurrentGpuFrameSource
+ {
+ public IGpuFrame? CurrentFrame { get; set; }
+ }
+}