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

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