refactor(client): abstract chunk mesh upload behind a MeshSink trait

This commit is contained in:
Serkyo 2026-07-26 20:19:27 +02:00
parent 2abe08bf57
commit dae0bafcd3

View file

@ -6,6 +6,8 @@ use std::collections::{HashMap, HashSet};
use std::sync::Arc; use std::sync::Arc;
use renderer::meshing::Neighbors; use renderer::meshing::Neighbors;
use renderer::vertex::Vertex;
use renderer::{MeshKey, RendererError};
use shared::protocol::chunk::ChunkMessage; use shared::protocol::chunk::ChunkMessage;
use shared::world::{CHUNK_SIZE, Chunk, ChunkPos}; use shared::world::{CHUNK_SIZE, Chunk, ChunkPos};
use tracing::{debug, error}; use tracing::{debug, error};
@ -31,6 +33,43 @@ const NEIGHBOR_OFFSETS: [(i32, i32, i32); 6] = [
(0, 0, -1), (0, 0, -1),
]; ];
/// Sink that receives finished chunk meshes for upload.
///
/// The production sink is the Vulkan [`Renderer`](renderer::Renderer); the abstraction exists so the meshing pipeline can be exercised against a recording double in tests, which have no GPU. Method signatures mirror the renderer's exactly so the production `impl` is a direct forward.
pub trait MeshSink {
/// Uploads (or replaces) the mesh identified by `key`.
///
/// # Errors
///
/// Returns [`RendererError`] when the underlying implementation fails to allocate or write the GPU buffers for the mesh.
fn insert_mesh(
&mut self,
key: MeshKey,
vertices: &[Vertex],
indices: &[u32],
world_offset: [f32; 3],
) -> Result<(), RendererError>;
/// Removes any mesh currently associated with `key`; a no-op when none exists.
fn remove_mesh(&mut self, key: MeshKey);
}
impl MeshSink for renderer::Renderer {
fn insert_mesh(
&mut self,
key: MeshKey,
vertices: &[Vertex],
indices: &[u32],
world_offset: [f32; 3],
) -> Result<(), RendererError> {
renderer::Renderer::insert_mesh(self, key, vertices, indices, world_offset)
}
fn remove_mesh(&mut self, key: MeshKey) {
renderer::Renderer::remove_mesh(self, key);
}
}
/// Tracks which server-streamed chunks are resident and orchestrates neighbour-aware meshing. /// Tracks which server-streamed chunks are resident and orchestrates neighbour-aware meshing.
pub struct ChunkManager { pub struct ChunkManager {
/// Resident chunks keyed by position, retained so the mesher can sample voxels across chunk boundaries. Stored behind [`Arc`] so a future worker pool can hand a chunk to a thread without copying it. /// Resident chunks keyed by position, retained so the mesher can sample voxels across chunk boundaries. Stored behind [`Arc`] so a future worker pool can hand a chunk to a thread without copying it.
@ -60,11 +99,11 @@ impl ChunkManager {
&mut self, &mut self,
center: ChunkPos, center: ChunkPos,
deliveries: &mut net::ChunkStream, deliveries: &mut net::ChunkStream,
renderer: &mut renderer::Renderer, sink: &mut impl MeshSink,
) { ) {
let unloaded = self.unload_outside(center, renderer); let unloaded = self.unload_outside(center, sink);
let (loaded, dropped) = self.apply_deliveries(deliveries, renderer); let (loaded, dropped) = self.apply_deliveries(deliveries, sink);
let meshed = self.drain_remesh(renderer); let meshed = self.drain_remesh(sink);
if loaded > 0 || dropped > 0 || unloaded > 0 || meshed > 0 { if loaded > 0 || dropped > 0 || unloaded > 0 || meshed > 0 {
debug!( debug!(
@ -85,7 +124,7 @@ impl ChunkManager {
fn apply_deliveries( fn apply_deliveries(
&mut self, &mut self,
deliveries: &mut net::ChunkStream, deliveries: &mut net::ChunkStream,
renderer: &mut renderer::Renderer, sink: &mut impl MeshSink,
) -> (usize, usize) { ) -> (usize, usize) {
let mut loaded = Vec::new(); let mut loaded = Vec::new();
let mut dropped = Vec::new(); let mut dropped = Vec::new();
@ -99,7 +138,7 @@ impl ChunkManager {
} }
Ok(ChunkMessage::Drop { pos }) => { Ok(ChunkMessage::Drop { pos }) => {
if self.resident.remove(&pos).is_some() { if self.resident.remove(&pos).is_some() {
renderer.remove_mesh((pos.x, pos.y, pos.z)); sink.remove_mesh((pos.x, pos.y, pos.z));
dropped.push(pos); dropped.push(pos);
} }
} }
@ -115,7 +154,7 @@ impl ChunkManager {
/// Evicts every resident chunk outside the load radius around `center`, returning the number removed. /// Evicts every resident chunk outside the load radius around `center`, returning the number removed.
/// ///
/// Each evicted chunk's resident neighbours have a boundary toward it that is now exposed, so they are enqueued for re-meshing. /// Each evicted chunk's resident neighbours have a boundary toward it that is now exposed, so they are enqueued for re-meshing.
fn unload_outside(&mut self, center: ChunkPos, renderer: &mut renderer::Renderer) -> usize { fn unload_outside(&mut self, center: ChunkPos, sink: &mut impl MeshSink) -> usize {
let desired = desired_chunks(center, LOAD_RADIUS); let desired = desired_chunks(center, LOAD_RADIUS);
let stale: Vec<ChunkPos> = self let stale: Vec<ChunkPos> = self
.resident .resident
@ -124,7 +163,7 @@ impl ChunkManager {
.copied() .copied()
.collect(); .collect();
for pos in &stale { for pos in &stale {
renderer.remove_mesh((pos.x, pos.y, pos.z)); sink.remove_mesh((pos.x, pos.y, pos.z));
self.resident.remove(pos); self.resident.remove(pos);
} }
self.queue_remesh(&[], &stale); self.queue_remesh(&[], &stale);
@ -140,7 +179,7 @@ impl ChunkManager {
/// Meshes and uploads up to [`MESHES_PER_UPDATE`] chunks from the pending set, returning the number processed. /// Meshes and uploads up to [`MESHES_PER_UPDATE`] chunks from the pending set, returning the number processed.
/// ///
/// Positions no longer resident (dropped after being enqueued) are discarded without meshing. /// Positions no longer resident (dropped after being enqueued) are discarded without meshing.
fn drain_remesh(&mut self, renderer: &mut renderer::Renderer) -> usize { fn drain_remesh(&mut self, sink: &mut impl MeshSink) -> usize {
// Take a bounded batch out of the set; the remainder stays queued for later frames. // Take a bounded batch out of the set; the remainder stays queued for later frames.
let batch: Vec<ChunkPos> = self let batch: Vec<ChunkPos> = self
.pending_remesh .pending_remesh
@ -153,17 +192,17 @@ impl ChunkManager {
for pos in batch { for pos in batch {
self.pending_remesh.remove(&pos); self.pending_remesh.remove(&pos);
if self.resident.contains_key(&pos) { if self.resident.contains_key(&pos) {
self.mesh_and_upload(pos, renderer); self.mesh_and_upload(pos, sink);
meshed += 1; meshed += 1;
} }
} }
meshed meshed
} }
/// Meshes the resident chunk at `pos` against its resident neighbours and uploads the result to the renderer. /// Meshes the resident chunk at `pos` against its resident neighbours and uploads the result to the sink.
/// ///
/// A chunk that meshes to no geometry (all air, or fully enclosed by solid neighbours) is removed from the renderer rather than uploaded, since a zero-length buffer is invalid; this also clears any mesh a previous state had left there. /// A chunk that meshes to no geometry (all air, or fully enclosed by solid neighbours) is removed from the sink rather than uploaded, since a zero-length buffer is invalid; this also clears any mesh a previous state had left there.
fn mesh_and_upload(&mut self, pos: ChunkPos, renderer: &mut renderer::Renderer) { fn mesh_and_upload(&mut self, pos: ChunkPos, sink: &mut impl MeshSink) {
let Some(chunk) = self.resident.get(&pos) else { let Some(chunk) = self.resident.get(&pos) else {
return; return;
}; };
@ -171,7 +210,7 @@ impl ChunkManager {
let (vertices, indices) = renderer::meshing::generate_mesh(chunk, &neighbors); let (vertices, indices) = renderer::meshing::generate_mesh(chunk, &neighbors);
if indices.is_empty() { if indices.is_empty() {
renderer.remove_mesh((pos.x, pos.y, pos.z)); sink.remove_mesh((pos.x, pos.y, pos.z));
return; return;
} }
@ -189,9 +228,7 @@ impl ChunkManager {
] ]
}; };
if let Err(e) = if let Err(e) = sink.insert_mesh((pos.x, pos.y, pos.z), &vertices, &indices, world_offset) {
renderer.insert_mesh((pos.x, pos.y, pos.z), &vertices, &indices, world_offset)
{
error!(?pos, "failed to upload chunk mesh: {e}"); error!(?pos, "failed to upload chunk mesh: {e}");
} }
} }