synvael/crates/renderer/src/renderer.rs

955 lines
41 KiB
Rust

// SPDX-License-Identifier: AGPL-3.0-only
use crate::stats::{ProjectionInfo, RenderStats, SwapchainInfo};
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);
/// Vertical field of view of the perspective projection, in degrees.
const FOV_Y_DEGREES: f32 = 45.0;
/// Distance to the near clip plane, in blocks.
const NEAR_PLANE: f32 = 0.1;
/// Distance to the far clip plane, in blocks.
const FAR_PLANE: f32 = 500.0;
/// 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,
/// Number of vertices in [`GpuMesh::vertex_buffer`]. Not needed to draw an indexed mesh; retained because the index count alone cannot recover it.
pub(crate) vertex_count: u32,
/// Size of [`GpuMesh::vertex_buffer`] in bytes, retained to report resident GPU geometry footprint.
pub(crate) vertex_bytes: u64,
/// Size of [`GpuMesh::index_buffer`] in bytes, retained to report resident GPU geometry footprint.
pub(crate) index_bytes: u64,
/// Chunk origin in world space (blocks); added to every vertex in the vertex shader.
pub(crate) world_offset: [f32; 3],
}
/// What one frame's draw-call recording submitted, gathered where the work is issued.
///
/// Kept private and distinct from [`RenderStats`]: this carries only the values that are observable inside the recording pass, while the published snapshot additionally folds in renderer-wide state (resident mesh totals, cumulative frame counters) that the recording pass has no reason to look at.
struct Submission {
/// Meshes that survived frustum culling and were submitted.
visible_meshes: usize,
/// Meshes rejected by frustum culling.
culled_meshes: usize,
/// Indexed draw calls recorded: one per visible mesh per pass.
draw_calls: usize,
/// Triangles submitted, counted across every pass.
triangles: u64,
/// Vertices referenced by the submitted meshes, counted across every pass.
vertices: u64,
/// Projection parameters used to build the frame's matrix.
projection: ProjectionInfo,
}
/// 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,
/// Presentation mode the swapchain was created with, retained as its Vulkan enum name for reporting.
pub(crate) present_mode: &'static str,
/// Frames presented since initialisation.
pub(crate) frames_presented: u64,
/// Frames abandoned before submission because the swapchain was out of date.
pub(crate) frames_skipped: u64,
/// Submission statistics for the most recently completed frame, or [`None`] before the first frame completes.
pub(crate) last_frame_stats: Option<RenderStats>,
}
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.frames_skipped = self.frames_skipped.saturating_add(1);
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, retaining what they submitted.
let submission = 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;
self.frames_presented = self.frames_presented.saturating_add(1);
// The snapshot is published only once the frame has been submitted, so a reader never observes counts for a frame that was abandoned.
self.last_frame_stats = Some(self.frame_stats(&submission));
// 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);
}
}
/// Assembles the published snapshot for a completed frame from the per-frame submission counts and the renderer's own resident state.
fn frame_stats(&self, submission: &Submission) -> RenderStats {
// Buffer footprint is a property of what is resident, not of what was drawn, so it is summed over every uploaded mesh rather than over the visible subset.
let (vertex_bytes, index_bytes) = self
.chunk_meshes
.values()
.fold((0, 0), |(vertex, index), mesh| {
(vertex + mesh.vertex_bytes, index + mesh.index_bytes)
});
RenderStats {
uploaded_meshes: self.chunk_meshes.len(),
visible_meshes: submission.visible_meshes,
culled_meshes: submission.culled_meshes,
draw_calls: submission.draw_calls,
triangles: submission.triangles,
vertices: submission.vertices,
vertex_bytes,
index_bytes,
render_mode: self.render_mode,
projection: submission.projection,
swapchain: SwapchainInfo {
// The image count is bounded by the surface's maximum, far inside u32.
#[expect(
clippy::cast_possible_truncation,
reason = "swapchain image counts are single digits"
)]
image_count: self.swapchain_images.len() as u32,
width: self.swapchain_extent.width,
height: self.swapchain_extent.height,
present_mode: self.present_mode,
},
frames_presented: self.frames_presented,
frames_skipped: self.frames_skipped,
}
}
/// Returns the submission statistics for the most recently completed frame, or [`None`] before the first frame has been presented.
#[must_use]
pub const fn stats(&self) -> Option<RenderStats> {
self.last_frame_stats
}
/// Records the drawing commands into the given command buffer, returning what they submitted.
///
/// # 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<Submission, 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);
}
let submission = self.issue_draw_calls(cmd, camera_view);
unsafe {
self.device.cmd_end_rendering(cmd);
}
// Transition back to present
self.transition_to_present_layout(cmd, image)?;
Ok(submission)
}
/// 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, returning what was submitted.
fn issue_draw_calls(&self, cmd: vk::CommandBuffer, camera_view: glam::Mat4) -> Submission {
let projection = self.projection_info();
// 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 = glam::camera::rh::proj::vulkan::perspective(
projection.fov_y_radians,
projection.aspect,
projection.near,
projection.far,
) * camera_view;
let (visible, culled) = self.cull_to_frustum(mvp);
let submission = self.summarise_submission(&visible, culled, projection);
self.set_dynamic_state(cmd);
self.record_passes(cmd, mvp, &visible);
submission
}
/// Derives this frame's projection parameters from the swapchain extent.
fn projection_info(&self) -> ProjectionInfo {
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"
)]
ProjectionInfo {
fov_y_radians: FOV_Y_DEGREES.to_radians(),
aspect: aspect as f32,
near: NEAR_PLANE,
far: FAR_PLANE,
}
}
/// Partitions the uploaded meshes against the view frustum derived from `mvp`, returning the survivors and the number rejected.
///
/// 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.
fn cull_to_frustum(&self, mvp: glam::Mat4) -> (Vec<&GpuMesh>, usize) {
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);
let mut culled = 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();
(visible, culled)
}
/// Totals what the surviving meshes will submit under the active render mode.
///
/// Geometry totals are summed once over the surviving set and multiplied by the pass count, since every pass submits the same meshes.
fn summarise_submission(
&self,
visible: &[&GpuMesh],
culled: usize,
projection: ProjectionInfo,
) -> Submission {
let per_pass_indices: u64 = visible.iter().map(|mesh| u64::from(mesh.index_count)).sum();
let per_pass_vertices: u64 = visible
.iter()
.map(|mesh| u64::from(mesh.vertex_count))
.sum();
let passes = self.render_mode.passes().len();
Submission {
visible_meshes: visible.len(),
culled_meshes: culled,
draw_calls: visible.len() * passes,
// Three indices per triangle; the mesher emits triangle lists exclusively.
triangles: per_pass_indices / 3 * passes as u64,
vertices: per_pass_vertices * passes as u64,
projection,
}
}
/// Records the viewport and scissor, which are dynamic pipeline state and must therefore be set on every command buffer.
fn set_dynamic_state(&self, cmd: vk::CommandBuffer) {
#[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,
};
let scissor = vk::Rect2D {
offset: vk::Offset2D { x: 0, y: 0 },
extent: self.swapchain_extent,
};
unsafe {
self.device.cmd_set_viewport(cmd, 0, &[viewport]);
self.device.cmd_set_scissor(cmd, 0, &[scissor]);
}
}
/// Records one indexed draw per visible mesh, for every pass the active render mode composes.
fn record_passes(&self, cmd: vk::CommandBuffer, mvp: glam::Mat4, visible: &[&GpuMesh]) {
unsafe {
// 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,
vertex_count: vertices.len() as u32,
vertex_bytes: std::mem::size_of_val(vertices) as u64,
index_bytes: std::mem::size_of_val(indices) as u64,
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);
}
}
}