Merge pull request #7 from Cryoforge-Nexus/feat/meshing-and-culling

feat(workspace): meshing, culling, and debug render modes
This commit is contained in:
Serkyo 2026-07-30 21:58:56 +02:00 committed by GitHub
commit a2b20f0ee9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 2020 additions and 442 deletions

2
Cargo.lock generated
View file

@ -547,6 +547,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ash-window", "ash-window",
"crossbeam-channel",
"glam 0.33.2", "glam 0.33.2",
"net", "net",
"raw-window-handle", "raw-window-handle",
@ -2142,6 +2143,7 @@ dependencies = [
"glam 0.33.2", "glam 0.33.2",
"gpu-allocator", "gpu-allocator",
"raw-window-handle", "raw-window-handle",
"shared",
"thiserror 2.0.18", "thiserror 2.0.18",
"tracing", "tracing",
] ]

View file

@ -8,13 +8,24 @@ layout(location = 0) out vec3 frag_color;
layout(push_constant) uniform PushConstants { layout(push_constant) uniform PushConstants {
mat4 mvp; 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; vec4 chunk_offset;
} push_constants; } 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() { void main() {
// The chunk-local vertex is shifted into world space by the per-chunk offset before projection. // 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; vec3 world_position = in_position + push_constants.chunk_offset.xyz;
gl_Position = push_constants.mvp * vec4(world_position, 1.0); 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);
} }

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:e96757ea7c663e85f5bb366e9475eb3621a1477c0c2afbef33c3a33a99564de8 oid sha256:17461a207a6e1d6ba3e2b050aaa83ad0fb880a29e54e42295a1d43cbdfbdc115
size 1576 size 1888

View file

@ -19,3 +19,4 @@ raw-window-handle.workspace = true
ash-window.workspace = true ash-window.workspace = true
shared = { path = "../shared" } shared = { path = "../shared" }
net = { version = "0.1.0", path = "../net" } net = { version = "0.1.0", path = "../net" }
crossbeam-channel = "0.5.16"

View file

