feat(render): Campaign V slice V6h — the Vulkan composition host

ACDREAM_RENDER_BACKEND=vulkan now runs the real GameWindow composition rather
than a second main(). All nine phases execute: DAT load, streaming, camera,
entity table, session, and the real retained UiHost drawing through the RHI.
No world renderers — they are raw GL until V4t and the world arm behind it.

The offline log is the client's own (acdream.pak opened, 6266 spells, Region
0x13000000, "loading world view centered on 0xA9B4FFFF", fourteen retail
LayoutDesc lines, streaming radii), and the captured frame is the retail
retained UI: vitals, combat/spell bar with DAT scarab icons, the nine-slot
toolbar, chat with tabs and Send, radar/compass with dat-font glyphs. Sampled
against the GL capture the widgets agree — chat interior RGBA (25,24,27,158)
vs (22,21,23,158), vitals bar (117,1,0) and toolbar slot (0,11,17) identical.

Three seams, as §5.5.9 specified:

1. Platform acquisition — already generic — publishes GameWindowGraphics
   instead of a bare GL. Phases that still speak raw GL read Graphics.Gl and
   take their Vulkan arm when it is null; each branch names the slice that
   removes it.
2. VulkanHostInputCameraCompositionFactory is a new file and the whole of the
   Phase-1 fork: four graphics members differ, input/camera/pointer delegate.
   The default factory is chosen inside the phase from the platform result.
   HostInputCameraResult gained backend-neutral Retirement and FrameSlots.
3. The frame root forks on one condition. The GL world-scene assembly is
   unchanged, wrapped in `if (gl is not null)`; the Vulkan arm's graph is one
   backbuffer clear pass computing the same RenderFrameFoundation from the same
   clock and weather owners, then private presentation over it.

§5.5.9's three TextureCache couplings are unpicked: the constructor takes GL?
and rejects bindless without one, world entry points route through a Gl
property that throws naming V4t, and the (GlGpuTexture) VRAM-accounting cast
became a backend test. That cast's stated reason — DrawSprite's texture-unit
binding — was already stale, deleted at V6d.

VulkanBringUpHost is reduced to the capability-probe harness it is named for:
the instance/surface/device/swapchain sequence moved into VulkanGraphicsContext,
which the composition host and the harness now share. It is reached only with
ACDREAM_VULKAN_PROBE=1.

One latent Vulkan defect surfaced and is fixed here. The first composition-host
frame died with ErrorDeviceLost; validation named VUID-vkCmdDraw-None-08600 —
descriptor set 2 never bound. VulkanGpuPassEncoder bound sets 0/1/2 only as a
side effect of BindStorageBuffer/BindUniformBuffer, so a pass sampling the
texture table while binding no buffer — every retained-UI and debug-line pass —
drew with the table unbound. It survived V6c-V6g because the bring-up host
always drew VulkanRhiScene first and the UI pass inherited its binds; the
composition host has no 3-D scene. The fix is one line in the encoder's
constructor beside the viewport and scissor defaults, which exist for exactly
the same reason: a pass opens with complete binding state rather than depending
on what preceded it.

Gates: strict GL offline pixel gate against 46d893f7 measures 1.24e-05 (7 of
563,200 pixels), inside the documented 15-23 px / 4.1e-05 band, so GL behaviour
did not move. App tests 4,075/3 skips; complete Release suite 9,138/5 skips.
One full Vulkan run with VK_LAYER_KHRONOS_validation: zero errors, zero
warnings. Both Vulkan runs converged the ownership ledger — no [shutdown]
diagnostic on either stream. The reduced probe harness presented 34,811
validation-clean frames.

No divergence-register row: GL is the shipping backend and the pixel gate proves
it unmoved; the Vulkan arm is not a retail deviation but a backend under
construction.

Next is V4t, the texture stack, which the world arm cannot be written without.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-28 11:47:37 +02:00
parent 46d893f7c3
commit b16f820643
29 changed files with 2292 additions and 997 deletions

View file

@ -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,2002,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 V6cV6g 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 1523 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

View file

