Skip to content

Keep heat haze when multisampling is enabled on the Vulkan backend - #166

Merged
themuffinator merged 1 commit into
themuffinator:mainfrom
rebelancap:vk-resolve-multisampled-capture
Sep 18, 2026
Merged

themuffinator merged 1 commit into
themuffinator:mainfrom
rebelancap:vk-resolve-multisampled-capture

Conversation

@rebelancap

@rebelancap rebelancap commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

The bug

VK_Exec_CopyRender refuses a multisampled source:

|| ( sourceEntry != NULL && sourceEntry->samples != VK_SAMPLE_COUNT_1_BIT ) ) {
    return false;

With r_multiSamples above 0 the scene renders into a multisampled target, so
that guard fires on every _currentRender capture. VK_Exec_CaptureCurrentRender
returns false, and VK_GuiExecutor_Draw3DView then skips every surface that
consumes _currentRender for the rest of the frame — heat haze, the warp_mask
stages, the fire distortion in effects/fire/*.fx. Turning antialiasing on
silently 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
vkCmdResolveImage into single-sample scratch of the source's own format, and
the existing blit reads the scratch. That is the same idiom
VK_Exec_ResolveRenderTargets already uses a few hundred lines down in the same
file, 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 vkCmdBlitImage keeps doing exactly what
it 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
vkCmdResolveImage does not already do.

The scratch image (VK_Image_AcquireResolveScratch, new, beside
VK_Image_MakeDepthCopyTarget) is TRANSFER_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_resolve inside a render pass, and no shipped
content 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 4 is a shipped preset. Instrumented frames on
an A19-class GPU, one frozen effects/fire/column_128.fx on mp/q4dm1:

state render passes _currentRender captures feedback failures warp_mask draw
MSAA 4, before 3 1 attempted, 0 succeeded 300/300 (source unusable) absent
MSAA 4, after 4 1 0/300 present
MSAA 0, after 2 1 0/300 present

The 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 compile on macOS arm64 (Clang 21, -Dmacos_openal_provider=system) is
clean 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
main hangs inside idSessionLocal::ExecuteMapChange after the map has finished
loading (6 warnings printed, then the loading-screen loop spins in
openQ4_BeginPresentationFrame/Sys_Sleep indefinitely). That reproduces
identically with a stock, unpatched renderer-vk module, so it is unrelated
to 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.md gains a Ready For Changelog entry, since this is
a visible rendering fix.

🤖 Generated with Claude Code

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

Copy link
Copy Markdown
Owner

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.

  • The resolve extent comes from VK_Exec_ActiveFramebufferWidth/Height. VK_Exec_SetRenderTarget sets those from the active colour entry, so the extent always matches the source image.
  • The scratch image's tracked layout means each frame's TRANSFER_SRC → TRANSFER_DST barrier orders it after the previous frame's blit on the same queue.
  • Recreation goes through the deferred-destroy queue.

I checked it on Windows with an RTX 4060 Laptop GPU. The runs used renderer_gameplay_benchmark.py on game/airdefense1 at 1280x720 with r_vkValidation 1, plus a local-only counter around VK_Exec_CaptureCurrentRender. Both Vulkan builds shared the same client; only renderer-vk differed.

renderer r_multiSamples _currentRender captures post-process pass mean pixel value
Vulkan, main 4 0 of 1000+ succeeded skipped 50.8
Vulkan, this PR 4 1400+, none failed ran: 7 surfaces, including warp_mask 37.5
Vulkan, main 0 all succeeded ran: 7 surfaces 37.3
OpenGL 4 – – 37.8

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, +set com_skipLoadingContinue 1 skips it, or +set com_loadingContinueAutoAdvance <ms> accepts it after a delay. The repo's smoke harnesses set these. If it still stalls with one of them set, please open an issue with the log.

Merging. Thank you!

@themuffinator
themuffinator merged commit 4e47314 into themuffinator:main Sep 18, 2026
17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants