From a20d98a10707a139d3ed1616c47dc6f0f4731668 Mon Sep 17 00:00:00 2001 From: Serkyo Date: Wed, 22 Jul 2026 05:00:27 +0200 Subject: [PATCH 01/20] feat(renderer): cull chunk meshes outside the view frustum --- Cargo.lock | 1 + crates/renderer/Cargo.toml | 1 + crates/renderer/src/frustum.rs | 101 ++++++++++++++++++++++++++++++++ crates/renderer/src/lib.rs | 1 + crates/renderer/src/renderer.rs | 24 +++++++- 5 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 crates/renderer/src/frustum.rs diff --git a/Cargo.lock b/Cargo.lock index 5dbbf69..ac4e2a2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2142,6 +2142,7 @@ dependencies = [ "glam 0.33.2", "gpu-allocator", "raw-window-handle", + "shared", "thiserror 2.0.18", "tracing", ] diff --git a/crates/renderer/Cargo.toml b/crates/renderer/Cargo.toml index 6c3904b..9e31cf1 100644 --- a/crates/renderer/Cargo.toml +++ b/crates/renderer/Cargo.toml @@ -17,3 +17,4 @@ tracing.workspace = true gpu-allocator = "0.28.0" bytemuck.workspace = true glam.workspace = true +shared = { path = "../shared" } diff --git a/crates/renderer/src/frustum.rs b/crates/renderer/src/frustum.rs new file mode 100644 index 0000000..d50083c --- /dev/null +++ b/crates/renderer/src/frustum.rs @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +//! CPU-side view-frustum culling for chunk meshes. + +use glam::{Mat4, Vec3, Vec4}; + +/// Six view-frustum planes in world space. +/// +/// Each plane is stored as a [`Vec4`] `(a, b, c, d)` where `(a, b, c)` is the inward-facing normal and the plane equation is `a·x + b·y + c·z + d = 0`. The planes are normalised, so evaluating the equation at a point yields the signed distance from that point to the plane; a non-negative result lies on the interior side. +pub(crate) struct Frustum { + /// The six planes in the order left, right, bottom, top, near, far. + planes: [Vec4; 6], +} + +impl Frustum { + /// Builds the frustum from a combined view-projection matrix. + /// + /// The matrix is expected to map world space into Vulkan clip space, whose depth range is `[0, 1]`. Under that convention the near plane is the third matrix row alone (`r2`), not `r3 + r2` as in the OpenGL `[-1, 1]` range; the OpenGL form would cull geometry directly ahead of the camera. Each plane is normalised by the length of its `(a, b, c)` normal so subsequent evaluations return true signed distances. + pub(crate) fn from_view_proj(mvp: Mat4) -> Self { + // glam stores matrices column-major; the Gribb–Hartmann derivation operates on the rows of the combined matrix, so rows are read here rather than columns. + let r0 = mvp.row(0); + let r1 = mvp.row(1); + let r2 = mvp.row(2); + let r3 = mvp.row(3); + + let mut planes = [ + r3 + r0, // left + r3 - r0, // right + r3 + r1, // bottom + r3 - r1, // top + r2, // near (Vulkan depth range [0, 1], hence r2 alone) + r3 - r2, // far + ]; + + for plane in &mut planes { + let normal_length = plane.truncate().length(); + *plane /= normal_length; + } + + Self { planes } + } + + /// Returns whether the axis-aligned box spanning `[min, max]` is at least partially inside the frustum. + pub(crate) fn intersects_aabb(&self, min: Vec3, max: Vec3) -> bool { + for plane in &self.planes { + let normal = plane.truncate(); + + // The "positive vertex" is the box corner farthest along the plane normal: per axis the max component is taken when the normal's component is non-negative, otherwise the min. If even that corner lies behind the plane, the whole box does. + let positive_vertex = Vec3::new( + if normal.x >= 0.0 { max.x } else { min.x }, + if normal.y >= 0.0 { max.y } else { min.y }, + if normal.z >= 0.0 { max.z } else { min.z }, + ); + + if plane.dot(positive_vertex.extend(1.0)) < 0.0 { + return false; + } + } + + true + } +} + +#[cfg(test)] +mod tests { + use super::Frustum; + use glam::Vec3; + + /// Builds a frustum for a camera at the origin looking down the -Z axis, matching the engine's right-handed Vulkan-clip projection. + fn forward_facing_frustum() -> Frustum { + let proj = glam::camera::rh::proj::vulkan::perspective(60f32.to_radians(), 1.0, 0.1, 100.0); + let view = glam::camera::rh::view::look_at_mat4(Vec3::ZERO, Vec3::NEG_Z, Vec3::Y); + Frustum::from_view_proj(proj * view) + } + + #[test] + fn box_in_front_is_visible() { + let frustum = forward_facing_frustum(); + assert!(frustum.intersects_aabb(Vec3::new(-1.0, -1.0, -6.0), Vec3::new(1.0, 1.0, -4.0))); + } + + #[test] + fn box_behind_camera_is_culled() { + // A box entirely behind the camera. This is the case that fails if the near plane is extracted with the OpenGL `r3 + r2` formula instead of `r2`. + let frustum = forward_facing_frustum(); + assert!(!frustum.intersects_aabb(Vec3::new(-1.0, -1.0, 4.0), Vec3::new(1.0, 1.0, 6.0))); + } + + #[test] + fn box_far_to_the_side_is_culled() { + // Well outside the horizontal field of view at an otherwise valid depth. + let frustum = forward_facing_frustum(); + assert!(!frustum.intersects_aabb(Vec3::new(50.0, -1.0, -5.0), Vec3::new(52.0, 1.0, -4.0))); + } + + #[test] + fn huge_box_straddling_origin_is_visible() { + let frustum = forward_facing_frustum(); + assert!(frustum.intersects_aabb(Vec3::splat(-100.0), Vec3::splat(100.0))); + } +} diff --git a/crates/renderer/src/lib.rs b/crates/renderer/src/lib.rs index d9b0b2b..7fe6dea 100644 --- a/crates/renderer/src/lib.rs +++ b/crates/renderer/src/lib.rs @@ -9,6 +9,7 @@ mod device; pub mod error; +mod frustum; mod instance; pub mod mesh; mod pipeline; diff --git a/crates/renderer/src/renderer.rs b/crates/renderer/src/renderer.rs index ba75418..380c2f2 100644 --- a/crates/renderer/src/renderer.rs +++ b/crates/renderer/src/renderer.rs @@ -2,7 +2,7 @@ use crate::sync::SyncPrimitives; use crate::{create_depth_resources, create_gpu_buffer, swapchain}; -use crate::{error::RendererError, mesh::Vertex}; +use crate::{error::RendererError, frustum::Frustum, mesh::Vertex}; use ash::{Device, Instance, khr, vk}; use gpu_allocator::vulkan::{Allocation, Allocator}; use std::collections::HashMap; @@ -428,6 +428,17 @@ impl Renderer { // 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 = projection * camera_view; + // The view frustum is derived from the same matrix and reused to reject chunks whose bounding box lies entirely outside the view before any draw work is recorded. + 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. + #[expect( + clippy::cast_precision_loss, + reason = "CHUNK_SIZE is 32, exactly representable as f32" + )] + let chunk_extent = glam::Vec3::splat(shared::world::CHUNK_SIZE as f32); + let mut culled: u32 = 0; + // The MVP is identical for every chunk this frame, so it is pushed once before the loop. let mvp_bytes = bytemuck::cast_slice(mvp.as_ref()); self.device.cmd_push_constants( @@ -446,6 +457,13 @@ 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], @@ -468,6 +486,10 @@ impl Renderer { self.device .cmd_draw_indexed(cmd, mesh.index_count, 1, 0, 0, 0); } + + if culled > 0 { + tracing::debug!(culled, "chunks skipped by frustum culling"); + } } } From f465cfeeaf8f8ec2976174694216b9b7eed254e2 Mon Sep 17 00:00:00 2001 From: Serkyo Date: Wed, 22 Jul 2026 05:19:22 +0200 Subject: [PATCH 02/20] fix(net): bound chunk delivery channel to apply backpressure --- crates/client/src/chunks.rs | 2 +- crates/client/src/main.rs | 4 ++-- crates/net/src/chunk.rs | 8 ++++---- crates/net/src/runtime.rs | 12 +++++++++--- 4 files changed, 16 insertions(+), 10 deletions(-) diff --git a/crates/client/src/chunks.rs b/crates/client/src/chunks.rs index 962ffeb..c20cffd 100644 --- a/crates/client/src/chunks.rs +++ b/crates/client/src/chunks.rs @@ -42,7 +42,7 @@ impl ChunkManager { pub fn update( &mut self, center: ChunkPos, - deliveries: &net::ChunkStream, + deliveries: &mut net::ChunkStream, renderer: &mut renderer::Renderer, ) { let unloaded = self.unload_outside(center, renderer); diff --git a/crates/client/src/main.rs b/crates/client/src/main.rs index 6f51997..44cb910 100644 --- a/crates/client/src/main.rs +++ b/crates/client/src/main.rs @@ -262,10 +262,10 @@ impl ApplicationHandler for App { // Apply queued server deliveries and reconcile the resident set against the camera. if let (Some(chunks), Some(link), Some(renderer)) = ( self.chunks.as_mut(), - self.link.as_ref(), + self.link.as_mut(), self.renderer.as_mut(), ) { - chunks.update(center, &link.chunks, renderer); + chunks.update(center, &mut link.chunks, renderer); } let view = self.camera.view_matrix(); diff --git a/crates/net/src/chunk.rs b/crates/net/src/chunk.rs index a2598e0..dea63b9 100644 --- a/crates/net/src/chunk.rs +++ b/crates/net/src/chunk.rs @@ -7,7 +7,7 @@ //! The two channels crossing the async/sync boundary run in opposite directions and therefore use different primitives. Inbound (`ChunkSubscribe` arriving async, consumed by the sync loop) reuses the crossbeam [`ServerEvent`] channel, whose sender is non-blocking. Outbound (a `ChunkMessage` produced by the sync loop, consumed async) uses a `tokio` unbounded MPSC: its `send` is synchronous, so the non-async simulation thread can push without a runtime, while the receiver's `recv().await` composes into the pump's `select!`. A blocking `crossbeam` receiver would instead freeze the current-thread runtime and cannot appear in a `select!` arm. use shared::protocol::chunk::{ChunkMessage, ChunkSubscribe}; -use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender}; +use tokio::sync::mpsc::{Sender, UnboundedReceiver, UnboundedSender}; use tracing::{debug, warn}; use crate::codec::{MAX_CHUNK_FRAME_LEN, read_frame, write_frame}; @@ -125,7 +125,7 @@ impl ChunkSubscriber { pub(crate) async fn client_chunk_task( connection: quinn::Connection, mut subscribe: UnboundedReceiver, - deliveries: crossbeam_channel::Sender, + deliveries: Sender, ) { // The client opens the chunk stream after the handshake; the server accepts it, mirroring the control-stream convention. let (mut send, mut recv) = match connection.open_bi().await { @@ -155,8 +155,8 @@ pub(crate) async fn client_chunk_task( frame = read_frame::(&mut recv, MAX_CHUNK_FRAME_LEN) => { match frame { Ok(message) => { - // A closed delivery receiver means the UI is gone; nothing more to do. - if deliveries.send(message).is_err() { + // `send` awaits when the delivery channel is full: the task suspends (yielding the runtime thread so the connection keeps ACKing) until the UI drains a slot, and until then reads no further frames, which backpressures the server via QUIC stream flow control. An error means the UI dropped its receiver, so the session ends. + if deliveries.send(message).await.is_err() { break; } } diff --git a/crates/net/src/runtime.rs b/crates/net/src/runtime.rs index 27b21c2..a81166a 100644 --- a/crates/net/src/runtime.rs +++ b/crates/net/src/runtime.rs @@ -17,8 +17,12 @@ use crate::handshake::{ServerConnection, accept_connection, connect}; /// Channel receiver delivering the outcome of a background client connect: the negotiated [`HandshakeAck`] on success, or a human-readable error string on failure. pub type ConnectOutcome = crossbeam_channel::Receiver>; -/// Non-blocking receiver of chunks delivered by the server, drained by the UI thread with `try_recv`. -pub type ChunkStream = crossbeam_channel::Receiver; +/// Bounded receiver of chunks delivered by the server, drained by the UI thread with `try_recv`. +pub type ChunkStream = tokio::sync::mpsc::Receiver; + +/// Capacity of the client's chunk-delivery channel, in [`ChunkMessage`]s. +// TODO: revisit once meshing moves to a worker pool; the right depth follows the UI's consume rate, so this is a candidate to derive from the meshing budget / view distance in a config layer rather than a hand-set constant. +const CHUNK_DELIVERY_CAPACITY: usize = 32; /// Handles a background client connection exposes to the synchronous UI thread. /// @@ -255,7 +259,9 @@ pub fn connect_in_background(server_addr: SocketAddr, hello: ClientHello) -> Cli let spawn_err_tx = outcome_tx.clone(); // Subscription updates flow UI -> network (sync send, async recv); chunk deliveries flow network -> UI (async send, sync try_recv). let (subscribe_tx, subscribe_rx) = tokio::sync::mpsc::unbounded_channel::(); - let (chunks_tx, chunks_rx) = crossbeam_channel::unbounded::(); + // The delivery channel is bounded so a slow (e.g. debug-build) UI thread applies backpressure to the network task instead of letting undelivered chunks accumulate without limit. + let (chunks_tx, chunks_rx) = + tokio::sync::mpsc::channel::(CHUNK_DELIVERY_CAPACITY); let spawned = thread::Builder::new() .name("net-client".to_owned()) From 20d36624c7470e70c240ff28fabe6fb60576aeab Mon Sep 17 00:00:00 2001 From: Serkyo Date: Wed, 22 Jul 2026 05:27:01 +0200 Subject: [PATCH 03/20] fix(net): enable QUIC keep-alive to survive backpressure stalls --- crates/net/src/endpoint.rs | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/crates/net/src/endpoint.rs b/crates/net/src/endpoint.rs index 2ef792b..479156d 100644 --- a/crates/net/src/endpoint.rs +++ b/crates/net/src/endpoint.rs @@ -6,9 +6,10 @@ use std::net::SocketAddr; use std::sync::Arc; +use std::time::Duration; use quinn::crypto::rustls::{QuicClientConfig, QuicServerConfig}; -use quinn::{ClientConfig, Endpoint, ServerConfig}; +use quinn::{ClientConfig, Endpoint, IdleTimeout, ServerConfig, TransportConfig, VarInt}; use rustls::DigitallySignedStruct; use rustls::SignatureScheme; use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; @@ -20,6 +21,29 @@ use crate::error::NetError; /// The Application-Layer Protocol Negotiation identifier for the Synvael protocol. pub const ALPN: &[u8] = b"synvael"; +/// Interval between QUIC keep-alive probes, in milliseconds. +/// +/// Kept well below [`MAX_IDLE_TIMEOUT_MS`] so several probes elapse before the idle timeout could fire. Keep-alives are required because chunk delivery deliberately stalls the stream when the client cannot mesh fast enough: during such a flow-control stall no application data flows in either direction, and without a probe the connection would be indistinguishable from a dead peer and closed on the idle timeout. +const KEEP_ALIVE_INTERVAL_MS: u32 = 5_000; + +/// Maximum time with no received packets before a connection is considered lost, in milliseconds. +const MAX_IDLE_TIMEOUT_MS: u32 = 30_000; + +/// Builds the QUIC transport configuration shared by both endpoints. +/// +/// Enables keep-alive probes and sets an explicit idle timeout; see [`KEEP_ALIVE_INTERVAL_MS`] for why probes are mandatory given the chunk stream's backpressure behaviour. All other transport parameters retain their `quinn` defaults. +fn transport_config() -> Arc { + let mut transport = TransportConfig::default(); + transport.keep_alive_interval(Some(Duration::from_millis(u64::from( + KEEP_ALIVE_INTERVAL_MS, + )))); + // `VarInt::from_u32` is infallible, so no fallible `IdleTimeout::try_from(Duration)` conversion is needed. + transport.max_idle_timeout(Some(IdleTimeout::from(VarInt::from_u32( + MAX_IDLE_TIMEOUT_MS, + )))); + Arc::new(transport) +} + /// Installs the process-wide default `rustls` `CryptoProvider` if one is not already installed. fn ensure_crypto_provider() { if rustls::crypto::ring::default_provider() @@ -49,7 +73,8 @@ pub fn server_endpoint(bind: SocketAddr) -> Result { tls_config.alpn_protocols = vec![ALPN.to_vec()]; let quic_config = QuicServerConfig::try_from(tls_config)?; - let server_config = ServerConfig::with_crypto(Arc::new(quic_config)); + let mut server_config = ServerConfig::with_crypto(Arc::new(quic_config)); + server_config.transport_config(transport_config()); Ok(Endpoint::server(server_config, bind)?) } @@ -69,7 +94,8 @@ pub fn client_endpoint() -> Result { tls_config.alpn_protocols = vec![ALPN.to_vec()]; let quic_config = QuicClientConfig::try_from(tls_config)?; - let client_config = ClientConfig::new(Arc::new(quic_config)); + let mut client_config = ClientConfig::new(Arc::new(quic_config)); + client_config.transport_config(transport_config()); let mut endpoint = Endpoint::client("0.0.0.0:0".parse().map_err(std::io::Error::other)?)?; endpoint.set_default_client_config(client_config); From bb4e591a0923eebfe8229e5c53cb300ee6f6a129 Mon Sep 17 00:00:00 2001 From: Serkyo Date: Wed, 22 Jul 2026 14:45:41 +0200 Subject: [PATCH 04/20] fix(net): split chunk stream reader and writer to avoid frame desync --- crates/net/src/chunk.rs | 118 ++++++++++++++++++++-------------------- 1 file changed, 58 insertions(+), 60 deletions(-) diff --git a/crates/net/src/chunk.rs b/crates/net/src/chunk.rs index dea63b9..8ab581f 100644 --- a/crates/net/src/chunk.rs +++ b/crates/net/src/chunk.rs @@ -1,10 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-only //! Chunk-stream transport: the per-connection task that pumps chunk subscriptions and deliveries. -//! -//! After the handshake, each connection carries a dedicated bidirectional QUIC stream for chunk sync (the canonical `StreamLayout::chunk_lod0` id). The client writes [`ChunkSubscribe`] requests on it and the server writes [`ChunkMessage`] deliveries back on the same stream. This module owns the server-side pump: a single [`tokio::select`] loop that reads subscriptions off the stream and forwards them to the synchronous simulation loop, while draining outbound [`ChunkMessage`]s handed to it by that loop. -//! -//! The two channels crossing the async/sync boundary run in opposite directions and therefore use different primitives. Inbound (`ChunkSubscribe` arriving async, consumed by the sync loop) reuses the crossbeam [`ServerEvent`] channel, whose sender is non-blocking. Outbound (a `ChunkMessage` produced by the sync loop, consumed async) uses a `tokio` unbounded MPSC: its `send` is synchronous, so the non-async simulation thread can push without a runtime, while the receiver's `recv().await` composes into the pump's `select!`. A blocking `crossbeam` receiver would instead freeze the current-thread runtime and cannot appear in a `select!` arm. use shared::protocol::chunk::{ChunkMessage, ChunkSubscribe}; use tokio::sync::mpsc::{Sender, UnboundedReceiver, UnboundedSender}; @@ -56,41 +52,42 @@ pub(crate) async fn chunk_stream_task( } }; - loop { - tokio::select! { - // A subscription frame arrived from the client. - frame = read_frame::(&mut recv, MAX_CHUNK_FRAME_LEN) => { - match frame { - Ok(request) => { - // A closed events receiver means the simulation loop is gone; nothing more to do. - if events - .send(ServerEvent::ChunkSubscribe { id, request }) - .is_err() - { - break; - } - } - Err(error) => { - // A read error is the normal end of a client session (stream finished or reset). - debug!(%error, id, "chunk stream read ended"); + // Inbound subscriptions are read on their own future so `read_frame` is never cancelled mid-frame by an outbound write becoming ready. + let reader = async { + loop { + match read_frame::(&mut recv, MAX_CHUNK_FRAME_LEN).await { + Ok(request) => { + // A closed events receiver means the simulation loop is gone; nothing more to do. + if events + .send(ServerEvent::ChunkSubscribe { id, request }) + .is_err() + { break; } } - } - // The simulation loop handed back a chunk to deliver. - msg = outbound.recv() => { - match msg { - Some(message) => { - if let Err(error) = write_frame(&mut send, &message).await { - warn!(%error, id, "failed to write chunk frame; ending chunk stream"); - break; - } - } - // The sink was dropped: the connection is being torn down. - None => break, + Err(error) => { + // A read error is the normal end of a client session (stream finished or reset). + debug!(%error, id, "chunk stream read ended"); + break; } } } + }; + + // Outbound chunks handed back by the simulation loop are written on their own future. The loop ends when the sink is dropped (the connection is being torn down). + let writer = async { + while let Some(message) = outbound.recv().await { + if let Err(error) = write_frame(&mut send, &message).await { + warn!(%error, id, "failed to write chunk frame; ending chunk stream"); + break; + } + } + }; + + // The task ends as soon as either direction closes; the other future is then dropped, abandoning the stream that is already being torn down. + tokio::select! { + () = reader => {} + () = writer => {} } } @@ -136,38 +133,39 @@ pub(crate) async fn client_chunk_task( } }; - loop { - tokio::select! { - // The UI thread pushed a subscription update to forward to the server. - request = subscribe.recv() => { - match request { - Some(request) => { - if let Err(error) = write_frame(&mut send, &request).await { - warn!(%error, "failed to write chunk subscribe; ending chunk stream"); - break; - } - } - // The subscriber was dropped: the UI is shutting down. - None => break, - } - } - // A chunk arrived from the server. - frame = read_frame::(&mut recv, MAX_CHUNK_FRAME_LEN) => { - match frame { - Ok(message) => { - // `send` awaits when the delivery channel is full: the task suspends (yielding the runtime thread so the connection keeps ACKing) until the UI drains a slot, and until then reads no further frames, which backpressures the server via QUIC stream flow control. An error means the UI dropped its receiver, so the session ends. - if deliveries.send(message).await.is_err() { - break; - } - } - Err(error) => { - // A read error is the normal end of the session (stream finished or reset). - debug!(%error, "chunk stream read ended"); + // Inbound chunks are read on their own future so `read_frame` is never cancelled mid-frame by an outbound subscribe becoming ready. + let reader = async { + loop { + match read_frame::(&mut recv, MAX_CHUNK_FRAME_LEN).await { + Ok(message) => { + // `send` awaits when the delivery channel is full: the task suspends (yielding the runtime thread so the connection keeps ACKing) until the UI drains a slot, and until then reads no further frames, which backpressures the server via QUIC stream flow control. An error means the UI dropped its receiver, so the session ends. + if deliveries.send(message).await.is_err() { break; } } + Err(error) => { + // A read error is the normal end of the session (stream finished or reset). + debug!(%error, "chunk stream read ended"); + break; + } } } + }; + + // Outbound subscription updates from the UI thread are written on their own future. The loop ends when the subscriber is dropped (the UI is shutting down). + let writer = async { + while let Some(request) = subscribe.recv().await { + if let Err(error) = write_frame(&mut send, &request).await { + warn!(%error, "failed to write chunk subscribe; ending chunk stream"); + break; + } + } + }; + + // The task ends as soon as either direction closes; the other future is then dropped, abandoning the stream that is already being torn down. + tokio::select! { + () = reader => {} + () = writer => {} } } From 748e783f1ebc2f04d43e5a27ab66e944e1ddcee8 Mon Sep 17 00:00:00 2001 From: Serkyo Date: Thu, 23 Jul 2026 02:11:50 +0200 Subject: [PATCH 05/20] test(renderer): relocate frustum tests into src/tests --- crates/renderer/src/frustum.rs | 39 ++-------------------------- crates/renderer/src/tests/frustum.rs | 39 ++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 37 deletions(-) create mode 100644 crates/renderer/src/tests/frustum.rs diff --git a/crates/renderer/src/frustum.rs b/crates/renderer/src/frustum.rs index d50083c..becdcc5 100644 --- a/crates/renderer/src/frustum.rs +++ b/crates/renderer/src/frustum.rs @@ -62,40 +62,5 @@ impl Frustum { } #[cfg(test)] -mod tests { - use super::Frustum; - use glam::Vec3; - - /// Builds a frustum for a camera at the origin looking down the -Z axis, matching the engine's right-handed Vulkan-clip projection. - fn forward_facing_frustum() -> Frustum { - let proj = glam::camera::rh::proj::vulkan::perspective(60f32.to_radians(), 1.0, 0.1, 100.0); - let view = glam::camera::rh::view::look_at_mat4(Vec3::ZERO, Vec3::NEG_Z, Vec3::Y); - Frustum::from_view_proj(proj * view) - } - - #[test] - fn box_in_front_is_visible() { - let frustum = forward_facing_frustum(); - assert!(frustum.intersects_aabb(Vec3::new(-1.0, -1.0, -6.0), Vec3::new(1.0, 1.0, -4.0))); - } - - #[test] - fn box_behind_camera_is_culled() { - // A box entirely behind the camera. This is the case that fails if the near plane is extracted with the OpenGL `r3 + r2` formula instead of `r2`. - let frustum = forward_facing_frustum(); - assert!(!frustum.intersects_aabb(Vec3::new(-1.0, -1.0, 4.0), Vec3::new(1.0, 1.0, 6.0))); - } - - #[test] - fn box_far_to_the_side_is_culled() { - // Well outside the horizontal field of view at an otherwise valid depth. - let frustum = forward_facing_frustum(); - assert!(!frustum.intersects_aabb(Vec3::new(50.0, -1.0, -5.0), Vec3::new(52.0, 1.0, -4.0))); - } - - #[test] - fn huge_box_straddling_origin_is_visible() { - let frustum = forward_facing_frustum(); - assert!(frustum.intersects_aabb(Vec3::splat(-100.0), Vec3::splat(100.0))); - } -} +#[path = "tests/frustum.rs"] +mod tests; diff --git a/crates/renderer/src/tests/frustum.rs b/crates/renderer/src/tests/frustum.rs new file mode 100644 index 0000000..56233e4 --- /dev/null +++ b/crates/renderer/src/tests/frustum.rs @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +//! Unit tests for view-frustum culling in [`crate::frustum`]. + +use super::Frustum; +use glam::Vec3; + +/// Builds a frustum for a camera at the origin looking down the -Z axis, matching the engine's right-handed Vulkan-clip projection. +fn forward_facing_frustum() -> Frustum { + let proj = glam::camera::rh::proj::vulkan::perspective(60f32.to_radians(), 1.0, 0.1, 100.0); + let view = glam::camera::rh::view::look_at_mat4(Vec3::ZERO, Vec3::NEG_Z, Vec3::Y); + Frustum::from_view_proj(proj * view) +} + +#[test] +fn box_in_front_is_visible() { + let frustum = forward_facing_frustum(); + assert!(frustum.intersects_aabb(Vec3::new(-1.0, -1.0, -6.0), Vec3::new(1.0, 1.0, -4.0))); +} + +#[test] +fn box_behind_camera_is_culled() { + // A box entirely behind the camera. This is the case that fails if the near plane is extracted with the OpenGL `r3 + r2` formula instead of `r2`. + let frustum = forward_facing_frustum(); + assert!(!frustum.intersects_aabb(Vec3::new(-1.0, -1.0, 4.0), Vec3::new(1.0, 1.0, 6.0))); +} + +#[test] +fn box_far_to_the_side_is_culled() { + // Well outside the horizontal field of view at an otherwise valid depth. + let frustum = forward_facing_frustum(); + assert!(!frustum.intersects_aabb(Vec3::new(50.0, -1.0, -5.0), Vec3::new(52.0, 1.0, -4.0))); +} + +#[test] +fn huge_box_straddling_origin_is_visible() { + let frustum = forward_facing_frustum(); + assert!(frustum.intersects_aabb(Vec3::splat(-100.0), Vec3::splat(100.0))); +} From edc72a0f6fb753304d6999489b1e5dfba20de625 Mon Sep 17 00:00:00 2001 From: Serkyo Date: Thu, 23 Jul 2026 02:12:15 +0200 Subject: [PATCH 06/20] perf(renderer): merge coplanar voxel faces with a greedy mesher --- crates/renderer/src/lib.rs | 1 + crates/renderer/src/mesh.rs | 2 +- crates/renderer/src/meshing.rs | 361 +++++++++++++++++++++++++++ crates/renderer/src/tests/meshing.rs | 161 ++++++++++++ 4 files changed, 524 insertions(+), 1 deletion(-) create mode 100644 crates/renderer/src/meshing.rs create mode 100644 crates/renderer/src/tests/meshing.rs diff --git a/crates/renderer/src/lib.rs b/crates/renderer/src/lib.rs index 7fe6dea..9a3bce3 100644 --- a/crates/renderer/src/lib.rs +++ b/crates/renderer/src/lib.rs @@ -12,6 +12,7 @@ pub mod error; mod frustum; mod instance; pub mod mesh; +pub mod meshing; mod pipeline; mod renderer; mod surface; diff --git a/crates/renderer/src/mesh.rs b/crates/renderer/src/mesh.rs index 6117cea..d2e0011 100644 --- a/crates/renderer/src/mesh.rs +++ b/crates/renderer/src/mesh.rs @@ -9,7 +9,7 @@ use bytemuck::{Pod, Zeroable}; /// 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. #[repr(C)] -#[derive(Copy, Clone, Debug, Pod, Zeroable)] +#[derive(Copy, Clone, Debug, PartialEq, Pod, Zeroable)] pub struct Vertex { /// 3D position of the vertex (X, Y, Z). pub position: [f32; 3], diff --git a/crates/renderer/src/meshing.rs b/crates/renderer/src/meshing.rs new file mode 100644 index 0000000..940c1b0 --- /dev/null +++ b/crates/renderer/src/meshing.rs @@ -0,0 +1,361 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +//! Cubic greedy mesher: converts a dense voxel [`Chunk`] into renderer geometry. +//! +//! Exposed voxel faces are merged into the largest possible axis-aligned +//! rectangles before emission. The output is visually identical to a naive +//! per-face emitter (same faces, colours, and world positions) but carries far +//! fewer vertices and indices: a flat `CHUNK_SIZE`×`CHUNK_SIZE` surface becomes a +//! single quad rather than one quad per voxel. +//! +//! This is the **cubic** meshing path only. It is a pure `chunk → (vertices, +//! indices)` function and makes no assumption of being the sole mesher, so a +//! merged-granular mesher can coexist for softer materials. +//! +//! Out-of-chunk neighbours are treated as air, so every face on a chunk boundary +//! is emitted. Cross-chunk face culling is a separate concern layered on top. + +use crate::mesh::Vertex; +use shared::world::{BlockId, CHUNK_SIZE, Chunk}; + +/// The signed direction a face points along one of the three axes. +/// +/// The sign is part of the merge key: two faces on the same plane but pointing +/// in opposite directions (for example a top face and the bottom face directly +/// above it) must never merge, so `PosY` and `NegY` are distinct variants. +#[derive(Copy, Clone, PartialEq, Eq)] +enum FaceDir { + /// Points toward increasing X. + PosX, + /// Points toward decreasing X. + NegX, + /// Points toward increasing Y (upward). + PosY, + /// Points toward decreasing Y (downward). + NegY, + /// Points toward increasing Z. + PosZ, + /// Points toward decreasing Z. + NegZ, +} + +/// Identifies whether two faces are mergeable. +/// +/// Two faces merge only if every attribute a vertex carries is identical. Colour +/// currently depends only on [`FaceDir`], but keying additionally on [`BlockId`] +/// keeps the merge correct once per-material colours are introduced: two distinct +/// block types will not silently coalesce into one quad. +#[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. + 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], + } +} + +/// Converts a chunk-local integer coordinate to its floating-point value. +#[expect( + clippy::cast_precision_loss, + reason = "chunk-local coordinates never exceed CHUNK_SIZE (32) and are exact as f32" +)] +const fn coord(i: usize) -> f32 { + i as f32 +} + +/// Meshes `chunk` into GPU vertices and triangle indices via greedy merging. +/// +/// Each axis is swept slice by slice; on every slice a 2D mask of exposed faces +/// over the two perpendicular axes is built and merged into rectangles. Boundary +/// voxels treat out-of-chunk neighbours as air, so chunk-edge faces are emitted. +#[must_use] +#[expect( + clippy::too_many_lines, + reason = "six directional passes, each an inline sample + corners closure pair" +)] +pub fn generate_mesh(chunk: &Chunk) -> (Vec, Vec) { + let mut vertices = Vec::new(); + let mut indices = Vec::new(); + // A single u×v mask, reused across every slice of every axis; each pass fully + // overwrites it per slice, so no explicit clearing is required. + let mut mask = vec![None; CHUNK_SIZE * CHUNK_SIZE]; + + // +Y (top): slice = y, mask u = x, mask v = z. + run_pass( + &mut mask, + &mut vertices, + &mut indices, + |y, x, z| { + let block = chunk.get(x, y, z); + (block != BlockId::AIR + && (y == CHUNK_SIZE - 1 || chunk.get(x, y + 1, z) == BlockId::AIR)) + .then_some(FaceKey { + block, + dir: FaceDir::PosY, + }) + }, + |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; + [ + [xmin, yp, zmax], + [xmax, yp, zmax], + [xmax, yp, zmin], + [xmin, yp, zmin], + ] + }, + ); + + // -Y (bottom): slice = y, mask u = x, mask v = z. + run_pass( + &mut mask, + &mut vertices, + &mut indices, + |y, x, z| { + let block = chunk.get(x, y, z); + (block != BlockId::AIR && (y == 0 || chunk.get(x, y - 1, z) == BlockId::AIR)).then_some( + FaceKey { + block, + dir: FaceDir::NegY, + }, + ) + }, + |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; + [ + [xmin, yp, zmin], + [xmax, yp, zmin], + [xmax, yp, zmax], + [xmin, yp, zmax], + ] + }, + ); + + // +X: slice = x, mask u = z, mask v = y. + run_pass( + &mut mask, + &mut vertices, + &mut indices, + |x, z, y| { + let block = chunk.get(x, y, z); + (block != BlockId::AIR + && (x == CHUNK_SIZE - 1 || chunk.get(x + 1, y, z) == BlockId::AIR)) + .then_some(FaceKey { + block, + dir: FaceDir::PosX, + }) + }, + |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; + [ + [xp, ymin, zmax], + [xp, ymin, zmin], + [xp, ymax, zmin], + [xp, ymax, zmax], + ] + }, + ); + + // -X: slice = x, mask u = z, mask v = y. + run_pass( + &mut mask, + &mut vertices, + &mut indices, + |x, z, y| { + let block = chunk.get(x, y, z); + (block != BlockId::AIR && (x == 0 || chunk.get(x - 1, y, z) == BlockId::AIR)).then_some( + FaceKey { + block, + dir: FaceDir::NegX, + }, + ) + }, + |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; + [ + [xp, ymin, zmin], + [xp, ymin, zmax], + [xp, ymax, zmax], + [xp, ymax, zmin], + ] + }, + ); + + // +Z: slice = z, mask u = x, mask v = y. + run_pass( + &mut mask, + &mut vertices, + &mut indices, + |z, x, y| { + let block = chunk.get(x, y, z); + (block != BlockId::AIR + && (z == CHUNK_SIZE - 1 || chunk.get(x, y, z + 1) == BlockId::AIR)) + .then_some(FaceKey { + block, + dir: FaceDir::PosZ, + }) + }, + |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; + [ + [xmin, ymin, zp], + [xmax, ymin, zp], + [xmax, ymax, zp], + [xmin, ymax, zp], + ] + }, + ); + + // -Z: slice = z, mask u = x, mask v = y. + run_pass( + &mut mask, + &mut vertices, + &mut indices, + |z, x, y| { + let block = chunk.get(x, y, z); + (block != BlockId::AIR && (z == 0 || chunk.get(x, y, z - 1) == BlockId::AIR)).then_some( + FaceKey { + block, + dir: FaceDir::NegZ, + }, + ) + }, + |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; + [ + [xmax, ymin, zp], + [xmin, ymin, zp], + [xmin, ymax, zp], + [xmax, ymax, zp], + ] + }, + ); + + (vertices, indices) +} + +/// Runs one directional meshing pass over all `CHUNK_SIZE` slices. +/// +/// `sample(slice, u, v)` returns the [`FaceKey`] for the face at mask cell +/// `(u, v)` of `slice`, or `None` when no face is exposed there. `corners(slice, +/// u0, v0, w, h)` yields the four world-space corners, ordered counter-clockwise +/// as seen from outside the face, of a merged rectangle rooted at `(u0, v0)` with +/// width `w` along `u` and height `h` along `v`. +fn run_pass( + mask: &mut [Option], + vertices: &mut Vec, + indices: &mut Vec, + mut sample: impl FnMut(usize, usize, usize) -> Option, + corners: impl Fn(usize, usize, usize, usize, usize) -> [[f32; 3]; 4], +) { + for slice in 0..CHUNK_SIZE { + for v in 0..CHUNK_SIZE { + for u in 0..CHUNK_SIZE { + mask[u + v * CHUNK_SIZE] = sample(slice, u, v); + } + } + + merge_mask(mask, |key, u0, v0, w, h| { + push_quad( + vertices, + indices, + corners(slice, u0, v0, w, h), + color_of(key.dir), + ); + }); + } +} + +/// Greedily covers the exposed cells of `mask` with maximal rectangles. +/// +/// Cells are scanned row-major. At the first exposed, unconsumed cell the run is +/// extended along `u` while the key matches, then along `v` while every cell of +/// the next row over the current width matches. The covered cells are marked +/// consumed (set to `None`) so they are not re-emitted, and `emit(key, u0, v0, w, +/// h)` is called once for the rectangle. +fn merge_mask( + mask: &mut [Option], + mut emit: impl FnMut(FaceKey, usize, usize, usize, usize), +) { + for v in 0..CHUNK_SIZE { + for u in 0..CHUNK_SIZE { + let Some(key) = mask[u + v * CHUNK_SIZE] else { + continue; + }; + + // Extend width along u while the key is unbroken. + let mut w = 1; + while u + w < CHUNK_SIZE && mask[(u + w) + v * CHUNK_SIZE] == Some(key) { + w += 1; + } + + // Extend height along v while every cell of the next row matches over [0, w). + let mut h = 1; + 'grow: while v + h < CHUNK_SIZE { + for du in 0..w { + if mask[(u + du) + (v + h) * CHUNK_SIZE] != Some(key) { + break 'grow; + } + } + h += 1; + } + + // Consume the covered rectangle so its cells are not re-emitted. + for dv in 0..h { + for du in 0..w { + mask[(u + du) + (v + dv) * CHUNK_SIZE] = None; + } + } + + emit(key, u, v, w, h); + } + } +} + +/// Appends one quad (four vertices, six indices) with the given corners and colour. +/// +/// 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], +) { + #[expect( + clippy::cast_possible_truncation, + reason = "a chunk mesh holds far fewer than u32::MAX vertices" + )] + let base = vertices.len() as u32; + for position in corners { + vertices.push(Vertex { position, color }); + } + indices.extend_from_slice(&[base, base + 1, base + 2, base + 2, base + 3, base]); +} + +#[cfg(test)] +#[path = "tests/meshing.rs"] +mod tests; diff --git a/crates/renderer/src/tests/meshing.rs b/crates/renderer/src/tests/meshing.rs new file mode 100644 index 0000000..db95dcd --- /dev/null +++ b/crates/renderer/src/tests/meshing.rs @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +//! Unit tests for the greedy chunk mesher in [`crate::meshing`]. + +use super::*; + +/// Minimal deterministic xorshift64 generator for seeded test chunks. +struct Rng(u64); + +impl Rng { + fn next(&mut self) -> u64 { + let mut x = self.0; + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + self.0 = x; + x + } +} + +/// Builds a deterministic pseudo-random chunk at roughly one-third density. +fn random_chunk(seed: u64) -> Chunk { + let mut rng = Rng(seed); + let mut chunk = Chunk::default(); + for x in 0..CHUNK_SIZE { + for y in 0..CHUNK_SIZE { + for z in 0..CHUNK_SIZE { + if rng.next().is_multiple_of(3) { + chunk.set(x, y, z, BlockId(1)); + } + } + } + } + chunk +} + +/// Counts exposed unit faces the naive way (out-of-chunk neighbours are air). +/// +/// Every unit face has area 1, so this count equals the total surface area a correct greedy mesh must reproduce. +fn count_exposed_faces(chunk: &Chunk) -> usize { + let mut n = 0; + for x in 0..CHUNK_SIZE { + for y in 0..CHUNK_SIZE { + for z in 0..CHUNK_SIZE { + if chunk.get(x, y, z) == BlockId::AIR { + continue; + } + n += usize::from(y == CHUNK_SIZE - 1 || chunk.get(x, y + 1, z) == BlockId::AIR); + n += usize::from(y == 0 || chunk.get(x, y - 1, z) == BlockId::AIR); + n += usize::from(x == CHUNK_SIZE - 1 || chunk.get(x + 1, y, z) == BlockId::AIR); + n += usize::from(x == 0 || chunk.get(x - 1, y, z) == BlockId::AIR); + n += usize::from(z == CHUNK_SIZE - 1 || chunk.get(x, y, z + 1) == BlockId::AIR); + n += usize::from(z == 0 || chunk.get(x, y, z - 1) == BlockId::AIR); + } + } + } + n +} + +/// Sums the area of every triangle in the mesh via the cross-product magnitude. +fn total_area(vertices: &[Vertex], indices: &[u32]) -> f64 { + let mut area = 0.0f64; + for tri in indices.chunks_exact(3) { + let a = vertices[tri[0] as usize].position; + let b = vertices[tri[1] as usize].position; + let c = vertices[tri[2] as usize].position; + let ab = [ + f64::from(b[0] - a[0]), + f64::from(b[1] - a[1]), + f64::from(b[2] - a[2]), + ]; + let ac = [ + f64::from(c[0] - a[0]), + f64::from(c[1] - a[1]), + f64::from(c[2] - a[2]), + ]; + let cross = [ + ab[1] * ac[2] - ab[2] * ac[1], + ab[2] * ac[0] - ab[0] * ac[2], + ab[0] * ac[1] - ab[1] * ac[0], + ]; + area += 0.5 * cross.iter().map(|c| c * c).sum::().sqrt(); + } + area +} + +#[test] +fn all_air_chunk_is_empty() { + let (vertices, indices) = generate_mesh(&Chunk::default()); + assert!(vertices.is_empty()); + assert!(indices.is_empty()); +} + +#[test] +fn single_block_emits_six_quads() { + let mut chunk = Chunk::default(); + chunk.set(5, 5, 5, BlockId(1)); + let (vertices, indices) = generate_mesh(&chunk); + // Six exposed faces, none mergeable: 6 quads. + assert_eq!(vertices.len(), 24); + assert_eq!(indices.len(), 36); +} + +#[test] +fn full_chunk_merges_each_face_into_one_quad() { + let mut chunk = Chunk::default(); + for x in 0..CHUNK_SIZE { + for y in 0..CHUNK_SIZE { + for z in 0..CHUNK_SIZE { + chunk.set(x, y, z, BlockId(1)); + } + } + } + let (vertices, indices) = generate_mesh(&chunk); + // Only the six boundary planes are exposed, each merging to a single quad. + assert_eq!(vertices.len(), 24); + assert_eq!(indices.len(), 36); +} + +#[test] +fn adjacent_pair_culls_shared_face_and_merges_sides() { + let mut chunk = Chunk::default(); + chunk.set(0, 0, 0, BlockId(1)); + chunk.set(1, 0, 0, BlockId(1)); + let (vertices, indices) = generate_mesh(&chunk); + // Shared internal face pair is culled; +Y/-Y/+Z/-Z each merge across the pair into one quad, and the two X ends are one quad each: 6 quads total. + assert_eq!(vertices.len(), 24); + assert_eq!(indices.len(), 36); +} + +#[test] +#[expect( + clippy::cast_precision_loss, + reason = "exposed-face counts are far below f64's exact-integer range" +)] +fn greedy_area_equals_naive_and_never_more_indices() { + for seed in 1..=8u64 { + let chunk = random_chunk(seed); + let (vertices, indices) = generate_mesh(&chunk); + let naive_faces = count_exposed_faces(&chunk); + + // Area equality proves no faces were lost, doubled, or misplaced. + let expected_area = naive_faces as f64; + assert!( + (total_area(&vertices, &indices) - expected_area).abs() < 1e-6, + "seed {seed}: greedy area diverged from naive" + ); + + // Merging can only reduce (or match) the index count of the naive mesh. + assert!( + indices.len() <= naive_faces * 6, + "seed {seed}: greedy emitted more indices than naive" + ); + } +} + +#[test] +fn mesh_is_deterministic() { + let chunk = random_chunk(42); + assert_eq!(generate_mesh(&chunk), generate_mesh(&chunk)); +} From d2f4beb5af62c96278bba3ec034c2cfc9924f128 Mon Sep 17 00:00:00 2001 From: Serkyo Date: Thu, 23 Jul 2026 02:12:25 +0200 Subject: [PATCH 07/20] refactor(client): mesh chunks via the renderer and drop the client copy --- crates/client/src/chunks.rs | 4 +- crates/client/src/main.rs | 1 - crates/client/src/meshing.rs | 208 ----------------------------------- 3 files changed, 1 insertion(+), 212 deletions(-) delete mode 100644 crates/client/src/meshing.rs diff --git a/crates/client/src/chunks.rs b/crates/client/src/chunks.rs index c20cffd..784581b 100644 --- a/crates/client/src/chunks.rs +++ b/crates/client/src/chunks.rs @@ -8,8 +8,6 @@ use shared::protocol::chunk::ChunkMessage; use shared::world::{CHUNK_SIZE, Chunk, ChunkData, ChunkPos}; use tracing::{debug, error}; -use crate::meshing; - /// 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; @@ -80,7 +78,7 @@ impl ChunkManager { /// Materializes, meshes, and uploads one delivered chunk, marking its position resident. fn apply_chunk(&mut self, pos: ChunkPos, data: &ChunkData, renderer: &mut renderer::Renderer) { let chunk = data.materialize(&self.baseline); - let (vertices, indices) = meshing::generate_mesh(&chunk); + let (vertices, indices) = renderer::meshing::generate_mesh(&chunk); // Uploading a zero-length buffer is invalid, so an all-air chunk skips the renderer entirely. It is still marked resident below so a later delivery is not double-counted. if !indices.is_empty() { diff --git a/crates/client/src/main.rs b/crates/client/src/main.rs index 44cb910..f5a816e 100644 --- a/crates/client/src/main.rs +++ b/crates/client/src/main.rs @@ -6,7 +6,6 @@ mod camera; mod chunks; -mod meshing; use std::time::Instant; diff --git a/crates/client/src/meshing.rs b/crates/client/src/meshing.rs deleted file mode 100644 index da41f3d..0000000 --- a/crates/client/src/meshing.rs +++ /dev/null @@ -1,208 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only - -use renderer::mesh::Vertex; -use shared::world::{BlockId, CHUNK_SIZE, Chunk}; - -#[expect( - clippy::cast_precision_loss, - clippy::cast_possible_truncation, - clippy::too_many_lines, - reason = "voxel coordinates and vertex counts are small and lossless as f32/u32; the per-face unrolling is intentionally long" -)] -pub fn generate_mesh(chunk: &Chunk) -> (Vec, Vec) { - let mut vertices = Vec::new(); - let mut indices = Vec::new(); - - for x in 0..CHUNK_SIZE { - for y in 0..CHUNK_SIZE { - for z in 0..CHUNK_SIZE { - let block = chunk.get(x, y, z); - - if block == BlockId::AIR { - continue; - } - - let fx = x as f32; - let fy = y as f32; - let fz = z as f32; - - if y == CHUNK_SIZE - 1 || chunk.get(x, y + 1, z) == BlockId::AIR { - let base_idx = vertices.len() as u32; - - vertices.push(Vertex { - position: [fx - 0.5, fy + 0.5, fz + 0.5], - color: [0.2, 0.8, 0.2], - }); - vertices.push(Vertex { - position: [fx + 0.5, fy + 0.5, fz + 0.5], - color: [0.2, 0.8, 0.2], - }); - vertices.push(Vertex { - position: [fx + 0.5, fy + 0.5, fz - 0.5], - color: [0.2, 0.8, 0.2], - }); - vertices.push(Vertex { - position: [fx - 0.5, fy + 0.5, fz - 0.5], - color: [0.2, 0.8, 0.2], - }); - - indices.extend_from_slice(&[ - base_idx, - base_idx + 1, - base_idx + 2, - base_idx + 2, - base_idx + 3, - base_idx, - ]); - } - - if y == 0 || chunk.get(x, y - 1, z) == BlockId::AIR { - let base_idx = vertices.len() as u32; - - vertices.push(Vertex { - position: [fx - 0.5, fy - 0.5, fz - 0.5], - color: [0.1, 0.4, 0.1], - }); - vertices.push(Vertex { - position: [fx + 0.5, fy - 0.5, fz - 0.5], - color: [0.1, 0.4, 0.1], - }); - vertices.push(Vertex { - position: [fx + 0.5, fy - 0.5, fz + 0.5], - color: [0.1, 0.4, 0.1], - }); - vertices.push(Vertex { - position: [fx - 0.5, fy - 0.5, fz + 0.5], - color: [0.1, 0.4, 0.1], - }); - indices.extend_from_slice(&[ - base_idx, - base_idx + 1, - base_idx + 2, - base_idx + 2, - base_idx + 3, - base_idx, - ]); - } - - if x == CHUNK_SIZE - 1 || chunk.get(x + 1, y, z) == BlockId::AIR { - let base_idx = vertices.len() as u32; - - vertices.push(Vertex { - position: [fx + 0.5, fy - 0.5, fz + 0.5], - color: [0.15, 0.6, 0.15], - }); - vertices.push(Vertex { - position: [fx + 0.5, fy - 0.5, fz - 0.5], - color: [0.15, 0.6, 0.15], - }); - vertices.push(Vertex { - position: [fx + 0.5, fy + 0.5, fz - 0.5], - color: [0.15, 0.6, 0.15], - }); - vertices.push(Vertex { - position: [fx + 0.5, fy + 0.5, fz + 0.5], - color: [0.15, 0.6, 0.15], - }); - indices.extend_from_slice(&[ - base_idx, - base_idx + 1, - base_idx + 2, - base_idx + 2, - base_idx + 3, - base_idx, - ]); - } - - if x == 0 || chunk.get(x - 1, y, z) == BlockId::AIR { - let base_idx = vertices.len() as u32; - - vertices.push(Vertex { - position: [fx - 0.5, fy - 0.5, fz - 0.5], - color: [0.15, 0.6, 0.15], - }); - vertices.push(Vertex { - position: [fx - 0.5, fy - 0.5, fz + 0.5], - color: [0.15, 0.6, 0.15], - }); - vertices.push(Vertex { - position: [fx - 0.5, fy + 0.5, fz + 0.5], - color: [0.15, 0.6, 0.15], - }); - vertices.push(Vertex { - position: [fx - 0.5, fy + 0.5, fz - 0.5], - color: [0.15, 0.6, 0.15], - }); - indices.extend_from_slice(&[ - base_idx, - base_idx + 1, - base_idx + 2, - base_idx + 2, - base_idx + 3, - base_idx, - ]); - } - - if z == CHUNK_SIZE - 1 || chunk.get(x, y, z + 1) == BlockId::AIR { - let base_idx = vertices.len() as u32; - - vertices.push(Vertex { - position: [fx - 0.5, fy - 0.5, fz + 0.5], - color: [0.18, 0.7, 0.18], - }); - vertices.push(Vertex { - position: [fx + 0.5, fy - 0.5, fz + 0.5], - color: [0.18, 0.7, 0.18], - }); - vertices.push(Vertex { - position: [fx + 0.5, fy + 0.5, fz + 0.5], - color: [0.18, 0.7, 0.18], - }); - vertices.push(Vertex { - position: [fx - 0.5, fy + 0.5, fz + 0.5], - color: [0.18, 0.7, 0.18], - }); - indices.extend_from_slice(&[ - base_idx, - base_idx + 1, - base_idx + 2, - base_idx + 2, - base_idx + 3, - base_idx, - ]); - } - - if z == 0 || chunk.get(x, y, z - 1) == BlockId::AIR { - let base_idx = vertices.len() as u32; - - vertices.push(Vertex { - position: [fx + 0.5, fy - 0.5, fz - 0.5], - color: [0.18, 0.7, 0.18], - }); - vertices.push(Vertex { - position: [fx - 0.5, fy - 0.5, fz - 0.5], - color: [0.18, 0.7, 0.18], - }); - vertices.push(Vertex { - position: [fx - 0.5, fy + 0.5, fz - 0.5], - color: [0.18, 0.7, 0.18], - }); - vertices.push(Vertex { - position: [fx + 0.5, fy + 0.5, fz - 0.5], - color: [0.18, 0.7, 0.18], - }); - indices.extend_from_slice(&[ - base_idx, - base_idx + 1, - base_idx + 2, - base_idx + 2, - base_idx + 3, - base_idx, - ]); - } - } - } - } - - (vertices, indices) -} From d8d6635f370443bc8fa1e74aae510ffaddf38e6b Mon Sep 17 00:00:00 2001 From: Serkyo Date: Thu, 23 Jul 2026 02:13:36 +0200 Subject: [PATCH 08/20] refactor(renderer): rename mesh module to vertex --- crates/renderer/src/lib.rs | 2 +- crates/renderer/src/meshing.rs | 49 ++++------------------ crates/renderer/src/pipeline.rs | 2 +- crates/renderer/src/renderer.rs | 2 +- crates/renderer/src/{mesh.rs => vertex.rs} | 0 5 files changed, 11 insertions(+), 44 deletions(-) rename crates/renderer/src/{mesh.rs => vertex.rs} (100%) diff --git a/crates/renderer/src/lib.rs b/crates/renderer/src/lib.rs index 9a3bce3..70e235b 100644 --- a/crates/renderer/src/lib.rs +++ b/crates/renderer/src/lib.rs @@ -11,13 +11,13 @@ mod device; pub mod error; mod frustum; mod instance; -pub mod mesh; pub mod meshing; mod pipeline; mod renderer; mod surface; mod swapchain; mod sync; +pub mod vertex; /// The maximum number of frames that can be processed by the GPU and CPU simultaneously. pub const MAX_FRAMES_IN_FLIGHT: usize = 3; diff --git a/crates/renderer/src/meshing.rs b/crates/renderer/src/meshing.rs index 940c1b0..2f38536 100644 --- a/crates/renderer/src/meshing.rs +++ b/crates/renderer/src/meshing.rs @@ -1,28 +1,13 @@ // SPDX-License-Identifier: AGPL-3.0-only //! Cubic greedy mesher: converts a dense voxel [`Chunk`] into renderer geometry. -//! -//! Exposed voxel faces are merged into the largest possible axis-aligned -//! rectangles before emission. The output is visually identical to a naive -//! per-face emitter (same faces, colours, and world positions) but carries far -//! fewer vertices and indices: a flat `CHUNK_SIZE`×`CHUNK_SIZE` surface becomes a -//! single quad rather than one quad per voxel. -//! -//! This is the **cubic** meshing path only. It is a pure `chunk → (vertices, -//! indices)` function and makes no assumption of being the sole mesher, so a -//! merged-granular mesher can coexist for softer materials. -//! -//! Out-of-chunk neighbours are treated as air, so every face on a chunk boundary -//! is emitted. Cross-chunk face culling is a separate concern layered on top. -use crate::mesh::Vertex; +use crate::vertex::Vertex; use shared::world::{BlockId, CHUNK_SIZE, Chunk}; /// The signed direction a face points along one of the three axes. /// -/// The sign is part of the merge key: two faces on the same plane but pointing -/// in opposite directions (for example a top face and the bottom face directly -/// above it) must never merge, so `PosY` and `NegY` are distinct variants. +/// The sign is part of the merge key: two faces on the same plane but pointing in opposite directions (for example a top face and the bottom face directly above it) must never merge, so `PosY` and `NegY` are distinct variants. #[derive(Copy, Clone, PartialEq, Eq)] enum FaceDir { /// Points toward increasing X. @@ -40,11 +25,6 @@ enum FaceDir { } /// Identifies whether two faces are mergeable. -/// -/// Two faces merge only if every attribute a vertex carries is identical. Colour -/// currently depends only on [`FaceDir`], but keying additionally on [`BlockId`] -/// keeps the merge correct once per-material colours are introduced: two distinct -/// block types will not silently coalesce into one quad. #[derive(Copy, Clone, PartialEq, Eq)] struct FaceKey { /// The material of the voxel owning the face. @@ -55,8 +35,7 @@ struct FaceKey { /// 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. +/// 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], @@ -77,9 +56,7 @@ const fn coord(i: usize) -> f32 { /// Meshes `chunk` into GPU vertices and triangle indices via greedy merging. /// -/// Each axis is swept slice by slice; on every slice a 2D mask of exposed faces -/// over the two perpendicular axes is built and merged into rectangles. Boundary -/// voxels treat out-of-chunk neighbours as air, so chunk-edge faces are emitted. +/// Each axis is swept slice by slice; on every slice a 2D mask of exposed faces over the two perpendicular axes is built and merged into rectangles. Boundary voxels treat out-of-chunk neighbours as air, so chunk-edge faces are emitted. #[must_use] #[expect( clippy::too_many_lines, @@ -88,8 +65,7 @@ const fn coord(i: usize) -> f32 { pub fn generate_mesh(chunk: &Chunk) -> (Vec, Vec) { let mut vertices = Vec::new(); let mut indices = Vec::new(); - // A single u×v mask, reused across every slice of every axis; each pass fully - // overwrites it per slice, so no explicit clearing is required. + // A single u×v mask, reused across every slice of every axis; each pass fully overwrites it per slice, so no explicit clearing is required. let mut mask = vec![None; CHUNK_SIZE * CHUNK_SIZE]; // +Y (top): slice = y, mask u = x, mask v = z. @@ -259,11 +235,7 @@ pub fn generate_mesh(chunk: &Chunk) -> (Vec, Vec) { /// Runs one directional meshing pass over all `CHUNK_SIZE` slices. /// -/// `sample(slice, u, v)` returns the [`FaceKey`] for the face at mask cell -/// `(u, v)` of `slice`, or `None` when no face is exposed there. `corners(slice, -/// u0, v0, w, h)` yields the four world-space corners, ordered counter-clockwise -/// as seen from outside the face, of a merged rectangle rooted at `(u0, v0)` with -/// width `w` along `u` and height `h` along `v`. +/// `sample(slice, u, v)` returns the [`FaceKey`] for the face at mask cell `(u, v)` of `slice`, or `None` when no face is exposed there. `corners(slice, u0, v0, w, h)` yields the four world-space corners, ordered counter-clockwise as seen from outside the face, of a merged rectangle rooted at `(u0, v0)` with width `w` along `u` and height `h` along `v`. fn run_pass( mask: &mut [Option], vertices: &mut Vec, @@ -291,11 +263,7 @@ fn run_pass( /// Greedily covers the exposed cells of `mask` with maximal rectangles. /// -/// Cells are scanned row-major. At the first exposed, unconsumed cell the run is -/// extended along `u` while the key matches, then along `v` while every cell of -/// the next row over the current width matches. The covered cells are marked -/// consumed (set to `None`) so they are not re-emitted, and `emit(key, u0, v0, w, -/// h)` is called once for the rectangle. +/// Cells are scanned row-major. At the first exposed, unconsumed cell the run is extended along `u` while the key matches, then along `v` while every cell of the next row over the current width matches. The covered cells are marked consumed (set to `None`) so they are not re-emitted, and `emit(key, u0, v0, w, h)` is called once for the rectangle. fn merge_mask( mask: &mut [Option], mut emit: impl FnMut(FaceKey, usize, usize, usize, usize), @@ -337,8 +305,7 @@ fn merge_mask( /// Appends one quad (four vertices, six indices) with the given corners and colour. /// -/// Indices wind the two triangles as `[base, base+1, base+2, base+2, base+3, -/// base]`, matching the corner ordering supplied by the caller. +/// 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, diff --git a/crates/renderer/src/pipeline.rs b/crates/renderer/src/pipeline.rs index 4a3e5bc..f8aa16b 100644 --- a/crates/renderer/src/pipeline.rs +++ b/crates/renderer/src/pipeline.rs @@ -3,7 +3,7 @@ //! Graphics pipeline creation and shader management. use crate::error::RendererError; -use crate::mesh::Vertex; +use crate::vertex::Vertex; use ash::{Device, vk}; use std::io::Cursor; diff --git a/crates/renderer/src/renderer.rs b/crates/renderer/src/renderer.rs index 380c2f2..4f1c557 100644 --- a/crates/renderer/src/renderer.rs +++ b/crates/renderer/src/renderer.rs @@ -2,7 +2,7 @@ use crate::sync::SyncPrimitives; use crate::{create_depth_resources, create_gpu_buffer, swapchain}; -use crate::{error::RendererError, frustum::Frustum, mesh::Vertex}; +use crate::{error::RendererError, frustum::Frustum, vertex::Vertex}; use ash::{Device, Instance, khr, vk}; use gpu_allocator::vulkan::{Allocation, Allocator}; use std::collections::HashMap; diff --git a/crates/renderer/src/mesh.rs b/crates/renderer/src/vertex.rs similarity index 100% rename from crates/renderer/src/mesh.rs rename to crates/renderer/src/vertex.rs From 3b3a4a107bc1e680e10e66e298ddaf574e633e44 Mon Sep 17 00:00:00 2001 From: Serkyo Date: Thu, 23 Jul 2026 03:40:09 +0200 Subject: [PATCH 09/20] feat(renderer): cull voxel faces against neighbouring chunks --- crates/renderer/src/meshing.rs | 111 +++++++++++++++++++++++---- crates/renderer/src/tests/meshing.rs | 88 +++++++++++++++++++-- 2 files changed, 176 insertions(+), 23 deletions(-) diff --git a/crates/renderer/src/meshing.rs b/crates/renderer/src/meshing.rs index 2f38536..1744d14 100644 --- a/crates/renderer/src/meshing.rs +++ b/crates/renderer/src/meshing.rs @@ -54,15 +54,92 @@ const fn coord(i: usize) -> f32 { i as f32 } +/// The six face-adjacent neighbour chunks, if resident. +/// +/// A `None` side means the neighbour is not loaded; that boundary is treated as exposed (its faces are emitted) so the load frontier shows no holes. The referenced chunks must outlive the [`Neighbors`] value, which is what the `'a` lifetime records. +#[derive(Default)] +pub struct Neighbors<'a> { + /// Neighbour toward decreasing X, sampled at its `x = CHUNK_SIZE - 1` face. + pub neg_x: Option<&'a Chunk>, + /// Neighbour toward increasing X, sampled at its `x = 0` face. + pub pos_x: Option<&'a Chunk>, + /// Neighbour toward decreasing Y, sampled at its `y = CHUNK_SIZE - 1` face. + pub neg_y: Option<&'a Chunk>, + /// Neighbour toward increasing Y, sampled at its `y = 0` face. + pub pos_y: Option<&'a Chunk>, + /// Neighbour toward decreasing Z, sampled at its `z = CHUNK_SIZE - 1` face. + pub neg_z: Option<&'a Chunk>, + /// Neighbour toward increasing Z, sampled at its `z = 0` face. + pub pos_z: Option<&'a Chunk>, +} + +/// Returns the block occluding the `dir` face of the voxel at (`x`, `y`, `z`). +/// +/// When the adjacent voxel lies inside the chunk it is read directly. When it lies across the chunk boundary it is read from the matching entry of `neighbors` at the opposite edge; a `None` neighbour is treated as [`BlockId::AIR`] so the boundary face is emitted (frontier safety). +fn occluder( + chunk: &Chunk, + neighbors: &Neighbors, + x: usize, + y: usize, + z: usize, + dir: FaceDir, +) -> BlockId { + const LAST: usize = CHUNK_SIZE - 1; + match dir { + FaceDir::PosX => { + if x < LAST { + chunk.get(x + 1, y, z) + } else { + neighbors.pos_x.map_or(BlockId::AIR, |c| c.get(0, y, z)) + } + } + FaceDir::NegX => { + if x > 0 { + chunk.get(x - 1, y, z) + } else { + neighbors.neg_x.map_or(BlockId::AIR, |c| c.get(LAST, y, z)) + } + } + FaceDir::PosY => { + if y < LAST { + chunk.get(x, y + 1, z) + } else { + neighbors.pos_y.map_or(BlockId::AIR, |c| c.get(x, 0, z)) + } + } + FaceDir::NegY => { + if y > 0 { + chunk.get(x, y - 1, z) + } else { + neighbors.neg_y.map_or(BlockId::AIR, |c| c.get(x, LAST, z)) + } + } + FaceDir::PosZ => { + if z < LAST { + chunk.get(x, y, z + 1) + } else { + neighbors.pos_z.map_or(BlockId::AIR, |c| c.get(x, y, 0)) + } + } + FaceDir::NegZ => { + if z > 0 { + chunk.get(x, y, z - 1) + } else { + neighbors.neg_z.map_or(BlockId::AIR, |c| c.get(x, y, LAST)) + } + } + } +} + /// Meshes `chunk` into GPU vertices and triangle indices via greedy merging. /// -/// Each axis is swept slice by slice; on every slice a 2D mask of exposed faces over the two perpendicular axes is built and merged into rectangles. Boundary voxels treat out-of-chunk neighbours as air, so chunk-edge faces are emitted. +/// Each axis is swept slice by slice; on every slice a 2D mask of exposed faces over the two perpendicular axes is built and merged into rectangles. Boundary voxels are tested against `neighbors`: a chunk-edge face is emitted only when the adjoining voxel in the matching neighbour is air, or when that neighbour is absent (see [`Neighbors`]). #[must_use] #[expect( clippy::too_many_lines, reason = "six directional passes, each an inline sample + corners closure pair" )] -pub fn generate_mesh(chunk: &Chunk) -> (Vec, Vec) { +pub fn generate_mesh(chunk: &Chunk, neighbors: &Neighbors) -> (Vec, Vec) { let mut vertices = Vec::new(); let mut indices = Vec::new(); // A single u×v mask, reused across every slice of every axis; each pass fully overwrites it per slice, so no explicit clearing is required. @@ -76,7 +153,7 @@ pub fn generate_mesh(chunk: &Chunk) -> (Vec, Vec) { |y, x, z| { let block = chunk.get(x, y, z); (block != BlockId::AIR - && (y == CHUNK_SIZE - 1 || chunk.get(x, y + 1, z) == BlockId::AIR)) + && occluder(chunk, neighbors, x, y, z, FaceDir::PosY) == BlockId::AIR) .then_some(FaceKey { block, dir: FaceDir::PosY, @@ -102,12 +179,12 @@ pub fn generate_mesh(chunk: &Chunk) -> (Vec, Vec) { &mut indices, |y, x, z| { let block = chunk.get(x, y, z); - (block != BlockId::AIR && (y == 0 || chunk.get(x, y - 1, z) == BlockId::AIR)).then_some( - FaceKey { + (block != BlockId::AIR + && occluder(chunk, neighbors, x, y, z, FaceDir::NegY) == BlockId::AIR) + .then_some(FaceKey { block, dir: FaceDir::NegY, - }, - ) + }) }, |y, x0, z0, w, h| { let (xmin, xmax) = (coord(x0) - 0.5, coord(x0 + w) - 0.5); @@ -130,7 +207,7 @@ pub fn generate_mesh(chunk: &Chunk) -> (Vec, Vec) { |x, z, y| { let block = chunk.get(x, y, z); (block != BlockId::AIR - && (x == CHUNK_SIZE - 1 || chunk.get(x + 1, y, z) == BlockId::AIR)) + && occluder(chunk, neighbors, x, y, z, FaceDir::PosX) == BlockId::AIR) .then_some(FaceKey { block, dir: FaceDir::PosX, @@ -156,12 +233,12 @@ pub fn generate_mesh(chunk: &Chunk) -> (Vec, Vec) { &mut indices, |x, z, y| { let block = chunk.get(x, y, z); - (block != BlockId::AIR && (x == 0 || chunk.get(x - 1, y, z) == BlockId::AIR)).then_some( - FaceKey { + (block != BlockId::AIR + && occluder(chunk, neighbors, x, y, z, FaceDir::NegX) == BlockId::AIR) + .then_some(FaceKey { block, dir: FaceDir::NegX, - }, - ) + }) }, |x, z0, y0, w, h| { let (zmin, zmax) = (coord(z0) - 0.5, coord(z0 + w) - 0.5); @@ -184,7 +261,7 @@ pub fn generate_mesh(chunk: &Chunk) -> (Vec, Vec) { |z, x, y| { let block = chunk.get(x, y, z); (block != BlockId::AIR - && (z == CHUNK_SIZE - 1 || chunk.get(x, y, z + 1) == BlockId::AIR)) + && occluder(chunk, neighbors, x, y, z, FaceDir::PosZ) == BlockId::AIR) .then_some(FaceKey { block, dir: FaceDir::PosZ, @@ -210,12 +287,12 @@ pub fn generate_mesh(chunk: &Chunk) -> (Vec, Vec) { &mut indices, |z, x, y| { let block = chunk.get(x, y, z); - (block != BlockId::AIR && (z == 0 || chunk.get(x, y, z - 1) == BlockId::AIR)).then_some( - FaceKey { + (block != BlockId::AIR + && occluder(chunk, neighbors, x, y, z, FaceDir::NegZ) == BlockId::AIR) + .then_some(FaceKey { block, dir: FaceDir::NegZ, - }, - ) + }) }, |z, x0, y0, w, h| { let (xmin, xmax) = (coord(x0) - 0.5, coord(x0 + w) - 0.5); diff --git a/crates/renderer/src/tests/meshing.rs b/crates/renderer/src/tests/meshing.rs index db95dcd..818861a 100644 --- a/crates/renderer/src/tests/meshing.rs +++ b/crates/renderer/src/tests/meshing.rs @@ -34,6 +34,19 @@ fn random_chunk(seed: u64) -> Chunk { chunk } +/// Builds a fully solid chunk (every voxel `BlockId(1)`). +fn solid_chunk() -> Chunk { + let mut chunk = Chunk::default(); + for x in 0..CHUNK_SIZE { + for y in 0..CHUNK_SIZE { + for z in 0..CHUNK_SIZE { + chunk.set(x, y, z, BlockId(1)); + } + } + } + chunk +} + /// Counts exposed unit faces the naive way (out-of-chunk neighbours are air). /// /// Every unit face has area 1, so this count equals the total surface area a correct greedy mesh must reproduce. @@ -86,7 +99,7 @@ fn total_area(vertices: &[Vertex], indices: &[u32]) -> f64 { #[test] fn all_air_chunk_is_empty() { - let (vertices, indices) = generate_mesh(&Chunk::default()); + let (vertices, indices) = generate_mesh(&Chunk::default(), &Neighbors::default()); assert!(vertices.is_empty()); assert!(indices.is_empty()); } @@ -95,7 +108,7 @@ fn all_air_chunk_is_empty() { fn single_block_emits_six_quads() { let mut chunk = Chunk::default(); chunk.set(5, 5, 5, BlockId(1)); - let (vertices, indices) = generate_mesh(&chunk); + let (vertices, indices) = generate_mesh(&chunk, &Neighbors::default()); // Six exposed faces, none mergeable: 6 quads. assert_eq!(vertices.len(), 24); assert_eq!(indices.len(), 36); @@ -111,7 +124,7 @@ fn full_chunk_merges_each_face_into_one_quad() { } } } - let (vertices, indices) = generate_mesh(&chunk); + let (vertices, indices) = generate_mesh(&chunk, &Neighbors::default()); // Only the six boundary planes are exposed, each merging to a single quad. assert_eq!(vertices.len(), 24); assert_eq!(indices.len(), 36); @@ -122,7 +135,7 @@ fn adjacent_pair_culls_shared_face_and_merges_sides() { let mut chunk = Chunk::default(); chunk.set(0, 0, 0, BlockId(1)); chunk.set(1, 0, 0, BlockId(1)); - let (vertices, indices) = generate_mesh(&chunk); + let (vertices, indices) = generate_mesh(&chunk, &Neighbors::default()); // Shared internal face pair is culled; +Y/-Y/+Z/-Z each merge across the pair into one quad, and the two X ends are one quad each: 6 quads total. assert_eq!(vertices.len(), 24); assert_eq!(indices.len(), 36); @@ -136,7 +149,7 @@ fn adjacent_pair_culls_shared_face_and_merges_sides() { fn greedy_area_equals_naive_and_never_more_indices() { for seed in 1..=8u64 { let chunk = random_chunk(seed); - let (vertices, indices) = generate_mesh(&chunk); + let (vertices, indices) = generate_mesh(&chunk, &Neighbors::default()); let naive_faces = count_exposed_faces(&chunk); // Area equality proves no faces were lost, doubled, or misplaced. @@ -154,8 +167,71 @@ fn greedy_area_equals_naive_and_never_more_indices() { } } +#[test] +fn absent_neighbor_emits_boundary_faces() { + // A solid chunk with no neighbours (frontier) still emits all six boundary sheets: None ⇒ air ⇒ emit. + let (vertices, _) = generate_mesh(&solid_chunk(), &Neighbors::default()); + assert_eq!(vertices.len(), 6 * 4); +} + +#[test] +fn solid_neighbor_culls_that_boundary() { + // A solid neighbour on +X occludes the whole +X sheet; the other five boundary planes each still merge to one quad. + let neighbor = solid_chunk(); + let neighbors = Neighbors { + pos_x: Some(&neighbor), + ..Default::default() + }; + let (vertices, indices) = generate_mesh(&solid_chunk(), &neighbors); + assert_eq!(vertices.len(), 5 * 4); + assert_eq!(indices.len(), 5 * 6); +} + +#[test] +fn adjacent_solid_chunks_cull_shared_boundary() { + // Two solid chunks touching along X: the left chunk's +X sheet and the right chunk's -X sheet are both culled. + let left = solid_chunk(); + let right = solid_chunk(); + let (left_verts, _) = generate_mesh( + &left, + &Neighbors { + pos_x: Some(&right), + ..Default::default() + }, + ); + let (right_verts, _) = generate_mesh( + &right, + &Neighbors { + neg_x: Some(&left), + ..Default::default() + }, + ); + assert_eq!(left_verts.len(), 5 * 4); + assert_eq!(right_verts.len(), 5 * 4); +} + +#[test] +fn fully_enclosed_solid_chunk_is_empty() { + // A solid chunk surrounded on all six sides by solid neighbours exposes no faces at all. + let neighbor = solid_chunk(); + let neighbors = Neighbors { + neg_x: Some(&neighbor), + pos_x: Some(&neighbor), + neg_y: Some(&neighbor), + pos_y: Some(&neighbor), + neg_z: Some(&neighbor), + pos_z: Some(&neighbor), + }; + let (vertices, indices) = generate_mesh(&solid_chunk(), &neighbors); + assert!(vertices.is_empty()); + assert!(indices.is_empty()); +} + #[test] fn mesh_is_deterministic() { let chunk = random_chunk(42); - assert_eq!(generate_mesh(&chunk), generate_mesh(&chunk)); + assert_eq!( + generate_mesh(&chunk, &Neighbors::default()), + generate_mesh(&chunk, &Neighbors::default()) + ); } From 79543002b1fe1b6272ba155def319352f1c78a20 Mon Sep 17 00:00:00 2001 From: Serkyo Date: Thu, 23 Jul 2026 03:40:16 +0200 Subject: [PATCH 10/20] feat(client): retain chunk voxels and re-mesh neighbours on load and drop --- crates/client/src/chunks.rs | 290 +++++++++++++++++++++++++++--------- 1 file changed, 221 insertions(+), 69 deletions(-) diff --git a/crates/client/src/chunks.rs b/crates/client/src/chunks.rs index 784581b..9198247 100644 --- a/crates/client/src/chunks.rs +++ b/crates/client/src/chunks.rs @@ -2,25 +2,43 @@ //! Client-side chunk streaming around the camera. -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; +use renderer::meshing::Neighbors; use shared::protocol::chunk::ChunkMessage; -use shared::world::{CHUNK_SIZE, Chunk, ChunkData, ChunkPos}; +use shared::world::{CHUNK_SIZE, Chunk, ChunkPos}; use tracing::{debug, error}; /// 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; -/// Maximum number of chunks meshed and uploaded in a single call to [`ChunkManager::update`], bounding per-frame meshing work so the winit loop stays responsive. Deliveries beyond the budget remain queued for the next frame. -// TODO: move meshing to a worker pool. +/// 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; -/// Tracks which server-streamed chunks are currently uploaded to the renderer. +/// Maximum number of chunks (re)meshed and uploaded per call to [`ChunkManager::update`], draining the pending re-mesh set under a bound so the winit loop stays responsive. One delivery can enqueue up to seven mesh jobs (itself plus six neighbours), so this budget exceeds [`LOADS_PER_UPDATE`]. +// TODO: move meshing to a worker pool; until then this budget caps meshing work on the winit thread. +const MESHES_PER_UPDATE: usize = 16; + +/// The six face-adjacent neighbour offsets, in chunk coordinates. +const NEIGHBOR_OFFSETS: [(i32, i32, i32); 6] = [ + (1, 0, 0), + (-1, 0, 0), + (0, 1, 0), + (0, -1, 0), + (0, 0, 1), + (0, 0, -1), +]; + +/// Tracks which server-streamed chunks are resident and orchestrates neighbour-aware meshing. pub struct ChunkManager { - /// Positions uploaded to the renderer (whether or not they produced a non-empty mesh), so unload and drop can reconcile against the renderer. - resident: HashSet, - /// Reused all-air baseline that server [`ChunkData`] diffs are materialized against. + /// Resident chunks keyed by position, retained so the mesher can sample voxels across chunk boundaries. Stored behind [`Arc`] so a future worker pool can hand a chunk to a thread without copying it. + // TODO: a resident Chunk is 32³ × 2 bytes = 64 KiB; at LOAD_RADIUS = 8 the resident set is thousands of chunks (hundreds of MiB). A follow-up can store only the six 32×32 boundary planes per chunk instead of the full volume. + resident: HashMap>, + /// Positions whose mesh must be rebuilt, accumulated across frames and drained under [`MESHES_PER_UPDATE`]. Held as a set so a burst of deliveries re-meshes each affected neighbour at most once per frame. + pending_remesh: HashSet, + /// Reused all-air baseline that server [`ChunkData`](shared::world::ChunkData) diffs are materialized against. baseline: Chunk, } @@ -29,12 +47,13 @@ impl ChunkManager { #[must_use] pub fn new() -> Self { Self { - resident: HashSet::new(), + resident: HashMap::new(), + pending_remesh: HashSet::new(), baseline: Chunk::default(), } } - /// Reconciles the resident chunk set: evicts chunks outside the load radius around `center`, then applies queued server deliveries under a per-frame meshing budget. + /// Reconciles the resident chunk set: evicts chunks outside the load radius around `center`, applies queued server deliveries under a materialization budget, then drains the pending re-mesh set under a meshing budget. /// /// The client's own radius eviction runs independently of the server's authoritative `Drop`, so memory stays bounded even if the server is slow to drop chunks that leave the region. pub fn update( @@ -44,19 +63,44 @@ impl ChunkManager { renderer: &mut renderer::Renderer, ) { let unloaded = self.unload_outside(center, renderer); + let (loaded, dropped) = self.apply_deliveries(deliveries, renderer); + let meshed = self.drain_remesh(renderer); - let mut loaded = 0; - let mut dropped = 0; - // Only chunk deliveries count against the meshing budget; drops are cheap and always applied. - while loaded < LOADS_PER_UPDATE { + if loaded > 0 || dropped > 0 || unloaded > 0 || meshed > 0 { + debug!( + loaded, + dropped, + unloaded, + meshed, + pending = self.pending_remesh.len(), + resident = self.resident.len(), + "chunk stream reconciled" + ); + } + } + + /// Applies up to [`LOADS_PER_UPDATE`] chunk deliveries plus any interleaved drops, returning the counts of chunks loaded and dropped. + /// + /// Delivered chunks are materialized and retained; drops remove the chunk from residency and from the renderer. Both kinds enqueue the affected neighbourhood for re-meshing. + fn apply_deliveries( + &mut self, + deliveries: &mut net::ChunkStream, + renderer: &mut renderer::Renderer, + ) -> (usize, usize) { + let mut loaded = Vec::new(); + let mut dropped = Vec::new(); + // Only chunk deliveries count against the budget; drops are cheap and always applied. + while loaded.len() < LOADS_PER_UPDATE { match deliveries.try_recv() { Ok(ChunkMessage::Chunk { pos, data }) => { - self.apply_chunk(pos, &data, renderer); - loaded += 1; + let chunk = Arc::new(data.materialize(&self.baseline)); + self.resident.insert(pos, chunk); + loaded.push(pos); } Ok(ChunkMessage::Drop { pos }) => { - if self.drop_chunk(pos, renderer) { - dropped += 1; + if self.resident.remove(&pos).is_some() { + renderer.remove_mesh((pos.x, pos.y, pos.z)); + dropped.push(pos); } } // Empty or disconnected: nothing more to apply this frame. @@ -64,64 +108,18 @@ impl ChunkManager { } } - if loaded > 0 || dropped > 0 || unloaded > 0 { - debug!( - loaded, - dropped, - unloaded, - resident = self.resident.len(), - "chunk stream reconciled" - ); - } - } - - /// Materializes, meshes, and uploads one delivered chunk, marking its position resident. - fn apply_chunk(&mut self, pos: ChunkPos, data: &ChunkData, renderer: &mut renderer::Renderer) { - let chunk = data.materialize(&self.baseline); - let (vertices, indices) = renderer::meshing::generate_mesh(&chunk); - - // Uploading a zero-length buffer is invalid, so an all-air chunk skips the renderer entirely. It is still marked resident below so a later delivery is not double-counted. - if !indices.is_empty() { - // Chunk coordinates and CHUNK_SIZE are small and represent exactly as f32. - #[expect( - clippy::cast_precision_loss, - reason = "chunk coordinates stay well within f32's exact-integer range" - )] - let world_offset = { - let size = CHUNK_SIZE as f32; - [ - pos.x as f32 * size, - pos.y as f32 * size, - pos.z as f32 * size, - ] - }; - - if let Err(e) = - renderer.insert_mesh((pos.x, pos.y, pos.z), &vertices, &indices, world_offset) - { - error!(?pos, "failed to upload chunk mesh: {e}"); - } - } - - self.resident.insert(pos); - } - - /// Removes one chunk from the renderer on the server's authoritative instruction, returning whether it was resident. - fn drop_chunk(&mut self, pos: ChunkPos, renderer: &mut renderer::Renderer) -> bool { - if self.resident.remove(&pos) { - renderer.remove_mesh((pos.x, pos.y, pos.z)); - true - } else { - false - } + self.queue_remesh(&loaded, &dropped); + (loaded.len(), dropped.len()) } /// Evicts every resident chunk outside the load radius around `center`, returning the number removed. + /// + /// Each evicted chunk's resident neighbours have a boundary toward it that is now exposed, so they are enqueued for re-meshing. fn unload_outside(&mut self, center: ChunkPos, renderer: &mut renderer::Renderer) -> usize { let desired = desired_chunks(center, LOAD_RADIUS); let stale: Vec = self .resident - .iter() + .keys() .filter(|pos| !desired.contains(pos)) .copied() .collect(); @@ -129,8 +127,91 @@ impl ChunkManager { renderer.remove_mesh((pos.x, pos.y, pos.z)); self.resident.remove(pos); } + self.queue_remesh(&[], &stale); stale.len() } + + /// Adds the chunks affected by `loaded` and `dropped` to the pending re-mesh set. + fn queue_remesh(&mut self, loaded: &[ChunkPos], dropped: &[ChunkPos]) { + let targets = remesh_targets(loaded, dropped, |pos| self.resident.contains_key(&pos)); + self.pending_remesh.extend(targets); + } + + /// Meshes and uploads up to [`MESHES_PER_UPDATE`] chunks from the pending set, returning the number processed. + /// + /// Positions no longer resident (dropped after being enqueued) are discarded without meshing. + fn drain_remesh(&mut self, renderer: &mut renderer::Renderer) -> usize { + // Take a bounded batch out of the set; the remainder stays queued for later frames. + let batch: Vec = self + .pending_remesh + .iter() + .take(MESHES_PER_UPDATE) + .copied() + .collect(); + + let mut meshed = 0; + for pos in batch { + self.pending_remesh.remove(&pos); + if self.resident.contains_key(&pos) { + self.mesh_and_upload(pos, renderer); + meshed += 1; + } + } + meshed + } + + /// Meshes the resident chunk at `pos` against its resident neighbours and uploads the result to the renderer. + /// + /// A chunk that meshes to no geometry (all air, or fully enclosed by solid neighbours) is removed from the renderer rather than uploaded, since a zero-length buffer is invalid; this also clears any mesh a previous state had left there. + fn mesh_and_upload(&mut self, pos: ChunkPos, renderer: &mut renderer::Renderer) { + let Some(chunk) = self.resident.get(&pos) else { + return; + }; + let neighbors = self.neighbors_of(pos); + let (vertices, indices) = renderer::meshing::generate_mesh(chunk, &neighbors); + + if indices.is_empty() { + renderer.remove_mesh((pos.x, pos.y, pos.z)); + return; + } + + // Chunk coordinates and CHUNK_SIZE are small and represent exactly as f32. + #[expect( + clippy::cast_precision_loss, + reason = "chunk coordinates stay well within f32's exact-integer range" + )] + let world_offset = { + let size = CHUNK_SIZE as f32; + [ + pos.x as f32 * size, + pos.y as f32 * size, + pos.z as f32 * size, + ] + }; + + if let Err(e) = + renderer.insert_mesh((pos.x, pos.y, pos.z), &vertices, &indices, world_offset) + { + error!(?pos, "failed to upload chunk mesh: {e}"); + } + } + + /// Gathers the six face-adjacent resident chunks of `pos` into a [`Neighbors`] set for meshing. + fn neighbors_of(&self, pos: ChunkPos) -> Neighbors<'_> { + let get = |dx, dy, dz| { + self.resident + .get(&ChunkPos::new(pos.x + dx, pos.y + dy, pos.z + dz)) + .map(|chunk| &**chunk) + }; + Neighbors { + pos_x: get(1, 0, 0), + neg_x: get(-1, 0, 0), + pos_y: get(0, 1, 0), + neg_y: get(0, -1, 0), + pos_z: get(0, 0, 1), + neg_z: get(0, 0, -1), + } + } } impl Default for ChunkManager { @@ -139,6 +220,40 @@ impl Default for ChunkManager { } } +/// Returns the six face-adjacent neighbour positions of `pos`. +fn neighbor_positions(pos: ChunkPos) -> [ChunkPos; 6] { + NEIGHBOR_OFFSETS.map(|(dx, dy, dz)| ChunkPos::new(pos.x + dx, pos.y + dy, pos.z + dz)) +} + +/// Computes the deduplicated set of resident chunks whose mesh must be rebuilt after a batch of loads and drops. +/// +/// A newly-loaded chunk contributes itself (when resident) and each of its resident neighbours, whose boundary toward it may now be culled. A dropped chunk contributes only its resident neighbours, whose boundary toward it is re-exposed; the dropped chunk itself is gone and is never a target. `is_resident` reports whether a position is currently resident. +fn remesh_targets( + loaded: &[ChunkPos], + dropped: &[ChunkPos], + is_resident: impl Fn(ChunkPos) -> bool, +) -> HashSet { + let mut targets = HashSet::new(); + for &pos in loaded { + if is_resident(pos) { + targets.insert(pos); + } + for neighbor in neighbor_positions(pos) { + if is_resident(neighbor) { + targets.insert(neighbor); + } + } + } + for &pos in dropped { + for neighbor in neighbor_positions(pos) { + if is_resident(neighbor) { + targets.insert(neighbor); + } + } + } + targets +} + /// Returns the set of chunk positions within the streaming cylinder around `center`. /// /// The region is a disc of `radius` chunks in the horizontal XZ plane and half that extent in Y, matching the flatter vertical shape of the playable world. This mirrors the server's `world_server::cylinder_chunks`. @@ -198,4 +313,41 @@ mod tests { .collect(); assert_eq!(shifted, desired_chunks(ChunkPos::new(-10, -10, -10), 3)); } + + /// Builds a residency predicate over a fixed set of positions. + fn resident_in(set: &[ChunkPos]) -> impl Fn(ChunkPos) -> bool + '_ { + move |pos| set.contains(&pos) + } + + #[test] + fn loaded_chunk_remeshes_self_and_resident_neighbors() { + let p = ChunkPos::new(0, 0, 0); + let east = ChunkPos::new(1, 0, 0); + let down = ChunkPos::new(0, -1, 0); + // p plus two of its six neighbours are resident; the other four are not. + let resident = [p, east, down]; + let targets = remesh_targets(&[p], &[], resident_in(&resident)); + assert_eq!(targets, resident.into_iter().collect()); + } + + #[test] + fn dropped_chunk_remeshes_neighbors_but_not_itself() { + let p = ChunkPos::new(0, 0, 0); + let neighbor = ChunkPos::new(1, 0, 0); + let resident = [neighbor]; + let targets = remesh_targets(&[], &[p], resident_in(&resident)); + // The dropped chunk is never a target; its resident neighbour is. + assert!(!targets.contains(&p)); + assert_eq!(targets, [neighbor].into_iter().collect()); + } + + #[test] + fn remesh_targets_are_deduplicated() { + // Two adjacent chunks loaded in one batch each name the other as a neighbour, but the set holds each once. + let a = ChunkPos::new(0, 0, 0); + let b = ChunkPos::new(1, 0, 0); + let resident = [a, b]; + let targets = remesh_targets(&[a, b], &[], resident_in(&resident)); + assert_eq!(targets, resident.into_iter().collect()); + } } From 5e1b4de98be6dbf6024105c117b7dfefbba45dde Mon Sep 17 00:00:00 2001 From: Serkyo Date: Thu, 23 Jul 2026 03:45:21 +0200 Subject: [PATCH 11/20] test(client): relocate chunk streaming tests into src/tests --- crates/client/src/chunks.rs | 76 +------------------------------ crates/client/src/tests/chunks.rs | 76 +++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 74 deletions(-) create mode 100644 crates/client/src/tests/chunks.rs diff --git a/crates/client/src/chunks.rs b/crates/client/src/chunks.rs index 9198247..ca22e7a 100644 --- a/crates/client/src/chunks.rs +++ b/crates/client/src/chunks.rs @@ -277,77 +277,5 @@ pub fn desired_chunks(center: ChunkPos, radius: i32) -> HashSet { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn center_is_always_included() { - let center = ChunkPos::new(0, 0, 0); - assert!(desired_chunks(center, 4).contains(¢er)); - } - - #[test] - fn excludes_columns_beyond_the_disc() { - let set = desired_chunks(ChunkPos::new(0, 0, 0), 4); - // One chunk past the radius along an axis: squared distance 25 > 16. - assert!(!set.contains(&ChunkPos::new(5, 0, 0))); - // The far corner: squared distance 4*4 + 4*4 = 32 > 16. - assert!(!set.contains(&ChunkPos::new(4, 0, 4))); - } - - #[test] - fn vertical_extent_is_half_the_radius() { - let set = desired_chunks(ChunkPos::new(0, 0, 0), 4); - // radius / 2 == 2, so the column at the center spans y in [-2, 2]. - assert!(set.contains(&ChunkPos::new(0, 2, 0))); - assert!(!set.contains(&ChunkPos::new(0, 3, 0))); - } - - #[test] - fn set_is_translation_invariant() { - // Shifting the center shifts every member by the same offset; this also exercises negative coordinates on the shifted side. - let base = desired_chunks(ChunkPos::new(0, 0, 0), 3); - let shifted: HashSet = base - .iter() - .map(|p| ChunkPos::new(p.x - 10, p.y - 10, p.z - 10)) - .collect(); - assert_eq!(shifted, desired_chunks(ChunkPos::new(-10, -10, -10), 3)); - } - - /// Builds a residency predicate over a fixed set of positions. - fn resident_in(set: &[ChunkPos]) -> impl Fn(ChunkPos) -> bool + '_ { - move |pos| set.contains(&pos) - } - - #[test] - fn loaded_chunk_remeshes_self_and_resident_neighbors() { - let p = ChunkPos::new(0, 0, 0); - let east = ChunkPos::new(1, 0, 0); - let down = ChunkPos::new(0, -1, 0); - // p plus two of its six neighbours are resident; the other four are not. - let resident = [p, east, down]; - let targets = remesh_targets(&[p], &[], resident_in(&resident)); - assert_eq!(targets, resident.into_iter().collect()); - } - - #[test] - fn dropped_chunk_remeshes_neighbors_but_not_itself() { - let p = ChunkPos::new(0, 0, 0); - let neighbor = ChunkPos::new(1, 0, 0); - let resident = [neighbor]; - let targets = remesh_targets(&[], &[p], resident_in(&resident)); - // The dropped chunk is never a target; its resident neighbour is. - assert!(!targets.contains(&p)); - assert_eq!(targets, [neighbor].into_iter().collect()); - } - - #[test] - fn remesh_targets_are_deduplicated() { - // Two adjacent chunks loaded in one batch each name the other as a neighbour, but the set holds each once. - let a = ChunkPos::new(0, 0, 0); - let b = ChunkPos::new(1, 0, 0); - let resident = [a, b]; - let targets = remesh_targets(&[a, b], &[], resident_in(&resident)); - assert_eq!(targets, resident.into_iter().collect()); - } -} +#[path = "tests/chunks.rs"] +mod tests; diff --git a/crates/client/src/tests/chunks.rs b/crates/client/src/tests/chunks.rs new file mode 100644 index 0000000..5895e96 --- /dev/null +++ b/crates/client/src/tests/chunks.rs @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +//! Unit tests for the chunk streaming logic in [`crate::chunks`]. + +use super::*; + +#[test] +fn center_is_always_included() { + let center = ChunkPos::new(0, 0, 0); + assert!(desired_chunks(center, 4).contains(¢er)); +} + +#[test] +fn excludes_columns_beyond_the_disc() { + let set = desired_chunks(ChunkPos::new(0, 0, 0), 4); + // One chunk past the radius along an axis: squared distance 25 > 16. + assert!(!set.contains(&ChunkPos::new(5, 0, 0))); + // The far corner: squared distance 4*4 + 4*4 = 32 > 16. + assert!(!set.contains(&ChunkPos::new(4, 0, 4))); +} + +#[test] +fn vertical_extent_is_half_the_radius() { + let set = desired_chunks(ChunkPos::new(0, 0, 0), 4); + // radius / 2 == 2, so the column at the center spans y in [-2, 2]. + assert!(set.contains(&ChunkPos::new(0, 2, 0))); + assert!(!set.contains(&ChunkPos::new(0, 3, 0))); +} + +#[test] +fn set_is_translation_invariant() { + // Shifting the center shifts every member by the same offset; this also exercises negative coordinates on the shifted side. + let base = desired_chunks(ChunkPos::new(0, 0, 0), 3); + let shifted: HashSet = base + .iter() + .map(|p| ChunkPos::new(p.x - 10, p.y - 10, p.z - 10)) + .collect(); + assert_eq!(shifted, desired_chunks(ChunkPos::new(-10, -10, -10), 3)); +} + +/// Builds a residency predicate over a fixed set of positions. +fn resident_in(set: &[ChunkPos]) -> impl Fn(ChunkPos) -> bool + '_ { + move |pos| set.contains(&pos) +} + +#[test] +fn loaded_chunk_remeshes_self_and_resident_neighbors() { + let p = ChunkPos::new(0, 0, 0); + let east = ChunkPos::new(1, 0, 0); + let down = ChunkPos::new(0, -1, 0); + // p plus two of its six neighbours are resident; the other four are not. + let resident = [p, east, down]; + let targets = remesh_targets(&[p], &[], resident_in(&resident)); + assert_eq!(targets, resident.into_iter().collect()); +} + +#[test] +fn dropped_chunk_remeshes_neighbors_but_not_itself() { + let p = ChunkPos::new(0, 0, 0); + let neighbor = ChunkPos::new(1, 0, 0); + let resident = [neighbor]; + let targets = remesh_targets(&[], &[p], resident_in(&resident)); + // The dropped chunk is never a target; its resident neighbour is. + assert!(!targets.contains(&p)); + assert_eq!(targets, [neighbor].into_iter().collect()); +} + +#[test] +fn remesh_targets_are_deduplicated() { + // Two adjacent chunks loaded in one batch each name the other as a neighbour, but the set holds each once. + let a = ChunkPos::new(0, 0, 0); + let b = ChunkPos::new(1, 0, 0); + let resident = [a, b]; + let targets = remesh_targets(&[a, b], &[], resident_in(&resident)); + assert_eq!(targets, resident.into_iter().collect()); +} From 2abe08bf57251848d0089cf6fa2b1f5680b3f378 Mon Sep 17 00:00:00 2001 From: Serkyo Date: Sun, 26 Jul 2026 20:14:11 +0200 Subject: [PATCH 12/20] chore(client): add crossbeam-channel dependency --- Cargo.lock | 1 + crates/client/Cargo.toml | 1 + 2 files changed, 2 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index ac4e2a2..6c92e96 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -547,6 +547,7 @@ version = "0.1.0" dependencies = [ "anyhow", "ash-window", + "crossbeam-channel", "glam 0.33.2", "net", "raw-window-handle", diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index 18f6000..5f1330a 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -19,3 +19,4 @@ raw-window-handle.workspace = true ash-window.workspace = true shared = { path = "../shared" } net = { version = "0.1.0", path = "../net" } +crossbeam-channel = "0.5.16" From dae0bafcd323d3abdf4711083e163a1744b6c9c8 Mon Sep 17 00:00:00 2001 From: Serkyo Date: Sun, 26 Jul 2026 20:19:27 +0200 Subject: [PATCH 13/20] refactor(client): abstract chunk mesh upload behind a MeshSink trait --- crates/client/src/chunks.rs | 71 ++++++++++++++++++++++++++++--------- 1 file changed, 54 insertions(+), 17 deletions(-) diff --git a/crates/client/src/chunks.rs b/crates/client/src/chunks.rs index ca22e7a..f484112 100644 --- a/crates/client/src/chunks.rs +++ b/crates/client/src/chunks.rs @@ -6,6 +6,8 @@ use std::collections::{HashMap, HashSet}; use std::sync::Arc; use renderer::meshing::Neighbors; +use renderer::vertex::Vertex; +use renderer::{MeshKey, RendererError}; use shared::protocol::chunk::ChunkMessage; use shared::world::{CHUNK_SIZE, Chunk, ChunkPos}; use tracing::{debug, error}; @@ -31,6 +33,43 @@ const NEIGHBOR_OFFSETS: [(i32, i32, i32); 6] = [ (0, 0, -1), ]; +/// Sink that receives finished chunk meshes for upload. +/// +/// The production sink is the Vulkan [`Renderer`](renderer::Renderer); the abstraction exists so the meshing pipeline can be exercised against a recording double in tests, which have no GPU. Method signatures mirror the renderer's exactly so the production `impl` is a direct forward. +pub trait MeshSink { + /// Uploads (or replaces) the mesh identified by `key`. + /// + /// # Errors + /// + /// Returns [`RendererError`] when the underlying implementation fails to allocate or write the GPU buffers for the mesh. + fn insert_mesh( + &mut self, + key: MeshKey, + vertices: &[Vertex], + indices: &[u32], + world_offset: [f32; 3], + ) -> Result<(), RendererError>; + + /// Removes any mesh currently associated with `key`; a no-op when none exists. + fn remove_mesh(&mut self, key: MeshKey); +} + +impl MeshSink for renderer::Renderer { + fn insert_mesh( + &mut self, + key: MeshKey, + vertices: &[Vertex], + indices: &[u32], + world_offset: [f32; 3], + ) -> Result<(), RendererError> { + renderer::Renderer::insert_mesh(self, key, vertices, indices, world_offset) + } + + fn remove_mesh(&mut self, key: MeshKey) { + renderer::Renderer::remove_mesh(self, key); + } +} + /// Tracks which server-streamed chunks are resident and orchestrates neighbour-aware meshing. pub struct ChunkManager { /// Resident chunks keyed by position, retained so the mesher can sample voxels across chunk boundaries. Stored behind [`Arc`] so a future worker pool can hand a chunk to a thread without copying it. @@ -60,11 +99,11 @@ impl ChunkManager { &mut self, center: ChunkPos, deliveries: &mut net::ChunkStream, - renderer: &mut renderer::Renderer, + sink: &mut impl MeshSink, ) { - let unloaded = self.unload_outside(center, renderer); - let (loaded, dropped) = self.apply_deliveries(deliveries, renderer); - let meshed = self.drain_remesh(renderer); + let unloaded = self.unload_outside(center, sink); + let (loaded, dropped) = self.apply_deliveries(deliveries, sink); + let meshed = self.drain_remesh(sink); if loaded > 0 || dropped > 0 || unloaded > 0 || meshed > 0 { debug!( @@ -85,7 +124,7 @@ impl ChunkManager { fn apply_deliveries( &mut self, deliveries: &mut net::ChunkStream, - renderer: &mut renderer::Renderer, + sink: &mut impl MeshSink, ) -> (usize, usize) { let mut loaded = Vec::new(); let mut dropped = Vec::new(); @@ -99,7 +138,7 @@ impl ChunkManager { } Ok(ChunkMessage::Drop { pos }) => { if self.resident.remove(&pos).is_some() { - renderer.remove_mesh((pos.x, pos.y, pos.z)); + sink.remove_mesh((pos.x, pos.y, pos.z)); dropped.push(pos); } } @@ -115,7 +154,7 @@ impl ChunkManager { /// Evicts every resident chunk outside the load radius around `center`, returning the number removed. /// /// Each evicted chunk's resident neighbours have a boundary toward it that is now exposed, so they are enqueued for re-meshing. - fn unload_outside(&mut self, center: ChunkPos, renderer: &mut renderer::Renderer) -> usize { + fn unload_outside(&mut self, center: ChunkPos, sink: &mut impl MeshSink) -> usize { let desired = desired_chunks(center, LOAD_RADIUS); let stale: Vec = self .resident @@ -124,7 +163,7 @@ impl ChunkManager { .copied() .collect(); for pos in &stale { - renderer.remove_mesh((pos.x, pos.y, pos.z)); + sink.remove_mesh((pos.x, pos.y, pos.z)); self.resident.remove(pos); } self.queue_remesh(&[], &stale); @@ -140,7 +179,7 @@ impl ChunkManager { /// Meshes and uploads up to [`MESHES_PER_UPDATE`] chunks from the pending set, returning the number processed. /// /// Positions no longer resident (dropped after being enqueued) are discarded without meshing. - fn drain_remesh(&mut self, renderer: &mut renderer::Renderer) -> usize { + fn drain_remesh(&mut self, sink: &mut impl MeshSink) -> usize { // Take a bounded batch out of the set; the remainder stays queued for later frames. let batch: Vec = self .pending_remesh @@ -153,17 +192,17 @@ impl ChunkManager { for pos in batch { self.pending_remesh.remove(&pos); if self.resident.contains_key(&pos) { - self.mesh_and_upload(pos, renderer); + self.mesh_and_upload(pos, sink); meshed += 1; } } meshed } - /// Meshes the resident chunk at `pos` against its resident neighbours and uploads the result to the renderer. + /// Meshes the resident chunk at `pos` against its resident neighbours and uploads the result to the sink. /// - /// A chunk that meshes to no geometry (all air, or fully enclosed by solid neighbours) is removed from the renderer rather than uploaded, since a zero-length buffer is invalid; this also clears any mesh a previous state had left there. - fn mesh_and_upload(&mut self, pos: ChunkPos, renderer: &mut renderer::Renderer) { + /// A chunk that meshes to no geometry (all air, or fully enclosed by solid neighbours) is removed from the sink rather than uploaded, since a zero-length buffer is invalid; this also clears any mesh a previous state had left there. + fn mesh_and_upload(&mut self, pos: ChunkPos, sink: &mut impl MeshSink) { let Some(chunk) = self.resident.get(&pos) else { return; }; @@ -171,7 +210,7 @@ impl ChunkManager { let (vertices, indices) = renderer::meshing::generate_mesh(chunk, &neighbors); if indices.is_empty() { - renderer.remove_mesh((pos.x, pos.y, pos.z)); + sink.remove_mesh((pos.x, pos.y, pos.z)); return; } @@ -189,9 +228,7 @@ impl ChunkManager { ] }; - if let Err(e) = - renderer.insert_mesh((pos.x, pos.y, pos.z), &vertices, &indices, world_offset) - { + if let Err(e) = sink.insert_mesh((pos.x, pos.y, pos.z), &vertices, &indices, world_offset) { error!(?pos, "failed to upload chunk mesh: {e}"); } } From a5af42f119030acdc4befbc7b1fd7fedbd2c5ba8 Mon Sep 17 00:00:00 2001 From: Serkyo Date: Sun, 26 Jul 2026 20:19:59 +0200 Subject: [PATCH 14/20] feat(client): mesh chunks on a background worker pool --- crates/client/src/chunks.rs | 202 +++++++++++++++++++----------- crates/client/src/main.rs | 1 + crates/client/src/mesh_pool.rs | 150 ++++++++++++++++++++++ crates/client/src/tests/chunks.rs | 154 +++++++++++++++++++++++ 4 files changed, 436 insertions(+), 71 deletions(-) create mode 100644 crates/client/src/mesh_pool.rs diff --git a/crates/client/src/chunks.rs b/crates/client/src/chunks.rs index f484112..1d7405b 100644 --- a/crates/client/src/chunks.rs +++ b/crates/client/src/chunks.rs @@ -5,13 +5,14 @@ use std::collections::{HashMap, HashSet}; use std::sync::Arc; -use renderer::meshing::Neighbors; use renderer::vertex::Vertex; use renderer::{MeshKey, RendererError}; use shared::protocol::chunk::ChunkMessage; use shared::world::{CHUNK_SIZE, Chunk, ChunkPos}; use tracing::{debug, error}; +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; @@ -19,11 +20,10 @@ pub const LOAD_RADIUS: i32 = 8; /// 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; -/// Maximum number of chunks (re)meshed and uploaded per call to [`ChunkManager::update`], draining the pending re-mesh set under a bound so the winit loop stays responsive. One delivery can enqueue up to seven mesh jobs (itself plus six neighbours), so this budget exceeds [`LOADS_PER_UPDATE`]. -// TODO: move meshing to a worker pool; until then this budget caps meshing work on the winit thread. +/// Maximum number of mesh jobs dispatched to the worker pool per call to [`ChunkManager::update`], draining the pending re-mesh set under a bound so a burst of deliveries does not flood the pool in a single frame. One delivery can enqueue up to seven mesh jobs (itself plus six neighbours), so this budget exceeds [`LOADS_PER_UPDATE`]. Finished meshes are ingested without a per-frame bound, since uploading already-computed geometry is cheap relative to generating it. const MESHES_PER_UPDATE: usize = 16; -/// The six face-adjacent neighbour offsets, in chunk coordinates. +/// The six face-adjacent neighbour offsets, in chunk coordinates. The order matches the neighbour array carried by [`MeshJob`]: `[+X, -X, +Y, -Y, +Z, -Z]`. const NEIGHBOR_OFFSETS: [(i32, i32, i32); 6] = [ (1, 0, 0), (-1, 0, 0), @@ -35,7 +35,7 @@ const NEIGHBOR_OFFSETS: [(i32, i32, i32); 6] = [ /// Sink that receives finished chunk meshes for upload. /// -/// The production sink is the Vulkan [`Renderer`](renderer::Renderer); the abstraction exists so the meshing pipeline can be exercised against a recording double in tests, which have no GPU. Method signatures mirror the renderer's exactly so the production `impl` is a direct forward. +/// The production sink is the Vulkan [`Renderer`](renderer::Renderer); the abstraction exists so the ingest pipeline can be exercised against a recording double in tests, which have no GPU. Method signatures mirror the renderer's exactly so the production `impl` is a direct forward. pub trait MeshSink { /// Uploads (or replaces) the mesh identified by `key`. /// @@ -70,29 +70,38 @@ impl MeshSink for renderer::Renderer { } } -/// Tracks which server-streamed chunks are resident and orchestrates neighbour-aware meshing. +/// Tracks which server-streamed chunks are resident and orchestrates neighbour-aware background meshing. pub struct ChunkManager { - /// Resident chunks keyed by position, retained so the mesher can sample voxels across chunk boundaries. Stored behind [`Arc`] so a future worker pool can hand a chunk to a thread without copying it. + /// Resident chunks keyed by position, retained so the mesher can sample voxels across chunk boundaries. Stored behind [`Arc`] so a chunk can be handed to a worker thread without copying its 64 KiB volume. // TODO: a resident Chunk is 32³ × 2 bytes = 64 KiB; at LOAD_RADIUS = 8 the resident set is thousands of chunks (hundreds of MiB). A follow-up can store only the six 32×32 boundary planes per chunk instead of the full volume. resident: HashMap>, - /// Positions whose mesh must be rebuilt, accumulated across frames and drained under [`MESHES_PER_UPDATE`]. Held as a set so a burst of deliveries re-meshes each affected neighbour at most once per frame. + /// Positions whose mesh must be rebuilt, accumulated across frames and dispatched under [`MESHES_PER_UPDATE`]. Held as a set so a burst of deliveries re-meshes each affected neighbour at most once. pending_remesh: HashSet, + /// Positions with a mesh job currently outstanding, mapped to the generation of that job. A returned mesh is applied only when its generation still matches, so meshes superseded by a re-dispatch (or by eviction) are discarded rather than uploaded stale. + in_flight: HashMap, + /// The generation stamped on the next dispatched job. Global and strictly increasing across all positions, so no two dispatches ever share a token; see [`JobGen`]. + next_gen: JobGen, + /// Background worker pool that turns chunks into CPU geometry off the winit thread. + pool: MeshPool, /// Reused all-air baseline that server [`ChunkData`](shared::world::ChunkData) diffs are materialized against. baseline: Chunk, } impl ChunkManager { - /// Creates a manager with no chunks yet resident. + /// Creates a manager with no chunks yet resident, spawning the background mesh worker pool. #[must_use] pub fn new() -> Self { Self { resident: HashMap::new(), pending_remesh: HashSet::new(), + in_flight: HashMap::new(), + next_gen: JobGen::FIRST, + pool: MeshPool::new(), baseline: Chunk::default(), } } - /// Reconciles the resident chunk set: evicts chunks outside the load radius around `center`, applies queued server deliveries under a materialization budget, then drains the pending re-mesh set under a meshing budget. + /// Advances the streaming pipeline for one frame: ingests finished meshes from the pool, evicts chunks outside the load radius around `center`, applies queued server deliveries under a materialization budget, then dispatches pending re-mesh jobs under a dispatch budget. /// /// The client's own radius eviction runs independently of the server's authoritative `Drop`, so memory stays bounded even if the server is slow to drop chunks that leave the region. pub fn update( @@ -101,26 +110,57 @@ impl ChunkManager { deliveries: &mut net::ChunkStream, sink: &mut impl MeshSink, ) { + let applied = self.drain_results(sink); let unloaded = self.unload_outside(center, sink); let (loaded, dropped) = self.apply_deliveries(deliveries, sink); - let meshed = self.drain_remesh(sink); + let dispatched = self.dispatch_pending(); - if loaded > 0 || dropped > 0 || unloaded > 0 || meshed > 0 { + if loaded > 0 || dropped > 0 || unloaded > 0 || applied > 0 || dispatched > 0 { debug!( loaded, dropped, unloaded, - meshed, + dispatched, + applied, pending = self.pending_remesh.len(), + in_flight = self.in_flight.len(), resident = self.resident.len(), "chunk stream reconciled" ); } } + /// Ingests every finished mesh currently available from the pool, uploading the ones that are still current and discarding superseded or evicted ones. Returns the number uploaded. + fn drain_results(&mut self, sink: &mut impl MeshSink) -> usize { + let mut applied = 0; + while let Some(result) = self.pool.poll() { + if self.apply_result(&result, sink) { + applied += 1; + } + } + applied + } + + /// Uploads a single finished mesh when it is still current, returning whether it was uploaded. + /// + /// A result is current when its position is still resident and its generation matches the latest job dispatched for that position (see [`should_apply`]). On a match the in-flight entry is cleared; otherwise the result is dropped and any newer outstanding job for the position is left untouched. + fn apply_result(&mut self, result: &MeshResult, sink: &mut impl MeshSink) -> bool { + if !should_apply( + result.pos, + result.generation, + |pos| self.resident.contains_key(&pos), + &self.in_flight, + ) { + return false; + } + self.in_flight.remove(&result.pos); + upload_result(result, sink); + true + } + /// Applies up to [`LOADS_PER_UPDATE`] chunk deliveries plus any interleaved drops, returning the counts of chunks loaded and dropped. /// - /// Delivered chunks are materialized and retained; drops remove the chunk from residency and from the renderer. Both kinds enqueue the affected neighbourhood for re-meshing. + /// Delivered chunks are materialized and retained; drops remove the chunk from residency, from any in-flight tracking, and from the renderer. Both kinds enqueue the affected neighbourhood for re-meshing. fn apply_deliveries( &mut self, deliveries: &mut net::ChunkStream, @@ -138,6 +178,7 @@ impl ChunkManager { } Ok(ChunkMessage::Drop { pos }) => { if self.resident.remove(&pos).is_some() { + self.in_flight.remove(&pos); sink.remove_mesh((pos.x, pos.y, pos.z)); dropped.push(pos); } @@ -153,7 +194,7 @@ impl ChunkManager { /// Evicts every resident chunk outside the load radius around `center`, returning the number removed. /// - /// Each evicted chunk's resident neighbours have a boundary toward it that is now exposed, so they are enqueued for re-meshing. + /// Each evicted chunk is removed from residency, from in-flight tracking, and from the renderer; its resident neighbours have a boundary toward it that is now exposed, so they are enqueued for re-meshing. fn unload_outside(&mut self, center: ChunkPos, sink: &mut impl MeshSink) -> usize { let desired = desired_chunks(center, LOAD_RADIUS); let stale: Vec = self @@ -165,6 +206,7 @@ impl ChunkManager { for pos in &stale { sink.remove_mesh((pos.x, pos.y, pos.z)); self.resident.remove(pos); + self.in_flight.remove(pos); } self.queue_remesh(&[], &stale); stale.len() @@ -176,10 +218,10 @@ impl ChunkManager { self.pending_remesh.extend(targets); } - /// Meshes and uploads up to [`MESHES_PER_UPDATE`] chunks from the pending set, returning the number processed. + /// Dispatches up to [`MESHES_PER_UPDATE`] pending re-mesh jobs to the worker pool, returning the number dispatched. /// - /// Positions no longer resident (dropped after being enqueued) are discarded without meshing. - fn drain_remesh(&mut self, sink: &mut impl MeshSink) -> usize { + /// Each dispatched position snapshots its chunk and current resident neighbours behind [`Arc`]s, is stamped with a fresh generation, and is recorded as in-flight (superseding any previous outstanding job for it). Positions no longer resident (dropped after being enqueued) are skipped without dispatch. + fn dispatch_pending(&mut self) -> usize { // Take a bounded batch out of the set; the remainder stays queued for later frames. let batch: Vec = self .pending_remesh @@ -188,66 +230,41 @@ impl ChunkManager { .copied() .collect(); - let mut meshed = 0; + let mut dispatched = 0; for pos in batch { self.pending_remesh.remove(&pos); - if self.resident.contains_key(&pos) { - self.mesh_and_upload(pos, sink); - meshed += 1; - } + let Some(chunk) = self.resident.get(&pos) else { + continue; + }; + let chunk = Arc::clone(chunk); + let neighbors = self.neighbor_arcs(pos); + let generation = self.bump_gen(); + self.in_flight.insert(pos, generation); + self.pool.dispatch(MeshJob { + pos, + generation, + chunk, + neighbors, + }); + dispatched += 1; } - meshed + dispatched } - /// Meshes the resident chunk at `pos` against its resident neighbours and uploads the result to the sink. - /// - /// A chunk that meshes to no geometry (all air, or fully enclosed by solid neighbours) is removed from the sink rather than uploaded, since a zero-length buffer is invalid; this also clears any mesh a previous state had left there. - fn mesh_and_upload(&mut self, pos: ChunkPos, sink: &mut impl MeshSink) { - let Some(chunk) = self.resident.get(&pos) else { - return; - }; - let neighbors = self.neighbors_of(pos); - let (vertices, indices) = renderer::meshing::generate_mesh(chunk, &neighbors); - - if indices.is_empty() { - sink.remove_mesh((pos.x, pos.y, pos.z)); - return; - } - - // Chunk coordinates and CHUNK_SIZE are small and represent exactly as f32. - #[expect( - clippy::cast_precision_loss, - reason = "chunk coordinates stay well within f32's exact-integer range" - )] - let world_offset = { - let size = CHUNK_SIZE as f32; - [ - pos.x as f32 * size, - pos.y as f32 * size, - pos.z as f32 * size, - ] - }; - - if let Err(e) = sink.insert_mesh((pos.x, pos.y, pos.z), &vertices, &indices, world_offset) { - error!(?pos, "failed to upload chunk mesh: {e}"); - } - } - - /// Gathers the six face-adjacent resident chunks of `pos` into a [`Neighbors`] set for meshing. - fn neighbors_of(&self, pos: ChunkPos) -> Neighbors<'_> { - let get = |dx, dy, dz| { + /// Snapshots the six face-adjacent resident chunks of `pos` as [`Arc`] handles, ordered to match [`NEIGHBOR_OFFSETS`]. Absent neighbours are `None`. + fn neighbor_arcs(&self, pos: ChunkPos) -> [Option>; 6] { + NEIGHBOR_OFFSETS.map(|(dx, dy, dz)| { self.resident .get(&ChunkPos::new(pos.x + dx, pos.y + dy, pos.z + dz)) - .map(|chunk| &**chunk) - }; - Neighbors { - pos_x: get(1, 0, 0), - neg_x: get(-1, 0, 0), - pos_y: get(0, 1, 0), - neg_y: get(0, -1, 0), - pos_z: get(0, 0, 1), - neg_z: get(0, 0, -1), - } + .map(Arc::clone) + }) + } + + /// Returns a fresh, never-before-used generation and advances the counter. + fn bump_gen(&mut self) -> JobGen { + let current = self.next_gen; + self.next_gen = self.next_gen.next(); + current } } @@ -257,6 +274,49 @@ impl Default for ChunkManager { } } +/// Reports whether a finished mesh should be uploaded. +/// +/// A mesh is current, and therefore applied, only when its position is still wanted (resident) and the generation recorded as in-flight for that position still equals the mesh's own generation. A missing in-flight entry (the position was evicted) or a mismatched generation (a newer job superseded this one) both mean the result is stale and must be discarded. +fn should_apply( + pos: ChunkPos, + generation: JobGen, + is_wanted: impl Fn(ChunkPos) -> bool, + in_flight: &HashMap, +) -> bool { + is_wanted(pos) && in_flight.get(&pos) == Some(&generation) +} + +/// Uploads a finished mesh to the sink, or clears the slot when the mesh is empty. +/// +/// A chunk that meshes to no geometry (all air, or fully enclosed by solid neighbours) is removed from the sink rather than uploaded, since a zero-length buffer is invalid; this also clears any mesh a previous state had left there. +fn upload_result(result: &MeshResult, sink: &mut impl MeshSink) { + let pos = result.pos; + let key = (pos.x, pos.y, pos.z); + + if result.indices.is_empty() { + sink.remove_mesh(key); + return; + } + + // Chunk coordinates and CHUNK_SIZE are small and represent exactly as f32. + #[expect( + clippy::cast_precision_loss, + reason = "chunk coordinates stay well within f32's exact-integer range" + )] + let world_offset = { + let size = CHUNK_SIZE as f32; + [ + pos.x as f32 * size, + pos.y as f32 * size, + pos.z as f32 * size, + ] + }; + + if let Err(e) = sink.insert_mesh(key, &result.vertices, &result.indices, world_offset) { + error!(?pos, "failed to upload chunk mesh: {e}"); + } +} + /// Returns the six face-adjacent neighbour positions of `pos`. fn neighbor_positions(pos: ChunkPos) -> [ChunkPos; 6] { NEIGHBOR_OFFSETS.map(|(dx, dy, dz)| ChunkPos::new(pos.x + dx, pos.y + dy, pos.z + dz)) diff --git a/crates/client/src/main.rs b/crates/client/src/main.rs index f5a816e..941fb3d 100644 --- a/crates/client/src/main.rs +++ b/crates/client/src/main.rs @@ -6,6 +6,7 @@ mod camera; mod chunks; +mod mesh_pool; use std::time::Instant; diff --git a/crates/client/src/mesh_pool.rs b/crates/client/src/mesh_pool.rs new file mode 100644 index 0000000..cb8643e --- /dev/null +++ b/crates/client/src/mesh_pool.rs @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +//! Background worker pool that meshes chunks off the winit thread. + +use std::num::NonZero; +use std::sync::Arc; +use std::thread::JoinHandle; + +use crossbeam_channel::{Receiver, Sender}; +use renderer::meshing::{Neighbors, generate_mesh}; +use renderer::vertex::Vertex; +use shared::world::{Chunk, ChunkPos}; + +/// Monotonic staleness token stamped on every dispatched [`MeshJob`]. +/// +/// Between dispatching a job for a position and the worker returning it, that position may have been evicted or re-dispatched with fresher neighbours (a neighbour loaded or dropped). A returned mesh is applied only when its generation still matches the latest generation recorded for the position; older generations are superseded and discarded. +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] +pub(crate) struct JobGen(u64); + +impl JobGen { + /// The generation of the first job ever dispatched. + pub(crate) const FIRST: Self = Self(0); + + /// Returns the next generation after `self`. + /// + /// Wraps on overflow rather than panicking; wrap-around requires 2^64 dispatches in one session, at which point a collision would additionally require the wrapped-to job to still be outstanding, which is unreachable in practice. + pub(crate) fn next(self) -> Self { + Self(self.0.wrapping_add(1)) + } +} + +/// A unit of meshing work handed to a worker: an owned snapshot so the worker borrows nothing from the manager. +/// +/// The chunk and its neighbours are carried as [`Arc`] handles so dispatch is a cheap refcount bump rather than a copy of the 64 KiB voxel volume. Meshing is neighbour-dependent (boundary faces are culled against adjacent chunks), so the six face-adjacent neighbours are snapshotted at dispatch time; a `None` entry means that neighbour is not resident and the boundary is treated as exposed. +pub(crate) struct MeshJob { + /// Chunk-space position of the chunk to mesh. + pub(crate) pos: ChunkPos, + /// Staleness token identifying this dispatch; echoed back on the result. + pub(crate) generation: JobGen, + /// The chunk to mesh. + pub(crate) chunk: Arc, + /// The six face-adjacent neighbours, ordered `[+X, -X, +Y, -Y, +Z, -Z]` to match `chunks::NEIGHBOR_OFFSETS`. `None` marks an absent neighbour. + pub(crate) neighbors: [Option>; 6], +} + +/// A finished mesh returned from a worker to the main thread for upload. +pub(crate) struct MeshResult { + /// Chunk-space position the mesh belongs to. + pub(crate) pos: ChunkPos, + /// Generated vertices; empty when the chunk meshes to no geometry. + pub(crate) vertices: Vec, + /// Generated triangle indices; empty when the chunk meshes to no geometry. + pub(crate) indices: Vec, + /// The generation stamped on the originating [`MeshJob`], used to discard superseded results. + pub(crate) generation: JobGen, +} + +/// A pool of worker threads that mesh chunks and return CPU geometry. +pub(crate) struct MeshPool { + /// Sending end of the job queue; the main thread pushes [`MeshJob`]s. + job_tx: Sender, + /// Receiving end of the result queue; the main thread drains finished meshes. + result_rx: Receiver, + /// Handles to the worker threads, retained for a future graceful-stop path that drops `job_tx` and joins them; the process currently relies on OS teardown at exit. + #[expect( + dead_code, + reason = "retained for a future graceful-shutdown join path, mirroring the server pool" + )] + workers: Vec>, +} + +impl MeshPool { + /// Spawns the worker pool, sizing it to leave one logical core for the main thread. + pub(crate) fn new() -> Self { + let (job_tx, job_rx) = crossbeam_channel::unbounded::(); + let (result_tx, result_rx) = crossbeam_channel::unbounded::(); + + // One worker per logical core, less one to keep the winit thread responsive, but never fewer than one. + let cores = std::thread::available_parallelism().map_or(4, NonZero::get); + let worker_count = cores.saturating_sub(1).max(1); + + let workers = (0..worker_count) + .map(|_| { + // Each worker owns its own clone of the shared job queue and of the sender back into the result queue. + let job_rx = job_rx.clone(); + let result_tx = result_tx.clone(); + + std::thread::spawn(move || { + // Block until a job arrives; a blocking recv is fine off the main thread. + while let Ok(job) = job_rx.recv() { + let (vertices, indices) = mesh_job(&job); + let result = MeshResult { + pos: job.pos, + vertices, + indices, + generation: job.generation, + }; + // A send error means the main thread has gone away; the worker winds down. + if result_tx.send(result).is_err() { + break; + } + } + }) + }) + .collect(); + + // Drop the template ends left over after cloning so the channels close once the real holders are gone: workers observe job-channel shutdown, and the main thread observes result-channel shutdown. + drop(job_rx); + drop(result_tx); + + Self { + job_tx, + result_rx, + workers, + } + } + + /// Enqueues a meshing job for the pool. + /// + /// A send error (the workers have shut down) is ignored: there is nothing useful to do with the job, and shutdown only happens at process teardown. + pub(crate) fn dispatch(&self, job: MeshJob) { + let _ = self.job_tx.send(job); + } + + /// Returns the next finished mesh without blocking, or `None` when none is ready. + pub(crate) fn poll(&self) -> Option { + self.result_rx.try_recv().ok() + } +} + +impl Default for MeshPool { + fn default() -> Self { + Self::new() + } +} + +/// Reconstructs a borrowed [`Neighbors`] view from a job's owned neighbour [`Arc`]s and meshes the chunk. +/// +/// The [`Neighbors`] view borrows `&Chunk` out of the job's `Arc`s, so it is built and consumed here in one scope while those `Arc`s are still alive. +fn mesh_job(job: &MeshJob) -> (Vec, Vec) { + let neighbors = Neighbors { + pos_x: job.neighbors[0].as_deref(), + neg_x: job.neighbors[1].as_deref(), + pos_y: job.neighbors[2].as_deref(), + neg_y: job.neighbors[3].as_deref(), + pos_z: job.neighbors[4].as_deref(), + neg_z: job.neighbors[5].as_deref(), + }; + generate_mesh(&job.chunk, &neighbors) +} diff --git a/crates/client/src/tests/chunks.rs b/crates/client/src/tests/chunks.rs index 5895e96..d65ba8c 100644 --- a/crates/client/src/tests/chunks.rs +++ b/crates/client/src/tests/chunks.rs @@ -2,6 +2,10 @@ //! Unit tests for the chunk streaming logic in [`crate::chunks`]. +use std::time::{Duration, Instant}; + +use shared::world::BlockId; + use super::*; #[test] @@ -74,3 +78,153 @@ fn remesh_targets_are_deduplicated() { let targets = remesh_targets(&[a, b], &[], resident_in(&resident)); assert_eq!(targets, resident.into_iter().collect()); } + +// --- Staleness decision (`should_apply`) --------------------------------- + +#[test] +fn should_apply_accepts_current_result() { + let pos = ChunkPos::new(1, 2, 3); + let generation = JobGen::FIRST; + let mut in_flight = HashMap::new(); + in_flight.insert(pos, generation); + // Resident and generation matches the outstanding job: apply. + assert!(should_apply(pos, generation, |_| true, &in_flight)); +} + +#[test] +fn should_apply_rejects_stale_generation() { + let pos = ChunkPos::new(0, 0, 0); + let mut in_flight = HashMap::new(); + // A newer job (next generation) is outstanding for the position. + in_flight.insert(pos, JobGen::FIRST.next()); + // The result carries the older generation and must be discarded. + assert!(!should_apply(pos, JobGen::FIRST, |_| true, &in_flight)); +} + +#[test] +fn should_apply_rejects_unwanted_position() { + let pos = ChunkPos::new(0, 0, 0); + let generation = JobGen::FIRST; + let mut in_flight = HashMap::new(); + in_flight.insert(pos, generation); + // The position is no longer resident even though a job is tracked. + assert!(!should_apply(pos, generation, |_| false, &in_flight)); +} + +#[test] +fn should_apply_rejects_missing_in_flight() { + let pos = ChunkPos::new(0, 0, 0); + // No job is tracked for the position (it was evicted after dispatch). + let in_flight = HashMap::new(); + assert!(!should_apply(pos, JobGen::FIRST, |_| true, &in_flight)); +} + +// --- Ingest pipeline plumbing -------------------------------------------- + +/// Recording [`MeshSink`] double capturing the keys passed to it, so ingest can be exercised without a GPU. +#[derive(Default)] +struct RecordingSink { + /// Keys uploaded via [`MeshSink::insert_mesh`], in call order. + inserted: Vec, + /// Keys cleared via [`MeshSink::remove_mesh`], in call order. + removed: Vec, +} + +impl MeshSink for RecordingSink { + fn insert_mesh( + &mut self, + key: MeshKey, + _vertices: &[Vertex], + _indices: &[u32], + _world_offset: [f32; 3], + ) -> Result<(), RendererError> { + self.inserted.push(key); + Ok(()) + } + + fn remove_mesh(&mut self, key: MeshKey) { + self.removed.push(key); + } +} + +/// Builds a chunk with a single solid block so it meshes to non-empty geometry. +fn solid_chunk() -> Chunk { + let mut chunk = Chunk::default(); + chunk.set(0, 0, 0, BlockId(1)); + chunk +} + +/// Blocks until the pool yields a finished mesh, panicking if none arrives within a generous deadline. +fn wait_for_result(pool: &MeshPool) -> MeshResult { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + if let Some(result) = pool.poll() { + return result; + } + assert!( + Instant::now() < deadline, + "worker pool did not return a mesh within the deadline" + ); + std::thread::sleep(Duration::from_millis(1)); + } +} + +#[test] +fn finished_mesh_is_uploaded() { + let mut manager = ChunkManager::new(); + let pos = ChunkPos::new(0, 0, 0); + manager.resident.insert(pos, Arc::new(solid_chunk())); + manager.pending_remesh.insert(pos); + + assert_eq!(manager.dispatch_pending(), 1); + let result = wait_for_result(&manager.pool); + + let mut sink = RecordingSink::default(); + assert!(manager.apply_result(&result, &mut sink)); + // A non-empty mesh is uploaded once and the in-flight entry is cleared. + assert_eq!(sink.inserted, vec![(0, 0, 0)]); + assert!(sink.removed.is_empty()); + assert!(!manager.in_flight.contains_key(&pos)); +} + +#[test] +fn superseded_mesh_is_discarded() { + let mut manager = ChunkManager::new(); + let pos = ChunkPos::new(0, 0, 0); + manager.resident.insert(pos, Arc::new(solid_chunk())); + manager.pending_remesh.insert(pos); + + manager.dispatch_pending(); + let stale = wait_for_result(&manager.pool); + + // A newer job supersedes the outstanding one before the first result is applied. + manager.pending_remesh.insert(pos); + manager.dispatch_pending(); + + let mut sink = RecordingSink::default(); + assert!(!manager.apply_result(&stale, &mut sink)); + assert!(sink.inserted.is_empty()); + assert!(sink.removed.is_empty()); + // The newer job remains tracked as outstanding. + assert!(manager.in_flight.contains_key(&pos)); +} + +#[test] +fn evicted_mesh_is_discarded() { + let mut manager = ChunkManager::new(); + let pos = ChunkPos::new(0, 0, 0); + manager.resident.insert(pos, Arc::new(solid_chunk())); + manager.pending_remesh.insert(pos); + + manager.dispatch_pending(); + let result = wait_for_result(&manager.pool); + + // The chunk leaves the load radius before its mesh arrives. + manager.resident.remove(&pos); + manager.in_flight.remove(&pos); + + let mut sink = RecordingSink::default(); + assert!(!manager.apply_result(&result, &mut sink)); + assert!(sink.inserted.is_empty()); + assert!(sink.removed.is_empty()); +} From 5f4b30a4dc1c5f8f7422b9eeaf58d7b0fded6047 Mon Sep 17 00:00:00 2001 From: Serkyo Date: Mon, 27 Jul 2026 21:10:26 +0200 Subject: [PATCH 15/20] feat(renderer): add selectable debug render modes --- crates/renderer/src/device.rs | 4 +++ crates/renderer/src/lib.rs | 18 +++++++--- crates/renderer/src/pipeline.rs | 3 +- crates/renderer/src/renderer.rs | 60 ++++++++++++++++++++++++++++++--- 4 files changed, 76 insertions(+), 9 deletions(-) diff --git a/crates/renderer/src/device.rs b/crates/renderer/src/device.rs index 6fd461a..48ef9d6 100644 --- a/crates/renderer/src/device.rs +++ b/crates/renderer/src/device.rs @@ -53,9 +53,13 @@ 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); + let create_info = vk::DeviceCreateInfo::default() .queue_create_infos(std::slice::from_ref(&queue_info)) .enabled_extension_names(&device_extensions) + .enabled_features(&enabled_features) .push_next(&mut synchronization2_features) .push_next(&mut dynamic_rendering_features); diff --git a/crates/renderer/src/lib.rs b/crates/renderer/src/lib.rs index 70e235b..195ec25 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, Renderer}; +pub use renderer::{MeshKey, RenderMode, Renderer}; use std::collections::HashMap; @@ -124,8 +124,17 @@ impl Renderer { // 12. Graphics Pipeline Configuration let pipeline_layout = pipeline::create_pipeline_layout(&device)?; - let graphics_pipeline = - pipeline::create_graphics_pipeline(&device, pipeline_layout, swapchain_format)?; + + // 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( + &device, + pipeline_layout, + swapchain_format, + mode.polygon_mode(), + )?; + } let (depth_image, depth_allocation, depth_image_view) = create_depth_resources(&device, &mut allocator, swapchain_extent)?; @@ -155,7 +164,8 @@ impl Renderer { depth_allocation: Some(depth_allocation), depth_image_view, pipeline_layout, - graphics_pipeline, + pipelines, + render_mode: RenderMode::default(), sync: Some(sync), current_frame: 0, }) diff --git a/crates/renderer/src/pipeline.rs b/crates/renderer/src/pipeline.rs index f8aa16b..d0e175d 100644 --- a/crates/renderer/src/pipeline.rs +++ b/crates/renderer/src/pipeline.rs @@ -67,6 +67,7 @@ pub fn create_graphics_pipeline( device: &Device, layout: vk::PipelineLayout, color_format: vk::Format, + polygon_mode: vk::PolygonMode, ) -> Result { // 1. Load and compile shader modules let (vert_module, frag_module) = load_shader_modules(device)?; @@ -101,7 +102,7 @@ pub fn create_graphics_pipeline( let rasterizer = vk::PipelineRasterizationStateCreateInfo::default() .depth_clamp_enable(false) .rasterizer_discard_enable(false) - .polygon_mode(vk::PolygonMode::FILL) + .polygon_mode(polygon_mode) .line_width(1.0) .cull_mode(vk::CullModeFlags::BACK) .front_face(vk::FrontFace::COUNTER_CLOCKWISE) diff --git a/crates/renderer/src/renderer.rs b/crates/renderer/src/renderer.rs index 4f1c557..6813c3e 100644 --- a/crates/renderer/src/renderer.rs +++ b/crates/renderer/src/renderer.rs @@ -10,6 +10,41 @@ 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. +/// +/// 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 { + /// Filled triangles; the normal presentation path. + #[default] + Filled, + /// One point per polygon vertex, exposing the density of the geometry the mesher emitted. + Points, +} + +impl RenderMode { + /// Number of variants, and therefore the number of pipelines built at initialisation. + pub const COUNT: usize = 2; + + /// 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]; + + /// Returns the rasterisation polygon mode backing this render mode. + #[must_use] + pub const fn polygon_mode(self) -> vk::PolygonMode { + match self { + Self::Filled => vk::PolygonMode::FILL, + Self::Points => vk::PolygonMode::POINT, + } + } + + /// Returns this mode's position in [`RenderMode::ALL`], used to index [`Renderer::pipelines`]. + #[must_use] + pub const fn index(self) -> usize { + self as usize + } +} + /// GPU resources for a single chunk mesh, drawn at a fixed world offset. pub(crate) struct GpuMesh { /// Buffer holding the chunk's vertex data. @@ -70,8 +105,10 @@ pub struct Renderer { pub(crate) command_buffers: Vec, /// The layout of the graphics pipeline. pub(crate) pipeline_layout: vk::PipelineLayout, - /// The compiled graphics pipeline state. - pub(crate) graphics_pipeline: vk::Pipeline, + /// 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], + /// The rasterisation mode selected for subsequent frames, chosen by [`Renderer::set_render_mode`]. + pub(crate) render_mode: RenderMode, /// Memory manager for GPU allocations. pub(crate) allocator: Option, /// Uploaded chunk meshes, keyed by an opaque renderer-side handle and drawn independently. @@ -385,10 +422,11 @@ 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.graphics_pipeline, + self.pipelines[self.render_mode.index()], ); #[expect( @@ -592,6 +630,17 @@ impl Renderer { Ok(()) } + /// Sets the rasterisation mode used for subsequent frames. + pub const fn set_render_mode(&mut self, mode: RenderMode) { + self.render_mode = mode; + } + + /// Returns the rasterisation mode currently in use. + #[must_use] + pub const fn render_mode(&self) -> RenderMode { + self.render_mode + } + /// Frees the GPU mesh stored under `key`. Does nothing if no mesh is present. pub fn remove_mesh(&mut self, key: MeshKey) { let Some(mesh) = self.chunk_meshes.remove(&key) else { @@ -617,7 +666,10 @@ impl Drop for Renderer { unsafe { let _ = self.device.device_wait_idle(); - self.device.destroy_pipeline(self.graphics_pipeline, None); + // Every rasterisation variant is a distinct pipeline object and must be destroyed, or the validation layers report the survivors as leaked at teardown. + for pipeline in self.pipelines { + self.device.destroy_pipeline(pipeline, None); + } self.device .destroy_pipeline_layout(self.pipeline_layout, None); From 5c10aefee9ea6277c8979d32c52c7c5e24266aab Mon Sep 17 00:00:00 2001 From: Serkyo Date: Mon, 27 Jul 2026 21:14:22 +0200 Subject: [PATCH 16/20] feat(client): add debug controls for toggling render modes --- crates/client/src/debug.rs | 72 ++++++++++++++++++++++++++++++++ crates/client/src/main.rs | 25 +++++++++++ crates/client/src/tests/debug.rs | 69 ++++++++++++++++++++++++++++++ 3 files changed, 166 insertions(+) create mode 100644 crates/client/src/debug.rs create mode 100644 crates/client/src/tests/debug.rs diff --git a/crates/client/src/debug.rs b/crates/client/src/debug.rs new file mode 100644 index 0000000..78141f9 --- /dev/null +++ b/crates/client/src/debug.rs @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +//! Debug-only input handling, kept separate from the gameplay input path. +//! +//! Debug affordances are bound behind a modifier chord so they cannot collide with movement keys: [`DEBUG_MODIFIER`] (F1) is held, and a second key selects the affordance. The currently bound chords are: +//! +//! - **F1 + V**: toggles the [`RenderMode::Points`] debug rasterisation mode, which draws one point per mesh vertex. Pressing it again returns to [`RenderMode::Filled`]. + +use renderer::RenderMode; +use winit::keyboard::KeyCode; + +/// The key that must be held for a debug chord to be recognised. +const DEBUG_MODIFIER: KeyCode = KeyCode::F1; + +/// A debug operation requested by the input layer, applied by the caller. +/// +/// The layer deliberately returns an intent rather than acting directly, so it owns no renderer or window handles and stays a pure function of key events. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub(crate) enum DebugAction { + /// Applies the given rasterisation mode to the renderer. + SetRenderMode(RenderMode), +} + +/// Owns debug-only input state and translates key events into [`DebugAction`]s. +#[derive(Default)] +pub(crate) struct DebugControls { + /// Whether [`DEBUG_MODIFIER`] is currently held. Chords are recognised only while this is set. + modifier_held: bool, + /// The rasterisation mode most recently requested, used to make each chord a toggle back to [`RenderMode::Filled`]. + render_mode: RenderMode, +} + +impl DebugControls { + /// Translates one key event into a debug action, updating internal state. + /// + /// Returns [`None`] when the event is not part of a debug chord, which is the common case; the caller then handles the key normally. Actions fire on the press edge only, so one physical tap toggles once rather than once per press and once per release. + pub(crate) fn handle_key(&mut self, code: KeyCode, pressed: bool) -> Option { + if code == DEBUG_MODIFIER { + self.modifier_held = pressed; + return None; + } + + if !pressed || !self.modifier_held { + return None; + } + + let requested = render_mode_for_key(code)?; + + // Re-pressing the chord for the active mode returns to the normal path, so a single chord both enables and disables its mode. + self.render_mode = if self.render_mode == requested { + RenderMode::Filled + } else { + requested + }; + + Some(DebugAction::SetRenderMode(self.render_mode)) + } +} + +/// Maps a chord key to the render mode it selects, or [`None`] if the key is unbound. +/// +/// This is the single table a new rasterisation debug mode is added to. +const fn render_mode_for_key(code: KeyCode) -> Option { + match code { + KeyCode::KeyV => Some(RenderMode::Points), + _ => None, + } +} + +#[cfg(test)] +#[path = "tests/debug.rs"] +mod tests; diff --git a/crates/client/src/main.rs b/crates/client/src/main.rs index 941fb3d..080cfe2 100644 --- a/crates/client/src/main.rs +++ b/crates/client/src/main.rs @@ -6,6 +6,7 @@ mod camera; mod chunks; +mod debug; mod mesh_pool; use std::time::Instant; @@ -56,6 +57,8 @@ struct App { camera: Camera, /// The current keyboard and mouse input state. input: InputState, + /// Debug-only key handling, kept separate from the gameplay input path. + debug: debug::DebugControls, /// Timestamp of the previous frame, used to derive delta-time. `None` before the first frame. last_frame: Option, /// Handles onto the background network connection: the handshake outcome, the chunk-subscription sender, and the chunk-delivery receiver. `None` before the connection is started. @@ -80,6 +83,7 @@ impl Default for App { -0.5, ), input: InputState::default(), + debug: debug::DebugControls::default(), last_frame: None, link: None, connected: false, @@ -89,6 +93,22 @@ impl Default for App { } } +impl App { + /// Applies a debug action produced by [`debug::DebugControls`]. + /// + /// Actions targeting the renderer are dropped while it is uninitialised, which is the window between application start and the first `resumed` call. + fn apply_debug_action(&mut self, action: debug::DebugAction) { + match action { + debug::DebugAction::SetRenderMode(mode) => { + if let Some(renderer) = self.renderer.as_mut() { + renderer.set_render_mode(mode); + info!(?mode, "render mode toggled"); + } + } + } + } +} + impl ApplicationHandler for App { fn resumed(&mut self, event_loop: &ActiveEventLoop) { let attributes = Window::default_attributes().with_title("Synvael"); @@ -194,6 +214,11 @@ impl ApplicationHandler for App { WindowEvent::KeyboardInput { event, .. } => { let pressed = event.state == ElementState::Pressed; if let PhysicalKey::Code(code) = event.physical_key { + // Debug chords are resolved first and on their own seam, so debug bindings can grow without entangling the gameplay bindings below. + if let Some(action) = self.debug.handle_key(code, pressed) { + self.apply_debug_action(action); + } + match code { KeyCode::KeyW => self.input.forward = pressed, KeyCode::KeyS => self.input.backward = pressed, diff --git a/crates/client/src/tests/debug.rs b/crates/client/src/tests/debug.rs new file mode 100644 index 0000000..eeee158 --- /dev/null +++ b/crates/client/src/tests/debug.rs @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +//! Unit tests for the debug chord handling in [`crate::debug`]. + +use super::*; + +/// Presses and releases a key, returning the action produced on the press edge. +fn tap(controls: &mut DebugControls, code: KeyCode) -> Option { + let action = controls.handle_key(code, true); + controls.handle_key(code, false); + action +} + +#[test] +fn chord_key_alone_does_nothing() { + let mut controls = DebugControls::default(); + assert_eq!(tap(&mut controls, KeyCode::KeyV), None); +} + +#[test] +fn modifier_alone_produces_no_action() { + let mut controls = DebugControls::default(); + assert_eq!(controls.handle_key(DEBUG_MODIFIER, true), None); + assert_eq!(controls.handle_key(DEBUG_MODIFIER, false), None); +} + +#[test] +fn held_modifier_plus_bound_key_selects_the_mode() { + let mut controls = DebugControls::default(); + controls.handle_key(DEBUG_MODIFIER, true); + assert_eq!( + tap(&mut controls, KeyCode::KeyV), + Some(DebugAction::SetRenderMode(RenderMode::Points)) + ); +} + +#[test] +fn repeating_the_chord_toggles_back_to_filled() { + let mut controls = DebugControls::default(); + controls.handle_key(DEBUG_MODIFIER, true); + tap(&mut controls, KeyCode::KeyV); + assert_eq!( + tap(&mut controls, KeyCode::KeyV), + Some(DebugAction::SetRenderMode(RenderMode::Filled)) + ); +} + +#[test] +fn action_fires_on_the_press_edge_only() { + let mut controls = DebugControls::default(); + controls.handle_key(DEBUG_MODIFIER, true); + assert!(controls.handle_key(KeyCode::KeyV, true).is_some()); + assert_eq!(controls.handle_key(KeyCode::KeyV, false), None); +} + +#[test] +fn releasing_the_modifier_disarms_the_chord() { + let mut controls = DebugControls::default(); + controls.handle_key(DEBUG_MODIFIER, true); + controls.handle_key(DEBUG_MODIFIER, false); + assert_eq!(tap(&mut controls, KeyCode::KeyV), None); +} + +#[test] +fn unbound_key_under_the_modifier_is_ignored() { + let mut controls = DebugControls::default(); + controls.handle_key(DEBUG_MODIFIER, true); + assert_eq!(tap(&mut controls, KeyCode::KeyW), None); +} From 8af6ae9661502b42ac852228ce7cf20252be51e1 Mon Sep 17 00:00:00 2001 From: Serkyo Date: Mon, 27 Jul 2026 21:36:14 +0200 Subject: [PATCH 17/20] feat(assets): size and tint debug raster passes in the vertex shader --- assets/shaders/cube.vert | 13 ++++++++++++- assets/shaders/cube.vert.spv | 4 ++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/assets/shaders/cube.vert b/assets/shaders/cube.vert index b741b5d..ecaf58f 100644 --- a/assets/shaders/cube.vert +++ b/assets/shaders/cube.vert @@ -8,13 +8,24 @@ layout(location = 0) out vec3 frag_color; 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; } 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; + 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; gl_Position = push_constants.mvp * vec4(world_position, 1.0); - frag_color = in_color; + // 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); } diff --git a/assets/shaders/cube.vert.spv b/assets/shaders/cube.vert.spv index 3a3e0a0..b9569a9 100644 --- a/assets/shaders/cube.vert.spv +++ b/assets/shaders/cube.vert.spv @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e96757ea7c663e85f5bb366e9475eb3621a1477c0c2afbef33c3a33a99564de8 -size 1576 +oid sha256:17461a207a6e1d6ba3e2b050aaa83ad0fb880a29e54e42295a1d43cbdfbdc115 +size 1888 From 324d1b144dfefcdaa7f7bb508b14b8cd601bbc2b Mon Sep 17 00:00:00 2001 From: Serkyo Date: Mon, 27 Jul 2026 21:36:14 +0200 Subject: [PATCH 18/20] feat(renderer): compose render modes from raster passes --- crates/renderer/src/device.rs | 6 +- crates/renderer/src/lib.rs | 13 +-- crates/renderer/src/pipeline.rs | 3 +- crates/renderer/src/renderer.rs | 179 ++++++++++++++++++++++---------- 4 files changed, 139 insertions(+), 62 deletions(-) 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); + } } } } From 1d041e035e5285eaecdb945a1f8b133c7586e0b7 Mon Sep 17 00:00:00 2001 From: Serkyo Date: Mon, 27 Jul 2026 21:36:14 +0200 Subject: [PATCH 19/20] feat(client): add a solo modifier for debug view chords --- crates/client/src/debug.rs | 33 +++++++++++--- crates/client/src/tests/debug.rs | 74 ++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 7 deletions(-) diff --git a/crates/client/src/debug.rs b/crates/client/src/debug.rs index 78141f9..722de16 100644 --- a/crates/client/src/debug.rs +++ b/crates/client/src/debug.rs @@ -4,7 +4,12 @@ //! //! Debug affordances are bound behind a modifier chord so they cannot collide with movement keys: [`DEBUG_MODIFIER`] (F1) is held, and a second key selects the affordance. The currently bound chords are: //! -//! - **F1 + V**: toggles the [`RenderMode::Points`] debug rasterisation mode, which draws one point per mesh vertex. Pressing it again returns to [`RenderMode::Filled`]. +//! - **F1 + V**: filled terrain with vertex points overlaid, showing where the mesher placed geometry without losing the surface. +//! - **F1 + B**: filled terrain with the triangle edges overlaid, showing the size and shape of the emitted quads. +//! +//! Holding a [`SOLO_MODIFIER`] (either Shift) as well drops the filled pass, leaving the debug geometry alone against the clear colour: **F1 + Shift + V** for points only, **F1 + Shift + B** for wireframe only. +//! +//! Each chord toggles: pressing the chord for the active mode returns to [`RenderMode::Filled`]. use renderer::RenderMode; use winit::keyboard::KeyCode; @@ -12,6 +17,9 @@ use winit::keyboard::KeyCode; /// The key that must be held for a debug chord to be recognised. const DEBUG_MODIFIER: KeyCode = KeyCode::F1; +/// The keys that, held alongside [`DEBUG_MODIFIER`], select the solo form of a debug view. Both shifts are accepted so the chord is reachable with either hand. +const SOLO_MODIFIER: [KeyCode; 2] = [KeyCode::ShiftLeft, KeyCode::ShiftRight]; + /// A debug operation requested by the input layer, applied by the caller. /// /// The layer deliberately returns an intent rather than acting directly, so it owns no renderer or window handles and stays a pure function of key events. @@ -26,6 +34,8 @@ pub(crate) enum DebugAction { pub(crate) struct DebugControls { /// Whether [`DEBUG_MODIFIER`] is currently held. Chords are recognised only while this is set. modifier_held: bool, + /// Whether a [`SOLO_MODIFIER`] is currently held, selecting the solo form of the chord. + solo_held: bool, /// The rasterisation mode most recently requested, used to make each chord a toggle back to [`RenderMode::Filled`]. render_mode: RenderMode, } @@ -40,11 +50,17 @@ impl DebugControls { return None; } + // A solo modifier is tracked unconditionally rather than only while the debug modifier is held, so its state is correct whichever of the two is pressed first. + if SOLO_MODIFIER.contains(&code) { + self.solo_held = pressed; + return None; + } + if !pressed || !self.modifier_held { return None; } - let requested = render_mode_for_key(code)?; + let requested = render_mode_for_key(code, self.solo_held)?; // Re-pressing the chord for the active mode returns to the normal path, so a single chord both enables and disables its mode. self.render_mode = if self.render_mode == requested { @@ -57,12 +73,15 @@ impl DebugControls { } } -/// Maps a chord key to the render mode it selects, or [`None`] if the key is unbound. +/// Maps a chord key, and whether a [`SOLO_MODIFIER`] is held, to the render mode it selects. Returns [`None`] if the key is unbound. /// -/// This is the single table a new rasterisation debug mode is added to. -const fn render_mode_for_key(code: KeyCode) -> Option { - match code { - KeyCode::KeyV => Some(RenderMode::Points), +/// This is the single table a new rasterisation debug mode is added to: one key, one overlaid form, one solo form. +const fn render_mode_for_key(code: KeyCode, solo: bool) -> Option { + match (code, solo) { + (KeyCode::KeyV, false) => Some(RenderMode::FilledPoints), + (KeyCode::KeyV, true) => Some(RenderMode::Points), + (KeyCode::KeyB, false) => Some(RenderMode::FilledWireframe), + (KeyCode::KeyB, true) => Some(RenderMode::Wireframe), _ => None, } } diff --git a/crates/client/src/tests/debug.rs b/crates/client/src/tests/debug.rs index eeee158..e02b0fc 100644 --- a/crates/client/src/tests/debug.rs +++ b/crates/client/src/tests/debug.rs @@ -28,12 +28,79 @@ fn modifier_alone_produces_no_action() { fn held_modifier_plus_bound_key_selects_the_mode() { let mut controls = DebugControls::default(); controls.handle_key(DEBUG_MODIFIER, true); + assert_eq!( + tap(&mut controls, KeyCode::KeyV), + Some(DebugAction::SetRenderMode(RenderMode::FilledPoints)) + ); +} + +#[test] +fn each_bound_key_selects_a_distinct_overlay_mode() { + let mut controls = DebugControls::default(); + controls.handle_key(DEBUG_MODIFIER, true); + for (code, expected) in [ + (KeyCode::KeyV, RenderMode::FilledPoints), + (KeyCode::KeyB, RenderMode::FilledWireframe), + ] { + assert_eq!( + tap(&mut controls, code), + Some(DebugAction::SetRenderMode(expected)) + ); + } +} + +#[test] +fn solo_modifier_drops_the_filled_pass() { + for solo in SOLO_MODIFIER { + let mut controls = DebugControls::default(); + controls.handle_key(DEBUG_MODIFIER, true); + controls.handle_key(solo, true); + assert_eq!( + tap(&mut controls, KeyCode::KeyV), + Some(DebugAction::SetRenderMode(RenderMode::Points)) + ); + assert_eq!( + tap(&mut controls, KeyCode::KeyB), + Some(DebugAction::SetRenderMode(RenderMode::Wireframe)) + ); + } +} + +#[test] +fn solo_modifier_is_tracked_before_the_debug_modifier() { + let mut controls = DebugControls::default(); + controls.handle_key(KeyCode::ShiftLeft, true); + controls.handle_key(DEBUG_MODIFIER, true); assert_eq!( tap(&mut controls, KeyCode::KeyV), Some(DebugAction::SetRenderMode(RenderMode::Points)) ); } +#[test] +fn releasing_the_solo_modifier_restores_the_overlay_form() { + let mut controls = DebugControls::default(); + controls.handle_key(DEBUG_MODIFIER, true); + controls.handle_key(KeyCode::ShiftLeft, true); + tap(&mut controls, KeyCode::KeyV); + controls.handle_key(KeyCode::ShiftLeft, false); + assert_eq!( + tap(&mut controls, KeyCode::KeyV), + Some(DebugAction::SetRenderMode(RenderMode::FilledPoints)) + ); +} + +#[test] +fn switching_between_modes_does_not_pass_through_filled() { + let mut controls = DebugControls::default(); + controls.handle_key(DEBUG_MODIFIER, true); + tap(&mut controls, KeyCode::KeyV); + assert_eq!( + tap(&mut controls, KeyCode::KeyB), + Some(DebugAction::SetRenderMode(RenderMode::FilledWireframe)) + ); +} + #[test] fn repeating_the_chord_toggles_back_to_filled() { let mut controls = DebugControls::default(); @@ -67,3 +134,10 @@ fn unbound_key_under_the_modifier_is_ignored() { controls.handle_key(DEBUG_MODIFIER, true); assert_eq!(tap(&mut controls, KeyCode::KeyW), None); } + +#[test] +fn solo_modifier_alone_produces_no_action() { + let mut controls = DebugControls::default(); + assert_eq!(controls.handle_key(KeyCode::ShiftLeft, true), None); + assert_eq!(tap(&mut controls, KeyCode::KeyV), None); +} From 95c798954c6cdf73e0505d4cdd8d4cd0121a4287 Mon Sep 17 00:00:00 2001 From: Serkyo Date: Tue, 28 Jul 2026 00:22:37 +0200 Subject: [PATCH 20/20] perf(server): generate chunk baselines outside the cache lock --- crates/server/src/chunk_cache.rs | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/crates/server/src/chunk_cache.rs b/crates/server/src/chunk_cache.rs index 89d752e..e555630 100644 --- a/crates/server/src/chunk_cache.rs +++ b/crates/server/src/chunk_cache.rs @@ -26,18 +26,30 @@ impl ChunkCache { } /// Returns the baseline for `pos`, generating and caching it on a miss. + /// + /// Generation runs outside the lock, so concurrent callers do not serialise on a cache miss. Two callers racing on the same position may each generate a baseline; generation is deterministic and side-effect-free, so the duplicated work is redundant rather than incorrect, and is far cheaper than serialising every miss behind the store. #[must_use] pub fn get_or_generate(&self, pos: ChunkPos, generator: &VoxelGenerator) -> Chunk { - // A poisoned lock cannot yield a usable cache; recovering the guard lets generation proceed rather than propagating a panic across every worker that shares this cache. - let mut guard = self - .inner - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - if let Some(hit) = guard.get(&pos) { - return hit.clone(); + // Probe under the lock, then release it before generating. + { + // A poisoned lock cannot yield a usable cache; recovering the guard lets generation proceed rather than propagating a panic across every worker that shares this cache. + let mut guard = self + .inner + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(hit) = guard.get(&pos) { + return hit.clone(); + } } + let chunk = generator.generate_chunk(pos); - guard.put(pos, chunk.clone()); + + // Retake the lock only to publish. A concurrent caller may have inserted the same position in the meantime; overwriting is harmless because both baselines are identical. + self.inner + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .put(pos, chunk.clone()); + chunk } }