feat(client): stream local chunk meshes around camera
This commit is contained in:
parent
60fe2fc294
commit
2bf140cf02
167
crates/client/src/chunks.rs
Normal file
167
crates/client/src/chunks.rs
Normal 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(¢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<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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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}");
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue