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>
149 lines
6.3 KiB
C#
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;
|
|
}
|
|
}
|