Vulkan Tutorial in C - 008 - Main Loop
Before entering the main loop, we need to set the globalRunning variable to true to indicate that our app is running. This variable is a simple way to check if the window is still open.
You might remember from earlier in this series that in our WindowProc callback, we set globalRunning to false when receiving a WM_CLOSE or WM_DESTROY message. This ensures that if the user closes or destroys the window, we properly exit the app’s infinite loop.
However, since we're passing UINT64_MAX to the timeout parameter, the function will block and wait for an image to be ready, and eventually return with the imageIndex. But, this doesn't mean the image is immediately ready for rendering right at that moment.
If that wasn't confusing enough, we could also pass a fence to this function, which would signal the fence at the same time the semaphore is signaled. This would allow us to know on the CPU side when the image is ready for rendering, but we're not using that in this case.
In summary, if you pass UINT64_MAX as the timeout, vkAcquireNextImageKHR will block until an image is available—meaning the OS compositor is no longer using it.
This blocking behavior has nothing to do with the semaphore or the fence. When the function returns, the image is not necessarily ready for rendering yet. Vulkan may still need to do some internal operations before the image can be used.
Once the image is actually ready for rendering, Vulkan will signal the semaphore and/or fence, if provided. The semaphore is used for GPU-side synchronization. The fence (if passed) allows the CPU to wait for the image to be ready.
Note that resetting the command buffer on the very first frame is technically unnecessary since it hasn’t been used yet, but there's no penalty for it.
The wait stage tells the GPU which part of the pipeline should wait for the semaphore. In our case, we want the GPU to wait until the image is available before starting to write new color data. This means the GPU won’t run the fragment shader until the imageAvailableSemaphore is signaled.
You can find the full source code for this tutorial series here. If you encounter any issues or have questions, feel free to leave a comment, and I'll do my best to improve the tutorial!
I hope you enjoyed this series. Next we'll add textures to our app, see you there!
Next
You might remember from earlier in this series that in our WindowProc callback, we set globalRunning to false when receiving a WM_CLOSE or WM_DESTROY message. This ensures that if the user closes or destroys the window, we properly exit the app’s infinite loop.
globalRunning = true;
while (globalRunning)
{
// ...
}
while (globalRunning)
{
// ...
}
Wait for the Frame Fence, then Reset It
vkWaitForFences(vk.device, 1, &frameFence, VK_TRUE, UINT64_MAX);
vkResetFences(vk.device, 1, &frameFence);
That's not technically necessary (you can confirm it yourself by testing the app without it), but we should still keep it. Otherwise, it will lead to unnecessary CPU usage, as I explained in an earlier post.
vkResetFences(vk.device, 1, &frameFence);
Acquire the "Next" Swapchain Image
u32 imageIndex = UINT32_MAX;
if (vkAcquireNextImageKHR(vk.device, vk.swapchain,
UINT64_MAX, // timeout
imageAvailableSemaphore,
VK_NULL_HANDLE, // fence (ignored)
&imageIndex) == VK_ERROR_OUT_OF_DATE_KHR)
{
// TODO: Handle window resize - recreate swapchain
}
assert(imageIndex != UINT32_MAX);
We need to ask Vulkan which swap chain image we should be rendering to at this point. Notice that we're passing the imageAvailableSemaphore to this function. When I first wrote this code, I thought the function would wait on the semaphore, but that's not the case. Instead, this function tells the GPU to signal the imageAvailableSemaphore once the next image is ready for rendering.if (vkAcquireNextImageKHR(vk.device, vk.swapchain,
UINT64_MAX, // timeout
imageAvailableSemaphore,
VK_NULL_HANDLE, // fence (ignored)
&imageIndex) == VK_ERROR_OUT_OF_DATE_KHR)
{
// TODO: Handle window resize - recreate swapchain
}
assert(imageIndex != UINT32_MAX);
However, since we're passing UINT64_MAX to the timeout parameter, the function will block and wait for an image to be ready, and eventually return with the imageIndex. But, this doesn't mean the image is immediately ready for rendering right at that moment.
If that wasn't confusing enough, we could also pass a fence to this function, which would signal the fence at the same time the semaphore is signaled. This would allow us to know on the CPU side when the image is ready for rendering, but we're not using that in this case.
In summary, if you pass UINT64_MAX as the timeout, vkAcquireNextImageKHR will block until an image is available—meaning the OS compositor is no longer using it.
This blocking behavior has nothing to do with the semaphore or the fence. When the function returns, the image is not necessarily ready for rendering yet. Vulkan may still need to do some internal operations before the image can be used.
Once the image is actually ready for rendering, Vulkan will signal the semaphore and/or fence, if provided. The semaphore is used for GPU-side synchronization. The fence (if passed) allows the CPU to wait for the image to be ready.
Why allow vkAcquireNextImageKHR not to block anyway?
The main reason to not block in vkAcquireNextImageKHR (i.e., passing a timeout less than UINT64_MAX) is to avoid stalling the CPU and give more control over how the application handles the lack of an available image. These are complicated cases which we're not going to discuss now.
When you pass UINT64_MAX to vkAcquireNextImageKHR, the CPU does NOT spinlock. Instead, Vulkan internally puts the thread to sleep until an image becomes available.
This is typically implemented using OS synchronization primitives like WaitForSingleObject (on Windows), so the CPU thread isn’t actively consuming cycles while waiting.
So, no unnecessary CPU usage happens—your thread just gets blocked and wakes up when an image is ready.
This is typically implemented using OS synchronization primitives like WaitForSingleObject (on Windows), so the CPU thread isn’t actively consuming cycles while waiting.
So, no unnecessary CPU usage happens—your thread just gets blocked and wakes up when an image is ready.
Process Windows' messages
MSG message;
while (PeekMessage(&message, NULL, 0, 0, PM_REMOVE))
{
TranslateMessage(&message);
DispatchMessage(&message);
}
while (PeekMessage(&message, NULL, 0, 0, PM_REMOVE))
{
TranslateMessage(&message);
DispatchMessage(&message);
}
Reset and Begin Command Buffer
vkResetCommandBuffer(commandBuffer, 0);
VkCommandBufferBeginInfo beginInfo =
{
VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO,
NULL,
0,
NULL // pInheritanceInfo
};
vkBeginCommandBuffer(commandBuffer, &beginInfo);
First, we need to reset the command buffer since it was used in the last frame. Then, we’ll begin recording new commands into it, preparing it for this frame’s rendering operations.VkCommandBufferBeginInfo beginInfo =
{
VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO,
NULL,
0,
NULL // pInheritanceInfo
};
vkBeginCommandBuffer(commandBuffer, &beginInfo);
Note that resetting the command buffer on the very first frame is technically unnecessary since it hasn’t been used yet, but there's no penalty for it.
Begin Render Pass
Even though we created the render pass earlier, in Vulkan, we still need to begin it explicitly. This is done using vkCmdBeginRenderPass, which is a command recorded into our command buffer:
VkOffset2D renderAreaOffset = { 0, 0 };
VkRect2D renderArea =
{
renderAreaOffset,
vk.swapchainExtents
};
VkClearColorValue clearColor = {1, 1, 0, 1}; // yellow
VkClearValue clearValue = { clearColor };
VkClearValue clearValues[] = { clearValue };
VkRenderPassBeginInfo renderPassBeginInfo =
{
VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO,
NULL,
renderPass,
swapchainFramebuffers[imageIndex], // framebuffer
renderArea,
array_count(clearValues),
clearValues
};
vkCmdBeginRenderPass(commandBuffer, &renderPassBeginInfo,
VK_SUBPASS_CONTENTS_INLINE);
VkRect2D renderArea =
{
renderAreaOffset,
vk.swapchainExtents
};
VkClearColorValue clearColor = {1, 1, 0, 1}; // yellow
VkClearValue clearValue = { clearColor };
VkClearValue clearValues[] = { clearValue };
VkRenderPassBeginInfo renderPassBeginInfo =
{
VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO,
NULL,
renderPass,
swapchainFramebuffers[imageIndex], // framebuffer
renderArea,
array_count(clearValues),
clearValues
};
vkCmdBeginRenderPass(commandBuffer, &renderPassBeginInfo,
VK_SUBPASS_CONTENTS_INLINE);
Finish the Command Buffer
To wrap up our simple command buffer, we bind the graphics pipeline we created earlier, issue a draw command specifying 3 vertices (which are defined directly in the vertex shader, as mentioned in an earlier post), and set 1 instance, meaning we’ll draw a single triangle. Finally, we end the render pass and end the command buffer to complete recording:
// Bind the pipeline
vkCmdBindPipeline(commandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS,
graphicsPipeline);
// Draw 3 vertices (triangle)
vkCmdDraw(commandBuffer, 3, 1, 0, 0);
// End the render pass
vkCmdEndRenderPass(commandBuffer);
// End the command buffer
vkEndCommandBuffer(commandBuffer);
vkCmdBindPipeline(commandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS,
graphicsPipeline);
// Draw 3 vertices (triangle)
vkCmdDraw(commandBuffer, 3, 1, 0, 0);
// End the render pass
vkCmdEndRenderPass(commandBuffer);
// End the command buffer
vkEndCommandBuffer(commandBuffer);
Submit Command Buffer
VkCommandBuffer commandBuffers[] = { commandBuffer };
VkSemaphore imageAvailableSemaphores[] = { imageAvailableSemaphore };
VkPipelineStageFlags waitStages[] =
{
VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT
};
VkSemaphore renderFinishedSemaphores[] = { renderFinishedSemaphore };
VkSubmitInfo submitInfo =
{
VK_STRUCTURE_TYPE_SUBMIT_INFO,
NULL,
array_count(imageAvailableSemaphores),
imageAvailableSemaphores,
waitStages,
array_count(commandBuffers),
commandBuffers,
array_count(renderFinishedSemaphores),
renderFinishedSemaphores
};
if (vkQueueSubmit(vk.graphicsAndPresentQueue, 1, &submitInfo,
frameFence) != VK_SUCCESS)
{
assert(!"failed to submit draw command buffer!");
}
VkSemaphore imageAvailableSemaphores[] = { imageAvailableSemaphore };
VkPipelineStageFlags waitStages[] =
{
VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT
};
VkSemaphore renderFinishedSemaphores[] = { renderFinishedSemaphore };
VkSubmitInfo submitInfo =
{
VK_STRUCTURE_TYPE_SUBMIT_INFO,
NULL,
array_count(imageAvailableSemaphores),
imageAvailableSemaphores,
waitStages,
array_count(commandBuffers),
commandBuffers,
array_count(renderFinishedSemaphores),
renderFinishedSemaphores
};
if (vkQueueSubmit(vk.graphicsAndPresentQueue, 1, &submitInfo,
frameFence) != VK_SUCCESS)
{
assert(!"failed to submit draw command buffer!");
}
Wait Semaphore and Wait Stage
Before the command queue begins rendering to our swapchain image, it will need to wait on the imageAvailableSemaphore.The wait stage tells the GPU which part of the pipeline should wait for the semaphore. In our case, we want the GPU to wait until the image is available before starting to write new color data. This means the GPU won’t run the fragment shader until the imageAvailableSemaphore is signaled.
Signal Semaphore
The signal semaphore (renderFinishedSemaphore), on the other hand, will be signaled once our command buffer has finished executing. This ensures that the GPU knows when it is safe to present the final rendered image, preventing any race conditions between rendering and presentation.
Note that we pass the frameFence to vkQueueSubmit so that the GPU can signal the fence once it finishes executing our command buffer.
Present the Image
VkSwapchainKHR swapchains[] = { vk.swapchain };
u32 imageIndices[] = { imageIndex };
VkPresentInfoKHR presentInfo =
{
VK_STRUCTURE_TYPE_PRESENT_INFO_KHR,
NULL,
array_count(renderFinishedSemaphores), // waitSemaphoreCount
renderFinishedSemaphores, // pWaitSemaphores
array_count(swapchains),
swapchains,
imageIndices,
NULL, // pResults
};
if (vkQueuePresentKHR(vk.graphicsAndPresentQueue, &presentInfo) ==
VK_ERROR_OUT_OF_DATE_KHR)
{
// TODO: Handle window resize - recreate swapchain
}
u32 imageIndices[] = { imageIndex };
VkPresentInfoKHR presentInfo =
{
VK_STRUCTURE_TYPE_PRESENT_INFO_KHR,
NULL,
array_count(renderFinishedSemaphores), // waitSemaphoreCount
renderFinishedSemaphores, // pWaitSemaphores
array_count(swapchains),
swapchains,
imageIndices,
NULL, // pResults
};
if (vkQueuePresentKHR(vk.graphicsAndPresentQueue, &presentInfo) ==
VK_ERROR_OUT_OF_DATE_KHR)
{
// TODO: Handle window resize - recreate swapchain
}
Semaphore Usage: Wait and Signal
When submitting the command buffer, we pass imageAvailableSemaphore as the wait semaphore and renderFinishedSemaphore as the signal semaphore. This setup might be a bit confusing, so let's break it down:- The GPU waits on imageAvailableSemaphore before starting the fragment shader, ensuring the image is ready to be written to.
- After the command buffer finishes, the GPU signals renderFinishedSemaphore to indicate completion.
- When presenting the image, the GPU waits on renderFinishedSemaphore to ensure the image is fully rendered before display.
Conclusion
And that’s a wrap for this mini series! We’ve built a minimal Vulkan app that draws a single red triangle on a yellow background. The app is quite simple, and there are still plenty of features left to explore in future tutorials, such as handling window resizing, using vertex and index buffers for drawing more complex scenes, and adding textures.You can find the full source code for this tutorial series here. If you encounter any issues or have questions, feel free to leave a comment, and I'll do my best to improve the tutorial!
I hope you enjoyed this series. Next we'll add textures to our app, see you there!
Next
Comments
Post a Comment