Handle Window Resize
Task: Detect window resize events and recreate the swapchain without crashing or leaking resources.
Prerequisites
- Hello Triangle, Part 2 (swapchain creation)
- Synchronization (device idle)
- Implement Double Buffering (frames in flight)
The problem
When the window is resized, the swapchain images no longer match the window dimensions. Vulkan tells you this has happened through two mechanisms:
acquire_next_image_khrorqueue_present_khrreturnsERROR_OUT_OF_DATE, meaning the swapchain is no longer compatible with the surface.queue_present_khrreturnsSUBOPTIMAL, meaning the swapchain still works but no longer matches the surface properties perfectly.
In either case, you must recreate the swapchain (and everything that depends on its images) before rendering can continue.
Step 1: Detect the resize
Track resize events from your windowing library and from Vulkan return codes.
let mut framebuffer_resized = false;
// In your window event handler (winit example):
match event {
WindowEvent::Resized(_) => {
framebuffer_resized = true;
}
_ => {}
}
In the render loop, check both the flag and the Vulkan result codes:
use vulkan_rust::vk;
use vk::*;
use vk::Result as VkError;
let acquire_result = unsafe {
device.acquire_next_image_khr(
swapchain, u64::MAX, image_available_semaphore, Fence::null(),
)
};
let image_index = match acquire_result {
Ok(index) => index,
Err(VkError::ERROR_OUT_OF_DATE) => {
recreate_swapchain(/* ... */);
continue; // restart this loop iteration
}
Err(e) => panic!("Failed to acquire swapchain image: {e:?}"),
};
// ... record and submit ...
let present_result = unsafe {
device.queue_present_khr(graphics_queue, &present_info)
};
match present_result {
Ok(_) => {}
Err(VkError::ERROR_OUT_OF_DATE | VkError::SUBOPTIMAL) => {
framebuffer_resized = false;
recreate_swapchain(/* ... */);
}
Err(e) => panic!("Failed to present: {e:?}"),
}
// Also check the manual flag (some platforms don't always return OUT_OF_DATE).
if framebuffer_resized {
framebuffer_resized = false;
recreate_swapchain(/* ... */);
}
Before reading on: why do we check
framebuffer_resizedseparately from the Vulkan error codes? Why not rely onOUT_OF_DATE_KHRalone?
Some window systems (notably X11) do not always report out-of-date when the window is resized. The manual flag from the window event handler catches those cases.
Step 2: Wait for the GPU
Before destroying any swapchain-related resources, all in-flight work must finish.
unsafe { device.device_wait_idle() }
.expect("Failed to wait for device idle");
This is simple and correct. For higher performance you could track
individual fences per swapchain image, but device_wait_idle is the
right choice for a resize path that runs infrequently.
Step 3: Destroy old resources
Destroy everything that depends on the swapchain images, in reverse creation order.
// Destroy framebuffers (one per swapchain image).
for &fb in &swapchain_framebuffers {
unsafe { device.destroy_framebuffer(fb, None); }
}
// Destroy image views (one per swapchain image).
for &view in &swapchain_image_views {
unsafe { device.destroy_image_view(view, None); }
}
// Do NOT destroy the old swapchain yet, we pass it to the new one.
You do not need to destroy the swapchain images themselves. They are owned by the swapchain and will be cleaned up when the old swapchain is destroyed.
Step 4: Query new surface capabilities
The surface extent may have changed, so re-query it.
use vulkan_rust::vk;
use vk::*;
let surface_caps = unsafe {
instance.get_physical_device_surface_capabilities_khr(physical_device, surface)
}
.expect("Failed to query surface capabilities");
let new_extent = if surface_caps.current_extent.width != u32::MAX {
// The surface has a defined size, use it.
surface_caps.current_extent
} else {
// The surface size is undefined (e.g. Wayland), clamp to limits.
let window_size = window.inner_size();
Extent2D {
width: window_size.width.clamp(
surface_caps.min_image_extent.width,
surface_caps.max_image_extent.width,
),
height: window_size.height.clamp(
surface_caps.min_image_extent.height,
surface_caps.max_image_extent.height,
),
}
};
Step 5: Handle minimized windows
When a window is minimized, the surface extent can be (0, 0). You
cannot create a swapchain with zero dimensions. Pause the render loop
until the window is restored.
if new_extent.width == 0 || new_extent.height == 0 {
// Window is minimized. Wait for a resize event before continuing.
// With winit, use Event::MainEventsCleared to avoid busy-waiting.
return Ok(());
}
Step 6: Create the new swapchain
Pass the old swapchain handle to old_swapchain. This lets the driver
reuse internal resources and can make the transition smoother.
use vulkan_rust::vk;
use vk::*;
let old_swapchain = swapchain; // save the handle
let swapchain_info = SwapchainCreateInfoKHR::builder()
.surface(surface)
.min_image_count(desired_image_count)
.image_format(surface_format.format)
.image_color_space(surface_format.color_space)
.image_extent(new_extent)
.image_array_layers(1)
.image_usage(ImageUsageFlags::COLOR_ATTACHMENT)
.image_sharing_mode(SharingMode::EXCLUSIVE)
.pre_transform(surface_caps.current_transform)
.composite_alpha(CompositeAlphaFlagBitsKHR::OPAQUE)
.present_mode(present_mode)
.clipped(true)
.old_swapchain(old_swapchain); // <-- reuse hint
swapchain = unsafe { device.create_swapchain_khr(&swapchain_info, None) }
.expect("Failed to create swapchain");
// Now destroy the old swapchain.
unsafe { device.destroy_swapchain_khr(old_swapchain, None); }
Step 7: Recreate image views and framebuffers
The new swapchain has new images, so create fresh image views and framebuffers.
use vulkan_rust::vk;
use vk::*;
let swapchain_images = unsafe { device.get_swapchain_images_khr(swapchain) }
.expect("Failed to get swapchain images");
swapchain_image_views = swapchain_images
.iter()
.map(|&image| {
let view_info = ImageViewCreateInfo::builder()
.image(image)
.view_type(ImageViewType::_2D)
.format(surface_format.format)
.subresource_range(ImageSubresourceRange {
aspect_mask: ImageAspectFlags::COLOR,
base_mip_level: 0,
level_count: 1,
base_array_layer: 0,
layer_count: 1,
});
unsafe { device.create_image_view(&view_info, None) }
.expect("Failed to create image view")
})
.collect();
swapchain_framebuffers = swapchain_image_views
.iter()
.map(|&view| {
let attachments = [view];
let fb_info = FramebufferCreateInfo::builder()
.render_pass(render_pass)
.attachments(&attachments)
.width(new_extent.width)
.height(new_extent.height)
.layers(1);
unsafe { device.create_framebuffer(&fb_info, None) }
.expect("Failed to create framebuffer")
})
.collect();
Putting it all together
A helper function that bundles the recreation logic:
use vulkan_rust::vk;
use vk::*;
fn recreate_swapchain(
instance: &vulkan_rust::Instance,
device: &vulkan_rust::Device,
physical_device: PhysicalDevice,
surface: SurfaceKHR,
window: &winit::window::Window,
render_pass: RenderPass,
swapchain: &mut SwapchainKHR,
swapchain_image_views: &mut Vec<ImageView>,
swapchain_framebuffers: &mut Vec<Framebuffer>,
surface_format: SurfaceFormatKHR,
present_mode: PresentModeKHR,
) -> Extent2D {
unsafe {
device.device_wait_idle()
.expect("Failed to wait for device idle");
// Destroy old framebuffers and image views.
for &fb in swapchain_framebuffers.iter() {
device.destroy_framebuffer(fb, None);
}
for &view in swapchain_image_views.iter() {
device.destroy_image_view(view, None);
}
}
// Query new extent, create new swapchain, views, framebuffers.
// ... (Steps 4 through 7 from above) ...
new_extent
}
Common mistakes
Forgetting to update the viewport and scissor. If you use dynamic viewport/scissor state (which you should), update them to the new extent each frame. If you baked them into the pipeline, you need to recreate the pipeline too.
Leaking old image views. Every create_image_view must have a
matching destroy_image_view. If you overwrite the Vec without
destroying the old views first, those handles leak.
Not handling SUBOPTIMAL. SUBOPTIMAL from queue_present_khr is
not a fatal error, but ignoring it means you render at the wrong
resolution until something else triggers an ERROR_OUT_OF_DATE.
Notes
- Depth buffers. If your render pass uses a depth attachment, you must also recreate the depth image, its memory, and its image view when the swapchain extent changes.
- Render pass compatibility. The render pass itself does not depend on the swapchain extent, only on the image format. You do not need to recreate it unless the surface format changes (which is extremely rare).
- Dynamic state. Using
DynamicState::VIEWPORTandDynamicState::SCISSORavoids having to recreate the pipeline on resize. This is the recommended approach.