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;
|
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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,75 +571,131 @@ 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 {
|
||||||
|
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 {
|
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]);
|
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]);
|
self.device.cmd_set_scissor(cmd, 0, &[scissor]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let aspect =
|
/// Records one indexed draw per visible mesh, for every pass the active render mode composes.
|
||||||
f64::from(self.swapchain_extent.width) / f64::from(self.swapchain_extent.height);
|
fn record_passes(&self, cmd: vk::CommandBuffer, mvp: glam::Mat4, visible: &[&GpuMesh]) {
|
||||||
|
unsafe {
|
||||||
#[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");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
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 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)? };
|
||||||
|
|
|
||||||
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