RustyVulkan GuideContents

Part 2 · Chapter 04 · ~9 min prose skim

Surfaces and the swapchain

How finished images get from the GPU into the window, the choices that involves, and what happens when the window changes size.

Overview The big picture

Vulkan by itself knows nothing about windows. It can render into images in GPU memory, but showing one on screen is the operating system's job. Two objects bridge the gap.

A surface is the window as Vulkan sees it: a target that the system's compositor (the part of the OS that assembles all the windows into the final screen image) can display. On a Mac it wraps the window's Metal drawing layer. The surface is also what you ask about capabilities: which pixel formats it accepts, its current size, how images can be handed over.

A swapchain is a small set of images, owned by the system's presentation machinery, that take turns being shown on that surface. You don't draw into the window directly. Each frame, you acquire one of the swapchain's images, render into it, and present it, which hands it back to be displayed. Meanwhile, the image you presented last is on screen, and at most one other may be waiting its turn.

The swapchain as a ring of three images Image 0 on screen now Image 1 presented, waiting for its turn Image 2 acquired: the renderer is drawing into it present → ← acquire display → Images move anticlockwise; the renderer only ever touches the one it has acquired.
With three images, the renderer can draw a new frame while one is displayed and another is queued.

Choices made when building it

When the window changes

Swapchain images have a fixed size. When the window is resized, the swapchain no longer matches, and Vulkan says so by reporting it "out of date". The renderer then builds a new swapchain and throws away the old one, at a moment when nothing is mid-frame. Minimising the window is a special case: a zero-sized window can't have a swapchain at all, so the redraw handler skips drawing entirely until it comes back.

This renderer also keeps a pair of window-sized multisampled images alongside the swapchain, used when anti-aliasing is on: one for colour and one for depth. They store several samples per pixel for anti-aliasing, and the result is averaged down into the swapchain image at the end of the main pass (Chapter 10). Because they must match the window's size, they're created, recreated and destroyed together with the swapchain.

Skip to next overview →

Deeper dive

Making the surface

As Chapter 3 showed, the surface is created before a GPU is chosen. The ash-window crate does the platform-specific part:

let surface = unsafe {
    create_surface(
        entry,
        instance,
        event_loop.display_handle().unwrap().as_raw(),
        window.window_handle().unwrap().as_raw(),
        None,
    )?
};
src/vulkan/base/setup.rs:278-286

display_handle() and window_handle() come from the raw-window-handle crate, a common vocabulary that windowing libraries (here winit) and graphics libraries (here ash-window) both speak. On macOS, ash-window uses them to find the window's CAMetalLayer and creates the surface with the VK_EXT_metal_surface extension.

Asking what the surface supports

All the swapchain's choices start from three queries made through the surface extension's loader:

let capabilities = unsafe {
    surface_loader.get_physical_device_surface_capabilities(physical_device, surface)?
};

let formats = unsafe {
    surface_loader.get_physical_device_surface_formats(physical_device, surface)?
};

let present_modes = unsafe {
    surface_loader.get_physical_device_surface_present_modes(physical_device, surface)?
};
src/vulkan/swapchain.rs:500-510

Capabilities cover image-count limits, size limits and the current size, and supported transforms and alpha modes. Formats are the (pixel format, colour space) pairs the surface accepts, and present modes are the presentation strategies. Then new_with_old_swapchain picks from each:

let swapchain_support =
    SwapchainSupportDetails::query(physical_device, *surface, surface_loader)?;

let surface_format = Self::choose_swap_surface_format(&swapchain_support.formats)?;
let present_mode = Self::choose_swap_present_mode(&swapchain_support.present_modes);
let extent = Self::choose_swap_extent(&swapchain_support.capabilities, window);
let depth_format = vk::Format::D32_SFLOAT;

let mut image_count = 3;
if image_count < swapchain_support.capabilities.min_image_count {
    image_count = swapchain_support.capabilities.min_image_count;
}
if swapchain_support.capabilities.max_image_count > 0
    && image_count > swapchain_support.capabilities.max_image_count
{
    image_count = swapchain_support.capabilities.max_image_count;
}
src/vulkan/swapchain.rs:96-112

The depth format, D32_SFLOAT (a 32-bit float per pixel), isn't part of the swapchain at all; it's recorded here for the depth images made alongside it. The image count is three, clamped into the surface's allowed range. A max_image_count of 0 means "no upper limit", hence the extra test.

