feat(render): Campaign V slice V6i-3 commit 2 — the Vulkan frame gets a world pass, and descriptors bind at draw time

Two structural prerequisites for the world renderers' submission arms. Both are
in Gpu/Vk only; the GL backend executes not one changed statement, and the
frame this commit produces is bit-identical to the one before it.

1. The clear merges into the world pass (plan §5.5.12 item 5).

V6h gave the clear phase a backbuffer pass of its own: clear, resolve, close.
Under MSAA that is a trap for whatever comes next. A multisampled backbuffer
pass renders into a scratch image and RESOLVES it into the acquired swapchain
image, and the scratch's store op is DONT_CARE — so a world pass that followed
and declared Load would load undefined contents and lose the clear entirely.
The world renderers cannot work around it by each opening their own pass, for
the same reason: every pass after the first would load a discarded scratch.

So the clear phase now computes the same RenderFrameFoundation from the same
world clock and weather owners and publishes only the COLOUR, through
VulkanBackbufferClearState; VulkanWorldScenePhase opens the one backbuffer pass
and clears as its load op, with Store=Resolve when the backbuffer is
multisampled. That makes it the frame's one clear and its one resolve. The
retained UI's pass is single-sampled and targets the swapchain image directly,
so it composites over the resolved result exactly as it did.

The clear stays unconditional because the frame graph makes it so rather than
because anything asserts it: RenderFrameOrchestrator runs resource preparation,
then the world phase, then private presentation, with no branch between. A frame
with no world still opens the pass and leaves a cleared backbuffer — which is
precisely the frame captured below, since nothing draws into the pass yet.

2. Descriptor sets bind at DRAW time, not at bind time.

V6i-1 derives a descriptor-set scope from the descriptor state itself, which is
what closes §5.5.8's one-binding-two-buffers hazard. But the encoder issued
vkCmdBindDescriptorSets from inside BindStorageBuffer/BindUniformBuffer, so the
arena resolved after EVERY bind. For the retained UI's one or two binds that is
free. For a world renderer binding ten buffers it materialises up to ten scopes
per draw — nine of them PARTIAL states no draw ever uses, each claiming a real
descriptor-set pair out of a fixed-size pool and each paying 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.

It is 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) — the same property that lets a bucketed pass change pipeline for free.
The pass still opens with all three sets bound, which is V6h's fix for
VUID-vkCmdDraw-None-08600 and stays exactly as it was.

Gates. Release build green. App tests 4,112 passed / 3 skipped, unchanged from
commit 1. Strict GL offline pixel gate against commit 1: 3.73e-05 (21 differing
pixels of 563,200), inside the documented 9-31 px control band — expected, since
no GL file is touched. One offline Vulkan run with VK_LAYER_KHRONOS_validation
proven inserted by the loader: zero validation errors, zero warnings, and no
[shutdown] diagnostic on either stream. The captured Vulkan frame is compared
against commit 1's rather than merely eyeballed: 0 differing pixels of 921,600,
maximum channel delta 0 — bit-identical across the merge, which is the strongest
available evidence that moving the clear into the world pass changed nothing
about what is drawn.

What this does NOT do: draw a world. See the report for the enumerated remainder.

No divergence-register row: no retail-facing behaviour changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-28 14:43:12 +02:00
parent fe8abacfc6
commit 887de4aec2
3 changed files with 138 additions and 41 deletions

View file

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

View file

