feat(renderer): expose per-frame draw statistics

This commit is contained in:
Serkyo 2026-07-30 23:31:18 +02:00
parent c33643027c
commit 89887c5b98
5 changed files with 393 additions and 75 deletions

View file

@ -14,6 +14,7 @@ mod instance;
pub mod meshing; pub mod meshing;
mod pipeline; mod pipeline;
mod renderer; mod renderer;
pub mod stats;
mod surface; mod surface;
mod swapchain; mod swapchain;
mod sync; mod sync;
@ -29,6 +30,7 @@ use std::ffi::c_char;
pub use error::RendererError; pub use error::RendererError;
pub use renderer::{MeshKey, RasterPass, RenderMode, Renderer}; pub use renderer::{MeshKey, RasterPass, RenderMode, Renderer};
pub use stats::{ProjectionInfo, RenderStats, SwapchainInfo};
use std::collections::HashMap; use std::collections::HashMap;
@ -169,6 +171,10 @@ impl Renderer {
render_mode: RenderMode::default(), render_mode: RenderMode::default(),
sync: Some(sync), sync: Some(sync),
current_frame: 0, current_frame: 0,
present_mode: swapchain::present_mode_name(swapchain::PRESENT_MODE),
frames_presented: 0,
frames_skipped: 0,
last_frame_stats: None,
}) })
} }
} }

View file

@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
use crate::stats::{ProjectionInfo, RenderStats, SwapchainInfo};
use crate::sync::SyncPrimitives; use crate::sync::SyncPrimitives;
use crate::{create_depth_resources, create_gpu_buffer, swapchain}; use crate::{create_depth_resources, create_gpu_buffer, swapchain};
use crate::{error::RendererError, frustum::Frustum, vertex::Vertex}; 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. /// Opaque, renderer-side identifier for one uploaded chunk mesh.
pub type MeshKey = (i32, i32, i32); 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. /// 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. /// 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, pub(crate) index_allocation: Allocation,
/// Number of indices submitted in the mesh's `cmd_draw_indexed` call. /// Number of indices submitted in the mesh's `cmd_draw_indexed` call.
pub(crate) index_count: u32, 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. /// Chunk origin in world space (blocks); added to every vertex in the vertex shader.
pub(crate) world_offset: [f32; 3], 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. /// The core renderer structure holding the Vulkan resources.
pub struct Renderer { pub struct Renderer {
/// Entry point to the Vulkan library. /// Entry point to the Vulkan library.
@ -183,6 +217,14 @@ pub struct Renderer {
pub(crate) sync: Option<SyncPrimitives>, pub(crate) sync: Option<SyncPrimitives>,
/// Index of the current frame being processed (0 to `crate::MAX_FRAMES_IN_FLIGHT` - 1). /// Index of the current frame being processed (0 to `crate::MAX_FRAMES_IN_FLIGHT` - 1).
pub(crate) current_frame: usize, 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<RenderStats>,
} }
impl Renderer { impl Renderer {
@ -218,6 +260,7 @@ impl Renderer {
let (image_index, _is_suboptimal) = match acquire { let (image_index, _is_suboptimal) = match acquire {
Ok(pair) => pair, Ok(pair) => pair,
Err(vk::Result::ERROR_OUT_OF_DATE_KHR) => { 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)?; self.recreate_swapchain(self.swapchain_extent.width, self.swapchain_extent.height)?;
return Ok(()); return Ok(());
} }
@ -248,8 +291,8 @@ impl Renderer {
let image = self.swapchain_images[image_index as usize]; let image = self.swapchain_images[image_index as usize];
let view = self.swapchain_image_views[image_index as usize]; let view = self.swapchain_image_views[image_index as usize];
// 4. Record the actual rendering commands // 4. Record the actual rendering commands, retaining what they submitted.
self.record_commands(cmd, view, image, camera_view)?; let submission = self.record_commands(cmd, view, image, camera_view)?;
// 5. Submit the work to the GPU // 5. Submit the work to the GPU
let submit_info = vk::SubmitInfo::default() 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`. // 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.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. // 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 { 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<RenderStats> {
self.last_frame_stats
}
/// Records the drawing commands into the given command buffer, returning what they submitted.
/// ///
/// # Errors /// # Errors
/// ///
@ -386,7 +476,7 @@ impl Renderer {
view: vk::ImageView, view: vk::ImageView,
image: vk::Image, image: vk::Image,
camera_view: glam::Mat4, camera_view: glam::Mat4,
) -> Result<(), RendererError> { ) -> Result<Submission, RendererError> {
// Transition layouts for drawing // Transition layouts for drawing
self.transition_to_draw_layout(cmd, image); self.transition_to_draw_layout(cmd, image);
@ -425,14 +515,16 @@ impl Renderer {
unsafe { unsafe {
self.device.cmd_begin_rendering(cmd, &rendering_info); 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); self.device.cmd_end_rendering(cmd);
} }
// Transition back to present // Transition back to present
self.transition_to_present_layout(cmd, image)?; self.transition_to_present_layout(cmd, image)?;
Ok(()) Ok(submission)
} }
/// Transitions the swapchain and depth images to layouts suitable for drawing. /// Transitions the swapchain and depth images to layouts suitable for drawing.
@ -479,29 +571,29 @@ impl Renderer {
unsafe { self.device.cmd_pipeline_barrier2(cmd, &dependency_info) }; unsafe { self.device.cmd_pipeline_barrier2(cmd, &dependency_info) };
} }
/// Issues the actual draw calls for the frame. /// Issues the actual draw calls for the frame, returning what was submitted.
fn issue_draw_calls(&self, cmd: vk::CommandBuffer, camera_view: glam::Mat4) { fn issue_draw_calls(&self, cmd: vk::CommandBuffer, camera_view: glam::Mat4) -> Submission {
unsafe { let projection = self.projection_info();
#[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 { // 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.
offset: vk::Offset2D { x: 0, y: 0 }, let mvp = glam::camera::rh::proj::vulkan::perspective(
extent: self.swapchain_extent, projection.fov_y_radians,
}; projection.aspect,
self.device.cmd_set_scissor(cmd, 0, &[scissor]); 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 = let aspect =
f64::from(self.swapchain_extent.width) / f64::from(self.swapchain_extent.height); f64::from(self.swapchain_extent.width) / f64::from(self.swapchain_extent.height);
@ -509,17 +601,18 @@ impl Renderer {
clippy::cast_possible_truncation, clippy::cast_possible_truncation,
reason = "the aspect ratio is a small value; f32 precision is sufficient" reason = "the aspect ratio is a small value; f32 precision is sufficient"
)] )]
let projection = glam::camera::rh::proj::vulkan::perspective( ProjectionInfo {
45.0_f32.to_radians(), fov_y_radians: FOV_Y_DEGREES.to_radians(),
aspect as f32, aspect: aspect as f32,
0.1, near: NEAR_PLANE,
500.0, far: FAR_PLANE,
); }
}
// 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. /// Partitions the uploaded meshes against the view frustum derived from `mvp`, returning the survivors and the number rejected.
let mvp = projection * camera_view; ///
/// 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.
// 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. fn cull_to_frustum(&self, mvp: glam::Mat4) -> (Vec<&GpuMesh>, usize) {
let frustum = Frustum::from_view_proj(mvp); 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. // 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.
@ -528,8 +621,8 @@ impl Renderer {
reason = "CHUNK_SIZE is 32, exactly representable as f32" reason = "CHUNK_SIZE is 32, exactly representable as f32"
)] )]
let chunk_extent = glam::Vec3::splat(shared::world::CHUNK_SIZE 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 mut culled = 0;
let visible: Vec<&GpuMesh> = self let visible: Vec<&GpuMesh> = self
.chunk_meshes .chunk_meshes
.values() .values()
@ -544,10 +637,65 @@ impl Renderer {
}) })
.collect(); .collect();
if culled > 0 { (visible, culled)
tracing::debug!(culled, "chunks skipped by frustum culling");
} }
/// 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 {
self.device.cmd_set_viewport(cmd, 0, &[viewport]);
self.device.cmd_set_scissor(cmd, 0, &[scissor]);
}
}
/// 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. // 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()); let mvp_bytes = bytemuck::cast_slice(mvp.as_ref());
self.device.cmd_push_constants( self.device.cmd_push_constants(
@ -573,7 +721,7 @@ impl Renderer {
self.pipelines[pass.index()], 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. // 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 = [ let offset = [
mesh.world_offset[0], mesh.world_offset[0],
@ -696,6 +844,9 @@ impl Renderer {
index_buffer, index_buffer,
index_allocation, index_allocation,
index_count: indices.len() as u32, 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, world_offset,
}, },
); );

View file

@ -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;

View file

@ -3,6 +3,26 @@
use crate::error::RendererError; use crate::error::RendererError;
use ash::{Device, Instance, khr, vk}; 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. /// Creates a swapchain and retrieves its images.
/// ///
/// # Errors /// # Errors
@ -47,8 +67,6 @@ pub fn create_swapchain(
}) })
.unwrap_or(&surface_formats[0]); .unwrap_or(&surface_formats[0]);
let present_mode = vk::PresentModeKHR::FIFO;
let extent = if surface_capabilities.current_extent.width == u32::MAX { let extent = if surface_capabilities.current_extent.width == u32::MAX {
vk::Extent2D { vk::Extent2D {
width: width.clamp( width: width.clamp(
@ -85,7 +103,7 @@ pub fn create_swapchain(
.image_sharing_mode(vk::SharingMode::EXCLUSIVE) .image_sharing_mode(vk::SharingMode::EXCLUSIVE)
.pre_transform(surface_capabilities.current_transform) .pre_transform(surface_capabilities.current_transform)
.composite_alpha(vk::CompositeAlphaFlagsKHR::OPAQUE) .composite_alpha(vk::CompositeAlphaFlagsKHR::OPAQUE)
.present_mode(present_mode) .present_mode(PRESENT_MODE)
.clipped(true); .clipped(true);
let swapchain = unsafe { swapchain_loader.create_swapchain(&create_info, None)? }; let swapchain = unsafe { swapchain_loader.create_swapchain(&create_info, None)? };

View file

@ -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);
}