Synvael/crates/renderer/src/renderer.rs

657 lines
27 KiB
Rust

// SPDX-License-Identifier: AGPL-3.0-only
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};
use std::collections::HashMap;
/// Opaque, renderer-side identifier for one uploaded chunk mesh.
pub type MeshKey = (i32, i32, i32);
/// GPU resources for a single chunk mesh, drawn at a fixed world offset.
pub(crate) struct GpuMesh {
/// Buffer holding the chunk's vertex data.
pub(crate) vertex_buffer: vk::Buffer,
/// Backing allocation for [`GpuMesh::vertex_buffer`], freed when the mesh is removed.
pub(crate) vertex_allocation: Allocation,
/// Buffer holding the chunk's index data for indexed drawing.
pub(crate) index_buffer: vk::Buffer,
/// Backing allocation for [`GpuMesh::index_buffer`], freed when the mesh is removed.
pub(crate) index_allocation: Allocation,
/// Number of indices submitted in the mesh's `cmd_draw_indexed` call.
pub(crate) index_count: u32,
/// Chunk origin in world space (blocks); added to every vertex in the vertex shader.
pub(crate) world_offset: [f32; 3],
}
/// The core renderer structure holding the Vulkan resources.
pub struct Renderer {
/// Entry point to the Vulkan library.
pub(crate) _entry: ash::Entry,
/// The Vulkan instance.
pub(crate) instance: Instance,
/// Optional debug utility loader for validation layers.
pub(crate) debug_utils: Option<ash::ext::debug_utils::Instance>,
/// The debug messenger for validation layer output.
pub(crate) debug_messenger: vk::DebugUtilsMessengerEXT,
/// Handle to the selected physical device (GPU).
pub(crate) physical_device: vk::PhysicalDevice,
/// The logical Vulkan device.
pub(crate) device: Device,
/// The queue used for graphics operations.
pub(crate) graphics_queue: vk::Queue,
/// Index of the graphics queue family.
#[expect(
dead_code,
reason = "retained for later queue-family-dependent operations"
)]
pub(crate) graphics_queue_index: u32,
/// Surface extension loader.
pub(crate) surface_loader: khr::surface::Instance,
/// The presentation surface.
pub(crate) surface: vk::SurfaceKHR,
/// Swapchain extension loader.
pub(crate) swapchain_loader: khr::swapchain::Device,
/// The swapchain for presenting images.
pub(crate) swapchain: vk::SwapchainKHR,
/// Images acquired from the swapchain.
pub(crate) swapchain_images: Vec<vk::Image>,
/// The pixel format of the swapchain images.
pub(crate) swapchain_format: vk::Format,
/// The dimensions of the swapchain images.
pub(crate) swapchain_extent: vk::Extent2D,
/// Image views for each swapchain image.
pub(crate) swapchain_image_views: Vec<vk::ImageView>,
/// The command pool used for allocating command buffers.
pub(crate) command_pool: vk::CommandPool,
/// Pre-allocated command buffers for each frame in flight.
pub(crate) command_buffers: Vec<vk::CommandBuffer>,
/// The layout of the graphics pipeline.
pub(crate) pipeline_layout: vk::PipelineLayout,
/// The compiled graphics pipeline state.
pub(crate) graphics_pipeline: vk::Pipeline,
/// Memory manager for GPU allocations.
pub(crate) allocator: Option<Allocator>,
/// Uploaded chunk meshes, keyed by an opaque renderer-side handle and drawn independently.
pub(crate) chunk_meshes: HashMap<MeshKey, GpuMesh>,
/// The depth image used for depth testing.
pub(crate) depth_image: vk::Image,
/// Image view for the depth buffer.
pub(crate) depth_image_view: vk::ImageView,
/// Memory allocation for the depth image.
pub(crate) depth_allocation: Option<Allocation>,
/// Synchronization primitives for frame-by-frame execution.
pub(crate) sync: Option<SyncPrimitives>,
/// Index of the current frame being processed (0 to `crate::MAX_FRAMES_IN_FLIGHT` - 1).
pub(crate) current_frame: usize,
}
impl Renderer {
/// Renders a single frame.
///
/// # Errors
///
/// Returns [`RendererError::SyncPrimitivesMissing`] if the synchronization primitives have been torn down, or [`RendererError::VulkanError`] if any device operation (fence wait, image acquire, command recording, submit, or present) fails.
pub fn draw_frame(&mut self, camera_view: glam::Mat4) -> Result<(), RendererError> {
let sync = self
.sync
.as_ref()
.ok_or(RendererError::SyncPrimitivesMissing)?;
let in_flight_fence = sync.in_flight[self.current_frame];
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. 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)?;
}
// 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
.sync
.as_ref()
.ok_or(RendererError::SyncPrimitivesMissing)?
.render_finished[image_index as usize];
// 3. Reset and begin recording the command buffer
unsafe {
self.device
.reset_command_buffer(cmd, vk::CommandBufferResetFlags::empty())?;
let begin_info = vk::CommandBufferBeginInfo::default()
.flags(vk::CommandBufferUsageFlags::ONE_TIME_SUBMIT);
self.device.begin_command_buffer(cmd, &begin_info)?;
}
let image = self.swapchain_images[image_index as usize];
let view = self.swapchain_image_views[image_index as usize];
// 4. Record the actual rendering commands
self.record_commands(cmd, view, image, camera_view)?;
// 5. Submit the work to the GPU
let submit_info = vk::SubmitInfo::default()
.wait_semaphores(std::slice::from_ref(&image_available_semaphore))
.wait_dst_stage_mask(&[vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT])
.command_buffers(std::slice::from_ref(&cmd))
.signal_semaphores(std::slice::from_ref(&render_finished_semaphore));
unsafe {
self.device
.queue_submit(self.graphics_queue, &[submit_info], in_flight_fence)?;
}
// 6. Present the result to the screen
let present_info = vk::PresentInfoKHR::default()
.wait_semaphores(std::slice::from_ref(&render_finished_semaphore))
.swapchains(std::slice::from_ref(&self.swapchain))
.image_indices(std::slice::from_ref(&image_index));
let present = unsafe {
self.swapchain_loader
.queue_present(self.graphics_queue, &present_info)
};
// 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
///
/// Returns [`RendererError::VulkanError`] if beginning or ending command-buffer recording fails.
fn record_commands(
&self,
cmd: vk::CommandBuffer,
view: vk::ImageView,
image: vk::Image,
camera_view: glam::Mat4,
) -> Result<(), RendererError> {
// Transition layouts for drawing
self.transition_to_draw_layout(cmd, image);
// Begin rendering
let color_attachment = vk::RenderingAttachmentInfo::default()
.image_view(view)
.image_layout(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL)
.load_op(vk::AttachmentLoadOp::CLEAR)
.store_op(vk::AttachmentStoreOp::STORE)
.clear_value(vk::ClearValue {
color: vk::ClearColorValue {
float32: [0.1, 0.2, 0.4, 1.0],
},
});
let depth_attachment = vk::RenderingAttachmentInfo::default()
.image_view(self.depth_image_view)
.image_layout(vk::ImageLayout::DEPTH_ATTACHMENT_OPTIMAL)
.load_op(vk::AttachmentLoadOp::CLEAR)
.store_op(vk::AttachmentStoreOp::STORE)
.clear_value(vk::ClearValue {
depth_stencil: vk::ClearDepthStencilValue {
depth: 1.0,
stencil: 0,
},
});
let rendering_info = vk::RenderingInfo::default()
.render_area(vk::Rect2D {
offset: vk::Offset2D { x: 0, y: 0 },
extent: self.swapchain_extent,
})
.layer_count(1)
.color_attachments(std::slice::from_ref(&color_attachment))
.depth_attachment(&depth_attachment);
unsafe {
self.device.cmd_begin_rendering(cmd, &rendering_info);
self.issue_draw_calls(cmd, camera_view);
self.device.cmd_end_rendering(cmd);
}
// Transition back to present
self.transition_to_present_layout(cmd, image)?;
Ok(())
}
/// Transitions the swapchain and depth images to layouts suitable for drawing.
fn transition_to_draw_layout(&self, cmd: vk::CommandBuffer, image: vk::Image) {
let range = vk::ImageSubresourceRange {
aspect_mask: vk::ImageAspectFlags::COLOR,
base_mip_level: 0,
level_count: 1,
base_array_layer: 0,
layer_count: 1,
};
let barrier_to_draw = vk::ImageMemoryBarrier2::default()
.image(image)
.subresource_range(range)
.src_stage_mask(vk::PipelineStageFlags2::COLOR_ATTACHMENT_OUTPUT)
.src_access_mask(vk::AccessFlags2::empty())
.dst_stage_mask(vk::PipelineStageFlags2::COLOR_ATTACHMENT_OUTPUT)
.dst_access_mask(vk::AccessFlags2::COLOR_ATTACHMENT_WRITE)
.old_layout(vk::ImageLayout::UNDEFINED)
.new_layout(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL);
let depth_range = vk::ImageSubresourceRange {
aspect_mask: vk::ImageAspectFlags::DEPTH,
base_mip_level: 0,
level_count: 1,
base_array_layer: 0,
layer_count: 1,
};
let depth_barrier = vk::ImageMemoryBarrier2::default()
.image(self.depth_image)
.subresource_range(depth_range)
.src_stage_mask(vk::PipelineStageFlags2::EARLY_FRAGMENT_TESTS)
.src_access_mask(vk::AccessFlags2::empty())
.dst_stage_mask(vk::PipelineStageFlags2::EARLY_FRAGMENT_TESTS)
.dst_access_mask(vk::AccessFlags2::DEPTH_STENCIL_ATTACHMENT_WRITE)
.old_layout(vk::ImageLayout::UNDEFINED)
.new_layout(vk::ImageLayout::DEPTH_ATTACHMENT_OPTIMAL);
let barriers = [barrier_to_draw, depth_barrier];
let dependency_info = vk::DependencyInfo::default().image_memory_barriers(&barriers);
unsafe { self.device.cmd_pipeline_barrier2(cmd, &dependency_info) };
}
/// Issues the actual draw calls for the frame.
fn issue_draw_calls(&self, cmd: vk::CommandBuffer, camera_view: glam::Mat4) {
unsafe {
self.device.cmd_bind_pipeline(
cmd,
vk::PipelineBindPoint::GRAPHICS,
self.graphics_pipeline,
);
#[expect(
clippy::cast_precision_loss,
reason = "swapchain extents are within f32's exact-integer range"
)]
let viewport = vk::Viewport {
x: 0.0,
y: 0.0,
width: self.swapchain_extent.width as f32,
height: self.swapchain_extent.height as f32,
min_depth: 0.0,
max_depth: 1.0,
};
self.device.cmd_set_viewport(cmd, 0, &[viewport]);
let scissor = vk::Rect2D {
offset: vk::Offset2D { x: 0, y: 0 },
extent: self.swapchain_extent,
};
self.device.cmd_set_scissor(cmd, 0, &[scissor]);
let aspect =
f64::from(self.swapchain_extent.width) / f64::from(self.swapchain_extent.height);
#[expect(
clippy::cast_possible_truncation,
reason = "the aspect ratio is a small value; f32 precision is sufficient"
)]
let projection = glam::camera::rh::proj::vulkan::perspective(
45.0_f32.to_radians(),
aspect as f32,
0.1,
500.0,
);
// The view matrix is supplied by the caller (the client's camera); the renderer owns only the projection, which depends on the swapchain aspect ratio it manages.
let mvp = projection * camera_view;
// The MVP is identical for every chunk this frame, so it is pushed once before the loop.
let mvp_bytes = bytemuck::cast_slice(mvp.as_ref());
self.device.cmd_push_constants(
cmd,
self.pipeline_layout,
vk::ShaderStageFlags::VERTEX,
0,
mvp_bytes,
);
// The per-chunk offset occupies the push-constant range immediately after the 64-byte MVP.
#[expect(
clippy::cast_possible_truncation,
reason = "size_of::<Mat4>() is 64 bytes, well within u32 range"
)]
let chunk_offset_byte = size_of::<glam::Mat4>() as u32;
for mesh in self.chunk_meshes.values() {
// The offset is padded to a vec4 to match the std140 layout of the push-constant block; only xyz is read by the shader.
let offset = [
mesh.world_offset[0],
mesh.world_offset[1],
mesh.world_offset[2],
0.0_f32,
];
self.device.cmd_push_constants(
cmd,
self.pipeline_layout,
vk::ShaderStageFlags::VERTEX,
chunk_offset_byte,
bytemuck::cast_slice(&offset),
);
self.device
.cmd_bind_vertex_buffers(cmd, 0, &[mesh.vertex_buffer], &[0]);
self.device
.cmd_bind_index_buffer(cmd, mesh.index_buffer, 0, vk::IndexType::UINT32);
self.device
.cmd_draw_indexed(cmd, mesh.index_count, 1, 0, 0, 0);
}
}
}
/// Transitions the swapchain image back to the presentation layout.
///
/// # Errors
///
/// Returns [`RendererError::VulkanError`] if the pipeline barrier command cannot be recorded.
fn transition_to_present_layout(
&self,
cmd: vk::CommandBuffer,
image: vk::Image,
) -> Result<(), RendererError> {
let range = vk::ImageSubresourceRange {
aspect_mask: vk::ImageAspectFlags::COLOR,
base_mip_level: 0,
level_count: 1,
base_array_layer: 0,
layer_count: 1,
};
let barrier_to_present = vk::ImageMemoryBarrier2::default()
.image(image)
.subresource_range(range)
.src_stage_mask(vk::PipelineStageFlags2::COLOR_ATTACHMENT_OUTPUT)
.src_access_mask(vk::AccessFlags2::COLOR_ATTACHMENT_WRITE)
.dst_stage_mask(vk::PipelineStageFlags2::BOTTOM_OF_PIPE)
.dst_access_mask(vk::AccessFlags2::empty())
.old_layout(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL)
.new_layout(vk::ImageLayout::PRESENT_SRC_KHR);
let dependency_info = vk::DependencyInfo::default()
.image_memory_barriers(std::slice::from_ref(&barrier_to_present));
unsafe {
self.device.cmd_pipeline_barrier2(cmd, &dependency_info);
self.device.end_command_buffer(cmd)?;
}
Ok(())
}
/// Uploads (or replaces) the mesh stored under `key`, positioned at `world_offset` (in blocks).
///
/// If a mesh already exists under `key`, its GPU resources are freed before the replacement is
/// uploaded.
///
/// # Errors
///
/// Returns [`RendererError::AllocatorMissing`] if the GPU allocator has been torn down,
/// [`RendererError::AllocationError`] if GPU memory cannot be allocated, or
/// [`RendererError::VulkanError`] if the vertex or index buffers cannot be created.
#[expect(
clippy::cast_possible_truncation,
reason = "a chunk mesh's index count never approaches u32::MAX"
)]
pub fn insert_mesh(
&mut self,
key: MeshKey,
vertices: &[Vertex],
indices: &[u32],
world_offset: [f32; 3],
) -> Result<(), RendererError> {
// Free any mesh already stored under this key before uploading its replacement.
self.remove_mesh(key);
let allocator = self
.allocator
.as_mut()
.ok_or(RendererError::AllocatorMissing)?;
let (vertex_buffer, vertex_allocation) = create_gpu_buffer(
&self.device,
allocator,
bytemuck::cast_slice(vertices),
vk::BufferUsageFlags::VERTEX_BUFFER,
"Chunk Vertex Buffer",
)?;
let (index_buffer, index_allocation) = create_gpu_buffer(
&self.device,
allocator,
bytemuck::cast_slice(indices),
vk::BufferUsageFlags::INDEX_BUFFER,
"Chunk Index Buffer",
)?;
self.chunk_meshes.insert(
key,
GpuMesh {
vertex_buffer,
vertex_allocation,
index_buffer,
index_allocation,
index_count: indices.len() as u32,
world_offset,
},
);
Ok(())
}
/// Frees the GPU mesh stored under `key`. Does nothing if no mesh is present.
pub fn remove_mesh(&mut self, key: MeshKey) {
let Some(mesh) = self.chunk_meshes.remove(&key) else {
return;
};
unsafe {
// Waiting idle per removal is the simple, always-correct approach; in a bulk load/unload loop it serialises the GPU, so a single wait around the loop is preferable if this ever shows up as a measured bottleneck.
let _ = self.device.device_wait_idle();
if let Some(allocator) = self.allocator.as_mut() {
let _ = allocator.free(mesh.vertex_allocation);
let _ = allocator.free(mesh.index_allocation);
}
self.device.destroy_buffer(mesh.vertex_buffer, None);
self.device.destroy_buffer(mesh.index_buffer, None);
}
}
}
impl Drop for Renderer {
fn drop(&mut self) {
unsafe {
let _ = self.device.device_wait_idle();
self.device.destroy_pipeline(self.graphics_pipeline, None);
self.device
.destroy_pipeline_layout(self.pipeline_layout, None);
// Drain the chunk meshes so each owned allocation can be freed and its buffers destroyed.
let meshes: Vec<GpuMesh> = self.chunk_meshes.drain().map(|(_, mesh)| mesh).collect();
if let Some(allocator) = self.allocator.as_mut() {
for mesh in meshes {
if let Err(e) = allocator.free(mesh.vertex_allocation) {
tracing::error!("Failed to free chunk vertex allocation: {e}");
}
if let Err(e) = allocator.free(mesh.index_allocation) {
tracing::error!("Failed to free chunk index allocation: {e}");
}
self.device.destroy_buffer(mesh.vertex_buffer, None);
self.device.destroy_buffer(mesh.index_buffer, None);
}
if let Some(alloc) = self.depth_allocation.take()
&& let Err(e) = allocator.free(alloc)
{
tracing::error!("Failed to free depth image allocation: {e}");
}
}
self.device.destroy_image_view(self.depth_image_view, None);
self.device.destroy_image(self.depth_image, None);
// Drop the allocator before destroying the logical device so its remaining memory blocks are released while the device is still valid.
drop(self.allocator.take());
self.device.destroy_command_pool(self.command_pool, None);
if let Some(sync) = self.sync.take() {
crate::sync::destroy_sync_primitives(&self.device, sync);
}
// Destroy image views before the swapchain that owns the underlying images.
for &view in &self.swapchain_image_views {
self.device.destroy_image_view(view, None);
}
self.swapchain_loader
.destroy_swapchain(self.swapchain, None);
// Destroy the logical device
self.device.destroy_device(None);
// Destroy the surface
self.surface_loader.destroy_surface(self.surface, None);
// Destroy the debug messenger if it exists
if let Some(debug_utils) = self.debug_utils.as_ref() {
debug_utils.destroy_debug_utils_messenger(self.debug_messenger, None);
}
// Destroy the instance
self.instance.destroy_instance(None);
}
}
}