From 0c7785d869a84df0d862f8149207f6b19bb53996 Mon Sep 17 00:00:00 2001 From: Serkyo Date: Fri, 31 Jul 2026 01:26:39 +0200 Subject: [PATCH] feat(client): expose chunk streaming statistics --- crates/client/src/chunks.rs | 105 ++++++++++++++++++++++++++++++ crates/client/src/mesh_pool.rs | 11 ++-- crates/client/src/tests/chunks.rs | 43 ++++++++++++ 3 files changed, 154 insertions(+), 5 deletions(-) diff --git a/crates/client/src/chunks.rs b/crates/client/src/chunks.rs index 1d7405b..2c0d18a 100644 --- a/crates/client/src/chunks.rs +++ b/crates/client/src/chunks.rs @@ -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, diff --git a/crates/client/src/mesh_pool.rs b/crates/client/src/mesh_pool.rs index cb8643e..5c0cd22 100644 --- a/crates/client/src/mesh_pool.rs +++ b/crates/client/src/mesh_pool.rs @@ -61,11 +61,7 @@ pub(crate) struct MeshPool { job_tx: Sender, /// Receiving end of the result queue; the main thread drains finished meshes. result_rx: Receiver, - /// 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>, } @@ -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); diff --git a/crates/client/src/tests/chunks.rs b/crates/client/src/tests/chunks.rs index d65ba8c..49d9539 100644 --- a/crates/client/src/tests/chunks.rs +++ b/crates/client/src/tests/chunks.rs @@ -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); +}