feat(client): retain chunk voxels and re-mesh neighbours on load and drop

This commit is contained in:
Serkyo 2026-07-23 03:40:16 +02:00
parent 3b3a4a107b
commit 79543002b1

View file

@ -2,25 +2,43 @@
//! Client-side chunk streaming around the camera. //! Client-side chunk streaming around the camera.
use std::collections::HashSet; use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use renderer::meshing::Neighbors;
use shared::protocol::chunk::ChunkMessage; use shared::protocol::chunk::ChunkMessage;
use shared::world::{CHUNK_SIZE, Chunk, ChunkData, ChunkPos}; use shared::world::{CHUNK_SIZE, Chunk, ChunkPos};
use tracing::{debug, error}; use tracing::{debug, error};
/// 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. /// 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. // TODO: make configurable / drive from view-distance setting.
pub const LOAD_RADIUS: i32 = 8; 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. /// Maximum number of chunk deliveries materialized in a single call to [`ChunkManager::update`], bounding per-frame materialization work. Deliveries beyond the budget remain queued in the transport for the next frame.
// TODO: move meshing to a worker pool.
const LOADS_PER_UPDATE: usize = 4; const LOADS_PER_UPDATE: usize = 4;
/// Tracks which server-streamed chunks are currently uploaded to the renderer. /// Maximum number of chunks (re)meshed and uploaded per call to [`ChunkManager::update`], draining the pending re-mesh set under a bound so the winit loop stays responsive. One delivery can enqueue up to seven mesh jobs (itself plus six neighbours), so this budget exceeds [`LOADS_PER_UPDATE`].
// TODO: move meshing to a worker pool; until then this budget caps meshing work on the winit thread.
const MESHES_PER_UPDATE: usize = 16;
/// The six face-adjacent neighbour offsets, in chunk coordinates.
const NEIGHBOR_OFFSETS: [(i32, i32, i32); 6] = [
(1, 0, 0),
(-1, 0, 0),
(0, 1, 0),
(0, -1, 0),
(0, 0, 1),
(0, 0, -1),
];
/// Tracks which server-streamed chunks are resident and orchestrates neighbour-aware meshing.
pub struct ChunkManager { 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 chunks keyed by position, retained so the mesher can sample voxels across chunk boundaries. Stored behind [`Arc`] so a future worker pool can hand a chunk to a thread without copying it.
resident: HashSet<ChunkPos>, // TODO: a resident Chunk is 32³ × 2 bytes = 64 KiB; at LOAD_RADIUS = 8 the resident set is thousands of chunks (hundreds of MiB). A follow-up can store only the six 32×32 boundary planes per chunk instead of the full volume.
/// Reused all-air baseline that server [`ChunkData`] diffs are materialized against. resident: HashMap<ChunkPos, Arc<Chunk>>,
/// Positions whose mesh must be rebuilt, accumulated across frames and drained under [`MESHES_PER_UPDATE`]. Held as a set so a burst of deliveries re-meshes each affected neighbour at most once per frame.
pending_remesh: HashSet<ChunkPos>,
/// Reused all-air baseline that server [`ChunkData`](shared::world::ChunkData) diffs are materialized against.
baseline: Chunk, baseline: Chunk,
} }
@ -29,12 +47,13 @@ impl ChunkManager {
#[must_use] #[must_use]
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
resident: HashSet::new(), resident: HashMap::new(),
pending_remesh: HashSet::new(),
baseline: Chunk::default(), 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. /// Reconciles the resident chunk set: evicts chunks outside the load radius around `center`, applies queued server deliveries under a materialization budget, then drains the pending re-mesh set under a 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. /// 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( pub fn update(
@ -44,19 +63,44 @@ impl ChunkManager {
renderer: &mut renderer::Renderer, renderer: &mut renderer::Renderer,
) { ) {
let unloaded = self.unload_outside(center, renderer); let unloaded = self.unload_outside(center, renderer);
let (loaded, dropped) = self.apply_deliveries(deliveries, renderer);
let meshed = self.drain_remesh(renderer);
let mut loaded = 0; if loaded > 0 || dropped > 0 || unloaded > 0 || meshed > 0 {
let mut dropped = 0; debug!(
// Only chunk deliveries count against the meshing budget; drops are cheap and always applied. loaded,
while loaded < LOADS_PER_UPDATE { dropped,
unloaded,
meshed,
pending = self.pending_remesh.len(),
resident = self.resident.len(),
"chunk stream reconciled"
);
}
}
/// Applies up to [`LOADS_PER_UPDATE`] chunk deliveries plus any interleaved drops, returning the counts of chunks loaded and dropped.
///
/// Delivered chunks are materialized and retained; drops remove the chunk from residency and from the renderer. Both kinds enqueue the affected neighbourhood for re-meshing.
fn apply_deliveries(
&mut self,
deliveries: &mut net::ChunkStream,
renderer: &mut renderer::Renderer,
) -> (usize, usize) {
let mut loaded = Vec::new();
let mut dropped = Vec::new();
// Only chunk deliveries count against the budget; drops are cheap and always applied.
while loaded.len() < LOADS_PER_UPDATE {
match deliveries.try_recv() { match deliveries.try_recv() {
Ok(ChunkMessage::Chunk { pos, data }) => { Ok(ChunkMessage::Chunk { pos, data }) => {
self.apply_chunk(pos, &data, renderer); let chunk = Arc::new(data.materialize(&self.baseline));
loaded += 1; self.resident.insert(pos, chunk);
loaded.push(pos);
} }
Ok(ChunkMessage::Drop { pos }) => { Ok(ChunkMessage::Drop { pos }) => {
if self.drop_chunk(pos, renderer) { if self.resident.remove(&pos).is_some() {
dropped += 1; renderer.remove_mesh((pos.x, pos.y, pos.z));
dropped.push(pos);
} }
} }
// Empty or disconnected: nothing more to apply this frame. // Empty or disconnected: nothing more to apply this frame.
@ -64,24 +108,73 @@ impl ChunkManager {
} }
} }
if loaded > 0 || dropped > 0 || unloaded > 0 { self.queue_remesh(&loaded, &dropped);
debug!( (loaded.len(), dropped.len())
loaded,
dropped,
unloaded,
resident = self.resident.len(),
"chunk stream reconciled"
);
}
} }
/// Materializes, meshes, and uploads one delivered chunk, marking its position resident. /// Evicts every resident chunk outside the load radius around `center`, returning the number removed.
fn apply_chunk(&mut self, pos: ChunkPos, data: &ChunkData, renderer: &mut renderer::Renderer) { ///
let chunk = data.materialize(&self.baseline); /// Each evicted chunk's resident neighbours have a boundary toward it that is now exposed, so they are enqueued for re-meshing.
let (vertices, indices) = renderer::meshing::generate_mesh(&chunk); 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
.keys()
.filter(|pos| !desired.contains(pos))
.copied()
.collect();
for pos in &stale {
renderer.remove_mesh((pos.x, pos.y, pos.z));
self.resident.remove(pos);
}
self.queue_remesh(&[], &stale);
stale.len()
}
/// Adds the chunks affected by `loaded` and `dropped` to the pending re-mesh set.
fn queue_remesh(&mut self, loaded: &[ChunkPos], dropped: &[ChunkPos]) {
let targets = remesh_targets(loaded, dropped, |pos| self.resident.contains_key(&pos));
self.pending_remesh.extend(targets);
}
/// Meshes and uploads up to [`MESHES_PER_UPDATE`] chunks from the pending set, returning the number processed.
///
/// Positions no longer resident (dropped after being enqueued) are discarded without meshing.
fn drain_remesh(&mut self, renderer: &mut renderer::Renderer) -> usize {
// Take a bounded batch out of the set; the remainder stays queued for later frames.
let batch: Vec<ChunkPos> = self
.pending_remesh
.iter()
.take(MESHES_PER_UPDATE)
.copied()
.collect();
let mut meshed = 0;
for pos in batch {
self.pending_remesh.remove(&pos);
if self.resident.contains_key(&pos) {
self.mesh_and_upload(pos, renderer);
meshed += 1;
}
}
meshed
}
/// Meshes the resident chunk at `pos` against its resident neighbours and uploads the result to the renderer.
///
/// A chunk that meshes to no geometry (all air, or fully enclosed by solid neighbours) is removed from the renderer rather than uploaded, since a zero-length buffer is invalid; this also clears any mesh a previous state had left there.
fn mesh_and_upload(&mut self, pos: ChunkPos, renderer: &mut renderer::Renderer) {
let Some(chunk) = self.resident.get(&pos) else {
return;
};
let neighbors = self.neighbors_of(pos);
let (vertices, indices) = renderer::meshing::generate_mesh(chunk, &neighbors);
if indices.is_empty() {
renderer.remove_mesh((pos.x, pos.y, pos.z));
return;
}
// 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. // Chunk coordinates and CHUNK_SIZE are small and represent exactly as f32.
#[expect( #[expect(
clippy::cast_precision_loss, clippy::cast_precision_loss,
@ -103,33 +196,21 @@ impl ChunkManager {
} }
} }
self.resident.insert(pos); /// Gathers the six face-adjacent resident chunks of `pos` into a [`Neighbors`] set for meshing.
fn neighbors_of(&self, pos: ChunkPos) -> Neighbors<'_> {
let get = |dx, dy, dz| {
self.resident
.get(&ChunkPos::new(pos.x + dx, pos.y + dy, pos.z + dz))
.map(|chunk| &**chunk)
};
Neighbors {
pos_x: get(1, 0, 0),
neg_x: get(-1, 0, 0),
pos_y: get(0, 1, 0),
neg_y: get(0, -1, 0),
pos_z: get(0, 0, 1),
neg_z: get(0, 0, -1),
} }
/// 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()
} }
} }
@ -139,6 +220,40 @@ impl Default for ChunkManager {
} }
} }
/// Returns the six face-adjacent neighbour positions of `pos`.
fn neighbor_positions(pos: ChunkPos) -> [ChunkPos; 6] {
NEIGHBOR_OFFSETS.map(|(dx, dy, dz)| ChunkPos::new(pos.x + dx, pos.y + dy, pos.z + dz))
}
/// Computes the deduplicated set of resident chunks whose mesh must be rebuilt after a batch of loads and drops.
///
/// A newly-loaded chunk contributes itself (when resident) and each of its resident neighbours, whose boundary toward it may now be culled. A dropped chunk contributes only its resident neighbours, whose boundary toward it is re-exposed; the dropped chunk itself is gone and is never a target. `is_resident` reports whether a position is currently resident.
fn remesh_targets(
loaded: &[ChunkPos],
dropped: &[ChunkPos],
is_resident: impl Fn(ChunkPos) -> bool,
) -> HashSet<ChunkPos> {
let mut targets = HashSet::new();
for &pos in loaded {
if is_resident(pos) {
targets.insert(pos);
}
for neighbor in neighbor_positions(pos) {
if is_resident(neighbor) {
targets.insert(neighbor);
}
}
}
for &pos in dropped {
for neighbor in neighbor_positions(pos) {
if is_resident(neighbor) {
targets.insert(neighbor);
}
}
}
targets
}
/// Returns the set of chunk positions within the streaming cylinder around `center`. /// 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`. /// 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`.
@ -198,4 +313,41 @@ mod tests {
.collect(); .collect();
assert_eq!(shifted, desired_chunks(ChunkPos::new(-10, -10, -10), 3)); assert_eq!(shifted, desired_chunks(ChunkPos::new(-10, -10, -10), 3));
} }
/// Builds a residency predicate over a fixed set of positions.
fn resident_in(set: &[ChunkPos]) -> impl Fn(ChunkPos) -> bool + '_ {
move |pos| set.contains(&pos)
}
#[test]
fn loaded_chunk_remeshes_self_and_resident_neighbors() {
let p = ChunkPos::new(0, 0, 0);
let east = ChunkPos::new(1, 0, 0);
let down = ChunkPos::new(0, -1, 0);
// p plus two of its six neighbours are resident; the other four are not.
let resident = [p, east, down];
let targets = remesh_targets(&[p], &[], resident_in(&resident));
assert_eq!(targets, resident.into_iter().collect());
}
#[test]
fn dropped_chunk_remeshes_neighbors_but_not_itself() {
let p = ChunkPos::new(0, 0, 0);
let neighbor = ChunkPos::new(1, 0, 0);
let resident = [neighbor];
let targets = remesh_targets(&[], &[p], resident_in(&resident));
// The dropped chunk is never a target; its resident neighbour is.
assert!(!targets.contains(&p));
assert_eq!(targets, [neighbor].into_iter().collect());
}
#[test]
fn remesh_targets_are_deduplicated() {
// Two adjacent chunks loaded in one batch each name the other as a neighbour, but the set holds each once.
let a = ChunkPos::new(0, 0, 0);
let b = ChunkPos::new(1, 0, 0);
let resident = [a, b];
let targets = remesh_targets(&[a, b], &[], resident_in(&resident));
assert_eq!(targets, resident.into_iter().collect());
}
} }