From 10b035504ceadef189433a2837886cdee8af443f Mon Sep 17 00:00:00 2001 From: Serkyo Date: Wed, 5 Aug 2026 01:57:44 +0200 Subject: [PATCH] fix(renderer): extend the far plane to cover the fog reach The fixed 500-block far plane clipped geometry before fog saturated, making the clip plane itself the visible boundary at large view distances. --- crates/renderer/src/renderer.rs | 29 ++++++++++++++++----- crates/renderer/src/tests/renderer.rs | 37 +++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 6 deletions(-) create mode 100644 crates/renderer/src/tests/renderer.rs diff --git a/crates/renderer/src/renderer.rs b/crates/renderer/src/renderer.rs index f46bb70..3f12ba6 100644 --- a/crates/renderer/src/renderer.rs +++ b/crates/renderer/src/renderer.rs @@ -18,8 +18,10 @@ 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]; @@ -43,6 +45,17 @@ pub struct FrameParams { 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. /// /// 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. @@ -632,7 +645,7 @@ impl Renderer { /// Issues the actual draw calls for the frame, returning what was submitted. fn issue_draw_calls(&self, cmd: vk::CommandBuffer, frame: FrameParams) -> Submission { - let projection = self.projection_info(); + 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( @@ -651,8 +664,8 @@ impl Renderer { 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); @@ -664,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), } } @@ -1036,3 +1049,7 @@ impl Drop for Renderer { } } } + +#[cfg(test)] +#[path = "tests/renderer.rs"] +mod tests; 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); +}