diff --git a/crates/renderer/src/device.rs b/crates/renderer/src/device.rs index 48ef9d6..34b0390 100644 --- a/crates/renderer/src/device.rs +++ b/crates/renderer/src/device.rs @@ -53,8 +53,10 @@ pub fn create_logical_device( let mut dynamic_rendering_features = vk::PhysicalDeviceDynamicRenderingFeatures::default().dynamic_rendering(true); - // `fillModeNonSolid` unlocks the `POINT` and `LINE` polygon modes used by the debug render modes. - let enabled_features = vk::PhysicalDeviceFeatures::default().fill_mode_non_solid(true); + // `fillModeNonSolid` unlocks the `POINT` and `LINE` polygon modes used by the debug render modes. `largePoints` permits a shader-written point size above 1.0, without which debug points rasterise as single pixels. + let enabled_features = vk::PhysicalDeviceFeatures::default() + .fill_mode_non_solid(true) + .large_points(true); let create_info = vk::DeviceCreateInfo::default() .queue_create_infos(std::slice::from_ref(&queue_info)) diff --git a/crates/renderer/src/lib.rs b/crates/renderer/src/lib.rs index 195ec25..6ccc548 100644 --- a/crates/renderer/src/lib.rs +++ b/crates/renderer/src/lib.rs @@ -28,7 +28,7 @@ use raw_window_handle::{RawDisplayHandle, RawWindowHandle}; use std::ffi::c_char; pub use error::RendererError; -pub use renderer::{MeshKey, RenderMode, Renderer}; +pub use renderer::{MeshKey, RasterPass, RenderMode, Renderer}; use std::collections::HashMap; @@ -125,14 +125,15 @@ impl Renderer { // 12. Graphics Pipeline Configuration let pipeline_layout = pipeline::create_pipeline_layout(&device)?; - // One pipeline per render mode, built up front so switching modes is a bind-time choice rather than a pipeline compilation stall. All variants share `pipeline_layout`; only their rasterisation state differs. - let mut pipelines = [vk::Pipeline::null(); RenderMode::COUNT]; - for mode in RenderMode::ALL { - pipelines[mode.index()] = pipeline::create_graphics_pipeline( + // One pipeline per raster pass, built up front so selecting a mode is a bind-time choice rather than a pipeline compilation stall. All variants share `pipeline_layout`; only their rasterisation and depth-compare state differs. + let mut pipelines = [vk::Pipeline::null(); RasterPass::COUNT]; + for pass in RasterPass::ALL { + pipelines[pass.index()] = pipeline::create_graphics_pipeline( &device, pipeline_layout, swapchain_format, - mode.polygon_mode(), + pass.polygon_mode(), + pass.depth_compare_op(), )?; } diff --git a/crates/renderer/src/pipeline.rs b/crates/renderer/src/pipeline.rs index d0e175d..7c5fdf6 100644 --- a/crates/renderer/src/pipeline.rs +++ b/crates/renderer/src/pipeline.rs @@ -68,6 +68,7 @@ pub fn create_graphics_pipeline( layout: vk::PipelineLayout, color_format: vk::Format, polygon_mode: vk::PolygonMode, + depth_compare_op: vk::CompareOp, ) -> Result { // 1. Load and compile shader modules let (vert_module, frag_module) = load_shader_modules(device)?; @@ -132,7 +133,7 @@ pub fn create_graphics_pipeline( let depth_stencil_state = &vk::PipelineDepthStencilStateCreateInfo::default() .depth_test_enable(true) .depth_write_enable(true) - .depth_compare_op(vk::CompareOp::LESS) + .depth_compare_op(depth_compare_op) .depth_bounds_test_enable(false) .stencil_test_enable(false); diff --git a/crates/renderer/src/renderer.rs b/crates/renderer/src/renderer.rs index 6813c3e..0341c38 100644 --- a/crates/renderer/src/renderer.rs +++ b/crates/renderer/src/renderer.rs @@ -10,41 +10,101 @@ use std::collections::HashMap; /// Opaque, renderer-side identifier for one uploaded chunk mesh. pub type MeshKey = (i32, i32, i32); -/// Selects how chunk meshes are rasterised. +/// One rasterisation pass over the visible chunk meshes. /// -/// Non-[`RenderMode::Filled`] variants are debug modes for inspecting the mesher's output; they are not gameplay state. One pipeline is built per variant at initialisation and held in [`Renderer::pipelines`], indexed by [`RenderMode::index`]. -#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)] -pub enum RenderMode { +/// 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. +/// +/// Adding a pass requires three compiler-checked edits: the variant, an entry in [`RasterPass::ALL`] (whose length is pinned to [`RasterPass::COUNT`]), and arms in the `match`es below. +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] +pub enum RasterPass { /// Filled triangles; the normal presentation path. - #[default] - Filled, + Fill, /// One point per polygon vertex, exposing the density of the geometry the mesher emitted. Points, + /// Triangle edges only, exposing the shape and size of the quads the mesher produced. + Wireframe, } -impl RenderMode { - /// Number of variants, and therefore the number of pipelines built at initialisation. - pub const COUNT: usize = 2; +impl RasterPass { + /// Number of passes, and therefore the number of pipelines built at initialisation. + pub const COUNT: usize = 3; - /// Every variant, in discriminant order. The array length is checked against [`RenderMode::COUNT`] at compile time, so a new variant that is not listed here fails to build. - pub const ALL: [Self; Self::COUNT] = [Self::Filled, Self::Points]; + /// Every pass, in discriminant order. The array length is checked against [`RasterPass::COUNT`] at compile time, so a new variant that is not listed here fails to build. + pub const ALL: [Self; Self::COUNT] = [Self::Fill, Self::Points, Self::Wireframe]; - /// Returns the rasterisation polygon mode backing this render mode. + /// Returns the rasterisation polygon mode backing this pass. #[must_use] pub const fn polygon_mode(self) -> vk::PolygonMode { match self { - Self::Filled => vk::PolygonMode::FILL, + Self::Fill => vk::PolygonMode::FILL, Self::Points => vk::PolygonMode::POINT, + Self::Wireframe => vk::PolygonMode::LINE, } } - /// Returns this mode's position in [`RenderMode::ALL`], used to index [`Renderer::pipelines`]. + /// Returns the depth-comparison used by this pass. + /// + /// Debug passes use `LESS_OR_EQUAL` so they survive being drawn over a filled pass that has already written the same depth values; a strict `LESS` would reject every overlaid fragment and render the overlay invisible. + #[must_use] + pub const fn depth_compare_op(self) -> vk::CompareOp { + match self { + Self::Fill => vk::CompareOp::LESS, + Self::Points | Self::Wireframe => vk::CompareOp::LESS_OR_EQUAL, + } + } + + /// Returns the debug-tint weight pushed to the vertex shader for this pass. + /// + /// Debug passes are tinted a uniform colour because geometry drawn in the terrain's own vertex colours is indistinguishable from the surface beneath it when overlaid. + #[must_use] + pub const fn tint(self) -> f32 { + match self { + Self::Fill => 0.0, + Self::Points | Self::Wireframe => 1.0, + } + } + + /// Returns this pass's position in [`RasterPass::ALL`], used to index [`Renderer::pipelines`]. #[must_use] pub const fn index(self) -> usize { self as usize } } +/// Selects how chunk meshes are presented, as an ordered list of [`RasterPass`]es. +/// +/// Non-[`RenderMode::Filled`] variants are debug modes for inspecting the mesher's output; they are not gameplay state. The `Filled*` variants draw the terrain normally and overlay a debug pass on top, which keeps the surface readable while showing where its geometry actually lies. +/// +/// Adding a mode is one variant plus one arm in [`RenderMode::passes`]; it needs a new [`RasterPass`] only if it requires rasterisation state that no existing pass provides. +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)] +pub enum RenderMode { + /// Filled triangles only; the normal presentation path. + #[default] + Filled, + /// Vertex points only, against the clear colour. + Points, + /// Triangle edges only, against the clear colour. + Wireframe, + /// Filled triangles with vertex points overlaid. + FilledPoints, + /// Filled triangles with triangle edges overlaid. + FilledWireframe, +} + +impl RenderMode { + /// Returns the passes to run, in submission order. Later passes are drawn over earlier ones. + #[must_use] + pub const fn passes(self) -> &'static [RasterPass] { + match self { + Self::Filled => &[RasterPass::Fill], + Self::Points => &[RasterPass::Points], + Self::Wireframe => &[RasterPass::Wireframe], + Self::FilledPoints => &[RasterPass::Fill, RasterPass::Points], + Self::FilledWireframe => &[RasterPass::Fill, RasterPass::Wireframe], + } + } +} + /// GPU resources for a single chunk mesh, drawn at a fixed world offset. pub(crate) struct GpuMesh { /// Buffer holding the chunk's vertex data. @@ -105,8 +165,8 @@ pub struct Renderer { pub(crate) command_buffers: Vec, /// The layout of the graphics pipeline. pub(crate) pipeline_layout: vk::PipelineLayout, - /// One compiled pipeline per [`RenderMode`], indexed by [`RenderMode::index`]. All variants share [`Renderer::pipeline_layout`] and differ only in rasterisation state. - pub(crate) pipelines: [vk::Pipeline; RenderMode::COUNT], + /// One compiled pipeline per [`RasterPass`], indexed by [`RasterPass::index`]. All variants share [`Renderer::pipeline_layout`] and differ only in rasterisation and depth-compare state. + pub(crate) pipelines: [vk::Pipeline; RasterPass::COUNT], /// The rasterisation mode selected for subsequent frames, chosen by [`Renderer::set_render_mode`]. pub(crate) render_mode: RenderMode, /// Memory manager for GPU allocations. @@ -422,13 +482,6 @@ impl Renderer { /// Issues the actual draw calls for the frame. fn issue_draw_calls(&self, cmd: vk::CommandBuffer, camera_view: glam::Mat4) { unsafe { - // The bound pipeline is the sole difference between render modes; every other command recorded below is mode-independent. - self.device.cmd_bind_pipeline( - cmd, - vk::PipelineBindPoint::GRAPHICS, - self.pipelines[self.render_mode.index()], - ); - #[expect( clippy::cast_precision_loss, reason = "swapchain extents are within f32's exact-integer range" @@ -475,9 +528,27 @@ impl Renderer { 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(); - // The MVP is identical for every chunk this frame, so it is pushed once before the loop. + 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. let mvp_bytes = bytemuck::cast_slice(mvp.as_ref()); self.device.cmd_push_constants( cmd, @@ -494,39 +565,41 @@ impl Renderer { )] let chunk_offset_byte = size_of::() as u32; - for mesh in self.chunk_meshes.values() { - // 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); - if !frustum.intersects_aabb(box_min, box_min + chunk_extent) { - culled += 1; - continue; - } - - // The offset is padded to a vec4 to match the std140 layout of the push-constant block; only xyz is read by the shader. - let offset = [ - mesh.world_offset[0], - mesh.world_offset[1], - mesh.world_offset[2], - 0.0_f32, - ]; - self.device.cmd_push_constants( + // Overlay modes submit the same geometry more than once, each pass binding a pipeline whose rasterisation state differs. Later passes draw over earlier ones. + for pass in self.render_mode.passes() { + self.device.cmd_bind_pipeline( cmd, - self.pipeline_layout, - vk::ShaderStageFlags::VERTEX, - chunk_offset_byte, - bytemuck::cast_slice(&offset), + vk::PipelineBindPoint::GRAPHICS, + self.pipelines[pass.index()], ); - self.device - .cmd_bind_vertex_buffers(cmd, 0, &[mesh.vertex_buffer], &[0]); - self.device - .cmd_bind_index_buffer(cmd, mesh.index_buffer, 0, vk::IndexType::UINT32); - self.device - .cmd_draw_indexed(cmd, mesh.index_count, 1, 0, 0, 0); - } + 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], + mesh.world_offset[1], + mesh.world_offset[2], + pass.tint(), + ]; + self.device.cmd_push_constants( + cmd, + self.pipeline_layout, + vk::ShaderStageFlags::VERTEX, + chunk_offset_byte, + bytemuck::cast_slice(&offset), + ); - if culled > 0 { - tracing::debug!(culled, "chunks skipped by frustum culling"); + self.device + .cmd_bind_vertex_buffers(cmd, 0, &[mesh.vertex_buffer], &[0]); + self.device.cmd_bind_index_buffer( + cmd, + mesh.index_buffer, + 0, + vk::IndexType::UINT32, + ); + self.device + .cmd_draw_indexed(cmd, mesh.index_count, 1, 0, 0, 0); + } } } }