diff --git a/crates/client/src/chunks.rs b/crates/client/src/chunks.rs index 784581b..9198247 100644 --- a/crates/client/src/chunks.rs +++ b/crates/client/src/chunks.rs @@ -2,25 +2,43 @@ //! Client-side chunk streaming around the camera. -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; +use renderer::meshing::Neighbors; use shared::protocol::chunk::ChunkMessage; -use shared::world::{CHUNK_SIZE, Chunk, ChunkData, ChunkPos}; +use shared::world::{CHUNK_SIZE, Chunk, ChunkPos}; use tracing::{debug, error}; /// Radius, in chunks, of the region kept resident around the camera center. Also the radius the client subscribes with, so the server's resident set matches the client's. // TODO: make configurable / drive from view-distance setting. pub const LOAD_RADIUS: i32 = 8; -/// Maximum number of chunks meshed and uploaded in a single call to [`ChunkManager::update`], bounding per-frame meshing work so the winit loop stays responsive. Deliveries beyond the budget remain queued for the next frame. -// TODO: move meshing to a worker pool. +/// Maximum number of chunk deliveries materialized in a single call to [`ChunkManager::update`], bounding per-frame materialization work. Deliveries beyond the budget remain queued in the transport for the next frame. const LOADS_PER_UPDATE: usize = 4; -/// Tracks which server-streamed chunks are currently uploaded to the renderer. +/// Maximum number of chunks (re)meshed and uploaded per call to [`ChunkManager::update`], draining the pending re-mesh set under a bound so the winit loop stays responsive. One delivery can enqueue up to seven mesh jobs (itself plus six neighbours), so this budget exceeds [`LOADS_PER_UPDATE`]. +// TODO: move meshing to a worker pool; until then this budget caps meshing work on the winit thread. +const MESHES_PER_UPDATE: usize = 16; + +/// The six face-adjacent neighbour offsets, in chunk coordinates. +const NEIGHBOR_OFFSETS: [(i32, i32, i32); 6] = [ + (1, 0, 0), + (-1, 0, 0), + (0, 1, 0), + (0, -1, 0), + (0, 0, 1), + (0, 0, -1), +]; + +/// Tracks which server-streamed chunks are resident and orchestrates neighbour-aware meshing. pub struct ChunkManager { - /// Positions uploaded to the renderer (whether or not they produced a non-empty mesh), so unload and drop can reconcile against the renderer. - resident: HashSet, - /// Reused all-air baseline that server [`ChunkData`] diffs are materialized against. + /// 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. + // TODO: a resident Chunk is 32³ × 2 bytes = 64 KiB; at LOAD_RADIUS = 8 the resident set is thousands of chunks (hundreds of MiB). A follow-up can store only the six 32×32 boundary planes per chunk instead of the full volume. + resident: HashMap>, + /// Positions whose mesh must be rebuilt, accumulated across frames and drained under [`MESHES_PER_UPDATE`]. Held as a set so a burst of deliveries re-meshes each affected neighbour at most once per frame. + pending_remesh: HashSet, + /// Reused all-air baseline that server [`ChunkData`](shared::world::ChunkData) diffs are materialized against. baseline: Chunk, } @@ -29,12 +47,13 @@ impl ChunkManager { #[must_use] pub fn new() -> Self { Self { - resident: HashSet::new(), + resident: HashMap::new(), + pending_remesh: HashSet::new(), baseline: Chunk::default(), } } - /// Reconciles the resident chunk set: evicts chunks outside the load radius around `center`, then applies queued server deliveries under a per-frame meshing budget. + /// Reconciles the resident chunk set: evicts chunks outside the load radius around `center`, applies queued server deliveries under a materialization budget, then drains the pending re-mesh set under a meshing budget. /// /// The client's own radius eviction runs independently of the server's authoritative `Drop`, so memory stays bounded even if the server is slow to drop chunks that leave the region. pub fn update( @@ -44,19 +63,44 @@ impl ChunkManager { renderer: &mut renderer::Renderer, ) { let unloaded = self.unload_outside(center, renderer); + let (loaded, dropped) = self.apply_deliveries(deliveries, renderer); + let meshed = self.drain_remesh(renderer); - let mut loaded = 0; - let mut dropped = 0; - // Only chunk deliveries count against the meshing budget; drops are cheap and always applied. - while loaded < LOADS_PER_UPDATE { + if loaded > 0 || dropped > 0 || unloaded > 0 || meshed > 0 { + debug!( + loaded, + dropped, + unloaded, + meshed, + pending = self.pending_remesh.len(), + resident = self.resident.len(), + "chunk stream reconciled" + ); + } + } + + /// Applies up to [`LOADS_PER_UPDATE`] chunk deliveries plus any interleaved drops, returning the counts of chunks loaded and dropped. + /// + /// Delivered chunks are materialized and retained; drops remove the chunk from residency and from the renderer. Both kinds enqueue the affected neighbourhood for re-meshing. + fn apply_deliveries( + &mut self, + deliveries: &mut net::ChunkStream, + renderer: &mut renderer::Renderer, + ) -> (usize, usize) { + let mut loaded = Vec::new(); + let mut dropped = Vec::new(); + // Only chunk deliveries count against the budget; drops are cheap and always applied. + while loaded.len() < LOADS_PER_UPDATE { match deliveries.try_recv() { Ok(ChunkMessage::Chunk { pos, data }) => { - self.apply_chunk(pos, &data, renderer); - loaded += 1; + let chunk = Arc::new(data.materialize(&self.baseline)); + self.resident.insert(pos, chunk); + loaded.push(pos); } Ok(ChunkMessage::Drop { pos }) => { - if self.drop_chunk(pos, renderer) { - dropped += 1; + if self.resident.remove(&pos).is_some() { + renderer.remove_mesh((pos.x, pos.y, pos.z)); + dropped.push(pos); } } // Empty or disconnected: nothing more to apply this frame. @@ -64,64 +108,18 @@ impl ChunkManager { } } - if loaded > 0 || dropped > 0 || unloaded > 0 { - debug!( - loaded, - dropped, - unloaded, - resident = self.resident.len(), - "chunk stream reconciled" - ); - } - } - - /// Materializes, meshes, and uploads one delivered chunk, marking its position resident. - fn apply_chunk(&mut self, pos: ChunkPos, data: &ChunkData, renderer: &mut renderer::Renderer) { - let chunk = data.materialize(&self.baseline); - let (vertices, indices) = renderer::meshing::generate_mesh(&chunk); - - // Uploading a zero-length buffer is invalid, so an all-air chunk skips the renderer entirely. It is still marked resident below so a later delivery is not double-counted. - if !indices.is_empty() { - // Chunk coordinates and CHUNK_SIZE are small and represent exactly as f32. - #[expect( - clippy::cast_precision_loss, - reason = "chunk coordinates stay well within f32's exact-integer range" - )] - let world_offset = { - let size = CHUNK_SIZE as f32; - [ - pos.x as f32 * size, - pos.y as f32 * size, - pos.z as f32 * size, - ] - }; - - if let Err(e) = - renderer.insert_mesh((pos.x, pos.y, pos.z), &vertices, &indices, world_offset) - { - error!(?pos, "failed to upload chunk mesh: {e}"); - } - } - - self.resident.insert(pos); - } - - /// Removes one chunk from the renderer on the server's authoritative instruction, returning whether it was resident. - fn drop_chunk(&mut self, pos: ChunkPos, renderer: &mut renderer::Renderer) -> bool { - if self.resident.remove(&pos) { - renderer.remove_mesh((pos.x, pos.y, pos.z)); - true - } else { - false - } + self.queue_remesh(&loaded, &dropped); + (loaded.len(), dropped.len()) } /// 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. fn unload_outside(&mut self, center: ChunkPos, renderer: &mut renderer::Renderer) -> usize { let desired = desired_chunks(center, LOAD_RADIUS); let stale: Vec = self .resident - .iter() + .keys() .filter(|pos| !desired.contains(pos)) .copied() .collect(); @@ -129,8 +127,91 @@ impl ChunkManager { renderer.remove_mesh((pos.x, pos.y, pos.z)); self.resident.remove(pos); } + self.queue_remesh(&[], &stale); stale.len() } + + /// Adds the chunks affected by `loaded` and `dropped` to the pending re-mesh set. + fn queue_remesh(&mut self, loaded: &[ChunkPos], dropped: &[ChunkPos]) { + let targets = remesh_targets(loaded, dropped, |pos| self.resident.contains_key(&pos)); + self.pending_remesh.extend(targets); + } + + /// 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. + fn drain_remesh(&mut self, renderer: &mut renderer::Renderer) -> usize { + // Take a bounded batch out of the set; the remainder stays queued for later frames. + let batch: Vec = self + .pending_remesh + .iter() + .take(MESHES_PER_UPDATE) + .copied() + .collect(); + + let mut meshed = 0; + for pos in batch { + self.pending_remesh.remove(&pos); + if self.resident.contains_key(&pos) { + self.mesh_and_upload(pos, renderer); + meshed += 1; + } + } + meshed + } + + /// Meshes the resident chunk at `pos` against its resident neighbours and uploads the result to the renderer. + /// + /// 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. + fn mesh_and_upload(&mut self, pos: ChunkPos, renderer: &mut renderer::Renderer) { + let Some(chunk) = self.resident.get(&pos) else { + return; + }; + let neighbors = self.neighbors_of(pos); + let (vertices, indices) = renderer::meshing::generate_mesh(chunk, &neighbors); + + if indices.is_empty() { + renderer.remove_mesh((pos.x, pos.y, pos.z)); + return; + } + + // Chunk coordinates and CHUNK_SIZE are small and represent exactly as f32. + #[expect( + clippy::cast_precision_loss, + reason = "chunk coordinates stay well within f32's exact-integer range" + )] + let world_offset = { + let size = CHUNK_SIZE as f32; + [ + pos.x as f32 * size, + pos.y as f32 * size, + pos.z as f32 * size, + ] + }; + + if let Err(e) = + renderer.insert_mesh((pos.x, pos.y, pos.z), &vertices, &indices, world_offset) + { + error!(?pos, "failed to upload chunk mesh: {e}"); + } + } + + /// Gathers the six face-adjacent resident chunks of `pos` into a [`Neighbors`] set for meshing. + fn neighbors_of(&self, pos: ChunkPos) -> Neighbors<'_> { + let get = |dx, dy, dz| { + self.resident + .get(&ChunkPos::new(pos.x + dx, pos.y + dy, pos.z + dz)) + .map(|chunk| &**chunk) + }; + Neighbors { + pos_x: get(1, 0, 0), + neg_x: get(-1, 0, 0), + pos_y: get(0, 1, 0), + neg_y: get(0, -1, 0), + pos_z: get(0, 0, 1), + neg_z: get(0, 0, -1), + } + } } impl Default for ChunkManager { @@ -139,6 +220,40 @@ impl Default for ChunkManager { } } +/// Returns the six face-adjacent neighbour positions of `pos`. +fn neighbor_positions(pos: ChunkPos) -> [ChunkPos; 6] { + NEIGHBOR_OFFSETS.map(|(dx, dy, dz)| ChunkPos::new(pos.x + dx, pos.y + dy, pos.z + dz)) +} + +/// Computes the deduplicated set of resident chunks whose mesh must be rebuilt after a batch of loads and drops. +/// +/// A newly-loaded chunk contributes itself (when resident) and each of its resident neighbours, whose boundary toward it may now be culled. A dropped chunk contributes only its resident neighbours, whose boundary toward it is re-exposed; the dropped chunk itself is gone and is never a target. `is_resident` reports whether a position is currently resident. +fn remesh_targets( + loaded: &[ChunkPos], + dropped: &[ChunkPos], + is_resident: impl Fn(ChunkPos) -> bool, +) -> HashSet { + let mut targets = HashSet::new(); + for &pos in loaded { + if is_resident(pos) { + targets.insert(pos); + } + for neighbor in neighbor_positions(pos) { + if is_resident(neighbor) { + targets.insert(neighbor); + } + } + } + for &pos in dropped { + for neighbor in neighbor_positions(pos) { + if is_resident(neighbor) { + targets.insert(neighbor); + } + } + } + targets +} + /// Returns the set of chunk positions within the streaming cylinder around `center`. /// /// The region is a disc of `radius` chunks in the horizontal XZ plane and half that extent in Y, matching the flatter vertical shape of the playable world. This mirrors the server's `world_server::cylinder_chunks`. @@ -198,4 +313,41 @@ mod tests { .collect(); assert_eq!(shifted, desired_chunks(ChunkPos::new(-10, -10, -10), 3)); } + + /// Builds a residency predicate over a fixed set of positions. + fn resident_in(set: &[ChunkPos]) -> impl Fn(ChunkPos) -> bool + '_ { + move |pos| set.contains(&pos) + } + + #[test] + fn loaded_chunk_remeshes_self_and_resident_neighbors() { + let p = ChunkPos::new(0, 0, 0); + let east = ChunkPos::new(1, 0, 0); + let down = ChunkPos::new(0, -1, 0); + // p plus two of its six neighbours are resident; the other four are not. + let resident = [p, east, down]; + let targets = remesh_targets(&[p], &[], resident_in(&resident)); + assert_eq!(targets, resident.into_iter().collect()); + } + + #[test] + fn dropped_chunk_remeshes_neighbors_but_not_itself() { + let p = ChunkPos::new(0, 0, 0); + let neighbor = ChunkPos::new(1, 0, 0); + let resident = [neighbor]; + let targets = remesh_targets(&[], &[p], resident_in(&resident)); + // The dropped chunk is never a target; its resident neighbour is. + assert!(!targets.contains(&p)); + assert_eq!(targets, [neighbor].into_iter().collect()); + } + + #[test] + fn remesh_targets_are_deduplicated() { + // Two adjacent chunks loaded in one batch each name the other as a neighbour, but the set holds each once. + let a = ChunkPos::new(0, 0, 0); + let b = ChunkPos::new(1, 0, 0); + let resident = [a, b]; + let targets = remesh_targets(&[a, b], &[], resident_in(&resident)); + assert_eq!(targets, resident.into_iter().collect()); + } }