feat(client): stream local chunk meshes around camera

This commit is contained in:
Serkyo 2026-07-16 02:09:12 +02:00
parent 60fe2fc294
commit 2bf140cf02
2 changed files with 186 additions and 18 deletions

167
crates/client/src/chunks.rs Normal file
View file

@ -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<ChunkPos>,
}
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<ChunkPos> = 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<ChunkPos> {
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(&center));
}
#[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<ChunkPos> = 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));
}
}

View file

@ -3,7 +3,9 @@
//! Main entry point for the Synvael client. //! Main entry point for the Synvael client.
//! //!
//! This crate handles window creation, input processing, and drives the renderer to display the game world. //! This crate handles window creation, input processing, and drives the renderer to display the game world.
mod camera; mod camera;
mod chunks;
mod meshing; mod meshing;
use std::time::Instant; use std::time::Instant;
@ -58,6 +60,8 @@ struct App {
last_frame: Option<Instant>, last_frame: Option<Instant>,
/// 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. /// 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<net::ConnectOutcome>, handshake_rx: Option<net::ConnectOutcome>,
/// Streams chunk meshes in and out around the camera. `None` until the renderer and worldgen config are initialised on resume.
chunks: Option<chunks::ChunkManager>,
} }
impl Default for App { impl Default for App {
@ -74,6 +78,7 @@ impl Default for App {
input: InputState::default(), input: InputState::default(),
last_frame: None, last_frame: None,
handshake_rx: None, handshake_rx: None,
chunks: None,
} }
} }
} }
@ -162,24 +167,7 @@ impl ApplicationHandler for App {
let seed = 4_813_530; let seed = 4_813_530;
let generator = shared::generator::VoxelGenerator::new(worldgen_config, seed); let generator = shared::generator::VoxelGenerator::new(worldgen_config, seed);
let chunk = generator.generate_chunk(shared::world::ChunkPos::new(0, 0, 0)); self.chunks = Some(chunks::ChunkManager::new(generator));
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");
// Kick off a background connect + handshake to the local server. // Kick off a background connect + handshake to the local server.
let hello = shared::protocol::ClientHello { 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. // The accumulated motion has been applied; clear it so it is not counted twice.
self.input.mouse_delta = (0.0, 0.0); 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(); let view = self.camera.view_matrix();
if let Some(Err(e)) = self.renderer.as_mut().map(|r| r.draw_frame(view)) { if let Some(Err(e)) = self.renderer.as_mut().map(|r| r.draw_frame(view)) {
error!("Failed to draw frame: {e}"); error!("Failed to draw frame: {e}");