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; } }