From 89887c5b98e75f69e65001aee30c2f45b4364343 Mon Sep 17 00:00:00 2001 From: Serkyo Date: Thu, 30 Jul 2026 23:31:18 +0200 Subject: [PATCH] feat(renderer): expose per-frame draw statistics --- crates/renderer/src/lib.rs | 6 + crates/renderer/src/renderer.rs | 295 ++++++++++++++++++++++------- crates/renderer/src/stats.rs | 89 +++++++++ crates/renderer/src/swapchain.rs | 24 ++- crates/renderer/src/tests/stats.rs | 54 ++++++ 5 files changed, 393 insertions(+), 75 deletions(-) create mode 100644 crates/renderer/src/stats.rs create mode 100644 crates/renderer/src/tests/stats.rs diff --git a/crates/renderer/src/lib.rs b/crates/renderer/src/lib.rs index 6ccc548..6fef170 100644 --- a/crates/renderer/src/lib.rs +++ b/crates/renderer/src/lib.rs @@ -14,6 +14,7 @@ mod instance; pub mod meshing; mod pipeline; mod renderer; +pub mod stats; mod surface; mod swapchain; mod sync; @@ -29,6 +30,7 @@ use std::ffi::c_char; pub use error::RendererError; pub use renderer::{MeshKey, RasterPass, RenderMode, Renderer}; +pub use stats::{ProjectionInfo, RenderStats, SwapchainInfo}; use std::collections::HashMap; @@ -169,6 +171,10 @@ impl Renderer { render_mode: RenderMode::default(), sync: Some(sync), current_frame: 0, + present_mode: swapchain::present_mode_name(swapchain::PRESENT_MODE), + frames_presented: 0, + frames_skipped: 0, + last_frame_stats: None, }) } } diff --git a/crates/renderer/src/renderer.rs b/crates/renderer/src/renderer.rs index 0341c38..4a8f2e6 100644 --- a/crates/renderer/src/renderer.rs +++ b/crates/renderer/src/renderer.rs @@ -1,5 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-only +use crate::stats::{ProjectionInfo, RenderStats, SwapchainInfo}; use crate::sync::SyncPrimitives; use crate::{create_depth_resources, create_gpu_buffer, swapchain}; use crate::{error::RendererError, frustum::Frustum, vertex::Vertex}; @@ -10,6 +11,15 @@ use std::collections::HashMap; /// Opaque, renderer-side identifier for one uploaded chunk mesh. pub type MeshKey = (i32, i32, i32); +/// Vertical field of view of the perspective projection, in degrees. +const FOV_Y_DEGREES: f32 = 45.0; + +/// Distance to the near clip plane, in blocks. +const NEAR_PLANE: f32 = 0.1; + +/// Distance to the far clip plane, in blocks. +const FAR_PLANE: f32 = 500.0; + /// One rasterisation pass over the visible chunk meshes. /// /// A pass corresponds one-to-one with a pipeline object, since polygon mode and depth-compare state are baked into a pipeline and cannot be changed by a command. Passes are the GPU-level primitive; [`RenderMode`] composes them into what is actually presented. @@ -117,10 +127,34 @@ pub(crate) struct GpuMesh { pub(crate) index_allocation: Allocation, /// Number of indices submitted in the mesh's `cmd_draw_indexed` call. pub(crate) index_count: u32, + /// Number of vertices in [`GpuMesh::vertex_buffer`]. Not needed to draw an indexed mesh; retained because the index count alone cannot recover it. + pub(crate) vertex_count: u32, + /// Size of [`GpuMesh::vertex_buffer`] in bytes, retained to report resident GPU geometry footprint. + pub(crate) vertex_bytes: u64, + /// Size of [`GpuMesh::index_buffer`] in bytes, retained to report resident GPU geometry footprint. + pub(crate) index_bytes: u64, /// Chunk origin in world space (blocks); added to every vertex in the vertex shader. pub(crate) world_offset: [f32; 3], } +/// What one frame's draw-call recording submitted, gathered where the work is issued. +/// +/// Kept private and distinct from [`RenderStats`]: this carries only the values that are observable inside the recording pass, while the published snapshot additionally folds in renderer-wide state (resident mesh totals, cumulative frame counters) that the recording pass has no reason to look at. +struct Submission { + /// Meshes that survived frustum culling and were submitted. + visible_meshes: usize, + /// Meshes rejected by frustum culling. + culled_meshes: usize, + /// Indexed draw calls recorded: one per visible mesh per pass. + draw_calls: usize, + /// Triangles submitted, counted across every pass. + triangles: u64, + /// Vertices referenced by the submitted meshes, counted across every pass. + vertices: u64, + /// Projection parameters used to build the frame's matrix. + projection: ProjectionInfo, +} + /// The core renderer structure holding the Vulkan resources. pub struct Renderer { /// Entry point to the Vulkan library. @@ -183,6 +217,14 @@ pub struct Renderer { pub(crate) sync: Option, /// Index of the current frame being processed (0 to `crate::MAX_FRAMES_IN_FLIGHT` - 1). pub(crate) current_frame: usize, + /// Presentation mode the swapchain was created with, retained as its Vulkan enum name for reporting. + pub(crate) present_mode: &'static str, + /// Frames presented since initialisation. + pub(crate) frames_presented: u64, + /// Frames abandoned before submission because the swapchain was out of date. + pub(crate) frames_skipped: u64, + /// Submission statistics for the most recently completed frame, or [`None`] before the first frame completes. + pub(crate) last_frame_stats: Option, } impl Renderer { @@ -218,6 +260,7 @@ impl Renderer { let (image_index, _is_suboptimal) = match acquire { Ok(pair) => pair, Err(vk::Result::ERROR_OUT_OF_DATE_KHR) => { + self.frames_skipped = self.frames_skipped.saturating_add(1); self.recreate_swapchain(self.swapchain_extent.width, self.swapchain_extent.height)?; return Ok(()); } @@ -248,8 +291,8 @@ impl Renderer { let image = self.swapchain_images[image_index as usize]; let view = self.swapchain_image_views[image_index as usize]; - // 4. Record the actual rendering commands - self.record_commands(cmd, view, image, camera_view)?; + // 4. Record the actual rendering commands, retaining what they submitted. + let submission = self.record_commands(cmd, view, image, camera_view)?; // 5. Submit the work to the GPU let submit_info = vk::SubmitInfo::default() @@ -276,6 +319,10 @@ impl Renderer { // Advance the frame index regardless of the present outcome; the submitted work is already in flight on `in_flight_fence`. self.current_frame = (self.current_frame + 1) % crate::MAX_FRAMES_IN_FLIGHT; + self.frames_presented = self.frames_presented.saturating_add(1); + + // The snapshot is published only once the frame has been submitted, so a reader never observes counts for a frame that was abandoned. + self.last_frame_stats = Some(self.frame_stats(&submission)); // A suboptimal (`Ok(true)`) or out-of-date swapchain is rebuilt so the next frame targets a surface-matched swapchain. The rebuilt swapchain also corrects the projection aspect ratio, which is derived from the swapchain extent. match present { @@ -375,7 +422,50 @@ impl Renderer { } } - /// Records the drawing commands into the given command buffer. + /// Assembles the published snapshot for a completed frame from the per-frame submission counts and the renderer's own resident state. + fn frame_stats(&self, submission: &Submission) -> RenderStats { + // Buffer footprint is a property of what is resident, not of what was drawn, so it is summed over every uploaded mesh rather than over the visible subset. + let (vertex_bytes, index_bytes) = self + .chunk_meshes + .values() + .fold((0, 0), |(vertex, index), mesh| { + (vertex + mesh.vertex_bytes, index + mesh.index_bytes) + }); + + RenderStats { + uploaded_meshes: self.chunk_meshes.len(), + visible_meshes: submission.visible_meshes, + culled_meshes: submission.culled_meshes, + draw_calls: submission.draw_calls, + triangles: submission.triangles, + vertices: submission.vertices, + vertex_bytes, + index_bytes, + render_mode: self.render_mode, + projection: submission.projection, + swapchain: SwapchainInfo { + // The image count is bounded by the surface's maximum, far inside u32. + #[expect( + clippy::cast_possible_truncation, + reason = "swapchain image counts are single digits" + )] + image_count: self.swapchain_images.len() as u32, + width: self.swapchain_extent.width, + height: self.swapchain_extent.height, + present_mode: self.present_mode, + }, + frames_presented: self.frames_presented, + frames_skipped: self.frames_skipped, + } + } + + /// Returns the submission statistics for the most recently completed frame, or [`None`] before the first frame has been presented. + #[must_use] + pub const fn stats(&self) -> Option { + self.last_frame_stats + } + + /// Records the drawing commands into the given command buffer, returning what they submitted. /// /// # Errors /// @@ -386,7 +476,7 @@ impl Renderer { view: vk::ImageView, image: vk::Image, camera_view: glam::Mat4, - ) -> Result<(), RendererError> { + ) -> Result { // Transition layouts for drawing self.transition_to_draw_layout(cmd, image); @@ -425,14 +515,16 @@ impl Renderer { unsafe { self.device.cmd_begin_rendering(cmd, &rendering_info); - self.issue_draw_calls(cmd, camera_view); + } + let submission = self.issue_draw_calls(cmd, camera_view); + unsafe { self.device.cmd_end_rendering(cmd); } // Transition back to present self.transition_to_present_layout(cmd, image)?; - Ok(()) + Ok(submission) } /// Transitions the swapchain and depth images to layouts suitable for drawing. @@ -479,75 +571,131 @@ impl Renderer { unsafe { self.device.cmd_pipeline_barrier2(cmd, &dependency_info) }; } - /// Issues the actual draw calls for the frame. - fn issue_draw_calls(&self, cmd: vk::CommandBuffer, camera_view: glam::Mat4) { + /// Issues the actual draw calls for the frame, returning what was submitted. + fn issue_draw_calls(&self, cmd: vk::CommandBuffer, camera_view: glam::Mat4) -> Submission { + let projection = self.projection_info(); + + // The view matrix is supplied by the caller (the client's camera); the renderer owns only the projection, which depends on the swapchain aspect ratio it manages. + let mvp = glam::camera::rh::proj::vulkan::perspective( + projection.fov_y_radians, + projection.aspect, + projection.near, + projection.far, + ) * camera_view; + + let (visible, culled) = self.cull_to_frustum(mvp); + let submission = self.summarise_submission(&visible, culled, projection); + + self.set_dynamic_state(cmd); + self.record_passes(cmd, mvp, &visible); + + submission + } + + /// Derives this frame's projection parameters from the swapchain extent. + fn projection_info(&self) -> ProjectionInfo { + let aspect = + f64::from(self.swapchain_extent.width) / f64::from(self.swapchain_extent.height); + + #[expect( + clippy::cast_possible_truncation, + reason = "the aspect ratio is a small value; f32 precision is sufficient" + )] + ProjectionInfo { + fov_y_radians: FOV_Y_DEGREES.to_radians(), + aspect: aspect as f32, + near: NEAR_PLANE, + far: FAR_PLANE, + } + } + + /// Partitions the uploaded meshes against the view frustum derived from `mvp`, returning the survivors and the number rejected. + /// + /// Culling is performed once per frame rather than once per pass: the frustum does not change between passes, so the surviving set is shared by all of them. + fn cull_to_frustum(&self, mvp: glam::Mat4) -> (Vec<&GpuMesh>, usize) { + let frustum = Frustum::from_view_proj(mvp); + + // A chunk spans CHUNK_SIZE blocks on each axis. The mesher centres block i on [i - 0.5, i + 0.5], so a chunk's box runs [offset - 0.5, offset + CHUNK_SIZE - 0.5]; the extent below is added to that shifted minimum corner. + #[expect( + clippy::cast_precision_loss, + reason = "CHUNK_SIZE is 32, exactly representable as f32" + )] + let chunk_extent = glam::Vec3::splat(shared::world::CHUNK_SIZE as f32); + + let mut culled = 0; + let visible: Vec<&GpuMesh> = self + .chunk_meshes + .values() + .filter(|mesh| { + // Reject the chunk when its world-space bounding box falls entirely outside the frustum. + let box_min = glam::Vec3::from(mesh.world_offset) - glam::Vec3::splat(0.5); + let visible = frustum.intersects_aabb(box_min, box_min + chunk_extent); + if !visible { + culled += 1; + } + visible + }) + .collect(); + + (visible, culled) + } + + /// Totals what the surviving meshes will submit under the active render mode. + /// + /// Geometry totals are summed once over the surviving set and multiplied by the pass count, since every pass submits the same meshes. + fn summarise_submission( + &self, + visible: &[&GpuMesh], + culled: usize, + projection: ProjectionInfo, + ) -> Submission { + let per_pass_indices: u64 = visible.iter().map(|mesh| u64::from(mesh.index_count)).sum(); + let per_pass_vertices: u64 = visible + .iter() + .map(|mesh| u64::from(mesh.vertex_count)) + .sum(); + let passes = self.render_mode.passes().len(); + + Submission { + visible_meshes: visible.len(), + culled_meshes: culled, + draw_calls: visible.len() * passes, + // Three indices per triangle; the mesher emits triangle lists exclusively. + triangles: per_pass_indices / 3 * passes as u64, + vertices: per_pass_vertices * passes as u64, + projection, + } + } + + /// Records the viewport and scissor, which are dynamic pipeline state and must therefore be set on every command buffer. + fn set_dynamic_state(&self, cmd: vk::CommandBuffer) { + #[expect( + clippy::cast_precision_loss, + reason = "swapchain extents are within f32's exact-integer range" + )] + let viewport = vk::Viewport { + x: 0.0, + y: 0.0, + width: self.swapchain_extent.width as f32, + height: self.swapchain_extent.height as f32, + min_depth: 0.0, + max_depth: 1.0, + }; + + let scissor = vk::Rect2D { + offset: vk::Offset2D { x: 0, y: 0 }, + extent: self.swapchain_extent, + }; + unsafe { - #[expect( - clippy::cast_precision_loss, - reason = "swapchain extents are within f32's exact-integer range" - )] - let viewport = vk::Viewport { - x: 0.0, - y: 0.0, - width: self.swapchain_extent.width as f32, - height: self.swapchain_extent.height as f32, - min_depth: 0.0, - max_depth: 1.0, - }; self.device.cmd_set_viewport(cmd, 0, &[viewport]); - - let scissor = vk::Rect2D { - offset: vk::Offset2D { x: 0, y: 0 }, - extent: self.swapchain_extent, - }; self.device.cmd_set_scissor(cmd, 0, &[scissor]); + } + } - let aspect = - f64::from(self.swapchain_extent.width) / f64::from(self.swapchain_extent.height); - - #[expect( - clippy::cast_possible_truncation, - reason = "the aspect ratio is a small value; f32 precision is sufficient" - )] - let projection = glam::camera::rh::proj::vulkan::perspective( - 45.0_f32.to_radians(), - aspect as f32, - 0.1, - 500.0, - ); - - // The view matrix is supplied by the caller (the client's camera); the renderer owns only the projection, which depends on the swapchain aspect ratio it manages. - let mvp = projection * camera_view; - - // The view frustum is derived from the same matrix and reused to reject chunks whose bounding box lies entirely outside the view before any draw work is recorded. - let frustum = Frustum::from_view_proj(mvp); - - // A chunk spans CHUNK_SIZE blocks on each axis. The mesher centres block i on [i - 0.5, i + 0.5], so a chunk's box runs [offset - 0.5, offset + CHUNK_SIZE - 0.5]; the extent below is added to that shifted minimum corner. - #[expect( - clippy::cast_precision_loss, - reason = "CHUNK_SIZE is 32, exactly representable as f32" - )] - let chunk_extent = glam::Vec3::splat(shared::world::CHUNK_SIZE as f32); - // Culling is performed once per frame rather than once per pass: the frustum does not change between passes, so the surviving set is shared by all of them. - let mut culled: u32 = 0; - let visible: Vec<&GpuMesh> = self - .chunk_meshes - .values() - .filter(|mesh| { - // Reject the chunk when its world-space bounding box falls entirely outside the frustum. - let box_min = glam::Vec3::from(mesh.world_offset) - glam::Vec3::splat(0.5); - let visible = frustum.intersects_aabb(box_min, box_min + chunk_extent); - if !visible { - culled += 1; - } - visible - }) - .collect(); - - if culled > 0 { - tracing::debug!(culled, "chunks skipped by frustum culling"); - } - + /// Records one indexed draw per visible mesh, for every pass the active render mode composes. + fn record_passes(&self, cmd: vk::CommandBuffer, mvp: glam::Mat4, visible: &[&GpuMesh]) { + unsafe { // The MVP is identical for every chunk and every pass this frame, so it is pushed once before the loops. let mvp_bytes = bytemuck::cast_slice(mvp.as_ref()); self.device.cmd_push_constants( @@ -573,7 +721,7 @@ impl Renderer { self.pipelines[pass.index()], ); - for mesh in &visible { + for mesh in visible { // The offset is padded to a vec4 to match the std140 layout of the push-constant block. The shader reads xyz as the chunk's world offset and w as the debug-tint weight for this pass. let offset = [ mesh.world_offset[0], @@ -696,6 +844,9 @@ impl Renderer { index_buffer, index_allocation, index_count: indices.len() as u32, + vertex_count: vertices.len() as u32, + vertex_bytes: std::mem::size_of_val(vertices) as u64, + index_bytes: std::mem::size_of_val(indices) as u64, world_offset, }, ); diff --git a/crates/renderer/src/stats.rs b/crates/renderer/src/stats.rs new file mode 100644 index 0000000..ae3e847 --- /dev/null +++ b/crates/renderer/src/stats.rs @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +//! Snapshot types describing what the renderer submitted and what device it submitted to. + +use crate::renderer::RenderMode; + +/// The projection parameters used to build this frame's perspective matrix. +#[derive(Copy, Clone, Debug, PartialEq)] +pub struct ProjectionInfo { + /// Vertical field of view, in radians. + pub fov_y_radians: f32, + /// Width-to-height ratio of the render target, derived from the swapchain extent. + pub aspect: f32, + /// Distance to the near clip plane, in blocks. + pub near: f32, + /// Distance to the far clip plane, in blocks. + pub far: f32, +} + +/// Description of the swapchain currently backing presentation. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub struct SwapchainInfo { + /// Number of images the swapchain was created with. + pub image_count: u32, + /// Width of the swapchain images, in pixels. + pub width: u32, + /// Height of the swapchain images, in pixels. + pub height: u32, + /// Presentation mode the swapchain was created with, rendered as its Vulkan enum name. + pub present_mode: &'static str, +} + +/// What the renderer submitted for one frame, plus the cumulative frame counters. +/// +/// Populated at the end of every successful [`Renderer::draw_frame`](crate::Renderer::draw_frame) and retained until the next frame replaces it, so a reader running on a slower cadence than the render loop always observes a complete, self-consistent frame rather than a partially-updated one. +#[derive(Copy, Clone, Debug, PartialEq)] +pub struct RenderStats { + /// Total chunk meshes currently uploaded to the GPU, visible or not. + pub uploaded_meshes: usize, + /// Meshes that survived frustum culling and were submitted this frame. + pub visible_meshes: usize, + /// Meshes rejected by frustum culling this frame. + pub culled_meshes: usize, + /// Indexed draw calls recorded this frame: one per visible mesh per raster pass. + pub draw_calls: usize, + /// Triangles submitted this frame, counted across every pass. + pub triangles: u64, + /// Vertices referenced by the submitted meshes, counted across every pass. + pub vertices: u64, + /// Bytes of vertex buffer held by every uploaded mesh, visible or not. + pub vertex_bytes: u64, + /// Bytes of index buffer held by every uploaded mesh, visible or not. + pub index_bytes: u64, + /// The render mode in force this frame, which determines the pass list and therefore the draw-call multiplier. + pub render_mode: RenderMode, + /// Projection parameters used to build this frame's matrix. + pub projection: ProjectionInfo, + /// The swapchain backing presentation at the end of this frame. + pub swapchain: SwapchainInfo, + /// Frames presented since renderer initialisation. + pub frames_presented: u64, + /// Frames abandoned before submission because the swapchain reported itself out of date, typically during a window resize. + pub frames_skipped: u64, +} + +impl RenderStats { + /// Returns the fraction of uploaded meshes rejected by frustum culling this frame, in percent. + /// + /// Returns zero when nothing was uploaded, since no meshes means no meshes were culled rather than an undefined ratio. + #[must_use] + pub fn cull_ratio_percent(&self) -> f32 { + let considered = self.visible_meshes + self.culled_meshes; + if considered == 0 { + return 0.0; + } + // Mesh counts are bounded by the resident chunk set (thousands), far inside f32's exact-integer range. + #[expect( + clippy::cast_precision_loss, + reason = "mesh counts stay well within f32's exact-integer range" + )] + { + self.culled_meshes as f32 / considered as f32 * 100.0 + } + } +} + +#[cfg(test)] +#[path = "tests/stats.rs"] +mod tests; diff --git a/crates/renderer/src/swapchain.rs b/crates/renderer/src/swapchain.rs index 159c272..e854883 100644 --- a/crates/renderer/src/swapchain.rs +++ b/crates/renderer/src/swapchain.rs @@ -3,6 +3,26 @@ use crate::error::RendererError; use ash::{Device, Instance, khr, vk}; +/// Presentation mode every swapchain is created with. +/// +/// `FIFO` is the only mode the specification guarantees to be supported, and it is vsync-locked, so presentation never tears. +// TODO: select from the surface's supported modes once a vsync setting exists; `MAILBOX` is the low-latency alternative where available. +pub const PRESENT_MODE: vk::PresentModeKHR = vk::PresentModeKHR::FIFO; + +/// Returns the Vulkan enum name of a presentation mode, for reporting. +/// +/// A mode outside the known set is reported as `"UNKNOWN"` rather than its numeric value, since the numeric value carries no meaning to a reader. +#[must_use] +pub const fn present_mode_name(mode: vk::PresentModeKHR) -> &'static str { + match mode { + vk::PresentModeKHR::IMMEDIATE => "IMMEDIATE", + vk::PresentModeKHR::MAILBOX => "MAILBOX", + vk::PresentModeKHR::FIFO => "FIFO", + vk::PresentModeKHR::FIFO_RELAXED => "FIFO_RELAXED", + _ => "UNKNOWN", + } +} + /// Creates a swapchain and retrieves its images. /// /// # Errors @@ -47,8 +67,6 @@ pub fn create_swapchain( }) .unwrap_or(&surface_formats[0]); - let present_mode = vk::PresentModeKHR::FIFO; - let extent = if surface_capabilities.current_extent.width == u32::MAX { vk::Extent2D { width: width.clamp( @@ -85,7 +103,7 @@ pub fn create_swapchain( .image_sharing_mode(vk::SharingMode::EXCLUSIVE) .pre_transform(surface_capabilities.current_transform) .composite_alpha(vk::CompositeAlphaFlagsKHR::OPAQUE) - .present_mode(present_mode) + .present_mode(PRESENT_MODE) .clipped(true); let swapchain = unsafe { swapchain_loader.create_swapchain(&create_info, None)? }; diff --git a/crates/renderer/src/tests/stats.rs b/crates/renderer/src/tests/stats.rs new file mode 100644 index 0000000..11436a9 --- /dev/null +++ b/crates/renderer/src/tests/stats.rs @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +//! Unit tests for the derived arithmetic in [`crate::stats`]. + +use super::*; + +/// Builds a stats snapshot whose only meaningful fields are the two mesh counts the ratio is derived from. +fn stats_with_counts(visible: usize, culled: usize) -> RenderStats { + RenderStats { + uploaded_meshes: visible + culled, + visible_meshes: visible, + culled_meshes: culled, + draw_calls: 0, + triangles: 0, + vertices: 0, + vertex_bytes: 0, + index_bytes: 0, + render_mode: RenderMode::Filled, + projection: ProjectionInfo { + fov_y_radians: 0.0, + aspect: 1.0, + near: 0.1, + far: 500.0, + }, + swapchain: SwapchainInfo { + image_count: 3, + width: 1, + height: 1, + present_mode: "FIFO", + }, + frames_presented: 0, + frames_skipped: 0, + } +} + +#[test] +fn cull_ratio_is_zero_when_nothing_is_uploaded() { + assert!((stats_with_counts(0, 0).cull_ratio_percent() - 0.0).abs() < f32::EPSILON); +} + +#[test] +fn cull_ratio_is_zero_when_every_mesh_is_visible() { + assert!((stats_with_counts(8, 0).cull_ratio_percent() - 0.0).abs() < f32::EPSILON); +} + +#[test] +fn cull_ratio_is_full_when_every_mesh_is_culled() { + assert!((stats_with_counts(0, 8).cull_ratio_percent() - 100.0).abs() < f32::EPSILON); +} + +#[test] +fn cull_ratio_is_the_culled_share_of_the_considered_set() { + assert!((stats_with_counts(3, 1).cull_ratio_percent() - 25.0).abs() < f32::EPSILON); +}