synvael/crates/client/src/chunks.rs

204 lines
7.6 KiB
Rust

// 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<ChunkPos>,
/// 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: &mut 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<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);
}
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<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));
}
}