Synvael/crates/renderer/src/renderer.rs

804 lines
34 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, frustum::Frustum, vertex::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);
/// One rasterisation pass over the visible chunk meshes.
///
/// A pass corresponds one-to-one with a pipeline object, since polygon mode and depth-compare state are baked into a pipeline and cannot be changed by a command. Passes are the GPU-level primitive; [`RenderMode`] composes them into what is actually presented.
///
/// Adding a pass requires three compiler-checked edits: the variant, an entry in [`RasterPass::ALL`] (whose length is pinned to [`RasterPass::COUNT`]), and arms in the `match`es below.
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
pub enum RasterPass {
/// Filled triangles; the normal presentation path.
Fill,
/// One point per polygon vertex, exposing the density of the geometry the mesher emitted.
Points,
/// Triangle edges only, exposing the shape and size of the quads the mesher produced.
Wireframe,
}
impl RasterPass {
/// Number of passes, and therefore the number of pipelines built at initialisation.
pub const COUNT: usize = 3;
/// Every pass, in discriminant order. The array length is checked against [`RasterPass::COUNT`] at compile time, so a new variant that is not listed here fails to build.
pub const ALL: [Self; Self::COUNT] = [Self::Fill, Self::Points, Self::Wireframe];
/// Returns the rasterisation polygon mode backing this pass.
#[must_use]
pub const fn polygon_mode(self) -> vk::PolygonMode {
match self {
Self::Fill => vk::PolygonMode::FILL,
Self::Points => vk::PolygonMode::POINT,
Self::Wireframe => vk::PolygonMode::LINE,
}
}
/// Returns the depth-comparison used by this pass.
///
/// Debug passes use `LESS_OR_EQUAL` so they survive being drawn over a filled pass that has already written the same depth values; a strict `LESS` would reject every overlaid fragment and render the overlay invisible.
#[must_use]
pub const fn depth_compare_op(self) -> vk::CompareOp {
match self {
Self::Fill => vk::CompareOp::LESS,
Self::Points | Self::Wireframe => vk::CompareOp::LESS_OR_EQUAL,
}
}
/// Returns the debug-tint weight pushed to the vertex shader for this pass.
///
/// Debug passes are tinted a uniform colour because geometry drawn in the terrain's own vertex colours is indistinguishable from the surface beneath it when overlaid.
#[must_use]
pub const fn tint(self) -> f32 {
match self {
Self::Fill => 0.0,
Self::Points | Self::Wireframe => 1.0,
}
}
/// Returns this pass's position in [`RasterPass::ALL`], used to index [`Renderer::pipelines`].
#[must_use]
pub const fn index(self) -> usize {
self as usize
}
}
/// Selects how chunk meshes are presented, as an ordered list of [`RasterPass`]es.
///
/// Non-[`RenderMode::Filled`] variants are debug modes for inspecting the mesher's output; they are not gameplay state. The `Filled*` variants draw the terrain normally and overlay a debug pass on top, which keeps the surface readable while showing where its geometry actually lies.
///
/// Adding a mode is one variant plus one arm in [`RenderMode::passes`]; it needs a new [`RasterPass`] only if it requires rasterisation state that no existing pass provides.
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)]
pub enum RenderMode {
/// Filled triangles only; the normal presentation path.
#[default]
Filled,
/// Vertex points only, against the clear colour.
Points,
/// Triangle edges only, against the clear colour.
Wireframe,
/// Filled triangles with vertex points overlaid.
FilledPoints,
/// Filled triangles with triangle edges overlaid.
FilledWireframe,
}
impl RenderMode {
/// Returns the passes to run, in submission order. Later passes are drawn over earlier ones.
#[must_use]
pub const fn passes(self) -> &'static [RasterPass] {
match self {
Self::Filled => &[RasterPass::Fill],
Self::Points => &[RasterPass::Points],
Self::Wireframe => &[RasterPass::Wireframe],
Self::FilledPoints => &[RasterPass::Fill, RasterPass::Points],
Self::FilledWireframe => &[RasterPass::Fill, RasterPass::Wireframe],
}
}
}
/// 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,
/// One compiled pipeline per [`RasterPass`], indexed by [`RasterPass::index`]. All variants share [`Renderer::pipeline_layout`] and differ only in rasterisation and depth-compare state.
pub(crate) pipelines: [vk::Pipeline; RasterPass::COUNT],
/// The rasterisation mode selected for subsequent frames, chosen by [`Renderer::set_render_mode`].
pub(crate) render_mode: RenderMode,
/// 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 {
#[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 view frustum is derived from the same matrix and reused to reject chunks whose bounding box lies entirely outside the view before any draw work is recorded.
let frustum = Frustum::from_view_proj(mvp);
// A chunk spans CHUNK_SIZE blocks on each axis. The mesher centres block i on [i - 0.5, i + 0.5], so a chunk's box runs [offset - 0.5, offset + CHUNK_SIZE - 0.5]; the extent below is added to that shifted minimum corner.
#[expect(
clippy::cast_precision_loss,
reason = "CHUNK_SIZE is 32, exactly representable as f32"
)]
let chunk_extent = glam::Vec3::splat(shared::world::CHUNK_SIZE as f32);
// Culling is performed once per frame rather than once per pass: the frustum does not change between passes, so the surviving set is shared by all of them.
let mut culled: u32 = 0;
let visible: Vec<&GpuMesh> = self
.chunk_meshes
.values()
.filter(|mesh| {
// Reject the chunk when its world-space bounding box falls entirely outside the frustum.
let box_min = glam::Vec3::from(mesh.world_offset) - glam::Vec3::splat(0.5);
let visible = frustum.intersects_aabb(box_min, box_min + chunk_extent);
if !visible {
culled += 1;
}
visible
})
.collect();
if culled > 0 {
tracing::debug!(culled, "chunks skipped by frustum culling");
}
// The MVP is identical for every chunk and every pass this frame, so it is pushed once before the loops.
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;
// Overlay modes submit the same geometry more than once, each pass binding a pipeline whose rasterisation state differs. Later passes draw over earlier ones.
for pass in self.render_mode.passes() {
self.device.cmd_bind_pipeline(
cmd,
vk::PipelineBindPoint::GRAPHICS,
self.pipelines[pass.index()],
);
for mesh in &visible {
// The offset is padded to a vec4 to match the std140 layout of the push-constant block. The shader reads xyz as the chunk's world offset and w as the debug-tint weight for this pass.
let offset = [
mesh.world_offset[0],
mesh.world_offset[1],
mesh.world_offset[2],
pass.tint(),
];
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(())
}
/// Sets the rasterisation mode used for subsequent frames.
pub const fn set_render_mode(&mut self, mode: RenderMode) {
self.render_mode = mode;
}
/// Returns the rasterisation mode currently in use.
#[must_use]
pub const fn render_mode(&self) -> RenderMode {
self.render_mode
}
/// 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();
// Every rasterisation variant is a distinct pipeline object and must be destroyed, or the validation layers report the survivors as leaked at teardown.
for pipeline in self.pipelines {
self.device.destroy_pipeline(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);
}
}
}