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,