@ -49,6 +49,30 @@ internal sealed class CompositionAcquisitionScope : IRetryableResourceCleanup
return Own(name, resource, release);
}
/// <summary>
/// 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.
/// </summary>
public CompositionAcquisitionOptionalLease<T> AcquireOptional<T>(
string name,
Func<T?> factory,
Action<T> release)
where T : class
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
ArgumentNullException.ThrowIfNull(factory);
ArgumentNullException.ThrowIfNull(release);
EnsureAcceptingOwnership();
T? resource = factory();
return resource is null
? new CompositionAcquisitionOptionalLease<T>(null)
: new CompositionAcquisitionOptionalLease<T>(
Own(name, resource, release));
}
public CompositionAcquisitionLease<T> Own<T>(
string name,
T resource,
@ -216,6 +240,29 @@ internal sealed class CompositionAcquisitionScope : IRetryableResourceCleanup
return Transfer();
}
}
/// <summary>A lease over a resource the active backend may not own at all.</summary>
internal sealed class CompositionAcquisitionOptionalLease<T>(
CompositionAcquisitionLease<T>? inner)
where T : class
{
public T? Resource => inner?.Resource;
public T? Transfer() => inner?.Transfer();
public T? Publish(Action<T?> publish)
{
ArgumentNullException.ThrowIfNull(publish);
if (inner is null)
{
publish(null);
return null;
}
publish(inner.Resource);
return inner.Transfer();
}
}
}
/// <summary>

View file

@ -280,7 +280,7 @@ internal enum ContentEffectsAudioCompositionPoint
/// </summary>
internal sealed class ContentEffectsAudioCompositionPhase :
IContentEffectsAudioCompositionPhase<
GameWindowPlatformResult<GL, IInputContext>,
GameWindowPlatformResult<GameWindowGraphics, IInputContext>,
HostInputCameraResult,
ContentEffectsAudioResult>
{
@ -304,7 +304,7 @@ internal sealed class ContentEffectsAudioCompositionPhase :
}
public ContentEffectsAudioResult Compose(
GameWindowPlatformResult<GL, IInputContext> platform,
GameWindowPlatformResult<GameWindowGraphics, IInputContext> platform,
HostInputCameraResult host)
{
ArgumentNullException.ThrowIfNull(platform);

View file

@ -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<GL, IInputContext>,
GameWindowPlatformResult<GameWindowGraphics, IInputContext>,
HostInputCameraResult,
ContentEffectsAudioResult,
SettingsDevToolsResult,
@ -164,7 +164,7 @@ internal sealed class FrameRootCompositionPhase
}
public FrameRootResult Compose(
GameWindowPlatformResult<GL, IInputContext> platform,
GameWindowPlatformResult<GameWindowGraphics, IInputContext> 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,

View file

@ -0,0 +1,80 @@
using AcDream.App.Rendering.Gpu.Vk;
using Silk.NET.OpenGL;
namespace AcDream.App.Composition;
/// <summary>
/// Campaign V slice V6h: the graphics ownership that platform acquisition
/// publishes, once per backend.
///
/// <para><see cref="GameWindowPlatformAcquisition"/> was already generic over its
/// graphics type; only the call sites pinned <c>GL</c>. 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.</para>
///
/// <para>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.</para>
/// </summary>
internal abstract class GameWindowGraphics : IDisposable
{
/// <summary>Which backend this handle owns.</summary>
public abstract RenderBackendKind Backend { get; }
/// <summary>
/// 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.
/// </summary>
public virtual GL? Gl => null;
/// <summary>The live Vulkan context, or null on any other backend.</summary>
public virtual VulkanGraphicsContext? Vulkan => null;
/// <summary>
/// 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.
/// </summary>
public GL RequireGl(string owner) =>
Gl ?? throw new InvalidOperationException(
$"'{owner}' requires the OpenGL context, but the {Backend} backend is active.");
public abstract void Dispose();
}
/// <summary>OpenGL ownership: the Silk.NET <see cref="GL"/> context itself.</summary>
internal sealed class OpenGlGameWindowGraphics : GameWindowGraphics
{
public OpenGlGameWindowGraphics(GL gl) =>
Context = gl ?? throw new ArgumentNullException(nameof(gl));
/// <summary>The owned context. <see cref="GameWindowGraphics.Gl"/> is the borrowed view.</summary>
public GL Context { get; }
public override RenderBackendKind Backend => RenderBackendKind.Gl;
public override GL? Gl => Context;
public override void Dispose() => Context.Dispose();
}
/// <summary>
/// Vulkan ownership: the instance, surface, device, swapchain and RHI device
/// that <see cref="VulkanGraphicsContext"/> acquired and gated.
/// </summary>
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();
}

View file

