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;
pub use error::RendererError;
pub use renderer::Renderer;
pub use renderer::{MeshKey, Renderer};
use crate::mesh::Vertex;
use std::collections::HashMap;
impl Renderer {
/// Initializes the Vulkan renderer.
@ -42,7 +42,7 @@ impl Renderer {
///
/// # 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.
pub fn new(
display_handle: RawDisplayHandle,
@ -125,11 +125,6 @@ impl Renderer {
let graphics_pipeline =
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) =
create_depth_resources(&device, &mut allocator, swapchain_extent)?;
@ -153,16 +148,12 @@ impl Renderer {
command_pool,
command_buffers,
allocator: Some(allocator),
index_buffer,
index_allocation: Some(index_allocation),
index_count,
chunk_meshes: HashMap::new(),
depth_image,
depth_allocation: Some(depth_allocation),
depth_image_view,
pipeline_layout,
graphics_pipeline,
vertex_buffer,
vertex_allocation: Some(vertex_allocation),
sync: Some(sync),
current_frame: 0,
})
@ -193,84 +184,6 @@ fn create_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).
///
/// # Errors

View file

@ -36,16 +36,17 @@ pub fn create_shader_module(
///
/// Returns [`RendererError::VulkanError`] if the device fails to create the pipeline layout.
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(
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()
.stage_flags(vk::ShaderStageFlags::VERTEX)
.offset(0)
.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()

View file

@ -5,6 +5,26 @@ use crate::sync::SyncPrimitives;
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 {
@ -56,15 +76,8 @@ pub struct Renderer {
pub(crate) graphics_pipeline: vk::Pipeline,
/// Memory manager for GPU allocations.
pub(crate) allocator: Option<Allocator>,
/// Buffer containing the vertex data for the initial triangle.
pub(crate) vertex_buffer: vk::Buffer,
/// 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,
/// 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.
@ -293,11 +306,6 @@ impl Renderer {
};
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 =
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.
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,
@ -324,8 +333,36 @@ impl Renderer {
mvp_bytes,
);
self.device
.cmd_draw_indexed(cmd, self.index_count, 1, 0, 0, 0);
// 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);
}
}
}
@ -368,47 +405,36 @@ impl Renderer {
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
///
/// 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(
clippy::cast_possible_truncation,
reason = "a chunk mesh's index count never approaches u32::MAX"
)]
pub fn update_mesh(
pub fn insert_mesh(
&mut self,
key: MeshKey,
vertices: &[Vertex],
indices: &[u32],
world_offset: [f32; 3],
) -> Result<(), RendererError> {
unsafe {
let _ = self.device.device_wait_idle();
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();
}
// 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 (v_buf, v_alloc) = create_gpu_buffer(
let (vertex_buffer, vertex_allocation) = create_gpu_buffer(
&self.device,
allocator,
bytemuck::cast_slice(vertices),
@ -416,7 +442,7 @@ impl Renderer {
"Chunk Vertex Buffer",
)?;
let (i_buf, i_alloc) = crate::create_gpu_buffer(
let (index_buffer, index_allocation) = create_gpu_buffer(
&self.device,
allocator,
bytemuck::cast_slice(indices),
@ -424,14 +450,39 @@ impl Renderer {
"Chunk Index Buffer",
)?;
self.vertex_buffer = v_buf;
self.vertex_allocation = Some(v_alloc);
self.index_buffer = i_buf;
self.index_allocation = Some(i_alloc);
self.index_count = indices.len() as u32;
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 {
@ -443,16 +494,18 @@ impl Drop for Renderer {
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() {
if let Some(alloc) = self.vertex_allocation.take()
&& let Err(e) = allocator.free(alloc)
{
tracing::error!("Failed to free vertex buffer allocation: {e}");
}
if let Some(alloc) = self.index_allocation.take()
&& let Err(e) = allocator.free(alloc)
{
tracing::error!("Failed to free index buffer allocation: {e}");
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)
@ -460,8 +513,6 @@ impl Drop for Renderer {
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(self.depth_image, None);