From 2bf140cf0224093eaf7852f3e6afe4b392ad1171 Mon Sep 17 00:00:00 2001 From: Serkyo Date: Thu, 16 Jul 2026 02:09:12 +0200 Subject: [PATCH] feat(client): stream local chunk meshes around camera --- crates/client/src/chunks.rs | 167 ++++++++++++++++++++++++++++++++++++ crates/client/src/main.rs | 37 ++++---- 2 files changed, 186 insertions(+), 18 deletions(-) create mode 100644 crates/client/src/chunks.rs diff --git a/crates/client/src/chunks.rs b/crates/client/src/chunks.rs new file mode 100644 index 0000000..91aeca8 --- /dev/null +++ b/crates/client/src/chunks.rs @@ -0,0 +1,167 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +//! Client-side chunk streaming around the camera. + +use std::collections::HashSet; + +use shared::generator::VoxelGenerator; +use shared::world::{CHUNK_SIZE, ChunkPos}; +use tracing::{debug, error}; + +use crate::meshing; + +/// Radius, in chunks, of the region kept resident around the camera center. +// TODO: make configurable / drive from view-distance setting. +const LOAD_RADIUS: i32 = 4; + +/// Maximum number of chunks generated and uploaded in a single call to [`ChunkManager::update`], bounding per-frame meshing work so the winit loop stays responsive. +// TODO: move meshing to a worker pool. +const LOADS_PER_UPDATE: usize = 4; + +/// Owns the local voxel source and tracks which chunks are currently streamed to the renderer. +pub struct ChunkManager { + /// Deterministic voxel source for locally generated chunks. + generator: VoxelGenerator, + /// Positions already generated and reconciled with the renderer, whether or not they produced a non-empty mesh. + resident: HashSet, +} + +impl ChunkManager { + /// Creates a manager drawing chunks from `generator`, with no chunks yet resident. + #[must_use] + pub fn new(generator: VoxelGenerator) -> Self { + Self { + generator, + resident: HashSet::new(), + } + } + + /// Reconciles the resident chunk set toward the load radius around `center`. + pub fn update(&mut self, center: ChunkPos, renderer: &mut renderer::Renderer) { + let desired = desired_chunks(center, LOAD_RADIUS); + + // Evict every resident chunk outside the desired set. + 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); + } + + // Load up to a bounded number of desired chunks that are not yet resident. + let mut loaded = 0; + for &pos in &desired { + if loaded >= LOADS_PER_UPDATE { + break; + } + if self.resident.contains(&pos) { + continue; + } + + let chunk = self.generator.generate_chunk(pos); + 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 it is not regenerated on every update. + 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) + { + // The upload failed; the position is left non-resident so the next update retries it rather than silently dropping the chunk. + error!(?pos, "failed to upload chunk mesh: {e}"); + continue; + } + } + + self.resident.insert(pos); + loaded += 1; + } + + if loaded > 0 || !stale.is_empty() { + debug!( + loaded, + unloaded = stale.len(), + resident = self.resident.len(), + "chunk stream reconciled" + ); + } + } +} + +/// 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)); + } +} diff --git a/crates/client/src/main.rs b/crates/client/src/main.rs index 0c9c9d3..adadbb8 100644 --- a/crates/client/src/main.rs +++ b/crates/client/src/main.rs @@ -3,7 +3,9 @@ //! Main entry point for the Synvael client. //! //! This crate handles window creation, input processing, and drives the renderer to display the game world. + mod camera; +mod chunks; mod meshing; use std::time::Instant; @@ -58,6 +60,8 @@ struct App { last_frame: Option, /// Receives the outcome of the background connect and handshake, drained non-blocking from the event loop. `None` before the connection is started and once the outcome has been observed. handshake_rx: Option, + /// Streams chunk meshes in and out around the camera. `None` until the renderer and worldgen config are initialised on resume. + chunks: Option, } impl Default for App { @@ -74,6 +78,7 @@ impl Default for App { input: InputState::default(), last_frame: None, handshake_rx: None, + chunks: None, } } } @@ -162,24 +167,7 @@ impl ApplicationHandler for App { let seed = 4_813_530; let generator = shared::generator::VoxelGenerator::new(worldgen_config, seed); - let chunk = generator.generate_chunk(shared::world::ChunkPos::new(0, 0, 0)); - - let (vertices, indices) = meshing::generate_mesh(&chunk); - tracing::info!( - "Generated Mesh with {} vertices and {} indices!", - vertices.len(), - indices.len() - ); - - #[expect( - clippy::expect_used, - reason = "the renderer is assigned earlier in this function" - )] - self.renderer - .as_mut() - .expect("Renderer initialized") - .insert_mesh((0, 0, 0), &vertices, &indices, [0.0, 0.0, 0.0]) - .expect("Failed to upload terrain to GPU"); + self.chunks = Some(chunks::ChunkManager::new(generator)); // Kick off a background connect + handshake to the local server. let hello = shared::protocol::ClientHello { @@ -252,6 +240,19 @@ impl ApplicationHandler for App { // The accumulated motion has been applied; clear it so it is not counted twice. self.input.mouse_delta = (0.0, 0.0); + // Reconcile streamed chunks toward the chunk the camera now occupies. `from_world` floors via `div_euclid`, so negative coordinates map to the correct chunk. + let pos = self.camera.position; + let center = shared::world::ChunkPos::from_world( + f64::from(pos.x), + f64::from(pos.y), + f64::from(pos.z), + ); + if let (Some(chunks), Some(renderer)) = + (self.chunks.as_mut(), self.renderer.as_mut()) + { + chunks.update(center, renderer); + } + let view = self.camera.view_matrix(); if let Some(Err(e)) = self.renderer.as_mut().map(|r| r.draw_frame(view)) { error!("Failed to draw frame: {e}");