@ -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);
}
/// <param name="GpuFrameFlights">
/// The GL fence/slot ring, or null on a backend whose RHI device owns its own
/// flight control. Campaign V slice V6h: <see cref="Retirement"/> and
/// <see cref="FrameSlots"/> are the backend-neutral views every consumer should
/// take; this field exists because GL's teardown ledger still names the
/// controller itself.
/// </param>
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);
/// <summary>
/// The construction seam every backend differs at. Campaign V slice V6h widened
/// the first four members from <c>GL</c> to <see cref="GameWindowGraphics"/> —
/// they are the whole of what a backend has to supply before the composition
/// pipeline is identical again.
/// </summary>
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);
/// <summary>The GL fence ring, or null when the backend's RHI device owns its flights.</summary>
GpuFrameFlightController? CreateGpuFrameFlights(GameWindowGraphics graphics);
IGpuDevice CreateGpuDevice(
GameWindowGraphics graphics,
GpuFrameFlightController? frameFlights);
/// <summary>Where per-frame resource release is queued. GL's ring, or the RHI device's own.</summary>
IGpuResourceRetirementQueue CreateRetirement(
GameWindowGraphics graphics,
GpuFrameFlightController? frameFlights,
IGpuDevice device);
/// <summary>The ring slot renderers index their per-flight buffers by.</summary>
IRenderFrameSlotSource CreateFrameSlots(
GameWindowGraphics graphics,
GpuFrameFlightController? frameFlights,
IGpuDevice device);
/// <summary>The raw-GL state tripwire, or null on a backend that has no GL state.</summary>
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
/// </summary>
internal sealed class HostInputCameraCompositionPhase :
IHostInputCameraCompositionPhase<
GameWindowPlatformResult<GL, IInputContext>,
GameWindowPlatformResult<GameWindowGraphics, IInputContext>,
HostInputCameraResult>
{
private readonly HostInputCameraDependencies _dependencies;
private readonly IGameWindowHostInputCameraPublication _publication;
private readonly IHostInputCameraCompositionFactory _factory;
private readonly IHostInputCameraCompositionFactory? _injectedFactory;
private readonly Action<HostInputCameraCompositionPoint>? _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;
}
/// <summary>
/// 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.
/// </summary>
private static IHostInputCameraCompositionFactory DefaultFactoryFor(
GameWindowGraphics graphics) =>
graphics.Backend == RenderBackendKind.Vulkan
? new VulkanHostInputCameraCompositionFactory()
: new RetailHostInputCameraCompositionFactory();
public HostInputCameraResult Compose(
GameWindowPlatformResult<GL, IInputContext> platform)
GameWindowPlatformResult<GameWindowGraphics, IInputContext> platform)
{
ArgumentNullException.ThrowIfNull(platform);
var scope = new CompositionAcquisitionScope();
@ -216,19 +284,23 @@ internal sealed class HostInputCameraCompositionPhase :
}
private HostInputCameraResult ComposeCore(
GameWindowPlatformResult<GL, IInputContext> platform,
GameWindowPlatformResult<GameWindowGraphics, IInputContext> 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,

View file

@ -30,9 +30,20 @@ using Silk.NET.Windowing;
namespace AcDream.App.Composition;
/// <param name="Graphics">
/// 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 <see cref="BackbufferReader"/>.
/// </param>
/// <param name="BackbufferReader">
/// Reads the presented frame as tightly packed RGBA8 in the backend's own row
/// order; <see cref="FrameScreenshotController"/> applies the bottom-up flip
/// glReadPixels needs, so a top-left-origin backend pre-flips to cancel it.
/// </param>
internal sealed record InteractionRetainedUiDependencies(
RuntimeOptions Options,
GL Gl,
GameWindowGraphics Graphics,
Func<int, int, byte[]> 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<GL, IInputContext>,
GameWindowPlatformResult<GameWindowGraphics, IInputContext>,
HostInputCameraResult,
ContentEffectsAudioResult,
SettingsDevToolsResult,
@ -892,7 +903,7 @@ internal sealed class InteractionRetainedUiCompositionPhase
}
public InteractionRetainedUiResult Compose(
GameWindowPlatformResult<GL, IInputContext> platform,
GameWindowPlatformResult<GameWindowGraphics, IInputContext> 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)

View file

@ -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<GL, Silk.NET.Input.IInputContext>,
GameWindowPlatformResult<GameWindowGraphics, Silk.NET.Input.IInputContext>,
HostInputCameraResult,
ContentEffectsAudioResult,
SettingsDevToolsResult,
@ -177,7 +177,7 @@ internal sealed class LivePresentationCompositionPhase
}
public LivePresentationResult Compose(
GameWindowPlatformResult<GL, Silk.NET.Input.IInputContext> platform,
GameWindowPlatformResult<GameWindowGraphics, Silk.NET.Input.IInputContext> 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);

View file

@ -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,

View file

@ -334,7 +334,7 @@ internal sealed class CombatFeedbackBinding : IDisposable
/// </summary>
internal sealed class SettingsDevToolsCompositionPhase :
ISettingsDevToolsCompositionPhase<
GameWindowPlatformResult<GL, IInputContext>,
GameWindowPlatformResult<GameWindowGraphics, IInputContext>,
HostInputCameraResult,
ContentEffectsAudioResult,
SettingsDevToolsResult>
@ -357,7 +357,7 @@ internal sealed class SettingsDevToolsCompositionPhase :
}
public SettingsDevToolsResult Compose(
GameWindowPlatformResult<GL, IInputContext> platform,
GameWindowPlatformResult<GameWindowGraphics, IInputContext> platform,
HostInputCameraResult host,
ContentEffectsAudioResult content)
{
@ -377,7 +377,7 @@ internal sealed class SettingsDevToolsCompositionPhase :
}
private DevToolsCompositionOwner? ComposeOptionalDevTools(
GameWindowPlatformResult<GL, IInputContext> platform,
GameWindowPlatformResult<GameWindowGraphics, IInputContext> 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());

View file

@ -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;
/// <summary>
/// Campaign V slice V6h: the Vulkan arm of the host phase.
///
/// <para>This is the whole of the backend fork at composition Phase 1. Only the
/// four graphics members differ from
/// <see cref="RetailHostInputCameraCompositionFactory"/>; 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.</para>
///
/// <para><b>What is absent, and why.</b> 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
/// <see cref="WorldRenderDiagnostics"/>: 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.</para>
/// </summary>
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<IMouse> 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.");
/// <summary>
/// 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.
/// </summary>
private sealed class NullFramebufferViewportTarget : IFramebufferViewportTarget
{
public static NullFramebufferViewportTarget Instance { get; } = new();
private NullFramebufferViewportTarget()
{
}
public void ResizeViewport(int width, int height)
{
}
}
/// <summary>
/// The flight slot per-frame buffers index by. Identical in role to the GL
/// ring's <see cref="GpuFrameFlightController.CurrentSlot"/>; the count comes
/// from the device's timeline flight controller rather than a fence array.
/// </summary>
private sealed class VulkanRenderFrameSlotSource(VulkanGpuDevice device)
: IRenderFrameSlotSource
{
public int CurrentSlot => device.Flights.CurrentSlot;
}
}

View file

@ -28,20 +28,30 @@ internal sealed record WorldTerrainBuildContext(
TerrainBlendingContext Blending,
ConcurrentDictionary<uint, SurfaceInfo> SurfaceCache);
/// <summary>
/// The render foundation the later phases build on.
///
/// <para>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.</para>
/// </summary>
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);
/// <param name="atlas">
/// 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 <see cref="TerrainBlendingContext"/>: streaming still builds real
/// landblock heightfields and collision, but every surface resolves to
/// <see cref="SurfaceInfo.None"/> because nothing is going to sample it.
/// Campaign V slice V4t builds those tables without GL.
/// </param>
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<uint, byte>(),
RoadLayer: SurfaceInfo.None,
CornerAlphaLayers: [],
SideAlphaLayers: [],
RoadAlphaLayers: [],
CornerAlphaTCodes: [],
SideAlphaTCodes: [],
RoadAlphaRCodes: []),
new ConcurrentDictionary<uint, SurfaceInfo>());
}
var layers = new Dictionary<uint, byte>(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
/// </summary>
internal sealed class WorldRenderCompositionPhase
: IWorldRenderCompositionPhase<
GameWindowPlatformResult<GL, IInputContext>,
GameWindowPlatformResult<GameWindowGraphics, IInputContext>,
ContentEffectsAudioResult,
SettingsDevToolsResult,
WorldRenderResult>
@ -443,7 +480,7 @@ internal sealed class WorldRenderCompositionPhase
}
public WorldRenderResult Compose(
GameWindowPlatformResult<GL, IInputContext> platform,
GameWindowPlatformResult<GameWindowGraphics, IInputContext> 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;
}
/// <summary>
/// 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.
/// </summary>
private T? AcquireAndPublishIf<T>(
bool supported,
CompositionAcquisitionScope scope,
string name,
Func<T> factory,
Action<T> 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);
}

View file

@ -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<GCGenerationInfo> 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,

View file

