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(",