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