@ -23,7 +23,7 @@ namespace AcDream.App.Rendering;
public sealed class GameWindow :
IDisposable,
IGameWindowPlatformPublication<GL, IInputContext>,
IGameWindowPlatformPublication<GameWindowGraphics, IInputContext>,
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;
/// <summary>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<int>(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<int>(1280, 720),
Title = "acdream — Vulkan",
VSync = startupPacing.UseVSync,
}
: WindowOptions.Default with
{
Size = new Vector2D<int>(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<GL, IInputContext>.PublishGraphics(
GL graphics) =>
PublishCompositionOwner(ref _gl, graphics, "graphics API");
void IGameWindowPlatformPublication<GameWindowGraphics, IInputContext>.PublishGraphics(
GameWindowGraphics graphics) =>
PublishCompositionOwner(ref _graphics, graphics, "graphics API");
void IGameWindowPlatformPublication<GL, IInputContext>.PublishInput(
void IGameWindowPlatformPublication<GameWindowGraphics, IInputContext>.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<GL, IInputContext> AcquirePlatform()
[System.Diagnostics.CodeAnalysis.MemberNotNull(nameof(_graphics), nameof(_input))]
private GameWindowPlatformResult<GameWindowGraphics, IInputContext> AcquirePlatform()
{
GameWindowPlatformResult<GL, IInputContext> platform =
GameWindowPlatformResult<GameWindowGraphics, IInputContext> 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;
}
/// <summary>
/// Campaign V slice V6h: how the UI probe reads a completed frame.
///
/// <para><see cref="FrameScreenshotController"/> applies the bottom-up flip
/// <c>glReadPixels</c> needs, so GL hands it the raw read and Vulkan — whose
/// <see cref="IGpuDevice.CaptureBackbuffer"/> is documented top-left-origin —
/// pre-flips so the two cancel. Routing GL through the same
/// <c>CaptureBackbuffer</c> seam would double-flip, which is exactly the kind
/// of "usually right" instrument §5.5 of the campaign plan spent three
/// slices removing.</para>
/// </summary>
private static Func<int, int, byte[]> 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);
/// <summary>
/// Campaign V slice V6h: the backend fork, in one expression. Vulkan's
/// context acquisition runs its own capability gate inside
/// <see cref="AcDream.App.Rendering.Gpu.Vk.VulkanGraphicsContext.Acquire"/>,
/// throwing <see cref="NotSupportedException"/> into the same exit-code-4
/// contract <c>Program.cs</c> publishes for GL.
/// </summary>
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<GL, IInputContext> platform = AcquirePlatform();
string capabilityReportPath = Path.Combine(
_applicationPaths.DiagnosticsDirectory,
"graphical-capabilities.json");
_graphicalCapabilities =
GraphicalCapabilityGuard.CaptureVerifyAndWrite(
platform.Graphics,
_window!,
platform.Input,
_platformServices,
GameWindowPlatformResult<GameWindowGraphics, IInputContext> 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<GL, IInputContext>,
GameWindowPlatformResult<GameWindowGraphics, IInputContext>,
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<int> 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);

View file

@ -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()),
]));
}

View file

@ -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;
/// <summary>
/// Campaign V slice V5 — Vulkan bring-up, dark.
/// The Vulkan capability-probe and bring-up harness. Reached only when
/// <c>ACDREAM_RENDER_BACKEND=vulkan</c> <b>and</b> <c>ACDREAM_VULKAN_PROBE=1</c>.
///
/// Reached only when <c>ACDREAM_RENDER_BACKEND=vulkan</c>. 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.
/// <b>Nothing of the game renders through it</b> — the RHI backend lands at V6
/// and the default flips at V10.
/// <para><b>What it is for.</b> 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
/// <see cref="VulkanGraphicsContext"/> — 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.</para>
///
/// <para>Deliberately a self-contained host rather than a branch woven into
/// <c>GameWindow</c>'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. <c>GameWindow.Run</c> delegates here in four lines and returns, so the
/// GL path executes not one new statement.</para>
/// <para><b>What it is no longer.</b> Through slice V6g this was the whole
/// Vulkan path — a second <c>main()</c> beside <c>GameWindow</c>'s composition.
/// Slice V6h moved production Vulkan onto the real composition host and lifted
/// the instance/surface/device/swapchain sequence out into
/// <see cref="VulkanGraphicsContext"/>, which both now share. What remains here
/// is the harness role the class is named for.</para>
///
/// <para><b>Untested by the implementing slice.</b> 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."</para>
/// window and a driver. The pure decisions it depends on are unit-tested beside
/// it; the remaining gate is manual.</para>
/// </summary>
internal sealed unsafe class VulkanBringUpHost : IDisposable
internal sealed class VulkanBringUpHost : IDisposable
{
/// <summary>Two frames in flight, matching plan §4.8 and the GL flight controller.</summary>
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<string> _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
}
/// <summary>The record the gate evaluated, available after <see cref="Run"/> starts.</summary>
internal VulkanCapabilityRecord? Capabilities { get; private set; }
internal VulkanCapabilityRecord? Capabilities => _graphics?.Capabilities;
/// <summary>
/// Open the window, pass the capability gate, and present the clear colour
/// until the window closes. Throws <see cref="NotSupportedException"/> when
/// the gate rejects the device, which <c>Program.cs</c> 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 <see cref="NotSupportedException"/>
/// when the gate rejects the device, which <c>Program.cs</c> turns into exit
/// code 4 exactly as it does for the GL gate.
/// </summary>
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<int>(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<string>((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<AllocationCallbacks>(
_instance.ToHandle(),
null).ToSurface();
}
private IReadOnlyList<string> InstanceExtensions { get; set; } = [];
private void SelectDeviceAndGate()
{
Silk.NET.Vulkan.Vk vk = _vk!;
IReadOnlyList<VulkanPhysicalDeviceCandidate> 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<VulkanQueueFamilyCandidate> 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<SurfaceFormatKHR> formats,
IReadOnlyList<PresentModeKHR> presentModes) = _swapchain.QuerySurface();
Vector2D<int> 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<int> 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);
}
/// <summary>
/// Campaign V slice V6c: build the RHI backend and the scene that proves it.
///
/// <para>The host's own command pools, acquire semaphores and timeline are
/// gone — <see cref="VulkanGpuDevice"/> 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.</para>
/// </summary>
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)"));
}
/// <summary>Where the committed SPIR-V lives beside the binary.</summary>
private static string ShaderSpirvDirectory() =>
Path.Combine(AppContext.BaseDirectory, "Rendering", "Shaders", "spv");
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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
}
/// <summary>
/// Teardown in strict reverse-construction order. Every handle is checked
/// before destruction because <see cref="Run"/> 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.
/// </summary>
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;
}