@ -2,63 +2,185 @@
//! Client-side chunk streaming around the camera. //! Client-side chunk streaming around the camera.
use std::collections::HashSet; use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use renderer::vertex::Vertex;
use renderer::{MeshKey, RendererError};
use shared::protocol::chunk::ChunkMessage; use shared::protocol::chunk::ChunkMessage;
use shared::world::{CHUNK_SIZE, Chunk, ChunkData, ChunkPos}; use shared::world::{CHUNK_SIZE, Chunk, ChunkPos};
use tracing::{debug, error}; use tracing::{debug, error};
use crate::meshing; 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. /// 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. // TODO: make configurable / drive from view-distance setting.
pub const LOAD_RADIUS: i32 = 8; 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. /// 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.
// TODO: move meshing to a worker pool.
const LOADS_PER_UPDATE: usize = 4; const LOADS_PER_UPDATE: usize = 4;
/// Tracks which server-streamed chunks are currently uploaded to the renderer. /// 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 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),
(0, 1, 0),
(0, -1, 0),
(0, 0, 1),
(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 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`.
///
/// # 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 background meshing.
pub struct ChunkManager { 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 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.
resident: HashSet<ChunkPos>, // 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.
/// Reused all-air baseline that server [`ChunkData`] diffs are materialized against. resident: HashMap<ChunkPos, Arc<Chunk>>,
/// 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<ChunkPos>,
/// 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<ChunkPos, JobGen>,
/// 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, baseline: Chunk,
} }
impl ChunkManager { 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] #[must_use]
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
resident: HashSet::new(), resident: HashMap::new(),
pending_remesh: HashSet::new(),
in_flight: HashMap::new(),
next_gen: JobGen::FIRST,
pool: MeshPool::new(),
baseline: Chunk::default(), 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. /// 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. /// 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( pub fn update(
&mut self, &mut self,
center: ChunkPos, center: ChunkPos,
deliveries: &net::ChunkStream, deliveries: &mut net::ChunkStream,
renderer: &mut renderer::Renderer, sink: &mut impl MeshSink,
) { ) {
let unloaded = self.unload_outside(center, renderer); let applied = self.drain_results(sink);
let unloaded = self.unload_outside(center, sink);
let (loaded, dropped) = self.apply_deliveries(deliveries, sink);
let dispatched = self.dispatch_pending();
let mut loaded = 0; if loaded > 0 || dropped > 0 || unloaded > 0 || applied > 0 || dispatched > 0 {
let mut dropped = 0; debug!(
// Only chunk deliveries count against the meshing budget; drops are cheap and always applied. loaded,
while loaded < LOADS_PER_UPDATE { dropped,
unloaded,
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, 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,
sink: &mut impl MeshSink,
) -> (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() { match deliveries.try_recv() {
Ok(ChunkMessage::Chunk { pos, data }) => { Ok(ChunkMessage::Chunk { pos, data }) => {
self.apply_chunk(pos, &data, renderer); let chunk = Arc::new(data.materialize(&self.baseline));
loaded += 1; self.resident.insert(pos, chunk);
loaded.push(pos);
} }
Ok(ChunkMessage::Drop { pos }) => { Ok(ChunkMessage::Drop { pos }) => {
if self.drop_chunk(pos, renderer) { if self.resident.remove(&pos).is_some() {
dropped += 1; self.in_flight.remove(&pos);
sink.remove_mesh((pos.x, pos.y, pos.z));
dropped.push(pos);
} }
} }
// Empty or disconnected: nothing more to apply this frame. // Empty or disconnected: nothing more to apply this frame.
@ -66,73 +188,84 @@ impl ChunkManager {
} }
} }
if loaded > 0 || dropped > 0 || unloaded > 0 { self.queue_remesh(&loaded, &dropped);
debug!( (loaded.len(), dropped.len())
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) = 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
}
} }
/// Evicts every resident chunk outside the load radius around `center`, returning the number removed. /// Evicts every resident chunk outside the load radius around `center`, returning the number removed.
fn unload_outside(&mut self, center: ChunkPos, renderer: &mut renderer::Renderer) -> usize { ///
/// 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 desired = desired_chunks(center, LOAD_RADIUS);
let stale: Vec<ChunkPos> = self let stale: Vec<ChunkPos> = self
.resident .resident
.iter() .keys()
.filter(|pos| !desired.contains(pos)) .filter(|pos| !desired.contains(pos))
.copied() .copied()
.collect(); .collect();
for pos in &stale { 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.resident.remove(pos);
self.in_flight.remove(pos);
} }
self.queue_remesh(&[], &stale);
stale.len() 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);
}
/// Dispatches up to [`MESHES_PER_UPDATE`] pending re-mesh jobs to the worker pool, returning the number dispatched.
///
/// 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<ChunkPos> = self
.pending_remesh
.iter()
.take(MESHES_PER_UPDATE)
.copied()
.collect();
let mut dispatched = 0;
for pos in batch {
self.pending_remesh.remove(&pos);
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;
}
dispatched
}
/// 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<Arc<Chunk>>; 6] {
NEIGHBOR_OFFSETS.map(|(dx, dy, dz)| {
self.resident
.get(&ChunkPos::new(pos.x + dx, pos.y + dy, pos.z + dz))
.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
}
} }
impl Default for ChunkManager { impl Default for ChunkManager {
@ -141,6 +274,83 @@ 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<ChunkPos, JobGen>,
) -> 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))
}
/// 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<ChunkPos> {
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`. /// 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`. /// 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`.
@ -164,40 +374,5 @@ pub fn desired_chunks(center: ChunkPos, radius: i32) -> HashSet<ChunkPos> {
} }
#[cfg(test)] #[cfg(test)]
mod tests { #[path = "tests/chunks.rs"]
use super::*; mod tests;
#[test]
fn center_is_always_included() {
let center = ChunkPos::new(0, 0, 0);
assert!(desired_chunks(center, 4).contains(&center));
}
#[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<ChunkPos> = 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));
}
}

View file

@ -0,0 +1,91 @@
// 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**: 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;
/// 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.
#[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,
/// 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,
}
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<DebugAction> {
if code == DEBUG_MODIFIER {
self.modifier_held = pressed;
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, 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 {
RenderMode::Filled
} else {
requested
};
Some(DebugAction::SetRenderMode(self.render_mode))
}
}
/// 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: one key, one overlaid form, one solo form.
const fn render_mode_for_key(code: KeyCode, solo: bool) -> Option<RenderMode> {
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,
}
}
#[cfg(test)]
#[path = "tests/debug.rs"]
mod tests;

