feat(renderer): fade distant geometry into the sky colour
This commit is contained in:
parent
d68cb966f7
commit
51b6b1e5e8
|
|
@ -3,9 +3,21 @@
|
|||
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;
|
||||
|
||||
// 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);
|
||||
|
||||
|
|
@ -15,6 +27,21 @@ 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);
|
||||
|
|
@ -24,5 +51,16 @@ void main() {
|
|||
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.
|
||||
out_color = vec4(mix(lit, DEBUG_COLOR, frag_debug_tint), 1.0);
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,11 +8,17 @@ 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;
|
||||
|
||||
// Size, in pixels, of the points emitted under VK_POLYGON_MODE_POINT. Sizes above 1.0 require the largePoints device feature.
|
||||
|
|
@ -43,4 +49,7 @@ void main() {
|
|||
|
||||
// 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = 12;
|
||||
|
||||
/// 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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<vk::PipelineLayout, RendererError> {
|
||||
// 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::<glam::Mat4>() + std::mem::size_of::<[f32; 4]>())
|
||||
u32::try_from(std::mem::size_of::<glam::Mat4>())
|
||||
.map(|mvp| mvp + PUSH_CONSTANT_VEC4S * VEC4_BYTES)
|
||||
.expect("push-constant size exceeds u32 range"),
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
@ -20,6 +21,28 @@ const NEAR_PLANE: f32 = 0.1;
|
|||
/// Distance to the far clip plane, in blocks.
|
||||
const 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,
|
||||
}
|
||||
|
||||
/// 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.
|
||||
|
|
@ -237,7 +260,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 +319,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 +534,7 @@ impl Renderer {
|
|||
cmd: vk::CommandBuffer,
|
||||
view: vk::ImageView,
|
||||
image: vk::Image,
|
||||
camera_view: glam::Mat4,
|
||||
frame: FrameParams,
|
||||
) -> Result<Submission, RendererError> {
|
||||
// Transition layouts for drawing
|
||||
self.transition_to_draw_layout(cmd, image);
|
||||
|
|
@ -524,7 +547,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 +575,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,7 +631,7 @@ 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 {
|
||||
fn issue_draw_calls(&self, cmd: vk::CommandBuffer, frame: FrameParams) -> 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.
|
||||
|
|
@ -617,13 +640,13 @@ 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
|
||||
}
|
||||
|
|
@ -730,14 +753,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 +778,25 @@ impl Renderer {
|
|||
)]
|
||||
let chunk_offset_byte = size_of::<glam::Mat4>() 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 +816,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),
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue