feat(client): expose chunk streaming statistics

This commit is contained in:
Serkyo 2026-07-31 01:26:39 +02:00
parent 0e8ba02038
commit 0c7785d869
3 changed files with 154 additions and 5 deletions

View file

@ -70,6 +70,42 @@ impl MeshSink for renderer::Renderer {
}
}
/// Approximate resident footprint of one chunk, in bytes: a full `CHUNK_SIZE³` volume of `u16` block identifiers.
///
/// Ignores the `HashMap` entry and [`Arc`] header overheads, which are negligible beside the volume itself.
const CHUNK_RESIDENT_BYTES: u64 = (CHUNK_SIZE as u64).pow(3) * 2;
/// A snapshot of the streaming pipeline's state and cumulative throughput.
///
/// Every field is a plain value copied out of [`ChunkManager`] at the moment of the call; nothing is retained or shared, so a reader on a slower cadence than the frame loop observes one self-consistent instant.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct ChunkStats {
/// Chunks currently held in memory, each retaining its full voxel volume.
pub resident: usize,
/// Positions queued for re-meshing but not yet dispatched to the worker pool.
pub pending_remesh: usize,
/// Positions with a mesh job outstanding in the worker pool.
pub in_flight: usize,
/// Streaming radius, in chunks, currently in force.
pub load_radius: i32,
/// Size of the set of positions the streaming region wants resident. Residency lagging behind this figure indicates the server has not yet delivered the remainder.
pub desired: usize,
/// Chunks materialized from server deliveries since startup.
pub loaded_total: u64,
/// Chunks removed on the server's authoritative drop since startup.
pub dropped_total: u64,
/// Chunks evicted by the client's own radius check since startup, independently of any server drop.
pub evicted_total: u64,
/// Mesh jobs handed to the worker pool since startup.
pub dispatched_total: u64,
/// Finished meshes that were still current on return and were therefore uploaded since startup. The shortfall against `dispatched_total` is work superseded by a newer job or invalidated by eviction.
pub applied_total: u64,
/// Worker threads in the meshing pool.
pub mesh_workers: usize,
/// Estimated memory held by the resident chunk set, in bytes.
pub resident_bytes: u64,
}
/// Tracks which server-streamed chunks are resident and orchestrates neighbour-aware background meshing.
pub struct ChunkManager {
/// Resident chunks keyed by position, retained so the mesher can sample voxels across chunk boundaries. Stored behind [`Arc`] so a chunk can be handed to a worker thread without copying its 64 KiB volume.
@ -85,6 +121,45 @@ pub struct ChunkManager {
pool: MeshPool,
/// Reused all-air baseline that server [`ChunkData`](shared::world::ChunkData) diffs are materialized against.
baseline: Chunk,
/// Running totals of pipeline throughput since startup, reported through [`ChunkManager::stats`].
totals: ChunkTotals,
}
/// Cumulative counts of the work the streaming pipeline has performed since startup.
///
/// Kept as a separate struct so the per-frame counters already computed inside [`ChunkManager::update`] fold into one place rather than becoming five loose fields on the manager.
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
struct ChunkTotals {
/// Chunks materialized from server deliveries.
loaded: u64,
/// Chunks removed on the server's authoritative drop.
dropped: u64,
/// Chunks evicted by the client's own radius check.
evicted: u64,
/// Mesh jobs handed to the worker pool.
dispatched: u64,
/// Finished meshes uploaded because they were still current on return.
applied: u64,
}
impl ChunkTotals {
/// Folds one frame's per-category counts into the running totals.
///
/// Saturating addition is used throughout: these are monotonic diagnostic counters, and pinning them at `u64::MAX` is preferable to an overflow panic in the frame loop. Reaching the bound would require more chunk operations than any session performs.
fn accumulate(
&mut self,
loaded: usize,
dropped: usize,
evicted: usize,
dispatched: usize,
applied: usize,
) {
self.loaded = self.loaded.saturating_add(loaded as u64);
self.dropped = self.dropped.saturating_add(dropped as u64);
self.evicted = self.evicted.saturating_add(evicted as u64);
self.dispatched = self.dispatched.saturating_add(dispatched as u64);
self.applied = self.applied.saturating_add(applied as u64);
}
}
impl ChunkManager {
@ -98,6 +173,33 @@ impl ChunkManager {
next_gen: JobGen::FIRST,
pool: MeshPool::new(),
baseline: Chunk::default(),
totals: ChunkTotals::default(),
}
}
/// Snapshots the streaming pipeline's current state and cumulative throughput.
///
/// `center` is the chunk the streaming region is currently anchored to, and is needed only to size the desired set; it is not retained.
#[must_use]
// The statistics overlay is the sole consumer and is wired in a later change; until then nothing in the binary reads this, though the tests below do.
#[cfg_attr(
not(test),
expect(dead_code, reason = "consumed by the statistics overlay")
)]
pub fn stats(&self, center: ChunkPos) -> ChunkStats {
ChunkStats {
resident: self.resident.len(),
pending_remesh: self.pending_remesh.len(),
in_flight: self.in_flight.len(),
load_radius: LOAD_RADIUS,
desired: desired_chunks(center, LOAD_RADIUS).len(),
loaded_total: self.totals.loaded,
dropped_total: self.totals.dropped,
evicted_total: self.totals.evicted,
dispatched_total: self.totals.dispatched,
applied_total: self.totals.applied,
mesh_workers: self.pool.worker_count(),
resident_bytes: self.resident.len() as u64 * CHUNK_RESIDENT_BYTES,
}
}
@ -115,6 +217,9 @@ impl ChunkManager {
let (loaded, dropped) = self.apply_deliveries(deliveries, sink);
let dispatched = self.dispatch_pending();
self.totals
.accumulate(loaded, dropped, unloaded, dispatched, applied);
if loaded > 0 || dropped > 0 || unloaded > 0 || applied > 0 || dispatched > 0 {
debug!(
loaded,

View file

@ -61,11 +61,7 @@ pub(crate) struct MeshPool {
job_tx: Sender<MeshJob>,
/// Receiving end of the result queue; the main thread drains finished meshes.
result_rx: Receiver<MeshResult>,
/// Handles to the worker threads, retained for a future graceful-stop path that drops `job_tx` and joins them; the process currently relies on OS teardown at exit.
#[expect(
dead_code,
reason = "retained for a future graceful-shutdown join path, mirroring the server pool"
)]
/// Handles to the worker threads, retained for a future graceful-stop path that drops `job_tx` and joins them; the process currently relies on OS teardown at exit. Read in the meantime only for its length, by [`MeshPool::worker_count`].
workers: Vec<JoinHandle<()>>,
}
@ -117,6 +113,11 @@ impl MeshPool {
/// Enqueues a meshing job for the pool.
///
/// Returns the number of worker threads the pool was spawned with.
pub(crate) fn worker_count(&self) -> usize {
self.workers.len()
}
/// A send error (the workers have shut down) is ignored: there is nothing useful to do with the job, and shutdown only happens at process teardown.
pub(crate) fn dispatch(&self, job: MeshJob) {
let _ = self.job_tx.send(job);

View file

@ -228,3 +228,46 @@ fn evicted_mesh_is_discarded() {
assert!(sink.inserted.is_empty());
assert!(sink.removed.is_empty());
}
#[test]
fn stats_report_live_pipeline_state() {
let mut manager = ChunkManager::new();
let center = ChunkPos::new(0, 0, 0);
manager
.resident
.insert(center, Arc::new(ChunkManager::new().baseline.clone()));
manager.pending_remesh.insert(ChunkPos::new(1, 0, 0));
let stats = manager.stats(center);
assert_eq!(stats.resident, 1);
assert_eq!(stats.pending_remesh, 1);
assert_eq!(stats.in_flight, 0);
assert_eq!(stats.load_radius, LOAD_RADIUS);
assert_eq!(stats.desired, desired_chunks(center, LOAD_RADIUS).len());
// One resident chunk accounts for exactly one chunk's worth of voxel storage.
assert_eq!(stats.resident_bytes, CHUNK_RESIDENT_BYTES);
assert!(stats.mesh_workers >= 1);
}
#[test]
fn totals_accumulate_across_frames() {
let mut totals = ChunkTotals::default();
totals.accumulate(1, 2, 3, 4, 5);
totals.accumulate(10, 20, 30, 40, 50);
assert_eq!(totals.loaded, 11);
assert_eq!(totals.dropped, 22);
assert_eq!(totals.evicted, 33);
assert_eq!(totals.dispatched, 44);
assert_eq!(totals.applied, 55);
}
#[test]
fn totals_saturate_rather_than_overflow() {
let mut totals = ChunkTotals {
loaded: u64::MAX,
..ChunkTotals::default()
};
totals.accumulate(1, 0, 0, 0, 0);
assert_eq!(totals.loaded, u64::MAX);
}