View file

@ -6,7 +6,8 @@
mod camera; mod camera;
mod chunks; mod chunks;
mod meshing; mod debug;
mod mesh_pool;
use std::time::Instant; use std::time::Instant;
@ -56,6 +57,8 @@ struct App {
camera: Camera, camera: Camera,
/// The current keyboard and mouse input state. /// The current keyboard and mouse input state.
input: InputState, 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. /// Timestamp of the previous frame, used to derive delta-time. `None` before the first frame.
last_frame: Option<Instant>, last_frame: Option<Instant>,
/// Handles onto the background network connection: the handshake outcome, the chunk-subscription sender, and the chunk-delivery receiver. `None` before the connection is started. /// 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, -0.5,
), ),
input: InputState::default(), input: InputState::default(),
debug: debug::DebugControls::default(),
last_frame: None, last_frame: None,
link: None, link: None,
connected: false, 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 { impl ApplicationHandler for App {
fn resumed(&mut self, event_loop: &ActiveEventLoop) { fn resumed(&mut self, event_loop: &ActiveEventLoop) {
let attributes = Window::default_attributes().with_title("Synvael"); let attributes = Window::default_attributes().with_title("Synvael");
@ -194,6 +214,11 @@ impl ApplicationHandler for App {
WindowEvent::KeyboardInput { event, .. } => { WindowEvent::KeyboardInput { event, .. } => {
let pressed = event.state == ElementState::Pressed; let pressed = event.state == ElementState::Pressed;
if let PhysicalKey::Code(code) = event.physical_key { 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 { match code {
KeyCode::KeyW => self.input.forward = pressed, KeyCode::KeyW => self.input.forward = pressed,
KeyCode::KeyS => self.input.backward = pressed, KeyCode::KeyS => self.input.backward = pressed,
@ -262,10 +287,10 @@ impl ApplicationHandler for App {
// Apply queued server deliveries and reconcile the resident set against the camera. // Apply queued server deliveries and reconcile the resident set against the camera.
if let (Some(chunks), Some(link), Some(renderer)) = ( if let (Some(chunks), Some(link), Some(renderer)) = (
self.chunks.as_mut(), self.chunks.as_mut(),
self.link.as_ref(), self.link.as_mut(),
self.renderer.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(); let view = self.camera.view_matrix();

View file

@ -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<Chunk>,
/// 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<Arc<Chunk>>; 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<Vertex>,
/// Generated triangle indices; empty when the chunk meshes to no geometry.
pub(crate) indices: Vec<u32>,
/// 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<MeshJob>,
/// Receiving end of the result queue; the main thread drains finished meshes.
result_rx: Receiver<MeshResult>,
/// 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<JoinHandle<()>>,
}
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::<MeshJob>();
let (result_tx, result_rx) = crossbeam_channel::unbounded::<MeshResult>();
// 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<MeshResult> {
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<Vertex>, Vec<u32>) {
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)
}

View file

@ -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<Vertex>, Vec<u32>) {
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)
}

View file

@ -0,0 +1,230 @@
// SPDX-License-Identifier: AGPL-3.0-only
//! Unit tests for the chunk streaming logic in [`crate::chunks`].
use std::time::{Duration, Instant};
use shared::world::BlockId;
use super::*;
#[test]
fn center_is_always_included() {
let center = ChunkPos::new(0, 0, 0);
assert!(desired_chunks(center, 4).contains(&center));
}
#[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<ChunkPos> = 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());
}
// --- 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<MeshKey>,
/// Keys cleared via [`MeshSink::remove_mesh`], in call order.
removed: Vec<MeshKey>,
}
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());
}

View file

@ -0,0 +1,143 @@
// 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<DebugAction> {
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::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();
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);
}
#[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);
}

View file

@ -1,13 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
//! Chunk-stream transport: the per-connection task that pumps chunk subscriptions and deliveries. //! 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 shared::protocol::chunk::{ChunkMessage, ChunkSubscribe};
use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender}; use tokio::sync::mpsc::{Sender, UnboundedReceiver, UnboundedSender};
use tracing::{debug, warn}; use tracing::{debug, warn};
use crate::codec::{MAX_CHUNK_FRAME_LEN, read_frame, write_frame}; use crate::codec::{MAX_CHUNK_FRAME_LEN, read_frame, write_frame};
@ -56,41 +52,42 @@ pub(crate) async fn chunk_stream_task(
} }
}; };
loop { // Inbound subscriptions are read on their own future so `read_frame` is never cancelled mid-frame by an outbound write becoming ready.
tokio::select! { let reader = async {
// A subscription frame arrived from the client. loop {
frame = read_frame::<ChunkSubscribe>(&mut recv, MAX_CHUNK_FRAME_LEN) => { match read_frame::<ChunkSubscribe>(&mut recv, MAX_CHUNK_FRAME_LEN).await {
match frame { Ok(request) => {
Ok(request) => { // A closed events receiver means the simulation loop is gone; nothing more to do.
// A closed events receiver means the simulation loop is gone; nothing more to do. if events
if events .send(ServerEvent::ChunkSubscribe { id, request })
.send(ServerEvent::ChunkSubscribe { id, request }) .is_err()
.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");
break; break;
} }
} }
} Err(error) => {
// The simulation loop handed back a chunk to deliver. // A read error is the normal end of a client session (stream finished or reset).
msg = outbound.recv() => { debug!(%error, id, "chunk stream read ended");
match msg { break;
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,
} }
} }
} }
};
// 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 => {}
} }
} }
@ -125,7 +122,7 @@ impl ChunkSubscriber {
pub(crate) async fn client_chunk_task( pub(crate) async fn client_chunk_task(
connection: quinn::Connection, connection: quinn::Connection,
mut subscribe: UnboundedReceiver<ChunkSubscribe>, mut subscribe: UnboundedReceiver<ChunkSubscribe>,
deliveries: crossbeam_channel::Sender<ChunkMessage>, deliveries: Sender<ChunkMessage>,
) { ) {
// The client opens the chunk stream after the handshake; the server accepts it, mirroring the control-stream convention. // 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 { let (mut send, mut recv) = match connection.open_bi().await {
@ -136,38 +133,39 @@ pub(crate) async fn client_chunk_task(
} }
}; };
loop { // Inbound chunks are read on their own future so `read_frame` is never cancelled mid-frame by an outbound subscribe becoming ready.
tokio::select! { let reader = async {
// The UI thread pushed a subscription update to forward to the server. loop {
request = subscribe.recv() => { match read_frame::<ChunkMessage>(&mut recv, MAX_CHUNK_FRAME_LEN).await {
match request { Ok(message) => {
Some(request) => { // `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 let Err(error) = write_frame(&mut send, &request).await { if deliveries.send(message).await.is_err() {
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::<ChunkMessage>(&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() {
break;
}
}
Err(error) => {
// A read error is the normal end of the session (stream finished or reset).
debug!(%error, "chunk stream read ended");
break; 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 => {}
} }
} }

View file

@ -6,9 +6,10 @@
use std::net::SocketAddr; use std::net::SocketAddr;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration;
use quinn::crypto::rustls::{QuicClientConfig, QuicServerConfig}; use quinn::crypto::rustls::{QuicClientConfig, QuicServerConfig};
use quinn::{ClientConfig, Endpoint, ServerConfig}; use quinn::{ClientConfig, Endpoint, IdleTimeout, ServerConfig, TransportConfig, VarInt};
use rustls::DigitallySignedStruct; use rustls::DigitallySignedStruct;
use rustls::SignatureScheme; use rustls::SignatureScheme;
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; 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. /// The Application-Layer Protocol Negotiation identifier for the Synvael protocol.
pub const ALPN: &[u8] = b"synvael"; 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<TransportConfig> {
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. /// Installs the process-wide default `rustls` `CryptoProvider` if one is not already installed.
fn ensure_crypto_provider() { fn ensure_crypto_provider() {
if rustls::crypto::ring::default_provider() if rustls::crypto::ring::default_provider()
@ -49,7 +73,8 @@ pub fn server_endpoint(bind: SocketAddr) -> Result<Endpoint, NetError> {
tls_config.alpn_protocols = vec![ALPN.to_vec()]; tls_config.alpn_protocols = vec![ALPN.to_vec()];
let quic_config = QuicServerConfig::try_from(tls_config)?; 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)?) Ok(Endpoint::server(server_config, bind)?)
} }
@ -69,7 +94,8 @@ pub fn client_endpoint() -> Result<Endpoint, NetError> {
tls_config.alpn_protocols = vec![ALPN.to_vec()]; tls_config.alpn_protocols = vec![ALPN.to_vec()];
let quic_config = QuicClientConfig::try_from(tls_config)?; 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)?)?; let mut endpoint = Endpoint::client("0.0.0.0:0".parse().map_err(std::io::Error::other)?)?;
endpoint.set_default_client_config(client_config); endpoint.set_default_client_config(client_config);

View file

@ -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. /// 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<Result<HandshakeAck, String>>; pub type ConnectOutcome = crossbeam_channel::Receiver<Result<HandshakeAck, String>>;
/// Non-blocking receiver of chunks delivered by the server, drained by the UI thread with `try_recv`. /// Bounded receiver of chunks delivered by the server, drained by the UI thread with `try_recv`.
pub type ChunkStream = crossbeam_channel::Receiver<ChunkMessage>; pub type ChunkStream = tokio::sync::mpsc::Receiver<ChunkMessage>;
/// 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. /// 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(); 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). // 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::<ChunkSubscribe>(); let (subscribe_tx, subscribe_rx) = tokio::sync::mpsc::unbounded_channel::<ChunkSubscribe>();
let (chunks_tx, chunks_rx) = crossbeam_channel::unbounded::<ChunkMessage>(); // 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::<ChunkMessage>(CHUNK_DELIVERY_CAPACITY);
let spawned = thread::Builder::new() let spawned = thread::Builder::new()
.name("net-client".to_owned()) .name("net-client".to_owned())

View file

@ -17,3 +17,4 @@ tracing.workspace = true
gpu-allocator = "0.28.0" gpu-allocator = "0.28.0"
bytemuck.workspace = true bytemuck.workspace = true
glam.workspace = true glam.workspace = true
shared = { path = "../shared" }

View file

@ -53,9 +53,15 @@ pub fn create_logical_device(
let mut dynamic_rendering_features = let mut dynamic_rendering_features =
vk::PhysicalDeviceDynamicRenderingFeatures::default().dynamic_rendering(true); vk::PhysicalDeviceDynamicRenderingFeatures::default().dynamic_rendering(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() let create_info = vk::DeviceCreateInfo::default()
.queue_create_infos(std::slice::from_ref(&queue_info)) .queue_create_infos(std::slice::from_ref(&queue_info))
.enabled_extension_names(&device_extensions) .enabled_extension_names(&device_extensions)
.enabled_features(&enabled_features)
.push_next(&mut synchronization2_features) .push_next(&mut synchronization2_features)
.push_next(&mut dynamic_rendering_features); .push_next(&mut dynamic_rendering_features);

View file

@ -0,0 +1,66 @@
// 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 GribbHartmann 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)]
#[path = "tests/frustum.rs"]
mod tests;

View file

@ -9,13 +9,15 @@
mod device; mod device;
pub mod error; pub mod error;
mod frustum;
mod instance; mod instance;
pub mod mesh; pub mod meshing;
mod pipeline; mod pipeline;
mod renderer; mod renderer;
mod surface; mod surface;
mod swapchain; mod swapchain;
mod sync; mod sync;
pub mod vertex;
/// The maximum number of frames that can be processed by the GPU and CPU simultaneously. /// The maximum number of frames that can be processed by the GPU and CPU simultaneously.
pub const MAX_FRAMES_IN_FLIGHT: usize = 3; pub const MAX_FRAMES_IN_FLIGHT: usize = 3;
@ -26,7 +28,7 @@ use raw_window_handle::{RawDisplayHandle, RawWindowHandle};
use std::ffi::c_char; use std::ffi::c_char;
pub use error::RendererError; pub use error::RendererError;
pub use renderer::{MeshKey, Renderer}; pub use renderer::{MeshKey, RasterPass, RenderMode, Renderer};
use std::collections::HashMap; use std::collections::HashMap;
@ -122,8 +124,18 @@ impl Renderer {
// 12. Graphics Pipeline Configuration // 12. Graphics Pipeline Configuration
let pipeline_layout = pipeline::create_pipeline_layout(&device)?; let pipeline_layout = pipeline::create_pipeline_layout(&device)?;
let graphics_pipeline =
pipeline::create_graphics_pipeline(&device, pipeline_layout, swapchain_format)?; // 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,
pass.polygon_mode(),
pass.depth_compare_op(),
)?;
}
let (depth_image, depth_allocation, depth_image_view) = let (depth_image, depth_allocation, depth_image_view) =
create_depth_resources(&device, &mut allocator, swapchain_extent)?; create_depth_resources(&device, &mut allocator, swapchain_extent)?;
@ -153,7 +165,8 @@ impl Renderer {
depth_allocation: Some(depth_allocation), depth_allocation: Some(depth_allocation),
depth_image_view, depth_image_view,
pipeline_layout, pipeline_layout,
graphics_pipeline, pipelines,
render_mode: RenderMode::default(),
sync: Some(sync), sync: Some(sync),
current_frame: 0, current_frame: 0,
}) })

View file

@ -0,0 +1,405 @@
// SPDX-License-Identifier: AGPL-3.0-only
//! Cubic greedy mesher: converts a dense voxel [`Chunk`] into renderer geometry.
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.
#[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.
#[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
}
/// 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 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, neighbors: &Neighbors) -> (Vec<Vertex>, Vec<u32>) {
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
&& occluder(chunk, neighbors, x, y, z, FaceDir::PosY) == 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
&& 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);
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
&& occluder(chunk, neighbors, x, y, z, FaceDir::PosX) == 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
&& 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);
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
&& occluder(chunk, neighbors, x, y, z, FaceDir::PosZ) == 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
&& 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);
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<FaceKey>],
vertices: &mut Vec<Vertex>,
indices: &mut Vec<u32>,
mut sample: impl FnMut(usize, usize, usize) -> Option<FaceKey>,
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<FaceKey>],
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<Vertex>,
indices: &mut Vec<u32>,
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;

