// SPDX-License-Identifier: AGPL-3.0-only //! Client-side chunk streaming around the camera. use std::collections::HashSet; use shared::protocol::chunk::ChunkMessage; use shared::world::{CHUNK_SIZE, Chunk, ChunkData, ChunkPos}; use tracing::{debug, error}; use crate::meshing; /// 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. const LOADS_PER_UPDATE: usize = 4; /// Tracks which server-streamed chunks are currently uploaded to the renderer. 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. baseline: Chunk, } impl ChunkManager { /// Creates a manager with no chunks yet resident. #[must_use] pub fn new() -> Self { Self { resident: 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. /// /// 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( &mut self, center: ChunkPos, deliveries: &net::ChunkStream, renderer: &mut renderer::Renderer, ) { let unloaded = self.unload_outside(center, 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 { match deliveries.try_recv() { Ok(ChunkMessage::Chunk { pos, data }) => { self.apply_chunk(pos, &data, renderer); loaded += 1; } Ok(ChunkMessage::Drop { pos }) => { if self.drop_chunk(pos, renderer) { dropped += 1; } } // Empty or disconnected: nothing more to apply this frame. Err(_) => break, } } 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) = 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 } } /// Evicts every resident chunk outside the load radius around `center`, returning the number removed. 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() .filter(|pos| !desired.contains(pos)) .copied() .collect(); for pos in &stale { renderer.remove_mesh((pos.x, pos.y, pos.z)); self.resident.remove(pos); } stale.len() } } impl Default for ChunkManager { fn default() -> Self { Self::new() } } /// 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`. #[must_use] pub fn desired_chunks(center: ChunkPos, radius: i32) -> HashSet { let mut out = HashSet::new(); for x in center.x - radius..=center.x + radius { for z in center.z - radius..=center.z + radius { let dx = x - center.x; let dz = z - center.z; // Keep only the columns whose XZ distance falls within the disc. if dx * dx + dz * dz <= radius * radius { for y in center.y - radius / 2..=center.y + radius / 2 { out.insert(ChunkPos::new(x, y, z)); } } } } out } #[cfg(test)] mod tests { use super::*; #[test] fn center_is_always_included() { let center = ChunkPos::new(0, 0, 0); assert!(desired_chunks(center, 4).contains(¢er)); } #[test] fn excludes_columns_beyond_the_disc() { let set = desired_chunks(ChunkPos::new(0, 0, 0), 4); // One chunk past the radius along an axis: squared distance 25 > 16. assert!(!set.contains(&ChunkPos::new(5, 0, 0))); // The far corner: squared distance 4*4 + 4*4 = 32 > 16. assert!(!set.contains(&ChunkPos::new(4, 0, 4))); } #[test] fn vertical_extent_is_half_the_radius() { let set = desired_chunks(ChunkPos::new(0, 0, 0), 4); // radius / 2 == 2, so the column at the center spans y in [-2, 2]. assert!(set.contains(&ChunkPos::new(0, 2, 0))); assert!(!set.contains(&ChunkPos::new(0, 3, 0))); } #[test] fn set_is_translation_invariant() { // Shifting the center shifts every member by the same offset; this also exercises negative coordinates on the shifted side. let base = desired_chunks(ChunkPos::new(0, 0, 0), 3); let shifted: HashSet = base .iter() .map(|p| ChunkPos::new(p.x - 10, p.y - 10, p.z - 10)) .collect(); assert_eq!(shifted, desired_chunks(ChunkPos::new(-10, -10, -10), 3)); } }