From 0b778d6b83fe72e722d00a31efb46a11bfe0dde7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Fri, 25 Sep 2026 09:06:56 -0600 Subject: [PATCH] Vulkan: Add an offscreen mode to VulkanGraphicsContext For when there's nothing to present to. Instead of a surface and swapchain, it renders into images of its own through the VulkanPresentation interface libretro uses, picking the graphics queue without a surface. Acquiring and presenting signal and wait on the frame's semaphores with empty submits. Co-Authored-By: Claude Opus 5.5 (1M context) --- Common/GPU/Vulkan/VulkanGraphicsContext.cpp | 151 +++++++++++++++++--- Common/GPU/Vulkan/VulkanGraphicsContext.h | 10 ++ 2 files changed, 143 insertions(+), 18 deletions(-) diff --git a/Common/GPU/Vulkan/VulkanGraphicsContext.cpp b/Common/GPU/Vulkan/VulkanGraphicsContext.cpp index 91242dccdb..b711acfb62 100644 --- a/Common/GPU/Vulkan/VulkanGraphicsContext.cpp +++ b/Common/GPU/Vulkan/VulkanGraphicsContext.cpp @@ -55,6 +55,7 @@ #include "Core/FrameTiming.h" #include "Common/GPU/Vulkan/VulkanLoader.h" #include "Common/GPU/Vulkan/VulkanContext.h" +#include "Common/GPU/Vulkan/VulkanPresentation.h" #include "Common/GPU/thin3d.h" #include "Common/GPU/thin3d_create.h" @@ -70,6 +71,94 @@ static const bool g_validate_ = true; static const bool g_validate_ = false; #endif +using namespace PPSSPP_VK; + +// Stands in for the swapchain in offscreen mode: a few images that frames are rendered into and left +// in. Acquiring and presenting only signal and wait on the frame's semaphores, with empty submits, so +// the rest of the frame synchronization works as with a real swapchain. +class VulkanOffscreenPresentation : public VulkanPresentation { +public: + VulkanOffscreenPresentation(VkFormat format, VkExtent2D extent) : format_(format), extent_(extent) {} + + bool Create(VulkanContext *vulkan) { + VkDevice device = vulkan->GetDevice(); + for (Image &img : images_) { + VkImageCreateInfo info{ VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO }; + info.imageType = VK_IMAGE_TYPE_2D; + info.format = format_; + info.extent = { extent_.width, extent_.height, 1 }; + info.mipLevels = 1; + info.arrayLayers = 1; + info.samples = VK_SAMPLE_COUNT_1_BIT; + info.tiling = VK_IMAGE_TILING_OPTIMAL; + info.usage = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT; + info.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED; + if (vkCreateImage(device, &info, nullptr, &img.image) != VK_SUCCESS) + return false; + + VkMemoryRequirements memreq; + vkGetImageMemoryRequirements(device, img.image, &memreq); + VkMemoryAllocateInfo alloc{ VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO }; + alloc.allocationSize = memreq.size; + if (!vulkan->MemoryTypeFromProperties(memreq.memoryTypeBits, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, &alloc.memoryTypeIndex)) + return false; + if (vkAllocateMemory(device, &alloc, nullptr, &img.memory) != VK_SUCCESS) + return false; + if (vkBindImageMemory(device, img.image, img.memory, 0) != VK_SUCCESS) + return false; + } + return true; + } + + void Destroy(VulkanContext *vulkan) override { + VkDevice device = vulkan->GetDevice(); + for (Image &img : images_) { + if (img.image) + vkDestroyImage(device, img.image, nullptr); + if (img.memory) + vkFreeMemory(device, img.memory, nullptr); + img = {}; + } + } + + VkResult AcquireNextImage(VulkanContext *vulkan, VkSemaphore signalSemaphore, uint32_t *imageIndex) override { + // The queue runs in order, so an image is free again by the time later work reaches it. + *imageIndex = next_; + next_ = (next_ + 1) % IMAGE_COUNT; + VkSubmitInfo submit{ VK_STRUCTURE_TYPE_SUBMIT_INFO }; + submit.signalSemaphoreCount = 1; + submit.pSignalSemaphores = &signalSemaphore; + return vkQueueSubmit(vulkan->GetGraphicsQueue(), 1, &submit, VK_NULL_HANDLE); + } + + VkResult QueuePresent(VulkanContext *vulkan, VkQueue queue, uint32_t imageIndex, VkSemaphore waitSemaphore) override { + // Nothing to show it on, so just consume the semaphore. + VkPipelineStageFlags stage = VK_PIPELINE_STAGE_ALL_COMMANDS_BIT; + VkSubmitInfo submit{ VK_STRUCTURE_TYPE_SUBMIT_INFO }; + submit.waitSemaphoreCount = 1; + submit.pWaitSemaphores = &waitSemaphore; + submit.pWaitDstStageMask = &stage; + return vkQueueSubmit(queue, 1, &submit, VK_NULL_HANDLE); + } + + uint32_t GetImageCount() const override { return IMAGE_COUNT; } + VkImage GetImage(uint32_t index) const override { return images_[index].image; } + VkExtent2D GetExtent() const override { return extent_; } + VkFormat GetFormat() const override { return format_; } + VkImageLayout GetPresentLayout() const override { return VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL; } + +private: + static constexpr int IMAGE_COUNT = 2; + struct Image { + VkImage image = VK_NULL_HANDLE; + VkDeviceMemory memory = VK_NULL_HANDLE; + }; + Image images_[IMAGE_COUNT]; + VkFormat format_; + VkExtent2D extent_; + uint32_t next_ = 0; +}; + bool VulkanGraphicsContext::InitAPI(void *wnd, std::string *deviceName, std::string *errorMessage) { *errorMessage = "N/A"; _dbg_assert_(deviceName); @@ -116,20 +205,35 @@ bool VulkanGraphicsContext::InitAPI(void *wnd, std::string *deviceName, std::str vulkan_ = nullptr; return false; } + // Normally the queue is picked along with the surface, which must be able to present from it. + if (offscreenWidth_ > 0 && !vulkan_->ChooseGraphicsQueueWithoutSurface()) { + *errorMessage = "No graphics queue"; + return false; + } return true; } bool VulkanGraphicsContext::InitSurface(WindowSystem winsys, void *data1, void *data2, std::string *errorMessage) { - // Don't proceed on failure - without a surface there's no present mode, no swapchain and no queue, - // so everything below would just fail in more confusing ways further down (it used to assert deep - // inside the thin3d context constructor). Let the caller fall back to another backend instead. - VkResult res = vulkan_->InitSurface(winsys, data1, data2); - if (res != VK_SUCCESS) { - *errorMessage = vulkan_->InitError(); - if (errorMessage->empty()) { - *errorMessage = StringFromFormat("Failed to initialize Vulkan surface: %s", VulkanResultToString(res)); + if (offscreenWidth_ > 0) { + auto presentation = std::make_unique(VK_FORMAT_B8G8R8A8_UNORM, VkExtent2D{ (uint32_t)offscreenWidth_, (uint32_t)offscreenHeight_ }); + if (!presentation->Create(vulkan_)) { + presentation->Destroy(vulkan_); + *errorMessage = "Failed to create the offscreen images"; + return false; + } + vulkan_->SetPresentation(std::move(presentation)); + } else { + // Don't proceed on failure - without a surface there's no present mode, no swapchain and no queue, + // so everything below would just fail in more confusing ways further down (it used to assert deep + // inside the thin3d context constructor). Let the caller fall back to another backend instead. + VkResult res = vulkan_->InitSurface(winsys, data1, data2); + if (res != VK_SUCCESS) { + *errorMessage = vulkan_->InitError(); + if (errorMessage->empty()) { + *errorMessage = StringFromFormat("Failed to initialize Vulkan surface: %s", VulkanResultToString(res)); + } + return false; } - return false; } bool useMultiThreading = g_Config.bRenderMultiThreading; @@ -139,17 +243,19 @@ bool VulkanGraphicsContext::InitSurface(WindowSystem winsys, void *data1, void * draw_ = Draw::T3DCreateVulkanContext(vulkan_, useMultiThreading); - VkPresentModeKHR presentMode = ConfigPresentModeToVulkan(draw_); + if (offscreenWidth_ == 0) { + VkPresentModeKHR presentMode = ConfigPresentModeToVulkan(draw_); #ifdef VK_EXT_full_screen_exclusive - vulkan_->SetFullScreenExclusiveMode(g_Config.bFullScreen && g_Config.bAllowFullScreenExclusive - ? VK_FULL_SCREEN_EXCLUSIVE_ALLOWED_EXT - : VK_FULL_SCREEN_EXCLUSIVE_DISALLOWED_EXT); + vulkan_->SetFullScreenExclusiveMode(g_Config.bFullScreen && g_Config.bAllowFullScreenExclusive + ? VK_FULL_SCREEN_EXCLUSIVE_ALLOWED_EXT + : VK_FULL_SCREEN_EXCLUSIVE_DISALLOWED_EXT); #endif - if (!vulkan_->InitSwapchain(presentMode)) { - *errorMessage = vulkan_->InitError(); - return false; + if (!vulkan_->InitSwapchain(presentMode)) { + *errorMessage = vulkan_->InitError(); + return false; + } } SetGPUBackend(GPUBackend::VULKAN, vulkan_->GetPhysicalDeviceProperties().properties.deviceName); @@ -176,8 +282,13 @@ void VulkanGraphicsContext::ShutdownSurface() { draw_ = nullptr; vulkan_->WaitUntilQueueIdle(); - vulkan_->DestroySwapchain(); - vulkan_->DestroySurface(); + if (VulkanPresentation *presentation = vulkan_->GetPresentation()) { + presentation->Destroy(vulkan_); + vulkan_->SetPresentation(nullptr); + } else { + vulkan_->DestroySwapchain(); + vulkan_->DestroySurface(); + } } void VulkanGraphicsContext::ShutdownAPI() { @@ -192,6 +303,10 @@ void VulkanGraphicsContext::ShutdownAPI() { } void VulkanGraphicsContext::Resize() { + if (offscreenWidth_ > 0) { + // The images have a fixed size. + return; + } draw_->HandleEvent(Draw::Event::LOST_BACKBUFFER, vulkan_->GetBackbufferWidth(), vulkan_->GetBackbufferHeight()); VkPresentModeKHR presentMode = ConfigPresentModeToVulkan(draw_); diff --git a/Common/GPU/Vulkan/VulkanGraphicsContext.h b/Common/GPU/Vulkan/VulkanGraphicsContext.h index 35450cd116..9fad4efa31 100644 --- a/Common/GPU/Vulkan/VulkanGraphicsContext.h +++ b/Common/GPU/Vulkan/VulkanGraphicsContext.h @@ -26,6 +26,14 @@ class VulkanRenderManager; class VulkanGraphicsContext : public GraphicsContext { public: VulkanGraphicsContext() : draw_(nullptr) {} + + // Renders into images of this size instead of a window's swapchain, for when there's nothing to + // present to (headless). Call before InitAPI. InitSurface then ignores the window system. + void SetOffscreen(int width, int height) { + offscreenWidth_ = width; + offscreenHeight_ = height; + } + bool InitAPI(void *wnd, std::string *deviceName, std::string *errorMessage) override; bool InitSurface(WindowSystem winsys, void *data1, void *data2, std::string *errorMessage) override; void ShutdownSurface() override; @@ -45,4 +53,6 @@ private: VulkanContext *vulkan_ = nullptr; VulkanRenderManager *renderManager_ = nullptr; bool windowRestored_ = false; + int offscreenWidth_ = 0; + int offscreenHeight_ = 0; };