View file

@ -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;
/// <summary>
/// Campaign V slice V6h: the Vulkan arm of the frame spine's clear phase.
///
/// <para>The GL arm establishes frame-global capability state and issues
/// <c>glClear</c> 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 <see cref="GpuLoadOp.Clear"/>, 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.</para>
///
/// <para>It computes the same <see cref="RenderFrameFoundation"/> 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.</para>
/// </summary>
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<int> _sampleCount;
public VulkanRenderFrameClearPhase(
ICurrentGpuFrameSource frames,
WorldTimeService worldTime,
WeatherSystem weather,
IRenderFramePortalStateSource portal,
ParticleVisibilityController particleVisibility,
Func<int> 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);
}
}
/// <summary>
/// Campaign V slice V6h: the Vulkan arm's world-scene phase.
///
/// <para>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.</para>
///
/// <para>Reporting <c>default</c> — zero visible, zero total, world not drawn —
/// is the honest answer and is what the lifecycle artifacts record.</para>
/// </summary>
internal sealed class VulkanWorldScenePhase : IWorldSceneFramePhase
{
public static VulkanWorldScenePhase Instance { get; } = new();
private VulkanWorldScenePhase()
{
}
public WorldRenderFrameOutcome Render(RenderFrameInput input) => default;
}
/// <summary>
/// Campaign V slice V6h: no GPU-timer bracket on the Vulkan arm.
///
/// <para><see cref="FrameProfilerGpuMeasurement"/> drives
/// <c>FrameProfiler</c>'s GL query ring, which is a GL-only instrument.
/// <see cref="IGpuDevice.Timers"/> 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.</para>
/// </summary>
internal sealed class NullRenderFrameGpuMeasurement : IRenderFrameGpuMeasurement
{
public static NullRenderFrameGpuMeasurement Instance { get; } = new();
private NullRenderFrameGpuMeasurement()
{
}
public void BeginFrame()
{
}
public void EndFrame()
{
}
}
/// <summary>
/// Campaign V slice V6h: the mesh backend on a backend that has none.
///
/// <para><c>WbMeshAdapter</c> owns an <c>OpenGLGraphicsDevice</c>, 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 <see cref="IsRenderDataReady"/> answers true because a mesh that is never
/// going to be drawn is never pending.</para>
/// </summary>
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;
}
/// <summary>
/// Campaign V slice V6h: the portal viewport on a backend with no portal tunnel.
///
/// <para><c>PortalTunnelPresentation</c> 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.</para>
/// </summary>
internal sealed class NullLocalPlayerTeleportPresentation
: ILocalPlayerTeleportPresentation
{
private readonly TeleportAnimSequencer _animation = new();
private readonly TeleportViewPlaneController _viewPlane = new();
/// <summary>Always false: with no tunnel renderer there is no replacement viewport.</summary>
public bool IsPortalViewportVisible => false;
public int CurrentTunnelFrame => 0;
public void Begin(Matrix4x4 projection)
{
_viewPlane.Begin(projection);
_animation.Begin(TeleportEntryKind.Portal);
}
public (TeleportAnimSnapshot Snapshot, IReadOnlyList<TeleportAnimEvent> 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()
{
}
}

View file

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

View file

@ -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;
/// <summary>
/// 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 <see cref="VulkanGpuDevice"/>.
///
/// <para>Extracted verbatim from <c>VulkanBringUpHost</c>, which was a second
/// <c>main()</c>: 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.</para>
///
/// <para><b>The swapchain deliberately stays here rather than inside
/// <see cref="VulkanGpuDevice"/></b>: 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 <see cref="IVulkanBackbuffer"/>.</para>
/// </summary>
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<string> _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<string> _instanceExtensions = [];
private bool _recreateAtFrameBoundary;
private bool _disposed;
private VulkanGraphicsContext(
IWindow window,
RuntimeOptions options,
GraphicalHostPlatformServices platform,
FramePacingPolicy pacing,
Action<string> 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));
}
/// <summary>
/// Opens the whole stack against an already-initialised Vulkan window.
/// Throws <see cref="NotSupportedException"/> when the capability gate
/// rejects the device, which <c>Program.cs</c> turns into exit code 4 exactly
/// as it does for GL. Any failure leaves zero Vulkan objects behind.
/// </summary>
/// <param name="requestedSampleCount">
/// MSAA samples asked for by the quality preset. Clamped to what the device
/// reports; 1 disables the multisampled scratch image entirely.
/// </param>
internal static VulkanGraphicsContext Acquire(
IWindow window,
RuntimeOptions options,
GraphicalHostPlatformServices platform,
FramePacingPolicy pacing,
int requestedSampleCount,
Action<string>? 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;
}
}
/// <summary>The record the gate evaluated. Non-null once <see cref="Acquire"/> returns.</summary>
internal VulkanCapabilityRecord? Capabilities { get; private set; }
/// <summary>The RHI device every renderer is constructed against.</summary>
internal VulkanGpuDevice Device =>
_gpuDevice ?? throw new InvalidOperationException(
"The Vulkan RHI device has not been created.");
/// <summary>Samples the backbuffer pass renders with. 1 when MSAA is off or unsupported.</summary>
internal int SampleCount { get; private set; } = 1;
internal uint Width => _swapchain?.Configuration?.Width ?? 0u;
internal uint Height => _swapchain?.Configuration?.Height ?? 0u;
/// <summary>Where the committed SPIR-V lives beside the binary.</summary>
internal static string ShaderSpirvDirectory() =>
Path.Combine(AppContext.BaseDirectory, "Rendering", "Shaders", "spv");
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
internal void NoteFrameClosed()
{
if (_gpuDevice is not null && !_gpuDevice.PresentSucceeded)
_recreateAtFrameBoundary = true;
}
/// <summary>Arms recreation, used when the acquire itself reported out-of-date.</summary>
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<string>((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<AllocationCallbacks>(
_instance.ToHandle(),
null).ToSurface();
}
private void SelectDeviceAndGate()
{
Silk.NET.Vulkan.Vk vk = _vk!;
IReadOnlyList<VulkanPhysicalDeviceCandidate> 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<VulkanQueueFamilyCandidate> 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<SurfaceFormatKHR> formats,
IReadOnlyList<PresentModeKHR> presentModes) = _swapchain.QuerySurface();
Vector2D<int> 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<int> 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);
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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;
}
}