Format

fn choose_swap_surface_format(
    available_formats: &[vk::SurfaceFormatKHR],
) -> Result<vk::SurfaceFormatKHR, vk::Result> {
    // Older implementations may report UNDEFINED to permit any format.
    if let [format] = available_formats
        && format.format == vk::Format::UNDEFINED
    {
        return Ok(vk::SurfaceFormatKHR {
            format: vk::Format::B8G8R8A8_UNORM,
            color_space: format.color_space,
        });
    }
    available_formats
        .iter()
        .copied()
        .find(|f| {
            f.format == vk::Format::B8G8R8A8_UNORM
                && f.color_space == vk::ColorSpaceKHR::SRGB_NONLINEAR
        })
        .or_else(|| available_formats.first().copied())
        .ok_or(vk::Result::ERROR_FORMAT_NOT_SUPPORTED)
}
src/vulkan/swapchain.rs:292-313

The preferred format is B8G8R8A8_UNORM in the standard sRGB colour space. If the surface doesn't offer it, the first supported format is used; if it offers none at all, that's an error. The very first branch handles an old convention where a surface reported one UNDEFINED format, meaning "anything you like".

Present mode and size

fn choose_swap_present_mode(
    available_present_modes: &[vk::PresentModeKHR],
) -> vk::PresentModeKHR {
    if available_present_modes.contains(&vk::PresentModeKHR::MAILBOX) {
        vk::PresentModeKHR::MAILBOX
    } else {
        vk::PresentModeKHR::FIFO
    }
}
src/vulkan/swapchain.rs:335-343
fn choose_swap_extent(
    capabilities: &vk::SurfaceCapabilitiesKHR,
    window: &Window,
) -> vk::Extent2D {
    if capabilities.current_extent.width != u32::MAX {
        capabilities.current_extent
    } else {
        let (width, height): (u32, u32) = window.inner_size().into();
        vk::Extent2D {
            width: width.clamp(
                capabilities.min_image_extent.width,
                capabilities.max_image_extent.width,
            ),
            height: height.clamp(
                capabilities.min_image_extent.height,
                capabilities.max_image_extent.height,
            ),
        }
    }
}
src/vulkan/swapchain.rs:352-371

Most platforms, macOS included, report the surface's current_extent, and the swapchain must match it. On some platforms the special value u32::MAX means the application decides, in which case the window's inner_size() (in physical pixels) is clamped into the allowed range.

Creating the swapchain

let create_info = vk::SwapchainCreateInfoKHR {
    surface: *surface,
    min_image_count: image_count,
    image_format: surface_format.format,
    image_color_space: surface_format.color_space,
    image_extent: extent,
    image_array_layers: 1,
    image_usage: vk::ImageUsageFlags::COLOR_ATTACHMENT,
    image_sharing_mode: vk::SharingMode::EXCLUSIVE,
    pre_transform: swapchain_support.capabilities.current_transform,
    composite_alpha: Self::choose_composite_alpha(
        swapchain_support.capabilities.supported_composite_alpha,
    )?,
    present_mode,
    clipped: vk::TRUE,
    old_swapchain,
    ..Default::default()
};
src/vulkan/swapchain.rs:114-131

Fields worth knowing:

Recording ownership as it goes

After the swapchain itself come the per-image views and the two multisampled images. Any of those steps can fail, and a failure must not leak what came before. DEVELOPMENT.md names this constructor as the example of the pattern from Chapter 2:

// Keep ownership recorded as each fallible step succeeds, so any later
// failure can release exactly the resources that were created.
let mut swapchain = Self {
    handle,
    images: Vec::new(),
    swapchain_image_views: Vec::new(),
    extent,
    color_format: surface_format.format,
    depth_format,
    color_msaa_image: vk::Image::null(),
    color_msaa_image_view: vk::ImageView::null(),
    color_msaa_allocation: None,
    depth_msaa_image: vk::Image::null(),
    depth_msaa_image_view: vk::ImageView::null(),
    depth_msaa_allocation: None,
};
let result = (|| -> Result<(), Box<dyn Error>> {
    swapchain.images = unsafe { swapchain_loader.get_swapchain_images(handle)? };
    let (image, allocation) = Self::create_msaa_image(
        allocator,
        extent,
        surface_format.format,
        msaa_samples,
        vk::ImageUsageFlags::COLOR_ATTACHMENT | vk::ImageUsageFlags::TRANSIENT_ATTACHMENT,
    )?;
// ...
    swapchain.swapchain_image_views =
        Self::create_image_views(device, &swapchain.images, surface_format.format)?;
    Ok(())
})();
if let Err(error) = result {
    swapchain.cleanup(instance, device, allocator);
    return Err(error);
}
src/vulkan/swapchain.rs:136-192