@ -12,41 +12,41 @@ namespace AcDream.App.Rendering.Gpu.Vk;
///
/// <para>The GL arm establishes frame-global capability state and issues
/// <c>glClear</c> against framebuffer 0. Vulkan has neither: a clear is a pass
/// load-op, and there is no global state to restore. So this opens one
/// backbuffer pass with <see cref="GpuLoadOp.Clear"/>, draws nothing, and closes
/// it — which resolves the multisampled scratch image into the acquired
/// swapchain image and leaves it in exactly the state the retained UI's own
/// load/store pass expects.</para>
/// load-op, and there is no global state to restore.</para>
///
/// <para>It computes the same <see cref="RenderFrameFoundation"/> the GL arm
/// does, from the same world clock and weather owners, so every consumer
/// downstream — portal viewport visibility, sky keyframe, atmosphere — reads
/// identical values on both backends.</para>
/// <para><b>Slice V6i-3 stopped it opening a pass of its own.</b> V6h's phase
/// opened a backbuffer pass, cleared, and closed it. Under MSAA that pass
/// resolves into the swapchain image and stores <c>DONT_CARE</c> into the
/// multisampled scratch, so the world pass that follows cannot <c>Load</c> 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
/// <see cref="VulkanBackbufferClearState"/>. Nothing else about it moved: it
/// computes the same <see cref="RenderFrameFoundation"/> from the same world
/// clock and weather owners, so every consumer downstream — portal viewport
/// visibility, sky keyframe, atmosphere — reads identical values on both
/// backends.</para>
/// </summary>
internal sealed class VulkanRenderFrameClearPhase : IRenderFrameClearPhase
{
private readonly ICurrentGpuFrameSource _frames;
private readonly WorldTimeService _worldTime;
private readonly WeatherSystem _weather;
private readonly IRenderFramePortalStateSource _portal;
private readonly ParticleVisibilityController _particleVisibility;
private readonly Func<int> _sampleCount;
private readonly VulkanBackbufferClearState _clear;
public VulkanRenderFrameClearPhase(
ICurrentGpuFrameSource frames,
WorldTimeService worldTime,
WeatherSystem weather,
IRenderFramePortalStateSource portal,
ParticleVisibilityController particleVisibility,
Func<int> 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);
}
}
/// <summary>
/// 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.
///
/// <para>There is nothing to draw. Every world renderer — terrain, statics,
/// EnvCells, sky, particles, the portal depth mask — is still raw GL, and the
/// slice that ports them is V4t plus the world arm behind it. The phase exists
/// so the frame spine's contract is identical on both backends: the world phase
/// runs, reports what it drew, and the private-presentation phase composites the
/// retained UI over whatever it left behind.</para>
/// <para><b>Why the clear moved here.</b> 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 <c>DONT_CARE</c> into the
/// scratch — so a world pass that followed and declared <c>Load</c> 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.</para>
///
/// <para>Reporting <c>default</c> — zero visible, zero total, world not drawn —
/// is the honest answer and is what the lifecycle artifacts record.</para>
/// <para>Depth is <c>Clear</c>/<c>DontCare</c> — nothing reads it after the
/// frame — and the colour store is <c>Resolve</c> 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.</para>
///
/// <para>The phase still reports <c>default</c> — 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.</para>
/// </summary>
internal sealed class VulkanWorldScenePhase : IWorldSceneFramePhase
{
public static VulkanWorldScenePhase Instance { get; } = new();
private readonly ICurrentGpuFrameSource _frames;
private readonly VulkanBackbufferClearState _clear;
private readonly Func<int> _sampleCount;
private VulkanWorldScenePhase()
public VulkanWorldScenePhase(
ICurrentGpuFrameSource frames,
VulkanBackbufferClearState clear,
Func<int> 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;
}
}
/// <summary>
/// Campaign V slice V6i-3: the colour the world pass clears to.
///
/// <para>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 <see cref="VulkanWorldScenePhase"/> for why.</para>
///
/// <para>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.</para>
/// </summary>
internal sealed class VulkanBackbufferClearState
{
internal System.Numerics.Vector4 ClearColor { get; set; } = new(0f, 0f, 0f, 1f);
}
/// <summary>

View file

@ -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);
}
/// <summary>
/// Campaign V slice V6i-3: descriptor sets are bound at DRAW time, not at
/// bind time.
///
/// <para>Each bind used to issue its own <c>vkCmdBindDescriptorSets</c>, 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
/// <c>vkUpdateDescriptorSets</c>. 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.</para>
///
/// <para>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).</para>
/// </summary>
private void FlushBindings() => _bindings.Bind(_commands, _device);
/// <summary>
/// A scoped clear inside the live render-pass instance — retail's interior
/// depth clear. Reached only through <see cref="VulkanWorldPassScope"/>; see
/// its documentation for why the pinned contract does not carry this verb.
/// </summary>
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,