fix #387: window resize never recreated the Vulkan swapchain (stretch)

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 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-13 09:05:13 +02:00
parent 2fd99c4265
commit a7ae756b44
4 changed files with 147 additions and 22 deletions

View file

@ -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.");
/// <summary>
/// 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 <see cref="VulkanGraphicsContext.RequestRecreate"/> so the next
/// <c>PrepareFrame</c> 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 (<see cref="FramebufferResizeController.Resize(int,int)"/>
/// gates them), and the armed flag survives a minimised skip until a
/// non-zero frame runs, which is the context's own existing behavior.
/// </summary>
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();
}
/// <summary>

View file

@ -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
{