Rether / docs
engine/apis/vulkan/Vulkan Swapchain
referenceBuilding and driving the swapchain on the Vulkan backend

Vulkan Swapchain

updated 2026.08.31created 2026.08.31~12 min

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.

// cpp
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.

// cpp
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.

// cpp
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.

// cpp
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.

// cpp
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 by eLazilyAllocated memory. We allocate plain eDeviceLocal, 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 (eD32Sfloat first). 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 imageAvailableSemaphore makes rendering wait until the display has released the image; the renderFinishedSemaphore makes presentation wait until the GPU has finished drawing.
// cpp
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_FLIGHT is how many frames the CPU prepares ahead; d_images.size() is the real number of images the display engine created, which can be larger. Binding renderFinishedSemaphore to 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.

// cpp
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. eErrorOutOfDateKHR means the image is unusable and we must bail out. eSuboptimalKHR means it still works, just not ideally — so we keep it. Recreating here instead would abandon an imageAvailableSemaphore that 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.

// cpp
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.

// cpp
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

Keyboard

search this vault⌘K / /
step the sidebarjk
open selected↵
switch browse mode123
switch vaultgv
toggle themet
close / clearesc
this help?
esc to close