diff --git a/crates/client/src/main.rs b/crates/client/src/main.rs index adadbb8..02c7289 100644 --- a/crates/client/src/main.rs +++ b/crates/client/src/main.rs @@ -190,6 +190,15 @@ impl ApplicationHandler for App { WindowEvent::CloseRequested => { event_loop.exit(); } + WindowEvent::Resized(size) => { + // Rebuild the swapchain to match the new surface size. Without this the swapchain keeps its initial extent and the compositor stretches the fixed-size image to the window, distorting the aspect ratio. + if let Some(renderer) = self.renderer.as_mut() + && let Err(e) = renderer.recreate_swapchain(size.width, size.height) + { + error!("Failed to recreate swapchain on resize: {e}"); + event_loop.exit(); + } + } WindowEvent::KeyboardInput { event, .. } => { let pressed = event.state == ElementState::Pressed; if let PhysicalKey::Code(code) = event.physical_key { diff --git a/crates/renderer/src/renderer.rs b/crates/renderer/src/renderer.rs index aba68c3..ba75418 100644 --- a/crates/renderer/src/renderer.rs +++ b/crates/renderer/src/renderer.rs @@ -1,7 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-only -use crate::create_gpu_buffer; use crate::sync::SyncPrimitives; +use crate::{create_depth_resources, create_gpu_buffer, swapchain}; use crate::{error::RendererError, mesh::Vertex}; use ash::{Device, Instance, khr, vk}; use gpu_allocator::vulkan::{Allocation, Allocator}; @@ -37,7 +37,6 @@ pub struct Renderer { /// The debug messenger for validation layer output. pub(crate) debug_messenger: vk::DebugUtilsMessengerEXT, /// Handle to the selected physical device (GPU). - #[expect(dead_code, reason = "retained for later device-capability queries")] pub(crate) physical_device: vk::PhysicalDevice, /// The logical Vulkan device. pub(crate) device: Device, @@ -60,7 +59,6 @@ pub struct Renderer { /// Images acquired from the swapchain. pub(crate) swapchain_images: Vec, /// The pixel format of the swapchain images. - #[expect(dead_code, reason = "retained for later swapchain recreation")] pub(crate) swapchain_format: vk::Format, /// The dimensions of the swapchain images. pub(crate) swapchain_extent: vk::Extent2D, @@ -105,22 +103,34 @@ impl Renderer { let image_available_semaphore = sync.image_available[self.current_frame]; let cmd = self.command_buffers[self.current_frame]; - // 1. Wait for the current frame's GPU work to finish + // 1. Wait for the current frame's GPU work to finish. The fence is intentionally not reset here: if the acquire below reports the swapchain is out of date, the frame is abandoned before any work is submitted, and a reset fence would then remain permanently unsignaled and deadlock the next wait. unsafe { self.device .wait_for_fences(&[in_flight_fence], true, u64::MAX)?; - self.device.reset_fences(&[in_flight_fence])?; } - // 2. Acquire an image from the swapchain - let (image_index, _is_suboptimal) = unsafe { + // 2. Acquire an image from the swapchain. An out-of-date swapchain (typically a window resize) is not a fatal error: the swapchain is rebuilt and this frame is skipped, to be retried on the next call. + let acquire = unsafe { self.swapchain_loader.acquire_next_image( self.swapchain, u64::MAX, image_available_semaphore, vk::Fence::null(), - )? + ) }; + let (image_index, _is_suboptimal) = match acquire { + Ok(pair) => pair, + Err(vk::Result::ERROR_OUT_OF_DATE_KHR) => { + self.recreate_swapchain(self.swapchain_extent.width, self.swapchain_extent.height)?; + return Ok(()); + } + Err(e) => return Err(e.into()), + }; + + // The frame will now be submitted, so the fence is reset immediately before it is handed to the queue. + unsafe { + self.device.reset_fences(&[in_flight_fence])?; + } // Use the semaphore tied to this specific swapchain image for rendering completion let render_finished_semaphore = self @@ -162,17 +172,112 @@ impl Renderer { .swapchains(std::slice::from_ref(&self.swapchain)) .image_indices(std::slice::from_ref(&image_index)); - unsafe { + let present = unsafe { self.swapchain_loader - .queue_present(self.graphics_queue, &present_info)?; - } + .queue_present(self.graphics_queue, &present_info) + }; - // Advance the frame index for the next call + // Advance the frame index regardless of the present outcome; the submitted work is already in flight on `in_flight_fence`. self.current_frame = (self.current_frame + 1) % crate::MAX_FRAMES_IN_FLIGHT; + // A suboptimal (`Ok(true)`) or out-of-date swapchain is rebuilt so the next frame targets a surface-matched swapchain. The rebuilt swapchain also corrects the projection aspect ratio, which is derived from the swapchain extent. + match present { + Ok(false) => {} + Ok(true) | Err(vk::Result::ERROR_OUT_OF_DATE_KHR) => { + self.recreate_swapchain(self.swapchain_extent.width, self.swapchain_extent.height)?; + } + Err(e) => return Err(e.into()), + } + Ok(()) } + /// Rebuilds the swapchain and every resource whose size derives from it, at the given surface dimensions. + /// + /// This is called when the presentation surface has changed size (a window resize) or when Vulkan reports the swapchain is out of date. The device is drained first so no in-flight work references the resources being freed. The projection aspect ratio is derived from [`Self::swapchain_extent`], so rebuilding the swapchain at the new extent corrects a stretched or squashed image for free. + /// + /// A zero-area surface (a minimized window) is a no-op: a swapchain cannot be created with a zero extent, so the previous resources are retained until a non-zero size is reported. + /// + /// On platforms where the surface reports a definitive `current_extent` (typically X11), `width` and `height` are ignored in favour of that value; they are used as the fallback size only where the surface defers to the application (typically Wayland). + /// + /// # Errors + /// + /// Returns [`RendererError::AllocatorMissing`] if the GPU allocator has been released, or a [`RendererError`] propagated from swapchain, image-view, or depth-resource creation. + /// + /// # Panics + /// + /// Panics if the driver reports zero surface formats, which the Vulkan specification forbids for a supported surface. + pub fn recreate_swapchain(&mut self, width: u32, height: u32) -> Result<(), RendererError> { + // A zero extent cannot back a swapchain; defer the rebuild until the surface has area again. + if width == 0 || height == 0 { + return Ok(()); + } + + // The old resources may still be referenced by in-flight frames; draining the device guarantees they are free to destroy. + unsafe { + self.device.device_wait_idle()?; + } + + self.destroy_swapchain_resources(); + + let (swapchain_loader, swapchain, swapchain_images, swapchain_format, swapchain_extent) = + swapchain::create_swapchain( + &self.instance, + self.physical_device, + &self.device, + &self.surface_loader, + self.surface, + width, + height, + )?; + let swapchain_image_views = + swapchain::create_image_views(&self.device, &swapchain_images, swapchain_format)?; + + let allocator = self + .allocator + .as_mut() + .ok_or(RendererError::AllocatorMissing)?; + let (depth_image, depth_allocation, depth_image_view) = + create_depth_resources(&self.device, allocator, swapchain_extent)?; + + self.swapchain_loader = swapchain_loader; + self.swapchain = swapchain; + self.swapchain_images = swapchain_images; + self.swapchain_format = swapchain_format; + self.swapchain_extent = swapchain_extent; + self.swapchain_image_views = swapchain_image_views; + self.depth_image = depth_image; + self.depth_allocation = Some(depth_allocation); + self.depth_image_view = depth_image_view; + + Ok(()) + } + + /// Destroys the swapchain and every size-dependent resource derived from it (image views and depth buffer), leaving the fields holding stale handles until the caller overwrites them. + /// + /// The device must already be idle; callers are responsible for that ordering. Only invoked from [`Self::recreate_swapchain`], which drains the device and immediately replaces every field this touches. + fn destroy_swapchain_resources(&mut self) { + unsafe { + self.device.destroy_image_view(self.depth_image_view, None); + self.device.destroy_image(self.depth_image, None); + if let Some(allocator) = self.allocator.as_mut() + && let Some(alloc) = self.depth_allocation.take() + && let Err(e) = allocator.free(alloc) + { + tracing::error!("Failed to free depth image allocation: {e}"); + } + + // Image views are destroyed before the swapchain that owns their underlying images. + for &view in &self.swapchain_image_views { + self.device.destroy_image_view(view, None); + } + self.swapchain_image_views.clear(); + + self.swapchain_loader + .destroy_swapchain(self.swapchain, None); + } + } + /// Records the drawing commands into the given command buffer. /// /// # Errors