Files
dustinspace217andDustinspace217 e66fe223bf Vulkan: use one rendering-finished semaphore per swap chain image (#7943)
Each VulkanRenderFrame signaled its own rendering-finished semaphore and
presented with it. The frame's fence only says the submit has finished, not
that the presentation engine has consumed the semaphore, so the next submit
on that frame could signal a semaphore that is still pending, which the
validation layer reports as VUID-vkQueueSubmit-pSignalSemaphores-00067.

Move the semaphore to the renderer, one per swap chain image, recreated with
the swap chain, and pass the one for the acquired image to submitAndPresent.
Acquiring an image again means its previous present has been consumed.

Co-authored-by: Dustinspace217 <[email protected]>
2026-11-04 18:21:32 +01:00

974 lines
37 KiB
C++

#include "VulkanRenderer.h"
#include "VulkanBarrier.h"
#include "VulkanMemory.h"
#include "VulkanBuffer.h"
#include "VulkanTexture.h"
#include "VulkanPostProcessing.h"
#include "cmdline/cmdline.h"
#include "graphics/grinternal.h"
#include "graphics/post_processing.h"
#include "graphics/util/pixel_swizzle.h"
#include "backends/imgui_impl_vulkan.h"
#include "graphics/2d.h"
#include "lighting/lighting.h"
#include "mod_table/mod_table.h"
#include <SDL3/SDL_vulkan.h>
VULKAN_HPP_DEFAULT_DISPATCH_LOADER_DYNAMIC_STORAGE
extern float flFrametime;
namespace graphics::vulkan {
VulkanRenderer::VulkanRenderer(std::unique_ptr<os::GraphicsOperations> graphicsOps)
: m_graphicsOps(std::move(graphicsOps))
{
}
void VulkanRenderer::createCompositionResources()
{
// Free any previous composition resources (swap chain recreation path)
m_compositionImageViews.clear();
m_compositionImages.clear();
for (auto& alloc : m_compositionAllocations) {
if (alloc.isValid()) {
m_memoryManager->freeAllocation(alloc);
}
}
m_compositionAllocations.clear();
const size_t count = m_swapChainImageViews.size();
m_compositionImages.reserve(count);
m_compositionImageViews.reserve(count);
m_compositionAllocations.reserve(count);
for (size_t i = 0; i < count; ++i) {
vk::ImageCreateInfo imageInfo;
imageInfo.imageType = vk::ImageType::e2D;
imageInfo.format = HDR_COLOR_FORMAT;
imageInfo.extent = vk::Extent3D(m_renderExtent.width, m_renderExtent.height, 1);
imageInfo.mipLevels = 1;
imageInfo.arrayLayers = 1;
imageInfo.samples = vk::SampleCountFlagBits::e1;
imageInfo.tiling = vk::ImageTiling::eOptimal;
imageInfo.usage = vk::ImageUsageFlagBits::eColorAttachment | vk::ImageUsageFlagBits::eSampled |
vk::ImageUsageFlagBits::eTransferSrc;
imageInfo.sharingMode = vk::SharingMode::eExclusive;
imageInfo.initialLayout = vk::ImageLayout::eUndefined;
auto image = m_device->createImageUnique(imageInfo);
VulkanAllocation alloc{};
m_memoryManager->allocateImageMemory(image.get(), MemoryUsage::GpuOnly, alloc);
vk::ImageViewCreateInfo viewInfo;
viewInfo.image = image.get();
viewInfo.viewType = vk::ImageViewType::e2D;
viewInfo.format = HDR_COLOR_FORMAT;
viewInfo.subresourceRange = {vk::ImageAspectFlagBits::eColor, 0, 1, 0, 1};
auto view = m_device->createImageViewUnique(viewInfo);
m_compositionImages.push_back(std::move(image));
m_compositionAllocations.push_back(alloc);
m_compositionImageViews.push_back(std::move(view));
}
// Sampler used by the output-encode pass to read the composition image.
if (!m_compositionSampler) {
vk::SamplerCreateInfo sampInfo;
sampInfo.magFilter = vk::Filter::eNearest;
sampInfo.minFilter = vk::Filter::eNearest;
sampInfo.mipmapMode = vk::SamplerMipmapMode::eNearest;
sampInfo.addressModeU = vk::SamplerAddressMode::eClampToEdge;
sampInfo.addressModeV = vk::SamplerAddressMode::eClampToEdge;
sampInfo.addressModeW = vk::SamplerAddressMode::eClampToEdge;
m_compositionSampler = m_device->createSamplerUnique(sampInfo);
}
}
void VulkanRenderer::createEncodeRenderPass()
{
// Color-only pass that writes the actual swap chain image. The fullscreen
// encode draw overwrites the whole image, so the prior contents are discarded.
vk::AttachmentDescription colorAttachment;
colorAttachment.format = m_swapChainImageFormat;
colorAttachment.samples = vk::SampleCountFlagBits::e1;
colorAttachment.loadOp = vk::AttachmentLoadOp::eDontCare;
colorAttachment.storeOp = vk::AttachmentStoreOp::eStore;
colorAttachment.stencilLoadOp = vk::AttachmentLoadOp::eDontCare;
colorAttachment.stencilStoreOp = vk::AttachmentStoreOp::eDontCare;
colorAttachment.initialLayout = vk::ImageLayout::eUndefined;
colorAttachment.finalLayout = vk::ImageLayout::ePresentSrcKHR;
vk::AttachmentReference colorRef;
colorRef.attachment = 0;
colorRef.layout = vk::ImageLayout::eColorAttachmentOptimal;
vk::SubpassDescription subpass;
subpass.pipelineBindPoint = vk::PipelineBindPoint::eGraphics;
subpass.colorAttachmentCount = 1;
subpass.pColorAttachments = &colorRef;
// Make the composition image (written by the main pass) available for
// sampling, and order against the swap chain image's prior presentation.
vk::SubpassDependency dependency;
dependency.srcSubpass = VK_SUBPASS_EXTERNAL;
dependency.dstSubpass = 0;
dependency.srcStageMask = vk::PipelineStageFlagBits::eColorAttachmentOutput
| vk::PipelineStageFlagBits::eFragmentShader;
dependency.dstStageMask = vk::PipelineStageFlagBits::eColorAttachmentOutput
| vk::PipelineStageFlagBits::eFragmentShader;
dependency.srcAccessMask = vk::AccessFlagBits::eColorAttachmentWrite;
dependency.dstAccessMask = vk::AccessFlagBits::eColorAttachmentWrite
| vk::AccessFlagBits::eShaderRead;
vk::RenderPassCreateInfo rpInfo;
rpInfo.attachmentCount = 1;
rpInfo.pAttachments = &colorAttachment;
rpInfo.subpassCount = 1;
rpInfo.pSubpasses = &subpass;
rpInfo.dependencyCount = 1;
rpInfo.pDependencies = &dependency;
m_encodeRenderPass = m_device->createRenderPassUnique(rpInfo);
}
void VulkanRenderer::createFrameBuffers()
{
m_swapChainFramebuffers.clear();
m_encodeFramebuffers.clear();
// Composition framebuffers: color = fp16 composition image, depth shared.
// Indexed by swap chain image so each in-flight frame uses its own image.
m_swapChainFramebuffers.reserve(m_compositionImageViews.size());
for (const auto& compView : m_compositionImageViews) {
const vk::ImageView attachments[] = {
compView.get(),
m_depthImageView.get(),
};
vk::FramebufferCreateInfo framebufferInfo;
framebufferInfo.renderPass = m_renderPass.get();
framebufferInfo.attachmentCount = 2;
framebufferInfo.pAttachments = attachments;
framebufferInfo.width = m_renderExtent.width;
framebufferInfo.height = m_renderExtent.height;
framebufferInfo.layers = 1;
m_swapChainFramebuffers.push_back(m_device->createFramebufferUnique(framebufferInfo));
}
// Encode framebuffers: color = actual swap chain image.
m_encodeFramebuffers.reserve(m_swapChainImageViews.size());
for (const auto& scView : m_swapChainImageViews) {
const vk::ImageView attachments[] = { scView.get() };
vk::FramebufferCreateInfo framebufferInfo;
framebufferInfo.renderPass = m_encodeRenderPass.get();
framebufferInfo.attachmentCount = 1;
framebufferInfo.pAttachments = attachments;
framebufferInfo.width = m_swapChainExtent.width;
framebufferInfo.height = m_swapChainExtent.height;
framebufferInfo.layers = 1;
m_encodeFramebuffers.push_back(m_device->createFramebufferUnique(framebufferInfo));
}
}
vk::Image VulkanRenderer::getCurrentCompositionImage() const
{
if (m_currentSwapChainImage >= m_compositionImages.size()) {
return {};
}
return m_compositionImages[m_currentSwapChainImage].get();
}
void VulkanRenderer::encodeToSwapChain()
{
if (!m_postProcessor || m_currentSwapChainImage >= m_swapChainImages.size()) {
return;
}
if (m_currentSwapChainImage >= m_encodeFramebuffers.size()) {
return;
}
const float gamma = Cmdline_no_set_gamma ? 1.0f : Gr_gamma;
// HDR10: PQ/BT.2020 encode plus the user gamma slider, must run as a
// shader (encodeOutput()).
if (m_hdrActive) {
m_postProcessor->encodeOutput(
m_currentCommandBuffer,
m_encodeRenderPass.get(),
m_encodeFramebuffers[m_currentSwapChainImage].get(),
m_swapChainExtent,
m_compositionImageViews[m_currentSwapChainImage].get(),
m_compositionSampler.get(),
Gr_hdr_paperwhite_nits,
Gr_hdr_peak_nits,
gamma);
return;
}
// SDR: the composition image already carries the final display-ready
// sRGB-encoded frame (3D scene + menus/HUD alike). Always run the
// gamma-encode shader here -- rather than a raw blit -- so the user
// gamma/brightness slider has a uniform, whole-frame effect regardless
// of device blit support.
m_postProcessor->encodeOutputSdr(
m_currentCommandBuffer,
m_encodeRenderPass.get(),
m_encodeFramebuffers[m_currentSwapChainImage].get(),
m_swapChainExtent,
m_compositionImageViews[m_currentSwapChainImage].get(),
m_compositionSampler.get(),
gamma);
}
vk::Format VulkanRenderer::findDepthFormat()
{
// Prefer D32_SFLOAT for best precision, fall back to D32_SFLOAT_S8 or D24_UNORM_S8
const vk::Format candidates[] = {
vk::Format::eD32Sfloat,
vk::Format::eD32SfloatS8Uint,
vk::Format::eD24UnormS8Uint,
};
for (auto format : candidates) {
auto props = m_physicalDevice.getFormatProperties(format);
if (props.optimalTilingFeatures & vk::FormatFeatureFlagBits::eDepthStencilAttachment) {
return format;
}
}
// Should never happen on any real GPU
Error(LOCATION, "Failed to find supported depth format!");
return vk::Format::eD32Sfloat;
}
void VulkanRenderer::createDepthResources()
{
const vk::Format depthFormat = findDepthFormat();
// The render passes (m_renderPass, scene/G-buffer passes, ...) bake in the
// depth format, and they are deliberately kept alive across swap chain
// recreation. A driver changing its supported depth formats mid-session
// would make them all incompatible with the new attachment.
if (m_depthFormat != vk::Format::eUndefined && depthFormat != m_depthFormat) {
Error(LOCATION, "Vulkan: depth format changed across swap chain recreation (%d -> %d)!",
static_cast<int>(m_depthFormat), static_cast<int>(depthFormat));
}
m_depthFormat = depthFormat;
// Create depth image
vk::ImageCreateInfo imageInfo;
imageInfo.imageType = vk::ImageType::e2D;
imageInfo.format = m_depthFormat;
imageInfo.extent.width = m_renderExtent.width;
imageInfo.extent.height = m_renderExtent.height;
imageInfo.extent.depth = 1;
imageInfo.mipLevels = 1;
imageInfo.arrayLayers = 1;
imageInfo.samples = vk::SampleCountFlagBits::e1;
imageInfo.tiling = vk::ImageTiling::eOptimal;
imageInfo.usage = vk::ImageUsageFlagBits::eDepthStencilAttachment;
imageInfo.sharingMode = vk::SharingMode::eExclusive;
imageInfo.initialLayout = vk::ImageLayout::eUndefined;
m_depthImage = m_device->createImageUnique(imageInfo);
// Allocate GPU memory for the depth image
m_memoryManager->allocateImageMemory(m_depthImage.get(), MemoryUsage::GpuOnly, m_depthImageMemory);
// Create depth image view
vk::ImageViewCreateInfo viewInfo;
viewInfo.image = m_depthImage.get();
viewInfo.viewType = vk::ImageViewType::e2D;
viewInfo.format = m_depthFormat;
viewInfo.subresourceRange.aspectMask = imageAspectFromFormat(m_depthFormat);
viewInfo.subresourceRange.baseMipLevel = 0;
viewInfo.subresourceRange.levelCount = 1;
viewInfo.subresourceRange.baseArrayLayer = 0;
viewInfo.subresourceRange.layerCount = 1;
m_depthImageView = m_device->createImageViewUnique(viewInfo);
nprintf(("vulkan", "Vulkan: Created depth buffer (%dx%d, format %d)\n",
m_renderExtent.width, m_renderExtent.height, static_cast<int>(m_depthFormat)));
}
void VulkanRenderer::destroyDepthResources()
{
m_depthImageView.reset();
m_depthImage.reset();
if (m_memoryManager && m_depthImageMemory.isValid()) {
m_memoryManager->freeAllocation(m_depthImageMemory);
m_depthImageMemory = {};
}
}
void VulkanRenderer::createRenderPass()
{
// Attachment 0: Color - clear each frame
// The whole frame is rendered into the fp16 composition image (not directly
// into the swap chain). A separate output-encode pass (m_encodeRenderPass)
// later samples this image, so the final layout is eShaderReadOnlyOptimal.
// UI screens draw their own full-screen backgrounds; 3D clears via scene_texture_begin.
// Popups that need previous frame content use gr_save_screen/gr_restore_screen.
vk::AttachmentDescription colorAttachment;
colorAttachment.format = HDR_COLOR_FORMAT;
colorAttachment.samples = vk::SampleCountFlagBits::e1;
colorAttachment.loadOp = vk::AttachmentLoadOp::eClear;
colorAttachment.storeOp = vk::AttachmentStoreOp::eStore;
colorAttachment.stencilLoadOp = vk::AttachmentLoadOp::eDontCare;
colorAttachment.stencilStoreOp = vk::AttachmentStoreOp::eDontCare;
colorAttachment.initialLayout = vk::ImageLayout::eUndefined;
colorAttachment.finalLayout = vk::ImageLayout::eShaderReadOnlyOptimal;
// Attachment 1: Depth
vk::AttachmentDescription depthAttachment;
depthAttachment.format = m_depthFormat;
depthAttachment.samples = vk::SampleCountFlagBits::e1;
depthAttachment.loadOp = vk::AttachmentLoadOp::eClear;
depthAttachment.storeOp = vk::AttachmentStoreOp::eDontCare;
depthAttachment.stencilLoadOp = vk::AttachmentLoadOp::eClear;
depthAttachment.stencilStoreOp = vk::AttachmentStoreOp::eDontCare;
depthAttachment.initialLayout = vk::ImageLayout::eUndefined;
depthAttachment.finalLayout = vk::ImageLayout::eDepthStencilAttachmentOptimal;
vk::AttachmentReference colorAttachRef;
colorAttachRef.attachment = 0;
colorAttachRef.layout = vk::ImageLayout::eColorAttachmentOptimal;
vk::AttachmentReference depthAttachRef;
depthAttachRef.attachment = 1;
depthAttachRef.layout = vk::ImageLayout::eDepthStencilAttachmentOptimal;
vk::SubpassDescription subpass;
subpass.pipelineBindPoint = vk::PipelineBindPoint::eGraphics;
subpass.colorAttachmentCount = 1;
subpass.pColorAttachments = &colorAttachRef;
subpass.pDepthStencilAttachment = &depthAttachRef;
// External dependency must make the PREVIOUS frame's accesses to these
// attachments available/ordered before this frame writes them again. The
// depth buffer is a single shared image (one m_depthImage for every
// swap-chain framebuffer), so frame N+1's loadOp=eClear collides with frame
// N's depth writes (WRITE_AFTER_WRITE) unless srcAccessMask lists the prior
// depth write; likewise the composition color's store when a swap image
// repeats. eFragmentShader in srcStageMask additionally orders this write
// after the prior frame's output-encode pass sampling the composition color
// (a write-after-read). (All detected/verified via -gr_sync_validation.)
vk::SubpassDependency dependency;
dependency.srcSubpass = VK_SUBPASS_EXTERNAL;
dependency.dstSubpass = 0;
dependency.srcStageMask = vk::PipelineStageFlagBits::eColorAttachmentOutput
| vk::PipelineStageFlagBits::eEarlyFragmentTests
| vk::PipelineStageFlagBits::eLateFragmentTests
| vk::PipelineStageFlagBits::eFragmentShader;
dependency.dstStageMask = vk::PipelineStageFlagBits::eColorAttachmentOutput
| vk::PipelineStageFlagBits::eEarlyFragmentTests;
dependency.srcAccessMask = vk::AccessFlagBits::eColorAttachmentWrite
| vk::AccessFlagBits::eDepthStencilAttachmentWrite;
// eColorAttachmentRead covers the loadOp=eLoad read in the m_renderPassLoad
// variant (which shares this dependency): its color initialLayout
// (eShaderReadOnlyOptimal) differs from the subpass layout, so begin performs
// an automatic layout transition (a write) that the load read must be ordered
// after -- otherwise a READ_AFTER_WRITE hazard vs the transition (flagged by
// -gr_sync_validation). Harmless for the eClear m_renderPass variant.
dependency.dstAccessMask = vk::AccessFlagBits::eColorAttachmentWrite
| vk::AccessFlagBits::eColorAttachmentRead
| vk::AccessFlagBits::eDepthStencilAttachmentWrite;
std::array<vk::AttachmentDescription, 2> attachments = {colorAttachment, depthAttachment};
vk::RenderPassCreateInfo renderPassInfo;
renderPassInfo.attachmentCount = static_cast<uint32_t>(attachments.size());
renderPassInfo.pAttachments = attachments.data();
renderPassInfo.subpassCount = 1;
renderPassInfo.pSubpasses = &subpass;
renderPassInfo.dependencyCount = 1;
renderPassInfo.pDependencies = &dependency;
m_renderPass = m_device->createRenderPassUnique(renderPassInfo);
// Create a second render pass with loadOp=eLoad for resuming the composition
// pass after post-processing. Render-pass compatibility (which is what lets a
// pipeline built against m_renderPass bind under m_renderPassLoad, and lets
// both share m_swapChainFramebuffers) is defined ONLY by matching attachment
// formats/sample counts and subpass structure -- it deliberately ignores
// load/store ops, layouts, AND subpass dependencies. So this variant reuses
// the same renderPassInfo/dependency but only overrides the ops/layouts below.
colorAttachment.loadOp = vk::AttachmentLoadOp::eLoad;
colorAttachment.initialLayout = vk::ImageLayout::eShaderReadOnlyOptimal;
// Depth is deliberately re-cleared (loadOp=eClear) on resume: the resumed
// composition pass draws only HUD / 2D / UI on top of the already-composited
// post-processed color, none of which depends on the 3D scene's depth (that
// lives in the separate post-processor scene-depth target). initialLayout is
// eDepthStencilAttachmentOptimal (not eUndefined) so no cross-frame layout
// transition write occurs here -- the depth WAW is handled by the shared
// dependency's eDepthStencilAttachmentWrite srcAccessMask above.
depthAttachment.loadOp = vk::AttachmentLoadOp::eClear;
depthAttachment.initialLayout = vk::ImageLayout::eDepthStencilAttachmentOptimal;
attachments = {colorAttachment, depthAttachment};
m_renderPassLoad = m_device->createRenderPassUnique(renderPassInfo);
}
void VulkanRenderer::createCommandPool(const PhysicalDeviceValues& values)
{
vk::CommandPoolCreateInfo poolCreate;
poolCreate.queueFamilyIndex = values.graphicsQueueIndex.index;
poolCreate.flags |= vk::CommandPoolCreateFlagBits::eTransient;
m_graphicsCommandPool = m_device->createCommandPoolUnique(poolCreate);
}
void VulkanRenderer::createPresentSyncObjects()
{
for (size_t i = 0; i < MAX_FRAMES_IN_FLIGHT; ++i) {
m_frames[i] = std::make_unique<VulkanRenderFrame>(m_device.get(), m_swapChain.get(), m_graphicsQueue, m_presentQueue);
}
m_swapChainImageRenderImage.resize(m_swapChainImages.size(), nullptr);
createRenderingFinishedSemaphores();
}
void VulkanRenderer::createRenderingFinishedSemaphores()
{
constexpr vk::SemaphoreCreateInfo semaphoreCreateInfo;
m_renderingFinishedSemaphores.clear();
for (size_t i = 0; i < m_swapChainImages.size(); ++i) {
m_renderingFinishedSemaphores.push_back(m_device->createSemaphoreUnique(semaphoreCreateInfo));
}
}
bool VulkanRenderer::readbackFramebuffer(ubyte** outPixels, uint32_t* outWidth, uint32_t* outHeight)
{
*outPixels = nullptr;
*outWidth = 0;
*outHeight = 0;
if (m_previousSwapChainImage == UINT32_MAX) {
nprintf(("vulkan", "VulkanRenderer::readbackFramebuffer - no previous frame available\n"));
return false;
}
if (!m_frameInProgress) {
nprintf(("vulkan", "VulkanRenderer::readbackFramebuffer - no frame in progress\n"));
return false;
}
// Read the previous frame's fp16 composition image in both SDR and HDR
// modes -- never the presented swap chain image. The swap chain image is
// owned by the presentation engine after present (reading it back violates
// its layout/ownership contract), and in HDR it's 10-bit PQ-packed anyway.
// The composition image is app-owned, stays in eShaderReadOnlyOptimal after
// the encode pass sampled it, and carries the final sRGB-encoded frame
// (1.0 == paper white) before the user gamma slider is applied -- the same
// fidelity class as OpenGL's back-buffer read. Converted to BGRA8 on the
// CPU below.
const vk::Image srcImage = m_compositionImages[m_previousSwapChainImage].get();
const vk::ImageLayout srcInitialLayout = vk::ImageLayout::eShaderReadOnlyOptimal;
const vk::PipelineStageFlags2 srcStageMask = vk::PipelineStageFlagBits2::eFragmentShader;
const vk::AccessFlags2 srcAccessMask = vk::AccessFlagBits2::eShaderSampledRead;
const uint32_t bytesPerSrcPixel = 8; // fp16 RGBA = 4 x 2 bytes
uint32_t w = m_renderExtent.width;
uint32_t h = m_renderExtent.height;
vk::DeviceSize bufferSize = static_cast<vk::DeviceSize>(w) * h * bytesPerSrcPixel;
// End the current render pass so we can record transfer commands
m_currentCommandBuffer.endRenderPass();
// --- One-shot command buffer to copy previous frame to staging buffer ---
vk::CommandBufferAllocateInfo cmdAlloc;
cmdAlloc.commandPool = m_graphicsCommandPool.get();
cmdAlloc.level = vk::CommandBufferLevel::ePrimary;
cmdAlloc.commandBufferCount = 1;
auto cmdBuffers = m_device->allocateCommandBuffers(cmdAlloc);
auto cmd = cmdBuffers.front();
vk::CommandBufferBeginInfo beginInfo;
beginInfo.flags = vk::CommandBufferUsageFlagBits::eOneTimeSubmit;
cmd.begin(beginInfo);
// Transition source image to eTransferSrcOptimal for readback (dest stage is
// eCopy: this barrier exists solely to feed the cmd.copyImageToBuffer() below)
ImageBarrier2 preBarrier;
preBarrier.image = srcImage;
preBarrier.levelCount = 1;
preBarrier.layerCount = 1;
preBarrier.oldLayout = srcInitialLayout;
preBarrier.newLayout = vk::ImageLayout::eTransferSrcOptimal;
preBarrier.srcStage = srcStageMask;
preBarrier.srcAccess = srcAccessMask;
preBarrier.dstStage = vk::PipelineStageFlagBits2::eCopy;
preBarrier.dstAccess = vk::AccessFlagBits2::eTransferRead;
cmdImageBarrier(cmd, preBarrier);
// Create staging buffer for readback
vk::BufferCreateInfo bufferCreateInfo;
bufferCreateInfo.size = bufferSize;
bufferCreateInfo.usage = vk::BufferUsageFlagBits::eTransferDst;
bufferCreateInfo.sharingMode = vk::SharingMode::eExclusive;
auto stagingBuffer = m_device->createBuffer(bufferCreateInfo);
VulkanAllocation stagingAlloc{};
if (!m_memoryManager->allocateBufferMemory(stagingBuffer, MemoryUsage::GpuToCpu, stagingAlloc)) {
nprintf(("vulkan", "VulkanRenderer::readbackFramebuffer - failed to allocate staging buffer\n"));
m_device->destroyBuffer(stagingBuffer);
cmd.end();
m_device->freeCommandBuffers(m_graphicsCommandPool.get(), cmdBuffers);
// Re-begin render pass so the frame can continue
resumeSwapChainPass();
return false;
}
// Copy image to staging buffer
vk::BufferImageCopy region;
region.bufferOffset = 0;
region.bufferRowLength = 0; // tightly packed
region.bufferImageHeight = 0; // tightly packed
region.imageSubresource = {vk::ImageAspectFlagBits::eColor, 0, 0, 1};
region.imageOffset = vk::Offset3D(0, 0, 0);
region.imageExtent = vk::Extent3D(w, h, 1);
cmd.copyImageToBuffer(srcImage, vk::ImageLayout::eTransferSrcOptimal, stagingBuffer, region);
// Transition source image back to its original layout
ImageBarrier2 postBarrier;
postBarrier.image = srcImage;
postBarrier.levelCount = 1;
postBarrier.layerCount = 1;
postBarrier.oldLayout = vk::ImageLayout::eTransferSrcOptimal;
postBarrier.newLayout = srcInitialLayout;
postBarrier.srcStage = vk::PipelineStageFlagBits2::eCopy;
postBarrier.srcAccess = vk::AccessFlagBits2::eTransferRead;
// The dst scope must cover every later consumer of this image: the encode
// pass samples it (eShaderSampledRead @ eFragmentShader), and the next
// composition pass targeting it performs a layout transition plus a
// loadOp=eLoad read at begin (read+write @ eColorAttachmentOutput). An
// empty dst scope here left this transition's write unordered against
// those loads (READ_AFTER_WRITE flagged by -gr_sync_validation); the fence
// wait below only synchronizes the host, not later GPU submissions.
postBarrier.dstStage = vk::PipelineStageFlagBits2::eFragmentShader
| vk::PipelineStageFlagBits2::eColorAttachmentOutput;
postBarrier.dstAccess = vk::AccessFlagBits2::eShaderSampledRead
| vk::AccessFlagBits2::eColorAttachmentRead
| vk::AccessFlagBits2::eColorAttachmentWrite;
cmdImageBarrier(cmd, postBarrier);
cmd.end();
// Submit one-shot command buffer and wait. The mid-frame fence stall is
// acceptable: readback is rare and user-triggered (save-screen/screenshot).
// A stall-free alternative (copy now, map after the next frame's fence)
// is possible but not worth the complexity today.
auto fence = m_device->createFence({});
vk::SubmitInfo submitInfo;
submitInfo.commandBufferCount = 1;
submitInfo.pCommandBuffers = &cmd;
m_graphicsQueue.submit(submitInfo, fence);
auto waitResult = m_device->waitForFences(fence, VK_TRUE, UINT64_MAX);
if (waitResult != vk::Result::eSuccess) {
nprintf(("vulkan", "VulkanRenderer::readbackFramebuffer - fence wait failed\n"));
}
m_device->destroyFence(fence);
m_device->freeCommandBuffers(m_graphicsCommandPool.get(), cmdBuffers);
// Read back and convert pixels from staging buffer: fp16 RGBA -> BGRA8.
// The composition image holds sRGB-encoded values (1.0 == paper white) so we
// just clamp to [0,1] and scale; no gamma re-encoding needed. Swap R<->B for
// BGRA output and force alpha = 255 so the bitmap restores as fully opaque.
const uint32_t outStride = w * 4u; // always 4 bytes/pixel BGRA8 output
bool success = false;
auto* mappedPtr = static_cast<ubyte*>(m_memoryManager->mapMemory(stagingAlloc));
if (mappedPtr) {
auto* pixels = static_cast<ubyte*>(vm_malloc(static_cast<int>(outStride) * static_cast<int>(h)));
if (pixels) {
// TODO: support true HDR10 screenshots. The scene is rendered in fp16, but
// screenshots are currently tonemapped/clamped down to 8-bit LDR PNG here.
// Clamp fp16 value to [0,1]; NaN-safe (all NaN comparisons yield false → 0).
auto hf = [](uint16_t v) -> float {
float f = graphics::util::half_to_float(v);
return (f >= 0.0f && f <= 1.0f) ? f : (f >= 1.0f ? 1.0f : 0.0f);
};
const auto* src = reinterpret_cast<const uint16_t*>(mappedPtr);
for (uint32_t i = 0; i < w * h; ++i) {
pixels[i * 4 + 0] = static_cast<ubyte>(hf(src[i * 4 + 2]) * 255.0f); // B ← src B
pixels[i * 4 + 1] = static_cast<ubyte>(hf(src[i * 4 + 1]) * 255.0f); // G ← src G
pixels[i * 4 + 2] = static_cast<ubyte>(hf(src[i * 4 + 0]) * 255.0f); // R ← src R
pixels[i * 4 + 3] = 255u; // A = opaque
}
*outPixels = pixels;
*outWidth = w;
*outHeight = h;
success = true;
}
m_memoryManager->unmapMemory(stagingAlloc);
}
// Free staging buffer
m_device->destroyBuffer(stagingBuffer);
m_memoryManager->freeAllocation(stagingAlloc);
// Resume the composition pass with loadOp=eLoad, preserving everything drawn
// into the composition image so far this frame. (The interrupted pass's
// finalLayout left it in eShaderReadOnlyOptimal, matching the load pass's
// initialLayout.)
resumeSwapChainPass();
return success;
}
bool VulkanRenderer::readbackRenderTarget(tcache_slot_vulkan* ts, ubyte** outPixels, uint32_t* outWidth,
uint32_t* outHeight)
{
*outPixels = nullptr;
*outWidth = 0;
*outHeight = 0;
if (!ts || !ts->image || !ts->renderPassLoad || !ts->framebuffer) {
nprintf(("vulkan", "readbackRenderTarget - render target has no readable/resumable resources\n"));
return false;
}
if (!m_frameInProgress || !m_currentCommandBuffer) {
nprintf(("vulkan", "readbackRenderTarget - no frame in progress\n"));
return false;
}
const uint32_t w = ts->width;
const uint32_t h = ts->height;
if (w == 0 || h == 0) {
return false;
}
// The target's draws are in the current (not-yet-submitted) frame command buffer, so
// we can't read them via a side command buffer like readbackFramebuffer does. Instead
// we flush the frame command buffer mid-frame: end the active render-target pass,
// record the color image -> staging copy into the SAME buffer, submit it (segment 1)
// with a temporary fence, and host-wait so the pixels are ready. Rendering then
// resumes on a fresh command buffer (segment 2) that flip() ultimately submits.
const vk::DeviceSize bufferSize = static_cast<vk::DeviceSize>(w) * h * 4u; // R8G8B8A8
// Allocate the staging buffer BEFORE touching the render pass or image layout. If it
// fails we return with the render-target pass still active and the image untouched, so
// the frame carries on exactly as if the capture never happened.
vk::BufferCreateInfo bufferCreateInfo;
bufferCreateInfo.size = bufferSize;
bufferCreateInfo.usage = vk::BufferUsageFlagBits::eTransferDst;
bufferCreateInfo.sharingMode = vk::SharingMode::eExclusive;
auto stagingBuffer = m_device->createBuffer(bufferCreateInfo);
VulkanAllocation stagingAlloc{};
if (!m_memoryManager->allocateBufferMemory(stagingBuffer, MemoryUsage::GpuToCpu, stagingAlloc)) {
nprintf(("vulkan", "readbackRenderTarget - failed to allocate staging buffer\n"));
m_device->destroyBuffer(stagingBuffer);
return false;
}
// End the active render-target pass; its finalLayout leaves the color image in
// eShaderReadOnlyOptimal.
m_currentCommandBuffer.endRenderPass();
// Transition color image eShaderReadOnlyOptimal -> eTransferSrcOptimal for the copy.
ImageBarrier2 preBarrier;
preBarrier.image = ts->image;
preBarrier.oldLayout = vk::ImageLayout::eShaderReadOnlyOptimal;
preBarrier.newLayout = vk::ImageLayout::eTransferSrcOptimal;
preBarrier.srcStage = vk::PipelineStageFlagBits2::eColorAttachmentOutput;
preBarrier.srcAccess = vk::AccessFlagBits2::eColorAttachmentWrite;
preBarrier.dstStage = vk::PipelineStageFlagBits2::eCopy;
preBarrier.dstAccess = vk::AccessFlagBits2::eTransferRead;
cmdImageBarrier(m_currentCommandBuffer, preBarrier);
vk::BufferImageCopy region;
region.bufferOffset = 0;
region.bufferRowLength = 0; // tightly packed
region.bufferImageHeight = 0; // tightly packed
region.imageSubresource = {vk::ImageAspectFlagBits::eColor, 0, 0, 1};
region.imageOffset = vk::Offset3D(0, 0, 0);
region.imageExtent = vk::Extent3D(w, h, 1);
m_currentCommandBuffer.copyImageToBuffer(ts->image, vk::ImageLayout::eTransferSrcOptimal, stagingBuffer, region);
// Transition color image back to eShaderReadOnlyOptimal (the load-variant resume pass
// expects this initial layout). The dst scope covers the loadOp=eLoad color read/write
// when rendering resumes.
ImageBarrier2 postBarrier;
postBarrier.image = ts->image;
postBarrier.oldLayout = vk::ImageLayout::eTransferSrcOptimal;
postBarrier.newLayout = vk::ImageLayout::eShaderReadOnlyOptimal;
postBarrier.srcStage = vk::PipelineStageFlagBits2::eCopy;
postBarrier.srcAccess = vk::AccessFlagBits2::eTransferRead;
postBarrier.dstStage = vk::PipelineStageFlagBits2::eColorAttachmentOutput | vk::PipelineStageFlagBits2::eFragmentShader;
postBarrier.dstAccess = vk::AccessFlagBits2::eColorAttachmentRead | vk::AccessFlagBits2::eColorAttachmentWrite |
vk::AccessFlagBits2::eShaderSampledRead;
cmdImageBarrier(m_currentCommandBuffer, postBarrier);
// --- Submit segment 1 (RT draws + copy) and wait. No swap-chain image is touched here,
// so this submission carries no acquire/present semaphores -- just a throwaway fence. ---
m_currentCommandBuffer.end();
auto fence = m_device->createFence({});
vk::SubmitInfo submitInfo;
submitInfo.commandBufferCount = static_cast<uint32_t>(m_currentCommandBuffers.size());
submitInfo.pCommandBuffers = m_currentCommandBuffers.data();
m_graphicsQueue.submit(submitInfo, fence);
auto waitResult = m_device->waitForFences(fence, VK_TRUE, UINT64_MAX);
if (waitResult != vk::Result::eSuccess) {
nprintf(("vulkan", "readbackRenderTarget - fence wait failed\n"));
}
m_device->destroyFence(fence);
// Segment 1's command buffers are done executing (fence signaled), so free them now.
m_device->freeCommandBuffers(m_graphicsCommandPool.get(), m_currentCommandBuffers);
m_currentCommandBuffers.clear();
m_currentCommandBuffer = nullptr;
// Copy pixels out: R8G8B8A8_UNORM is already tightly-packed RGBA with real alpha, so no
// swizzle and no forced opacity (the icons rely on the transparent background).
bool success = false;
auto* mappedPtr = static_cast<ubyte*>(m_memoryManager->mapMemory(stagingAlloc));
if (mappedPtr) {
auto* pixels = static_cast<ubyte*>(vm_malloc(static_cast<int>(bufferSize)));
if (pixels) {
memcpy(pixels, mappedPtr, static_cast<size_t>(bufferSize));
*outPixels = pixels;
*outWidth = w;
*outHeight = h;
success = true;
}
m_memoryManager->unmapMemory(stagingAlloc);
}
m_device->destroyBuffer(stagingBuffer);
m_memoryManager->freeAllocation(stagingAlloc);
// --- Begin segment 2: a fresh frame command buffer that flip() will submit+present. ---
vk::CommandBufferAllocateInfo cmdAlloc;
cmdAlloc.commandPool = m_graphicsCommandPool.get();
cmdAlloc.level = vk::CommandBufferLevel::ePrimary;
cmdAlloc.commandBufferCount = 1;
auto cmdBufs = m_device->allocateCommandBuffers(cmdAlloc);
m_currentCommandBuffers.assign(cmdBufs.begin(), cmdBufs.end());
m_currentCommandBuffer = m_currentCommandBuffers.front();
vk::CommandBufferBeginInfo beginInfo;
beginInfo.flags = vk::CommandBufferUsageFlagBits::eOneTimeSubmit;
m_currentCommandBuffer.begin(beginInfo);
// A fresh command buffer starts with no Vulkan state, so re-point the state tracker at
// it and invalidate every cached binding/dynamic-state value. beginFrame() does exactly
// this (and touches nothing else), so the next tracked draw re-binds pipeline,
// descriptors, viewport, scissor, etc. Deliberately NOT resetting the descriptor pool
// mid-frame (segment 2 keeps allocating past segment 1's sets).
m_stateTracker->beginFrame(m_currentCommandBuffer);
// Resume drawing into the target (loadOp=eLoad) so content survives the flush.
resumeRenderTargetPass(ts);
return success;
}
uint32_t VulkanRenderer::getMinUniformBufferOffsetAlignment() const
{
if (!m_physicalDevice) {
// Fallback to common value if device not initialized
return 256;
}
return static_cast<uint32_t>(m_deviceProperties.limits.minUniformBufferOffsetAlignment);
}
uint32_t VulkanRenderer::getMaxUniformBufferSize() const
{
if (!m_physicalDevice) {
return 65536;
}
return m_deviceProperties.limits.maxUniformBufferRange;
}
float VulkanRenderer::getMaxAnisotropy() const
{
if (!m_physicalDevice) {
return 1.0f;
}
return m_deviceProperties.limits.maxSamplerAnisotropy;
}
bool VulkanRenderer::isTextureCompressionBCSupported() const
{
if (!m_physicalDevice) {
return false;
}
return m_deviceFeatures.textureCompressionBC == VK_TRUE;
}
bool VulkanRenderer::isDepthClampSupported() const
{
if (!m_physicalDevice) {
return false;
}
return m_deviceFeatures.depthClamp == VK_TRUE;
}
void VulkanRenderer::waitIdle()
{
if (m_device) {
m_device->waitIdle();
}
}
bool VulkanRenderer::waitForFrame(uint64_t frameNumber, uint64_t timeoutNs)
{
// Fast path: if enough frames have elapsed, the work is definitely done --
// the frame's fence was waited before its slot was reused (see
// acquireNextSwapChainImage).
if (m_frameNumber >= frameNumber + MAX_FRAMES_IN_FLIGHT) {
return true;
}
// Not submitted yet: flip() advances m_frameNumber only after submission,
// so frameNumber >= m_frameNumber means the fence was taken during the
// frame currently being recorded. Its work cannot be complete, and blocking
// here would deadlock (submission happens on this thread).
if (frameNumber >= m_frameNumber) {
return false;
}
// Remaining case: frameNumber < m_frameNumber < frameNumber + MAX_FRAMES_IN_FLIGHT,
// so the slot still belongs to exactly that frame -- wait on its fence.
auto frameIndex = static_cast<uint32_t>(frameNumber % MAX_FRAMES_IN_FLIGHT);
return m_frames[frameIndex]->waitForFinish(timeoutNs);
}
VkCommandBuffer VulkanRenderer::getVkCurrentCommandBuffer() const
{
return static_cast<VkCommandBuffer>(m_currentCommandBuffer);
}
void VulkanRenderer::shutdown()
{
// Wait for all frames to complete to ensure no drawing is in progress when we destroy the device
for (uint32_t i = 0; i < MAX_FRAMES_IN_FLIGHT; ++i) {
m_frames[i]->waitForFinish();
}
// For good measure, also wait until the device is idle
m_device->waitIdle();
// Shutdown ImGui Vulkan backend before destroying any Vulkan objects
shutdownImGui();
// Shutdown managers in reverse order of initialization
if (m_raytracingManager) {
setRaytracingManager(nullptr);
m_raytracingManager->shutdown();
m_raytracingManager.reset();
}
if (m_queryManager) {
setQueryManager(nullptr);
m_queryManager->shutdown();
m_queryManager.reset();
}
if (m_postProcessor) {
setPostProcessor(nullptr);
m_postProcessor->shutdown();
m_postProcessor.reset();
}
// Clean up shared post-processing manager
if (graphics::Post_processing_manager) {
graphics::Post_processing_manager->clear();
graphics::Post_processing_manager = nullptr;
}
if (m_drawManager) {
setDrawManager(nullptr);
m_drawManager->shutdown();
m_drawManager.reset();
}
if (m_stateTracker) {
setStateTracker(nullptr);
m_stateTracker->shutdown();
m_stateTracker.reset();
}
if (m_pipelineManager) {
m_pipelineManager->savePipelineCache("vulkan_pipeline.cache");
setPipelineManager(nullptr);
m_pipelineManager->shutdown();
m_pipelineManager.reset();
}
if (m_descriptorManager) {
setDescriptorManager(nullptr);
m_descriptorManager->shutdown();
m_descriptorManager.reset();
}
if (m_shaderManager) {
setShaderManager(nullptr);
m_shaderManager->shutdown();
m_shaderManager.reset();
}
if (m_textureManager) {
setTextureManager(nullptr);
m_textureManager->shutdown();
m_textureManager.reset();
}
if (m_bufferManager) {
setBufferManager(nullptr);
m_bufferManager->shutdown();
m_bufferManager.reset();
}
// Destroy depth resources before memory manager
destroyDepthResources();
// Destroy composition resources before memory manager
m_compositionImageViews.clear();
m_compositionImages.clear();
if (m_memoryManager) {
for (auto& alloc : m_compositionAllocations) {
if (alloc.isValid()) {
m_memoryManager->freeAllocation(alloc);
}
}
}
m_compositionAllocations.clear();
// Deletion queue must be flushed before memory manager shutdown
if (m_deletionQueue) {
setDeletionQueue(nullptr);
m_deletionQueue->shutdown();
m_deletionQueue.reset();
}
if (m_memoryManager) {
setMemoryManager(nullptr);
m_memoryManager->shutdown();
m_memoryManager.reset();
}
}
} // namespace graphics::vulkan