synvael/crates/client/src/mesh_pool.rs

152 lines
6.7 KiB
Rust

// 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. Read in the meantime only for its length, by [`MeshPool::worker_count`].
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.
///
/// Returns the number of worker threads the pool was spawned with.
pub(crate) fn worker_count(&self) -> usize {
self.workers.len()
}
/// 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)
}