View file

@ -3,7 +3,7 @@
//! Graphics pipeline creation and shader management. //! Graphics pipeline creation and shader management.
use crate::error::RendererError; use crate::error::RendererError;
use crate::mesh::Vertex; use crate::vertex::Vertex;
use ash::{Device, vk}; use ash::{Device, vk};
use std::io::Cursor; use std::io::Cursor;
@ -67,6 +67,8 @@ pub fn create_graphics_pipeline(
device: &Device, device: &Device,
layout: vk::PipelineLayout, layout: vk::PipelineLayout,
color_format: vk::Format, color_format: vk::Format,
polygon_mode: vk::PolygonMode,
depth_compare_op: vk::CompareOp,
) -> Result<vk::Pipeline, RendererError> { ) -> Result<vk::Pipeline, RendererError> {
// 1. Load and compile shader modules // 1. Load and compile shader modules
let (vert_module, frag_module) = load_shader_modules(device)?; let (vert_module, frag_module) = load_shader_modules(device)?;
@ -101,7 +103,7 @@ pub fn create_graphics_pipeline(
let rasterizer = vk::PipelineRasterizationStateCreateInfo::default() let rasterizer = vk::PipelineRasterizationStateCreateInfo::default()
.depth_clamp_enable(false) .depth_clamp_enable(false)
.rasterizer_discard_enable(false) .rasterizer_discard_enable(false)
.polygon_mode(vk::PolygonMode::FILL) .polygon_mode(polygon_mode)
.line_width(1.0) .line_width(1.0)
.cull_mode(vk::CullModeFlags::BACK) .cull_mode(vk::CullModeFlags::BACK)
.front_face(vk::FrontFace::COUNTER_CLOCKWISE) .front_face(vk::FrontFace::COUNTER_CLOCKWISE)
@ -131,7 +133,7 @@ pub fn create_graphics_pipeline(
let depth_stencil_state = &vk::PipelineDepthStencilStateCreateInfo::default() let depth_stencil_state = &vk::PipelineDepthStencilStateCreateInfo::default()
.depth_test_enable(true) .depth_test_enable(true)
.depth_write_enable(true) .depth_write_enable(true)
.depth_compare_op(vk::CompareOp::LESS) .depth_compare_op(depth_compare_op)
.depth_bounds_test_enable(false) .depth_bounds_test_enable(false)
.stencil_test_enable(false); .stencil_test_enable(false);

View file

@ -2,7 +2,7 @@
use crate::sync::SyncPrimitives; use crate::sync::SyncPrimitives;
use crate::{create_depth_resources, create_gpu_buffer, swapchain}; use crate::{create_depth_resources, create_gpu_buffer, swapchain};
use crate::{error::RendererError, mesh::Vertex}; use crate::{error::RendererError, frustum::Frustum, vertex::Vertex};
use ash::{Device, Instance, khr, vk}; use ash::{Device, Instance, khr, vk};
use gpu_allocator::vulkan::{Allocation, Allocator}; use gpu_allocator::vulkan::{Allocation, Allocator};
use std::collections::HashMap; use std::collections::HashMap;
@ -10,6 +10,101 @@ use std::collections::HashMap;
/// Opaque, renderer-side identifier for one uploaded chunk mesh. /// Opaque, renderer-side identifier for one uploaded chunk mesh.
pub type MeshKey = (i32, i32, i32); pub type MeshKey = (i32, i32, i32);
/// One rasterisation pass over the visible chunk meshes.
///
/// A pass corresponds one-to-one with a pipeline object, since polygon mode and depth-compare state are baked into a pipeline and cannot be changed by a command. Passes are the GPU-level primitive; [`RenderMode`] composes them into what is actually presented.
///
/// 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.
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 RasterPass {
/// Number of passes, and therefore the number of pipelines built at initialisation.
pub const COUNT: usize = 3;
/// 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 pass.
#[must_use]
pub const fn polygon_mode(self) -> vk::PolygonMode {
match self {
Self::Fill => vk::PolygonMode::FILL,
Self::Points => vk::PolygonMode::POINT,
Self::Wireframe => vk::PolygonMode::LINE,
}
}
/// 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. /// GPU resources for a single chunk mesh, drawn at a fixed world offset.
pub(crate) struct GpuMesh { pub(crate) struct GpuMesh {
/// Buffer holding the chunk's vertex data. /// Buffer holding the chunk's vertex data.
@ -70,8 +165,10 @@ pub struct Renderer {
pub(crate) command_buffers: Vec<vk::CommandBuffer>, pub(crate) command_buffers: Vec<vk::CommandBuffer>,
/// The layout of the graphics pipeline. /// The layout of the graphics pipeline.
pub(crate) pipeline_layout: vk::PipelineLayout, pub(crate) pipeline_layout: vk::PipelineLayout,
/// The compiled graphics pipeline state. /// 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) graphics_pipeline: vk::Pipeline, 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. /// Memory manager for GPU allocations.
pub(crate) allocator: Option<Allocator>, pub(crate) allocator: Option<Allocator>,
/// Uploaded chunk meshes, keyed by an opaque renderer-side handle and drawn independently. /// Uploaded chunk meshes, keyed by an opaque renderer-side handle and drawn independently.
@ -385,12 +482,6 @@ impl Renderer {
/// Issues the actual draw calls for the frame. /// Issues the actual draw calls for the frame.
fn issue_draw_calls(&self, cmd: vk::CommandBuffer, camera_view: glam::Mat4) { fn issue_draw_calls(&self, cmd: vk::CommandBuffer, camera_view: glam::Mat4) {
unsafe { unsafe {
self.device.cmd_bind_pipeline(
cmd,
vk::PipelineBindPoint::GRAPHICS,
self.graphics_pipeline,
);
#[expect( #[expect(
clippy::cast_precision_loss, clippy::cast_precision_loss,
reason = "swapchain extents are within f32's exact-integer range" reason = "swapchain extents are within f32's exact-integer range"
@ -428,7 +519,36 @@ 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. // 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; let mvp = projection * camera_view;
// The MVP is identical for every chunk this frame, so it is pushed once before the loop. // 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);
// 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();
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()); let mvp_bytes = bytemuck::cast_slice(mvp.as_ref());
self.device.cmd_push_constants( self.device.cmd_push_constants(
cmd, cmd,
@ -445,28 +565,41 @@ impl Renderer {
)] )]
let chunk_offset_byte = size_of::<glam::Mat4>() as u32; let chunk_offset_byte = size_of::<glam::Mat4>() as u32;
for mesh in self.chunk_meshes.values() { // Overlay modes submit the same geometry more than once, each pass binding a pipeline whose rasterisation state differs. Later passes draw over earlier ones.
// The offset is padded to a vec4 to match the std140 layout of the push-constant block; only xyz is read by the shader. for pass in self.render_mode.passes() {
let offset = [ self.device.cmd_bind_pipeline(
mesh.world_offset[0],
mesh.world_offset[1],
mesh.world_offset[2],
0.0_f32,
];
self.device.cmd_push_constants(
cmd, cmd,
self.pipeline_layout, vk::PipelineBindPoint::GRAPHICS,
vk::ShaderStageFlags::VERTEX, self.pipelines[pass.index()],
chunk_offset_byte,
bytemuck::cast_slice(&offset),
); );
self.device for mesh in &visible {
.cmd_bind_vertex_buffers(cmd, 0, &[mesh.vertex_buffer], &[0]); // 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.
self.device let offset = [
.cmd_bind_index_buffer(cmd, mesh.index_buffer, 0, vk::IndexType::UINT32); mesh.world_offset[0],
self.device mesh.world_offset[1],
.cmd_draw_indexed(cmd, mesh.index_count, 1, 0, 0, 0); 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),
);
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);
}
} }
} }
} }
@ -570,6 +703,17 @@ impl Renderer {
Ok(()) 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. /// Frees the GPU mesh stored under `key`. Does nothing if no mesh is present.
pub fn remove_mesh(&mut self, key: MeshKey) { pub fn remove_mesh(&mut self, key: MeshKey) {
let Some(mesh) = self.chunk_meshes.remove(&key) else { let Some(mesh) = self.chunk_meshes.remove(&key) else {
@ -595,7 +739,10 @@ impl Drop for Renderer {
unsafe { unsafe {
let _ = self.device.device_wait_idle(); 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 self.device
.destroy_pipeline_layout(self.pipeline_layout, None); .destroy_pipeline_layout(self.pipeline_layout, None);

View file

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

View file

@ -0,0 +1,237 @@
// 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
}
/// 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.
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::<f64>().sqrt();
}
area
}
#[test]
fn all_air_chunk_is_empty() {
let (vertices, indices) = generate_mesh(&Chunk::default(), &Neighbors::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, &Neighbors::default());
// 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, &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);
}
#[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, &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);
}
#[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, &Neighbors::default());
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 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, &Neighbors::default()),
generate_mesh(&chunk, &Neighbors::default())
);
}

View file

@ -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). /// Uses `repr(C)` to ensure the memory layout matches what the GPU expects (no Rust-specific reordering).
/// `Pod` and `Zeroable` allows safely casting this struct to a raw byte slice. /// `Pod` and `Zeroable` allows safely casting this struct to a raw byte slice.
#[repr(C)] #[repr(C)]
#[derive(Copy, Clone, Debug, Pod, Zeroable)] #[derive(Copy, Clone, Debug, PartialEq, Pod, Zeroable)]
pub struct Vertex { pub struct Vertex {
/// 3D position of the vertex (X, Y, Z). /// 3D position of the vertex (X, Y, Z).
pub position: [f32; 3], pub position: [f32; 3],

View file

@ -26,18 +26,30 @@ impl ChunkCache {
} }
/// Returns the baseline for `pos`, generating and caching it on a miss. /// 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] #[must_use]
pub fn get_or_generate(&self, pos: ChunkPos, generator: &VoxelGenerator) -> Chunk { 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. // Probe under the lock, then release it before generating.
let mut guard = self {
.inner // 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.
.lock() let mut guard = self
.unwrap_or_else(std::sync::PoisonError::into_inner); .inner
if let Some(hit) = guard.get(&pos) { .lock()
return hit.clone(); .unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(hit) = guard.get(&pos) {
return hit.clone();
}
} }
let chunk = generator.generate_chunk(pos); 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 chunk
} }
} }