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; } + } +}