using AcDream.App.Diagnostics;
using AcDream.App.Platform;
using AcDream.App.Rendering;
using Silk.NET.Maths;
using Silk.NET.Windowing;
namespace AcDream.App.Rendering.Gpu.Vk;
///
/// The Vulkan capability-probe and bring-up harness. Reached only when
/// ACDREAM_RENDER_BACKEND=vulkan and ACDREAM_VULKAN_PROBE=1.
///
/// What it is for. Answering two questions without starting the
/// client: does this machine pass the Vulkan capability gate, and does the RHI
/// backend actually draw? It opens a window, acquires a
/// — which runs the gate and writes the
/// report — and presents the V6c/V6d verification scenes until the window
/// closes. The scenes are deliberately synthetic and deliberately asymmetric:
/// the one thing a symmetric layout could never prove is that the
/// negative-viewport Y flip and the capture path agree.
///
/// What it is no longer. Through slice V6g this was the whole
/// Vulkan path — a second main() beside GameWindow's composition.
/// Slice V6h moved production Vulkan onto the real composition host and lifted
/// the instance/surface/device/swapchain sequence out into
/// , which both now share. What remains here
/// is the harness role the class is named for.
///
/// Untested by the implementing slice. Every line below needs a
/// window and a driver. The pure decisions it depends on are unit-tested beside
/// it; the remaining gate is manual.
///
internal sealed class VulkanBringUpHost : IDisposable
{
/// Two frames in flight, matching plan §4.8 and the GL flight controller.
internal const int FlightCount = 2;
///
/// The bring-up clear colour, linear-encoded straight into a UNORM
/// swapchain. A deliberate deep blue: black would be indistinguishable from
/// an unpainted window, and magenta is reserved as the "unresolved texture
/// slot" sentinel everywhere else in this codebase.
///
internal static readonly float[] ClearColor = [0.043f, 0.075f, 0.153f, 1f];
private const string ScreenshotName = "vulkan-bringup";
private readonly RuntimeOptions _options;
private readonly GraphicalHostPlatformServices _platform;
private readonly FramePacingPolicy _pacing;
private readonly Action _log;
private IWindow? _window;
private VulkanGraphicsContext? _graphics;
private VulkanRhiScene? _scene;
private VulkanRetainedUiScene? _ui;
private ulong _frameSerial;
private bool _disposed;
internal VulkanBringUpHost(
RuntimeOptions options,
GraphicalHostPlatformServices platform,
bool requestedVSync,
Action? log = null)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_platform = platform ?? throw new ArgumentNullException(nameof(platform));
_log = log ?? Console.WriteLine;
// Resolved from the same pure policy the GL path uses, so "VSync on means
// FIFO" is one decision expressed once. The monitor refresh is left
// unknown because the harness consumes only UseVSync: there is no
// software pacer here to feed a limit to.
_pacing = FramePacingPolicy.Resolve(
requestedVSync,
_options.UncappedRendering,
monitorRefreshHz: null);
}
/// The record the gate evaluated, available after starts.
internal VulkanCapabilityRecord? Capabilities => _graphics?.Capabilities;
///
/// Open the window, pass the capability gate, and present the verification
/// scenes until the window closes. Throws
/// when the gate rejects the device, which Program.cs turns into exit
/// code 4 exactly as it does for the GL gate.
///
internal void Run()
{
ObjectDisposedException.ThrowIf(_disposed, this);
CreateWindow();
_graphics = VulkanGraphicsContext.Acquire(
_window!,
_options,
_platform,
_pacing,
// Four samples where the device allows it, so the backbuffer pass
// really resolves rather than rendering straight into the swapchain
// image. Plan §4.10 records that the V7 differential must force MSAA
// off; this is not that gate, and a resolve path that is never
// exercised is a resolve path that does not work.
requestedSampleCount: 4,
_log);
CreateScenes();
Present();
}
private void CreateWindow()
{
var options = WindowOptions.DefaultVulkan with
{
Size = new Vector2D(1280, 720),
Title = "acdream — Vulkan capability probe",
VSync = _pacing.UseVSync,
};
_window = Window.Create(options);
_window.Initialize();
}
private void CreateScenes()
{
VulkanGraphicsContext graphics = _graphics!;
_scene = new VulkanRhiScene(graphics.Device, graphics.SampleCount);
// Campaign V slice V6d: the retained UI and the debug lines draw here
// through exactly the classes the client uses, with a generated widget
// tree. Slice V6h's composition host draws the client's own tree; this
// keeps the isolated, session-free version reproducible.
_ui = new VulkanRetainedUiScene(
graphics.Device,
VulkanGraphicsContext.ShaderSpirvDirectory());
_log(
"vulkan: retained UI up — TextRenderer and DebugLineRenderer on the Vulkan device" +
(_ui.HasFont ? string.Empty : " (no system font found; glyph draws are skipped)"));
}
///
/// The frame loop: record the verification scenes through the RHI, present,
/// and capture one screenshot once the scene has settled.
///
/// Campaign V slice V9: when ACDREAM_VULKAN_PROBE_FRAMES is
/// positive the loop retires itself after that many presented frames instead
/// of waiting for the window to close. CI has no one to close it. The budget
/// never cuts the capture short — the loop stays open until the screenshot
/// has been attempted — because an unattended run whose whole product is a
/// PNG must not be able to exit without producing one.
///
private void Present()
{
IWindow window = _window!;
VulkanGraphicsContext graphics = _graphics!;
VulkanGpuDevice device = graphics.Device;
VulkanRhiScene scene = _scene!;
FrameScreenshotController? screenshots = CreateScreenshotController();
bool screenshotRequested = false;
DateTimeOffset started = DateTimeOffset.UtcNow;
while (!window.IsClosing)
{
window.DoEvents();
if (window.IsClosing)
break;
if (!graphics.PrepareFrame())
{
// Minimised: idle without burning a core, and without pretending
// a zero-area swapchain can be created.
Thread.Sleep(16);
continue;
}
if (!device.TryBeginFrame(out IGpuFrame? frame) || frame is null)
{
graphics.RequestRecreate();
continue;
}
double elapsed = (DateTimeOffset.UtcNow - started).TotalSeconds;
using (frame)
{
scene.Render(frame, graphics.Width, graphics.Height, elapsed);
// After the 3-D scene, in its own single-sampled load/store pass
// against the backbuffer — the same shape the client's HUD phase
// has, and the reason the multisampled pass must resolve rather
// than store.
_ui?.Render(frame, graphics.Width, graphics.Height, elapsed);
}
_frameSerial = (ulong)frame.Serial;
graphics.NoteFrameClosed();
// Capture after a few frames so the timer pool has resolved and the
// ring has cycled through both flight slots at least once.
if (screenshots is not null && !screenshotRequested && _frameSerial >= 4)
{
screenshotRequested = true;
if (screenshots.TryRequest(ScreenshotName, out string error))
{
screenshots.CapturePending((int)graphics.Width, (int)graphics.Height);
ReportTimings(device);
}
else
{
_log($"vulkan: screenshot request rejected: {error}");
}
}
if (ShouldRetire(screenshots, screenshotRequested))
{
_log(
$"vulkan: frame budget of {_options.VulkanCapabilityProbeFrames} " +
"reached; closing the probe.");
break;
}
}
device.WaitIdle();
_log($"vulkan: presented {_frameSerial} RHI frame(s); shutting down.");
}
private bool ShouldRetire(
FrameScreenshotController? screenshots,
bool screenshotRequested) =>
ShouldRetire(
_options.VulkanCapabilityProbeFrames,
_frameSerial,
screenshots is not null,
screenshotRequested);
///
/// Campaign V slice V9. The bounded-run decision, pure so it can be tested
/// without a window or a driver.
///
///
/// ACDREAM_VULKAN_PROBE_FRAMES. Zero or negative means the
/// interactive behaviour: run until the window closes.
///
/// Frames presented so far.
///
/// Whether an artifact directory was configured, and so whether this run owes
/// a PNG.
///
/// Whether that capture has been attempted.
internal static bool ShouldRetire(
int frameBudget,
ulong presentedFrames,
bool capturesScreenshot,
bool screenshotRequested)
{
if (frameBudget <= 0)
return false;
if (presentedFrames < (ulong)frameBudget)
return false;
// A budget below the capture threshold would otherwise exit with an empty
// artifact directory and a green step, which is the one outcome an
// unattended render gate must never produce.
return !capturesScreenshot || screenshotRequested;
}
private void ReportTimings(VulkanGpuDevice device)
{
if (!device.Timers.IsSupported)
{
_log("vulkan: GPU timestamps are unsupported on this device");
return;
}
string offscreen = device.Timers.TryResolve("offscreen", out double offscreenMs)
? $"{offscreenMs:F3} ms"
: "pending";
string main = device.Timers.TryResolve("main", out double mainMs)
? $"{mainMs:F3} ms"
: "pending";
_log($"vulkan: GPU timer scopes — offscreen {offscreen}, main {main}");
}
private FrameScreenshotController? CreateScreenshotController()
{
if (string.IsNullOrWhiteSpace(_options.AutomationArtifactDirectory))
return null;
return new FrameScreenshotController(
// IGpuDevice.CaptureBackbuffer is documented top-left-origin and a
// Vulkan image already is; FrameScreenshotController flips what it
// receives because glReadPixels hands back bottom-up rows. Flipping
// here makes the two cancel, so the PNG is right-side-up.
(width, height) => FrameScreenshotController.FlipRows(
_graphics!.Device.CaptureBackbuffer(width, height),
width,
height),
_options.AutomationArtifactDirectory,
_log);
}
///
/// Teardown in strict reverse-construction order. The scenes go before the
/// context: they own buffers, textures, render targets and pipelines whose
/// release routes through the device's retirement queue, so the device has to
/// still be alive to drain it.
///
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
_ui?.Dispose();
_ui = null;
_scene?.Dispose();
_scene = null;
_graphics?.Dispose();
_graphics = null;
_window?.Dispose();
_window = null;
}
}