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.
This commit is contained in:
Serkyo 2026-08-05 01:57:44 +02:00
parent 51b6b1e5e8
commit 10b035504c
2 changed files with 60 additions and 6 deletions

View file

@ -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;

View file

@ -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);
}