View file

@ -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
{
}
/// <param name="gl">
/// The GL context, or null on a backend that has none. Campaign V slice V6h:
/// the UI path (<see cref="GetOrUploadRenderSurface"/>, <see cref="UploadRgba8"/>)
/// is entirely <see cref="IGpuDevice"/>-driven and runs on either backend,
/// while the world paths — the legacy <c>Texture2D</c> 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 <paramref name="bindless"/> 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.
/// </param>
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
}
}
/// <summary>
/// The GL context the world texture paths need. Campaign V slice V6h: a
/// Vulkan-composed cache serves the UI path through <see cref="IGpuDevice"/>
/// alone and never reaches here, so a failure names the slice that owns the
/// port rather than dereferencing null.
/// </summary>
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
}
}
/// <summary>
/// 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
/// <c>ACDREAM_DUMP_SURFACES</c> 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 <c>uint.MaxValue</c> because
/// GL hands out small ascending names and the two spaces share the
/// <c>_uploadMetadata</c> 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.
/// </summary>
private uint UploadAccountingName(IGpuTexture texture) =>
texture is GlGpuTexture glTexture
? glTexture.GlName
: _nextSyntheticUploadName--;
private uint _nextSyntheticUploadName = uint.MaxValue;
/// <summary>
/// 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
/// </summary>
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);
}

