From a7ae756b44f16330c0ece178bbe70ac54fb8601b Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 13 Aug 2026 09:05:13 +0200 Subject: [PATCH] fix #387: window resize never recreated the Vulkan swapchain (stretch) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User report: resolution picks (and window drags) stretched the image instead of changing the pixel count. Root cause: Campaign V slice V11 deleted the GL viewport target and left a null target, assuming the driver's OUT_OF_DATE/SUBOPTIMAL acquire/present results would drive swapchain recreation on resize. That is driver-dependent and spec-insufficient — this machine's Windows AMD driver keeps presenting the stale-extent swapchain scaled to the new window indefinitely, so OnFramebufferResize only ever updated the camera aspect while every pass (UI included) kept rendering at the old extent. Fix: SwapchainRecreateViewportTarget implements the existing IFramebufferViewportTarget seam for Vulkan and arms VulkanGraphicsContext.RequestRecreate() on every resize event; the next PrepareFrame rebuilds the swapchain at the live FramebufferSize (bursts collapse to one recreation, stale events cannot install a stale extent, minimised sizes stay gated by FramebufferResizeController). Tests: SwapchainRecreateViewportTargetTests (target contract, size- agnostic arming, null hook, controller-to-target end-to-end with the minimised gate). Full Debug App suite 4,941/3 skips. Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 32 ++++++++ ...VulkanHostInputCameraCompositionFactory.cs | 44 +++++----- .../Rendering/FramebufferResizeController.cs | 13 ++- .../SwapchainRecreateViewportTargetTests.cs | 80 +++++++++++++++++++ 4 files changed, 147 insertions(+), 22 deletions(-) create mode 100644 tests/AcDream.App.Tests/Composition/SwapchainRecreateViewportTargetTests.cs diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 0f0bbb0c..4b15cd0b 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -24,6 +24,38 @@ What does NOT go here: - Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending. - Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed. +## #387 — Window resize never recreates the Vulkan swapchain: resolution picks stretch the image instead of changing the pixel count + +**Status:** DONE 2026-08-13 (this commit) — pending the user's re-check. +User report (verbatim shape): "It looks like it is doing now is just +stretching the window, not changing the pixel count when I change the +resolution." Confirmed real and root-caused the same session. + +**ROOT CAUSE — the resize event never reached the swapchain.** Campaign V +slice V11 deleted the GL `SilkFramebufferViewportTarget` and left +`NullFramebufferViewportTarget` on the assumption that the driver's +OUT_OF_DATE/SUBOPTIMAL acquire/present results would drive +`VulkanGraphicsContext`'s frame-boundary swapchain recreation whenever the +window resized. That assumption is driver-dependent and spec-insufficient: +a conformant driver may keep presenting the stale-extent swapchain scaled +to the new window indefinitely — which is exactly what this machine's +Windows AMD driver does. Result: `OnFramebufferResize` updated only the +camera aspect; the swapchain (and every render pass sized from its extent, +UI included) stayed at the old pixel count and the presentation engine +stretched it — for Options resolution picks AND manual window-edge drags +alike. **Fix:** `SwapchainRecreateViewportTarget` (the Vulkan +implementation of the existing `IFramebufferViewportTarget` seam) arms +`VulkanGraphicsContext.RequestRecreate()` on every resize event; the next +`PrepareFrame` rebuilds the swapchain at the live `FramebufferSize`, so +event bursts collapse into one recreation and stale events cannot install +a stale extent. Regressed by +`tests/AcDream.App.Tests/Composition/SwapchainRecreateViewportTargetTests.cs` +(target contract + the controller→target end-to-end seam with the +minimised-gate case). **Re-check:** pick a smaller Resolution — the window +should shrink AND the image should re-render crisp at the new pixel count +(UI elements occupy proportionally more of the window, retail-style), not +scale down blurrily; same for a window-edge drag. + ## #386 — Vendor category dropdown: authored ListBox is edge-docked — retail would size the popup to content, our shipped 6-row window may diverge **Status:** OPEN — filed 2026-08-13 while fixing #385. The #385 probe diff --git a/src/AcDream.App/Composition/VulkanHostInputCameraCompositionFactory.cs b/src/AcDream.App/Composition/VulkanHostInputCameraCompositionFactory.cs index 6a19956d..a96508e8 100644 --- a/src/AcDream.App/Composition/VulkanHostInputCameraCompositionFactory.cs +++ b/src/AcDream.App/Composition/VulkanHostInputCameraCompositionFactory.cs @@ -26,11 +26,17 @@ internal sealed class VulkanHostInputCameraCompositionFactory { public IFramebufferViewportTarget CreateViewportTarget( GameWindowGraphics graphics) => - // The viewport is a pipeline dynamic state on Vulkan, set per pass by - // the encoder from the pass extent, so there is no persistent viewport - // to bind here. The framebuffer-resize controller still drives the - // camera aspect through its own target. - NullFramebufferViewportTarget.Instance; + // The viewport itself is a pipeline dynamic state on Vulkan, set per + // pass by the encoder from the pass extent — but the swapchain still + // has to be told the framebuffer changed. Relying on the driver's + // OUT_OF_DATE/SUBOPTIMAL results alone is spec-insufficient: a + // conformant driver (observed: Windows AMD, 2026-08-13 user gate — + // "just stretching the window, not changing the pixel count") may keep + // presenting the stale-extent swapchain scaled to the new window + // forever. The V11 null target assumed the driver signal; this target + // arms the context's existing frame-boundary recreation instead, so + // the next PrepareFrame recreates at the real FramebufferSize. + new SwapchainRecreateViewportTarget(RequireContext(graphics).RequestRecreate); public GpuFrameFlightController? CreateGpuFrameFlights( GameWindowGraphics graphics) => null; @@ -110,22 +116,24 @@ internal sealed class VulkanHostInputCameraCompositionFactory "Vulkan context."); /// - /// Vulkan sets the viewport per pass from the pass extent, so there is no - /// persistent viewport binding for the resize controller to update. Size is - /// still recorded, because the swapchain recreation the host performs is - /// what actually resizes the surface. + /// The Vulkan implementation of the resize seam (#387): a window resize + /// arms so the next + /// PrepareFrame rebuilds the swapchain at the current + /// FramebufferSize. The width/height arguments are deliberately unused — + /// the context re-reads the live framebuffer size at the frame boundary, + /// so a burst of resize events collapses into one recreation and a stale + /// event can never install a stale extent. Zero-area (minimised) sizes + /// never reach this target ( + /// gates them), and the armed flag survives a minimised skip until a + /// non-zero frame runs, which is the context's own existing behavior. /// - private sealed class NullFramebufferViewportTarget : IFramebufferViewportTarget + internal sealed class SwapchainRecreateViewportTarget(Action requestRecreate) + : IFramebufferViewportTarget { - public static NullFramebufferViewportTarget Instance { get; } = new(); + private readonly Action _requestRecreate = requestRecreate + ?? throw new ArgumentNullException(nameof(requestRecreate)); - private NullFramebufferViewportTarget() - { - } - - public void ResizeViewport(int width, int height) - { - } + public void ResizeViewport(int width, int height) => _requestRecreate(); } /// diff --git a/src/AcDream.App/Rendering/FramebufferResizeController.cs b/src/AcDream.App/Rendering/FramebufferResizeController.cs index 868370f3..9b962f79 100644 --- a/src/AcDream.App/Rendering/FramebufferResizeController.cs +++ b/src/AcDream.App/Rendering/FramebufferResizeController.cs @@ -9,10 +9,15 @@ internal interface IFramebufferViewportTarget } // Campaign V slice V11 deleted SilkFramebufferViewportTarget, the GL -// implementation of IFramebufferViewportTarget: VulkanHostInputCameraComposition -// Factory's NullFramebufferViewportTarget is the sole surviving implementation -// (Vulkan's swapchain recreation owns the actual viewport-equivalent resize, -// which this seam never drove). +// implementation of IFramebufferViewportTarget, and left a null target on the +// assumption that the driver's OUT_OF_DATE/SUBOPTIMAL results would drive +// swapchain recreation on resize. That assumption is driver-dependent — a +// conformant driver may present a stale-extent swapchain scaled to the new +// window indefinitely (observed on Windows AMD; #387, the 2026-08-13 +// "resolution pick just stretches" user gate report). The sole implementation +// is now VulkanHostInputCameraCompositionFactory.SwapchainRecreateViewportTarget, +// which arms the context's frame-boundary swapchain recreation on every +// resize event. internal interface IFramebufferCameraTarget { diff --git a/tests/AcDream.App.Tests/Composition/SwapchainRecreateViewportTargetTests.cs b/tests/AcDream.App.Tests/Composition/SwapchainRecreateViewportTargetTests.cs new file mode 100644 index 00000000..2e1c205a --- /dev/null +++ b/tests/AcDream.App.Tests/Composition/SwapchainRecreateViewportTargetTests.cs @@ -0,0 +1,80 @@ +using AcDream.App.Composition; +using AcDream.App.Input; +using AcDream.App.Rendering; + +namespace AcDream.App.Tests.Composition; + +/// +/// #387: the V11 null viewport target assumed the driver's OUT_OF_DATE/ +/// SUBOPTIMAL results would drive swapchain recreation on window resize; on +/// Windows AMD the driver instead presents the stale-extent swapchain scaled +/// to the new window (the "resolution pick just stretches" 2026-08-13 user +/// gate report). These pin the replacement target's contract: every resize +/// event reaching the seam arms the context's frame-boundary recreation. +/// +public sealed class SwapchainRecreateViewportTargetTests +{ + [Fact] + public void ResizeViewport_ArmsRecreation_OncePerEvent() + { + int armed = 0; + IFramebufferViewportTarget target = + new VulkanHostInputCameraCompositionFactory.SwapchainRecreateViewportTarget( + () => armed++); + + target.ResizeViewport(1024, 768); + Assert.Equal(1, armed); + + // A second resize arms again — the context collapses bursts itself at + // the frame boundary (it re-reads the live FramebufferSize), so the + // target must never de-duplicate or latch. + target.ResizeViewport(800, 600); + Assert.Equal(2, armed); + } + + [Fact] + public void ResizeViewport_IgnoresTheEventSize_ContextReadsLiveFramebuffer() + { + // The width/height are deliberately unused (doc on the target): the + // context reads the CURRENT framebuffer size at the frame boundary, + // so a stale event cannot install a stale extent. Pin that the arm + // fires regardless of the reported size values. + int armed = 0; + IFramebufferViewportTarget target = + new VulkanHostInputCameraCompositionFactory.SwapchainRecreateViewportTarget( + () => armed++); + + target.ResizeViewport(int.MaxValue, 1); + Assert.Equal(1, armed); + } + + [Fact] + public void Constructor_RejectsNullRecreateHook() + { + Assert.Throws(() => + new VulkanHostInputCameraCompositionFactory.SwapchainRecreateViewportTarget(null!)); + } + + [Fact] + public void ResizeController_DeliversResizeEventsToTheBoundTarget() + { + // The end-to-end seam FramebufferResizeController.Resize → + // ResizeViewport → arm: the same event flow GameWindow's + // OnFramebufferResize drives in production. Zero-area (minimised) + // events are gated by the controller and must NOT arm. + int armed = 0; + var controller = new FramebufferResizeController(new ViewportAspectState()); + controller.BindViewport( + new VulkanHostInputCameraCompositionFactory.SwapchainRecreateViewportTarget( + () => armed++)); + + controller.Resize(1280, 720); + Assert.Equal(1, armed); + + controller.Resize(0, 0); // minimised — gated, no arm + Assert.Equal(1, armed); + + controller.Resize(1024, 768); + Assert.Equal(2, armed); + } +}