feat(renderer): expose per-frame draw statistics
This commit is contained in:
parent
c33643027c
commit
89887c5b98
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<SyncPrimitives>,
|
||||
/// 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<RenderStats>,
|
||||
}
|
||||
|
||||
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<RenderStats> {
|
||||
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<Submission, RendererError> {
|
||||
// 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,
|
||||
},
|
||||
);
|
||||
|
|
|
|||
89
crates/renderer/src/stats.rs
Normal file
89
crates/renderer/src/stats.rs
Normal 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;
|
||||
|
|
@ -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)? };
|
||||
|
|
|
|||
54
crates/renderer/src/tests/stats.rs
Normal file
54
crates/renderer/src/tests/stats.rs
Normal 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);
|
||||
}
|
||||
Loading…
Reference in a new issue