The Swapchain struct is created first, with null handles and None allocations. Each step then stores its result in the struct the moment it succeeds. If any step fails, the struct accurately lists what exists, and cleanup destroys exactly that.

Two new terms appear here. An image view describes how to interpret an image: which format, which parts (colour or depth), and which sub-images (the smaller mipmap levels, or layers of an image array). Vulkan always reads and writes images through views. create_image_views makes one per swapchain image, and its error path destroys any already made. The TRANSIENT_ATTACHMENT usage on the multisampled images tells the driver their contents only matter during rendering and never need to be read back afterwards, which some GPUs can exploit to save memory traffic.

Tearing down, safely and repeatably

unsafe {
    for view in self.swapchain_image_views.drain(..) {
        device.destroy_image_view(view, None);
    }
    device.destroy_image_view(
        std::mem::replace(&mut self.color_msaa_image_view, vk::ImageView::null()),
        None,
    );
    if let Some(mut allocation) = self.color_msaa_allocation.take() {
        allocator.destroy_image(self.color_msaa_image, &mut allocation);
    }
// ...
    let swapchain_loader = swapchain::Device::new(instance, device);
    swapchain_loader.destroy_swapchain(
        std::mem::replace(&mut self.handle, vk::SwapchainKHR::null()),
        None,
    );
}
self.images.clear();
src/vulkan/swapchain.rs:258-284

cleanup is written so that running it twice is harmless. drain(..) empties the vector of views as it goes. Option::take removes each allocation (Chapter 2). std::mem::replace swaps each handle for null and hands back the old value to destroy, so a second run would only "destroy" null handles, which Vulkan permits. Note that the swapchain's own images aren't destroyed: they belong to the swapchain and go with it.

Recreating on resize

Resizes arrive as window events. Window systems often send a burst of them, so the input handler only records the intent:

if size.width == 0 || size.height == 0 {
    return;
}
if let Some(vulkan_base) = &mut app.vulkan_base {
    // Window systems often emit several resize events together. Record
    // intent here; the render loop performs one recreation at a safe point.
    vulkan_base.request_swapchain_recreation();
}
src/app/input.rs:81-88

At the start of the next frame, before anything is acquired, the render graph acts on it once:

if vb.take_swapchain_recreation_request() {
    let size = app.window.as_ref().unwrap().inner_size();
    if size.width != 0 && size.height != 0 {
        vb.recreate_swapchain(app.window.as_ref().unwrap())?;
        device_is_idle = true;
    }
}
src/vulkan/render_graph.rs:212-218

The same request is raised if acquiring or presenting reports ERROR_OUT_OF_DATE_KHR or "suboptimal" (Chapter 2 showed the acquire side). Inside the swapchain, recreation is short:

// Synchronize before destroying images or views referenced by the GPU.
unsafe { device.device_wait_idle()? };
let new_swapchain = Self::new_with_old_swapchain(
    instance,
    device,
    physical_device,
    surface,
    surface_loader,
    window,
    allocator,
    msaa_samples,
    self.handle,
)?;
self.cleanup(instance, device, allocator);
*self = new_swapchain;
src/vulkan/swapchain.rs:227-241

Wait for the GPU to stop using the old images, build the new swapchain with the old one as old_swapchain, destroy the old resources, and overwrite self with the new value. VulkanBase::recreate_swapchain then rebuilds everything sized per swapchain image, including command buffers, semaphores, uniform buffers and their descriptor sets, and the timestamp pool (Chapter 9). If the pixel format changed, it also marks every pipeline for rebuilding (Chapter 7).

Testing the choices without a GPU

The selection functions are plain functions of their inputs, so they can be unit-tested without a window or GPU:

#[cfg(test)]
mod tests {
    use super::Swapchain;
    use ash::vk;

    #[test]
    fn empty_surface_formats_return_an_error() {
        assert_eq!(
            Swapchain::choose_swap_surface_format(&[]),
            Err(vk::Result::ERROR_FORMAT_NOT_SUPPORTED)
        );
    }
src/vulkan/swapchain.rs:526-537

Recap