View file

@ -59,7 +59,8 @@ public sealed record RuntimeOptions(
StreamingWorkBudgetOptions StreamingWorkBudgets,
RenderBackendKind RenderBackend,
string? VulkanDeviceOverride,
string? VulkanForcedUnsupportedFeature)
string? VulkanForcedUnsupportedFeature,
bool VulkanCapabilityProbe)
{
/// <summary>
/// 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")));
}
/// <summary>

View file

@ -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,

View file

@ -253,7 +253,7 @@ public sealed class ContentEffectsAudioCompositionTests
Poses = new EntityEffectPoseRegistry();
Factory = new Factory();
Publication = new Publication();
Platform = new GameWindowPlatformResult<GL, IInputContext>(null!, null!);
Platform = new GameWindowPlatformResult<GameWindowGraphics, IInputContext>(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<GL, IInputContext> Platform { get; }
public GameWindowPlatformResult<GameWindowGraphics, IInputContext> Platform { get; }
public HostInputCameraResult Host { get; }
public ContentEffectsAudioCompositionPhase Phase() => new(

View file

@ -95,7 +95,7 @@ public sealed class HostInputCameraCompositionTests
IKeyboard keyboard = DispatchProxy.Create<IKeyboard, NullDeviceProxy>();
IMouse mouse = DispatchProxy.Create<IMouse, NullDeviceProxy>();
Input = new InputContext(keyboard, mouse);
Platform = new GameWindowPlatformResult<GL, IInputContext>(null!, Input);
Platform = new GameWindowPlatformResult<GameWindowGraphics, IInputContext>(TestGameWindowGraphics.OpenGl, Input);
ViewportAspect = new ViewportAspectState();
Framebuffer = new FramebufferResizeController(ViewportAspect);
Capture = new CaptureSource();
@ -111,7 +111,7 @@ public sealed class HostInputCameraCompositionTests
public List<HostInputCameraCompositionPoint> Points { get; } = [];
public InputContext Input { get; }
public GameWindowPlatformResult<GL, IInputContext> Platform { get; }
public GameWindowPlatformResult<GameWindowGraphics, IInputContext> 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);

View file

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

View file

@ -219,13 +219,15 @@ public sealed class SettingsDevToolsCompositionTests
null!,
null!,
null!,
null!,
null,
null,
null,
null,
_dispatcher,
camera,
null);
Platform = new GameWindowPlatformResult<GL, IInputContext>(null!, null!);
Platform = new GameWindowPlatformResult<GameWindowGraphics, IInputContext>(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<GL, IInputContext> Platform { get; }
public GameWindowPlatformResult<GameWindowGraphics, IInputContext> Platform { get; }
public ContentEffectsAudioResult Content { get; }
public SettingsDevToolsResult Compose() =>

View file

@ -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;
/// <summary>
/// Campaign V slice V6h: the graphics handle composition tests hand to a phase.
///
/// <para>The phases now select their backend arm from
/// <see cref="GameWindowGraphics"/> rather than from a bare <c>GL</c> 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.</para>
/// </summary>
internal sealed class TestGameWindowGraphics : GameWindowGraphics
{
private readonly GL? _gl;
private TestGameWindowGraphics(RenderBackendKind backend, GL? gl)
{
Backend = backend;
_gl = gl;
}
/// <summary>Selects the OpenGL arm, with a context token no test dereferences.</summary>
public static TestGameWindowGraphics OpenGl { get; } =
new(RenderBackendKind.Gl, new GL(new UnusableNativeContext()));
/// <summary>Selects the Vulkan arm: no GL context exists.</summary>
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()
{
}
}
}

View file

@ -193,7 +193,7 @@ public sealed class WorldRenderCompositionTests
if (point == _failurePoint)
throw new InvalidOperationException($"fault at {point}");
}).Compose(
new GameWindowPlatformResult<GL, IInputContext>(null!, null!),
new GameWindowPlatformResult<GameWindowGraphics, IInputContext>(TestGameWindowGraphics.OpenGl, null!),
Content,
new SettingsDevToolsResult(
QualitySettings.From(QualityPreset.High),

View file

@ -51,7 +51,7 @@ public sealed class GameWindowSlice8BoundaryTests
AssertAppearsInOrder(
body,
"GameWindowPlatformResult<GL, IInputContext> platform = AcquirePlatform();",
"GameWindowPlatformResult<GameWindowGraphics, IInputContext> 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(",