Merge pull request #7 from Cryoforge-Nexus/feat/meshing-and-culling
feat(workspace): meshing, culling, and debug render modes
This commit is contained in:
commit
a2b20f0ee9
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -547,6 +547,7 @@ version = "0.1.0"
|
|||
dependencies = [
|
||||
"anyhow",
|
||||
"ash-window",
|
||||
"crossbeam-channel",
|
||||
"glam 0.33.2",
|
||||
"net",
|
||||
"raw-window-handle",
|
||||
|
|
@ -2142,6 +2143,7 @@ dependencies = [
|
|||
"glam 0.33.2",
|
||||
"gpu-allocator",
|
||||
"raw-window-handle",
|
||||
"shared",
|
||||
"thiserror 2.0.18",
|
||||
"tracing",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -8,13 +8,24 @@ layout(location = 0) out vec3 frag_color;
|
|||
|
||||
layout(push_constant) uniform PushConstants {
|
||||
mat4 mvp;
|
||||
// xyz is the chunk's world offset; w is the debug-tint weight, 0.0 for normal rendering and 1.0 for a debug raster pass.
|
||||
vec4 chunk_offset;
|
||||
} push_constants;
|
||||
|
||||
// Colour applied to debug raster passes, chosen to contrast with terrain and to remain legible when overlaid on filled geometry.
|
||||
const vec3 DEBUG_COLOR = vec3(1.0, 0.0, 1.0);
|
||||
|
||||
// Size, in pixels, of the points emitted under VK_POLYGON_MODE_POINT. Sizes above 1.0 require the largePoints device feature.
|
||||
const float DEBUG_POINT_SIZE = 5.0;
|
||||
|
||||
void main() {
|
||||
// The chunk-local vertex is shifted into world space by the per-chunk offset before projection.
|
||||
vec3 world_position = in_position + push_constants.chunk_offset.xyz;
|
||||
gl_Position = push_constants.mvp * vec4(world_position, 1.0);
|
||||
|
||||
frag_color = in_color;
|
||||
// Point size is consulted whenever the polygon mode is POINT; leaving it unwritten renders points of undefined size. It is ignored by the FILL and LINE pipelines, so it is written unconditionally.
|
||||
gl_PointSize = DEBUG_POINT_SIZE;
|
||||
|
||||
float debug_tint = push_constants.chunk_offset.w;
|
||||
frag_color = mix(in_color, DEBUG_COLOR, debug_tint);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e96757ea7c663e85f5bb366e9475eb3621a1477c0c2afbef33c3a33a99564de8
|
||||
size 1576
|
||||
oid sha256:17461a207a6e1d6ba3e2b050aaa83ad0fb880a29e54e42295a1d43cbdfbdc115
|
||||
size 1888
|
||||
|
|
|
|||
|
|
@ -19,3 +19,4 @@ raw-window-handle.workspace = true
|
|||
ash-window.workspace = true
|
||||
shared = { path = "../shared" }
|
||||
net = { version = "0.1.0", path = "../net" }
|
||||
crossbeam-channel = "0.5.16"
|
||||
|
|
|
|||
|
|
@ -2,63 +2,185 @@
|
|||
|
||||
//! 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::world::{CHUNK_SIZE, Chunk, ChunkData, ChunkPos};
|
||||
use shared::world::{CHUNK_SIZE, Chunk, ChunkPos};
|
||||
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.
|
||||
// TODO: make configurable / drive from view-distance setting.
|
||||
pub const LOAD_RADIUS: i32 = 8;
|
||||
|
||||
/// Maximum number of chunks meshed and uploaded in a single call to [`ChunkManager::update`], bounding per-frame meshing work so the winit loop stays responsive. Deliveries beyond the budget remain queued for the next frame.
|
||||
// TODO: move meshing to a worker pool.
|
||||
/// Maximum number of chunk deliveries materialized in a single call to [`ChunkManager::update`], bounding per-frame materialization work. Deliveries beyond the budget remain queued in the transport for the next frame.
|
||||
const LOADS_PER_UPDATE: usize = 4;
|
||||
|
||||
/// Tracks which server-streamed chunks are currently uploaded to the renderer.
|
||||
/// Maximum number of 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 {
|
||||
/// Positions uploaded to the renderer (whether or not they produced a non-empty mesh), so unload and drop can reconcile against the renderer.
|
||||
resident: HashSet<ChunkPos>,
|
||||
/// Reused all-air baseline that server [`ChunkData`] diffs are materialized against.
|
||||
/// Resident chunks keyed by position, retained so the mesher can sample voxels across chunk boundaries. Stored behind [`Arc`] so a chunk can be handed to a worker thread without copying its 64 KiB volume.
|
||||
// TODO: a resident Chunk is 32³ × 2 bytes = 64 KiB; at LOAD_RADIUS = 8 the resident set is thousands of chunks (hundreds of MiB). A follow-up can store only the six 32×32 boundary planes per chunk instead of the full volume.
|
||||
resident: HashMap<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,
|
||||
}
|
||||
|
||||
impl ChunkManager {
|
||||
/// Creates a manager with no chunks yet resident.
|
||||
/// Creates a manager with no chunks yet resident, spawning the background mesh worker pool.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
resident: HashSet::new(),
|
||||
resident: HashMap::new(),
|
||||
pending_remesh: HashSet::new(),
|
||||
in_flight: HashMap::new(),
|
||||
next_gen: JobGen::FIRST,
|
||||
pool: MeshPool::new(),
|
||||
baseline: Chunk::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reconciles the resident chunk set: evicts chunks outside the load radius around `center`, 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.
|
||||
pub fn update(
|
||||
&mut self,
|
||||
center: ChunkPos,
|
||||
deliveries: &net::ChunkStream,
|
||||
renderer: &mut renderer::Renderer,
|
||||
deliveries: &mut net::ChunkStream,
|
||||
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;
|
||||
let mut dropped = 0;
|
||||
// Only chunk deliveries count against the meshing budget; drops are cheap and always applied.
|
||||
while loaded < LOADS_PER_UPDATE {
|
||||
if loaded > 0 || dropped > 0 || unloaded > 0 || applied > 0 || dispatched > 0 {
|
||||
debug!(
|
||||
loaded,
|
||||
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() {
|
||||
Ok(ChunkMessage::Chunk { pos, data }) => {
|
||||
self.apply_chunk(pos, &data, renderer);
|
||||
loaded += 1;
|
||||
let chunk = Arc::new(data.materialize(&self.baseline));
|
||||
self.resident.insert(pos, chunk);
|
||||
loaded.push(pos);
|
||||
}
|
||||
Ok(ChunkMessage::Drop { pos }) => {
|
||||
if self.drop_chunk(pos, renderer) {
|
||||
dropped += 1;
|
||||
if self.resident.remove(&pos).is_some() {
|
||||
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.
|
||||
|
|
@ -66,24 +188,116 @@ impl ChunkManager {
|
|||
}
|
||||
}
|
||||
|
||||
if loaded > 0 || dropped > 0 || unloaded > 0 {
|
||||
debug!(
|
||||
loaded,
|
||||
dropped,
|
||||
unloaded,
|
||||
resident = self.resident.len(),
|
||||
"chunk stream reconciled"
|
||||
);
|
||||
}
|
||||
self.queue_remesh(&loaded, &dropped);
|
||||
(loaded.len(), dropped.len())
|
||||
}
|
||||
|
||||
/// 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);
|
||||
/// Evicts every resident chunk outside the load radius around `center`, returning the number removed.
|
||||
///
|
||||
/// Each evicted chunk is removed from residency, from in-flight tracking, and from the renderer; its resident neighbours have a boundary toward it that is now exposed, so they are enqueued for re-meshing.
|
||||
fn unload_outside(&mut self, center: ChunkPos, sink: &mut impl MeshSink) -> usize {
|
||||
let desired = desired_chunks(center, LOAD_RADIUS);
|
||||
let stale: Vec<ChunkPos> = self
|
||||
.resident
|
||||
.keys()
|
||||
.filter(|pos| !desired.contains(pos))
|
||||
.copied()
|
||||
.collect();
|
||||
for pos in &stale {
|
||||
sink.remove_mesh((pos.x, pos.y, pos.z));
|
||||
self.resident.remove(pos);
|
||||
self.in_flight.remove(pos);
|
||||
}
|
||||
self.queue_remesh(&[], &stale);
|
||||
stale.len()
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
|
||||
// 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,
|
||||
|
|
@ -98,47 +312,43 @@ impl ChunkManager {
|
|||
]
|
||||
};
|
||||
|
||||
if let Err(e) =
|
||||
renderer.insert_mesh((pos.x, pos.y, pos.z), &vertices, &indices, world_offset)
|
||||
{
|
||||
if let Err(e) = sink.insert_mesh(key, &result.vertices, &result.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.
|
||||
fn unload_outside(&mut self, center: ChunkPos, renderer: &mut renderer::Renderer) -> usize {
|
||||
let desired = desired_chunks(center, LOAD_RADIUS);
|
||||
let stale: Vec<ChunkPos> = self
|
||||
.resident
|
||||
.iter()
|
||||
.filter(|pos| !desired.contains(pos))
|
||||
.copied()
|
||||
.collect();
|
||||
for pos in &stale {
|
||||
renderer.remove_mesh((pos.x, pos.y, pos.z));
|
||||
self.resident.remove(pos);
|
||||
}
|
||||
stale.len()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ChunkManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
/// 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`.
|
||||
|
|
@ -164,40 +374,5 @@ pub fn desired_chunks(center: ChunkPos, radius: i32) -> HashSet<ChunkPos> {
|
|||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn center_is_always_included() {
|
||||
let center = ChunkPos::new(0, 0, 0);
|
||||
assert!(desired_chunks(center, 4).contains(¢er));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn excludes_columns_beyond_the_disc() {
|
||||
let set = desired_chunks(ChunkPos::new(0, 0, 0), 4);
|
||||
// One chunk past the radius along an axis: squared distance 25 > 16.
|
||||
assert!(!set.contains(&ChunkPos::new(5, 0, 0)));
|
||||
// The far corner: squared distance 4*4 + 4*4 = 32 > 16.
|
||||
assert!(!set.contains(&ChunkPos::new(4, 0, 4)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertical_extent_is_half_the_radius() {
|
||||
let set = desired_chunks(ChunkPos::new(0, 0, 0), 4);
|
||||
// radius / 2 == 2, so the column at the center spans y in [-2, 2].
|
||||
assert!(set.contains(&ChunkPos::new(0, 2, 0)));
|
||||
assert!(!set.contains(&ChunkPos::new(0, 3, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_is_translation_invariant() {
|
||||
// Shifting the center shifts every member by the same offset; this also exercises negative coordinates on the shifted side.
|
||||
let base = desired_chunks(ChunkPos::new(0, 0, 0), 3);
|
||||
let shifted: HashSet<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));
|
||||
}
|
||||
}
|
||||
#[path = "tests/chunks.rs"]
|
||||
mod tests;
|
||||
|
|
|
|||
91
crates/client/src/debug.rs
Normal file
91
crates/client/src/debug.rs
Normal 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;
|
||||
|
|
@ -6,7 +6,8 @@
|
|||
|
||||
mod camera;
|
||||
mod chunks;
|
||||
mod meshing;
|
||||
mod debug;
|
||||
mod mesh_pool;
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
|
|
@ -56,6 +57,8 @@ struct App {
|
|||
camera: Camera,
|
||||
/// The current keyboard and mouse input state.
|
||||
input: InputState,
|
||||
/// Debug-only key handling, kept separate from the gameplay input path.
|
||||
debug: debug::DebugControls,
|
||||
/// Timestamp of the previous frame, used to derive delta-time. `None` before the first frame.
|
||||
last_frame: Option<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.
|
||||
|
|
@ -80,6 +83,7 @@ impl Default for App {
|
|||
-0.5,
|
||||
),
|
||||
input: InputState::default(),
|
||||
debug: debug::DebugControls::default(),
|
||||
last_frame: None,
|
||||
link: None,
|
||||
connected: false,
|
||||
|
|
@ -89,6 +93,22 @@ impl Default for App {
|
|||
}
|
||||
}
|
||||
|
||||
impl App {
|
||||
/// Applies a debug action produced by [`debug::DebugControls`].
|
||||
///
|
||||
/// Actions targeting the renderer are dropped while it is uninitialised, which is the window between application start and the first `resumed` call.
|
||||
fn apply_debug_action(&mut self, action: debug::DebugAction) {
|
||||
match action {
|
||||
debug::DebugAction::SetRenderMode(mode) => {
|
||||
if let Some(renderer) = self.renderer.as_mut() {
|
||||
renderer.set_render_mode(mode);
|
||||
info!(?mode, "render mode toggled");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ApplicationHandler for App {
|
||||
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
|
||||
let attributes = Window::default_attributes().with_title("Synvael");
|
||||
|
|
@ -194,6 +214,11 @@ impl ApplicationHandler for App {
|
|||
WindowEvent::KeyboardInput { event, .. } => {
|
||||
let pressed = event.state == ElementState::Pressed;
|
||||
if let PhysicalKey::Code(code) = event.physical_key {
|
||||
// Debug chords are resolved first and on their own seam, so debug bindings can grow without entangling the gameplay bindings below.
|
||||
if let Some(action) = self.debug.handle_key(code, pressed) {
|
||||
self.apply_debug_action(action);
|
||||
}
|
||||
|
||||
match code {
|
||||
KeyCode::KeyW => self.input.forward = pressed,
|
||||
KeyCode::KeyS => self.input.backward = pressed,
|
||||
|
|
@ -262,10 +287,10 @@ impl ApplicationHandler for App {
|
|||
// Apply queued server deliveries and reconcile the resident set against the camera.
|
||||
if let (Some(chunks), Some(link), Some(renderer)) = (
|
||||
self.chunks.as_mut(),
|
||||
self.link.as_ref(),
|
||||
self.link.as_mut(),
|
||||
self.renderer.as_mut(),
|
||||
) {
|
||||
chunks.update(center, &link.chunks, renderer);
|
||||
chunks.update(center, &mut link.chunks, renderer);
|
||||
}
|
||||
|
||||
let view = self.camera.view_matrix();
|
||||
|
|
|
|||
150
crates/client/src/mesh_pool.rs
Normal file
150
crates/client/src/mesh_pool.rs
Normal 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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
230
crates/client/src/tests/chunks.rs
Normal file
230
crates/client/src/tests/chunks.rs
Normal 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(¢er));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn excludes_columns_beyond_the_disc() {
|
||||
let set = desired_chunks(ChunkPos::new(0, 0, 0), 4);
|
||||
// One chunk past the radius along an axis: squared distance 25 > 16.
|
||||
assert!(!set.contains(&ChunkPos::new(5, 0, 0)));
|
||||
// The far corner: squared distance 4*4 + 4*4 = 32 > 16.
|
||||
assert!(!set.contains(&ChunkPos::new(4, 0, 4)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertical_extent_is_half_the_radius() {
|
||||
let set = desired_chunks(ChunkPos::new(0, 0, 0), 4);
|
||||
// radius / 2 == 2, so the column at the center spans y in [-2, 2].
|
||||
assert!(set.contains(&ChunkPos::new(0, 2, 0)));
|
||||
assert!(!set.contains(&ChunkPos::new(0, 3, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_is_translation_invariant() {
|
||||
// Shifting the center shifts every member by the same offset; this also exercises negative coordinates on the shifted side.
|
||||
let base = desired_chunks(ChunkPos::new(0, 0, 0), 3);
|
||||
let shifted: HashSet<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());
|
||||
}
|
||||
143
crates/client/src/tests/debug.rs
Normal file
143
crates/client/src/tests/debug.rs
Normal 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);
|
||||
}
|
||||
|
|
@ -1,13 +1,9 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
//! Chunk-stream transport: the per-connection task that pumps chunk subscriptions and deliveries.
|
||||
//!
|
||||
//! After the handshake, each connection carries a dedicated bidirectional QUIC stream for chunk sync (the canonical `StreamLayout::chunk_lod0` id). The client writes [`ChunkSubscribe`] requests on it and the server writes [`ChunkMessage`] deliveries back on the same stream. This module owns the server-side pump: a single [`tokio::select`] loop that reads subscriptions off the stream and forwards them to the synchronous simulation loop, while draining outbound [`ChunkMessage`]s handed to it by that loop.
|
||||
//!
|
||||
//! The two channels crossing the async/sync boundary run in opposite directions and therefore use different primitives. Inbound (`ChunkSubscribe` arriving async, consumed by the sync loop) reuses the crossbeam [`ServerEvent`] channel, whose sender is non-blocking. Outbound (a `ChunkMessage` produced by the sync loop, consumed async) uses a `tokio` unbounded MPSC: its `send` is synchronous, so the non-async simulation thread can push without a runtime, while the receiver's `recv().await` composes into the pump's `select!`. A blocking `crossbeam` receiver would instead freeze the current-thread runtime and cannot appear in a `select!` arm.
|
||||
|
||||
use shared::protocol::chunk::{ChunkMessage, ChunkSubscribe};
|
||||
use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
|
||||
use tokio::sync::mpsc::{Sender, UnboundedReceiver, UnboundedSender};
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::codec::{MAX_CHUNK_FRAME_LEN, read_frame, write_frame};
|
||||
|
|
@ -56,11 +52,10 @@ pub(crate) async fn chunk_stream_task(
|
|||
}
|
||||
};
|
||||
|
||||
// Inbound subscriptions are read on their own future so `read_frame` is never cancelled mid-frame by an outbound write becoming ready.
|
||||
let reader = async {
|
||||
loop {
|
||||
tokio::select! {
|
||||
// A subscription frame arrived from the client.
|
||||
frame = read_frame::<ChunkSubscribe>(&mut recv, MAX_CHUNK_FRAME_LEN) => {
|
||||
match frame {
|
||||
match read_frame::<ChunkSubscribe>(&mut recv, MAX_CHUNK_FRAME_LEN).await {
|
||||
Ok(request) => {
|
||||
// A closed events receiver means the simulation loop is gone; nothing more to do.
|
||||
if events
|
||||
|
|
@ -77,20 +72,22 @@ pub(crate) async fn chunk_stream_task(
|
|||
}
|
||||
}
|
||||
}
|
||||
// The simulation loop handed back a chunk to deliver.
|
||||
msg = outbound.recv() => {
|
||||
match msg {
|
||||
Some(message) => {
|
||||
};
|
||||
|
||||
// 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 sink was dropped: the connection is being torn down.
|
||||
None => 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(
|
||||
connection: quinn::Connection,
|
||||
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.
|
||||
let (mut send, mut recv) = match connection.open_bi().await {
|
||||
|
|
@ -136,27 +133,13 @@ pub(crate) async fn client_chunk_task(
|
|||
}
|
||||
};
|
||||
|
||||
// Inbound chunks are read on their own future so `read_frame` is never cancelled mid-frame by an outbound subscribe becoming ready.
|
||||
let reader = async {
|
||||
loop {
|
||||
tokio::select! {
|
||||
// The UI thread pushed a subscription update to forward to the server.
|
||||
request = subscribe.recv() => {
|
||||
match request {
|
||||
Some(request) => {
|
||||
if let Err(error) = write_frame(&mut send, &request).await {
|
||||
warn!(%error, "failed to write chunk subscribe; ending chunk stream");
|
||||
break;
|
||||
}
|
||||
}
|
||||
// The subscriber was dropped: the UI is shutting down.
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
// A chunk arrived from the server.
|
||||
frame = read_frame::<ChunkMessage>(&mut recv, MAX_CHUNK_FRAME_LEN) => {
|
||||
match frame {
|
||||
match read_frame::<ChunkMessage>(&mut recv, MAX_CHUNK_FRAME_LEN).await {
|
||||
Ok(message) => {
|
||||
// A closed delivery receiver means the UI is gone; nothing more to do.
|
||||
if deliveries.send(message).is_err() {
|
||||
// `send` awaits when the delivery channel is full: the task suspends (yielding the runtime thread so the connection keeps ACKing) until the UI drains a slot, and until then reads no further frames, which backpressures the server via QUIC stream flow control. An error means the UI dropped its receiver, so the session ends.
|
||||
if deliveries.send(message).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
@ -167,8 +150,23 @@ pub(crate) async fn client_chunk_task(
|
|||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 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 => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -6,9 +6,10 @@
|
|||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use quinn::crypto::rustls::{QuicClientConfig, QuicServerConfig};
|
||||
use quinn::{ClientConfig, Endpoint, ServerConfig};
|
||||
use quinn::{ClientConfig, Endpoint, IdleTimeout, ServerConfig, TransportConfig, VarInt};
|
||||
use rustls::DigitallySignedStruct;
|
||||
use rustls::SignatureScheme;
|
||||
use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
|
||||
|
|
@ -20,6 +21,29 @@ use crate::error::NetError;
|
|||
/// The Application-Layer Protocol Negotiation identifier for the Synvael protocol.
|
||||
pub const ALPN: &[u8] = b"synvael";
|
||||
|
||||
/// Interval between QUIC keep-alive probes, in milliseconds.
|
||||
///
|
||||
/// Kept well below [`MAX_IDLE_TIMEOUT_MS`] so several probes elapse before the idle timeout could fire. Keep-alives are required because chunk delivery deliberately stalls the stream when the client cannot mesh fast enough: during such a flow-control stall no application data flows in either direction, and without a probe the connection would be indistinguishable from a dead peer and closed on the idle timeout.
|
||||
const KEEP_ALIVE_INTERVAL_MS: u32 = 5_000;
|
||||
|
||||
/// Maximum time with no received packets before a connection is considered lost, in milliseconds.
|
||||
const MAX_IDLE_TIMEOUT_MS: u32 = 30_000;
|
||||
|
||||
/// Builds the QUIC transport configuration shared by both endpoints.
|
||||
///
|
||||
/// Enables keep-alive probes and sets an explicit idle timeout; see [`KEEP_ALIVE_INTERVAL_MS`] for why probes are mandatory given the chunk stream's backpressure behaviour. All other transport parameters retain their `quinn` defaults.
|
||||
fn transport_config() -> Arc<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.
|
||||
fn ensure_crypto_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()];
|
||||
|
||||
let quic_config = QuicServerConfig::try_from(tls_config)?;
|
||||
let server_config = ServerConfig::with_crypto(Arc::new(quic_config));
|
||||
let mut server_config = ServerConfig::with_crypto(Arc::new(quic_config));
|
||||
server_config.transport_config(transport_config());
|
||||
|
||||
Ok(Endpoint::server(server_config, bind)?)
|
||||
}
|
||||
|
|
@ -69,7 +94,8 @@ pub fn client_endpoint() -> Result<Endpoint, NetError> {
|
|||
tls_config.alpn_protocols = vec![ALPN.to_vec()];
|
||||
|
||||
let quic_config = QuicClientConfig::try_from(tls_config)?;
|
||||
let client_config = ClientConfig::new(Arc::new(quic_config));
|
||||
let mut client_config = ClientConfig::new(Arc::new(quic_config));
|
||||
client_config.transport_config(transport_config());
|
||||
|
||||
let mut endpoint = Endpoint::client("0.0.0.0:0".parse().map_err(std::io::Error::other)?)?;
|
||||
endpoint.set_default_client_config(client_config);
|
||||
|
|
|
|||
|
|
@ -17,8 +17,12 @@ use crate::handshake::{ServerConnection, accept_connection, connect};
|
|||
/// Channel receiver delivering the outcome of a background client connect: the negotiated [`HandshakeAck`] on success, or a human-readable error string on failure.
|
||||
pub type ConnectOutcome = crossbeam_channel::Receiver<Result<HandshakeAck, String>>;
|
||||
|
||||
/// Non-blocking receiver of chunks delivered by the server, drained by the UI thread with `try_recv`.
|
||||
pub type ChunkStream = crossbeam_channel::Receiver<ChunkMessage>;
|
||||
/// Bounded receiver of chunks delivered by the server, drained by the UI thread with `try_recv`.
|
||||
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.
|
||||
///
|
||||
|
|
@ -255,7 +259,9 @@ pub fn connect_in_background(server_addr: SocketAddr, hello: ClientHello) -> Cli
|
|||
let spawn_err_tx = outcome_tx.clone();
|
||||
// Subscription updates flow UI -> network (sync send, async recv); chunk deliveries flow network -> UI (async send, sync try_recv).
|
||||
let (subscribe_tx, subscribe_rx) = tokio::sync::mpsc::unbounded_channel::<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()
|
||||
.name("net-client".to_owned())
|
||||
|
|
|
|||
|
|
@ -17,3 +17,4 @@ tracing.workspace = true
|
|||
gpu-allocator = "0.28.0"
|
||||
bytemuck.workspace = true
|
||||
glam.workspace = true
|
||||
shared = { path = "../shared" }
|
||||
|
|
|
|||
|
|
@ -53,9 +53,15 @@ pub fn create_logical_device(
|
|||
let mut dynamic_rendering_features =
|
||||
vk::PhysicalDeviceDynamicRenderingFeatures::default().dynamic_rendering(true);
|
||||
|
||||
// `fillModeNonSolid` unlocks the `POINT` and `LINE` polygon modes used by the debug render modes. `largePoints` permits a shader-written point size above 1.0, without which debug points rasterise as single pixels.
|
||||
let enabled_features = vk::PhysicalDeviceFeatures::default()
|
||||
.fill_mode_non_solid(true)
|
||||
.large_points(true);
|
||||
|
||||
let create_info = vk::DeviceCreateInfo::default()
|
||||
.queue_create_infos(std::slice::from_ref(&queue_info))
|
||||
.enabled_extension_names(&device_extensions)
|
||||
.enabled_features(&enabled_features)
|
||||
.push_next(&mut synchronization2_features)
|
||||
.push_next(&mut dynamic_rendering_features);
|
||||
|
||||
|
|
|
|||
66
crates/renderer/src/frustum.rs
Normal file
66
crates/renderer/src/frustum.rs
Normal 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 Gribb–Hartmann derivation operates on the rows of the combined matrix, so rows are read here rather than columns.
|
||||
let r0 = mvp.row(0);
|
||||
let r1 = mvp.row(1);
|
||||
let r2 = mvp.row(2);
|
||||
let r3 = mvp.row(3);
|
||||
|
||||
let mut planes = [
|
||||
r3 + r0, // left
|
||||
r3 - r0, // right
|
||||
r3 + r1, // bottom
|
||||
r3 - r1, // top
|
||||
r2, // near (Vulkan depth range [0, 1], hence r2 alone)
|
||||
r3 - r2, // far
|
||||
];
|
||||
|
||||
for plane in &mut planes {
|
||||
let normal_length = plane.truncate().length();
|
||||
*plane /= normal_length;
|
||||
}
|
||||
|
||||
Self { planes }
|
||||
}
|
||||
|
||||
/// Returns whether the axis-aligned box spanning `[min, max]` is at least partially inside the frustum.
|
||||
pub(crate) fn intersects_aabb(&self, min: Vec3, max: Vec3) -> bool {
|
||||
for plane in &self.planes {
|
||||
let normal = plane.truncate();
|
||||
|
||||
// The "positive vertex" is the box corner farthest along the plane normal: per axis the max component is taken when the normal's component is non-negative, otherwise the min. If even that corner lies behind the plane, the whole box does.
|
||||
let positive_vertex = Vec3::new(
|
||||
if normal.x >= 0.0 { max.x } else { min.x },
|
||||
if normal.y >= 0.0 { max.y } else { min.y },
|
||||
if normal.z >= 0.0 { max.z } else { min.z },
|
||||
);
|
||||
|
||||
if plane.dot(positive_vertex.extend(1.0)) < 0.0 {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/frustum.rs"]
|
||||
mod tests;
|
||||
|
|
@ -9,13 +9,15 @@
|
|||
|
||||
mod device;
|
||||
pub mod error;
|
||||
mod frustum;
|
||||
mod instance;
|
||||
pub mod mesh;
|
||||
pub mod meshing;
|
||||
mod pipeline;
|
||||
mod renderer;
|
||||
mod surface;
|
||||
mod swapchain;
|
||||
mod sync;
|
||||
pub mod vertex;
|
||||
|
||||
/// The maximum number of frames that can be processed by the GPU and CPU simultaneously.
|
||||
pub const MAX_FRAMES_IN_FLIGHT: usize = 3;
|
||||
|
|
@ -26,7 +28,7 @@ use raw_window_handle::{RawDisplayHandle, RawWindowHandle};
|
|||
use std::ffi::c_char;
|
||||
|
||||
pub use error::RendererError;
|
||||
pub use renderer::{MeshKey, Renderer};
|
||||
pub use renderer::{MeshKey, RasterPass, RenderMode, Renderer};
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
|
|
@ -122,8 +124,18 @@ impl Renderer {
|
|||
|
||||
// 12. Graphics Pipeline Configuration
|
||||
let pipeline_layout = pipeline::create_pipeline_layout(&device)?;
|
||||
let graphics_pipeline =
|
||||
pipeline::create_graphics_pipeline(&device, pipeline_layout, swapchain_format)?;
|
||||
|
||||
// One pipeline per 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) =
|
||||
create_depth_resources(&device, &mut allocator, swapchain_extent)?;
|
||||
|
|
@ -153,7 +165,8 @@ impl Renderer {
|
|||
depth_allocation: Some(depth_allocation),
|
||||
depth_image_view,
|
||||
pipeline_layout,
|
||||
graphics_pipeline,
|
||||
pipelines,
|
||||
render_mode: RenderMode::default(),
|
||||
sync: Some(sync),
|
||||
current_frame: 0,
|
||||
})
|
||||
|
|
|
|||
405
crates/renderer/src/meshing.rs
Normal file
405
crates/renderer/src/meshing.rs
Normal 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;
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
//! Graphics pipeline creation and shader management.
|
||||
|
||||
use crate::error::RendererError;
|
||||
use crate::mesh::Vertex;
|
||||
use crate::vertex::Vertex;
|
||||
use ash::{Device, vk};
|
||||
use std::io::Cursor;
|
||||
|
||||
|
|
@ -67,6 +67,8 @@ pub fn create_graphics_pipeline(
|
|||
device: &Device,
|
||||
layout: vk::PipelineLayout,
|
||||
color_format: vk::Format,
|
||||
polygon_mode: vk::PolygonMode,
|
||||
depth_compare_op: vk::CompareOp,
|
||||
) -> Result<vk::Pipeline, RendererError> {
|
||||
// 1. Load and compile shader modules
|
||||
let (vert_module, frag_module) = load_shader_modules(device)?;
|
||||
|
|
@ -101,7 +103,7 @@ pub fn create_graphics_pipeline(
|
|||
let rasterizer = vk::PipelineRasterizationStateCreateInfo::default()
|
||||
.depth_clamp_enable(false)
|
||||
.rasterizer_discard_enable(false)
|
||||
.polygon_mode(vk::PolygonMode::FILL)
|
||||
.polygon_mode(polygon_mode)
|
||||
.line_width(1.0)
|
||||
.cull_mode(vk::CullModeFlags::BACK)
|
||||
.front_face(vk::FrontFace::COUNTER_CLOCKWISE)
|
||||
|
|
@ -131,7 +133,7 @@ pub fn create_graphics_pipeline(
|
|||
let depth_stencil_state = &vk::PipelineDepthStencilStateCreateInfo::default()
|
||||
.depth_test_enable(true)
|
||||
.depth_write_enable(true)
|
||||
.depth_compare_op(vk::CompareOp::LESS)
|
||||
.depth_compare_op(depth_compare_op)
|
||||
.depth_bounds_test_enable(false)
|
||||
.stencil_test_enable(false);
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
use crate::sync::SyncPrimitives;
|
||||
use crate::{create_depth_resources, create_gpu_buffer, swapchain};
|
||||
use crate::{error::RendererError, mesh::Vertex};
|
||||
use crate::{error::RendererError, frustum::Frustum, vertex::Vertex};
|
||||
use ash::{Device, Instance, khr, vk};
|
||||
use gpu_allocator::vulkan::{Allocation, Allocator};
|
||||
use std::collections::HashMap;
|
||||
|
|
@ -10,6 +10,101 @@ use std::collections::HashMap;
|
|||
/// Opaque, renderer-side identifier for one uploaded chunk mesh.
|
||||
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.
|
||||
pub(crate) struct GpuMesh {
|
||||
/// Buffer holding the chunk's vertex data.
|
||||
|
|
@ -70,8 +165,10 @@ pub struct Renderer {
|
|||
pub(crate) command_buffers: Vec<vk::CommandBuffer>,
|
||||
/// The layout of the graphics pipeline.
|
||||
pub(crate) pipeline_layout: vk::PipelineLayout,
|
||||
/// The compiled graphics pipeline state.
|
||||
pub(crate) graphics_pipeline: vk::Pipeline,
|
||||
/// One compiled pipeline per [`RasterPass`], indexed by [`RasterPass::index`]. All variants share [`Renderer::pipeline_layout`] and differ only in rasterisation and depth-compare state.
|
||||
pub(crate) pipelines: [vk::Pipeline; RasterPass::COUNT],
|
||||
/// The rasterisation mode selected for subsequent frames, chosen by [`Renderer::set_render_mode`].
|
||||
pub(crate) render_mode: RenderMode,
|
||||
/// Memory manager for GPU allocations.
|
||||
pub(crate) allocator: Option<Allocator>,
|
||||
/// 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.
|
||||
fn issue_draw_calls(&self, cmd: vk::CommandBuffer, camera_view: glam::Mat4) {
|
||||
unsafe {
|
||||
self.device.cmd_bind_pipeline(
|
||||
cmd,
|
||||
vk::PipelineBindPoint::GRAPHICS,
|
||||
self.graphics_pipeline,
|
||||
);
|
||||
|
||||
#[expect(
|
||||
clippy::cast_precision_loss,
|
||||
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.
|
||||
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());
|
||||
self.device.cmd_push_constants(
|
||||
cmd,
|
||||
|
|
@ -445,13 +565,21 @@ impl Renderer {
|
|||
)]
|
||||
let chunk_offset_byte = size_of::<glam::Mat4>() as u32;
|
||||
|
||||
for mesh in self.chunk_meshes.values() {
|
||||
// The offset is padded to a vec4 to match the std140 layout of the push-constant block; only xyz is read by the shader.
|
||||
// Overlay modes submit the same geometry more than once, each pass binding a pipeline whose rasterisation state differs. Later passes draw over earlier ones.
|
||||
for pass in self.render_mode.passes() {
|
||||
self.device.cmd_bind_pipeline(
|
||||
cmd,
|
||||
vk::PipelineBindPoint::GRAPHICS,
|
||||
self.pipelines[pass.index()],
|
||||
);
|
||||
|
||||
for mesh in &visible {
|
||||
// The offset is padded to a vec4 to match the std140 layout of the push-constant block. The shader reads xyz as the chunk's world offset and w as the debug-tint weight for this pass.
|
||||
let offset = [
|
||||
mesh.world_offset[0],
|
||||
mesh.world_offset[1],
|
||||
mesh.world_offset[2],
|
||||
0.0_f32,
|
||||
pass.tint(),
|
||||
];
|
||||
self.device.cmd_push_constants(
|
||||
cmd,
|
||||
|
|
@ -463,13 +591,18 @@ impl Renderer {
|
|||
|
||||
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_bind_index_buffer(
|
||||
cmd,
|
||||
mesh.index_buffer,
|
||||
0,
|
||||
vk::IndexType::UINT32,
|
||||
);
|
||||
self.device
|
||||
.cmd_draw_indexed(cmd, mesh.index_count, 1, 0, 0, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Transitions the swapchain image back to the presentation layout.
|
||||
///
|
||||
|
|
@ -570,6 +703,17 @@ impl Renderer {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Sets the rasterisation mode used for subsequent frames.
|
||||
pub const fn set_render_mode(&mut self, mode: RenderMode) {
|
||||
self.render_mode = mode;
|
||||
}
|
||||
|
||||
/// Returns the rasterisation mode currently in use.
|
||||
#[must_use]
|
||||
pub const fn render_mode(&self) -> RenderMode {
|
||||
self.render_mode
|
||||
}
|
||||
|
||||
/// Frees the GPU mesh stored under `key`. Does nothing if no mesh is present.
|
||||
pub fn remove_mesh(&mut self, key: MeshKey) {
|
||||
let Some(mesh) = self.chunk_meshes.remove(&key) else {
|
||||
|
|
@ -595,7 +739,10 @@ impl Drop for Renderer {
|
|||
unsafe {
|
||||
let _ = self.device.device_wait_idle();
|
||||
|
||||
self.device.destroy_pipeline(self.graphics_pipeline, None);
|
||||
// Every rasterisation variant is a distinct pipeline object and must be destroyed, or the validation layers report the survivors as leaked at teardown.
|
||||
for pipeline in self.pipelines {
|
||||
self.device.destroy_pipeline(pipeline, None);
|
||||
}
|
||||
self.device
|
||||
.destroy_pipeline_layout(self.pipeline_layout, None);
|
||||
|
||||
|
|
|
|||
39
crates/renderer/src/tests/frustum.rs
Normal file
39
crates/renderer/src/tests/frustum.rs
Normal 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)));
|
||||
}
|
||||
237
crates/renderer/src/tests/meshing.rs
Normal file
237
crates/renderer/src/tests/meshing.rs
Normal 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())
|
||||
);
|
||||
}
|
||||
|
|
@ -9,7 +9,7 @@ use bytemuck::{Pod, Zeroable};
|
|||
/// Uses `repr(C)` to ensure the memory layout matches what the GPU expects (no Rust-specific reordering).
|
||||
/// `Pod` and `Zeroable` allows safely casting this struct to a raw byte slice.
|
||||
#[repr(C)]
|
||||
#[derive(Copy, Clone, Debug, Pod, Zeroable)]
|
||||
#[derive(Copy, Clone, Debug, PartialEq, Pod, Zeroable)]
|
||||
pub struct Vertex {
|
||||
/// 3D position of the vertex (X, Y, Z).
|
||||
pub position: [f32; 3],
|
||||
|
|
@ -26,8 +26,12 @@ impl ChunkCache {
|
|||
}
|
||||
|
||||
/// Returns the baseline for `pos`, generating and caching it on a miss.
|
||||
///
|
||||
/// Generation runs outside the lock, so concurrent callers do not serialise on a cache miss. Two callers racing on the same position may each generate a baseline; generation is deterministic and side-effect-free, so the duplicated work is redundant rather than incorrect, and is far cheaper than serialising every miss behind the store.
|
||||
#[must_use]
|
||||
pub fn get_or_generate(&self, pos: ChunkPos, generator: &VoxelGenerator) -> Chunk {
|
||||
// Probe under the lock, then release it before generating.
|
||||
{
|
||||
// A poisoned lock cannot yield a usable cache; recovering the guard lets generation proceed rather than propagating a panic across every worker that shares this cache.
|
||||
let mut guard = self
|
||||
.inner
|
||||
|
|
@ -36,8 +40,16 @@ impl ChunkCache {
|
|||
if let Some(hit) = guard.get(&pos) {
|
||||
return hit.clone();
|
||||
}
|
||||
}
|
||||
|
||||
let chunk = generator.generate_chunk(pos);
|
||||
guard.put(pos, chunk.clone());
|
||||
|
||||
// Retake the lock only to publish. A concurrent caller may have inserted the same position in the meantime; overwriting is harmless because both baselines are identical.
|
||||
self.inner
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.put(pos, chunk.clone());
|
||||
|
||||
chunk
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue