feat(client): expose chunk streaming statistics
This commit is contained in:
parent
0e8ba02038
commit
0c7785d869
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue