diff --git a/assets/shaders/cube.frag b/assets/shaders/cube.frag index 91d2c3f..6fdc68d 100644 --- a/assets/shaders/cube.frag +++ b/assets/shaders/cube.frag @@ -1,9 +1,66 @@ #version 450 layout(location = 0) in vec3 frag_color; +layout(location = 1) in vec3 frag_normal; +layout(location = 2) in float frag_debug_tint; +layout(location = 3) in vec3 frag_world_position; layout(location = 0) out vec4 out_color; -void main () { - out_color = vec4(frag_color, 1.0); -} \ No newline at end of file +// The block is declared identically in cube.vert. A push-constant block is a single object shared by every stage of the pipeline, so the two declarations must agree exactly even where a stage reads only part of it. +layout(push_constant) uniform PushConstants { + mat4 mvp; + // xyz is the chunk's world offset; w is the debug-tint weight, 0.0 for normal rendering and 1.0 for a debug raster pass. + vec4 chunk_offset; + // xyz is the camera's world position; w is the horizontal distance at which fog reaches full opacity. + vec4 fog; + // rgb is the sky colour distant geometry fades into, matching the colour attachment's clear value; w is the vertical distance at which fog reaches full opacity. + vec4 sky_color; +} push_constants; + +// Direction the light travels, pointing downward and across both horizontal axes so that no cubic face orientation receives exactly the same amount of light as another. A light aligned with an axis would leave two of the three visible faces of a cube indistinguishable. +const vec3 LIGHT_DIRECTION = vec3(-0.4, -1.0, -0.3); + +// Fraction of the albedo retained by a fully unlit face, standing in for bounced light until a global-illumination term exists. Without it, faces turned away from the light collapse to black and their silhouettes disappear against one another. +const float AMBIENT = 0.25; + +// Colour applied to debug raster passes, chosen to contrast with terrain and to remain legible when overlaid on filled geometry. +const vec3 DEBUG_COLOR = vec3(1.0, 0.0, 1.0); + +// Fraction of the fog end distance at which the fade begins. Below it geometry is drawn unfogged, which keeps the fog out of the region the player is actually looking at while leaving enough depth for the ramp to read as gradual rather than as a band. +const float FOG_START_FRACTION = 0.6; + +// Floor on the width of the fade band, guarding the division below against a caller that supplies a fog end distance of zero. +const float MIN_FOG_RANGE = 1e-3; + +// Returns the fog opacity for a surface `distance` from the camera along one axis, given the distance at which that axis reaches full opacity. +// +// The ramp is linear rather than exponential. Exponential fog approaches full opacity asymptotically without ever reaching it, so geometry stays faintly visible right up to the moment its chunk is unloaded, which is the pop the fog exists to conceal. +float fog_ramp(float distance, float end) { + float start = end * FOG_START_FRACTION; + float range = max(end - start, MIN_FOG_RANGE); + return clamp((distance - start) / range, 0.0, 1.0); +} + +void main() { + // The interpolated normal is renormalised: the six face normals are unit length and constant across a quad, but interpolation across a triangle is not guaranteed to preserve that. + vec3 normal = normalize(frag_normal); + + // Lambertian term. The light vector is negated because LIGHT_DIRECTION points along the light's travel, whereas the dot product requires the direction from the surface toward the light. The clamp discards the negative half, where the face points away from the light. + float diffuse = max(dot(normal, normalize(-LIGHT_DIRECTION)), 0.0); + vec3 lit = frag_color * (AMBIENT + (1.0 - AMBIENT) * diffuse); + + // The debug tint is applied after shading so debug passes draw flat and stay legible over the shaded geometry beneath them. + vec3 shaded = mix(lit, DEBUG_COLOR, frag_debug_tint); + + // The horizontal and vertical extents of the streaming region are ramped independently, because the region is a cylinder rather than a sphere and therefore reaches one frontier well before the other. Fading both against a single distance leaves the nearer frontier unfogged and fully visible. + vec3 to_camera = frag_world_position - push_constants.fog.xyz; + float fog_horizontal = fog_ramp(length(to_camera.xz), push_constants.fog.w); + float fog_vertical = fog_ramp(abs(to_camera.y), push_constants.sky_color.a); + + // Whichever frontier the surface is closer to determines the fade, so geometry is fully obscured before it crosses either one. + float fog_factor = max(fog_horizontal, fog_vertical); + + // Fog is applied after the debug tint so an overlay recedes together with the geometry it annotates instead of punching through the fade. + out_color = vec4(mix(shaded, push_constants.sky_color.rgb, fog_factor), 1.0); +} diff --git a/assets/shaders/cube.vert b/assets/shaders/cube.vert index ecaf58f..511896b 100644 --- a/assets/shaders/cube.vert +++ b/assets/shaders/cube.vert @@ -3,21 +3,37 @@ layout(location = 0) in vec3 in_position; layout(location = 1) in vec3 in_color; +layout(location = 2) in uint in_face; layout(location = 0) out vec3 frag_color; +layout(location = 1) out vec3 frag_normal; +layout(location = 2) out float frag_debug_tint; +layout(location = 3) out vec3 frag_world_position; +// The block is declared identically in cube.frag. A push-constant block is a single object shared by every stage of the pipeline, so the two declarations must agree exactly even where a stage reads only part of it. layout(push_constant) uniform PushConstants { mat4 mvp; // xyz is the chunk's world offset; w is the debug-tint weight, 0.0 for normal rendering and 1.0 for a debug raster pass. vec4 chunk_offset; + // xyz is the camera's world position; w is the horizontal distance at which fog reaches full opacity. + vec4 fog; + // rgb is the sky colour distant geometry fades into, matching the colour attachment's clear value; w is the vertical distance at which fog reaches full opacity. + vec4 sky_color; } push_constants; -// Colour applied to debug raster passes, chosen to contrast with terrain and to remain legible when overlaid on filled geometry. -const vec3 DEBUG_COLOR = vec3(1.0, 0.0, 1.0); - // Size, in pixels, of the points emitted under VK_POLYGON_MODE_POINT. Sizes above 1.0 require the largePoints device feature. const float DEBUG_POINT_SIZE = 5.0; +// Outward normals of the six cubic face directions, indexed by the packed face attribute. +const vec3 FACE_NORMALS[6] = vec3[6]( + vec3( 1.0, 0.0, 0.0), + vec3(-1.0, 0.0, 0.0), + vec3( 0.0, 1.0, 0.0), + vec3( 0.0, -1.0, 0.0), + vec3( 0.0, 0.0, 1.0), + vec3( 0.0, 0.0, -1.0) +); + void main() { // The chunk-local vertex is shifted into world space by the per-chunk offset before projection. vec3 world_position = in_position + push_constants.chunk_offset.xyz; @@ -26,6 +42,14 @@ void main() { // Point size is consulted whenever the polygon mode is POINT; leaving it unwritten renders points of undefined size. It is ignored by the FILL and LINE pipelines, so it is written unconditionally. gl_PointSize = DEBUG_POINT_SIZE; - float debug_tint = push_constants.chunk_offset.w; - frag_color = mix(in_color, DEBUG_COLOR, debug_tint); + frag_color = in_color; + + // Chunk placement is a pure translation, so a chunk-local face normal is already a world-space normal and no normal matrix is required. + frag_normal = FACE_NORMALS[in_face]; + + // Shading and the debug tint both resolve in the fragment stage, so the weight is forwarded rather than applied here. + frag_debug_tint = push_constants.chunk_offset.w; + + // Forwarded for the fog term, which needs the distance from the camera to the shaded surface. Interpolating the world position is correct here because it is an affine function of the vertex positions. + frag_world_position = world_position; } diff --git a/crates/client/src/chunks.rs b/crates/client/src/chunks.rs index 4ac80c7..8f84532 100644 --- a/crates/client/src/chunks.rs +++ b/crates/client/src/chunks.rs @@ -15,7 +15,23 @@ use crate::mesh_pool::{JobGen, MeshJob, MeshPool, MeshResult}; /// Radius, in chunks, of the region kept resident around the camera center. Also the radius the client subscribes with, so the server's resident set matches the client's. // TODO: make configurable / drive from view-distance setting. -pub const LOAD_RADIUS: i32 = 8; +pub const LOAD_RADIUS: i32 = 16; + +/// Horizontal extent, in blocks, of the resident region around the camera. +#[expect( + clippy::cast_precision_loss, + reason = "the radius and chunk size are small compile-time constants, exact as f32" +)] +pub const LOAD_DISTANCE: f32 = LOAD_RADIUS as f32 * CHUNK_SIZE as f32; + +/// Vertical extent, in blocks, of the resident region around the camera. +/// +/// The streaming region is a cylinder half as tall as it is wide (see [`desired_chunks`]), so it reaches its vertical frontier at half the horizontal distance. Fading both extents against [`LOAD_DISTANCE`] leaves the cylinder's caps unfogged and their unloaded edge plainly visible from above or below, so the renderer ramps the two independently. The halving uses integer division to track [`desired_chunks`] exactly, including for odd radii. +#[expect( + clippy::cast_precision_loss, + reason = "the radius and chunk size are small compile-time constants, exact as f32" +)] +pub const LOAD_DISTANCE_VERTICAL: f32 = (LOAD_RADIUS / 2) as f32 * CHUNK_SIZE as f32; /// Maximum number of chunk deliveries materialized in a single call to [`ChunkManager::update`], bounding per-frame materialization work. Deliveries beyond the budget remain queued in the transport for the next frame. const LOADS_PER_UPDATE: usize = 4; diff --git a/crates/client/src/main.rs b/crates/client/src/main.rs index 497f153..820c957 100644 --- a/crates/client/src/main.rs +++ b/crates/client/src/main.rs @@ -232,8 +232,13 @@ impl App { self.report_statistics(frame, center, travelled); } - let view = self.camera.view_matrix(); - if let Some(Err(e)) = self.renderer.as_mut().map(|r| r.draw_frame(view)) { + let frame = renderer::FrameParams { + view: self.camera.view_matrix(), + camera_position: pos, + fog_end_horizontal: chunks::LOAD_DISTANCE, + fog_end_vertical: chunks::LOAD_DISTANCE_VERTICAL, + }; + if let Some(Err(e)) = self.renderer.as_mut().map(|r| r.draw_frame(frame)) { error!("Failed to draw frame: {e}"); event_loop.exit(); } diff --git a/crates/renderer/src/lib.rs b/crates/renderer/src/lib.rs index 7c85054..08d5503 100644 --- a/crates/renderer/src/lib.rs +++ b/crates/renderer/src/lib.rs @@ -29,7 +29,7 @@ use raw_window_handle::{RawDisplayHandle, RawWindowHandle}; use std::ffi::c_char; pub use error::RendererError; -pub use renderer::{MeshKey, RasterPass, RenderMode, Renderer}; +pub use renderer::{FrameParams, MeshKey, RasterPass, RenderMode, Renderer}; pub use stats::{GpuInfo, MemoryUsage, ProjectionInfo, RenderStats, SwapchainInfo}; use std::collections::HashMap; diff --git a/crates/renderer/src/meshing.rs b/crates/renderer/src/meshing.rs index 1744d14..25f8611 100644 --- a/crates/renderer/src/meshing.rs +++ b/crates/renderer/src/meshing.rs @@ -24,26 +24,33 @@ enum FaceDir { NegZ, } +impl FaceDir { + /// Returns the index identifying this direction's outward normal to the shader. + /// + /// The six values are a contract with the `FACE_NORMALS` table in `assets/shaders/cube.vert`, which is indexed by them directly. + const fn to_index(self) -> u32 { + match self { + Self::PosX => 0, + Self::NegX => 1, + Self::PosY => 2, + Self::NegY => 3, + Self::PosZ => 4, + Self::NegZ => 5, + } + } +} + /// Identifies whether two faces are mergeable. #[derive(Copy, Clone, PartialEq, Eq)] struct FaceKey { /// The material of the voxel owning the face. block: BlockId, - /// The face's signed axis direction, which selects its colour. + /// The face's signed axis direction, which selects its outward normal. dir: FaceDir, } -/// Returns the flat RGB colour for a face pointing in `dir`. -/// -/// The values reproduce the previous per-face emitter exactly so the rendered output is unchanged. -const fn color_of(dir: FaceDir) -> [f32; 3] { - match dir { - FaceDir::PosY => [0.2, 0.8, 0.2], - FaceDir::NegY => [0.1, 0.4, 0.1], - FaceDir::PosX | FaceDir::NegX => [0.15, 0.6, 0.15], - FaceDir::PosZ | FaceDir::NegZ => [0.18, 0.7, 0.18], - } -} +/// The flat RGB albedo emitted for every face. +const MATERIAL_COLOR: [f32; 3] = [0.2, 0.8, 0.2]; /// Converts a chunk-local integer coordinate to its floating-point value. #[expect( @@ -160,9 +167,9 @@ pub fn generate_mesh(chunk: &Chunk, neighbors: &Neighbors) -> (Vec, Vec< }) }, |y, x0, z0, w, h| { - let (xmin, xmax) = (coord(x0) - 0.5, coord(x0 + w) - 0.5); - let (zmin, zmax) = (coord(z0) - 0.5, coord(z0 + h) - 0.5); - let yp = coord(y) + 0.5; + let (xmin, xmax) = (coord(x0), coord(x0 + w)); + let (zmin, zmax) = (coord(z0), coord(z0 + h)); + let yp = coord(y + 1); [ [xmin, yp, zmax], [xmax, yp, zmax], @@ -187,9 +194,9 @@ pub fn generate_mesh(chunk: &Chunk, neighbors: &Neighbors) -> (Vec, Vec< }) }, |y, x0, z0, w, h| { - let (xmin, xmax) = (coord(x0) - 0.5, coord(x0 + w) - 0.5); - let (zmin, zmax) = (coord(z0) - 0.5, coord(z0 + h) - 0.5); - let yp = coord(y) - 0.5; + let (xmin, xmax) = (coord(x0), coord(x0 + w)); + let (zmin, zmax) = (coord(z0), coord(z0 + h)); + let yp = coord(y); [ [xmin, yp, zmin], [xmax, yp, zmin], @@ -214,9 +221,9 @@ pub fn generate_mesh(chunk: &Chunk, neighbors: &Neighbors) -> (Vec, Vec< }) }, |x, z0, y0, w, h| { - let (zmin, zmax) = (coord(z0) - 0.5, coord(z0 + w) - 0.5); - let (ymin, ymax) = (coord(y0) - 0.5, coord(y0 + h) - 0.5); - let xp = coord(x) + 0.5; + let (zmin, zmax) = (coord(z0), coord(z0 + w)); + let (ymin, ymax) = (coord(y0), coord(y0 + h)); + let xp = coord(x + 1); [ [xp, ymin, zmax], [xp, ymin, zmin], @@ -241,9 +248,9 @@ pub fn generate_mesh(chunk: &Chunk, neighbors: &Neighbors) -> (Vec, Vec< }) }, |x, z0, y0, w, h| { - let (zmin, zmax) = (coord(z0) - 0.5, coord(z0 + w) - 0.5); - let (ymin, ymax) = (coord(y0) - 0.5, coord(y0 + h) - 0.5); - let xp = coord(x) - 0.5; + let (zmin, zmax) = (coord(z0), coord(z0 + w)); + let (ymin, ymax) = (coord(y0), coord(y0 + h)); + let xp = coord(x); [ [xp, ymin, zmin], [xp, ymin, zmax], @@ -268,9 +275,9 @@ pub fn generate_mesh(chunk: &Chunk, neighbors: &Neighbors) -> (Vec, Vec< }) }, |z, x0, y0, w, h| { - let (xmin, xmax) = (coord(x0) - 0.5, coord(x0 + w) - 0.5); - let (ymin, ymax) = (coord(y0) - 0.5, coord(y0 + h) - 0.5); - let zp = coord(z) + 0.5; + let (xmin, xmax) = (coord(x0), coord(x0 + w)); + let (ymin, ymax) = (coord(y0), coord(y0 + h)); + let zp = coord(z + 1); [ [xmin, ymin, zp], [xmax, ymin, zp], @@ -295,9 +302,9 @@ pub fn generate_mesh(chunk: &Chunk, neighbors: &Neighbors) -> (Vec, Vec< }) }, |z, x0, y0, w, h| { - let (xmin, xmax) = (coord(x0) - 0.5, coord(x0 + w) - 0.5); - let (ymin, ymax) = (coord(y0) - 0.5, coord(y0 + h) - 0.5); - let zp = coord(z) - 0.5; + let (xmin, xmax) = (coord(x0), coord(x0 + w)); + let (ymin, ymax) = (coord(y0), coord(y0 + h)); + let zp = coord(z); [ [xmax, ymin, zp], [xmin, ymin, zp], @@ -332,7 +339,7 @@ fn run_pass( vertices, indices, corners(slice, u0, v0, w, h), - color_of(key.dir), + key.dir.to_index(), ); }); } @@ -380,14 +387,14 @@ fn merge_mask( } } -/// Appends one quad (four vertices, six indices) with the given corners and colour. +/// Appends one quad (four vertices, six indices) with the given corners and packed face normal. /// -/// Indices wind the two triangles as `[base, base+1, base+2, base+2, base+3, base]`, matching the corner ordering supplied by the caller. +/// A quad is planar, so all four vertices share `normal`. Indices wind the two triangles as `[base, base+1, base+2, base+2, base+3, base]`, matching the corner ordering supplied by the caller. fn push_quad( vertices: &mut Vec, indices: &mut Vec, corners: [[f32; 3]; 4], - color: [f32; 3], + normal: u32, ) { #[expect( clippy::cast_possible_truncation, @@ -395,7 +402,11 @@ fn push_quad( )] let base = vertices.len() as u32; for position in corners { - vertices.push(Vertex { position, color }); + vertices.push(Vertex { + position, + color: MATERIAL_COLOR, + normal, + }); } indices.extend_from_slice(&[base, base + 1, base + 2, base + 2, base + 3, base]); } diff --git a/crates/renderer/src/pipeline.rs b/crates/renderer/src/pipeline.rs index 9740815..0988ec8 100644 --- a/crates/renderer/src/pipeline.rs +++ b/crates/renderer/src/pipeline.rs @@ -28,6 +28,19 @@ pub fn create_shader_module( Ok(module) } +/// Size, in bytes, of one `vec4` slot of the push-constant block. +pub const VEC4_BYTES: u32 = 16; + +/// Number of `vec4` slots following the MVP matrix in the push-constant block: the per-chunk offset, the fog parameters, and the sky colour. +const PUSH_CONSTANT_VEC4S: u32 = 3; + +/// Shader stages that read the push-constant block. +/// +/// Both stages are declared across the entire range: the vertex stage consumes the MVP and the per-chunk offset, the fragment stage the fog and sky slots. Vulkan requires the `stage_flags` given to every `cmd_push_constants` call to cover exactly the stages the layout declares for the bytes being written, so the layout and every update read this one value rather than restating the flags. +pub const PUSH_CONSTANT_STAGES: vk::ShaderStageFlags = vk::ShaderStageFlags::from_raw( + vk::ShaderStageFlags::VERTEX.as_raw() | vk::ShaderStageFlags::FRAGMENT.as_raw(), +); + /// Defines the 'interface' of the pipeline (what data we can pass to the shaders). /// /// This layout defines any push constants or descriptor sets (textures/UBOs) accessed by the shaders during execution. @@ -36,16 +49,17 @@ pub fn create_shader_module( /// /// Returns [`RendererError::VulkanError`] if the device fails to create the pipeline layout. pub fn create_pipeline_layout(device: &Device) -> Result { - // The push-constant range covers the 64-byte MVP matrix followed by a 16-byte vec4 per-chunk world offset (80 bytes total, within the 128-byte guaranteed minimum). + // The push-constant range covers the 64-byte MVP matrix followed by three 16-byte vec4 slots (112 bytes total, within the 128-byte guaranteed minimum). #[expect( clippy::expect_used, - reason = "80 bytes (Mat4 + vec4) is well within u32 range" + reason = "112 bytes (Mat4 + three vec4s) is well within u32 range" )] let push_constant_range = vk::PushConstantRange::default() - .stage_flags(vk::ShaderStageFlags::VERTEX) + .stage_flags(PUSH_CONSTANT_STAGES) .offset(0) .size( - u32::try_from(std::mem::size_of::() + std::mem::size_of::<[f32; 4]>()) + u32::try_from(std::mem::size_of::()) + .map(|mvp| mvp + PUSH_CONSTANT_VEC4S * VEC4_BYTES) .expect("push-constant size exceeds u32 range"), ); diff --git a/crates/renderer/src/renderer.rs b/crates/renderer/src/renderer.rs index f344e11..cd870cc 100644 --- a/crates/renderer/src/renderer.rs +++ b/crates/renderer/src/renderer.rs @@ -1,5 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-only +use crate::pipeline::{PUSH_CONSTANT_STAGES, VEC4_BYTES}; use crate::stats::{GpuInfo, MemoryUsage, ProjectionInfo, RenderStats, SwapchainInfo}; use crate::sync::SyncPrimitives; use crate::{create_depth_resources, create_gpu_buffer, swapchain}; @@ -17,8 +18,43 @@ 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; +/// Lower bound on the distance to the far clip plane, in blocks. +/// +/// The far plane is extended past this whenever the frame's fog reaches further (see [`far_plane_for`]). The floor applies when the fog is nearer, and keeps the projection well-formed for a caller that supplies no fog distance at all. +const MIN_FAR_PLANE: f32 = 500.0; + +/// Linear RGB colour of the empty sky. +const SKY_COLOR: [f32; 3] = [0.1, 0.2, 0.4]; + +/// Per-frame parameters supplied by the caller to [`Renderer::draw_frame`]. +/// +/// The renderer owns the projection, which derives from the swapchain it manages; everything here is state only the caller knows. +#[derive(Copy, Clone, Debug)] +pub struct FrameParams { + /// Right-handed world-to-view matrix for this frame. + pub view: glam::Mat4, + /// World-space position of the camera eye, in blocks. Distance from this point drives the fog term. + pub camera_position: glam::Vec3, + /// Horizontal distance, in blocks, at which fog reaches full opacity. + /// + /// The caller derives this from its own streaming radius so that geometry has already faded out completely by the time the chunk holding it is unloaded, which is what keeps the unload from reading as a pop. + pub fog_end_horizontal: f32, + /// Vertical distance, in blocks, at which fog reaches full opacity. + /// + /// Supplied separately because a streaming region is not required to be a sphere. + pub fog_end_vertical: f32, +} + +/// Returns the far clip distance covering the fog reach implied by the two extents. +/// +/// The far plane must sit beyond every fragment the fog has not yet fully obscured, or the clip plane becomes the visible boundary and replaces the intended fade with a hard edge. Fog opacity saturates as soon as *either* axis passes its own end distance, so a fragment that is still partially visible lies strictly inside the box those two extents bound; the box's diagonal is therefore the furthest such a fragment can be, and covering it is exactly sufficient rather than merely conservative. +/// +/// [`MIN_FAR_PLANE`] applies as a floor, so a caller supplying no fog distance still receives a usable projection. +fn far_plane_for(fog_end_horizontal: f32, fog_end_vertical: f32) -> f32 { + fog_end_horizontal + .hypot(fog_end_vertical) + .max(MIN_FAR_PLANE) +} /// One rasterisation pass over the visible chunk meshes. /// @@ -237,7 +273,7 @@ impl Renderer { /// # Errors /// /// Returns [`RendererError::SyncPrimitivesMissing`] if the synchronization primitives have been torn down, or [`RendererError::VulkanError`] if any device operation (fence wait, image acquire, command recording, submit, or present) fails. - pub fn draw_frame(&mut self, camera_view: glam::Mat4) -> Result<(), RendererError> { + pub fn draw_frame(&mut self, frame: FrameParams) -> Result<(), RendererError> { let sync = self .sync .as_ref() @@ -296,7 +332,7 @@ impl Renderer { let view = self.swapchain_image_views[image_index as usize]; // 4. Record the actual rendering commands, retaining what they submitted. - let submission = self.record_commands(cmd, view, image, camera_view)?; + let submission = self.record_commands(cmd, view, image, frame)?; // 5. Submit the work to the GPU let submit_info = vk::SubmitInfo::default() @@ -511,7 +547,7 @@ impl Renderer { cmd: vk::CommandBuffer, view: vk::ImageView, image: vk::Image, - camera_view: glam::Mat4, + frame: FrameParams, ) -> Result { // Transition layouts for drawing self.transition_to_draw_layout(cmd, image); @@ -524,7 +560,7 @@ impl Renderer { .store_op(vk::AttachmentStoreOp::STORE) .clear_value(vk::ClearValue { color: vk::ClearColorValue { - float32: [0.1, 0.2, 0.4, 1.0], + float32: [SKY_COLOR[0], SKY_COLOR[1], SKY_COLOR[2], 1.0], }, }); @@ -552,7 +588,7 @@ impl Renderer { unsafe { self.device.cmd_begin_rendering(cmd, &rendering_info); } - let submission = self.issue_draw_calls(cmd, camera_view); + let submission = self.issue_draw_calls(cmd, frame); unsafe { self.device.cmd_end_rendering(cmd); } @@ -608,8 +644,8 @@ impl Renderer { } /// 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(); + fn issue_draw_calls(&self, cmd: vk::CommandBuffer, frame: FrameParams) -> Submission { + let projection = self.projection_info(frame); // 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( @@ -617,19 +653,19 @@ impl Renderer { projection.aspect, projection.near, projection.far, - ) * camera_view; + ) * frame.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); + self.record_passes(cmd, mvp, frame, &visible); submission } - /// Derives this frame's projection parameters from the swapchain extent. - fn projection_info(&self) -> ProjectionInfo { + /// Derives this frame's projection parameters from the swapchain extent and the frame's fog distances. + fn projection_info(&self, frame: FrameParams) -> ProjectionInfo { let aspect = f64::from(self.swapchain_extent.width) / f64::from(self.swapchain_extent.height); @@ -641,7 +677,7 @@ impl Renderer { fov_y_radians: FOV_Y_DEGREES.to_radians(), aspect: aspect as f32, near: NEAR_PLANE, - far: FAR_PLANE, + far: far_plane_for(frame.fog_end_horizontal, frame.fog_end_vertical), } } @@ -651,7 +687,7 @@ impl Renderer { 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. + // A chunk spans CHUNK_SIZE blocks on each axis. Block i spans [i, i+1), so a chunk's geometry runs [offset, offset + CHUNK_SIZE]. #[expect( clippy::cast_precision_loss, reason = "CHUNK_SIZE is 32, exactly representable as f32" @@ -664,7 +700,7 @@ impl Renderer { .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 box_min = glam::Vec3::from(mesh.world_offset); let visible = frustum.intersects_aabb(box_min, box_min + chunk_extent); if !visible { culled += 1; @@ -730,14 +766,20 @@ impl Renderer { } /// 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]) { + fn record_passes( + &self, + cmd: vk::CommandBuffer, + mvp: glam::Mat4, + frame: FrameParams, + 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( cmd, self.pipeline_layout, - vk::ShaderStageFlags::VERTEX, + PUSH_CONSTANT_STAGES, 0, mvp_bytes, ); @@ -749,6 +791,25 @@ impl Renderer { )] let chunk_offset_byte = size_of::() as u32; + // The fog and sky blocks are adjacent and both frame-constant, so the two vec4s are pushed together in a single command after the per-chunk offset slot. The two fog distances are packed into the spare `w` component of each slot rather than claiming a fourth vec4, which would take the block to exactly the 128-byte guaranteed minimum and leave no headroom. + let fog_and_sky = [ + frame.camera_position.x, + frame.camera_position.y, + frame.camera_position.z, + frame.fog_end_horizontal, + SKY_COLOR[0], + SKY_COLOR[1], + SKY_COLOR[2], + frame.fog_end_vertical, + ]; + self.device.cmd_push_constants( + cmd, + self.pipeline_layout, + PUSH_CONSTANT_STAGES, + chunk_offset_byte + VEC4_BYTES, + bytemuck::cast_slice(&fog_and_sky), + ); + // 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( @@ -768,7 +829,7 @@ impl Renderer { self.device.cmd_push_constants( cmd, self.pipeline_layout, - vk::ShaderStageFlags::VERTEX, + PUSH_CONSTANT_STAGES, chunk_offset_byte, bytemuck::cast_slice(&offset), ); @@ -988,3 +1049,7 @@ impl Drop for Renderer { } } } + +#[cfg(test)] +#[path = "tests/renderer.rs"] +mod tests; diff --git a/crates/renderer/src/tests/meshing.rs b/crates/renderer/src/tests/meshing.rs index 818861a..f799110 100644 --- a/crates/renderer/src/tests/meshing.rs +++ b/crates/renderer/src/tests/meshing.rs @@ -114,6 +114,50 @@ fn single_block_emits_six_quads() { assert_eq!(indices.len(), 36); } +#[test] +fn face_direction_indices_match_the_shader_normal_table() { + // Pins the numeric contract with the FACE_NORMALS table in assets/shaders/cube.vert, which is indexed by these values. Nothing else connects the two, and a silent reordering would mis-light every face rather than fail to build. + assert_eq!(FaceDir::PosX.to_index(), 0); + assert_eq!(FaceDir::NegX.to_index(), 1); + assert_eq!(FaceDir::PosY.to_index(), 2); + assert_eq!(FaceDir::NegY.to_index(), 3); + assert_eq!(FaceDir::PosZ.to_index(), 4); + assert_eq!(FaceDir::NegZ.to_index(), 5); +} + +#[test] +fn single_block_quads_carry_their_own_face_normal() { + let mut chunk = Chunk::default(); + chunk.set(5, 5, 5, BlockId(1)); + let (vertices, _) = generate_mesh(&chunk, &Neighbors::default()); + + // The constant axis and plane coordinate of each face of a block at (5, 5, 5), indexed by packed normal. Deducing the expected direction from the geometry rather than from the emission order keeps the assertion valid if the passes are reordered. + let expected: [(usize, f32); 6] = [(0, 6.0), (0, 5.0), (1, 6.0), (1, 5.0), (2, 6.0), (2, 5.0)]; + + let mut seen = [false; 6]; + for quad in vertices.chunks_exact(4) { + let normal = quad[0].normal; + assert!( + quad.iter().all(|v| v.normal == normal), + "a planar quad carries more than one normal index" + ); + + let (axis, plane) = expected[normal as usize]; + assert!( + quad.iter() + .all(|v| (v.position[axis] - plane).abs() < f32::EPSILON), + "the quad tagged with normal index {normal} does not lie on that face's plane" + ); + + seen[normal as usize] = true; + } + + assert!( + seen.iter().all(|&s| s), + "an isolated block must emit one quad per face direction" + ); +} + #[test] fn full_chunk_merges_each_face_into_one_quad() { let mut chunk = Chunk::default(); diff --git a/crates/renderer/src/tests/renderer.rs b/crates/renderer/src/tests/renderer.rs new file mode 100644 index 0000000..a9c2c1b --- /dev/null +++ b/crates/renderer/src/tests/renderer.rs @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +//! Unit tests for the pure helpers in [`crate::renderer`]. + +use super::*; + +#[test] +fn far_plane_floors_at_the_minimum_for_near_fog() { + // Fog that saturates well inside the minimum leaves the far plane at the floor; shrinking it to match would clip geometry for no gain. + assert!((far_plane_for(128.0, 64.0) - MIN_FAR_PLANE).abs() < f32::EPSILON); +} + +#[test] +fn far_plane_covers_the_diagonal_of_the_two_extents() { + // 768 horizontal and 384 vertical (a radius-24 cylinder) reach 858.6 at the corner, past the 500-block floor. + let far = far_plane_for(768.0, 384.0); + assert!(far > MIN_FAR_PLANE); + assert!( + (far - 858.65_f32).abs() < 0.01, + "unexpected far plane {far}" + ); +} + +#[test] +fn far_plane_reaches_past_each_extent_taken_alone() { + // The corner of the box is further than either edge, so covering only the larger extent would still clip partially-visible fragments near the diagonal. + let (horizontal, vertical) = (768.0_f32, 384.0_f32); + let far = far_plane_for(horizontal, vertical); + assert!(far > horizontal); + assert!(far > vertical); +} + +#[test] +fn far_plane_is_well_formed_without_fog() { + // A caller that supplies no fog distance must still receive a usable projection rather than a degenerate zero-depth one. + assert!(far_plane_for(0.0, 0.0) > 0.0); +} diff --git a/crates/renderer/src/vertex.rs b/crates/renderer/src/vertex.rs index d2e0011..645b4cb 100644 --- a/crates/renderer/src/vertex.rs +++ b/crates/renderer/src/vertex.rs @@ -4,10 +4,10 @@ use bytemuck::{Pod, Zeroable}; -/// Represents a single vertex in 3D space with position and texture coordinates. +/// Represents a single vertex in 3D space with position, colour, and face orientation. /// /// Uses `repr(C)` to ensure the memory layout matches what the GPU expects (no Rust-specific reordering). -/// `Pod` and `Zeroable` allows safely casting this struct to a raw byte slice. +/// `Pod` and `Zeroable` allows safely casting this struct to a raw byte slice. Every field is 4-byte aligned and the struct is 28 bytes, so no implicit padding exists for `Pod` to expose. #[repr(C)] #[derive(Copy, Clone, Debug, PartialEq, Pod, Zeroable)] pub struct Vertex { @@ -15,6 +15,10 @@ pub struct Vertex { pub position: [f32; 3], /// The RGB color of the vertex [r, g, b]. pub color: [f32; 3], + /// Index of the face's outward normal into the shader's normal table. + /// + /// Cubic geometry admits only six distinct normals, so the direction is packed as an index rather than a `vec3`, saving 8 bytes per vertex. The vertex shader decodes it; the index ordering is defined by `FaceDir::to_index` in `meshing.rs` and must stay in step with the `FACE_NORMALS` table in `cube.vert`. + pub normal: u32, } impl Vertex { @@ -40,7 +44,7 @@ impl Vertex { /// Describes the layout of individual fields (attributes) within a single vertex. /// /// These 'locations' must match the `layout(location = X)` qualifiers in the vertex shader. - pub fn get_attribute_descriptions() -> [ash::vk::VertexInputAttributeDescription; 2] { + pub fn get_attribute_descriptions() -> [ash::vk::VertexInputAttributeDescription; 3] { [ // Location 0: position (vec3 -> R32G32B32_SFLOAT) ash::vk::VertexInputAttributeDescription::default() @@ -53,6 +57,12 @@ impl Vertex { .location(1) .format(ash::vk::Format::R32G32B32_SFLOAT) .offset(12), + // Location 2: packed face normal index (uint -> R32_UINT). The shader input must be declared `uint`; reading an integer-formatted attribute through a float declaration is undefined and silently produces garbage on some drivers. + ash::vk::VertexInputAttributeDescription::default() + .binding(0) + .location(2) + .format(ash::vk::Format::R32_UINT) + .offset(24), ] } } diff --git a/crates/server/src/client_stream.rs b/crates/server/src/client_stream.rs index 48fce0e..e029b57 100644 --- a/crates/server/src/client_stream.rs +++ b/crates/server/src/client_stream.rs @@ -13,7 +13,7 @@ use crate::world_server::{ServerWorld, cylinder_chunks}; /// Upper bound, in chunks, on a client's requested load radius. A larger request is clamped to this, bounding the per-client resident set and the reconcile cost the server performs on the client's behalf. // TODO: derive from server configuration and per-tier LOD limits. -pub const SERVER_MAX_RADIUS: u16 = 12; +pub const SERVER_MAX_RADIUS: u16 = 24; /// Worldgen version stamped on delivered chunk diffs. A single version exists today; this becomes the chunk's stored version once worldgen versioning lands. const WORLDGEN_VERSION: u32 = 0; diff --git a/docs/meshing.md b/docs/meshing.md index 81b3174..7b1b336 100644 --- a/docs/meshing.md +++ b/docs/meshing.md @@ -26,9 +26,9 @@ The half-scale voxel grid ([ADR-0002](adr/0002-half-scale-voxel-grid.md)) makes ### Vertex extents: a shared convention -The mesher centres block `i` on the interval `[i - 0.5, i + 0.5]`, hence the `± 0.5` offsets throughout the quad emitters. A chunk's geometry therefore spans `[offset - 0.5, offset + CHUNK_SIZE - 0.5]`, **not** `[offset, offset + CHUNK_SIZE]`. +The mesher places block `i` on the interval `[i, i + 1)`: its near face sits at `coord(i)` and its far face at `coord(i + 1)`. This matches the `floor()`-based coordinate-to-block mapping used by the rest of the engine (`position.floor()` yields the block index), so a raycast or cursor highlight that floors a hit point resolves to the same cell the mesher drew. -That half-block shift is duplicated in the frustum cull, which builds each chunk's bounding box from the same shifted minimum corner. Nothing in the type system ties the two together: if the mesher's extents ever change, the cull's box must change with them, or chunks will be culled while still partially on screen (or drawn while fully off it). The coupling is noted at both sites; treat it as an invariant of this file pair. +A chunk's geometry therefore spans `[offset, offset + CHUNK_SIZE]`. The frustum cull builds each chunk's bounding box from the same origin; if the mesher's extents ever change, the cull's box must change with them, or chunks will be culled while still partially on screen (or drawn while fully off it). The coupling is noted at both sites; treat it as an invariant of this file pair. ## Neighbour-aware boundary culling