Keep heat haze when multisampling is enabled on the Vulkan backend - #166
Conversation
With r_multiSamples above 0 the scene renders into a multisampled target, and vkCmdBlitImage cannot read one, so VK_Exec_CopyRender rejected the source and failed the _currentRender capture outright. Every surface that consumes _currentRender was then skipped for the rest of the frame by VK_GuiExecutor_Draw3DView, which silently deleted heat haze, the warp_mask stages and the fire distortion in effects/fire/*.fx whenever antialiasing was on. Resolve a multisampled colour source into single-sample scratch of the source's own format with one vkCmdResolveImage -- the same idiom VK_Exec_ResolveRenderTargets already uses in this file -- and blit from that instead. A resolve can neither convert format nor flip, and the capture destination is the front-end's own format with a bottom-left GL rect, so the existing converting, Y-flipping blit still does that half of the work. The scratch image is allocated once at the render target size, reused every frame, recreated through the deferred-destroy queue when size or format changes, and retired by VK_Image_ShutdownAll; an allocation failure warns once and fails the capture the way the other failure paths do. Depth capture is unchanged: a multisampled depth source is still refused, since resolving it needs VK_KHR_depth_stencil_resolve inside a render pass and no shipped content captures depth from an MSAA target. The added resolve plus the capture's pass break costs roughly 1 ms at 2560x1440 with 4x multisampling on Apple M-class and A19-class GPUs.
|
Thanks for this, and for the careful write-up. The diagnosis is right and the fix is sound: resolve into single-sample scratch, then keep the existing format-converting, Y-flipping blit.
I checked it on Windows with an RTX 4060 Laptop GPU. The runs used
On stock content the regression was bigger than heat haze. With the capture failing, the whole post-process pass was skipped, so the opening of airdefense1 rendered visibly brighter than on OpenGL or on Vulkan without MSAA. With this change the MSAA frame matches both references to within normal cross-backend noise. No run printed a validation message, and the full commit-validation matrix is green. About the hang you hit: that loop is the single-player loading-screen continue gate, which waits for a key press or click after the map finishes loading. For unattended runs, Merging. Thank you! |
The bug
VK_Exec_CopyRenderrefuses a multisampled source:With
r_multiSamplesabove 0 the scene renders into a multisampled target, sothat guard fires on every
_currentRendercapture.VK_Exec_CaptureCurrentRenderreturns false, and
VK_GuiExecutor_Draw3DViewthen skips every surface thatconsumes
_currentRenderfor the rest of the frame — heat haze, thewarp_maskstages, the fire distortion in
effects/fire/*.fx. Turning antialiasing onsilently deletes those effects, on every platform running the Vulkan backend.
Nothing in the log says so; the capture just quietly fails.
The fix
Resolve first, then blit. A multisampled colour source is resolved with one
vkCmdResolveImageinto single-sample scratch of the source's own format, andthe existing blit reads the scratch. That is the same idiom
VK_Exec_ResolveRenderTargetsalready uses a few hundred lines down in the samefile, so nothing new is asked of the driver.
Two steps rather than one because a resolve can neither convert format nor flip,
and the capture destination is the front-end's own format while the capture rect
is expressed bottom-left (GL orientation). The resolve collapses the samples; the
existing format-converting, Y-flipping
vkCmdBlitImagekeeps doing exactly whatit does at one sample. A one-off dynamic-rendering pass with a resolve attachment
was considered and rejected — it adds a render pass and buys nothing
vkCmdResolveImagedoes not already do.The scratch image (
VK_Image_AcquireResolveScratch, new, besideVK_Image_MakeDepthCopyTarget) isTRANSFER_DST|TRANSFER_SRC, one sample,allocated once at the render-target size, reused every frame, recreated through
the deferred-destroy queue when size or format changes, and retired by
VK_Image_ShutdownAll. It is never sampled and never enters the image table.Allocation failure warns once, restores the layouts, reopens the pass and fails
the capture exactly the way the existing failure paths do.
Depth capture is unchanged. A multisampled depth source is still refused: it
would need
VK_KHR_depth_stencil_resolveinside a render pass, and no shippedcontent captures depth from an MSAA target. The guard now applies the
single-sample requirement only to
copyDepth.Evidence
Found and fixed while bringing the Vulkan backend up on iOS/visionOS against
MoltenVK, where
r_multiSamples 4is a shipped preset. Instrumented frames onan A19-class GPU, one frozen
effects/fire/column_128.fxonmp/q4dm1:_currentRendercaptureswarp_maskdrawThe MSAA-4 frame gains a pass because the capture now actually happens — the same
pass break the MSAA-0 frame has always paid. Cost measured on hardware: the
resolve plus the capture's LOAD/STORE continuation is roughly 1 ms at 2560x1440
with 4x, on Apple M-class and A19-class GPUs.
Validation
meson compileon macOS arm64 (Clang 21,-Dmacos_openal_provider=system) isclean for the whole tree including the Vulkan module. I could not get a
gameplay capture out of this checkout to accompany it: on this machine, current
mainhangs insideidSessionLocal::ExecuteMapChangeafter the map has finishedloading (
6 warningsprinted, then the loading-screen loop spins inopenQ4_BeginPresentationFrame/Sys_Sleepindefinitely). That reproducesidentically with a stock, unpatched
renderer-vkmodule, so it is unrelatedto this change — but it did keep me from running the SP/MP gameplay check the
contributor guide asks for on this tree. The in-game evidence above is from the
same code on the downstream build. Happy to re-run here if you know the trick, and
happy to file the map-change hang separately if it is not already known.
docs/dev/release-completion.mdgains a Ready For Changelog entry, since this isa visible rendering fix.
🤖 Generated with Claude Code