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.
Choices made when building it
- How many images. This renderer asks for three, within whatever range the surface allows.
- Pixel format. It prefers 8-bit blue-green-red-alpha, the common native format for displays.
- Size. The window's size in actual pixels. On a Mac's high-density "Retina" display that's twice the size macOS reports for layout.
- Present mode, meaning how presented images reach the screen. FIFO is a queue drained once per display refresh: classic vsync, always available. Mailbox also waits for the refresh but replaces a waiting image with a newer one. That cuts latency without tearing, the visible seam you get when the screen switches images mid-refresh. RustyVulkan uses mailbox when offered, otherwise FIFO.
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.
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,
)?
};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)?
};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;
}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)
}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
}
}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,
),
}
}
}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()
};Fields worth knowing:
image_usage: COLOR_ATTACHMENT: these images will be rendered into. This renderer never samples them as textures or copies to them.image_sharing_mode: EXCLUSIVE: only one queue family touches them, which is true here, since one queue does everything (Chapter 3).pre_transformandcomposite_alpha: keep the surface's current orientation, and treat the window as opaque if that's allowed.choose_composite_alphawalks a preference list and returns the first mode the surface supports.clipped: TRUE: the driver may skip pixels hidden behind other windows.old_swapchain: null the first time. On a resize it's the swapchain being replaced, which lets the driver reuse or retire the old images without a gap on screen (see "If recreation fails" below).
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);
}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();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();
}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;
}
}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;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)
);
}Recap
- The surface is the window as Vulkan sees it, created through
ash-windowfrom the window's raw handles. On macOS it wraps a Metal layer. - The swapchain is a ring of images you acquire, render into and present. Here there are three, in
B8G8R8A8_UNORM, using mailbox presentation if available and FIFO otherwise. - Multisampled colour and depth images live alongside it, because they share its size.
- Construction records ownership step by step so a failure cleans up exactly what was made.
cleanupis safe to repeat. - Resizes, and out-of-date or suboptimal results, request recreation, which is applied once at the start of a frame. Zero-sized windows are skipped.