From b16f82064314892e3a148b4a9429d6cad0f71877 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 28 Jul 2026 11:47:37 +0200 Subject: [PATCH] =?UTF-8?q?feat(render):=20Campaign=20V=20slice=20V6h=20?= =?UTF-8?q?=E2=80=94=20the=20Vulkan=20composition=20host?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ACDREAM_RENDER_BACKEND=vulkan now runs the real GameWindow composition rather than a second main(). All nine phases execute: DAT load, streaming, camera, entity table, session, and the real retained UiHost drawing through the RHI. No world renderers — they are raw GL until V4t and the world arm behind it. The offline log is the client's own (acdream.pak opened, 6266 spells, Region 0x13000000, "loading world view centered on 0xA9B4FFFF", fourteen retail LayoutDesc lines, streaming radii), and the captured frame is the retail retained UI: vitals, combat/spell bar with DAT scarab icons, the nine-slot toolbar, chat with tabs and Send, radar/compass with dat-font glyphs. Sampled against the GL capture the widgets agree — chat interior RGBA (25,24,27,158) vs (22,21,23,158), vitals bar (117,1,0) and toolbar slot (0,11,17) identical. Three seams, as §5.5.9 specified: 1. Platform acquisition — already generic — publishes GameWindowGraphics instead of a bare GL. Phases that still speak raw GL read Graphics.Gl and take their Vulkan arm when it is null; each branch names the slice that removes it. 2. VulkanHostInputCameraCompositionFactory is a new file and the whole of the Phase-1 fork: four graphics members differ, input/camera/pointer delegate. The default factory is chosen inside the phase from the platform result. HostInputCameraResult gained backend-neutral Retirement and FrameSlots. 3. The frame root forks on one condition. The GL world-scene assembly is unchanged, wrapped in `if (gl is not null)`; the Vulkan arm's graph is one backbuffer clear pass computing the same RenderFrameFoundation from the same clock and weather owners, then private presentation over it. §5.5.9's three TextureCache couplings are unpicked: the constructor takes GL? and rejects bindless without one, world entry points route through a Gl property that throws naming V4t, and the (GlGpuTexture) VRAM-accounting cast became a backend test. That cast's stated reason — DrawSprite's texture-unit binding — was already stale, deleted at V6d. VulkanBringUpHost is reduced to the capability-probe harness it is named for: the instance/surface/device/swapchain sequence moved into VulkanGraphicsContext, which the composition host and the harness now share. It is reached only with ACDREAM_VULKAN_PROBE=1. One latent Vulkan defect surfaced and is fixed here. The first composition-host frame died with ErrorDeviceLost; validation named VUID-vkCmdDraw-None-08600 — descriptor set 2 never bound. VulkanGpuPassEncoder bound sets 0/1/2 only as a side effect of BindStorageBuffer/BindUniformBuffer, so a pass sampling the texture table while binding no buffer — every retained-UI and debug-line pass — drew with the table unbound. It survived V6c-V6g because the bring-up host always drew VulkanRhiScene first and the UI pass inherited its binds; the composition host has no 3-D scene. The fix is one line in the encoder's constructor beside the viewport and scissor defaults, which exist for exactly the same reason: a pass opens with complete binding state rather than depending on what preceded it. Gates: strict GL offline pixel gate against 46d893f7 measures 1.24e-05 (7 of 563,200 pixels), 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 converged the ownership ledger — no [shutdown] diagnostic on either stream. The reduced probe harness presented 34,811 validation-clean frames. No divergence-register row: GL is the shipping backend and the pixel gate proves it unmoved; the Vulkan arm is not a retail deviation but a backend under construction. Next is V4t, the texture stack, which the world arm cannot be written without. Co-Authored-By: Claude Fable 5 --- docs/plans/2026-07-27-vulkan-campaign.md | 98 ++++ .../CompositionAcquisitionScope.cs | 47 ++ .../ContentEffectsAudioComposition.cs | 4 +- .../Composition/FrameRootComposition.cs | 317 +++++----- .../Composition/GameWindowGraphics.cs | 80 +++ .../Composition/HostInputCameraComposition.cs | 136 ++++- .../InteractionRetainedUiComposition.cs | 21 +- .../LivePresentationComposition.cs | 289 ++++++---- .../Composition/SessionPlayerComposition.cs | 87 ++- .../SettingsDevToolsComposition.cs | 11 +- ...VulkanHostInputCameraCompositionFactory.cs | 146 +++++ .../Composition/WorldRenderComposition.cs | 180 ++++-- .../WorldLifecycleResourceSnapshotSource.cs | 39 +- src/AcDream.App/Rendering/GameWindow.cs | 262 ++++++--- .../Rendering/GameWindowLifetime.cs | 4 +- .../Rendering/Gpu/Vk/VulkanBringUpHost.cs | 545 +++--------------- .../Gpu/Vk/VulkanCompositionFramePhases.cs | 239 ++++++++ .../Rendering/Gpu/Vk/VulkanGpuPassEncoder.cs | 18 + .../Rendering/Gpu/Vk/VulkanGraphicsContext.cs | 507 ++++++++++++++++ src/AcDream.App/Rendering/TextureCache.cs | 107 +++- src/AcDream.App/RuntimeOptions.cs | 12 +- .../Settings/RuntimeSettingsTargets.cs | 28 +- .../ContentEffectsAudioCompositionTests.cs | 4 +- .../HostInputCameraCompositionTests.cs | 28 +- .../InteractionRetainedUiCompositionTests.cs | 3 +- .../SettingsDevToolsCompositionTests.cs | 6 +- .../Composition/TestGameWindowGraphics.cs | 61 ++ .../WorldRenderCompositionTests.cs | 2 +- .../GameWindowSlice8BoundaryTests.cs | 8 +- 29 files changed, 2292 insertions(+), 997 deletions(-) create mode 100644 src/AcDream.App/Composition/GameWindowGraphics.cs create mode 100644 src/AcDream.App/Composition/VulkanHostInputCameraCompositionFactory.cs create mode 100644 src/AcDream.App/Rendering/Gpu/Vk/VulkanCompositionFramePhases.cs create mode 100644 src/AcDream.App/Rendering/Gpu/Vk/VulkanGraphicsContext.cs create mode 100644 tests/AcDream.App.Tests/Composition/TestGameWindowGraphics.cs 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(",