Vulkan Swapchain
The concept
This note is the Vulkan implementation of the Swapchain concept. Once we have a Vulkan logical device and a Vulkan surface, we have the infrastructure to draw — but no canvas yet. Vulkan never lets us render straight into the window's pixels; we render into off-screen images and swap a finished one onto the screen.
Creating the vk::SwapchainKHR means answering three questions about the
surface: its color format, its present mode, and its resolution (extent). The
three helpers that answer them are free functions in an anonymous namespace at
the top of vlk_swapchain.cpp — they need no state of their own, so they take
what they need as parameters and stay out of the class interface.
Surface format (color)
How a pixel is encoded: the channel layout and the color space. We prefer
B8G8R8A8Srgb with an SRGB non-linear color space so the hardware performs
gamma-correct blending for us, and fall back to whatever the surface reports
first.
vk::SurfaceFormatKHR
chooseSwapSurfaceFormat(const std::vector<vk::SurfaceFormatKHR>& formats)
{
for (const auto& fmt : formats) {
// Prefer SRGB for automatic gamma-correct rendering.
if (fmt.format == vk::Format::eB8G8R8A8Srgb &&
fmt.colorSpace == vk::ColorSpaceKHR::eSrgbNonlinear) {
return fmt;
}
}
return formats[0]; // Fallback to the first available format.
}Present mode (VSync)
The trade-off between latency and tearing. We take eMailbox (triple buffering:
no tearing, lowest latency) when the driver offers it, and fall back to eFifo
(classic VSync), which the spec guarantees is always available.
vk::PresentModeKHR
chooseSwapPresentMode(const std::vector<vk::PresentModeKHR>& presentModes)
{
for (const auto& mode : presentModes) {
// Mailbox: low latency and no tearing (triple buffering).
if (mode == vk::PresentModeKHR::eMailbox) {
return mode;
}
}
// Guaranteed to be available (standard V-Sync).
return vk::PresentModeKHR::eFifo;
}Swap extent (resolution)
The resolution of the swapchain images, in pixels. On Retina / high-DPI screens
the window size in screen coordinates is not the pixel count. The caller passes
the swapchain's cached framebuffer size — d_width / d_height, refreshed from
GLFW on every resize — and we clamp it to the surface's min/max.
vk::Extent2D chooseSwapExtent(const vk::SurfaceCapabilitiesKHR& caps,
const uint32_t width,
const uint32_t height)
{
if (caps.currentExtent.width != std::numeric_limits<uint32_t>::max()) {
return caps.currentExtent;
}
vk::Extent2D actualExtent = {width, height};
actualExtent.width = std::clamp(actualExtent.width,
caps.minImageExtent.width,
caps.maxImageExtent.width);
actualExtent.height = std::clamp(actualExtent.height,
caps.minImageExtent.height,
caps.maxImageExtent.height);
return actualExtent;
}Creating the swapchain
We combine the three choices into the create info. We request
minImageCount + 1 images so the CPU can prepare the next frame while the GPU
is still busy with the current one, clamped to the surface's maximum. Passing
the old swapchain as oldSwapchain lets the driver recycle resources across a
resize.
void Swapchain::createSwapchain()
{
const auto& physDev = d_context_p->physicalDevice();
const auto& surface = d_context_p->surface();
const auto caps = physDev.getSurfaceCapabilitiesKHR(*surface);
const auto formats = physDev.getSurfaceFormatsKHR(*surface);
const auto presentModes = physDev.getSurfacePresentModesKHR(*surface);
const auto surfaceFormat = chooseSwapSurfaceFormat(formats);
const auto presentMode = chooseSwapPresentMode(presentModes);
const auto extent = chooseSwapExtent(caps, d_width, d_height);
// One more than the minimum so the driver never stalls (triple buffering
// when the hardware allows it).
uint32_t imageCount = caps.minImageCount + 1;
if (caps.maxImageCount > 0 && imageCount > caps.maxImageCount) {
imageCount = caps.maxImageCount;
}
vk::SwapchainCreateInfoKHR createInfo(
{},
*surface,
imageCount,
surfaceFormat.format,
surfaceFormat.colorSpace,
extent,
1, // imageArrayLayers (1 for standard 2D, 2 for VR stereo).
vk::ImageUsageFlagBits::eColorAttachment);
createInfo.imageSharingMode = vk::SharingMode::eExclusive;
createInfo.preTransform = caps.currentTransform;
createInfo.compositeAlpha = vk::CompositeAlphaFlagBitsKHR::eOpaque;
createInfo.presentMode = presentMode;
createInfo.clipped = VK_TRUE;
createInfo.oldSwapchain = *d_swapchain; // Recycle on resize.
d_swapchain = vk::raii::SwapchainKHR(d_context_p->device(), createInfo);
// Cache for later use (pipelines, camera, recreation).
d_format = surfaceFormat.format;
d_extent = extent;
}Image views
A vk::Image from the swapchain is a raw block of memory; Vulkan cannot render
into it directly. We wrap each one in a vk::ImageView that says how to
interpret that memory — here a 2D color texture in our format. The engine uses
dynamic rendering (Vulkan 1.3), so there is no vk::RenderPass anywhere:
beginRendering takes the view straight as its color attachment.
This is also where d_images is filled. The swapchain does not tell us up front
how many images the display engine created, so we ask it here and keep the
answer — the accessors and createSyncObjects both read that member.
void Swapchain::createImageViews()
{
d_images = d_swapchain.getImages();
d_imageViews.clear(); // Clear old views in case of a resize.
d_imageViews.reserve(d_images.size());
for (const auto& image : d_images) {
vk::ImageViewCreateInfo viewInfo(
{},
image,
vk::ImageViewType::e2D,
d_format,
{},
{vk::ImageAspectFlagBits::eColor, 0, 1, 0, 1});
d_imageViews.emplace_back(d_context_p->device(), viewInfo);
}
}Color and depth resources
Two extra attachments live alongside the swapchain images:
- Color (MSAA) — swapchain images are always single-sample, so the GPU
cannot multisample into one directly. We keep a high-sample intermediate image
and resolve it into the swapchain image. It carries
eTransientAttachment, which declares that its contents never outlive the frame; on a tiled GPU (mobile, Apple) that lets the driver keep it on-chip — but only when the image is backed byeLazilyAllocatedmemory. We allocate plaineDeviceLocal, so today the flag states the intent without buying the saving. See anti-aliasing. - Depth (Z-buffer) — an invisible per-pixel distance image so a wall 100 m
away never overwrites a character 2 m away, whatever order they are drawn in.
We pick the best supported depth format (
eD32Sfloatfirst). See depth-buffer.
Both are recreated with the swapchain, at the new extent, and both use the context's MSAA sample count.
Synchronization objects
The frame loop overlaps CPU and GPU work, so it needs guardrails:
- Fences — GPU -> CPU. They tell the CPU "frame N is done; you may reuse that command list without overwriting what the GPU is still reading."
- Semaphores — GPU -> GPU, never waited on by the CPU. The
imageAvailableSemaphoremakes rendering wait until the display has released the image; therenderFinishedSemaphoremakes presentation wait until the GPU has finished drawing.
void Swapchain::createSyncObjects()
{
d_imageAvailableSemaphores.clear();
d_renderFinishedSemaphores.clear();
d_inFlightFences.clear();
vk::SemaphoreCreateInfo semaphoreInfo{};
// Signaled so the very first frame doesn't wait forever on a render that
// never happened.
vk::FenceCreateInfo fenceInfo(vk::FenceCreateFlagBits::eSignaled);
// CPU/GPU loop: one set per frame-in-flight.
for (size_t i = 0; i < MAX_FRAMES_IN_FLIGHT; i++) {
d_imageAvailableSemaphores.emplace_back(d_context_p->device(),
semaphoreInfo);
d_inFlightFences.emplace_back(d_context_p->device(), fenceInfo);
}
// GPU/screen loop: one render-finished semaphore per physical image.
for (size_t i = 0; i < d_images.size(); i++) {
d_renderFinishedSemaphores.emplace_back(d_context_p->device(),
semaphoreInfo);
}
}The two loops differ on purpose.
MAX_FRAMES_IN_FLIGHTis how many frames the CPU prepares ahead;d_images.size()is the real number of images the display engine created, which can be larger. BindingrenderFinishedSemaphoreto the physical image (not the frame index) avoids reusing a semaphore that MoltenVK may still hold on macOS.
Acquiring an image
The frame starts by waiting on this slot's fence. That is the CPU brake: it
blocks until the GPU has finished the frame that used these command buffers, so
we never overwrite work still being read. This is also what the signaled
eSignaled flag above is for — on the very first frame there is no previous
submission to wait for, and without it we would block forever.
Only then do we ask the swapchain which image is free. The passed semaphore is signaled once that image is fully released and safe to draw into.
std::optional<uint32_t> Swapchain::acquireNextImage()
{
try {
// CPU brake: wait until the GPU released this frame's resources.
[[maybe_unused]] auto waitRes = d_context_p->device().waitForFences(
*d_inFlightFences[d_currentFrame],
VK_TRUE,
std::numeric_limits<uint64_t>::max());
auto [result, index] = d_swapchain.acquireNextImage(
std::numeric_limits<uint64_t>::max(),
*d_imageAvailableSemaphores[d_currentFrame]);
if (result == vk::Result::eErrorOutOfDateKHR) {
recreateSwapchain();
return std::nullopt; // Skip this frame; draw on the next one.
}
// eSuboptimalKHR is deliberately not an error here: the image is
// valid, so we draw it and handle the resize after presenting.
if (result != vk::Result::eSuccess &&
result != vk::Result::eSuboptimalKHR) {
std::cerr << "[Error] Failed to acquire swapchain image!"
<< std::endl;
return std::nullopt;
}
return std::optional(index);
}
catch (const std::exception&) {
recreateSwapchain();
return std::nullopt;
}
}The split between the two results matters.
eErrorOutOfDateKHRmeans the image is unusable and we must bail out.eSuboptimalKHRmeans it still works, just not ideally — so we keep it. Recreating here instead would abandon animageAvailableSemaphorethat the acquire has already signaled and nobody waits on, which the validation layers flag; and because MoltenVK reports suboptimal readily on macOS, it would drop frames for no reason.
Recreating on resize
When the swapchain goes out of date we wait for the GPU to idle, then rebuild
the swapchain and all its attachments at the new extent. Minimizing is the sharp
edge: the framebuffer size becomes (0, 0), and a zero-sized swapchain crashes
Vulkan — so we block and pump events until the window has a real size again.
void Swapchain::recreateSwapchain()
{
int width = 0, height = 0;
const auto window = d_context_p->window()->window();
glfwGetFramebufferSize(window, &width, &height);
// Pause while the window is minimized.
while (width == 0 || height == 0) {
glfwGetFramebufferSize(window, &width, &height);
glfwWaitEvents();
}
// Finish in-flight work before destroying resources.
d_context_p->waitIdle();
d_width = static_cast<uint32_t>(width);
d_height = static_cast<uint32_t>(height);
try {
createSwapchain();
createImageViews();
createColorResources(); // MSAA target.
createDepthResources(); // Z-buffer.
}
catch (const std::exception& e) {
std::cerr << "[Error] Failed to recreate swapchain: " << e.what()
<< std::endl;
}
}Note what is not rebuilt here: createSyncObjects is absent, so the
semaphores and fences survive the resize. That holds as long as the display
engine hands back the same number of images — the render-finished semaphores are
sized on d_images.size(), and createImageViews reassigns that member on
every recreation.
Submit and present
Once the frame is recorded, one call does two things. Submit hands the
command list to the graphics queue: it waits on imageAvailableSemaphore at the
color-output stage (so vertex work can start early, but no pixel is written
before the image is free), and signals renderFinishedSemaphore and the frame's
fence when done. Present then queues that image for the display, waiting on
renderFinishedSemaphore so a half-drawn frame is never shown.
bool Swapchain::submitAndPresent(CommandListProtocol* cmd, uint32_t imageIndex)
{
// Wait just before writing colors; earlier stages may proceed.
constexpr vk::PipelineStageFlags waitStages[] = {
vk::PipelineStageFlagBits::eColorAttachmentOutput};
vk::SubmitInfo submitInfo{};
submitInfo.waitSemaphoreCount = 1;
submitInfo.pWaitSemaphores = &*d_imageAvailableSemaphores[d_currentFrame];
submitInfo.pWaitDstStageMask = waitStages;
submitInfo.signalSemaphoreCount = 1;
submitInfo.pSignalSemaphores = &*d_renderFinishedSemaphores[imageIndex];
const auto* vlkCmd = static_cast<CommandList*>(cmd);
const vk::CommandBuffer rawCmd = *vlkCmd->commandBuffer();
submitInfo.commandBufferCount = 1;
submitInfo.pCommandBuffers = &rawCmd;
// Reset this frame's fence, then submit; the GPU signals it when done.
d_context_p->device().resetFences(*d_inFlightFences[d_currentFrame]);
d_context_p->graphicsQueue().submit(submitInfo,
*d_inFlightFences[d_currentFrame]);
vk::PresentInfoKHR presentInfo{};
presentInfo.waitSemaphoreCount = 1;
presentInfo.pWaitSemaphores = &*d_renderFinishedSemaphores[imageIndex];
presentInfo.swapchainCount = 1;
const vk::SwapchainKHR swapchain = *d_swapchain;
presentInfo.pSwapchains = &swapchain;
presentInfo.pImageIndices = &imageIndex;
try {
const vk::Result result =
d_context_p->graphicsQueue().presentKHR(presentInfo);
if (result == vk::Result::eSuboptimalKHR) {
recreateSwapchain();
}
}
catch (const vk::OutOfDateKHRError&) {
recreateSwapchain(); // Surface became stale (resize / minimize).
}
catch (const std::exception& e) {
std::cerr << "[Error] Unexpected error during presentation: "
<< e.what() << std::endl;
return false;
}
// Advance to the next frame-in-flight so the CPU can work ahead.
d_currentFrame = (d_currentFrame + 1) %
static_cast<uint32_t>(d_inFlightFences.size());
return true;
}Relation with other concepts
- Swapchain: The API-agnostic idea this note implements.
- Vulkan surface / Vulkan logical device: The surface it presents to and the device / queues that drive it.
- anti-aliasing / depth-buffer: The color-resolve and depth attachments the swapchain carries alongside its images.