Using Vulkan command buffers
In the previous recipes, we learned how to create a Vulkan instance, a device for rendering, and a swapchain. In this recipe, we will learn how to manage command buffers and submit them using command queues which will bring us a bit closer to rendering our first image with Vulkan.
Vulkan command buffers are used to record Vulkan commands which can be then submitted to a device queue for execution. Command buffers are allocated from pools which allow the Vulkan implementation to amortize the cost of resource creation across multiple command buffers. Command pools are be externally synchronized which means one command pool should not be used between multiple threads. Let’s learn how to make a convenient user-friendly wrapper on top of Vulkan command buffers and pools.
Getting ready…
We are going to explore the command buffers management code from the LightweightVK library. Take a look at the class VulkanImmediateCommands
from lvk/vulkan/VulkanClasses.h
. In the previous edition of our book, we used very rudimentary command buffers management code which did not suppose any synchronization because every frame was “synchronized” with vkDeviceWaitIdle()
. Here we are going to explore a more pragmatic solution with some facilities for synchronization.
Let’s go back to our demo application from the recipe Initializing Vulkan swapchain which renders a black empty window Chapter02/01_Swapchain
. The main loop of the application looks as follows:
while (!glfwWindowShouldClose(window)) {
glfwPollEvents();
glfwGetFramebufferSize(window, &width, &height);
if (!width || !height) continue;
lvk::ICommandBuffer& buf = ctx->acquireCommandBuffer();
ctx->submit(buf, ctx->getCurrentSwapchainTexture());
}
Here we acquire a next command buffer and then submit it without writhing any commands into it so that LightweightVK
can run its swapchain presentation code and render a black window. Let’s dive deep into the implementation and learn how lvk::VulkanImmediateCommands
does all the heavy lifting behind the scenes.
How to do it...
- First, we need a helper struct,
SubmitHandle
, to identify previously submitted command buffers. It will be essential for implementing synchronization when scheduling work that depends on the results of a previously submitted command buffer. The struct includes an internal index for the submitted buffer and an integer ID for the submission. For convenience, handles can be converted to and from 64-bit integers.struct SubmitHandle { uint32_t bufferIndex_ = 0; uint32_t submitId_ = 0; SubmitHandle() = default; explicit SubmitHandle(uint64_t handle) : bufferIndex_(uint32_t(handle & 0xffffffff)), submitId_(uint32_t(handle >> 32)) {} bool empty() const { return submitId_ == 0; } uint64_t handle() const { return (uint64_t(submitId_) << 32) + bufferIndex_; } };
- Another helper struct,
CommandBufferWrapper
, is needed to encapsulate all Vulkan objects associated with a single Vulkan command buffer. This struct stores the originally allocated and currently active command buffers, the most recentSubmitHandle
linked to the command buffer, a Vulkan fence, and a Vulkan semaphore. The fence is used for GPU-CPU synchronization, while the semaphore ensures that command buffers are processed by the GPU in the order they were submitted. This sequential processing, enforced by LightweightVK, simplifies many aspects of rendering.struct CommandBufferWrapper { VkCommandBuffer cmdBuf_ = VK_NULL_HANDLE; VkCommandBuffer cmdBufAllocated_ = VK_NULL_HANDLE; SubmitHandle handle_ = {}; VkFence fence_ = VK_NULL_HANDLE; VkSemaphore semaphore_ = VK_NULL_HANDLE; bool isEncoding_ = false; };
Now let’s take a look at the interface of lvk::VulkanImmediateCommands
.
- Vulkan command buffers are preallocated and used in a round-robin manner. The maximum number of preallocated command buffers is defined by
kMaxCommandBuffers
. If all buffers are in use,VulkanImmediateCommands
waits for an existing command buffer to become available by waiting on a fence. Typically,64
command buffers are sufficient to ensure non-blocking operation in most cases. The constructor takes aqueueFamilyIdx
parameter to retrieve the appropriate Vulkan queue.class VulkanImmediateCommands final { public: static constexpr uint32_t kMaxCommandBuffers = 64; VulkanImmediateCommands(VkDevice device, uint32_t queueFamilyIdx, const char* debugName); ~VulkanImmediateCommands();
- The
acquire()
method returns a reference to the next available command buffer. If all command buffers are in use, it waits on a fence until one becomes available. Thesubmit()
method submits a command buffer to the assigned Vulkan queue.const CommandBufferWrapper& acquire(); SubmitHandle submit(const CommandBufferWrapper& wrapper);
- The next three methods provide GPU-GPU and GPU-CPU synchronization mechanisms. The
waitSemaphore()
method ensures the current command buffer waits on a given semaphore before execution. A common use case is using an “acquire semaphore” from ourVulkanSwapchain
object, which signals a semaphore when acquiring a swapchain image, ensuring the command buffer waits for it before starting to render into the swapchain image. ThesignalSemaphore()
method signals a corresponding Vulkan timeline semaphore when the current command buffer finishes execution. TheacquireLastSubmitSemaphore()
method retrieves the semaphore signaled when the last submitted command buffer completes. This semaphore can be used by the swapchain before presentation to ensure that rendering into the image is complete. We’ll take a closer look at how this works in a moment.void waitSemaphore(VkSemaphore semaphore); void signalSemaphore(VkSemaphore semaphore, uint64_t signalValue); VkSemaphore acquireLastSubmitSemaphore();
- The next set of methods manages GPU-CPU synchronization. As we’ll see later in this recipe, submit handles are implemented using Vulkan fences and can be used to wait for specific GPU operations to complete.
SubmitHandle getLastSubmitHandle() const; bool isReady(SubmitHandle handle) const; void wait(SubmitHandle handle); void waitAll();
- The private section of the class contains all the local state, including an array of preallocated
CommandBufferWrapper
objects calledbuffers_[]
.private: void purge(); VkDevice device_ = VK_NULL_HANDLE; VkQueue queue_ = VK_NULL_HANDLE; VkCommandPool commandPool_ = VK_NULL_HANDLE; uint32_t queueFamilyIndex_ = 0; const char* debugName_ = ""; CommandBufferWrapper buffers_[kMaxCommandBuffers];
- Note how the
VkSemaphoreSubmitInfo
structures are preinitialized with genericstageMask
values. For submitting Vulkan command buffers, we use the functionvkQueueSubmit2()
introduced in Vulkan 1.3, which requires pointers to these structures.VkSemaphoreSubmitInfo lastSubmitSemaphore_ = { .sType = VK_STRUCTURE_TYPE_SEMAPHORE_SUBMIT_INFO, .stageMask = VK_PIPELINE_STAGE_ALL_COMMANDS_BIT}; VkSemaphoreSubmitInfo waitSemaphore_ = { .sType = VK_STRUCTURE_TYPE_SEMAPHORE_SUBMIT_INFO, .stageMask = VK_PIPELINE_STAGE_ALL_COMMANDS_BIT}; VkSemaphoreSubmitInfo signalSemaphore_ = { .sType = VK_STRUCTURE_TYPE_SEMAPHORE_SUBMIT_INFO, .stageMask = VK_PIPELINE_STAGE_ALL_COMMANDS_BIT}; uint32_t numAvailableCommandBuffers_ = kMaxCommandBuffers; uint32_t submitCounter_ = 1; };
The VulkanImmediateCommands
class is central to the entire operation of our Vulkan backend. Let’s dive into its implementation, examining each method in detail.
Let’s begin with the class constructor and destructor. The constructor preallocates all command buffers. For simplicity, error checking and debugging code will be omitted here; please refer to the LightweightVK library source code for full error-checking details.
- First, we should retrieve a Vulkan device queue and allocate a command pool. The
VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT
flag is used to specify that any command buffers allocated from this pool can be individually reset to their initial state using the Vulkan functionvkResetCommandBuffer()
. To indicate that command buffers allocated from this pool will have a short lifespan, we use theVK_COMMAND_POOL_CREATE_TRANSIENT_BIT
flag, meaning they will be reset or freed within a relatively short timeframe.lvk::VulkanImmediateCommands::VulkanImmediateCommands( VkDevice device, uint32_t queueFamilyIndex, const char* debugName) : device_(device), queueFamilyIndex_(queueFamilyIndex), debugName_(debugName) { vkGetDeviceQueue(device, queueFamilyIndex, 0, &queue_); const VkCommandPoolCreateInfo ci = { .sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO, .flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT | VK_COMMAND_POOL_CREATE_TRANSIENT_BIT, .queueFamilyIndex = queueFamilyIndex, }; vkCreateCommandPool(device, &ci, nullptr, &commandPool_);
- Now, we can preallocate all the command buffers from the command pool. In addition, we create one semaphore and one fence for each command buffer to enable our synchronization system.
const VkCommandBufferAllocateInfo ai = { .sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO, .commandPool = commandPool_, .level = VK_COMMAND_BUFFER_LEVEL_PRIMARY, .commandBufferCount = 1, }; for (uint32_t i = 0; i != kMaxCommandBuffers; i++) { CommandBufferWrapper& buf = buffers_[i]; char fenceName[256] = {0}; char semaphoreName[256] = {0}; if (debugName) { // ... assign debug names to fenceName and semaphoreName } buf.semaphore_ = lvk::createSemaphore(device, semaphoreName); buf.fence_ = lvk::createFence(device, fenceName); vkAllocateCommandBuffers( device, &ai, &buf.cmdBufAllocated_); buffers_[i].handle_.bufferIndex_ = i; } }
- The destructor is almost trivial. We simply wait for all command buffers to be processed before destroying the command pool, fences, and semaphores.
lvk::VulkanImmediateCommands::~VulkanImmediateCommands() { waitAll(); for (CommandBufferWrapper& buf : buffers_) { vkDestroyFence(device_, buf.fence_, nullptr); vkDestroySemaphore(device_, buf.semaphore_, nullptr); } vkDestroyCommandPool(device_, commandPool_, nullptr); }
Now, let’s examine the implementation of our most important function acquire()
. All error checking code is omitted again to keep the explanation clear and focused.
- Before we can find an available command buffer, we need to ensure there is one. This busy-wait loop checks the number of currently available command buffers and calls the
purge()
function, which recycles processed command buffers and resets them to their initial state, until at least one buffer becomes available. In practice, this loop almost never runs.const lvk::VulkanImmediateCommands::CommandBufferWrapper& lvk::VulkanImmediateCommands::acquire() { while (!numAvailableCommandBuffers_) purge();
- Once we know there’s at least one command buffer available, we can find it by going through the array of all buffers and selecting the first available one. At this point, we decrement
numAvailableCommandBuffers
to ensure proper busy-waiting on the next call toacquire()
. TheisEncoding
member field is used to prevent the reuse of a command buffer that has already been acquired but has not yet been submitted.VulkanImmediateCommands::CommandBufferWrapper* current = nullptr; for (CommandBufferWrapper& buf : buffers_) { if (buf.cmdBuf_ == VK_NULL_HANDLE) { current = &buf; break; } } current->handle_.submitId_ = submitCounter_; numAvailableCommandBuffers_--; current->cmdBuf_ = current->cmdBufAllocated_; current->isEncoding_ = true;
- After completing all the bookkeeping on our side, we can call the Vulkan API to begin recording the current command buffer.
const VkCommandBufferBeginInfo bi = { .sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO, .flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT, }; VK_ASSERT(vkBeginCommandBuffer(current->cmdBuf_, &bi)); nextSubmitHandle_ = current->handle_; return *current; }
- Before we dive into the next series of functions, let’s take a look at a short helper function,
purge()
, which was mentioned earlier inacquire()
. This function callsvkWaitForFences()
with a Vulkan fence and a timeout value of0
, which causes it to return the current status of the fence without waiting. If the fence is signaled, we can reset the command buffer and incrementnumAvailableCommandBuffers
. We always begin checking with the oldest submitted buffer and then wrap around.void lvk::VulkanImmediateCommands::purge() { const uint32_t numBuffers = LVK_ARRAY_NUM_ELEMENTS(buffers_); for (uint32_t i = 0; i != numBuffers; i++) { const uint32_t index = i + lastSubmitHandle_.bufferIndex_+1; CommandBufferWrapper& buf = buffers_[index % numBuffers]; if (buf.cmdBuf_ == VK_NULL_HANDLE || buf.isEncoding_) continue; const VkResult result = vkWaitForFences(device_, 1, &buf.fence_, VK_TRUE, 0); if (result == VK_SUCCESS) { vkResetCommandBuffer( buf.cmdBuf_, VkCommandBufferResetFlags{0}); vkResetFences(device_, 1, &buf.fence_); buf.cmdBuf_ = VK_NULL_HANDLE; numAvailableCommandBuffers_++; } else { if (result != VK_TIMEOUT) VK_ASSERT(result); } } }
Another crucial function is submit()
, which submits a command buffer to a queue. Let’s take a look.
- First, we should call
vkEndCommandBuffer()
to finish recording a command buffer.SubmitHandle lvk::VulkanImmediateCommands::submit( const CommandBufferWrapper& wrapper) { vkEndCommandBuffer(wrapper.cmdBuf_);
- Then we should prepare semaphores. We can set two optional semaphores to be waited on before GPU processes our command buffer. The first one is the semaphore we injected with the
waitSemaphore()
function. It can be an “acquire semaphore” from a swapchain or any other user-provided semaphore if we want to organize a frame graph of some sort. The second semaphorelastSubmitSemaphore_
is the semaphore signaled by a previously submitted command buffer. This ensures all command buffers are processed sequentially one by one.VkSemaphoreSubmitInfo waitSemaphores[] = {{}, {}}; uint32_t numWaitSemaphores = 0; if (waitSemaphore_.semaphore) waitSemaphores[numWaitSemaphores++] = waitSemaphore_; if (lastSubmitSemaphore_.semaphore) waitSemaphores[numWaitSemaphores++] = lastSubmitSemaphore_;
- The
signalSemaphores[]
are signaled when the command buffer finishes execution. There are two of them: The first is the one we allocated along with our command buffer and is used for chaining command buffers together. The second is an optional timeline semaphore, injected by thesignalSemaphore()
function. It is injected at the end of the frame, before presenting the final image to the screen, and is used to orchestrate the swapchain presentation.VkSemaphoreSubmitInfo signalSemaphores[] = { VkSemaphoreSubmitInfo{ .sType = VK_STRUCTURE_TYPE_SEMAPHORE_SUBMIT_INFO, .semaphore = wrapper.semaphore_, .stageMask = VK_PIPELINE_STAGE_ALL_COMMANDS_BIT}, {}, }; uint32_t numSignalSemaphores = 1; if (signalSemaphore_.semaphore) { signalSemaphores[numSignalSemaphores++] = signalSemaphore_; }
- Once we have all the data in place, calling
vkQueueSubmit2()
is straightforward. We populate theVkCommandBufferSubmitInfo
structure usingVkCommandBuffer
from the currentCommandBufferWrapper
object and add all the semaphores toVkSubmitInfo2
, allowing us to synchronize on them during the nextsubmit()
call.const VkCommandBufferSubmitInfo bufferSI = { .sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_SUBMIT_INFO, .commandBuffer = wrapper.cmdBuf_, }; const VkSubmitInfo2 si = { .sType = VK_STRUCTURE_TYPE_SUBMIT_INFO_2, .waitSemaphoreInfoCount = numWaitSemaphores, .pWaitSemaphoreInfos = waitSemaphores, .commandBufferInfoCount = 1u, .pCommandBufferInfos = &bufferSI, .signalSemaphoreInfoCount = numSignalSemaphores, .pSignalSemaphoreInfos = signalSemaphores, }; vkQueueSubmit2(queue_, 1u, &si, wrapper.fence_); lastSubmitSemaphore_.semaphore = wrapper.semaphore_; lastSubmitHandle_ = wrapper.handle_;
- Once the
waitSemaphore_
andsignalSemaphore_
objects have been used, they should be discarded. They are meant to be used with exactly one command buffer. ThesubmitCounter_
variable is used to set thesubmitId
value in the nextSubmitHandle
. Here’s a trick we use: aSubmitHandle
is considered empty when its command buffer andsubmitId
are both zero. A simple way to achieve this is to always skip the zero value ofsubmitCounter
, hence double incrementing when we encounter zero.waitSemaphore_.semaphore = VK_NULL_HANDLE; signalSemaphore_.semaphore = VK_NULL_HANDLE; const_cast<CommandBufferWrapper&>(wrapper).isEncoding_ = false; submitCounter_++; if (!submitCounter_) submitCounter_++; return lastSubmitHandle_; }
This code is already sufficient to manage command buffers in an application. However, let’s take a look at other methods of VulkanImmediateCommands
that simplify working with Vulkan fences by hiding them behind SubmitHandle
. The next most useful method is isReady()
, which serves as our high-level equivalent of vkWaitForFences()
with the timeout set to 0
.
- First, we perform a trivial check for an empty submit handle.
bool VulkanImmediateCommands::isReady( const SubmitHandle handle) const { if (handle.empty()) return true;
- Next, we inspect the actual command buffer wrapper and check if its command buffer has already been recycled by the
purge()
method we explored earlier.const CommandBufferWrapper& buf = buffers_[handle.bufferIndex_]; if (buf.cmdBuf_ == VK_NULL_HANDLE) return true;
- Another scenario occurs when a command buffer has been recycled and then reused. Reuse can only happen after the command buffer has finished execution. In this case, the
submitId
values would be different. Only after this comparison can we invoke the Vulkan API to check the status of ourVkFence
object.if (buf.handle_.submitId_ != handle.submitId_) return true; return vkWaitForFences(device_, 1, &buf.fence_, VK_TRUE, 0) == VK_SUCCESS; }
The isReady()
method provides a simple interface to Vulkan fences, which can be exposed to applications using the LightweightVK library without revealing the actual VkFence
objects or the entire mechanism of how VkCommandBuffer
objects are submitted and reset.
There is a pair of similar methods that allow us to wait for a specific VkFence
hidden behind SubmitHandle
.
- The first method is
wait()
, and it waits for a single fence to be signaled. Two important points to mention here: We can detect a wait operation on a non-submitted command buffer using theisEncoding_
flag. Also, we callpurge()
at the end of the function because we are sure there is now at least one command buffer available to be reclaimed. There’s a special shortcut here: if we callwait()
with an emptySubmitHandle
, it will invokevkDeviceWaitIdle()
, which is often useful for debugging.void lvk::VulkanImmediateCommands::wait( const SubmitHandle handle) { if (handle.empty()) { vkDeviceWaitIdle(device_); return; } if (isReady(handle)) return; if (!LVK_VERIFY(!buffers_[handle.bufferIndex_].isEncoding_)) return; VK_ASSERT(vkWaitForFences(device_, 1, &buffers_[handle.bufferIndex_].fence_, VK_TRUE, UINT64_MAX)); purge(); }
- The second function waits for all submitted command buffers to be completed, and it is useful when we want to delete all resources, such as in the destructor. The implementation is straightforward, and we call
purge()
again to reclaim all completed command buffers.void lvk::VulkanImmediateCommands::waitAll() { VkFence fences[kMaxCommandBuffers]; uint32_t numFences = 0; for (const CommandBufferWrapper& buf : buffers_) { if (buf.cmdBuf_ != VK_NULL_HANDLE && !buf.isEncoding_) fences[numFences++] = buf.fence_; } if (numFences) VK_ASSERT(vkWaitForFences( device_, numFences, fences, VK_TRUE, UINT64_MAX)); purge(); }
Those are all the details about the low-level command buffers implementation. Now, let’s take a look at how this code works together with our demo application.
How it works…
Let’s go all the way back to our demo application Chapter02/01_Swapchain
and its main loop. We call the function VulkanContext::acquireCommandBuffer()
, which returns a reference to a high-level interface lvk::ICommandBuffer
. Then, we call VulkanContext::submit()
to submit that command buffer.
while (!glfwWindowShouldClose(window)) {
glfwPollEvents();
glfwGetFramebufferSize(window, &width, &height);
if (!width || !height) continue;
lvk::ICommandBuffer& buf = ctx->acquireCommandBuffer();
ctx->submit(buf, ctx->getCurrentSwapchainTexture());
}
Here’s what is going on inside those functions.
- The first function
VulkanContext::acquireCommandBuffer()
is very simple. It stores a newlvk::CommandBuffer
object insideVulkanContext
and returns a referent to it. This lightweight object implements thelvk::ICommandBuffer
interface and, in the constructor, just callsVulkanImmediateCommands::acquire()
we explored earlier.ICommandBuffer& VulkanContext::acquireCommandBuffer() { LVK_ASSERT_MSG(!pimpl_->currentCommandBuffer_.ctx_, "Cannot acquire more than 1 command buffer simultaneously"); pimpl_->currentCommandBuffer_ = CommandBuffer(this); return pimpl_->currentCommandBuffer_; }
- The function
VulkanContext::submit()
is more elaborate. Besides submitting a command buffer, it takes an optional argument of a swapchain texture to be presented. For now, we will skip this part and focus only on the command buffer submission.void VulkanContext::submit( const lvk::ICommandBuffer& commandBuffer, TextureHandle present) { vulkan::CommandBuffer* vkCmdBuffer = const_cast<vulkan::CommandBuffer*>( static_cast<const vulkan::CommandBuffer*>(&commandBuffer)); if (present) { // … do proper layout transitioning for the Vulkan image }
- If we are presenting a swapchain image to the screen, we need to signal our timeline semaphore. Our timeline semaphore orchestrates the swapchain and works as follows: There is a
uint64_t
frame counterVulkanSwapchain::currentFrameIndex_
, which increments monotonically with each presented frame. We have a specific number of frames in the swapchain—let’s say3
for example. Then, we can calculate different timeline signal values for each swapchain image so that we wait on these values every3
frames. We wait for these corresponding timeline values when we want to acquire the same swapchain image the next time, before callingvkAcquireNextImageKHR()
. For example, we render frame0
, and the next time we want to acquire it, we wait until the signal semaphore value reaches at least3
. Here, we call the functionsignalSemaphore()
mentioned earlier to inject this timeline signal into our command buffer submission.const bool shouldPresent = hasSwapchain() && present; if (shouldPresent) { const uint64_t signalValue = swapchain_->currentFrameIndex_ + swapchain_->getNumSwapchainImages(); swapchain_->timelineWaitValues_[ swapchain_->currentImageIndex_] = signalValue; immediate_->signalSemaphore(timelineSemaphore_, signalValue); } vkCmdBuffer->lastSubmitHandle_ = immediate_->submit(*vkCmdBuffer->wrapper_);
- After submission, we retrieve the last submit semaphore and pass it into the swapchain so it can wait on it before the image to be presented is fully rendered by the GPU.
if (shouldPresent) { swapchain_->present( immediate_->acquireLastSubmitSemaphore()); }
- Then we call abovementioned
VulkanImmediateCommands::submit()
and use its last submit semaphore to tell the swapchain to wait until the rendering is completed.vkCmdBuffer->lastSubmitHandle_ = immediate_->submit(*vkCmdBuffer->wrapper_); if (shouldPresent) { swapchain_->present(immediate_->acquireLastSubmitSemaphore()); }
- On every submit operation, we process so-called deferred tasks. Our deferred task is an
std::packaged_task
that should only be run when an associatedSubmitHandle
, also known asVkFence
, is ready. This mechanism is very helpful for managing or deallocating Vulkan resources that might still be in use by the GPU, and will be discussed in subsequent chapters.processDeferredTasks(); SubmitHandle handle = vkCmdBuffer->lastSubmitHandle_; pimpl_->currentCommandBuffer_ = {}; return handle; }
- Last but not least, let’s take a quick look at
VulkanSwapchain::getCurrentTexture()
to see howvkAcquireNextImageKHR()
interacts with all the aforementioned semaphores. Here, we wait on the timeline semaphore using the specific signal value for the current swapchain image, which we calculated in the code above. If you’re confused, the pattern here is that for rendering frameN
, we wait for the signal valueN
. After submitting GPU work, we signal the valueN+numSwapchainImages
.lvk::TextureHandle lvk::VulkanSwapchain::getCurrentTexture() { if (getNextImage_) { const VkSemaphoreWaitInfo waitInfo = { .sType = VK_STRUCTURE_TYPE_SEMAPHORE_WAIT_INFO, .semaphoreCount = 1, .pSemaphores = &ctx_.timelineSemaphore_, .pValues = &timelineWaitValues_[currentImageIndex_], }; vkWaitSemaphores(device_, &waitInfo, UINT64_MAX);
- Then, we can pass the corresponding acquire semaphore to
vkAcquireNextImageKHR()
. After this call, we pass thisacquireSemaphore
toVulkanImmediateCommands::waitSemaphore()
so that we wait on it before submitting the next command buffer that renders into this swapchain image.VkSemaphore acquireSemaphore = acquireSemaphore_[currentImageIndex_]; vkAcquireNextImageKHR(device_, swapchain_, UINT64_MAX, acquireSemaphore, VK_NULL_HANDLE, ¤tImageIndex_); getNextImage_ = false; ctx_.immediate_->waitSemaphore(acquireSemaphore); } if (LVK_VERIFY(currentImageIndex_ < numSwapchainImages_)) return swapchainTextures_[currentImageIndex_]; return {}; }
Now we have a working subsystem to wrangle Vulkan command buffers and expose VkFence
objects to user applications in a clean and straightforward way. We didn’t cover the ICommandBuffer
interface in this recipe, but we will address it shortly in this chapter while working on our first Vulkan rendering demo. Before we start rendering, let’s learn how to use compiled SPIR-V shaders from the recipe Compiling Vulkan shaders at runtime in Chapter 1.
There’s more…
We recommend referring to Vulkan Cookbook by Packt for in-depth coverage of swapchain creation and command queues management.