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:
parent
2fd99c4265
commit
a7ae756b44
4 changed files with 147 additions and 22 deletions
|
|
@ -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.
|
- 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.
|
- 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
|
## #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
|
**Status:** OPEN — filed 2026-08-13 while fixing #385. The #385 probe
|
||||||
|
|
|
||||||
|
|
@ -26,11 +26,17 @@ internal sealed class VulkanHostInputCameraCompositionFactory
|
||||||
{
|
{
|
||||||
public IFramebufferViewportTarget CreateViewportTarget(
|
public IFramebufferViewportTarget CreateViewportTarget(
|
||||||
GameWindowGraphics graphics) =>
|
GameWindowGraphics graphics) =>
|
||||||
// The viewport is a pipeline dynamic state on Vulkan, set per pass by
|
// The viewport itself is a pipeline dynamic state on Vulkan, set per
|
||||||
// the encoder from the pass extent, so there is no persistent viewport
|
// pass by the encoder from the pass extent — but the swapchain still
|
||||||
// to bind here. The framebuffer-resize controller still drives the
|
// has to be told the framebuffer changed. Relying on the driver's
|
||||||
// camera aspect through its own target.
|
// OUT_OF_DATE/SUBOPTIMAL results alone is spec-insufficient: a
|
||||||
NullFramebufferViewportTarget.Instance;
|
// 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(
|
public GpuFrameFlightController? CreateGpuFrameFlights(
|
||||||
GameWindowGraphics graphics) => null;
|
GameWindowGraphics graphics) => null;
|
||||||
|
|
@ -110,22 +116,24 @@ internal sealed class VulkanHostInputCameraCompositionFactory
|
||||||
"Vulkan context.");
|
"Vulkan context.");
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Vulkan sets the viewport per pass from the pass extent, so there is no
|
/// The Vulkan implementation of the resize seam (#387): a window resize
|
||||||
/// persistent viewport binding for the resize controller to update. Size is
|
/// arms <see cref="VulkanGraphicsContext.RequestRecreate"/> so the next
|
||||||
/// still recorded, because the swapchain recreation the host performs is
|
/// <c>PrepareFrame</c> rebuilds the swapchain at the current
|
||||||
/// what actually resizes the surface.
|
/// 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>
|
/// </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) => _requestRecreate();
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public void ResizeViewport(int width, int height)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,15 @@ internal interface IFramebufferViewportTarget
|
||||||
}
|
}
|
||||||
|
|
||||||
// Campaign V slice V11 deleted SilkFramebufferViewportTarget, the GL
|
// Campaign V slice V11 deleted SilkFramebufferViewportTarget, the GL
|
||||||
// implementation of IFramebufferViewportTarget: VulkanHostInputCameraComposition
|
// implementation of IFramebufferViewportTarget, and left a null target on the
|
||||||
// Factory's NullFramebufferViewportTarget is the sole surviving implementation
|
// assumption that the driver's OUT_OF_DATE/SUBOPTIMAL results would drive
|
||||||
// (Vulkan's swapchain recreation owns the actual viewport-equivalent resize,
|
// swapchain recreation on resize. That assumption is driver-dependent — a
|
||||||
// which this seam never drove).
|
// 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
|
internal interface IFramebufferCameraTarget
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
using AcDream.App.Composition;
|
||||||
|
using AcDream.App.Input;
|
||||||
|
using AcDream.App.Rendering;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.Composition;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #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.
|
||||||
|
/// </summary>
|
||||||
|
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<ArgumentNullException>(() =>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue