204 lines
7.6 KiB
Rust
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(¢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));
|
|
}
|
|
}
|