diff --git a/docs/plans/2026-07-27-vulkan-campaign.md b/docs/plans/2026-07-27-vulkan-campaign.md index 3259f0c3..24a17245 100644 --- a/docs/plans/2026-07-27-vulkan-campaign.md +++ b/docs/plans/2026-07-27-vulkan-campaign.md @@ -573,6 +573,7 @@ dialect slice. **V6f closed all three** (the third was the frag's GL-only tenth pair with no consumer at all; see the V6e report. | **V6g** | The four Vulkan validation defects §5.5.7 and its log left open: the dynamic-descriptor split (an architect decision, §5.5.8 item 1), per-pass depth-format pipeline variants, first-use backbuffer attachment layout transitions, and a backbuffer capture that no longer reads a presented swapchain image. Confined to `Gpu/Vk/`; the GL backend executes not one changed statement. | validation-clean bring-up run (0 errors / 0 warnings over 39,855 frames, against 7 VUIDs + 1 UNASSIGNED at the parent), App tests, GL offline pixel gate 4.08e-05 — its own same-commit control value | +| **V6h** ✅ | **The Vulkan composition host**, specified by §5.5.9 and reported in §5.5.10. `ACDREAM_RENDER_BACKEND=vulkan` runs the real `GameWindow` composition — DAT load, streaming, camera, entity table, session, and the real retained `UiHost` through the RHI — with no world renderers. Three seams: the already-generic platform acquisition now publishes a `GameWindowGraphics`; `VulkanHostInputCameraCompositionFactory` is the host-phase fork; the frame root gains a Vulkan arm. `VulkanBringUpHost` is reduced to the capability-probe harness over the extracted `VulkanGraphicsContext`. | offline Vulkan launch reaching the real composition with the client's own UI captured, one validation-layer run at 0 errors / 0 warnings, converging ownership ledger, App tests 4,075/3, complete Release suite 9,138/5, GL offline pixel gate 1.24e-05 | | **V7** | GL-versus-Vulkan differential: `tools/run-backend-differential-gate.ps1`, strict paired-PNG compare, divergences fixed in the Vulkan backend only, then lifecycle + R6 soak natively on Vulkan, one validation-layer-clean run, one RenderDoc capture. **Milestone: parity.** | every differential checkpoint passes; both connected routes green on VK | | **V8** | Perf gate on the RX 9070 XT, uncapped, both backends, same route. | §2 acceptance table; parity is the floor | | **V9** | Linux + CI: X11/Wayland surfaces; a `linux-vulkan` job on lavapipe (probe accepts on a real 1.3 software device, a short real render under xvfb, forced-unsupported → exit 4, `.spv` freshness). Physical Linux GPU row deferred post-cutover, as for Slice L. | CI green including the new job | @@ -1285,6 +1286,103 @@ Step 2's own acceptance criterion — "the real UI renders" — is what makes it doing before V4t rather than after: it is the first frame acdream draws on Vulkan that is the *client's* frame rather than a scene written to prove the backend. +#### 5.5.10 V6h (2026-07-28): the Vulkan composition host is built + +§5.5.9's step 2 is done. `ACDREAM_RENDER_BACKEND=vulkan` now runs the real +composition: `GameWindow.Run` opens a `WindowOptions.DefaultVulkan` window, +platform acquisition publishes a Vulkan graphics handle, and every one of the +nine composition phases executes. The offline log is the client's own — +`prepared assets: opened acdream.pak`, `spells: loaded 6266 entries`, +`sky: loaded Region 0x13000000`, `loading world view centered on 0xA9B4FFFF`, +the fourteen retail LayoutDesc lines, `streaming: nearRadius=4 farRadius=12` — +and the captured frame is the retail retained UI: vitals window, combat/spell bar +with DAT scarab icons, the nine-slot toolbar with backpack and dove chrome, the +chat window with its tabs and Send button, and the radar/compass with dat-font +N/E/S/W glyphs. Sampled against the GL capture the widgets agree — +chat interior RGBA (25,24,27,158) versus (22,21,23,158), vitals bar (117,1,0) and +toolbar slot (0,11,17) identical. The background is the atmosphere fog colour +because there is no world behind it, which is the slice's declared scope. + +**The estimate held.** §5.5.9 predicted ~1,200–2,000 lines across ~20 files +including `GameWindow.cs`; the commit is 22 modified and 5 new files. + +**What the three seams became.** + +1. **Platform acquisition** publishes `GameWindowGraphics` — an abstract handle + with an `OpenGl` and a `Vulkan` subclass — instead of a bare `GL`. Every phase + that still speaks raw GL asks `Graphics.Gl` and takes its Vulkan arm when the + answer is null; each such branch names the slice that will remove it. The + dependency records' `ReferenceEquals` consistency checks are unchanged in + kind, only in type. +2. **`VulkanHostInputCameraCompositionFactory`** is a new file and the whole of + the Phase-1 fork. Four members differ — viewport target, frame flights, GPU + device, GL state tripwire; input, camera and pointer construction delegate to + the retail factory because they are platform concerns, not graphics ones. The + default factory is now chosen inside the phase from the platform result rather + than at the call site. `HostInputCameraResult` gained backend-neutral + `Retirement` and `FrameSlots` views: on GL both are the fence ring, on Vulkan + the device's timeline-backed queue and flight controller. +3. **The frame root** forks on one condition. The GL world-scene assembly is + unchanged and merely wrapped in `if (gl is not null)`; the Vulkan arm's render + graph is `VulkanRenderFrameClearPhase` (one backbuffer clear pass computing + the same `RenderFrameFoundation` from the same clock and weather owners) plus + the private-presentation phase that composites the retained UI. + +**§5.5.9's three `TextureCache` couplings are unpicked.** The constructor takes +`GL?` and rejects a bindless argument without one; world entry points route +through a `Gl` property that throws naming slice V4t; and the +`(GlGpuTexture)texture` VRAM-accounting cast became a backend test, with a +descending synthetic counter supplying the dictionary key off GL. Worth +recording: that cast's stated reason — `TextRenderer.DrawSprite`'s texture-unit +binding — was already stale, deleted at V6d. Nothing draws with the value. + +**One latent Vulkan defect was exposed and fixed, and it was ours.** The first +composition-host frame died with `ErrorDeviceLost` at `vkQueueSubmit2`, and +validation named it: `VUID-vkCmdDraw-None-08600`, "the VkPipeline statically uses +descriptor set 2, but because a descriptor was never bound, the VkPipelineLayouts +are not compatible." `VulkanGpuPassEncoder` bound sets 0/1/2 only as a *side +effect* of `BindStorageBuffer`/`BindUniformBuffer`, so a pass whose pipeline +samples the texture table but binds no buffer — every retained-UI and debug-line +pass, whose per-draw data travels in push constants and a vertex buffer — drew +with the table unbound. It survived V6c–V6g because the bring-up host always drew +`VulkanRhiScene` first and its storage binds left all three sets bound in the same +command buffer; the UI pass inherited them. The composition host has no 3-D +scene, so its UI pass is first and inherits nothing. **The fix is one line in the +encoder's constructor, beside the viewport and scissor defaults that exist for +exactly the same reason**: a pass must open with complete binding state rather +than depend on what preceded it in the frame. This is the third instance of the +project's "latent bug masked by a wider path" class, and the first on Vulkan. + +**What the Vulkan arm deliberately does not have**, each with the slice that +brings it: the world renderers and the terrain blending tables (V4t, then the +world arm — streaming still runs and builds real heightfields and collision, but +publishes into no GPU state and every surface resolves to `SurfaceInfo.None`); +DevTools, because ImGui is not ported and V11 deletes it; the GPU-timer bracket, +because `FrameProfiler`'s query ring is GL-only and `IGpuDevice.Timers` replaces +it at V4h; and the portal tunnel, whose renderer is raw GL — the teleport owner +drives a presentation reporting "no tunnel showing" while reveal generation, +destination latch, placement and session run unchanged. + +**Gate results.** The strict GL offline pixel gate against `46d893f7` measured +**1.24e-05** (7 differing pixels of 563,200) — inside the documented 15–23 px / +≤4.1e-05 band, so GL behaviour did not move. App tests 4,075 / 3 skips; complete +Release suite 9,138 / 5 skips. One full Vulkan run with +`VK_LAYER_KHRONOS_validation`: **zero errors, zero warnings**. Both Vulkan runs +shut down with the ownership ledger converged — no `[shutdown]` diagnostic on +either stream, which is `GameWindowLifetimeStatus.Complete`. The reduced probe +harness (`ACDREAM_VULKAN_PROBE=1`) presented 34,811 validation-clean frames and +resolved its GPU timer scopes. + +**Next is V4t**, the texture stack, which the world arm cannot be written +without. §5.5.9 sized it: 69 references across 9 source and 4 test files for the +`ulong` bindless handle alone, on top of `TextureCache`, +`CompositeTextureArrayCache`, `ManagedGLTextureArray`, `TerrainAtlas` and +`ObjectMeshManager`'s material path. Two things this slice learned should go into +it: the composition host is now a real consumer, so V4t's Vulkan side can be +exercised the moment it exists; and §5.5.8's recorded one-binding-two-buffers +hazard is still unfired, because nothing on the Vulkan arm yet binds the same +storage binding to two buffers in one frame. The world arm will. + ### 5.4 The null-target `BeginPass` divergence (V4c) — must be undone at V6 V4c had to stop GL's `BeginPass` from binding framebuffer 0 when a pass declares diff --git a/src/AcDream.App/Composition/CompositionAcquisitionScope.cs b/src/AcDream.App/Composition/CompositionAcquisitionScope.cs index 09e4b85b..9c8cd90b 100644 --- a/src/AcDream.App/Composition/CompositionAcquisitionScope.cs +++ b/src/AcDream.App/Composition/CompositionAcquisitionScope.cs @@ -49,6 +49,30 @@ internal sealed class CompositionAcquisitionScope : IRetryableResourceCleanup return Own(name, resource, release); } + /// + /// Campaign V slice V6h: acquires a resource a backend may legitimately not + /// have. A null factory result is an absent owner, not a failure — the + /// publication still runs so the long-lived shell records the same slot on + /// both backends — and nothing enters the rollback ledger. + /// + public CompositionAcquisitionOptionalLease AcquireOptional( + string name, + Func factory, + Action release) + where T : class + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(factory); + ArgumentNullException.ThrowIfNull(release); + EnsureAcceptingOwnership(); + + T? resource = factory(); + return resource is null + ? new CompositionAcquisitionOptionalLease(null) + : new CompositionAcquisitionOptionalLease( + Own(name, resource, release)); + } + public CompositionAcquisitionLease Own( string name, T resource, @@ -216,6 +240,29 @@ internal sealed class CompositionAcquisitionScope : IRetryableResourceCleanup return Transfer(); } } + + /// A lease over a resource the active backend may not own at all. + internal sealed class CompositionAcquisitionOptionalLease( + CompositionAcquisitionLease? inner) + where T : class + { + public T? Resource => inner?.Resource; + + public T? Transfer() => inner?.Transfer(); + + public T? Publish(Action publish) + { + ArgumentNullException.ThrowIfNull(publish); + if (inner is null) + { + publish(null); + return null; + } + + publish(inner.Resource); + return inner.Transfer(); + } + } } /// diff --git a/src/AcDream.App/Composition/ContentEffectsAudioComposition.cs b/src/AcDream.App/Composition/ContentEffectsAudioComposition.cs index bfeb2859..9265c1f2 100644 --- a/src/AcDream.App/Composition/ContentEffectsAudioComposition.cs +++ b/src/AcDream.App/Composition/ContentEffectsAudioComposition.cs @@ -280,7 +280,7 @@ internal enum ContentEffectsAudioCompositionPoint /// internal sealed class ContentEffectsAudioCompositionPhase : IContentEffectsAudioCompositionPhase< - GameWindowPlatformResult, + GameWindowPlatformResult, HostInputCameraResult, ContentEffectsAudioResult> { @@ -304,7 +304,7 @@ internal sealed class ContentEffectsAudioCompositionPhase : } public ContentEffectsAudioResult Compose( - GameWindowPlatformResult platform, + GameWindowPlatformResult platform, HostInputCameraResult host) { ArgumentNullException.ThrowIfNull(platform); diff --git a/src/AcDream.App/Composition/FrameRootComposition.cs b/src/AcDream.App/Composition/FrameRootComposition.cs index 60b96b4e..3918a3ec 100644 --- a/src/AcDream.App/Composition/FrameRootComposition.cs +++ b/src/AcDream.App/Composition/FrameRootComposition.cs @@ -23,7 +23,7 @@ namespace AcDream.App.Composition; internal sealed record FrameRootDependencies( RuntimeOptions Options, GameRuntime Runtime, - GL Gl, + GameWindowGraphics Graphics, IWindow Window, IInputContext Input, WorldTimeService WorldTime, @@ -137,7 +137,7 @@ internal sealed class FrameRootRuntimeBindings : IDisposable internal sealed class FrameRootCompositionPhase : IFrameRootCompositionPhase< - GameWindowPlatformResult, + GameWindowPlatformResult, HostInputCameraResult, ContentEffectsAudioResult, SettingsDevToolsResult, @@ -164,7 +164,7 @@ internal sealed class FrameRootCompositionPhase } public FrameRootResult Compose( - GameWindowPlatformResult platform, + GameWindowPlatformResult platform, HostInputCameraResult host, ContentEffectsAudioResult content, SettingsDevToolsResult settings, @@ -181,7 +181,7 @@ internal sealed class FrameRootCompositionPhase ArgumentNullException.ThrowIfNull(interaction); ArgumentNullException.ThrowIfNull(live); ArgumentNullException.ThrowIfNull(session); - if (!ReferenceEquals(_dependencies.Gl, platform.Graphics) + if (!ReferenceEquals(_dependencies.Graphics, platform.Graphics) || !ReferenceEquals(_dependencies.Input, platform.Input)) { throw new InvalidOperationException( @@ -236,13 +236,20 @@ internal sealed class FrameRootCompositionPhase { FrameRootDependencies d = _dependencies; WorldRenderFoundation foundation = world.Foundation; + // Campaign V slice V6h: the frame root is the one seam that genuinely + // forks. On GL every world renderer exists and the render graph is + // unchanged. On Vulkan none of them does, so the graph is the clear pass, + // the private-presentation phase, and the retained UI inside it — the + // client's own frame, drawn entirely through the RHI. + GL? gl = d.Graphics.Gl; var teleportRenderState = new LocalPlayerTeleportRenderStateSource(session.LocalTeleport); var renderLoginState = new RenderLoginStateSource( d.Options.LiveMode, d.PlayerMode); - var renderFrameGlState = new RenderFrameGlStateController( - new SilkRenderFrameGlStateApi(d.Gl)); + RenderFrameGlStateController? renderFrameGlState = gl is null + ? null + : new RenderFrameGlStateController(new SilkRenderFrameGlStateApi(gl)); var renderFrameLivePreparation = new RuntimeRenderFrameLivePreparation( foundation.TextureCache, @@ -256,8 +263,26 @@ internal sealed class FrameRootCompositionPhase live.ParticleRenderer, d.FrameProfiler, d.FrameDiagnosticsEnabled); + IRenderFrameClearPhase clearPhase = gl is not null + ? new RuntimeRenderFrameClearPhase( + gl, + d.WorldTime, + d.Weather, + teleportRenderState, + d.ParticleVisibility, + host.WorldRenderDiagnostics + ?? throw new InvalidOperationException( + "The GL frame root requires the GL state tripwire."), + renderFrameGlState!) + : new AcDream.App.Rendering.Gpu.Vk.VulkanRenderFrameClearPhase( + host.GpuFrameLifetime, + d.WorldTime, + d.Weather, + teleportRenderState, + d.ParticleVisibility, + () => d.Graphics.Vulkan?.SampleCount ?? 1); var renderFrameResources = new RenderFrameResourceController( - host.GpuFrameFlights, + host.FrameSlots, new RuntimeRenderFrameBeginResources( foundation.TextureCache, live.DrawDispatcher, @@ -266,14 +291,7 @@ internal sealed class FrameRootCompositionPhase live.ClipFrame, foundation.Terrain, foundation.SceneLighting), - new RuntimeRenderFrameClearPhase( - d.Gl, - d.WorldTime, - d.Weather, - teleportRenderState, - d.ParticleVisibility, - host.WorldRenderDiagnostics, - renderFrameGlState), + clearPhase, renderFrameLivePreparation); Fault(FrameRootCompositionPoint.RenderResourcesCreated); @@ -285,98 +303,8 @@ internal sealed class FrameRootCompositionPhase content.ParticleSink, d.EffectPoses, live.EntityEffects); - var worldFrameEnvironment = - new RuntimeWorldFrameEnvironmentPreparation( - d.Options, - d.WorldTime, - d.Lighting, - live.DrawDispatcher, - live.EnvCellRenderer, - foundation.SceneLighting, - d.RenderRange, - skyPesFrame); - var worldRenderFrameBuilder = new WorldRenderFrameBuilder( - new RuntimeWorldFrameCameraSource( - host.CameraController, - session.LocalTeleport), - new RuntimeWorldFrameVisibilityPreparation( - live.SelectionScene, - d.ParticleVisibility, - foundation.Terrain, - session.WorldReveal, - live.EnvCellFrustum), - new RuntimeWorldFrameSettingsPreview( - d.Settings, - content.Audio?.Engine, - host.CameraController, - d.DisplayFramePacing), - new RuntimeWorldFrameRootSource( - d.PhysicsEngine, - d.CellVisibility, - d.PlayerMode, - d.ChaseCameraInput, - d.PlayerController, - d.WorldOrigin), - worldFrameEnvironment, - new RuntimeWorldFrameAnimatedEntitySource( - d.Animations, - live.StaticAnimationScheduler, - live.EquippedChildren), - new RuntimeWorldFrameBuildingSource( - live.LandblockPipeline, - d.CellVisibility)); - var terrainDrawDiagnostics = new TerrainDrawDiagnosticsController( - d.FrameDiagnosticsEnabled, - host.WorldRenderDiagnostics, - new RuntimeFramePipelineDiagnosticFactsSource( - foundation.Terrain, - live.LandblockPipeline, - renderFrameLivePreparation, - live.DrawDispatcher, - session.Streaming, - live.LiveEntities, - live.WorldState), - d.RenderDiagnosticLog); - var retailPViewCells = new RetailPViewCellSource(d.CellVisibility); - var retailPViewPassExecutor = new RetailPViewPassExecutor( - d.Gl, - renderFrameGlState, - new SilkRetailPViewFramebufferSource(d.Window), - live.ClipFrame, - foundation.Terrain, - live.EnvCellRenderer, - live.DrawDispatcher, - live.SkyRenderer, - content.ParticleSystem, - live.ParticleRenderer, - live.PortalDepthMask, - d.RetailAlphaQueue, - host.WorldRenderDiagnostics, - terrainDrawDiagnostics); - var worldSceneDiagnostics = new WorldSceneDiagnosticsController( - host.WorldRenderDiagnostics, - new RuntimeWorldScenePViewDiagnosticSource( - d.PlayerController, - d.PhysicsEngine, - d.CellVisibility), - d.WorldSceneDebugState, - foundation.DebugLines, - d.PhysicsEngine, - d.PlayerMode, - d.PlayerController, - d.DebugVmRenderFacts, - settings.DevTools is not null); - var worldScenePasses = new WorldScenePassExecutor( - d.Gl, - renderFrameGlState, - live.ClipFrame, - live.DrawDispatcher, - live.EnvCellRenderer, - foundation.Terrain, - terrainDrawDiagnostics, - live.SkyRenderer, - content.ParticleSystem, - live.ParticleRenderer); + IWorldSceneFramePhase worldSceneRenderer = + AcDream.App.Rendering.Gpu.Vk.VulkanWorldScenePhase.Instance; CurrentRenderSceneOracle? currentRenderSceneOracle = interaction.RetainedUi?.Screenshots is not null && d.Options.AutomationArtifactDirectory is not null @@ -391,41 +319,144 @@ internal sealed class FrameRootCompositionPhase message => d.Log("[UI-PROBE] " + message), acknowledgeDirty: false) : null; - RenderScenePViewFrameProductController? renderFrameProduct = - live.RenderSceneShadow is not null - ? new RenderScenePViewFrameProductController( - live.RenderSceneShadow, - currentRenderSceneOracle, - message => d.Log("[UI-PROBE] " + message), - live.DrawDispatcher) - : null; - // After G4 the retained product is the production object source. - // The old dispatcher/selection observer is intentionally detached; - // automation still compares the independently built PView route list. - live.DrawDispatcher.SetCurrentRenderSceneObserver(null); - live.SelectionScene.SetCurrentRenderSceneObserver(null); - var worldSceneRenderer = new WorldSceneRenderer( - renderFrameResources, - renderLoginState, - d.WorldEnvironment, - worldRenderFrameBuilder, - new RuntimeWorldSceneEntitySource(live.WorldState), - live.SelectionScene, - d.RetailAlphaQueue, - d.ParticleVisibility, - new WorldScenePViewRenderer( - new RetailPViewRenderer( - currentRenderSceneOracle, - renderFrameProduct), - retailPViewPassExecutor, - retailPViewPassExecutor), - retailPViewCells, - worldScenePasses, - d.RenderRange, - worldSceneDiagnostics, - live.WorldAvailability); + RenderScenePViewFrameProductController? renderFrameProduct = null; + if (gl is not null) + { + // The GL world scene, unchanged. Campaign V slice V6h wrapped it in + // this one condition and changed nothing inside it: every renderer + // it composes exists on GL and none of them exists on Vulkan, so the + // Vulkan arm keeps the VulkanWorldScenePhase assigned above. + WorldRenderDiagnostics worldRenderDiagnostics = + host.WorldRenderDiagnostics + ?? throw new InvalidOperationException( + "The GL world scene requires the GL state tripwire."); + var worldFrameEnvironment = + new RuntimeWorldFrameEnvironmentPreparation( + d.Options, + d.WorldTime, + d.Lighting, + live.DrawDispatcher!, + live.EnvCellRenderer!, + foundation.SceneLighting!, + d.RenderRange, + skyPesFrame); + var worldRenderFrameBuilder = new WorldRenderFrameBuilder( + new RuntimeWorldFrameCameraSource( + host.CameraController, + session.LocalTeleport), + new RuntimeWorldFrameVisibilityPreparation( + live.SelectionScene, + d.ParticleVisibility, + foundation.Terrain!, + session.WorldReveal, + live.EnvCellFrustum), + new RuntimeWorldFrameSettingsPreview( + d.Settings, + content.Audio?.Engine, + host.CameraController, + d.DisplayFramePacing), + new RuntimeWorldFrameRootSource( + d.PhysicsEngine, + d.CellVisibility, + d.PlayerMode, + d.ChaseCameraInput, + d.PlayerController, + d.WorldOrigin), + worldFrameEnvironment, + new RuntimeWorldFrameAnimatedEntitySource( + d.Animations, + live.StaticAnimationScheduler, + live.EquippedChildren), + new RuntimeWorldFrameBuildingSource( + live.LandblockPipeline, + d.CellVisibility)); + var terrainDrawDiagnostics = new TerrainDrawDiagnosticsController( + d.FrameDiagnosticsEnabled, + worldRenderDiagnostics, + new RuntimeFramePipelineDiagnosticFactsSource( + foundation.Terrain!, + live.LandblockPipeline, + renderFrameLivePreparation, + live.DrawDispatcher!, + session.Streaming, + live.LiveEntities, + live.WorldState), + d.RenderDiagnosticLog); + var retailPViewCells = new RetailPViewCellSource(d.CellVisibility); + var retailPViewPassExecutor = new RetailPViewPassExecutor( + gl, + renderFrameGlState!, + new SilkRetailPViewFramebufferSource(d.Window), + live.ClipFrame, + foundation.Terrain!, + live.EnvCellRenderer!, + live.DrawDispatcher!, + live.SkyRenderer!, + content.ParticleSystem, + live.ParticleRenderer!, + live.PortalDepthMask!, + d.RetailAlphaQueue, + worldRenderDiagnostics, + terrainDrawDiagnostics); + var worldSceneDiagnostics = new WorldSceneDiagnosticsController( + worldRenderDiagnostics, + new RuntimeWorldScenePViewDiagnosticSource( + d.PlayerController, + d.PhysicsEngine, + d.CellVisibility), + d.WorldSceneDebugState, + foundation.DebugLines, + d.PhysicsEngine, + d.PlayerMode, + d.PlayerController, + d.DebugVmRenderFacts, + settings.DevTools is not null); + var worldScenePasses = new WorldScenePassExecutor( + gl, + renderFrameGlState!, + live.ClipFrame, + live.DrawDispatcher!, + live.EnvCellRenderer!, + foundation.Terrain!, + terrainDrawDiagnostics, + live.SkyRenderer!, + content.ParticleSystem, + live.ParticleRenderer!); + renderFrameProduct = + live.RenderSceneShadow is not null + ? new RenderScenePViewFrameProductController( + live.RenderSceneShadow, + currentRenderSceneOracle, + message => d.Log("[UI-PROBE] " + message), + live.DrawDispatcher!) + : null; + // After G4 the retained product is the production object source. + // The old dispatcher/selection observer is intentionally detached; + // automation still compares the independently built PView route list. + live.DrawDispatcher!.SetCurrentRenderSceneObserver(null); + live.SelectionScene.SetCurrentRenderSceneObserver(null); + worldSceneRenderer = new WorldSceneRenderer( + renderFrameResources, + renderLoginState, + d.WorldEnvironment, + worldRenderFrameBuilder, + new RuntimeWorldSceneEntitySource(live.WorldState), + live.SelectionScene, + d.RetailAlphaQueue, + d.ParticleVisibility, + new WorldScenePViewRenderer( + new RetailPViewRenderer( + currentRenderSceneOracle, + renderFrameProduct), + retailPViewPassExecutor, + retailPViewPassExecutor), + retailPViewCells, + worldScenePasses, + d.RenderRange, + worldSceneDiagnostics, + live.WorldAvailability); + } Fault(FrameRootCompositionPoint.WorldRendererCreated); - bindings = new FrameRootRuntimeBindings(); WorldLifecycleAutomationController? lifecycleAutomation = null; if (interaction.RetainedUi?.Screenshots is { } screenshots @@ -508,7 +539,9 @@ internal sealed class FrameRootCompositionPhase ?? NullRenderFramePostDiagnosticsPhase.Instance; var renderFrame = new RenderFrameOrchestrator( host.GpuFrameLifetime, - new FrameProfilerGpuMeasurement(d.FrameProfiler, d.Gl), + gl is not null + ? new FrameProfilerGpuMeasurement(d.FrameProfiler, gl) + : AcDream.App.Rendering.Gpu.Vk.NullRenderFrameGpuMeasurement.Instance, framePreparation, worldSceneRenderer, privatePresentation, diff --git a/src/AcDream.App/Composition/GameWindowGraphics.cs b/src/AcDream.App/Composition/GameWindowGraphics.cs new file mode 100644 index 00000000..d85ac34f --- /dev/null +++ b/src/AcDream.App/Composition/GameWindowGraphics.cs @@ -0,0 +1,80 @@ +using AcDream.App.Rendering.Gpu.Vk; +using Silk.NET.OpenGL; + +namespace AcDream.App.Composition; + +/// +/// Campaign V slice V6h: the graphics ownership that platform acquisition +/// publishes, once per backend. +/// +/// was already generic over its +/// graphics type; only the call sites pinned GL. This class is what they +/// pin instead, so one composition pipeline drives both backends and the fork +/// lives at the handful of construction sites that genuinely differ rather than +/// in a second copy of the startup topology. +/// +/// It is deliberately NOT a capability abstraction. Nothing dispatches on +/// it per frame; phases that still speak raw GL ask for the context by name and +/// take their Vulkan arm when it is absent. The GL arm is deleted at slice V11 +/// and this class with it. +/// +internal abstract class GameWindowGraphics : IDisposable +{ + /// Which backend this handle owns. + public abstract RenderBackendKind Backend { get; } + + /// + /// The live GL context, or null on any other backend. Phases whose owners + /// are still raw GL branch on this; each such branch is a slice of Campaign + /// V that has not landed yet, and the null arm names which one. + /// + public virtual GL? Gl => null; + + /// The live Vulkan context, or null on any other backend. + public virtual VulkanGraphicsContext? Vulkan => null; + + /// + /// The GL context, or a failure naming the caller. Used where the call site + /// has already established that the GL arm is running, so a null would be a + /// composition bug rather than a backend difference. + /// + public GL RequireGl(string owner) => + Gl ?? throw new InvalidOperationException( + $"'{owner}' requires the OpenGL context, but the {Backend} backend is active."); + + public abstract void Dispose(); +} + +/// OpenGL ownership: the Silk.NET context itself. +internal sealed class OpenGlGameWindowGraphics : GameWindowGraphics +{ + public OpenGlGameWindowGraphics(GL gl) => + Context = gl ?? throw new ArgumentNullException(nameof(gl)); + + /// The owned context. is the borrowed view. + public GL Context { get; } + + public override RenderBackendKind Backend => RenderBackendKind.Gl; + + public override GL? Gl => Context; + + public override void Dispose() => Context.Dispose(); +} + +/// +/// Vulkan ownership: the instance, surface, device, swapchain and RHI device +/// that acquired and gated. +/// +internal sealed class VulkanGameWindowGraphics : GameWindowGraphics +{ + public VulkanGameWindowGraphics(VulkanGraphicsContext context) => + Context = context ?? throw new ArgumentNullException(nameof(context)); + + public VulkanGraphicsContext Context { get; } + + public override RenderBackendKind Backend => RenderBackendKind.Vulkan; + + public override VulkanGraphicsContext? Vulkan => Context; + + public override void Dispose() => Context.Dispose(); +} diff --git a/src/AcDream.App/Composition/HostInputCameraComposition.cs b/src/AcDream.App/Composition/HostInputCameraComposition.cs index 756d09d5..af96be07 100644 --- a/src/AcDream.App/Composition/HostInputCameraComposition.cs +++ b/src/AcDream.App/Composition/HostInputCameraComposition.cs @@ -10,7 +10,7 @@ namespace AcDream.App.Composition; internal interface IGameWindowHostInputCameraPublication { - void PublishGpuFrameFlights(GpuFrameFlightController value); + void PublishGpuFrameFlights(GpuFrameFlightController? value); void PublishGpuDevice(IGpuDevice value); void PublishGpuFrameLifetime(GpuDeviceFrameLifetime value); void PublishKeyboardSource(SilkKeyboardSource value); @@ -21,11 +21,20 @@ internal interface IGameWindowHostInputCameraPublication void PublishCameraPointerInput(CameraPointerInputController value); } +/// +/// The GL fence/slot ring, or null on a backend whose RHI device owns its own +/// flight control. Campaign V slice V6h: and +/// are the backend-neutral views every consumer should +/// take; this field exists because GL's teardown ledger still names the +/// controller itself. +/// internal sealed record HostInputCameraResult( - GpuFrameFlightController GpuFrameFlights, + GpuFrameFlightController? GpuFrameFlights, + IGpuResourceRetirementQueue Retirement, + IRenderFrameSlotSource FrameSlots, IGpuDevice GpuDevice, GpuDeviceFrameLifetime GpuFrameLifetime, - WorldRenderDiagnostics WorldRenderDiagnostics, + WorldRenderDiagnostics? WorldRenderDiagnostics, SilkKeyboardSource? KeyboardSource, SilkMouseSource? MouseSource, IMouseLookCursor? MouseLookCursor, @@ -46,14 +55,40 @@ internal sealed record HostInputCameraDependencies( PointerPositionState PointerPosition, IRenderFrameDiagnosticLog RenderDiagnosticLog); +/// +/// The construction seam every backend differs at. Campaign V slice V6h widened +/// the first four members from GL to — +/// they are the whole of what a backend has to supply before the composition +/// pipeline is identical again. +/// internal interface IHostInputCameraCompositionFactory { - IFramebufferViewportTarget CreateViewportTarget(GL gl); - GpuFrameFlightController CreateGpuFrameFlights(GL gl); - IGpuDevice CreateGpuDevice(GL gl, GpuFrameFlightController frameFlights); - WorldRenderDiagnostics CreateWorldRenderDiagnostics( - GL gl, + IFramebufferViewportTarget CreateViewportTarget(GameWindowGraphics graphics); + + /// The GL fence ring, or null when the backend's RHI device owns its flights. + GpuFrameFlightController? CreateGpuFrameFlights(GameWindowGraphics graphics); + + IGpuDevice CreateGpuDevice( + GameWindowGraphics graphics, + GpuFrameFlightController? frameFlights); + + /// Where per-frame resource release is queued. GL's ring, or the RHI device's own. + IGpuResourceRetirementQueue CreateRetirement( + GameWindowGraphics graphics, + GpuFrameFlightController? frameFlights, + IGpuDevice device); + + /// The ring slot renderers index their per-flight buffers by. + IRenderFrameSlotSource CreateFrameSlots( + GameWindowGraphics graphics, + GpuFrameFlightController? frameFlights, + IGpuDevice device); + + /// The raw-GL state tripwire, or null on a backend that has no GL state. + WorldRenderDiagnostics? CreateWorldRenderDiagnostics( + GameWindowGraphics graphics, IRenderFrameDiagnosticLog log); + SilkKeyboardSource CreateKeyboardSource( IKeyboard keyboard, HostQuiescenceGate quiescence); @@ -83,21 +118,39 @@ internal interface IHostInputCameraCompositionFactory internal sealed class RetailHostInputCameraCompositionFactory : IHostInputCameraCompositionFactory { - public IFramebufferViewportTarget CreateViewportTarget(GL gl) => - new SilkFramebufferViewportTarget(gl); + public IFramebufferViewportTarget CreateViewportTarget(GameWindowGraphics graphics) => + new SilkFramebufferViewportTarget(graphics.RequireGl("viewport target")); - public GpuFrameFlightController CreateGpuFrameFlights(GL gl) => new(gl); + public GpuFrameFlightController? CreateGpuFrameFlights(GameWindowGraphics graphics) => + new(graphics.RequireGl("GPU frame flights")); - public IGpuDevice CreateGpuDevice(GL gl, GpuFrameFlightController frameFlights) => + public IGpuDevice CreateGpuDevice( + GameWindowGraphics graphics, + GpuFrameFlightController? frameFlights) => new AcDream.App.Rendering.Gpu.Gl.GlGpuDevice( - gl, - frameFlights, + graphics.RequireGl("GPU device (RHI)"), + frameFlights ?? throw new ArgumentNullException(nameof(frameFlights)), Path.Combine(AppContext.BaseDirectory, "Rendering", "Shaders")); - public WorldRenderDiagnostics CreateWorldRenderDiagnostics( - GL gl, + public IGpuResourceRetirementQueue CreateRetirement( + GameWindowGraphics graphics, + GpuFrameFlightController? frameFlights, + IGpuDevice device) => + frameFlights ?? throw new ArgumentNullException(nameof(frameFlights)); + + public IRenderFrameSlotSource CreateFrameSlots( + GameWindowGraphics graphics, + GpuFrameFlightController? frameFlights, + IGpuDevice device) => + frameFlights ?? throw new ArgumentNullException(nameof(frameFlights)); + + public WorldRenderDiagnostics? CreateWorldRenderDiagnostics( + GameWindowGraphics graphics, IRenderFrameDiagnosticLog log) => - new(new SilkRenderGlStateReader(gl), log); + new( + new SilkRenderGlStateReader( + graphics.RequireGl("world render diagnostics")), + log); public SilkKeyboardSource CreateKeyboardSource( IKeyboard keyboard, @@ -175,13 +228,15 @@ internal enum HostInputCameraCompositionPoint /// internal sealed class HostInputCameraCompositionPhase : IHostInputCameraCompositionPhase< - GameWindowPlatformResult, + GameWindowPlatformResult, HostInputCameraResult> { private readonly HostInputCameraDependencies _dependencies; private readonly IGameWindowHostInputCameraPublication _publication; - private readonly IHostInputCameraCompositionFactory _factory; + private readonly IHostInputCameraCompositionFactory? _injectedFactory; private readonly Action? _faultInjection; + private IHostInputCameraCompositionFactory _factory = + new RetailHostInputCameraCompositionFactory(); public HostInputCameraCompositionPhase( HostInputCameraDependencies dependencies, @@ -193,12 +248,25 @@ internal sealed class HostInputCameraCompositionPhase : ?? throw new ArgumentNullException(nameof(dependencies)); _publication = publication ?? throw new ArgumentNullException(nameof(publication)); - _factory = factory ?? new RetailHostInputCameraCompositionFactory(); + _injectedFactory = factory; _faultInjection = faultInjection; } + /// + /// Campaign V slice V6h: the default factory is chosen from the platform + /// result, not from the host. The backend is a property of the graphics + /// ownership that acquisition published, so the phase reads it rather than + /// making every call site branch — and an injected factory (composition + /// tests) still wins. + /// + private static IHostInputCameraCompositionFactory DefaultFactoryFor( + GameWindowGraphics graphics) => + graphics.Backend == RenderBackendKind.Vulkan + ? new VulkanHostInputCameraCompositionFactory() + : new RetailHostInputCameraCompositionFactory(); + public HostInputCameraResult Compose( - GameWindowPlatformResult platform) + GameWindowPlatformResult platform) { ArgumentNullException.ThrowIfNull(platform); var scope = new CompositionAcquisitionScope(); @@ -216,19 +284,23 @@ internal sealed class HostInputCameraCompositionPhase : } private HostInputCameraResult ComposeCore( - GameWindowPlatformResult platform, + GameWindowPlatformResult platform, CompositionAcquisitionScope scope) { - GL gl = platform.Graphics; + GameWindowGraphics graphics = platform.Graphics; IInputContext input = platform.Input; + _factory = _injectedFactory ?? DefaultFactoryFor(graphics); _dependencies.FramebufferResize.BindViewport( - _factory.CreateViewportTarget(gl)); + _factory.CreateViewportTarget(graphics)); Fault(HostInputCameraCompositionPoint.ViewportBound); - GpuFrameFlightController gpuFrames = scope.Acquire( + // Null on a backend whose RHI device owns its own frame flights + // (Vulkan's timeline semaphore). The publication still runs so the + // teardown ledger records the same slot either way. + GpuFrameFlightController? gpuFrames = scope.AcquireOptional( "GPU frame flights", - () => _factory.CreateGpuFrameFlights(gl), + () => _factory.CreateGpuFrameFlights(graphics), static value => value.Dispose()).Publish( _publication.PublishGpuFrameFlights); Fault(HostInputCameraCompositionPoint.GpuFrameFlightsPublished); @@ -242,10 +314,14 @@ internal sealed class HostInputCameraCompositionPhase : // stack so later slices (starting at V4a) have somewhere to plug in. IGpuDevice gpuDevice = scope.Acquire( "GPU device (RHI)", - () => _factory.CreateGpuDevice(gl, gpuFrames), + () => _factory.CreateGpuDevice(graphics, gpuFrames), static value => value.Dispose()).Publish( _publication.PublishGpuDevice); Fault(HostInputCameraCompositionPoint.GpuDevicePublished); + IGpuResourceRetirementQueue retirement = + _factory.CreateRetirement(graphics, gpuFrames, gpuDevice); + IRenderFrameSlotSource frameSlots = + _factory.CreateFrameSlots(graphics, gpuFrames, gpuDevice); // Campaign V slice V4a: drives IGpuDevice.BeginFrame()/IGpuFrame.End() // once per rendered frame, additively over the existing @@ -258,9 +334,9 @@ internal sealed class HostInputCameraCompositionPhase : var gpuFrameLifetime = new GpuDeviceFrameLifetime(gpuDevice); _publication.PublishGpuFrameLifetime(gpuFrameLifetime); - WorldRenderDiagnostics diagnostics = + WorldRenderDiagnostics? diagnostics = _factory.CreateWorldRenderDiagnostics( - gl, + graphics, _dependencies.RenderDiagnosticLog); IKeyboard? firstKeyboard = input.Keyboards.FirstOrDefault(); @@ -357,6 +433,8 @@ internal sealed class HostInputCameraCompositionPhase : return new HostInputCameraResult( gpuFrames, + retirement, + frameSlots, gpuDevice, gpuFrameLifetime, diagnostics, diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs index 0dc10c38..f19f700b 100644 --- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs +++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs @@ -30,9 +30,20 @@ using Silk.NET.Windowing; namespace AcDream.App.Composition; +/// +/// The backend handle this composition was built against. Only the consistency +/// check reads it — Campaign V slice V6h moved the retained UI's last raw-GL +/// use, the probe screenshot reader, onto . +/// +/// +/// Reads the presented frame as tightly packed RGBA8 in the backend's own row +/// order; applies the bottom-up flip +/// glReadPixels needs, so a top-left-origin backend pre-flips to cancel it. +/// internal sealed record InteractionRetainedUiDependencies( RuntimeOptions Options, - GL Gl, + GameWindowGraphics Graphics, + Func BackbufferReader, IView Window, IInputContext Input, string ShadersDirectory, @@ -530,7 +541,7 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory && d.Options.AutomationArtifactDirectory is { } artifactDirectory) { screenshots = new FrameScreenshotController( - d.Gl, + d.BackbufferReader, Path.Combine(artifactDirectory, "screenshots"), ProbeLog); } @@ -774,7 +785,7 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory internal sealed class InteractionRetainedUiCompositionPhase : IInteractionUiCompositionPhase< - GameWindowPlatformResult, + GameWindowPlatformResult, HostInputCameraResult, ContentEffectsAudioResult, SettingsDevToolsResult, @@ -892,7 +903,7 @@ internal sealed class InteractionRetainedUiCompositionPhase } public InteractionRetainedUiResult Compose( - GameWindowPlatformResult platform, + GameWindowPlatformResult platform, HostInputCameraResult host, ContentEffectsAudioResult content, SettingsDevToolsResult settings, @@ -903,7 +914,7 @@ internal sealed class InteractionRetainedUiCompositionPhase ArgumentNullException.ThrowIfNull(content); ArgumentNullException.ThrowIfNull(settings); ArgumentNullException.ThrowIfNull(world); - if (!ReferenceEquals(_dependencies.Gl, platform.Graphics) + if (!ReferenceEquals(_dependencies.Graphics, platform.Graphics) || !ReferenceEquals(_dependencies.Input, platform.Input) || !ReferenceEquals(_dependencies.InputDispatcher, host.InputDispatcher) || !ReferenceEquals(_dependencies.Dats, content.Dats) diff --git a/src/AcDream.App/Composition/LivePresentationComposition.cs b/src/AcDream.App/Composition/LivePresentationComposition.cs index e14857f4..1e3b460c 100644 --- a/src/AcDream.App/Composition/LivePresentationComposition.cs +++ b/src/AcDream.App/Composition/LivePresentationComposition.cs @@ -37,7 +37,7 @@ namespace AcDream.App.Composition; internal sealed record LivePresentationDependencies( RuntimeOptions Options, - GL Gl, + GameWindowGraphics Graphics, IWindow Window, object DatLock, RuntimeSettingsController Settings, @@ -106,7 +106,7 @@ internal sealed record LivePresentationResult( EntityEffectController EntityEffects, LiveEntityPresentationController Presentation, RemoteTeleportController RemoteTeleport, - WbDrawDispatcher DrawDispatcher, + WbDrawDispatcher? DrawDispatcher, RetailSelectionScene SelectionScene, WorldSelectionQuery SelectionQuery, SelectionInteractionController SelectionInteractions, @@ -116,12 +116,12 @@ internal sealed record LivePresentationResult( CreatureAppraisalViewportRenderer? CreatureAppraisalRenderer, CreatureAppraisalFramePresenter? CreatureAppraisalPresenter, WbFrustum EnvCellFrustum, - EnvCellRenderer EnvCellRenderer, + EnvCellRenderer? EnvCellRenderer, LandblockPresentationPipeline LandblockPipeline, ClipFrame ClipFrame, - PortalDepthMaskRenderer PortalDepthMask, - SkyRenderer SkyRenderer, - ParticleRenderer ParticleRenderer, + PortalDepthMaskRenderer? PortalDepthMask, + SkyRenderer? SkyRenderer, + ParticleRenderer? ParticleRenderer, RenderFrameDiagnosticsController FrameDiagnostics, LivePresentationRuntimeBindings RuntimeBindings, DeferredLiveEntityLandblockLoadedSink LandblockLoaded); @@ -152,7 +152,7 @@ internal enum LivePresentationCompositionPoint internal sealed class LivePresentationCompositionPhase : ILivePresentationCompositionPhase< - GameWindowPlatformResult, + GameWindowPlatformResult, HostInputCameraResult, ContentEffectsAudioResult, SettingsDevToolsResult, @@ -177,7 +177,7 @@ internal sealed class LivePresentationCompositionPhase } public LivePresentationResult Compose( - GameWindowPlatformResult platform, + GameWindowPlatformResult platform, HostInputCameraResult host, ContentEffectsAudioResult content, SettingsDevToolsResult settings, @@ -190,7 +190,7 @@ internal sealed class LivePresentationCompositionPhase ArgumentNullException.ThrowIfNull(settings); ArgumentNullException.ThrowIfNull(world); ArgumentNullException.ThrowIfNull(interaction); - if (!ReferenceEquals(_dependencies.Gl, platform.Graphics)) + if (!ReferenceEquals(_dependencies.Graphics, platform.Graphics)) { throw new InvalidOperationException( "Live-presentation dependencies do not match the ordered platform result."); @@ -224,7 +224,9 @@ internal sealed class LivePresentationCompositionPhase var componentLifecycle = new DeferredLiveEntityRuntimeComponentLifecycle(); - var wbSpawnAdapter = new LandblockSpawnAdapter(foundation.MeshAdapter); + var wbSpawnAdapter = new LandblockSpawnAdapter( + (IWbMeshAdapter?)foundation.MeshAdapter + ?? AcDream.App.Rendering.Gpu.Vk.NullWbMeshAdapter.Instance); Setup? LoadPreparedSetup(uint sourceId) { if (!content.Dats.TryResolvePreferred( @@ -674,22 +676,29 @@ internal sealed class LivePresentationCompositionPhase AlphaScratchBudgetProfile.Create( d.Options.ResidencyBudgets.AlphaScratchBytes); + // Campaign V slice V6h: every renderer below this line is still raw GL. + // On a backend without a GL context none of them is constructed, and the + // CPU owners this phase also produces — entity lifetime, motion, + // selection, effects, lights, the landblock pipeline — run unchanged. + GL? gl = d.Graphics.Gl; var selectionScene = new RetailSelectionScene( new RetailSelectionGeometryCache(content.Dats, d.DatLock)); - var dispatcherLease = scope.Acquire( + var dispatcherLease = scope.AcquireOptional( "WB draw dispatcher", - () => new WbDrawDispatcher( - d.Gl, - foundation.MeshShader, - foundation.TextureCache, - foundation.MeshAdapter, - entitySpawnAdapter, - foundation.Bindless, - d.ClassificationCache, - d.TranslucencyFades, - selectionScene, - d.RetailAlphaQueue, - alphaScratchBudgets.DispatcherBytes), + () => gl is null + ? null + : new WbDrawDispatcher( + gl, + foundation.MeshShader!, + foundation.TextureCache, + foundation.MeshAdapter!, + entitySpawnAdapter, + foundation.Bindless!, + d.ClassificationCache, + d.TranslucencyFades, + selectionScene, + d.RetailAlphaQueue, + alphaScratchBudgets.DispatcherBytes), static value => value.Dispose()); var selectionQuery = new WorldSelectionQuery( liveEntities, @@ -764,23 +773,28 @@ internal sealed class LivePresentationCompositionPhase retainedGameplayLease.Resource.Attach(); } Fault(LivePresentationCompositionPoint.RetainedGameplayBound); - dispatcherLease.Resource.AlphaToCoverage = - d.Settings.ResolvedQuality.AlphaToCoverage; + if (dispatcherLease.Resource is { } alphaDispatcher) + { + alphaDispatcher.AlphaToCoverage = + d.Settings.ResolvedQuality.AlphaToCoverage; + } CompositionAcquisitionScope.CompositionAcquisitionLease< PaperdollViewportRenderer>? paperdollLease = null; PaperdollFramePresenter? paperdollPresenter = null; - if (interaction.RetainedUi?.Runtime.PaperdollViewportWidget is { } viewport + if (gl is not null + && dispatcherLease.Resource is { } paperdollDispatcher + && interaction.RetainedUi?.Runtime.PaperdollViewportWidget is { } viewport && interaction.RetainedUi.Runtime.InventoryFrame is { } inventoryFrame) { paperdollLease = scope.Acquire( "paperdoll viewport", () => new PaperdollViewportRenderer( - d.Gl, - dispatcherLease.Resource, - foundation.SceneLighting, + gl, + paperdollDispatcher, + foundation.SceneLighting!, foundation.TextureCache, - foundation.MeshAdapter), + foundation.MeshAdapter!), static value => value.Dispose()); IUiViewportRenderer? previousRenderer = viewport.Renderer; viewport.Renderer = paperdollLease.Resource; @@ -809,7 +823,9 @@ internal sealed class LivePresentationCompositionPhase CompositionAcquisitionScope.CompositionAcquisitionLease< CreatureAppraisalViewportRenderer>? creatureAppraisalLease = null; CreatureAppraisalFramePresenter? creatureAppraisalPresenter = null; - if (interaction.RetainedUi?.Runtime.CreatureAppraisalViewportWidget + if (gl is not null + && dispatcherLease.Resource is { } appraisalDispatcher + && interaction.RetainedUi?.Runtime.CreatureAppraisalViewportWidget is { } creatureViewport && interaction.RetainedUi.Runtime.ExaminationFrame is { } examinationFrame @@ -819,11 +835,11 @@ internal sealed class LivePresentationCompositionPhase creatureAppraisalLease = scope.Acquire( "creature appraisal viewport", () => new CreatureAppraisalViewportRenderer( - d.Gl, - dispatcherLease.Resource, - foundation.SceneLighting, + gl, + appraisalDispatcher, + foundation.SceneLighting!, foundation.TextureCache, - foundation.MeshAdapter), + foundation.MeshAdapter!), static value => value.Dispose()); IUiViewportRenderer? previousRenderer = creatureViewport.Renderer; creatureViewport.Renderer = creatureAppraisalLease.Resource; @@ -851,30 +867,41 @@ internal sealed class LivePresentationCompositionPhase Fault(LivePresentationCompositionPoint.PrivateCreatureViewportsCreated); var envCellFrustum = new WbFrustum(); - var envCellLease = scope.Acquire( + var envCellLease = scope.AcquireOptional( "environment-cell renderer", - () => new EnvCellRenderer( - d.Gl, - foundation.MeshAdapter.MeshManager!, - envCellFrustum), + () => gl is null + ? null + : new EnvCellRenderer( + gl, + foundation.MeshAdapter!.MeshManager!, + envCellFrustum), static value => value.Dispose()); - envCellLease.Resource.Initialize(foundation.MeshShader); + envCellLease.Resource?.Initialize(foundation.MeshShader!); Fault(LivePresentationCompositionPoint.EnvironmentCellsCreated); + // The streaming pipeline itself is backend-neutral and runs on both + // arms: landblocks load, heightfields and collision build, and the + // spatial index fills. Only publication into GPU state is renderer- + // owned, so the Vulkan arm publishes into nothing until the world arm + // lands. + TerrainModernRenderer? terrainRenderer = foundation.Terrain; + EnvCellRenderer? envCells = envCellLease.Resource; var landblockRenderPublisher = new LandblockRenderPublisher( (landblockId, meshData, origin) => - foundation.Terrain.AddLandblockWithMesh( + terrainRenderer?.AddLandblockWithMesh( landblockId, meshData, origin), - foundation.Terrain.RemoveLandblock, + landblockId => terrainRenderer?.RemoveLandblock(landblockId), d.CellVisibility, worldState, - prepareEnvCells: build => EnvCellMeshPreparationScheduler.Schedule( - build, - foundation.MeshAdapter.MeshManager!), - removeEnvCells: envCellLease.Resource.RemoveLandblock, - envCellPublisher: envCellLease.Resource); + prepareEnvCells: build => + { + if (foundation.MeshAdapter?.MeshManager is { } envCellMeshes) + EnvCellMeshPreparationScheduler.Schedule(build, envCellMeshes); + }, + removeEnvCells: landblockId => envCells?.RemoveLandblock(landblockId), + envCellPublisher: envCells); var landblockPhysicsPublisher = new LandblockPhysicsPublisher( d.EntityObjects.Physics, world.TerrainBuild.HeightTable); @@ -907,9 +934,9 @@ internal sealed class LivePresentationCompositionPhase "portal clip frame", ClipFrame.NoClip, static value => value.Dispose()); - var portalDepthLease = scope.Acquire( + var portalDepthLease = scope.AcquireOptional( "portal depth mask", - () => new PortalDepthMaskRenderer(d.Gl), + () => gl is null ? null : new PortalDepthMaskRenderer(gl), static value => value.Dispose()); CompositionAcquisitionScope.CompositionAcquisitionLease< PortalWaitNoticeController>? portalWaitNoticeLease = null; @@ -925,97 +952,113 @@ internal sealed class LivePresentationCompositionPhase portalWaitNoticeLease is { } waitNoticeLease ? waitNoticeLease.Resource.Set : null; - PortalTunnelPresentation portalTunnel; - try - { - portalTunnel = d.PortalTunnelFallback.AcquirePrepared( - () => PortalTunnelPresentation.CreateRequired( - d.Gl, - content.Dats, - content.AnimationLoader, - d.HookRouter, - dispatcherLease.Resource, - foundation.SceneLighting, - foundation.MeshAdapter, - displayPortalWaitNotice, - portalWaitNoticeLease?.Resource), - static tunnel => tunnel.PrepareResources()); - } - catch (Exception acquisitionFailure) + CompositionAcquisitionScope.CompositionAcquisitionLease< + PortalTunnelPresentation>? portalTunnelLease = null; + if (gl is not null && dispatcherLease.Resource is { } portalDispatcher) { + PortalTunnelPresentation portalTunnel; try { - d.PortalTunnelFallback.ReleaseFallback(); + portalTunnel = d.PortalTunnelFallback.AcquirePrepared( + () => PortalTunnelPresentation.CreateRequired( + gl, + content.Dats, + content.AnimationLoader, + d.HookRouter, + portalDispatcher, + foundation.SceneLighting!, + foundation.MeshAdapter!, + displayPortalWaitNotice, + portalWaitNoticeLease?.Resource), + static tunnel => tunnel.PrepareResources()); } - catch (Exception cleanupFailure) + catch (Exception acquisitionFailure) { - throw new AggregateException( - "Portal-tunnel construction and fallback rollback both failed.", - acquisitionFailure, - cleanupFailure); - } + try + { + d.PortalTunnelFallback.ReleaseFallback(); + } + catch (Exception cleanupFailure) + { + throw new AggregateException( + "Portal-tunnel construction and fallback rollback both failed.", + acquisitionFailure, + cleanupFailure); + } - throw; + throw; + } + portalTunnelLease = scope.Own( + "portal tunnel fallback", + portalTunnel, + _ => d.PortalTunnelFallback.ReleaseFallback()); } - var portalTunnelLease = scope.Own( - "portal tunnel fallback", - portalTunnel, - _ => d.PortalTunnelFallback.ReleaseFallback()); Fault(LivePresentationCompositionPoint.PortalResourcesCreated); - AcDream.App.Rendering.Shader skyShader = - d.RenderResourceLifetime.AcquireSkyShader( - () => new AcDream.App.Rendering.Shader( - d.Gl, - Path.Combine(foundation.ShadersDirectory, "sky.vert"), - Path.Combine(foundation.ShadersDirectory, "sky.frag"), - // Campaign V slice V6e: sky reads the shared texture table and - // ACDREAM_UBO_SET now, both of which common.glsl declares. - includeCommonPreamble: true)); - var skyShaderLease = scope.Own( - "sky shader lifetime", - skyShader, - _ => d.RenderResourceLifetime.ReleaseSkyShader()); - var skyLease = scope.Acquire( - "sky renderer", - () => new SkyRenderer( - d.Gl, - content.Dats, + AcDream.App.Rendering.Shader? skyShader = gl is null + ? null + : d.RenderResourceLifetime.AcquireSkyShader( + () => new AcDream.App.Rendering.Shader( + gl, + Path.Combine(foundation.ShadersDirectory, "sky.vert"), + Path.Combine(foundation.ShadersDirectory, "sky.frag"), + // Campaign V slice V6e: sky reads the shared texture table and + // ACDREAM_UBO_SET now, both of which common.glsl declares. + includeCommonPreamble: true)); + var skyShaderLease = skyShader is null + ? null + : scope.Own( + "sky shader lifetime", skyShader, - foundation.TextureCache, - foundation.Samplers, - // Slice V6e: the sky samples through the binding=9 handle table, - // so it needs the same bindless entry point the world path uses. - foundation.Bindless), + _ => d.RenderResourceLifetime.ReleaseSkyShader()); + var skyLease = scope.AcquireOptional( + "sky renderer", + () => gl is null || skyShader is null + ? null + : new SkyRenderer( + gl, + content.Dats, + skyShader, + foundation.TextureCache, + foundation.Samplers!, + // Slice V6e: the sky samples through the binding=9 handle table, + // so it needs the same bindless entry point the world path uses. + foundation.Bindless!), static value => value.Dispose()); - var particleLease = scope.Acquire( + var particleLease = scope.AcquireOptional( "particle renderer", - () => new ParticleRenderer( - d.Gl, - foundation.ShadersDirectory, - content.ParticleSystem, - foundation.TextureCache, - content.Dats, - foundation.MeshAdapter, - d.RetailAlphaQueue, - alphaScratchBudgets.ParticleBytes), + () => gl is null + ? null + : new ParticleRenderer( + gl, + foundation.ShadersDirectory, + content.ParticleSystem, + foundation.TextureCache, + content.Dats, + foundation.MeshAdapter!, + d.RetailAlphaQueue, + alphaScratchBudgets.ParticleBytes), static value => value.Dispose()); Fault(LivePresentationCompositionPoint.SkyAndParticlesCreated); IRenderFrameResourceDiagnosticsSource? resourceDiagnostics = d.Options.UiProbeDump + && dispatcherLease.Resource is { } diagnosticDispatcher + && envCellLease.Resource is { } diagnosticEnvCells + && particleLease.Resource is { } diagnosticParticles + && portalDepthLease.Resource is { } diagnosticPortalDepth ? new RuntimeRenderFrameResourceDiagnosticsSource( content.ParticleSystem, content.ParticleSink, - dispatcherLease.Resource, - envCellLease.Resource, - particleLease.Resource, + diagnosticDispatcher, + diagnosticEnvCells, + diagnosticParticles, interaction.RetainedUi?.Host.TextRenderer, - portalDepthLease.Resource, + diagnosticPortalDepth, clipFrameLease.Resource, - foundation.Terrain, - foundation.SceneLighting, - foundation.MeshAdapter, + foundation.Terrain!, + foundation.SceneLighting!, + foundation.MeshAdapter!, foundation.TextureCache, content.PreparedAssets) : null; @@ -1094,8 +1137,8 @@ internal sealed class LivePresentationCompositionPhase Charges: new ResidencyCharges( ScratchBytes: checked( d.RetailAlphaQueue.RetainedScratchBytes - + dispatcherLease.Resource.RetainedAlphaScratchBytes - + particleLease.Resource.RetainedAlphaScratchBytes)), + + (dispatcherLease.Resource?.RetainedAlphaScratchBytes ?? 0) + + (particleLease.Resource?.RetainedAlphaScratchBytes ?? 0))), BudgetBytes: alphaScratchBudgets.TotalBytes))); _publication.PublishLivePresentation(result); @@ -1112,10 +1155,10 @@ internal sealed class LivePresentationCompositionPhase envCellLease.Transfer(); clipFrameLease.Transfer(); portalDepthLease.Transfer(); - portalTunnelLease.Transfer(); + portalTunnelLease?.Transfer(); portalWaitNoticeLease?.Transfer(); skyLease.Transfer(); - skyShaderLease.Transfer(); + skyShaderLease?.Transfer(); particleLease.Transfer(); bindingsLease.Transfer(); Fault(LivePresentationCompositionPoint.ResultPublished); diff --git a/src/AcDream.App/Composition/SessionPlayerComposition.cs b/src/AcDream.App/Composition/SessionPlayerComposition.cs index 4d60a938..55546364 100644 --- a/src/AcDream.App/Composition/SessionPlayerComposition.cs +++ b/src/AcDream.App/Composition/SessionPlayerComposition.cs @@ -347,19 +347,30 @@ internal sealed class SessionPlayerCompositionPhase content.Audio?.Engine); var compositeWarmupSource = new CompositeWarmupEntitySource(live.WorldState); + // Campaign V slice V6h: composite-texture warmup is a property of the + // draw dispatcher, and mesh/texture reveal priority of the world upload + // path. On a backend with neither, the reveal gate's render-resource + // condition is trivially satisfied — there is nothing to warm — and + // every other reveal condition (streaming residence, spawn-cell + // readiness, terrain residence, quiescence) is unchanged. + WbDrawDispatcher? revealDispatcher = live.DrawDispatcher; var revealRenderResources = new WorldRevealRenderResourceScheduler( - foundation.MeshAdapter.SetDestinationRevealUploadPriority, + foundation.MeshAdapter is { } revealMeshes + ? revealMeshes.SetDestinationRevealUploadPriority + : static _ => { }, foundation.TextureCache.SetDestinationRevealUploadPriority); var worldReveal = new WorldRevealCoordinator( live.WorldTransit, streaming.IsRenderNeighborhoodResident, d.PhysicsEngine.IsSpawnCellReady, d.PhysicsEngine.IsNeighborhoodTerrainResident, - () => live.DrawDispatcher.CompositeTexturesReady, + () => revealDispatcher?.CompositeTexturesReady ?? true, (destinationCell, radius) => { + if (revealDispatcher is null) + return; compositeWarmupSource.Refresh(destinationCell, radius); - live.DrawDispatcher.PrepareCompositeTextures( + revealDispatcher.PrepareCompositeTextures( compositeWarmupSource.Entities, compositeWarmupSource.Generation, destinationCell, @@ -368,7 +379,7 @@ internal sealed class SessionPlayerCompositionPhase () => { compositeWarmupSource.Reset(); - live.DrawDispatcher.InvalidateCompositeWarmupReadiness(); + revealDispatcher?.InvalidateCompositeWarmupReadiness(); }, spawnClaimClassifier.IsUnhydratable, worldQuiescence, @@ -749,32 +760,50 @@ internal sealed class SessionPlayerCompositionPhase playerMode.BindAutoEntry(playerModeAutoEntry); Fault(SessionPlayerCompositionPoint.PlayerModeBound); + // Campaign V slice V6h: the portal tunnel is a raw-GL renderer, so a + // backend that composed none has nothing in the fallback slot to + // transfer, and the teleport owner drives a presentation that reports + // "no tunnel showing" instead. Every other part of the portal lifecycle + // — reveal generation, destination latch, placement, session — is + // unchanged and runs identically on both arms. LocalPlayerTeleportController localTeleport = - d.PortalTunnelFallback.Transfer( - portalTunnel => new LocalPlayerTeleportController( - new LiveLocalPlayerTeleportAuthority( - live.LiveEntities, - d.PlayerIdentity), - gameplayInput, - playerMode, - new LocalPlayerTeleportStreamingOperations( - d.WorldOrigin, - streamingOriginRecenter, - streaming, - sealedDungeonCells), - live.WorldTransit, - worldReveal, - new LocalPlayerTeleportPlacement( - d.PhysicsEngine, - live.LiveEntities, - d.PlayerIdentity, - d.PlayerController, - d.PlayerHost, - d.ChaseCameraInput, - d.WorldOrigin, - liveSpatialReconciler), - new LocalPlayerTeleportSession(liveSessionSource), - new LocalPlayerTeleportPresentation(portalTunnel))); + d.PortalTunnelFallback.HasFallback + ? d.PortalTunnelFallback.Transfer(CreateLocalTeleportWithTunnel) + : CreateLocalTeleport( + new AcDream.App.Rendering.Gpu.Vk + .NullLocalPlayerTeleportPresentation()); + + LocalPlayerTeleportController CreateLocalTeleport( + ILocalPlayerTeleportPresentation presentation) => + new LocalPlayerTeleportController( + new LiveLocalPlayerTeleportAuthority( + live.LiveEntities, + d.PlayerIdentity), + gameplayInput, + playerMode, + new LocalPlayerTeleportStreamingOperations( + d.WorldOrigin, + streamingOriginRecenter, + streaming, + sealedDungeonCells), + live.WorldTransit, + worldReveal, + new LocalPlayerTeleportPlacement( + d.PhysicsEngine, + live.LiveEntities, + d.PlayerIdentity, + d.PlayerController, + d.PlayerHost, + d.ChaseCameraInput, + d.WorldOrigin, + liveSpatialReconciler), + new LocalPlayerTeleportSession(liveSessionSource), + presentation); + + LocalPlayerTeleportController CreateLocalTeleportWithTunnel( + PortalTunnelPresentation portalTunnel) => + CreateLocalTeleport( + new LocalPlayerTeleportPresentation(portalTunnel)); var teleportLease = scope.Own( "local-player teleport", localTeleport, diff --git a/src/AcDream.App/Composition/SettingsDevToolsComposition.cs b/src/AcDream.App/Composition/SettingsDevToolsComposition.cs index 67d3c760..98c134f8 100644 --- a/src/AcDream.App/Composition/SettingsDevToolsComposition.cs +++ b/src/AcDream.App/Composition/SettingsDevToolsComposition.cs @@ -334,7 +334,7 @@ internal sealed class CombatFeedbackBinding : IDisposable /// internal sealed class SettingsDevToolsCompositionPhase : ISettingsDevToolsCompositionPhase< - GameWindowPlatformResult, + GameWindowPlatformResult, HostInputCameraResult, ContentEffectsAudioResult, SettingsDevToolsResult> @@ -357,7 +357,7 @@ internal sealed class SettingsDevToolsCompositionPhase : } public SettingsDevToolsResult Compose( - GameWindowPlatformResult platform, + GameWindowPlatformResult platform, HostInputCameraResult host, ContentEffectsAudioResult content) { @@ -377,7 +377,7 @@ internal sealed class SettingsDevToolsCompositionPhase : } private DevToolsCompositionOwner? ComposeOptionalDevTools( - GameWindowPlatformResult platform, + GameWindowPlatformResult platform, HostInputCameraResult host, SettingsDevToolsOptionalDependencies optional) { @@ -403,8 +403,11 @@ internal sealed class SettingsDevToolsCompositionPhase : var bootstrapLease = scope.Acquire( "ImGui bootstrap", + // ImGui is a GL-only frontend and is not ported to Vulkan — the + // campaign deletes it at slice V11 — so a Vulkan host composes + // no DevTools at all and never reaches here. () => _factory.CreateBootstrap( - platform.Graphics, + platform.Graphics.RequireGl("developer UI"), _dependencies.Window, input), static value => value.Dispose()); diff --git a/src/AcDream.App/Composition/VulkanHostInputCameraCompositionFactory.cs b/src/AcDream.App/Composition/VulkanHostInputCameraCompositionFactory.cs new file mode 100644 index 00000000..74cffdec --- /dev/null +++ b/src/AcDream.App/Composition/VulkanHostInputCameraCompositionFactory.cs @@ -0,0 +1,146 @@ +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; + +/// +/// Campaign V slice V6h: the Vulkan arm of the host phase. +/// +/// This is the whole of the backend fork at composition Phase 1. Only the +/// four graphics members differ from +/// ; input, camera and +/// pointer construction are platform concerns, not graphics ones, so they +/// delegate rather than duplicate. Adding a fifth backend would add one more +/// class of this shape and touch nothing else in the composition pipeline — +/// which is the property §5.5.9 asked this slice to establish. +/// +/// What is absent, and why. 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 +/// : it is 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. +/// +internal sealed class VulkanHostInputCameraCompositionFactory + : IHostInputCameraCompositionFactory +{ + private readonly RetailHostInputCameraCompositionFactory _platform = new(); + + public IFramebufferViewportTarget CreateViewportTarget( + GameWindowGraphics graphics) => + // The viewport is a pipeline dynamic state on Vulkan, set per pass by + // the encoder from the pass extent, so there is no persistent viewport + // to bind here. The framebuffer-resize controller still drives the + // camera aspect through its own target. + NullFramebufferViewportTarget.Instance; + + 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) => + _platform.CreateKeyboardSource(keyboard, quiescence); + + public SilkMouseSource CreateMouseSource( + IMouse mouse, + IInputCaptureSource capture, + IKeyboardSource? keyboard, + HostQuiescenceGate quiescence) => + _platform.CreateMouseSource(mouse, capture, keyboard, quiescence); + + public IMouseLookCursor CreateMouseLookCursor(IMouse mouse) => + _platform.CreateMouseLookCursor(mouse); + + public InputDispatcher CreateInputDispatcher( + IKeyboardSource keyboard, + IMouseSource mouse, + KeyBindings bindings) => + _platform.CreateInputDispatcher(keyboard, mouse, bindings); + + public CameraController CreateCameraController() => + _platform.CreateCameraController(); + + public IFramebufferCameraTarget CreateCameraTarget(CameraController camera) => + _platform.CreateCameraTarget(camera); + + public CameraPointerInputController CreateCameraPointerInput( + IReadOnlyList mice, + HostQuiescenceGate quiescence, + IInputCaptureSource capture, + LocalPlayerModeState playerMode, + CameraController camera, + ChaseCameraInputState chase, + IMouseSource mouse, + PointerPositionState pointer) => + _platform.CreateCameraPointerInput( + mice, + quiescence, + capture, + playerMode, + camera, + chase, + mouse, + pointer); + + private static VulkanGraphicsContext RequireContext( + GameWindowGraphics graphics) => + graphics.Vulkan + ?? throw new InvalidOperationException( + "The Vulkan host factory was composed against the " + + $"{graphics.Backend} backend."); + + /// + /// Vulkan sets the viewport per pass from the pass extent, so there is no + /// persistent viewport binding for the resize controller to update. Size is + /// still recorded, because the swapchain recreation the host performs is + /// what actually resizes the surface. + /// + private sealed class NullFramebufferViewportTarget : IFramebufferViewportTarget + { + public static NullFramebufferViewportTarget Instance { get; } = new(); + + private NullFramebufferViewportTarget() + { + } + + public void ResizeViewport(int width, int height) + { + } + } + + /// + /// The flight slot per-frame buffers index by. Identical in role to the GL + /// ring's ; the count comes + /// from the device's timeline flight controller rather than a fence array. + /// + private sealed class VulkanRenderFrameSlotSource(VulkanGpuDevice device) + : IRenderFrameSlotSource + { + public int CurrentSlot => device.Flights.CurrentSlot; + } +} diff --git a/src/AcDream.App/Composition/WorldRenderComposition.cs b/src/AcDream.App/Composition/WorldRenderComposition.cs index cb2604f2..b493e11b 100644 --- a/src/AcDream.App/Composition/WorldRenderComposition.cs +++ b/src/AcDream.App/Composition/WorldRenderComposition.cs @@ -28,20 +28,30 @@ internal sealed record WorldTerrainBuildContext( TerrainBlendingContext Blending, ConcurrentDictionary SurfaceCache); +/// +/// The render foundation the later phases build on. +/// +/// Campaign V slice V6h made every raw-GL member nullable. They are all +/// present on GL and all absent on Vulkan, because the world renderers that own +/// them are still raw GL until slices V4t/V4c/V4d land the Vulkan world arm. +/// What survives on both backends is exactly the RHI-ported set — the texture +/// cache's UI path, the debug font, the text renderer and the debug lines — plus +/// the backend-neutral residency ledger and shader directory. +/// internal sealed record WorldRenderFoundation( string ShadersDirectory, - BindlessSupport Bindless, - TerrainAtlas TerrainAtlas, - Shader TerrainShader, - SceneLightingUboBinding SceneLighting, + BindlessSupport? Bindless, + TerrainAtlas? TerrainAtlas, + Shader? TerrainShader, + SceneLightingUboBinding? SceneLighting, DebugLineRenderer DebugLines, BitmapFont? DebugFont, TextRenderer? TextRenderer, - TerrainModernRenderer Terrain, - Shader MeshShader, - WbMeshAdapter MeshAdapter, + TerrainModernRenderer? Terrain, + Shader? MeshShader, + WbMeshAdapter? MeshAdapter, TextureCache TextureCache, - SamplerCache Samplers, + SamplerCache? Samplers, ResidencyManager Residency); internal sealed record WorldRenderResult( @@ -107,10 +117,18 @@ internal interface IWorldRenderCompositionFactory Shader shader, TerrainAtlas atlas, IGpuResourceRetirementQueue retirement); + /// + /// The built terrain atlas, or null on a backend that has none. The atlas is + /// where the blending layer/T-code tables come from, so a null one yields an + /// empty : streaming still builds real + /// landblock heightfields and collision, but every surface resolves to + /// because nothing is going to sample it. + /// Campaign V slice V4t builds those tables without GL. + /// WorldTerrainBuildContext CreateTerrainBuildContext( uint initialCenterLandblockId, float[] heightTable, - TerrainAtlas atlas); + TerrainAtlas? atlas); Shader CreateMeshShader(GL gl, string shadersDirectory); WbMeshAdapter CreateMeshAdapter( GL gl, @@ -120,16 +138,16 @@ internal interface IWorldRenderCompositionFactory IGpuResourceRetirementQueue retirement, ResidencyBudgetOptions budgets); TextureCache CreateTextureCache( - GL gl, + GL? gl, AcDream.App.Rendering.Gpu.IGpuDevice device, IDatReaderWriter dats, - BindlessSupport bindless, + BindlessSupport? bindless, IGpuResourceRetirementQueue retirement, string diagnosticsDirectory, ResidencyBudgetOptions budgets); void RegisterResidencySources( ResidencyManager manager, - WbMeshAdapter meshes, + WbMeshAdapter? meshes, TextureCache textures, IPreparedAssetSource preparedAssets, IAnimationLoader animations, @@ -253,10 +271,29 @@ internal sealed class RetailWorldRenderCompositionFactory public WorldTerrainBuildContext CreateTerrainBuildContext( uint initialCenterLandblockId, float[] heightTable, - TerrainAtlas atlas) + TerrainAtlas? atlas) { int centerX = (int)((initialCenterLandblockId >> 24) & 0xFFu); int centerY = (int)((initialCenterLandblockId >> 16) & 0xFFu); + if (atlas is null) + { + return new WorldTerrainBuildContext( + initialCenterLandblockId, + centerX, + centerY, + heightTable, + new TerrainBlendingContext( + TerrainTypeToLayer: new Dictionary(), + RoadLayer: SurfaceInfo.None, + CornerAlphaLayers: [], + SideAlphaLayers: [], + RoadAlphaLayers: [], + CornerAlphaTCodes: [], + SideAlphaTCodes: [], + RoadAlphaRCodes: []), + new ConcurrentDictionary()); + } + var layers = new Dictionary(atlas.TerrainTypeToLayer.Count); foreach ((uint terrainType, uint layer) in atlas.TerrainTypeToLayer) layers[terrainType] = (byte)layer; @@ -307,10 +344,10 @@ internal sealed class RetailWorldRenderCompositionFactory budgets); public TextureCache CreateTextureCache( - GL gl, + GL? gl, AcDream.App.Rendering.Gpu.IGpuDevice device, IDatReaderWriter dats, - BindlessSupport bindless, + BindlessSupport? bindless, IGpuResourceRetirementQueue retirement, string diagnosticsDirectory, ResidencyBudgetOptions budgets) => @@ -325,13 +362,13 @@ internal sealed class RetailWorldRenderCompositionFactory public void RegisterResidencySources( ResidencyManager manager, - WbMeshAdapter meshes, + WbMeshAdapter? meshes, TextureCache textures, IPreparedAssetSource preparedAssets, IAnimationLoader animations, DatSoundCache? audio) { - meshes.RegisterResidencySources(manager); + meshes?.RegisterResidencySources(manager); textures.RegisterResidencySources(manager); manager.RegisterDomainSource(new DelegateResidencyDomainSource( ResidencyDomain.PreparedPackage, @@ -418,7 +455,7 @@ internal enum WorldRenderCompositionPoint /// internal sealed class WorldRenderCompositionPhase : IWorldRenderCompositionPhase< - GameWindowPlatformResult, + GameWindowPlatformResult, ContentEffectsAudioResult, SettingsDevToolsResult, WorldRenderResult> @@ -443,7 +480,7 @@ internal sealed class WorldRenderCompositionPhase } public WorldRenderResult Compose( - GameWindowPlatformResult platform, + GameWindowPlatformResult platform, ContentEffectsAudioResult content, SettingsDevToolsResult settings) { @@ -454,10 +491,16 @@ internal sealed class WorldRenderCompositionPhase var scope = new CompositionAcquisitionScope(); try { - GL gl = platform.Graphics; + // Campaign V slice V6h: null on a backend with no GL context. Every + // world renderer below is still raw GL, so the Vulkan arm composes + // the RHI-ported subset only — the texture cache's UI path, the + // debug font, the text renderer, the debug lines — and leaves the + // world absent until slices V4t/V4c/V4d land it. + GL? gl = platform.Graphics.Gl; var residency = new ResidencyManager( _dependencies.ResidencyBudgets); - _factory.InitializeGlState(gl); + if (gl is not null) + _factory.InitializeGlState(gl); Fault(WorldRenderCompositionPoint.GlStateInitialized); WorldRegionData region = _factory.LoadRegion(content.Dats); @@ -465,34 +508,44 @@ internal sealed class WorldRenderCompositionPhase _factory.InitializeEnvironment(_dependencies.Environment, region.Region); Fault(WorldRenderCompositionPoint.EnvironmentInitialized); - BindlessSupport bindless = _factory.RequireBindless(gl, _dependencies.Log); - _publication.PublishBindlessSupport(bindless); + BindlessSupport? bindless = gl is null + ? null + : _factory.RequireBindless(gl, _dependencies.Log); + if (bindless is not null) + _publication.PublishBindlessSupport(bindless); Fault(WorldRenderCompositionPoint.BindlessPublished); - TerrainAtlas terrainAtlas = _factory.AcquireTerrainAtlas( - _dependencies.RenderResources, - gl, - content.Dats, - bindless); - _factory.SetTerrainAnisotropic( - terrainAtlas, - settings.ResolvedQuality.AnisotropicLevel); + TerrainAtlas? terrainAtlas = gl is null || bindless is null + ? null + : _factory.AcquireTerrainAtlas( + _dependencies.RenderResources, + gl, + content.Dats, + bindless); + if (terrainAtlas is not null) + { + _factory.SetTerrainAnisotropic( + terrainAtlas, + settings.ResolvedQuality.AnisotropicLevel); + } Fault(WorldRenderCompositionPoint.TerrainAtlasAcquired); string shadersDirectory = Path.Combine( AppContext.BaseDirectory, "Rendering", "Shaders"); - Shader terrainShader = AcquireAndPublish( + Shader? terrainShader = AcquireAndPublishIf( + gl is not null, scope, "terrain shader", - () => _factory.CreateTerrainShader(gl, shadersDirectory), + () => _factory.CreateTerrainShader(gl!, shadersDirectory), _publication.PublishTerrainShader, WorldRenderCompositionPoint.TerrainShaderPublished); - SceneLightingUboBinding sceneLighting = AcquireAndPublish( + SceneLightingUboBinding? sceneLighting = AcquireAndPublishIf( + gl is not null, scope, "scene lighting", - () => _factory.CreateSceneLighting(gl), + () => _factory.CreateSceneLighting(gl!), _publication.PublishSceneLighting, WorldRenderCompositionPoint.SceneLightingPublished); DebugLineRenderer debugLines = AcquireAndPublish( @@ -508,14 +561,15 @@ internal sealed class WorldRenderCompositionPhase (BitmapFont? debugFont, TextRenderer? textRenderer) = ComposeOptionalHudResources(scope, shadersDirectory); - TerrainModernRenderer terrain = AcquireAndPublish( + TerrainModernRenderer? terrain = AcquireAndPublishIf( + gl is not null, scope, "terrain renderer", () => _factory.CreateTerrain( - gl, - bindless, - terrainShader, - terrainAtlas, + gl!, + bindless!, + terrainShader!, + terrainAtlas!, _dependencies.ResourceRetirement), _publication.PublishTerrain, WorldRenderCompositionPoint.TerrainPublished); @@ -531,18 +585,21 @@ internal sealed class WorldRenderCompositionPhase terrainBuild.SurfaceCache); Fault(WorldRenderCompositionPoint.TerrainBuildStatePublished); - Shader meshShader = AcquireAndPublish( + Shader? meshShader = AcquireAndPublishIf( + gl is not null, scope, "mesh shader", - () => _factory.CreateMeshShader(gl, shadersDirectory), + () => _factory.CreateMeshShader(gl!, shadersDirectory), _publication.PublishMeshShader, WorldRenderCompositionPoint.MeshShaderPublished); - _dependencies.Log("[N.5] mesh_modern shader loaded"); - WbMeshAdapter meshAdapter = AcquireAndPublish( + if (meshShader is not null) + _dependencies.Log("[N.5] mesh_modern shader loaded"); + WbMeshAdapter? meshAdapter = AcquireAndPublishIf( + gl is not null, scope, "WB mesh adapter", () => _factory.CreateMeshAdapter( - gl, + gl!, _dependencies.GpuDevice, content.Dats, content.PreparedAssets, @@ -563,10 +620,11 @@ internal sealed class WorldRenderCompositionPhase residency.Budgets), _publication.PublishTextureCache, WorldRenderCompositionPoint.TextureCachePublished); - SamplerCache samplers = AcquireAndPublish( + SamplerCache? samplers = AcquireAndPublishIf( + gl is not null, scope, "sampler cache", - () => _factory.CreateSamplerCache(gl), + () => _factory.CreateSamplerCache(gl!), _publication.PublishSamplerCache, WorldRenderCompositionPoint.SamplerCachePublished); _factory.RegisterResidencySources( @@ -579,8 +637,11 @@ internal sealed class WorldRenderCompositionPhase scope.Complete(); _dependencies.Log( - "[N.4+N.5] WB foundation + modern path active — " + - "routing all content through ObjectMeshManager."); + meshAdapter is not null + ? "[N.4+N.5] WB foundation + modern path active — " + + "routing all content through ObjectMeshManager." + : "[V6h] Vulkan composition host — RHI foundation active " + + "(retained UI, text, debug lines); no world renderers."); return new WorldRenderResult( terrainBuild, new WorldRenderFoundation( @@ -659,6 +720,27 @@ internal sealed class WorldRenderCompositionPhase return value; } + /// + /// Campaign V slice V6h: acquires an owner the active backend may not have. + /// The fault point still fires on both arms so a failure-injection test + /// covers the same ordered sequence whichever backend composed it. + /// + private T? AcquireAndPublishIf( + bool supported, + CompositionAcquisitionScope scope, + string name, + Func factory, + Action publish, + WorldRenderCompositionPoint point) + where T : class, IDisposable + { + T? value = supported + ? scope.Acquire(name, factory, _factory.Release).Publish(publish) + : null; + Fault(point); + return value; + } + private void Fault(WorldRenderCompositionPoint point) => _faultInjection?.Invoke(point); } diff --git a/src/AcDream.App/Diagnostics/WorldLifecycleResourceSnapshotSource.cs b/src/AcDream.App/Diagnostics/WorldLifecycleResourceSnapshotSource.cs index f97d4025..7af0f706 100644 --- a/src/AcDream.App/Diagnostics/WorldLifecycleResourceSnapshotSource.cs +++ b/src/AcDream.App/Diagnostics/WorldLifecycleResourceSnapshotSource.cs @@ -34,9 +34,11 @@ internal sealed class WorldLifecycleResourceSnapshotSource private readonly EntityEffectController _effects; private readonly LiveEntityLightController _lights; private readonly PhysicsScriptRunner _scripts; - private readonly WbMeshAdapter _meshes; + // Campaign V slice V6h: absent on a backend that composes no world + // renderers. Their counters report zero, which is the truth there. + private readonly WbMeshAdapter? _meshes; private readonly TextureCache _textures; - private readonly WbDrawDispatcher _dispatcher; + private readonly WbDrawDispatcher? _dispatcher; private readonly FrameProfiler _frameProfiler; private readonly IDatReaderWriter _dats; private readonly ResidencyManager _residency; @@ -59,9 +61,9 @@ internal sealed class WorldLifecycleResourceSnapshotSource EntityEffectController effects, LiveEntityLightController lights, PhysicsScriptRunner scripts, - WbMeshAdapter meshes, + WbMeshAdapter? meshes, TextureCache textures, - WbDrawDispatcher dispatcher, + WbDrawDispatcher? dispatcher, FrameProfiler frameProfiler, IDatReaderWriter dats, ResidencyManager residency, @@ -86,10 +88,9 @@ internal sealed class WorldLifecycleResourceSnapshotSource _effects = effects ?? throw new ArgumentNullException(nameof(effects)); _lights = lights ?? throw new ArgumentNullException(nameof(lights)); _scripts = scripts ?? throw new ArgumentNullException(nameof(scripts)); - _meshes = meshes ?? throw new ArgumentNullException(nameof(meshes)); + _meshes = meshes; _textures = textures ?? throw new ArgumentNullException(nameof(textures)); - _dispatcher = dispatcher - ?? throw new ArgumentNullException(nameof(dispatcher)); + _dispatcher = dispatcher; _frameProfiler = frameProfiler ?? throw new ArgumentNullException(nameof(frameProfiler)); _dats = dats ?? throw new ArgumentNullException(nameof(dats)); @@ -103,10 +104,15 @@ internal sealed class WorldLifecycleResourceSnapshotSource public WorldLifecycleResourceSnapshot Capture(RenderFrameOutcome outcome) { - ObjectMeshManager meshManager = _meshes.MeshManager - ?? throw new InvalidOperationException( - "Lifecycle snapshots require the composed modern mesh manager."); - var mesh = meshManager.Diagnostics; + // Present on GL, absent on a backend with no world renderers. The mesh + // manager is still required whenever an adapter exists — a composed + // adapter without one is a composition bug, not a backend difference. + ObjectMeshManager? meshManager = _meshes is null + ? null + : _meshes.MeshManager + ?? throw new InvalidOperationException( + "Lifecycle snapshots require the composed modern mesh manager."); + var mesh = meshManager?.Diagnostics ?? default; RenderFrameDiagnosticsSnapshot render = _renderDiagnostics.Snapshot; GCMemoryInfo memory = GC.GetGCMemoryInfo(); ReadOnlySpan generations = memory.GenerationInfo; @@ -125,8 +131,9 @@ internal sealed class WorldLifecycleResourceSnapshotSource CacheStats datObjectCacheStats = _dats is RuntimeDatCollection runtimeDats ? runtimeDats.ObjectCacheStats : default; - CacheStats cpuMeshCacheStats = meshManager.CpuMeshCacheStats; - CacheStats decodedTextureCacheStats = meshManager.DecodedTextureCacheStats; + CacheStats cpuMeshCacheStats = meshManager?.CpuMeshCacheStats ?? default; + CacheStats decodedTextureCacheStats = + meshManager?.DecodedTextureCacheStats ?? default; ResidencySnapshot residency = _residency.CaptureSnapshot(); long trackedGpuBytes = GpuMemoryTracker.AllocatedBytes; long residencyGpuBytes = residency.TotalCharges.PhysicalGpuBytes; @@ -160,8 +167,8 @@ internal sealed class WorldLifecycleResourceSnapshotSource MeshRenderData: mesh.RenderData, MeshAtlasArrays: mesh.AtlasArrays, MeshEstimatedBytes: mesh.EstimatedBytes, - StagedMeshUploads: _meshes.StagedUploadBacklog, - StagedMeshBytes: _meshes.StagedUploadBytes, + StagedMeshUploads: _meshes?.StagedUploadBacklog ?? 0, + StagedMeshBytes: _meshes?.StagedUploadBytes ?? 0, TrackedGpuBytes: trackedGpuBytes, ResidencyGpuBytes: residencyGpuBytes, GpuTrackerMinusResidencyBytes: checked( @@ -173,7 +180,7 @@ internal sealed class WorldLifecycleResourceSnapshotSource ActiveParticleTextures: _textures.ActiveParticleTextureCount, ParticleTextureOwners: _textures.ParticleTextureOwnerCount, CompositeWarmupPending: - _dispatcher.LastCompositeWarmupPendingCount, + _dispatcher?.LastCompositeWarmupPendingCount ?? 0, ManagedBytes: GC.GetTotalMemory(forceFullCollection: false), ManagedCommittedBytes: memory.TotalCommittedBytes, LohSizeBytes: lohSizeBytes, diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 9cdee745..b5cd7916 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -23,7 +23,7 @@ namespace AcDream.App.Rendering; public sealed class GameWindow : IDisposable, - IGameWindowPlatformPublication, + IGameWindowPlatformPublication, IGameWindowHostInputCameraPublication, IGameWindowContentEffectsAudioPublication, IGameWindowSettingsDevToolsPublication, @@ -45,7 +45,16 @@ public sealed class GameWindow : private readonly HostQuiescenceGate _hostQuiescence = new(); private IWindow? _window; private SilkWindowCallbackBinding? _windowCallbacks; - private GL? _gl; + private GameWindowGraphics? _graphics; + // Campaign V slice V6h: borrowed, not owned — _graphics owns the context and + // disposes it. Retained because the render callback has to bring the + // swapchain up to date before a frame opens, which is the one piece of + // presentation the RHI contract deliberately does not cover. + private AcDream.App.Rendering.Gpu.Vk.VulkanGraphicsContext? _vulkanGraphics; + private FramePacingPolicy _startupPacing; + private AcDream.UI.Abstractions.Settings.QualitySettings _startupQuality = + AcDream.UI.Abstractions.Settings.QualitySettings.From( + AcDream.UI.Abstractions.Settings.QualityPreset.High); private IInputContext? _input; private TerrainModernRenderer? _terrain; /// Phase N.5b: terrain_modern.vert/.frag program. Owned by @@ -414,7 +423,14 @@ public sealed class GameWindow : // directly to it during composition. private AcDream.UI.Abstractions.Panels.Debug.DebugVM? _debugVm; // DevToolsEnabled reads through typed RuntimeOptions. - private bool DevToolsEnabled => _options.DevTools; + // + // Campaign V slice V6h: the developer frontend is ImGui, which is not ported + // to Vulkan and which slice V11 deletes outright, so a Vulkan host composes + // none regardless of ACDREAM_DEVTOOLS. The flag still reaches + // VulkanInstanceFactory, where it selects the optional debug-utils + // instance extensions. + private bool DevToolsEnabled => + _options.DevTools && _options.RenderBackend != RenderBackendKind.Vulkan; // Phase G.1-G.2 world lighting/time state. The environment owner keeps // the clock, selected day group, and weather transitions coherent. @@ -680,44 +696,60 @@ public sealed class GameWindow : // attribute, so it must come from this same snapshot rather than a // second settings load during OnLoad. RuntimeSettingsSnapshot startup = _runtimeSettings.Startup; - if (_options.RenderBackend == RenderBackendKind.Vulkan) + if (_options.RenderBackend == RenderBackendKind.Vulkan + && _options.VulkanCapabilityProbe) { - // Campaign V slice V5 — dark Vulkan bring-up. Reached only when - // ACDREAM_RENDER_BACKEND=vulkan; the GL path below executes not one - // new statement. The host owns its own window, device and swapchain, - // and its capability gate throws NotSupportedException into the same - // exit-code-4 contract Program.cs already publishes for GL. - using var vulkan = new AcDream.App.Rendering.Gpu.Vk.VulkanBringUpHost( + // Campaign V slice V6h reduced the V5 bring-up host to what its name + // says: a capability probe. It opens its own window, runs the gate, + // presents the synthetic V6c/V6d verification scenes, and exits — + // useful for answering "does this machine pass the Vulkan gate, and + // does the backend draw?" without the client. The production Vulkan + // path is the composition host below. + using var probe = new AcDream.App.Rendering.Gpu.Vk.VulkanBringUpHost( _options, _platformServices, startup.Display.VSync); - vulkan.Run(); + probe.Run(); return; } FramePacingPolicy startupPacing = _displayFramePacing.InitializeStartup(startup.Display.VSync); - var options = WindowOptions.Default with - { - Size = new Vector2D(1280, 720), - Title = "acdream — phase 1", - API = new GraphicsAPI( - ContextAPI.OpenGL, - ContextProfile.Core, - ContextFlags.ForwardCompatible, - new APIVersion(4, 3)), - VSync = startupPacing.UseVSync, - // A.5 T22.5: MSAA from quality preset (0 = disabled, 2/4/8 = multisample). - // Silk.NET passes this to SDL_GL_SetAttribute(SDL_GL_MULTISAMPLESAMPLES). - // Cannot be changed at runtime; Quality changes mid-session that would - // alter MsaaSamples are logged as a restart-required warning. - Samples = startup.Quality.MsaaSamples, - // #117 (2026-06-11): the aperture punch's depth gate needs a - // stencil buffer (PortalDepthMaskRenderer two-pass mark+punch). - // GLFW defaults to 8 stencil bits, but make the requirement - // explicit rather than platform-implicit. - PreferredStencilBufferBits = 8, - }; + // Campaign V slice V6h: the ONLY startup difference between the two + // backends. Vulkan needs a client-API-less window (the surface comes from + // VK_KHR_surface), and neither MSAA nor the stencil bit count is a window + // attribute there — both are attachment properties the RHI device + // configures, so they are passed to VulkanGraphicsContext instead. + var options = _options.RenderBackend == RenderBackendKind.Vulkan + ? WindowOptions.DefaultVulkan with + { + Size = new Vector2D(1280, 720), + Title = "acdream — Vulkan", + VSync = startupPacing.UseVSync, + } + : WindowOptions.Default with + { + Size = new Vector2D(1280, 720), + Title = "acdream — phase 1", + API = new GraphicsAPI( + ContextAPI.OpenGL, + ContextProfile.Core, + ContextFlags.ForwardCompatible, + new APIVersion(4, 3)), + VSync = startupPacing.UseVSync, + // A.5 T22.5: MSAA from quality preset (0 = disabled, 2/4/8 = multisample). + // Silk.NET passes this to SDL_GL_SetAttribute(SDL_GL_MULTISAMPLESAMPLES). + // Cannot be changed at runtime; Quality changes mid-session that would + // alter MsaaSamples are logged as a restart-required warning. + Samples = startup.Quality.MsaaSamples, + // #117 (2026-06-11): the aperture punch's depth gate needs a + // stencil buffer (PortalDepthMaskRenderer two-pass mark+punch). + // GLFW defaults to 8 stencil bits, but make the requirement + // explicit rather than platform-implicit. + PreferredStencilBufferBits = 8, + }; + _startupPacing = startupPacing; + _startupQuality = startup.Quality; _window = Window.Create(options); _lifetime.PublishNativeWindow(_window); @@ -748,17 +780,21 @@ public sealed class GameWindow : } } - void IGameWindowPlatformPublication.PublishGraphics( - GL graphics) => - PublishCompositionOwner(ref _gl, graphics, "graphics API"); + void IGameWindowPlatformPublication.PublishGraphics( + GameWindowGraphics graphics) => + PublishCompositionOwner(ref _graphics, graphics, "graphics API"); - void IGameWindowPlatformPublication.PublishInput( + void IGameWindowPlatformPublication.PublishInput( IInputContext input) => PublishCompositionOwner(ref _input, input, "input context"); void IGameWindowHostInputCameraPublication.PublishGpuFrameFlights( - GpuFrameFlightController value) => - PublishCompositionOwner(ref _gpuFrameFlights, value, "GPU frame flights"); + GpuFrameFlightController? value) + { + // Null on a backend whose RHI device owns its own frame flights. + if (value is not null) + PublishCompositionOwner(ref _gpuFrameFlights, value, "GPU frame flights"); + } void IGameWindowHostInputCameraPublication.PublishGpuDevice( IGpuDevice value) => @@ -1169,51 +1205,105 @@ public sealed class GameWindow : destination = value; } - [System.Diagnostics.CodeAnalysis.MemberNotNull(nameof(_gl), nameof(_input))] - private GameWindowPlatformResult AcquirePlatform() + [System.Diagnostics.CodeAnalysis.MemberNotNull(nameof(_graphics), nameof(_input))] + private GameWindowPlatformResult AcquirePlatform() { - GameWindowPlatformResult platform = + GameWindowPlatformResult platform = GameWindowPlatformAcquisition.Acquire( - () => GL.GetApi(_window!), - static gl => gl.Dispose(), + CreateGraphics, + static graphics => graphics.Dispose(), () => _window!.CreateInput(), static input => input.Dispose(), this); - if (_gl is null || _input is null) + if (_graphics is null || _input is null) throw new InvalidOperationException( "Platform acquisition returned without publishing both owners."); return platform; } + /// + /// Campaign V slice V6h: how the UI probe reads a completed frame. + /// + /// applies the bottom-up flip + /// glReadPixels needs, so GL hands it the raw read and Vulkan — whose + /// is documented top-left-origin — + /// pre-flips so the two cancel. Routing GL through the same + /// CaptureBackbuffer seam would double-flip, which is exactly the kind + /// of "usually right" instrument §5.5 of the campaign plan spent three + /// slices removing. + /// + private static Func CreateBackbufferReader( + GameWindowGraphics graphics, + IGpuDevice device) => + graphics.Gl is { } gl + ? (width, height) => + AcDream.App.Diagnostics.FrameScreenshotController + .ReadDefaultFramebuffer(gl, width, height) + : (width, height) => + AcDream.App.Diagnostics.FrameScreenshotController.FlipRows( + device.CaptureBackbuffer(width, height), + width, + height); + + /// + /// Campaign V slice V6h: the backend fork, in one expression. Vulkan's + /// context acquisition runs its own capability gate inside + /// , + /// throwing into the same exit-code-4 + /// contract Program.cs publishes for GL. + /// + private GameWindowGraphics CreateGraphics() + { + if (_options.RenderBackend != RenderBackendKind.Vulkan) + return new OpenGlGameWindowGraphics(GL.GetApi(_window!)); + + AcDream.App.Rendering.Gpu.Vk.VulkanGraphicsContext vulkan = + AcDream.App.Rendering.Gpu.Vk.VulkanGraphicsContext.Acquire( + _window!, + _options, + _platformServices, + _startupPacing, + _startupQuality.MsaaSamples, + Console.WriteLine); + _vulkanGraphics = vulkan; + return new VulkanGameWindowGraphics(vulkan); + } + private void OnLoad() { // Task 7: wire the physics data cache into the engine so Transition can // run narrow-phase BSP tests during FindObjCollisions. - GameWindowPlatformResult platform = AcquirePlatform(); - string capabilityReportPath = Path.Combine( - _applicationPaths.DiagnosticsDirectory, - "graphical-capabilities.json"); - _graphicalCapabilities = - GraphicalCapabilityGuard.CaptureVerifyAndWrite( - platform.Graphics, - _window!, - platform.Input, - _platformServices, + GameWindowPlatformResult platform = AcquirePlatform(); + // The GL capability gate reads GL extension strings, so it runs only on + // the GL arm. Vulkan's equivalent gate already ran inside + // VulkanGraphicsContext.Acquire and wrote its own report. + if (platform.Graphics.Gl is { } capabilityGl) + { + string capabilityReportPath = Path.Combine( + _applicationPaths.DiagnosticsDirectory, + "graphical-capabilities.json"); + _graphicalCapabilities = + GraphicalCapabilityGuard.CaptureVerifyAndWrite( + capabilityGl, + _window!, + platform.Input, + _platformServices, + capabilityReportPath); + GraphicalCapabilityGuard.ThrowIfUnsupported( + _graphicalCapabilities, capabilityReportPath); - GraphicalCapabilityGuard.ThrowIfUnsupported( - _graphicalCapabilities, - capabilityReportPath); - Console.WriteLine( - "graphics: capability gate passed " + - $"({_graphicalCapabilities.ActiveDisplayProtocol}, " + - $"{_graphicalCapabilities.GlVendor}, " + - $"{_graphicalCapabilities.GlRenderer}, " + - $"{_graphicalCapabilities.GlVersion}); " + - $"report={capabilityReportPath}"); + Console.WriteLine( + "graphics: capability gate passed " + + $"({_graphicalCapabilities.ActiveDisplayProtocol}, " + + $"{_graphicalCapabilities.GlVendor}, " + + $"{_graphicalCapabilities.GlRenderer}, " + + $"{_graphicalCapabilities.GlVersion}); " + + $"report={capabilityReportPath}"); + } GameWindowCompositionPipeline.Run< - GameWindowPlatformResult, + GameWindowPlatformResult, HostInputCameraResult, ContentEffectsAudioResult, SettingsDevToolsResult, @@ -1322,7 +1412,12 @@ public sealed class GameWindow : new WorldRenderDependencies( _worldEnvironment, _renderResourceLifetime, - _gpuFrameFlights!, + // Campaign V slice V6h: the device's own retirement queue + // rather than the GL frame-flight controller directly. On + // GL these are the same object (GlGpuDevice.Retirement + // returns the controller it was constructed with); on + // Vulkan the device owns its timeline-backed queue. + _gpuDevice!.Retirement, _options.ResidencyBudgets, initialCenterLandblockId, _applicationPaths.DiagnosticsDirectory, @@ -1344,6 +1439,9 @@ public sealed class GameWindow : new InteractionRetainedUiDependencies( _options, platformResult.Graphics, + CreateBackbufferReader( + platformResult.Graphics, + hostInputCamera.GpuDevice), _window!, platformResult.Input, worldRender.Foundation.ShadersDirectory, @@ -1570,12 +1668,30 @@ public sealed class GameWindow : private void OnRender(double deltaSeconds) { Vector2D size = _window!.Size; - _frameGraphs.Render( - new AcDream.App.Rendering.RenderFrameInput( - deltaSeconds, - size.X, - size.Y), - out _); + // Campaign V slice V6h: swapchain currency is the one piece of + // presentation the RHI contract deliberately leaves to the host (plan + // §4.9). A minimised window has no swapchain to render into; an + // out-of-date one is recreated at a frame boundary, the only safe point. + // The handoff below stays exactly one call on both backends. + if (_vulkanGraphics is { } vulkan && !vulkan.PrepareFrame()) + return; + try + { + _frameGraphs.Render( + new AcDream.App.Rendering.RenderFrameInput( + deltaSeconds, + size.X, + size.Y), + out _); + } + catch (AcDream.App.Rendering.Gpu.Vk.VulkanSwapchainOutOfDateException) + when (_vulkanGraphics is not null) + { + _vulkanGraphics.RequestRecreate(); + return; + } + + _vulkanGraphics?.NoteFrameClosed(); } // IsEntityCurrentlyMoving REMOVED (2026-07-09): it powered a cache-bypass @@ -1709,7 +1825,7 @@ public sealed class GameWindow : _dats, _preparedAssets, _input, - _gl)); + _graphics)); private void OnFocusChanged(bool focused) => _cameraPointerInput?.HandleFocusChanged(focused); diff --git a/src/AcDream.App/Rendering/GameWindowLifetime.cs b/src/AcDream.App/Rendering/GameWindowLifetime.cs index 2fc22eb9..60368775 100644 --- a/src/AcDream.App/Rendering/GameWindowLifetime.cs +++ b/src/AcDream.App/Rendering/GameWindowLifetime.cs @@ -132,7 +132,7 @@ internal sealed record PlatformShutdownRoots( IDatReaderWriter? Dats, IPreparedAssetSource? PreparedAssets, IInputContext? Input, - GL? Gl); + GameWindowGraphics? Graphics); internal sealed record GameWindowShutdownRoots( IngressShutdownRoots Ingress, @@ -488,7 +488,7 @@ internal static class GameWindowShutdownManifest ]), new ResourceShutdownStage("OpenGL context", [ - Hard("OpenGL", () => platform.Gl?.Dispose()), + Hard("graphics API", () => platform.Graphics?.Dispose()), ])); } diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanBringUpHost.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanBringUpHost.cs index 632745f5..23687b89 100644 --- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanBringUpHost.cs +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanBringUpHost.cs @@ -1,38 +1,36 @@ using AcDream.App.Diagnostics; using AcDream.App.Platform; using AcDream.App.Rendering; -using Silk.NET.Core.Native; using Silk.NET.Maths; -using Silk.NET.Vulkan; -using Silk.NET.Vulkan.Extensions.KHR; using Silk.NET.Windowing; -using Semaphore = Silk.NET.Vulkan.Semaphore; namespace AcDream.App.Rendering.Gpu.Vk; /// -/// Campaign V slice V5 — Vulkan bring-up, dark. +/// The Vulkan capability-probe and bring-up harness. Reached only when +/// ACDREAM_RENDER_BACKEND=vulkan and ACDREAM_VULKAN_PROBE=1. /// -/// Reached only when ACDREAM_RENDER_BACKEND=vulkan. It opens its own -/// window, creates an instance, a surface, a device and a swapchain, runs the -/// capability gate, and presents a flat clear colour until the window closes. -/// Nothing of the game renders through it — the RHI backend lands at V6 -/// and the default flips at V10. +/// 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. /// -/// Deliberately a self-contained host rather than a branch woven into -/// GameWindow's composition: the composition phases each own GL resources -/// and would have to grow a backend switch apiece for a slice that draws one -/// colour. GameWindow.Run delegates here in four lines and returns, so the -/// GL path executes not one new statement. +/// 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 — device ranking, -/// present-mode mapping, extent and image-count clamping, the capability -/// accept/reject matrix, the report shape, the BGRA swizzle — are unit-tested -/// beside it. The remaining gate is manual: "Vulkan boots to a clear -/// colour." +/// window and a driver. The pure decisions it depends on are unit-tested beside +/// it; the remaining gate is manual. /// -internal sealed unsafe class VulkanBringUpHost : IDisposable +internal sealed class VulkanBringUpHost : IDisposable { /// Two frames in flight, matching plan §4.8 and the GL flight controller. internal const int FlightCount = 2; @@ -46,7 +44,6 @@ internal sealed unsafe class VulkanBringUpHost : IDisposable internal static readonly float[] ClearColor = [0.043f, 0.075f, 0.153f, 1f]; private const string ScreenshotName = "vulkan-bringup"; - private const ulong AcquireTimeoutNanoseconds = 1_000_000_000ul; private readonly RuntimeOptions _options; private readonly GraphicalHostPlatformServices _platform; @@ -54,31 +51,11 @@ internal sealed unsafe class VulkanBringUpHost : IDisposable private readonly Action _log; private IWindow? _window; - private Silk.NET.Vulkan.Vk? _vk; - private Instance _instance; - private KhrSurface? _surfaceApi; - private SurfaceKHR _surface; - private PhysicalDevice _physicalDevice; - private Device _device; - private KhrSwapchain? _swapchainApi; - private Queue _graphicsQueue; - private Queue _presentQueue; - private VulkanQueueFamilyChoice? _families; - private VulkanSwapchain? _swapchain; - - - private ulong _frameSerial; - private bool _recreateAtFrameBoundary; - private bool _disposed; - - // ── Campaign V slice V6c: the RHI backend and the scene that proves it ── - private VulkanGpuDevice? _gpuDevice; + private VulkanGraphicsContext? _graphics; private VulkanRhiScene? _scene; private VulkanRetainedUiScene? _ui; - private VulkanDebugNames _debugNames = VulkanDebugNames.Disabled; - private VulkanDeviceFeatureSupport? _features; - private VulkanDeviceLimitSupport? _limits; - private VulkanFormatSupport? _formats; + private ulong _frameSerial; + private bool _disposed; internal VulkanBringUpHost( RuntimeOptions options, @@ -91,9 +68,8 @@ internal sealed unsafe class VulkanBringUpHost : IDisposable _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 slice V5 consumes only UseVSync: there is no software - // pacer here to feed a limit to, and inventing one would be a number - // nothing reads. V6 wires FramePacingController and supplies it. + // unknown because the harness consumes only UseVSync: there is no + // software pacer here to feed a limit to. _pacing = FramePacingPolicy.Resolve( requestedVSync, _options.UncappedRendering, @@ -101,22 +77,32 @@ internal sealed unsafe class VulkanBringUpHost : IDisposable } /// The record the gate evaluated, available after starts. - internal VulkanCapabilityRecord? Capabilities { get; private set; } + internal VulkanCapabilityRecord? Capabilities => _graphics?.Capabilities; /// - /// Open the window, pass the capability gate, and present the clear colour - /// 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. + /// 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(); - CreateInstanceAndSurface(); - SelectDeviceAndGate(); - CreateFrameResources(); + _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(); } @@ -125,341 +111,38 @@ internal sealed unsafe class VulkanBringUpHost : IDisposable var options = WindowOptions.DefaultVulkan with { Size = new Vector2D(1280, 720), - Title = "acdream — Vulkan bring-up (Campaign V slice V5)", + Title = "acdream — Vulkan capability probe", VSync = _pacing.UseVSync, }; _window = Window.Create(options); _window.Initialize(); - if (_window.VkSurface is null) - { - throw new NotSupportedException( - "The windowing backend did not expose a Vulkan surface. " + - "acdream requires GLFW 3.4 built with Vulkan support."); - } } - private void CreateInstanceAndSurface() + private void CreateScenes() { - IWindow window = _window!; - _vk = Silk.NET.Vulkan.Vk.GetApi(); - - byte** requiredNames = window.VkSurface!.GetRequiredExtensions(out uint requiredCount); - var required = new List((int)requiredCount); - for (uint i = 0; i < requiredCount; i++) - required.Add(VulkanInterop.ReadString(requiredNames[i])); - - VulkanInstanceFactory.Created instance = VulkanInstanceFactory.Create( - _vk, - required, - enableOptionalExtensions: _options.DevTools); - _instance = instance.Instance; - InstanceExtensions = instance.EnabledExtensions; - - if (!_vk.TryGetInstanceExtension(_instance, out KhrSurface surfaceApi)) - { - throw new NotSupportedException( - "VK_KHR_surface is required but its entry points could not be loaded."); - } - - _surfaceApi = surfaceApi; - _surface = window.VkSurface.Create( - _instance.ToHandle(), - null).ToSurface(); - } - - private IReadOnlyList InstanceExtensions { get; set; } = []; - - private void SelectDeviceAndGate() - { - Silk.NET.Vulkan.Vk vk = _vk!; - IReadOnlyList candidates = - VulkanPhysicalDeviceInspector.Enumerate(vk, _instance, out PhysicalDevice[] handles); - VulkanPhysicalDeviceChoice? choice = VulkanPhysicalDeviceSelection.Choose( - candidates, - _options.VulkanDeviceOverride); - if (choice is null) - { - throw new NotSupportedException( - "No Vulkan physical device was enumerated. Install or update a " + - "Vulkan 1.3 driver for this GPU."); - } - - _physicalDevice = handles[choice.Device.Index]; - - IReadOnlyList queueFamilies = - VulkanPhysicalDeviceInspector.ReadQueueFamilies( - vk, - _physicalDevice, - _surfaceApi, - _surface); - VulkanQueueFamilyChoice? families = VulkanQueueFamilySelection.Choose(queueFamilies); - if (families is null) - { - throw new NotSupportedException( - $"'{choice.Device.DeviceName}' exposes no queue family that can both " + - "render and present to the window surface."); - } - - _families = families; - VulkanLogicalDeviceFactory.Created created = VulkanLogicalDeviceFactory.Create( - vk, - _physicalDevice, - families, - requireSwapchain: true); - _device = created.Device; - _graphicsQueue = created.GraphicsQueue; - _presentQueue = created.PresentQueue; - - if (!vk.TryGetDeviceExtension(_instance, _device, out KhrSwapchain swapchainApi)) - { - throw new NotSupportedException( - "VK_KHR_swapchain is required but its entry points could not be loaded."); - } - - _swapchainApi = swapchainApi; - _swapchain = new VulkanSwapchain( - vk, - _surfaceApi!, - swapchainApi, - _physicalDevice, - _device, - _surface, - families); - - (SurfaceCapabilitiesKHR surfaceCapabilities, - IReadOnlyList formats, - IReadOnlyList presentModes) = _swapchain.QuerySurface(); - - Vector2D framebuffer = _window!.FramebufferSize; - VulkanSwapchainConfiguration planned = VulkanSwapchainConfigurationFactory.Create( - surfaceCapabilities, - formats, - presentModes, - _pacing, - (uint)Math.Max(0, framebuffer.X), - (uint)Math.Max(0, framebuffer.Y)); - - var surfaceSupport = new VulkanSurfaceSupport( - PresentSupported: true, - SelectedFormat: planned.ImageFormat, - SelectedColorSpace: planned.ColorSpace, - SelectedPresentMode: planned.PresentMode, - SelectedImageCount: planned.ImageCount, - SelectedWidth: planned.Width, - SelectedHeight: planned.Height, - SupportsTransferSource: - VulkanSwapchainConfigurationFactory.SupportsTransferSource(surfaceCapabilities), - AvailableFormats: [.. formats.Select(format => format.Format).Distinct()], - AvailablePresentModes: [.. presentModes]); - - VulkanFunctionProbeResult probe = VulkanActiveDeviceProbe.Run( - vk, - _physicalDevice, - _device, - _graphicsQueue, - families.GraphicsFamily); - - _features = VulkanPhysicalDeviceInspector.ReadFeatures(vk, _physicalDevice); - _limits = VulkanPhysicalDeviceInspector.ReadLimits(vk, _physicalDevice); - _formats = VulkanPhysicalDeviceInspector.ReadFormats( - vk, - _physicalDevice, - VulkanSwapchainConfigurationFactory.OffersUnormFormat(formats)); - - var record = new VulkanCapabilityRecord( - DateTimeOffset.UtcNow, - _platform.RuntimeIdentifier, - _platform.OperatingSystem, - _platform.WindowBackend.RequestedProtocol, - GlfwNativePlatformProbe.GetActiveProtocol(_platform.OperatingSystem), - VulkanApiVersion.Describe( - VulkanApiVersion.Make( - VulkanCapabilityRequirements.RequiredApiMajor, - VulkanCapabilityRequirements.RequiredApiMinor, - 0)), - VulkanApiVersion.Describe(choice.Device.ApiVersion), - choice.Device.ApiVersion, - choice.Device.DeviceName, - VulkanPhysicalDeviceInspector.DescribeDriver(choice.Device), - choice.Device.DeviceType, - choice.Device.Index, - choice.Reason, - _options.VulkanDeviceOverride, - ForcedUnsupportedFeature: null, - candidates, - InstanceExtensions, - created.EnabledExtensions, - families.GraphicsFamily, - families.PresentFamily, - _features, - _limits, - _formats, - surfaceSupport, - probe, - SupportFailures: []); - - record = VulkanCapabilityRequirements.Reevaluate(record); - record = VulkanCapabilityRequirements.ApplyForcedUnsupported( - record, - _options.VulkanForcedUnsupportedFeature); - Capabilities = record; - - string reportPath = Path.Combine( - _platform.Paths.DiagnosticsDirectory, - VulkanCapabilityGuard.ReportFileName); - VulkanCapabilityReportWriter.Write(reportPath, record); - VulkanCapabilityGuard.ThrowIfUnsupported(record, reportPath); - - _log( - "vulkan: capability gate passed " + - $"({record.ActiveDisplayProtocol}, {record.DeviceName}, " + - $"{record.DeviceApiVersion}, {record.DriverInfo}); " + - $"swapchain {planned.ImageFormat}/{planned.PresentMode} " + - $"{planned.Width}x{planned.Height} x{planned.ImageCount}; " + - $"report={reportPath}"); - _log($"vulkan: device selection — {choice.Reason}"); - } - - private bool RecreateSwapchain() - { - Vector2D framebuffer = _window!.FramebufferSize; - uint width = (uint)Math.Max(0, framebuffer.X); - uint height = (uint)Math.Max(0, framebuffer.Y); - if (VulkanSwapchainRecreationPolicy.OnFramebufferSize(width, height) - == VulkanSwapchainAction.Idle) - { - return false; - } - - VulkanInterop.Check(_vk!.DeviceWaitIdle(_device), "vkDeviceWaitIdle (recreate)"); - _recreateAtFrameBoundary = false; - return _swapchain!.Recreate(_pacing, width, height); - } - - - /// - /// Campaign V slice V6c: build the RHI backend and the scene that proves it. - /// - /// The host's own command pools, acquire semaphores and timeline are - /// gone — owns all three now, because a frame - /// recorded through the contract has to be the same frame that presents. The - /// host keeps exactly what the contract deliberately does not cover: - /// swapchain configuration and the OUT_OF_DATE/SUBOPTIMAL policy, both of - /// which are slice V5's pure, unit-tested decisions. - /// - private void CreateFrameResources() - { - Silk.NET.Vulkan.Vk vk = _vk!; - _debugNames = VulkanDebugNames.Create(vk, _instance, _device, [.. InstanceExtensions]); - - if (!RecreateSwapchain()) - { - throw new InvalidOperationException( - "The swapchain could not be created for the initial framebuffer size."); - } - - VulkanSwapchainConfiguration configuration = _swapchain!.Configuration!; - _gpuDevice = new VulkanGpuDevice( - vk, - _physicalDevice, - _device, - _graphicsQueue, - _presentQueue, - _families!.GraphicsFamily, - _features!, - _limits!, - _formats!, - Capabilities!.DeviceName, - Capabilities.DriverInfo, - Capabilities.DeviceApiVersion, - _debugNames, - new SwapchainBackbuffer(_swapchain!, _presentQueue), - ShaderSpirvDirectory(), - _platform.Paths.CacheDirectory, - // Slice V6g: a frame can only be read back while it still owns its - // swapchain image, so retention has to be armed before the first - // frame rather than at the moment a screenshot is asked for. Armed - // exactly when an artifact directory exists, which is what a gate - // run has and a player run does not. - retainBackbufferCapture: !string.IsNullOrWhiteSpace(_options.AutomationArtifactDirectory)); - - // 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. - int sampleCount = (int)Math.Min(4u, Math.Max(1u, _gpuDevice.Capabilities.MaxSampleCount)); - _gpuDevice.ConfigureBackbufferAttachments( - configuration.Width, - configuration.Height, - configuration.ImageFormat, - sampleCount); - - _scene = new VulkanRhiScene(_gpuDevice, sampleCount); - _log( - $"vulkan: RHI backend up — {_gpuDevice.Allocator.Describe()}, " + - $"{sampleCount}x MSAA, pipeline cache " + - (_gpuDevice.PipelineCacheLoadedFromDisk ? "reused" : "cold") + - $", debug names {(_debugNames.IsEnabled ? "on" : "off")}"); - - // Campaign V slice V6d: the first production renderers on Vulkan. The - // retained UI and the debug lines draw here through exactly the classes - // the GL client uses — the scene only supplies a widget tree and its - // sprites, because the retail tree's chrome still comes from a GL-only - // TextureCache until V4t. - _ui = new VulkanRetainedUiScene(_gpuDevice, ShaderSpirvDirectory()); + 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)")); } - /// Where the committed SPIR-V lives beside the binary. - private static string ShaderSpirvDirectory() => - Path.Combine(AppContext.BaseDirectory, "Rendering", "Shaders", "spv"); - /// - /// Adapts slice V5's swapchain to the narrow surface the RHI device needs. - /// The device deliberately does not own presentation: format, extent, - /// present-mode and the recreation policy are pure decisions that are - /// already unit-tested, and duplicating that judgement inside the backend - /// would fork it. - /// - private sealed class SwapchainBackbuffer(VulkanSwapchain swapchain, Queue presentQueue) : IVulkanBackbuffer - { - public Format ImageFormat => swapchain.Configuration!.ImageFormat; - - public uint Width => swapchain.Configuration!.Width; - - public uint Height => swapchain.Configuration!.Height; - - public bool TryAcquire(Semaphore acquired, out uint imageIndex) - { - VulkanSwapchainAction action = swapchain.TryAcquire( - acquired, - AcquireTimeoutNanoseconds, - out imageIndex); - return action is VulkanSwapchainAction.Continue - or VulkanSwapchainAction.RecreateAtFrameBoundary; - } - - public Image ImageAt(uint imageIndex) => swapchain.ImageAt(imageIndex); - - public ImageView ViewAt(uint imageIndex) => swapchain.ViewAt(imageIndex); - - public Semaphore RenderCompleteAt(uint imageIndex) => swapchain.RenderCompleteAt(imageIndex); - - public bool Present(uint imageIndex) => - swapchain.Present(presentQueue, imageIndex) is VulkanSwapchainAction.Continue; - } - - /// - /// The frame loop: record the verification scene through the RHI, present, + /// The frame loop: record the verification scenes through the RHI, present, /// and capture one screenshot once the scene has settled. /// private void Present() { IWindow window = _window!; - VulkanGpuDevice device = _gpuDevice!; + VulkanGraphicsContext graphics = _graphics!; + VulkanGpuDevice device = graphics.Device; VulkanRhiScene scene = _scene!; FrameScreenshotController? screenshots = CreateScreenshotController(); bool screenshotRequested = false; @@ -471,45 +154,33 @@ internal sealed unsafe class VulkanBringUpHost : IDisposable if (window.IsClosing) break; - if (_recreateAtFrameBoundary || !_swapchain!.IsCreated) + if (!graphics.PrepareFrame()) { - if (!RecreateSwapchain()) - { - // Minimised: idle without burning a core, and without - // pretending a zero-area swapchain can be created. - Thread.Sleep(16); - continue; - } - - VulkanSwapchainConfiguration resized = _swapchain!.Configuration!; - device.ConfigureBackbufferAttachments( - resized.Width, - resized.Height, - resized.ImageFormat, - scene.SampleCount); + // 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) { - _recreateAtFrameBoundary = true; + graphics.RequestRecreate(); continue; } - VulkanSwapchainConfiguration configuration = _swapchain!.Configuration!; double elapsed = (DateTimeOffset.UtcNow - started).TotalSeconds; using (frame) { - scene.Render(frame, configuration.Width, configuration.Height, elapsed); + 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 GL client's HUD - // phase has, and the reason the multisampled pass must resolve - // rather than store. - _ui?.Render(frame, configuration.Width, configuration.Height, elapsed); + // 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; - if (!device.PresentSucceeded) - _recreateAtFrameBoundary = true; + 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. @@ -518,7 +189,7 @@ internal sealed unsafe class VulkanBringUpHost : IDisposable screenshotRequested = true; if (screenshots.TryRequest(ScreenshotName, out string error)) { - screenshots.CapturePending((int)configuration.Width, (int)configuration.Height); + screenshots.CapturePending((int)graphics.Width, (int)graphics.Height); ReportTimings(device); } else @@ -528,7 +199,7 @@ internal sealed unsafe class VulkanBringUpHost : IDisposable } } - VulkanInterop.Check(_vk!.DeviceWaitIdle(_device), "vkDeviceWaitIdle (shutdown)"); + device.WaitIdle(); _log($"vulkan: presented {_frameSerial} RHI frame(s); shutting down."); } @@ -558,11 +229,9 @@ internal sealed unsafe class VulkanBringUpHost : IDisposable // 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 — and it - // routes the screenshot through the RHI capture path, which is the - // thing slice V6c has to prove rather than assume. + // here makes the two cancel, so the PNG is right-side-up. (width, height) => FrameScreenshotController.FlipRows( - _gpuDevice!.CaptureBackbuffer(width, height), + _graphics!.Device.CaptureBackbuffer(width, height), width, height), _options.AutomationArtifactDirectory, @@ -570,10 +239,10 @@ internal sealed unsafe class VulkanBringUpHost : IDisposable } /// - /// Teardown in strict reverse-construction order. Every handle is checked - /// before destruction because can throw at any stage — a - /// rejected capability gate is a normal, expected exit, not a crash, and it - /// must still leave zero Vulkan objects behind. + /// 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() { @@ -581,62 +250,12 @@ internal sealed unsafe class VulkanBringUpHost : IDisposable return; _disposed = true; - Silk.NET.Vulkan.Vk? vk = _vk; - if (vk is not null && _device.Handle != 0) - { - vk.DeviceWaitIdle(_device); - - // Scene before device: the scene owns 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. - _ui?.Dispose(); - _ui = null; - _scene?.Dispose(); - _scene = null; - _gpuDevice?.Dispose(); - _gpuDevice = null; - - _swapchain?.Dispose(); - _swapchain = null; - - vk.DestroyDevice(_device, null); - _device = default; - } - else - { - _ui?.Dispose(); - _ui = null; - _scene?.Dispose(); - _scene = null; - _gpuDevice?.Dispose(); - _gpuDevice = null; - _swapchain?.Dispose(); - _swapchain = null; - } - - _debugNames.Dispose(); - _debugNames = VulkanDebugNames.Disabled; - - if (vk is not null && _surfaceApi is not null && _surface.Handle != 0) - { - _surfaceApi.DestroySurface(_instance, _surface, null); - _surface = default; - } - - _swapchainApi?.Dispose(); - _swapchainApi = null; - _surfaceApi?.Dispose(); - _surfaceApi = null; - - if (vk is not null && _instance.Handle != 0) - { - vk.DestroyInstance(_instance, null); - _instance = default; - } - - vk?.Dispose(); - _vk = null; - + _ui?.Dispose(); + _ui = null; + _scene?.Dispose(); + _scene = null; + _graphics?.Dispose(); + _graphics = null; _window?.Dispose(); _window = null; } diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanCompositionFramePhases.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanCompositionFramePhases.cs new file mode 100644 index 00000000..2c785047 --- /dev/null +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanCompositionFramePhases.cs @@ -0,0 +1,239 @@ +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Vfx; +using AcDream.App.Streaming; +using AcDream.App.World; +using AcDream.Core.World; + +namespace AcDream.App.Rendering.Gpu.Vk; + +/// +/// Campaign V slice V6h: the Vulkan arm of the frame spine's clear phase. +/// +/// The GL arm establishes frame-global capability state and issues +/// glClear against framebuffer 0. Vulkan has neither: a clear is a pass +/// load-op, and there is no global state to restore. So this opens one +/// backbuffer pass with , draws nothing, and closes +/// it — which resolves the multisampled scratch image into the acquired +/// swapchain image and leaves it in exactly the state the retained UI's own +/// load/store pass expects. +/// +/// It computes the same the GL arm +/// does, from the same world clock and weather owners, so every consumer +/// downstream — portal viewport visibility, sky keyframe, atmosphere — reads +/// identical values on both backends. +/// +internal sealed class VulkanRenderFrameClearPhase : IRenderFrameClearPhase +{ + private readonly ICurrentGpuFrameSource _frames; + private readonly WorldTimeService _worldTime; + private readonly WeatherSystem _weather; + private readonly IRenderFramePortalStateSource _portal; + private readonly ParticleVisibilityController _particleVisibility; + private readonly Func _sampleCount; + + public VulkanRenderFrameClearPhase( + ICurrentGpuFrameSource frames, + WorldTimeService worldTime, + WeatherSystem weather, + IRenderFramePortalStateSource portal, + ParticleVisibilityController particleVisibility, + Func sampleCount) + { + _frames = frames ?? throw new ArgumentNullException(nameof(frames)); + _worldTime = worldTime ?? throw new ArgumentNullException(nameof(worldTime)); + _weather = weather ?? throw new ArgumentNullException(nameof(weather)); + _portal = portal ?? throw new ArgumentNullException(nameof(portal)); + _particleVisibility = particleVisibility + ?? throw new ArgumentNullException(nameof(particleVisibility)); + _sampleCount = sampleCount ?? throw new ArgumentNullException(nameof(sampleCount)); + } + + public RenderFrameFoundation Clear() + { + bool portalViewportVisible = _portal.IsPortalViewportVisible; + if (portalViewportVisible) + _particleVisibility.Reset(); + + SkyKeyframe sky = _worldTime.CurrentSky; + AtmosphereSnapshot atmosphere = _weather.Snapshot(in sky); + // SceneTool::BeginScene @ 0x0043DAD0 starts the replacement CreatureMode + // frame with an opaque black target; otherwise the fog colour is the + // frame's ground truth, exactly as on GL. + Vector4 clear = portalViewportVisible + ? new Vector4(0f, 0f, 0f, 1f) + : new Vector4( + Math.Clamp(atmosphere.FogColor.X, 0f, 1f), + Math.Clamp(atmosphere.FogColor.Y, 0f, 1f), + Math.Clamp(atmosphere.FogColor.Z, 0f, 1f), + 1f); + + if (_frames.CurrentFrame is { } frame) + { + using IGpuPassEncoder pass = frame.BeginPass( + GpuPassDescription.BackbufferClear( + "vk-frame-clear", + clear, + _sampleCount())); + } + + return new RenderFrameFoundation(portalViewportVisible, sky, atmosphere); + } +} + +/// +/// Campaign V slice V6h: the Vulkan arm's world-scene phase. +/// +/// There is nothing to draw. Every world renderer — terrain, statics, +/// EnvCells, sky, particles, the portal depth mask — is still raw GL, and the +/// slice that ports them is V4t plus the world arm behind it. The phase exists +/// so the frame spine's contract is identical on both backends: the world phase +/// runs, reports what it drew, and the private-presentation phase composites the +/// retained UI over whatever it left behind. +/// +/// Reporting default — zero visible, zero total, world not drawn — +/// is the honest answer and is what the lifecycle artifacts record. +/// +internal sealed class VulkanWorldScenePhase : IWorldSceneFramePhase +{ + public static VulkanWorldScenePhase Instance { get; } = new(); + + private VulkanWorldScenePhase() + { + } + + public WorldRenderFrameOutcome Render(RenderFrameInput input) => default; +} + +/// +/// Campaign V slice V6h: no GPU-timer bracket on the Vulkan arm. +/// +/// drives +/// FrameProfiler's GL query ring, which is a GL-only instrument. +/// is its backend-neutral replacement and the +/// frame spine adopts it at slice V4h; until then the Vulkan arm reports no GPU +/// samples rather than reporting wrong ones. +/// +internal sealed class NullRenderFrameGpuMeasurement : IRenderFrameGpuMeasurement +{ + public static NullRenderFrameGpuMeasurement Instance { get; } = new(); + + private NullRenderFrameGpuMeasurement() + { + } + + public void BeginFrame() + { + } + + public void EndFrame() + { + } +} + +/// +/// Campaign V slice V6h: the mesh backend on a backend that has none. +/// +/// WbMeshAdapter owns an OpenGLGraphicsDevice, so it is not +/// constructible on Vulkan until slice V4t. The landblock spawn ledger and the +/// world state that drives it are backend-neutral and must keep running — they +/// are how streaming residence is tracked — so they register against this +/// instead. Reference counting is a no-op because there is nothing to count, +/// and answers true because a mesh that is never +/// going to be drawn is never pending. +/// +internal sealed class NullWbMeshAdapter : AcDream.App.Rendering.Wb.IWbMeshAdapter +{ + public static NullWbMeshAdapter Instance { get; } = new(); + + private NullWbMeshAdapter() + { + } + + public void IncrementRefCount(ulong id) + { + } + + public void DecrementRefCount(ulong id) + { + } + + public void PinPreparedRenderData(ulong id) + { + } + + public bool IsRenderDataReady(ulong id) => true; +} + +/// +/// Campaign V slice V6h: the portal viewport on a backend with no portal tunnel. +/// +/// PortalTunnelPresentation is a raw-GL renderer, so the Vulkan arm +/// composes none and the teleport controller drives this instead. Every state +/// query answers "no tunnel is showing", which is true, and keeps the +/// portal-space lifecycle's own invariants — reveal generation, destination +/// latch, wait cue — running unchanged in Runtime. +/// +internal sealed class NullLocalPlayerTeleportPresentation + : ILocalPlayerTeleportPresentation +{ + private readonly TeleportAnimSequencer _animation = new(); + private readonly TeleportViewPlaneController _viewPlane = new(); + + /// Always false: with no tunnel renderer there is no replacement viewport. + public bool IsPortalViewportVisible => false; + + public int CurrentTunnelFrame => 0; + + public void Begin(Matrix4x4 projection) + { + _viewPlane.Begin(projection); + _animation.Begin(TeleportEntryKind.Portal); + } + + public (TeleportAnimSnapshot Snapshot, IReadOnlyList Events) + Tick(float deltaSeconds, bool worldReady) + { + var (snapshot, events) = _animation.Tick( + deltaSeconds, + worldReady, + CurrentTunnelFrame); + _viewPlane.Update(snapshot); + return (snapshot, events); + } + + public void TickTunnel(float deltaSeconds) + { + } + + public void EnterTunnel() + { + } + + public void ExitTunnel() + { + } + + public void SetWaitCue(bool visible) + { + } + + public void Reset() + { + _animation.Reset(); + _viewPlane.Reset(); + } + + public Matrix4x4 ApplyViewPlane(Matrix4x4 projection) => + _viewPlane.Apply(projection); + + public ICamera ApplyViewPlane(ICamera camera) => _viewPlane.ApplyTo(camera); + + public void DrawPortalViewport(int width, int height, Matrix4x4 projection) + { + } + + public void Dispose() + { + } +} diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuPassEncoder.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuPassEncoder.cs index 48cec39e..d3dcbd9d 100644 --- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuPassEncoder.cs +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuPassEncoder.cs @@ -63,6 +63,24 @@ internal sealed unsafe class VulkanGpuPassEncoder : IGpuPassEncoder // draw, so the full-attachment default is the only safe starting point. SetViewport(0, 0, (int)attachmentWidth, (int)attachmentHeight); SetScissor(0, 0, (int)attachmentWidth, (int)attachmentHeight); + + // Campaign V slice V6h: and for the same reason, the descriptor sets. + // + // Before this, sets 0/1/2 were bound only as a side effect of + // BindStorageBuffer/BindUniformBuffer, so a pass whose pipeline reads the + // texture table but binds no buffer — every retained-UI and debug-line + // pass, because their per-draw data travels in push constants and a + // vertex buffer — issued vkCmdDraw with set 2 unbound. That is + // VUID-vkCmdDraw-None-08600 and, on the RX 9070 XT, an immediate + // ErrorDeviceLost at submit. + // + // It went unseen through V6c–V6g because the bring-up host always drew + // VulkanRhiScene first: its storage binds left all three sets bound in + // the same command buffer, so the UI pass that followed inherited them. + // The composition host has no 3-D scene, so its UI pass is the first + // thing in the buffer and inherits nothing. Binding here makes a pass + // self-contained rather than dependent on what preceded it in the frame. + _bindings.Bind(_commands, _device); } public GpuPassDescription Pass { get; } diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGraphicsContext.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGraphicsContext.cs new file mode 100644 index 00000000..0441ada8 --- /dev/null +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGraphicsContext.cs @@ -0,0 +1,507 @@ +using AcDream.App.Platform; +using AcDream.App.Rendering; +using Silk.NET.Core.Native; +using Silk.NET.Maths; +using Silk.NET.Vulkan; +using Silk.NET.Vulkan.Extensions.KHR; +using Silk.NET.Windowing; +using Semaphore = Silk.NET.Vulkan.Semaphore; + +namespace AcDream.App.Rendering.Gpu.Vk; + +/// +/// Campaign V slice V6h: everything a Vulkan-backed host owns between a window +/// and the RHI — instance, surface, physical-device choice, logical device, +/// queues, swapchain, the capability gate, and . +/// +/// Extracted verbatim from VulkanBringUpHost, which was a second +/// main(): the same sequence now serves the real composition host, and the +/// bring-up harness consumes it too, so the acquisition order that was proven at +/// V5/V6c is executed by exactly one piece of code rather than two. +/// +/// The swapchain deliberately stays here rather than inside +/// : format, extent, present-mode and the +/// OUT_OF_DATE/SUBOPTIMAL policy are slice V5's pure, unit-tested decisions, and +/// the RHI contract has nothing to say about presentation. The device borrows the +/// swapchain through . +/// +internal sealed unsafe class VulkanGraphicsContext : IDisposable +{ + private const ulong AcquireTimeoutNanoseconds = 1_000_000_000ul; + + private readonly IWindow _window; + private readonly RuntimeOptions _options; + private readonly GraphicalHostPlatformServices _platform; + private readonly FramePacingPolicy _pacing; + private readonly Action _log; + + private Silk.NET.Vulkan.Vk? _vk; + private Instance _instance; + private KhrSurface? _surfaceApi; + private SurfaceKHR _surface; + private PhysicalDevice _physicalDevice; + private Device _device; + private KhrSwapchain? _swapchainApi; + private Queue _graphicsQueue; + private Queue _presentQueue; + private VulkanQueueFamilyChoice? _families; + private VulkanSwapchain? _swapchain; + private VulkanGpuDevice? _gpuDevice; + private VulkanDebugNames _debugNames = VulkanDebugNames.Disabled; + private VulkanDeviceFeatureSupport? _features; + private VulkanDeviceLimitSupport? _limits; + private VulkanFormatSupport? _formats; + private IReadOnlyList _instanceExtensions = []; + private bool _recreateAtFrameBoundary; + private bool _disposed; + + private VulkanGraphicsContext( + IWindow window, + RuntimeOptions options, + GraphicalHostPlatformServices platform, + FramePacingPolicy pacing, + Action log) + { + _window = window ?? throw new ArgumentNullException(nameof(window)); + _options = options ?? throw new ArgumentNullException(nameof(options)); + _platform = platform ?? throw new ArgumentNullException(nameof(platform)); + _pacing = pacing; + _log = log ?? throw new ArgumentNullException(nameof(log)); + } + + /// + /// Opens the whole stack against an already-initialised Vulkan window. + /// Throws when the capability gate + /// rejects the device, which Program.cs turns into exit code 4 exactly + /// as it does for GL. Any failure leaves zero Vulkan objects behind. + /// + /// + /// MSAA samples asked for by the quality preset. Clamped to what the device + /// reports; 1 disables the multisampled scratch image entirely. + /// + internal static VulkanGraphicsContext Acquire( + IWindow window, + RuntimeOptions options, + GraphicalHostPlatformServices platform, + FramePacingPolicy pacing, + int requestedSampleCount, + Action? log = null) + { + var context = new VulkanGraphicsContext( + window, + options, + platform, + pacing, + log ?? Console.WriteLine); + try + { + context.CreateInstanceAndSurface(); + context.SelectDeviceAndGate(); + context.CreateDevice(requestedSampleCount); + return context; + } + catch + { + context.Dispose(); + throw; + } + } + + /// The record the gate evaluated. Non-null once returns. + internal VulkanCapabilityRecord? Capabilities { get; private set; } + + /// The RHI device every renderer is constructed against. + internal VulkanGpuDevice Device => + _gpuDevice ?? throw new InvalidOperationException( + "The Vulkan RHI device has not been created."); + + /// Samples the backbuffer pass renders with. 1 when MSAA is off or unsupported. + internal int SampleCount { get; private set; } = 1; + + internal uint Width => _swapchain?.Configuration?.Width ?? 0u; + + internal uint Height => _swapchain?.Configuration?.Height ?? 0u; + + /// Where the committed SPIR-V lives beside the binary. + internal static string ShaderSpirvDirectory() => + Path.Combine(AppContext.BaseDirectory, "Rendering", "Shaders", "spv"); + + /// + /// Brings the swapchain up to date with the current framebuffer size before a + /// frame is opened. Returns false when the window is minimised — there is no + /// zero-area swapchain to create, so the caller skips the frame. + /// + internal bool PrepareFrame() + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (!_recreateAtFrameBoundary && _swapchain!.IsCreated) + return true; + + if (!RecreateSwapchain()) + return false; + + VulkanSwapchainConfiguration resized = _swapchain!.Configuration!; + Device.ConfigureBackbufferAttachments( + resized.Width, + resized.Height, + resized.ImageFormat, + SampleCount); + return true; + } + + /// + /// Records the outcome of the frame the caller just closed. A failed present + /// (OUT_OF_DATE/SUBOPTIMAL) arms recreation at the next frame boundary rather + /// than mid-frame, which is the only point at which it is safe. + /// + internal void NoteFrameClosed() + { + if (_gpuDevice is not null && !_gpuDevice.PresentSucceeded) + _recreateAtFrameBoundary = true; + } + + /// Arms recreation, used when the acquire itself reported out-of-date. + internal void RequestRecreate() => _recreateAtFrameBoundary = true; + + private void CreateInstanceAndSurface() + { + _vk = Silk.NET.Vulkan.Vk.GetApi(); + if (_window.VkSurface is null) + { + throw new NotSupportedException( + "The windowing backend did not expose a Vulkan surface. " + + "acdream requires GLFW 3.4 built with Vulkan support."); + } + + byte** requiredNames = _window.VkSurface.GetRequiredExtensions(out uint requiredCount); + var required = new List((int)requiredCount); + for (uint i = 0; i < requiredCount; i++) + required.Add(VulkanInterop.ReadString(requiredNames[i])); + + VulkanInstanceFactory.Created instance = VulkanInstanceFactory.Create( + _vk, + required, + enableOptionalExtensions: _options.DevTools); + _instance = instance.Instance; + _instanceExtensions = instance.EnabledExtensions; + + if (!_vk.TryGetInstanceExtension(_instance, out KhrSurface surfaceApi)) + { + throw new NotSupportedException( + "VK_KHR_surface is required but its entry points could not be loaded."); + } + + _surfaceApi = surfaceApi; + _surface = _window.VkSurface.Create( + _instance.ToHandle(), + null).ToSurface(); + } + + private void SelectDeviceAndGate() + { + Silk.NET.Vulkan.Vk vk = _vk!; + IReadOnlyList candidates = + VulkanPhysicalDeviceInspector.Enumerate(vk, _instance, out PhysicalDevice[] handles); + VulkanPhysicalDeviceChoice? choice = VulkanPhysicalDeviceSelection.Choose( + candidates, + _options.VulkanDeviceOverride); + if (choice is null) + { + throw new NotSupportedException( + "No Vulkan physical device was enumerated. Install or update a " + + "Vulkan 1.3 driver for this GPU."); + } + + _physicalDevice = handles[choice.Device.Index]; + + IReadOnlyList queueFamilies = + VulkanPhysicalDeviceInspector.ReadQueueFamilies( + vk, + _physicalDevice, + _surfaceApi, + _surface); + VulkanQueueFamilyChoice? families = VulkanQueueFamilySelection.Choose(queueFamilies); + if (families is null) + { + throw new NotSupportedException( + $"'{choice.Device.DeviceName}' exposes no queue family that can both " + + "render and present to the window surface."); + } + + _families = families; + VulkanLogicalDeviceFactory.Created created = VulkanLogicalDeviceFactory.Create( + vk, + _physicalDevice, + families, + requireSwapchain: true); + _device = created.Device; + _graphicsQueue = created.GraphicsQueue; + _presentQueue = created.PresentQueue; + + if (!vk.TryGetDeviceExtension(_instance, _device, out KhrSwapchain swapchainApi)) + { + throw new NotSupportedException( + "VK_KHR_swapchain is required but its entry points could not be loaded."); + } + + _swapchainApi = swapchainApi; + _swapchain = new VulkanSwapchain( + vk, + _surfaceApi!, + swapchainApi, + _physicalDevice, + _device, + _surface, + families); + + (SurfaceCapabilitiesKHR surfaceCapabilities, + IReadOnlyList formats, + IReadOnlyList presentModes) = _swapchain.QuerySurface(); + + Vector2D framebuffer = _window.FramebufferSize; + VulkanSwapchainConfiguration planned = VulkanSwapchainConfigurationFactory.Create( + surfaceCapabilities, + formats, + presentModes, + _pacing, + (uint)Math.Max(0, framebuffer.X), + (uint)Math.Max(0, framebuffer.Y)); + + var surfaceSupport = new VulkanSurfaceSupport( + PresentSupported: true, + SelectedFormat: planned.ImageFormat, + SelectedColorSpace: planned.ColorSpace, + SelectedPresentMode: planned.PresentMode, + SelectedImageCount: planned.ImageCount, + SelectedWidth: planned.Width, + SelectedHeight: planned.Height, + SupportsTransferSource: + VulkanSwapchainConfigurationFactory.SupportsTransferSource(surfaceCapabilities), + AvailableFormats: [.. formats.Select(format => format.Format).Distinct()], + AvailablePresentModes: [.. presentModes]); + + VulkanFunctionProbeResult probe = VulkanActiveDeviceProbe.Run( + vk, + _physicalDevice, + _device, + _graphicsQueue, + families.GraphicsFamily); + + _features = VulkanPhysicalDeviceInspector.ReadFeatures(vk, _physicalDevice); + _limits = VulkanPhysicalDeviceInspector.ReadLimits(vk, _physicalDevice); + _formats = VulkanPhysicalDeviceInspector.ReadFormats( + vk, + _physicalDevice, + VulkanSwapchainConfigurationFactory.OffersUnormFormat(formats)); + + var record = new VulkanCapabilityRecord( + DateTimeOffset.UtcNow, + _platform.RuntimeIdentifier, + _platform.OperatingSystem, + _platform.WindowBackend.RequestedProtocol, + GlfwNativePlatformProbe.GetActiveProtocol(_platform.OperatingSystem), + VulkanApiVersion.Describe( + VulkanApiVersion.Make( + VulkanCapabilityRequirements.RequiredApiMajor, + VulkanCapabilityRequirements.RequiredApiMinor, + 0)), + VulkanApiVersion.Describe(choice.Device.ApiVersion), + choice.Device.ApiVersion, + choice.Device.DeviceName, + VulkanPhysicalDeviceInspector.DescribeDriver(choice.Device), + choice.Device.DeviceType, + choice.Device.Index, + choice.Reason, + _options.VulkanDeviceOverride, + ForcedUnsupportedFeature: null, + candidates, + _instanceExtensions, + created.EnabledExtensions, + families.GraphicsFamily, + families.PresentFamily, + _features, + _limits, + _formats, + surfaceSupport, + probe, + SupportFailures: []); + + record = VulkanCapabilityRequirements.Reevaluate(record); + record = VulkanCapabilityRequirements.ApplyForcedUnsupported( + record, + _options.VulkanForcedUnsupportedFeature); + Capabilities = record; + + string reportPath = Path.Combine( + _platform.Paths.DiagnosticsDirectory, + VulkanCapabilityGuard.ReportFileName); + VulkanCapabilityReportWriter.Write(reportPath, record); + VulkanCapabilityGuard.ThrowIfUnsupported(record, reportPath); + + _log( + "vulkan: capability gate passed " + + $"({record.ActiveDisplayProtocol}, {record.DeviceName}, " + + $"{record.DeviceApiVersion}, {record.DriverInfo}); " + + $"swapchain {planned.ImageFormat}/{planned.PresentMode} " + + $"{planned.Width}x{planned.Height} x{planned.ImageCount}; " + + $"report={reportPath}"); + _log($"vulkan: device selection — {choice.Reason}"); + } + + private void CreateDevice(int requestedSampleCount) + { + Silk.NET.Vulkan.Vk vk = _vk!; + _debugNames = VulkanDebugNames.Create(vk, _instance, _device, [.. _instanceExtensions]); + + if (!RecreateSwapchain()) + { + throw new InvalidOperationException( + "The swapchain could not be created for the initial framebuffer size."); + } + + VulkanSwapchainConfiguration configuration = _swapchain!.Configuration!; + _gpuDevice = new VulkanGpuDevice( + vk, + _physicalDevice, + _device, + _graphicsQueue, + _presentQueue, + _families!.GraphicsFamily, + _features!, + _limits!, + _formats!, + Capabilities!.DeviceName, + Capabilities.DriverInfo, + Capabilities.DeviceApiVersion, + _debugNames, + new SwapchainBackbuffer(_swapchain!, _presentQueue), + ShaderSpirvDirectory(), + _platform.Paths.CacheDirectory, + // Slice V6g: a frame can only be read back while it still owns its + // swapchain image, so retention has to be armed before the first + // frame rather than at the moment a screenshot is asked for. Armed + // exactly when an artifact directory exists, which is what a gate + // run has and a player run does not. + retainBackbufferCapture: + !string.IsNullOrWhiteSpace(_options.AutomationArtifactDirectory)); + + SampleCount = (int)Math.Min( + (uint)Math.Max(1, requestedSampleCount), + Math.Max(1u, _gpuDevice.Capabilities.MaxSampleCount)); + _gpuDevice.ConfigureBackbufferAttachments( + configuration.Width, + configuration.Height, + configuration.ImageFormat, + SampleCount); + + _log( + $"vulkan: RHI backend up — {_gpuDevice.Allocator.Describe()}, " + + $"{SampleCount}x MSAA, pipeline cache " + + (_gpuDevice.PipelineCacheLoadedFromDisk ? "reused" : "cold") + + $", debug names {(_debugNames.IsEnabled ? "on" : "off")}"); + } + + private bool RecreateSwapchain() + { + Vector2D framebuffer = _window.FramebufferSize; + uint width = (uint)Math.Max(0, framebuffer.X); + uint height = (uint)Math.Max(0, framebuffer.Y); + if (VulkanSwapchainRecreationPolicy.OnFramebufferSize(width, height) + == VulkanSwapchainAction.Idle) + { + return false; + } + + VulkanInterop.Check(_vk!.DeviceWaitIdle(_device), "vkDeviceWaitIdle (recreate)"); + _recreateAtFrameBoundary = false; + return _swapchain!.Recreate(_pacing, width, height); + } + + /// + /// Adapts the swapchain to the narrow surface the RHI device needs. The + /// device deliberately does not own presentation: format, extent, + /// present-mode and the recreation policy are pure decisions that are already + /// unit-tested, and duplicating that judgement inside the backend would fork + /// it. + /// + private sealed class SwapchainBackbuffer(VulkanSwapchain swapchain, Queue presentQueue) + : IVulkanBackbuffer + { + public Format ImageFormat => swapchain.Configuration!.ImageFormat; + + public uint Width => swapchain.Configuration!.Width; + + public uint Height => swapchain.Configuration!.Height; + + public bool TryAcquire(Semaphore acquired, out uint imageIndex) + { + VulkanSwapchainAction action = swapchain.TryAcquire( + acquired, + AcquireTimeoutNanoseconds, + out imageIndex); + return action is VulkanSwapchainAction.Continue + or VulkanSwapchainAction.RecreateAtFrameBoundary; + } + + public Image ImageAt(uint imageIndex) => swapchain.ImageAt(imageIndex); + + public ImageView ViewAt(uint imageIndex) => swapchain.ViewAt(imageIndex); + + public Semaphore RenderCompleteAt(uint imageIndex) => + swapchain.RenderCompleteAt(imageIndex); + + public bool Present(uint imageIndex) => + swapchain.Present(presentQueue, imageIndex) is VulkanSwapchainAction.Continue; + } + + /// + /// Teardown in strict reverse-construction order. Every handle is checked + /// before destruction because acquisition can throw at any stage — a rejected + /// capability gate is a normal, expected exit, not a crash, and it must still + /// leave zero Vulkan objects behind. + /// + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + + Silk.NET.Vulkan.Vk? vk = _vk; + if (vk is not null && _device.Handle != 0) + vk.DeviceWaitIdle(_device); + + _gpuDevice?.Dispose(); + _gpuDevice = null; + _swapchain?.Dispose(); + _swapchain = null; + + if (vk is not null && _device.Handle != 0) + { + vk.DestroyDevice(_device, null); + _device = default; + } + + _debugNames.Dispose(); + _debugNames = VulkanDebugNames.Disabled; + + if (vk is not null && _surfaceApi is not null && _surface.Handle != 0) + { + _surfaceApi.DestroySurface(_instance, _surface, null); + _surface = default; + } + + _swapchainApi?.Dispose(); + _swapchainApi = null; + _surfaceApi?.Dispose(); + _surfaceApi = null; + + if (vk is not null && _instance.Handle != 0) + { + vk.DestroyInstance(_instance, null); + _instance = default; + } + + vk?.Dispose(); + _vk = null; + } +} diff --git a/src/AcDream.App/Rendering/TextureCache.cs b/src/AcDream.App/Rendering/TextureCache.cs index e6c86be0..d90085c4 100644 --- a/src/AcDream.App/Rendering/TextureCache.cs +++ b/src/AcDream.App/Rendering/TextureCache.cs @@ -18,7 +18,7 @@ public sealed unsafe class TextureCache : Wb.IEntityTextureLifetime, IDisposable { - private readonly GL _gl; + private readonly GL? _gl; private readonly IGpuDevice _device; private readonly IDatReaderWriter _dats; private readonly string _diagnosticsDirectory; @@ -105,7 +105,7 @@ public sealed unsafe class TextureCache // contract), and this convenience overload has no real caller today (both // production construction sites already target the internal overload // below) — kept internal rather than deleted to preserve its shape. - internal TextureCache(GL gl, IGpuDevice device, IDatReaderWriter dats, Wb.BindlessSupport? bindless = null) + internal TextureCache(GL? gl, IGpuDevice device, IDatReaderWriter dats, Wb.BindlessSupport? bindless = null) : this( gl, device, @@ -119,8 +119,19 @@ public sealed unsafe class TextureCache { } + /// + /// The GL context, or null on a backend that has none. Campaign V slice V6h: + /// the UI path (, ) + /// is entirely -driven and runs on either backend, + /// while the world paths — the legacy Texture2D upload, particle + /// arrays, and the composite/bindless caches — still speak raw GL and are + /// unreachable without it. A null context therefore reaches exactly the same + /// code a null already gated, and every world + /// entry point throws with the slice that owns it named. Removed at V4t, + /// which ports the world texture stack onto the RHI. + /// internal TextureCache( - GL gl, + GL? gl, IGpuDevice device, IDatReaderWriter dats, Wb.BindlessSupport? bindless, @@ -130,6 +141,13 @@ public sealed unsafe class TextureCache { budgets ??= ResidencyBudgetOptions.Default; _gl = gl; + if (gl is null && bindless is not null) + { + throw new ArgumentException( + "Bindless composite/particle texture caches require a GL context.", + nameof(bindless)); + } + _device = device ?? throw new ArgumentNullException(nameof(device)); _dats = dats; _bindless = bindless; @@ -144,7 +162,7 @@ public sealed unsafe class TextureCache try { composite = new CompositeTextureArrayCache( - gl, + gl!, bindless, retirementQueue, budgets.CompositeUnownedBytes, @@ -170,6 +188,17 @@ public sealed unsafe class TextureCache } } + /// + /// The GL context the world texture paths need. Campaign V slice V6h: a + /// Vulkan-composed cache serves the UI path through + /// alone and never reaches here, so a failure names the slice that owns the + /// port rather than dereferencing null. + /// + private GL Gl => _gl ?? throw new InvalidOperationException( + "This TextureCache owns no GL context: the world texture paths " + + "(Texture2D upload, particle arrays, composite/bindless caches) are " + + "unavailable until Campaign V slice V4t ports them onto the RHI."); + internal void RegisterResidencySources(ResidencyManager manager) { ArgumentNullException.ThrowIfNull(manager); @@ -319,7 +348,7 @@ public sealed unsafe class TextureCache try { texture.Upload(0, 0, decoded.Rgba8); - uint glName = ((GlGpuTexture)texture).GlName; + uint glName = UploadAccountingName(texture); TrackUploadedTexture(glName, decoded.Width, decoded.Height); IGpuSampler sampler = _device.CreateSampler(nearest ? UiNearestRepeat : GpuSamplerDescription.WorldRepeat); @@ -333,6 +362,24 @@ public sealed unsafe class TextureCache } } + /// + /// The identity a UI upload is accounted under. On GL it is the texture's + /// own GL name — unchanged, so the VRAM ledger and the + /// ACDREAM_DUMP_SURFACES histogram key off exactly what they always + /// did. On any other backend there is no such name, so a descending + /// synthetic counter supplies one; it starts at uint.MaxValue because + /// GL hands out small ascending names and the two spaces share the + /// _uploadMetadata dictionary. The value is a dictionary key and a + /// dedup token only — Campaign V slice V6d removed the last draw-time + /// consumer of a raw GL name, so nothing binds it. + /// + private uint UploadAccountingName(IGpuTexture texture) => + texture is GlGpuTexture glTexture + ? glTexture.GlName + : _nextSyntheticUploadName--; + + private uint _nextSyntheticUploadName = uint.MaxValue; + /// /// Point sampling with REPEAT addressing — pixel-exact retail UI art that is /// still tiled by nine-slice chrome and meter tracks. Neither stock preset @@ -413,7 +460,7 @@ public sealed unsafe class TextureCache { handle = _bindless!.GetResidentHandle(name); Wb.GLHelpers.ThrowOnResourceError( - _gl, + Gl, $"making particle surface 0x{surfaceId:X8} resident"); var resource = new StandaloneBindlessTextureResource { @@ -441,7 +488,7 @@ public sealed unsafe class TextureCache { _bindless!.MakeNonResident(handle); Wb.GLHelpers.ThrowOnResourceError( - _gl, + Gl, "rolling back particle texture residency"); residencyReleased = true; }); @@ -636,7 +683,7 @@ public sealed unsafe class TextureCache { owner._bindless!.MakeNonResident(resource.Handle); Wb.GLHelpers.ThrowOnResourceError( - owner._gl, + owner.Gl, $"releasing particle texture handle {resource.Handle}"); } @@ -916,15 +963,15 @@ public sealed unsafe class TextureCache private uint UploadRgba8(DecodedTexture decoded, bool nearest = false) { - uint tex = _gl.GenTexture(); + uint tex = Gl.GenTexture(); if (tex == 0) throw new InvalidOperationException("OpenGL did not create a 2D texture."); try { - _gl.BindTexture(TextureTarget.Texture2D, tex); + Gl.BindTexture(TextureTarget.Texture2D, tex); fixed (byte* p = decoded.Rgba8) - _gl.TexImage2D( + Gl.TexImage2D( TextureTarget.Texture2D, 0, InternalFormat.Rgba8, @@ -938,12 +985,12 @@ public sealed unsafe class TextureCache // Point (nearest) sampling for pixel-exact UI text — bilinear softens the dat // font's small glyphs. Other surfaces use bilinear. int filter = nearest ? (int)TextureMinFilter.Nearest : (int)TextureMinFilter.Linear; - _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, filter); - _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, filter); - _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, (int)TextureWrapMode.Repeat); - _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT, (int)TextureWrapMode.Repeat); + Gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, filter); + Gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, filter); + Gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, (int)TextureWrapMode.Repeat); + Gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT, (int)TextureWrapMode.Repeat); Wb.GLHelpers.ThrowOnResourceError( - _gl, + Gl, $"uploading 2D RGBA8 texture {decoded.Width}x{decoded.Height}"); TrackUploadedTexture(tex, decoded.Width, decoded.Height); @@ -951,12 +998,12 @@ public sealed unsafe class TextureCache } catch { - _gl.DeleteTexture(tex); + Gl.DeleteTexture(tex); throw; } finally { - _gl.BindTexture(TextureTarget.Texture2D, 0); + Gl.BindTexture(TextureTarget.Texture2D, 0); } } @@ -967,15 +1014,15 @@ public sealed unsafe class TextureCache /// private uint UploadRgba8AsLayer1Array(DecodedTexture decoded) { - uint tex = _gl.GenTexture(); + uint tex = Gl.GenTexture(); if (tex == 0) throw new InvalidOperationException("OpenGL did not create a one-layer texture array."); try { - _gl.BindTexture(TextureTarget.Texture2DArray, tex); + Gl.BindTexture(TextureTarget.Texture2DArray, tex); fixed (byte* p = decoded.Rgba8) - _gl.TexImage3D( + Gl.TexImage3D( TextureTarget.Texture2DArray, 0, InternalFormat.Rgba8, @@ -987,12 +1034,12 @@ public sealed unsafe class TextureCache PixelType.UnsignedByte, p); - _gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear); - _gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear); - _gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapS, (int)TextureWrapMode.Repeat); - _gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapT, (int)TextureWrapMode.Repeat); + Gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear); + Gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear); + Gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapS, (int)TextureWrapMode.Repeat); + Gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapT, (int)TextureWrapMode.Repeat); Wb.GLHelpers.ThrowOnResourceError( - _gl, + Gl, $"uploading one-layer RGBA8 array {decoded.Width}x{decoded.Height}"); TrackUploadedTexture(tex, decoded.Width, decoded.Height); @@ -1000,12 +1047,12 @@ public sealed unsafe class TextureCache } catch { - _gl.DeleteTexture(tex); + Gl.DeleteTexture(tex); throw; } finally { - _gl.BindTexture(TextureTarget.Texture2DArray, 0); + Gl.BindTexture(TextureTarget.Texture2DArray, 0); } } @@ -1019,8 +1066,8 @@ public sealed unsafe class TextureCache private void DeleteUploadedTexture(uint name) { - _gl.DeleteTexture(name); - Wb.GLHelpers.ThrowOnResourceError(_gl, $"deleting uploaded texture {name}"); + Gl.DeleteTexture(name); + Wb.GLHelpers.ThrowOnResourceError(Gl, $"deleting uploaded texture {name}"); UntrackUploadedTexture(name); } diff --git a/src/AcDream.App/RuntimeOptions.cs b/src/AcDream.App/RuntimeOptions.cs index 7b37ae2e..3cfb93be 100644 --- a/src/AcDream.App/RuntimeOptions.cs +++ b/src/AcDream.App/RuntimeOptions.cs @@ -59,7 +59,8 @@ public sealed record RuntimeOptions( StreamingWorkBudgetOptions StreamingWorkBudgets, RenderBackendKind RenderBackend, string? VulkanDeviceOverride, - string? VulkanForcedUnsupportedFeature) + string? VulkanForcedUnsupportedFeature, + bool VulkanCapabilityProbe) { /// /// Build options from the process environment. Used by @@ -134,7 +135,14 @@ public sealed record RuntimeOptions( // absent so the NotSupportedException -> exit-code-4 -> report path // can be exercised on hardware that actually supports everything. VulkanForcedUnsupportedFeature: - NullIfEmpty(env("ACDREAM_VULKAN_FORCE_UNSUPPORTED"))); + NullIfEmpty(env("ACDREAM_VULKAN_FORCE_UNSUPPORTED")), + // Campaign V slice V6h: with ACDREAM_RENDER_BACKEND=vulkan, run the + // V5/V6c bring-up harness — capability gate plus the synthetic + // verification scenes — instead of the real composition host. A + // diagnostic for "does this machine pass the Vulkan gate, and does + // the backend draw?"; ignored on OpenGL. + VulkanCapabilityProbe: + IsExactlyOne(env("ACDREAM_VULKAN_PROBE"))); } /// diff --git a/src/AcDream.App/Settings/RuntimeSettingsTargets.cs b/src/AcDream.App/Settings/RuntimeSettingsTargets.cs index f94d0d94..2be9d202 100644 --- a/src/AcDream.App/Settings/RuntimeSettingsTargets.cs +++ b/src/AcDream.App/Settings/RuntimeSettingsTargets.cs @@ -134,27 +134,33 @@ internal sealed class RuntimeSettingsStartupTargets : IRuntimeSettingsStartupTar internal sealed class RuntimeQualityApplicationTarget : IRuntimeQualityApplicationTarget { - private readonly WbDrawDispatcher _dispatcher; - private readonly TerrainAtlas _terrainAtlas; + // Campaign V slice V6h: absent on a backend that composes no world + // renderers. Alpha-to-coverage and anisotropy are properties of renderers + // that do not exist there; render range and streaming radii still apply. + private readonly WbDrawDispatcher? _dispatcher; + private readonly TerrainAtlas? _terrainAtlas; private readonly StreamingController _streaming; private readonly WorldRenderRangeState _renderRange; public RuntimeQualityApplicationTarget( - WbDrawDispatcher dispatcher, - TerrainAtlas terrainAtlas, + WbDrawDispatcher? dispatcher, + TerrainAtlas? terrainAtlas, StreamingController streaming, WorldRenderRangeState renderRange) { - _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); - _terrainAtlas = terrainAtlas ?? throw new ArgumentNullException(nameof(terrainAtlas)); + _dispatcher = dispatcher; + _terrainAtlas = terrainAtlas; _streaming = streaming ?? throw new ArgumentNullException(nameof(streaming)); _renderRange = renderRange ?? throw new ArgumentNullException(nameof(renderRange)); } - public void SetAlphaToCoverage(bool enabled) => - _dispatcher.AlphaToCoverage = enabled; + public void SetAlphaToCoverage(bool enabled) + { + if (_dispatcher is not null) + _dispatcher.AlphaToCoverage = enabled; + } - public void SetAnisotropic(int level) => _terrainAtlas.SetAnisotropic(level); + public void SetAnisotropic(int level) => _terrainAtlas?.SetAnisotropic(level); public void PublishRenderRange(int nearRadius, int farRadius) { @@ -202,8 +208,8 @@ internal sealed class RuntimeSettingsTargets : IRuntimeSettingsTargets public RuntimeSettingsTargets( IRuntimeDisplayWindowTarget displayWindow, - WbDrawDispatcher dispatcher, - TerrainAtlas terrainAtlas, + WbDrawDispatcher? dispatcher, + TerrainAtlas? terrainAtlas, StreamingController streaming, WorldRenderRangeState renderRange, UiRoot? uiRoot, diff --git a/tests/AcDream.App.Tests/Composition/ContentEffectsAudioCompositionTests.cs b/tests/AcDream.App.Tests/Composition/ContentEffectsAudioCompositionTests.cs index 6573b198..bf69aeb9 100644 --- a/tests/AcDream.App.Tests/Composition/ContentEffectsAudioCompositionTests.cs +++ b/tests/AcDream.App.Tests/Composition/ContentEffectsAudioCompositionTests.cs @@ -253,7 +253,7 @@ public sealed class ContentEffectsAudioCompositionTests Poses = new EntityEffectPoseRegistry(); Factory = new Factory(); Publication = new Publication(); - Platform = new GameWindowPlatformResult(null!, null!); + Platform = new GameWindowPlatformResult(TestGameWindowGraphics.OpenGl, null!); Host = (HostInputCameraResult)RuntimeHelpers.GetUninitializedObject( typeof(HostInputCameraResult)); Dependencies = new ContentEffectsAudioDependencies( @@ -280,7 +280,7 @@ public sealed class ContentEffectsAudioCompositionTests public Factory Factory { get; } public Publication Publication { get; } public ContentEffectsAudioDependencies Dependencies { get; } - public GameWindowPlatformResult Platform { get; } + public GameWindowPlatformResult Platform { get; } public HostInputCameraResult Host { get; } public ContentEffectsAudioCompositionPhase Phase() => new( diff --git a/tests/AcDream.App.Tests/Composition/HostInputCameraCompositionTests.cs b/tests/AcDream.App.Tests/Composition/HostInputCameraCompositionTests.cs index 63727c77..9c517617 100644 --- a/tests/AcDream.App.Tests/Composition/HostInputCameraCompositionTests.cs +++ b/tests/AcDream.App.Tests/Composition/HostInputCameraCompositionTests.cs @@ -95,7 +95,7 @@ public sealed class HostInputCameraCompositionTests IKeyboard keyboard = DispatchProxy.Create(); IMouse mouse = DispatchProxy.Create(); Input = new InputContext(keyboard, mouse); - Platform = new GameWindowPlatformResult(null!, Input); + Platform = new GameWindowPlatformResult(TestGameWindowGraphics.OpenGl, Input); ViewportAspect = new ViewportAspectState(); Framebuffer = new FramebufferResizeController(ViewportAspect); Capture = new CaptureSource(); @@ -111,7 +111,7 @@ public sealed class HostInputCameraCompositionTests public List Points { get; } = []; public InputContext Input { get; } - public GameWindowPlatformResult Platform { get; } + public GameWindowPlatformResult Platform { get; } public ViewportAspectState ViewportAspect { get; } public FramebufferResizeController Framebuffer { get; } public CaptureSource Capture { get; } @@ -254,16 +254,30 @@ public sealed class HostInputCameraCompositionTests private readonly MouseSurface _mouse = new(); private readonly RawPointerSurface _rawPointer = new(); - public IFramebufferViewportTarget CreateViewportTarget(GL gl) => Viewport; + public IFramebufferViewportTarget CreateViewportTarget( + GameWindowGraphics graphics) => Viewport; - public GpuFrameFlightController CreateGpuFrameFlights(GL gl) => + public GpuFrameFlightController? CreateGpuFrameFlights( + GameWindowGraphics graphics) => new(new FenceApi()); - public IGpuDevice CreateGpuDevice(GL gl, GpuFrameFlightController frameFlights) => + public IGpuDevice CreateGpuDevice( + GameWindowGraphics graphics, + GpuFrameFlightController? frameFlights) => new RecordingGpuDevice(); - public WorldRenderDiagnostics CreateWorldRenderDiagnostics( - GL gl, + public IGpuResourceRetirementQueue CreateRetirement( + GameWindowGraphics graphics, + GpuFrameFlightController? frameFlights, + IGpuDevice device) => frameFlights!; + + public IRenderFrameSlotSource CreateFrameSlots( + GameWindowGraphics graphics, + GpuFrameFlightController? frameFlights, + IGpuDevice device) => frameFlights!; + + public WorldRenderDiagnostics? CreateWorldRenderDiagnostics( + GameWindowGraphics graphics, IRenderFrameDiagnosticLog log) => new(new GlStateReader(), log); diff --git a/tests/AcDream.App.Tests/Composition/InteractionRetainedUiCompositionTests.cs b/tests/AcDream.App.Tests/Composition/InteractionRetainedUiCompositionTests.cs index f6855583..1c8e2c92 100644 --- a/tests/AcDream.App.Tests/Composition/InteractionRetainedUiCompositionTests.cs +++ b/tests/AcDream.App.Tests/Composition/InteractionRetainedUiCompositionTests.cs @@ -188,7 +188,8 @@ public sealed class InteractionRetainedUiCompositionTests new NoopSpellOperations()); Dependencies = new InteractionRetainedUiDependencies( Options: options, - Gl: null!, + Graphics: null!, + BackbufferReader: static (_, _) => [], Window: null!, Input: null!, ShadersDirectory: "shaders", diff --git a/tests/AcDream.App.Tests/Composition/SettingsDevToolsCompositionTests.cs b/tests/AcDream.App.Tests/Composition/SettingsDevToolsCompositionTests.cs index a4bfedc1..9dd79291 100644 --- a/tests/AcDream.App.Tests/Composition/SettingsDevToolsCompositionTests.cs +++ b/tests/AcDream.App.Tests/Composition/SettingsDevToolsCompositionTests.cs @@ -219,13 +219,15 @@ public sealed class SettingsDevToolsCompositionTests null!, null!, null!, + null!, + null, null, null, null, _dispatcher, camera, null); - Platform = new GameWindowPlatformResult(null!, null!); + Platform = new GameWindowPlatformResult(TestGameWindowGraphics.OpenGl, null!); Content = (ContentEffectsAudioResult)RuntimeHelpers.GetUninitializedObject( typeof(ContentEffectsAudioResult)); Dependencies = new SettingsDevToolsDependencies( @@ -258,7 +260,7 @@ public sealed class SettingsDevToolsCompositionTests public RuntimeSettingsController Settings { get; } public SettingsDevToolsDependencies Dependencies { get; } public HostInputCameraResult Host { get; } - public GameWindowPlatformResult Platform { get; } + public GameWindowPlatformResult Platform { get; } public ContentEffectsAudioResult Content { get; } public SettingsDevToolsResult Compose() => diff --git a/tests/AcDream.App.Tests/Composition/TestGameWindowGraphics.cs b/tests/AcDream.App.Tests/Composition/TestGameWindowGraphics.cs new file mode 100644 index 00000000..dc23169b --- /dev/null +++ b/tests/AcDream.App.Tests/Composition/TestGameWindowGraphics.cs @@ -0,0 +1,61 @@ +using AcDream.App; +using AcDream.App.Composition; +using Silk.NET.Core.Contexts; +using Silk.NET.OpenGL; + +namespace AcDream.App.Tests.Composition; + +/// +/// Campaign V slice V6h: the graphics handle composition tests hand to a phase. +/// +/// The phases now select their backend arm from +/// rather than from a bare GL reference, +/// so a test that means "compose the OpenGL arm" has to say so. The GL instance +/// is never called: every composition test supplies a stub factory that ignores +/// its context argument, and the loader below would fault if anything did — which +/// is the point. It is a token identifying the arm, not a driver. +/// +internal sealed class TestGameWindowGraphics : GameWindowGraphics +{ + private readonly GL? _gl; + + private TestGameWindowGraphics(RenderBackendKind backend, GL? gl) + { + Backend = backend; + _gl = gl; + } + + /// Selects the OpenGL arm, with a context token no test dereferences. + public static TestGameWindowGraphics OpenGl { get; } = + new(RenderBackendKind.Gl, new GL(new UnusableNativeContext())); + + /// Selects the Vulkan arm: no GL context exists. + public static TestGameWindowGraphics Vulkan { get; } = + new(RenderBackendKind.Vulkan, null); + + public override RenderBackendKind Backend { get; } + + public override GL? Gl => _gl; + + public override void Dispose() + { + } + + private sealed class UnusableNativeContext : INativeContext + { + public nint GetProcAddress(string proc, int? slot = null) => + throw new InvalidOperationException( + $"A composition test called GL entry point '{proc}'. " + + "Test graphics are a backend token, not a driver."); + + public bool TryGetProcAddress(string proc, out nint addr, int? slot = null) + { + addr = 0; + return false; + } + + public void Dispose() + { + } + } +} diff --git a/tests/AcDream.App.Tests/Composition/WorldRenderCompositionTests.cs b/tests/AcDream.App.Tests/Composition/WorldRenderCompositionTests.cs index 4824c96f..4b76c611 100644 --- a/tests/AcDream.App.Tests/Composition/WorldRenderCompositionTests.cs +++ b/tests/AcDream.App.Tests/Composition/WorldRenderCompositionTests.cs @@ -193,7 +193,7 @@ public sealed class WorldRenderCompositionTests if (point == _failurePoint) throw new InvalidOperationException($"fault at {point}"); }).Compose( - new GameWindowPlatformResult(null!, null!), + new GameWindowPlatformResult(TestGameWindowGraphics.OpenGl, null!), Content, new SettingsDevToolsResult( QualitySettings.From(QualityPreset.High), diff --git a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs index 21a71fa7..a7b60087 100644 --- a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs +++ b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs @@ -51,7 +51,7 @@ public sealed class GameWindowSlice8BoundaryTests AssertAppearsInOrder( body, - "GameWindowPlatformResult platform = AcquirePlatform();", + "GameWindowPlatformResult platform = AcquirePlatform();", "GameWindowCompositionPipeline.Run<", "new HostInputCameraCompositionPhase(", "this).Compose(platformResult),", @@ -575,7 +575,7 @@ public sealed class GameWindowSlice8BoundaryTests "d.PortalTunnelFallback.AcquirePrepared(", "static tunnel => tunnel.PrepareResources());", "d.RenderResourceLifetime.AcquireSkyShader(", - "() => new SkyRenderer("); + "new SkyRenderer("); AssertAppearsInOrder( load, "new WorldRenderCompositionPhase(", @@ -597,7 +597,7 @@ public sealed class GameWindowSlice8BoundaryTests "lifetime.AcquireTerrainAtlas(", "TerrainAtlas.Build(gl, dats, bindless)", "TerrainModernRenderer CreateTerrain(", - "TerrainModernRenderer terrain = AcquireAndPublish("); + "TerrainModernRenderer? terrain = AcquireAndPublishIf("); AssertAppearsInOrder( shutdown, "frame.FrameGraphPublication?.Dispose()", @@ -610,7 +610,7 @@ public sealed class GameWindowSlice8BoundaryTests "render.DedicatedResources.ReleaseSkyShader", "render.DedicatedResources.ReleaseTerrainAtlas", "render.ConstructionCleanup.Dispose", - "platform.Gl?.Dispose()"); + "platform.Graphics?.Dispose()"); Assert.DoesNotContain( "RetailUiRuntime.Mount(",