diff --git a/src/AcDream.App/Composition/FrameRootComposition.cs b/src/AcDream.App/Composition/FrameRootComposition.cs
index 3918a3ec..4eba21ac 100644
--- a/src/AcDream.App/Composition/FrameRootComposition.cs
+++ b/src/AcDream.App/Composition/FrameRootComposition.cs
@@ -250,6 +250,11 @@ internal sealed class FrameRootCompositionPhase
RenderFrameGlStateController? renderFrameGlState = gl is null
? null
: new RenderFrameGlStateController(new SilkRenderFrameGlStateApi(gl));
+ // Campaign V slice V6i-3: on Vulkan the frame's clear is a load op of the
+ // world pass rather than a pass of its own, so the two phases share this
+ // one value. See VulkanWorldScenePhase for why the merge is required
+ // rather than tidier.
+ var vulkanClear = new AcDream.App.Rendering.Gpu.Vk.VulkanBackbufferClearState();
var renderFrameLivePreparation =
new RuntimeRenderFrameLivePreparation(
foundation.TextureCache,
@@ -275,12 +280,11 @@ internal sealed class FrameRootCompositionPhase
"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);
+ vulkanClear);
var renderFrameResources = new RenderFrameResourceController(
host.FrameSlots,
new RuntimeRenderFrameBeginResources(
@@ -304,7 +308,10 @@ internal sealed class FrameRootCompositionPhase
d.EffectPoses,
live.EntityEffects);
IWorldSceneFramePhase worldSceneRenderer =
- AcDream.App.Rendering.Gpu.Vk.VulkanWorldScenePhase.Instance;
+ new AcDream.App.Rendering.Gpu.Vk.VulkanWorldScenePhase(
+ host.GpuFrameLifetime,
+ vulkanClear,
+ () => d.Graphics.Vulkan?.SampleCount ?? 1);
CurrentRenderSceneOracle? currentRenderSceneOracle =
interaction.RetainedUi?.Screenshots is not null
&& d.Options.AutomationArtifactDirectory is not null
diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanCompositionFramePhases.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanCompositionFramePhases.cs
index 92e9e907..ce0fa9d1 100644
--- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanCompositionFramePhases.cs
+++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanCompositionFramePhases.cs
@@ -12,41 +12,41 @@ namespace AcDream.App.Rendering.Gpu.Vk;
///
/// The GL arm establishes frame-global capability state and issues
/// glClear against framebuffer 0. Vulkan has neither: a clear is a pass
-/// load-op, and there is no global state to restore. So this opens one
-/// backbuffer pass with , draws nothing, and closes
-/// it — which resolves the multisampled scratch image into the acquired
-/// swapchain image and leaves it in exactly the state the retained UI's own
-/// load/store pass expects.
+/// load-op, and there is no global state to restore.
///
-/// It computes the same the GL arm
-/// does, from the same world clock and weather owners, so every consumer
-/// downstream — portal viewport visibility, sky keyframe, atmosphere — reads
-/// identical values on both backends.
+/// Slice V6i-3 stopped it opening a pass of its own. V6h's phase
+/// opened a backbuffer pass, cleared, and closed it. Under MSAA that pass
+/// resolves into the swapchain image and stores DONT_CARE into the
+/// multisampled scratch, so the world pass that follows cannot Load what
+/// it left — plan §5.5.12 item 5. The clear is therefore MERGED into the world
+/// pass, and this phase hands the colour forward through
+/// . Nothing else about it moved: it
+/// computes the same from the same world
+/// clock and weather owners, so every consumer downstream — portal viewport
+/// visibility, sky keyframe, atmosphere — reads identical values on both
+/// backends.
///
internal sealed class VulkanRenderFrameClearPhase : IRenderFrameClearPhase
{
- private readonly ICurrentGpuFrameSource _frames;
private readonly WorldTimeService _worldTime;
private readonly WeatherSystem _weather;
private readonly IRenderFramePortalStateSource _portal;
private readonly ParticleVisibilityController _particleVisibility;
- private readonly Func _sampleCount;
+ private readonly VulkanBackbufferClearState _clear;
public VulkanRenderFrameClearPhase(
- ICurrentGpuFrameSource frames,
WorldTimeService worldTime,
WeatherSystem weather,
IRenderFramePortalStateSource portal,
ParticleVisibilityController particleVisibility,
- Func sampleCount)
+ VulkanBackbufferClearState clear)
{
- _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));
+ _clear = clear ?? throw new ArgumentNullException(nameof(clear));
}
public RenderFrameFoundation Clear()
@@ -68,41 +68,94 @@ internal sealed class VulkanRenderFrameClearPhase : IRenderFrameClearPhase
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()));
- }
-
+ _clear.ClearColor = clear;
return new RenderFrameFoundation(portalViewportVisible, sky, atmosphere);
}
}
///
-/// Campaign V slice V6h: the Vulkan arm's world-scene phase.
+/// Campaign V slice V6i-3: the Vulkan arm's world-scene phase, and the frame's
+/// one backbuffer pass.
///
-/// 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.
+/// Why the clear moved here. V6h gave the clear phase a pass of its
+/// own. Under MSAA a backbuffer pass renders into a multisampled scratch image
+/// and RESOLVES it into the swapchain image, storing DONT_CARE into the
+/// scratch — so a world pass that followed and declared Load would load
+/// undefined contents and lose everything the clear established. Plan §5.5.12
+/// item 5 recorded exactly that and prescribed the fix: merge the clear into the
+/// world pass, clear and resolve in one, with the clear phase handing its colour
+/// forward. That is what this is, and it is the structural prerequisite for the
+/// world renderers' submission arms — they cannot each open a pass of their own
+/// on this backend, so there has to be one for them to record into.
///
-/// Reporting default — zero visible, zero total, world not drawn —
-/// is the honest answer and is what the lifecycle artifacts record.
+/// Depth is Clear/DontCare — nothing reads it after the
+/// frame — and the colour store is Resolve whenever the backbuffer is
+/// multisampled, which makes this the one resolve in the frame. The retained
+/// UI's own pass is single-sampled and targets the swapchain image directly, so
+/// it composites over the resolved result exactly as before.
+///
+/// The phase still reports default — zero visible, zero total,
+/// world not drawn — because nothing draws into the pass yet. That remains the
+/// honest answer and is what the lifecycle artifacts record.
///
internal sealed class VulkanWorldScenePhase : IWorldSceneFramePhase
{
- public static VulkanWorldScenePhase Instance { get; } = new();
+ private readonly ICurrentGpuFrameSource _frames;
+ private readonly VulkanBackbufferClearState _clear;
+ private readonly Func _sampleCount;
- private VulkanWorldScenePhase()
+ public VulkanWorldScenePhase(
+ ICurrentGpuFrameSource frames,
+ VulkanBackbufferClearState clear,
+ Func sampleCount)
{
+ _frames = frames ?? throw new ArgumentNullException(nameof(frames));
+ _clear = clear ?? throw new ArgumentNullException(nameof(clear));
+ _sampleCount = sampleCount ?? throw new ArgumentNullException(nameof(sampleCount));
}
- public WorldRenderFrameOutcome Render(RenderFrameInput input) => default;
+ public WorldRenderFrameOutcome Render(RenderFrameInput input)
+ {
+ IGpuFrame frame = _frames.CurrentFrame
+ ?? throw new InvalidOperationException(
+ "The Vulkan world phase requires an open IGpuFrame (see GpuDeviceFrameLifetime).");
+
+ int samples = _sampleCount();
+ using IGpuPassEncoder encoder = frame.BeginPass(new GpuPassDescription
+ {
+ Name = "vk-world",
+ Color = new GpuColorAttachment(
+ Target: null,
+ Load: GpuLoadOp.Clear,
+ Store: samples > 1 ? GpuStoreOp.Resolve : GpuStoreOp.Store,
+ ClearColor: _clear.ClearColor),
+ Depth = new GpuDepthAttachment(
+ Load: GpuLoadOp.Clear,
+ Store: GpuStoreOp.DontCare,
+ ClearDepth: 1f,
+ ClearStencil: 0),
+ SampleCount = samples,
+ });
+
+ _ = encoder;
+ return default;
+ }
+}
+
+///
+/// Campaign V slice V6i-3: the colour the world pass clears to.
+///
+/// The clear phase computes it from the world clock and weather owners, as
+/// it always did, and publishes it here instead of issuing a pass of its own —
+/// see for why.
+///
+/// The clear stays unconditional because the frame graph makes it so: the
+/// orchestrator runs the world phase on every frame, between resource
+/// preparation and private presentation, with no branch in between.
+///
+internal sealed class VulkanBackbufferClearState
+{
+ internal System.Numerics.Vector4 ClearColor { get; set; } = new(0f, 0f, 0f, 1f);
}
///
diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuPassEncoder.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuPassEncoder.cs
index d3dcbd9d..4117d681 100644
--- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuPassEncoder.cs
+++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuPassEncoder.cs
@@ -109,14 +109,48 @@ internal sealed unsafe class VulkanGpuPassEncoder : IGpuPassEncoder
{
ThrowIfClosed();
_bindings.SetStorage(binding, RequireBuffer(buffer), offsetBytes, sizeBytes);
- _bindings.Bind(_commands, _device);
}
public void BindUniformBuffer(uint binding, IGpuBuffer buffer, uint offsetBytes, uint sizeBytes)
{
ThrowIfClosed();
_bindings.SetUniform(binding, RequireBuffer(buffer), offsetBytes, sizeBytes);
- _bindings.Bind(_commands, _device);
+ }
+
+ ///
+ /// Campaign V slice V6i-3: descriptor sets are bound at DRAW time, not at
+ /// bind time.
+ ///
+ /// Each bind used to issue its own vkCmdBindDescriptorSets, which
+ /// was correct and, for the retained UI's one or two binds, free. The world
+ /// arm made it expensive in a way that matters structurally rather than in
+ /// milliseconds: V6i-1's arena derives a descriptor-set scope from the
+ /// descriptor state, so binding ten buffers one at a time materialised up to
+ /// ten scopes per draw — nine of them PARTIAL states no draw ever used, each
+ /// claiming a real descriptor-set pair and a full round of
+ /// vkUpdateDescriptorSets. Recording the state and resolving it once,
+ /// where the draw needs it, yields exactly one scope per renderer, which is
+ /// what the arena was designed to produce.
+ ///
+ /// Legal because descriptor-set binding is independent of pipeline
+ /// binding when the layouts are compatible, and acdream has ONE pipeline
+ /// layout by design (§4.4).
+ ///
+ private void FlushBindings() => _bindings.Bind(_commands, _device);
+
+ ///
+ /// A scoped clear inside the live render-pass instance — retail's interior
+ /// depth clear. Reached only through ; see
+ /// its documentation for why the pinned contract does not carry this verb.
+ ///
+ internal void ClearAttachments(
+ uint attachmentCount,
+ ClearAttachment* attachments,
+ uint rectCount,
+ ClearRect* rects)
+ {
+ ThrowIfClosed();
+ _device.Api.CmdClearAttachments(_commands, attachmentCount, attachments, rectCount, rects);
}
public void BindVertexBuffer(IGpuBuffer buffer, uint offsetBytes)
@@ -193,6 +227,7 @@ internal sealed unsafe class VulkanGpuPassEncoder : IGpuPassEncoder
{
ThrowIfClosed();
RequirePipeline();
+ FlushBindings();
_device.Api.CmdDrawIndexed(_commands, indexCount, instanceCount, firstIndex, vertexOffset, firstInstance);
}
@@ -200,6 +235,7 @@ internal sealed unsafe class VulkanGpuPassEncoder : IGpuPassEncoder
{
ThrowIfClosed();
RequirePipeline();
+ FlushBindings();
_device.Api.CmdDraw(_commands, vertexCount, instanceCount, firstVertex, firstInstance);
}
@@ -209,6 +245,7 @@ internal sealed unsafe class VulkanGpuPassEncoder : IGpuPassEncoder
RequirePipeline();
if (drawCount == 0)
return;
+ FlushBindings();
_device.Api.CmdDrawIndexedIndirect(
_commands,
RequireBuffer(commands).Handle,