feat(client): retain chunk voxels and re-mesh neighbours on load and drop
This commit is contained in:
parent
3b3a4a107b
commit
79543002b1
|
|
@ -2,25 +2,43 @@
|
|||
|
||||
//! 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::world::{CHUNK_SIZE, Chunk, ChunkData, ChunkPos};
|
||||
use shared::world::{CHUNK_SIZE, Chunk, ChunkPos};
|
||||
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.
|
||||
// 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.
|
||||
/// 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.
|
||||
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 {
|
||||
/// 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.
|
||||
/// 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.
|
||||
// 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.
|
||||
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,
|
||||
}
|
||||
|
||||
|
|
@ -29,12 +47,13 @@ impl ChunkManager {
|
|||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
resident: HashSet::new(),
|
||||
resident: HashMap::new(),
|
||||
pending_remesh: 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.
|
||||
/// 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.
|
||||
pub fn update(
|
||||
|
|
@ -44,19 +63,44 @@ impl ChunkManager {
|
|||
renderer: &mut renderer::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;
|
||||
let mut dropped = 0;
|
||||
// Only chunk deliveries count against the meshing budget; drops are cheap and always applied.
|
||||
while loaded < LOADS_PER_UPDATE {
|
||||
if loaded > 0 || dropped > 0 || unloaded > 0 || meshed > 0 {
|
||||
debug!(
|
||||
loaded,
|
||||
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() {
|
||||
Ok(ChunkMessage::Chunk { pos, data }) => {
|
||||
self.apply_chunk(pos, &data, renderer);
|
||||
loaded += 1;
|
||||
let chunk = Arc::new(data.materialize(&self.baseline));
|
||||
self.resident.insert(pos, chunk);
|
||||
loaded.push(pos);
|
||||
}
|
||||
Ok(ChunkMessage::Drop { pos }) => {
|
||||
if self.drop_chunk(pos, renderer) {
|
||||
dropped += 1;
|
||||
if self.resident.remove(&pos).is_some() {
|
||||
renderer.remove_mesh((pos.x, pos.y, pos.z));
|
||||
dropped.push(pos);
|
||||
}
|
||||
}
|
||||
// Empty or disconnected: nothing more to apply this frame.
|
||||
|
|
@ -64,24 +108,73 @@ impl ChunkManager {
|
|||
}
|
||||
}
|
||||
|
||||
if loaded > 0 || dropped > 0 || unloaded > 0 {
|
||||
debug!(
|
||||
loaded,
|
||||
dropped,
|
||||
unloaded,
|
||||
resident = self.resident.len(),
|
||||
"chunk stream reconciled"
|
||||
);
|
||||
}
|
||||
self.queue_remesh(&loaded, &dropped);
|
||||
(loaded.len(), dropped.len())
|
||||
}
|
||||
|
||||
/// 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) = renderer::meshing::generate_mesh(&chunk);
|
||||
/// Evicts every resident chunk outside the load radius around `center`, returning the number removed.
|
||||
///
|
||||
/// Each evicted chunk's resident neighbours have a boundary toward it that is now exposed, so they are enqueued for re-meshing.
|
||||
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.
|
||||
#[expect(
|
||||
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`.
|
||||
///
|
||||
/// 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();
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue