From 887de4aec21674e28ede6411579b808b581cecaa Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 28 Jul 2026 14:43:12 +0200 Subject: [PATCH] =?UTF-8?q?feat(render):=20Campaign=20V=20slice=20V6i-3=20?= =?UTF-8?q?commit=202=20=E2=80=94=20the=20Vulkan=20frame=20gets=20a=20worl?= =?UTF-8?q?d=20pass,=20and=20descriptors=20bind=20at=20draw=20time?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Composition/FrameRootComposition.cs | 13 +- .../Gpu/Vk/VulkanCompositionFramePhases.cs | 125 +++++++++++++----- .../Rendering/Gpu/Vk/VulkanGpuPassEncoder.cs | 41 +++++- 3 files changed, 138 insertions(+), 41 deletions(-) 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,