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

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