acdream/src/AcDream.App/Composition/VulkanHostInputCameraCompositionFactory.cs
Erik a7ae756b44 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>
2026-08-13 09:05:13 +02:00

149 lines
6.3 KiB
C#

using AcDream.App.Input;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Gpu.Vk;
using AcDream.UI.Abstractions.Input;
using Silk.NET.Input;
namespace AcDream.App.Composition;
/// <summary>
/// Campaign V slice V6h: the Vulkan arm of the host phase — now the only arm,
/// the raw-GL <c>RetailHostInputCameraCompositionFactory</c> it used to fork
/// from having been deleted at slice V11. Input, camera and pointer
/// construction are inlined directly rather than delegated, since there is no
/// longer a second implementation to share them with.
///
/// <para><b>What is absent, and why.</b> There is no GL fence ring: the RHI
/// device owns its own frames-in-flight through a timeline semaphore, so
/// retirement and slot indexing come from the device instead. There is no
/// <see cref="WorldRenderDiagnostics"/>: it was a raw-GL state tripwire, and
/// Vulkan has no global state for it to watch. Both nulls are read by the
/// phases that would otherwise consume them.</para>
/// </summary>
internal sealed class VulkanHostInputCameraCompositionFactory
: IHostInputCameraCompositionFactory
{
public IFramebufferViewportTarget CreateViewportTarget(
GameWindowGraphics graphics) =>
// 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;
public IGpuDevice CreateGpuDevice(
GameWindowGraphics graphics,
GpuFrameFlightController? frameFlights) =>
RequireContext(graphics).Device;
public IGpuResourceRetirementQueue CreateRetirement(
GameWindowGraphics graphics,
GpuFrameFlightController? frameFlights,
IGpuDevice device) => device.Retirement;
public IRenderFrameSlotSource CreateFrameSlots(
GameWindowGraphics graphics,
GpuFrameFlightController? frameFlights,
IGpuDevice device) =>
new VulkanRenderFrameSlotSource(RequireContext(graphics).Device);
public WorldRenderDiagnostics? CreateWorldRenderDiagnostics(
GameWindowGraphics graphics,
IRenderFrameDiagnosticLog log) => null;
public SilkKeyboardSource CreateKeyboardSource(
IKeyboard keyboard,
HostQuiescenceGate quiescence) =>
SilkKeyboardSource.CreateDetached(keyboard, quiescence);
public SilkMouseSource CreateMouseSource(
IMouse mouse,
IInputCaptureSource capture,
IKeyboardSource? keyboard,
HostQuiescenceGate quiescence) =>
SilkMouseSource.CreateDetached(mouse, capture, keyboard, quiescence);
public IMouseLookCursor CreateMouseLookCursor(IMouse mouse) =>
new SilkMouseLookCursor(mouse);
public InputDispatcher CreateInputDispatcher(
IKeyboardSource keyboard,
IMouseSource mouse,
KeyBindings bindings) =>
InputDispatcher.CreateDetached(keyboard, mouse, bindings);
public CameraController CreateCameraController() =>
new(new OrbitCamera(), new FlyCamera());
public IFramebufferCameraTarget CreateCameraTarget(CameraController camera) =>
new CameraFramebufferTarget(camera);
public CameraPointerInputController CreateCameraPointerInput(
IReadOnlyList<IMouse> mice,
HostQuiescenceGate quiescence,
IInputCaptureSource capture,
LocalPlayerModeState playerMode,
CameraController camera,
ChaseCameraInputState chase,
IMouseSource mouse,
PointerPositionState pointer) =>
CameraPointerInputController.Create(
mice,
quiescence,
capture,
playerMode,
camera,
chase,
mouse,
pointer,
new EnvironmentInputMonotonicClock());
private static VulkanGraphicsContext RequireContext(
GameWindowGraphics graphics) =>
graphics.Vulkan
?? throw new InvalidOperationException(
"The Vulkan host factory was composed against a backend with no " +
"Vulkan context.");
/// <summary>
/// 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>
internal sealed class SwapchainRecreateViewportTarget(Action requestRecreate)
: IFramebufferViewportTarget
{
private readonly Action _requestRecreate = requestRecreate
?? throw new ArgumentNullException(nameof(requestRecreate));
public void ResizeViewport(int width, int height) => _requestRecreate();
}
/// <summary>
/// The flight slot per-frame buffers index by. Identical in role to the GL
/// ring's <see cref="GpuFrameFlightController.CurrentSlot"/>; the count comes
/// from the device's timeline flight controller rather than a fence array.
/// </summary>
private sealed class VulkanRenderFrameSlotSource(VulkanGpuDevice device)
: IRenderFrameSlotSource
{
public int CurrentSlot => device.Flights.CurrentSlot;
}
}