feat(renderer): render keyed collection of chunk meshes with per-chunk offset

This commit is contained in:
Serkyo 2026-07-16 00:05:30 +02:00
parent 2ca9ae801d
commit dd8a969828
3 changed files with 116 additions and 151 deletions

View file

@ -26,9 +26,9 @@ use raw_window_handle::{RawDisplayHandle, RawWindowHandle};
use std::ffi::c_char; use std::ffi::c_char;
pub use error::RendererError; pub use error::RendererError;
pub use renderer::Renderer; pub use renderer::{MeshKey, Renderer};
use crate::mesh::Vertex; use std::collections::HashMap;
impl Renderer { impl Renderer {
/// Initializes the Vulkan renderer. /// Initializes the Vulkan renderer.
@ -42,7 +42,7 @@ impl Renderer {
/// ///
/// # Panics /// # Panics
/// ///
/// Panics if `MAX_FRAMES_IN_FLIGHT` or vertex data sizes exceed `u32`/`u64` limits. /// Panics if `MAX_FRAMES_IN_FLIGHT` exceeds `u32`'s range.
// TODO: partial-construction leak. Each `?` below early-returns and leaks every Vulkan resource created so far; only a fully successful `new` reaches `Drop for Renderer`. Once the renderer grows more state, wrap each resource in an RAII guard so failure paths tear them down too. // TODO: partial-construction leak. Each `?` below early-returns and leaks every Vulkan resource created so far; only a fully successful `new` reaches `Drop for Renderer`. Once the renderer grows more state, wrap each resource in an RAII guard so failure paths tear them down too.
pub fn new( pub fn new(
display_handle: RawDisplayHandle, display_handle: RawDisplayHandle,
@ -125,11 +125,6 @@ impl Renderer {
let graphics_pipeline = let graphics_pipeline =
pipeline::create_graphics_pipeline(&device, pipeline_layout, swapchain_format)?; pipeline::create_graphics_pipeline(&device, pipeline_layout, swapchain_format)?;
let (vertex_buffer, vertex_allocation, index_buffer, index_allocation) =
create_geometry(&device, &mut allocator)?;
let index_count = 36;
let (depth_image, depth_allocation, depth_image_view) = let (depth_image, depth_allocation, depth_image_view) =
create_depth_resources(&device, &mut allocator, swapchain_extent)?; create_depth_resources(&device, &mut allocator, swapchain_extent)?;
@ -153,16 +148,12 @@ impl Renderer {
command_pool, command_pool,
command_buffers, command_buffers,
allocator: Some(allocator), allocator: Some(allocator),
index_buffer, chunk_meshes: HashMap::new(),
index_allocation: Some(index_allocation),
index_count,
depth_image, depth_image,
depth_allocation: Some(depth_allocation), depth_allocation: Some(depth_allocation),
depth_image_view, depth_image_view,
pipeline_layout, pipeline_layout,
graphics_pipeline, graphics_pipeline,
vertex_buffer,
vertex_allocation: Some(vertex_allocation),
sync: Some(sync), sync: Some(sync),
current_frame: 0, current_frame: 0,
}) })
@ -193,84 +184,6 @@ fn create_allocator(
Ok(allocator) Ok(allocator)
} }
/// Creates the 3D geometry buffers (vertex and index) for a cube.
///
/// # Errors
///
/// Returns [`RendererError::AllocationError`] if GPU memory cannot be allocated, or [`RendererError::VulkanError`] if a buffer cannot be created.
fn create_geometry(
device: &ash::Device,
allocator: &mut Allocator,
) -> Result<(vk::Buffer, Allocation, vk::Buffer, Allocation), RendererError> {
let vertices = [
// Front face
Vertex {
position: [-0.5, -0.5, 0.5],
color: [1.0, 0.0, 0.0],
},
Vertex {
position: [0.5, -0.5, 0.5],
color: [0.0, 1.0, 0.0],
},
Vertex {
position: [0.5, 0.5, 0.5],
color: [0.0, 0.0, 1.0],
},
Vertex {
position: [-0.5, 0.5, 0.5],
color: [1.0, 1.0, 1.0],
},
// Back face
Vertex {
position: [-0.5, -0.5, -0.5],
color: [1.0, 0.0, 0.0],
},
Vertex {
position: [0.5, -0.5, -0.5],
color: [0.0, 1.0, 0.0],
},
Vertex {
position: [0.5, 0.5, -0.5],
color: [0.0, 0.0, 1.0],
},
Vertex {
position: [-0.5, 0.5, -0.5],
color: [1.0, 1.0, 1.0],
},
];
let indices: [u32; 36] = [
0, 1, 2, 2, 3, 0, // front
1, 5, 6, 6, 2, 1, // right
7, 6, 5, 5, 4, 7, // back
4, 0, 3, 3, 7, 4, // left
4, 5, 1, 1, 0, 4, // bottom
3, 2, 6, 6, 7, 3, // top
];
let (vertex_buffer, vertex_allocation) = create_gpu_buffer(
device,
allocator,
bytemuck::cast_slice(&vertices),
vk::BufferUsageFlags::VERTEX_BUFFER,
"Vertex Buffer",
)?;
let (index_buffer, index_allocation) = create_gpu_buffer(
device,
allocator,
bytemuck::cast_slice(&indices),
vk::BufferUsageFlags::INDEX_BUFFER,
"Index Buffer",
)?;
Ok((
vertex_buffer,
vertex_allocation,
index_buffer,
index_allocation,
))
}
/// Creates the depth buffer resources (image, memory, and view). /// Creates the depth buffer resources (image, memory, and view).
/// ///
/// # Errors /// # Errors

View file

@ -36,16 +36,17 @@ pub fn create_shader_module(
/// ///
/// Returns [`RendererError::VulkanError`] if the device fails to create the pipeline layout. /// Returns [`RendererError::VulkanError`] if the device fails to create the pipeline layout.
pub fn create_pipeline_layout(device: &Device) -> Result<vk::PipelineLayout, RendererError> { pub fn create_pipeline_layout(device: &Device) -> Result<vk::PipelineLayout, RendererError> {
// A single push constant range is defined for the MVP matrix, allowing it to be updated for every draw call with high efficiency. // The push-constant range covers the 64-byte MVP matrix followed by a 16-byte vec4 per-chunk world offset (80 bytes total, within the 128-byte guaranteed minimum).
#[expect( #[expect(
clippy::expect_used, clippy::expect_used,
reason = "size_of::<Mat4>() is 64 bytes, well within u32 range" reason = "80 bytes (Mat4 + vec4) is well within u32 range"
)] )]
let push_constant_range = vk::PushConstantRange::default() let push_constant_range = vk::PushConstantRange::default()
.stage_flags(vk::ShaderStageFlags::VERTEX) .stage_flags(vk::ShaderStageFlags::VERTEX)
.offset(0) .offset(0)
.size( .size(
u32::try_from(std::mem::size_of::<glam::Mat4>()).expect("Mat4 size exceeds u32 range"), u32::try_from(std::mem::size_of::<glam::Mat4>() + std::mem::size_of::<[f32; 4]>())
.expect("push-constant size exceeds u32 range"),
); );
let layout_create_info = vk::PipelineLayoutCreateInfo::default() let layout_create_info = vk::PipelineLayoutCreateInfo::default()

View file

@ -5,6 +5,26 @@ use crate::sync::SyncPrimitives;
use crate::{error::RendererError, mesh::Vertex}; use crate::{error::RendererError, mesh::Vertex};
use ash::{Device, Instance, khr, vk}; use ash::{Device, Instance, khr, vk};
use gpu_allocator::vulkan::{Allocation, Allocator}; 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. /// The core renderer structure holding the Vulkan resources.
pub struct Renderer { pub struct Renderer {
@ -56,15 +76,8 @@ pub struct Renderer {
pub(crate) graphics_pipeline: vk::Pipeline, pub(crate) graphics_pipeline: vk::Pipeline,
/// Memory manager for GPU allocations. /// Memory manager for GPU allocations.
pub(crate) allocator: Option<Allocator>, pub(crate) allocator: Option<Allocator>,
/// Buffer containing the vertex data for the initial triangle. /// Uploaded chunk meshes, keyed by an opaque renderer-side handle and drawn independently.
pub(crate) vertex_buffer: vk::Buffer, pub(crate) chunk_meshes: HashMap<MeshKey, GpuMesh>,
/// Memory allocation for the vertex buffer.
pub(crate) vertex_allocation: Option<Allocation>,
/// Buffer containing the index data for indexed drawing.
pub(crate) index_buffer: vk::Buffer,
/// Memory allocation for the index buffer.
pub(crate) index_allocation: Option<Allocation>,
pub(crate) index_count: u32,
/// The depth image used for depth testing. /// The depth image used for depth testing.
pub(crate) depth_image: vk::Image, pub(crate) depth_image: vk::Image,
/// Image view for the depth buffer. /// Image view for the depth buffer.
@ -293,11 +306,6 @@ impl Renderer {
}; };
self.device.cmd_set_scissor(cmd, 0, &[scissor]); self.device.cmd_set_scissor(cmd, 0, &[scissor]);
self.device
.cmd_bind_vertex_buffers(cmd, 0, &[self.vertex_buffer], &[0]);
self.device
.cmd_bind_index_buffer(cmd, self.index_buffer, 0, vk::IndexType::UINT32);
let aspect = let aspect =
f64::from(self.swapchain_extent.width) / f64::from(self.swapchain_extent.height); f64::from(self.swapchain_extent.width) / f64::from(self.swapchain_extent.height);
@ -315,6 +323,7 @@ impl Renderer {
// 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. // 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; 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()); let mvp_bytes = bytemuck::cast_slice(mvp.as_ref());
self.device.cmd_push_constants( self.device.cmd_push_constants(
cmd, cmd,
@ -324,8 +333,36 @@ impl Renderer {
mvp_bytes, mvp_bytes,
); );
self.device // The per-chunk offset occupies the push-constant range immediately after the 64-byte MVP.
.cmd_draw_indexed(cmd, self.index_count, 1, 0, 0, 0); #[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);
}
} }
} }
@ -368,47 +405,36 @@ impl Renderer {
Ok(()) Ok(())
} }
/// Replaces the currently rendering mesh with a new set of vertices and indices. /// 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 /// # Errors
/// ///
/// Returns [`RendererError::AllocationError`] if GPU memory cannot be allocated, or [`RendererError::VulkanError`] if the vertex or index buffers cannot be created. /// 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( #[expect(
clippy::cast_possible_truncation, clippy::cast_possible_truncation,
reason = "a chunk mesh's index count never approaches u32::MAX" reason = "a chunk mesh's index count never approaches u32::MAX"
)] )]
pub fn update_mesh( pub fn insert_mesh(
&mut self, &mut self,
key: MeshKey,
vertices: &[Vertex], vertices: &[Vertex],
indices: &[u32], indices: &[u32],
world_offset: [f32; 3],
) -> Result<(), RendererError> { ) -> Result<(), RendererError> {
unsafe { // Free any mesh already stored under this key before uploading its replacement.
let _ = self.device.device_wait_idle(); self.remove_mesh(key);
let allocator = self
.allocator
.as_mut()
.ok_or(RendererError::AllocatorMissing)?;
if let Some(alloc) = self.vertex_allocation.take() {
let _ = allocator.free(alloc);
}
self.device.destroy_buffer(self.vertex_buffer, None);
self.vertex_buffer = vk::Buffer::null();
if let Some(alloc) = self.index_allocation.take() {
let _ = allocator.free(alloc);
}
self.device.destroy_buffer(self.index_buffer, None);
self.index_buffer = vk::Buffer::null();
}
let allocator = self let allocator = self
.allocator .allocator
.as_mut() .as_mut()
.ok_or(RendererError::AllocatorMissing)?; .ok_or(RendererError::AllocatorMissing)?;
let (v_buf, v_alloc) = create_gpu_buffer( let (vertex_buffer, vertex_allocation) = create_gpu_buffer(
&self.device, &self.device,
allocator, allocator,
bytemuck::cast_slice(vertices), bytemuck::cast_slice(vertices),
@ -416,7 +442,7 @@ impl Renderer {
"Chunk Vertex Buffer", "Chunk Vertex Buffer",
)?; )?;
let (i_buf, i_alloc) = crate::create_gpu_buffer( let (index_buffer, index_allocation) = create_gpu_buffer(
&self.device, &self.device,
allocator, allocator,
bytemuck::cast_slice(indices), bytemuck::cast_slice(indices),
@ -424,14 +450,39 @@ impl Renderer {
"Chunk Index Buffer", "Chunk Index Buffer",
)?; )?;
self.vertex_buffer = v_buf; self.chunk_meshes.insert(
self.vertex_allocation = Some(v_alloc); key,
self.index_buffer = i_buf; GpuMesh {
self.index_allocation = Some(i_alloc); vertex_buffer,
self.index_count = indices.len() as u32; vertex_allocation,
index_buffer,
index_allocation,
index_count: indices.len() as u32,
world_offset,
},
);
Ok(()) 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 { impl Drop for Renderer {
@ -443,16 +494,18 @@ impl Drop for Renderer {
self.device self.device
.destroy_pipeline_layout(self.pipeline_layout, None); .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() { if let Some(allocator) = self.allocator.as_mut() {
if let Some(alloc) = self.vertex_allocation.take() for mesh in meshes {
&& let Err(e) = allocator.free(alloc) if let Err(e) = allocator.free(mesh.vertex_allocation) {
{ tracing::error!("Failed to free chunk vertex allocation: {e}");
tracing::error!("Failed to free vertex buffer allocation: {e}"); }
} if let Err(e) = allocator.free(mesh.index_allocation) {
if let Some(alloc) = self.index_allocation.take() tracing::error!("Failed to free chunk index allocation: {e}");
&& let Err(e) = allocator.free(alloc) }
{ self.device.destroy_buffer(mesh.vertex_buffer, None);
tracing::error!("Failed to free index buffer allocation: {e}"); self.device.destroy_buffer(mesh.index_buffer, None);
} }
if let Some(alloc) = self.depth_allocation.take() if let Some(alloc) = self.depth_allocation.take()
&& let Err(e) = allocator.free(alloc) && let Err(e) = allocator.free(alloc)
@ -460,8 +513,6 @@ impl Drop for Renderer {
tracing::error!("Failed to free depth image allocation: {e}"); tracing::error!("Failed to free depth image allocation: {e}");
} }
} }
self.device.destroy_buffer(self.vertex_buffer, None);
self.device.destroy_buffer(self.index_buffer, None);
self.device.destroy_image_view(self.depth_image_view, None); self.device.destroy_image_view(self.depth_image_view, None);
self.device.destroy_image(self.depth_image, None); self.device.destroy_image(self.depth_image, None);