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.
|
//! 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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue