feat(client): render server-streamed chunks around the camera
This commit is contained in:
parent
e00ceecab6
commit
0a2049bbc9
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -551,7 +551,6 @@ dependencies = [
|
|||
"net",
|
||||
"raw-window-handle",
|
||||
"renderer",
|
||||
"serde_json",
|
||||
"shared",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,5 @@ renderer = { path = "../renderer" }
|
|||
glam.workspace = true
|
||||
raw-window-handle.workspace = true
|
||||
ash-window.workspace = true
|
||||
serde_json.workspace = true
|
||||
shared = { path = "../shared" }
|
||||
net = { version = "0.1.0", path = "../net" }
|
||||
|
|
|
|||
|
|
@ -4,43 +4,123 @@
|
|||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use shared::generator::VoxelGenerator;
|
||||
use shared::world::{CHUNK_SIZE, ChunkPos};
|
||||
use shared::protocol::chunk::ChunkMessage;
|
||||
use shared::world::{CHUNK_SIZE, Chunk, ChunkData, ChunkPos};
|
||||
use tracing::{debug, error};
|
||||
|
||||
use crate::meshing;
|
||||
|
||||
/// Radius, in chunks, of the region kept resident around the camera center.
|
||||
/// 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.
|
||||
const LOAD_RADIUS: i32 = 4;
|
||||
pub const LOAD_RADIUS: i32 = 4;
|
||||
|
||||
/// Maximum number of chunks generated and uploaded in a single call to [`ChunkManager::update`], bounding per-frame meshing work so the winit loop stays responsive.
|
||||
/// 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.
|
||||
const LOADS_PER_UPDATE: usize = 4;
|
||||
|
||||
/// Owns the local voxel source and tracks which chunks are currently streamed to the renderer.
|
||||
/// Tracks which server-streamed chunks are currently uploaded to the renderer.
|
||||
pub struct ChunkManager {
|
||||
/// Deterministic voxel source for locally generated chunks.
|
||||
generator: VoxelGenerator,
|
||||
/// Positions already generated and reconciled with the renderer, whether or not they produced a non-empty mesh.
|
||||
/// 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.
|
||||
baseline: Chunk,
|
||||
}
|
||||
|
||||
impl ChunkManager {
|
||||
/// Creates a manager drawing chunks from `generator`, with no chunks yet resident.
|
||||
/// Creates a manager with no chunks yet resident.
|
||||
#[must_use]
|
||||
pub fn new(generator: VoxelGenerator) -> Self {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
generator,
|
||||
resident: HashSet::new(),
|
||||
baseline: Chunk::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reconciles the resident chunk set toward the load radius around `center`.
|
||||
pub fn update(&mut self, center: ChunkPos, renderer: &mut renderer::Renderer) {
|
||||
let desired = desired_chunks(center, LOAD_RADIUS);
|
||||
/// Reconciles the resident chunk set: evicts chunks outside the load radius around `center`, then applies queued server deliveries under a per-frame meshing budget.
|
||||
///
|
||||
/// The client's own radius eviction runs independently of the server's authoritative `Drop`, so memory stays bounded even if the server is slow to drop chunks that leave the region.
|
||||
pub fn update(
|
||||
&mut self,
|
||||
center: ChunkPos,
|
||||
deliveries: &net::ChunkStream,
|
||||
renderer: &mut renderer::Renderer,
|
||||
) {
|
||||
let unloaded = self.unload_outside(center, renderer);
|
||||
|
||||
// Evict every resident chunk outside the desired set.
|
||||
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 {
|
||||
match deliveries.try_recv() {
|
||||
Ok(ChunkMessage::Chunk { pos, data }) => {
|
||||
self.apply_chunk(pos, &data, renderer);
|
||||
loaded += 1;
|
||||
}
|
||||
Ok(ChunkMessage::Drop { pos }) => {
|
||||
if self.drop_chunk(pos, renderer) {
|
||||
dropped += 1;
|
||||
}
|
||||
}
|
||||
// Empty or disconnected: nothing more to apply this frame.
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
if loaded > 0 || dropped > 0 || unloaded > 0 {
|
||||
debug!(
|
||||
loaded,
|
||||
dropped,
|
||||
unloaded,
|
||||
resident = self.resident.len(),
|
||||
"chunk stream reconciled"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Materializes, meshes, and uploads one delivered chunk, marking its position resident.
|
||||
fn apply_chunk(&mut self, pos: ChunkPos, data: &ChunkData, renderer: &mut renderer::Renderer) {
|
||||
let chunk = data.materialize(&self.baseline);
|
||||
let (vertices, indices) = meshing::generate_mesh(&chunk);
|
||||
|
||||
// Uploading a zero-length buffer is invalid, so an all-air chunk skips the renderer entirely. It is still marked resident below so a later delivery is not double-counted.
|
||||
if !indices.is_empty() {
|
||||
// Chunk coordinates and CHUNK_SIZE are small and represent exactly as f32.
|
||||
#[expect(
|
||||
clippy::cast_precision_loss,
|
||||
reason = "chunk coordinates stay well within f32's exact-integer range"
|
||||
)]
|
||||
let world_offset = {
|
||||
let size = CHUNK_SIZE as f32;
|
||||
[
|
||||
pos.x as f32 * size,
|
||||
pos.y as f32 * size,
|
||||
pos.z as f32 * size,
|
||||
]
|
||||
};
|
||||
|
||||
if let Err(e) =
|
||||
renderer.insert_mesh((pos.x, pos.y, pos.z), &vertices, &indices, world_offset)
|
||||
{
|
||||
error!(?pos, "failed to upload chunk mesh: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
self.resident.insert(pos);
|
||||
}
|
||||
|
||||
/// Removes one chunk from the renderer on the server's authoritative instruction, returning whether it was resident.
|
||||
fn drop_chunk(&mut self, pos: ChunkPos, renderer: &mut renderer::Renderer) -> bool {
|
||||
if self.resident.remove(&pos) {
|
||||
renderer.remove_mesh((pos.x, pos.y, pos.z));
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Evicts every resident chunk outside the load radius around `center`, returning the number removed.
|
||||
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()
|
||||
|
|
@ -51,57 +131,13 @@ impl ChunkManager {
|
|||
renderer.remove_mesh((pos.x, pos.y, pos.z));
|
||||
self.resident.remove(pos);
|
||||
}
|
||||
stale.len()
|
||||
}
|
||||
}
|
||||
|
||||
// Load up to a bounded number of desired chunks that are not yet resident.
|
||||
let mut loaded = 0;
|
||||
for &pos in &desired {
|
||||
if loaded >= LOADS_PER_UPDATE {
|
||||
break;
|
||||
}
|
||||
if self.resident.contains(&pos) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let chunk = self.generator.generate_chunk(pos);
|
||||
let (vertices, indices) = meshing::generate_mesh(&chunk);
|
||||
|
||||
// Uploading a zero-length buffer is invalid, so an all-air chunk skips the renderer entirely. It is still marked resident below so it is not regenerated on every update.
|
||||
if !indices.is_empty() {
|
||||
// Chunk coordinates and CHUNK_SIZE are small and represent exactly as f32.
|
||||
#[expect(
|
||||
clippy::cast_precision_loss,
|
||||
reason = "chunk coordinates stay well within f32's exact-integer range"
|
||||
)]
|
||||
let world_offset = {
|
||||
let size = CHUNK_SIZE as f32;
|
||||
[
|
||||
pos.x as f32 * size,
|
||||
pos.y as f32 * size,
|
||||
pos.z as f32 * size,
|
||||
]
|
||||
};
|
||||
|
||||
if let Err(e) =
|
||||
renderer.insert_mesh((pos.x, pos.y, pos.z), &vertices, &indices, world_offset)
|
||||
{
|
||||
// The upload failed; the position is left non-resident so the next update retries it rather than silently dropping the chunk.
|
||||
error!(?pos, "failed to upload chunk mesh: {e}");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
self.resident.insert(pos);
|
||||
loaded += 1;
|
||||
}
|
||||
|
||||
if loaded > 0 || !stale.is_empty() {
|
||||
debug!(
|
||||
loaded,
|
||||
unloaded = stale.len(),
|
||||
resident = self.resident.len(),
|
||||
"chunk stream reconciled"
|
||||
);
|
||||
}
|
||||
impl Default for ChunkManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -58,9 +58,13 @@ struct App {
|
|||
input: InputState,
|
||||
/// Timestamp of the previous frame, used to derive delta-time. `None` before the first frame.
|
||||
last_frame: Option<Instant>,
|
||||
/// Receives the outcome of the background connect and handshake, drained non-blocking from the event loop. `None` before the connection is started and once the outcome has been observed.
|
||||
handshake_rx: Option<net::ConnectOutcome>,
|
||||
/// Streams chunk meshes in and out around the camera. `None` until the renderer and worldgen config are initialised on resume.
|
||||
/// Handles onto the background network connection: the handshake outcome, the chunk-subscription sender, and the chunk-delivery receiver. `None` before the connection is started.
|
||||
link: Option<net::ClientLink>,
|
||||
/// Whether the handshake has completed successfully. Gates chunk subscription until the connection is usable.
|
||||
connected: bool,
|
||||
/// The chunk position the camera last subscribed around, so a new subscription is sent only when the center chunk changes.
|
||||
last_center: Option<shared::world::ChunkPos>,
|
||||
/// Streams chunk meshes in and out around the camera. `None` until the renderer is initialised on resume.
|
||||
chunks: Option<chunks::ChunkManager>,
|
||||
}
|
||||
|
||||
|
|
@ -77,7 +81,9 @@ impl Default for App {
|
|||
),
|
||||
input: InputState::default(),
|
||||
last_frame: None,
|
||||
handshake_rx: None,
|
||||
link: None,
|
||||
connected: false,
|
||||
last_center: None,
|
||||
chunks: None,
|
||||
}
|
||||
}
|
||||
|
|
@ -151,23 +157,9 @@ impl ApplicationHandler for App {
|
|||
self.window = Some(window);
|
||||
self.renderer = Some(renderer);
|
||||
|
||||
#[expect(
|
||||
clippy::expect_used,
|
||||
reason = "startup asset load; a missing worldgen config is unrecoverable at launch"
|
||||
)]
|
||||
let config_str = std::fs::read_to_string("assets/data/worldgen/default.json")
|
||||
.expect("Failed to read worldgen config");
|
||||
#[expect(
|
||||
clippy::expect_used,
|
||||
reason = "startup config parse; a malformed worldgen config is unrecoverable at launch"
|
||||
)]
|
||||
let worldgen_config: shared::generator::WorldGenConfig =
|
||||
serde_json::from_str(&config_str).expect("Failed to parse worldgen config");
|
||||
|
||||
let seed = 4_813_530;
|
||||
|
||||
let generator = shared::generator::VoxelGenerator::new(worldgen_config, seed);
|
||||
self.chunks = Some(chunks::ChunkManager::new(generator));
|
||||
// The client renders only server-streamed terrain and no longer generates chunks locally.
|
||||
// TODO: offline/singleplayer via an in-process server would reintroduce a local world source here.
|
||||
self.chunks = Some(chunks::ChunkManager::new());
|
||||
|
||||
// Kick off a background connect + handshake to the local server.
|
||||
let hello = shared::protocol::ClientHello {
|
||||
|
|
@ -182,7 +174,7 @@ impl ApplicationHandler for App {
|
|||
let server_addr =
|
||||
std::net::SocketAddr::from((std::net::Ipv4Addr::LOCALHOST, net::DEFAULT_PORT));
|
||||
info!("Connecting to server at {server_addr}");
|
||||
self.handshake_rx = Some(net::connect_in_background(server_addr, hello));
|
||||
self.link = Some(net::connect_in_background(server_addr, hello));
|
||||
}
|
||||
|
||||
fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
|
||||
|
|
@ -215,28 +207,28 @@ impl ApplicationHandler for App {
|
|||
}
|
||||
}
|
||||
WindowEvent::RedrawRequested => {
|
||||
// Non-blocking check for the handshake outcome.
|
||||
let mut handshake_done = false;
|
||||
if let Some(rx) = self.handshake_rx.as_ref() {
|
||||
match rx.try_recv() {
|
||||
Ok(Ok(ack)) => {
|
||||
// Non-blocking check for the handshake outcome. The link is retained after success so its chunk channels can be used; only a failure discards it.
|
||||
if !self.connected {
|
||||
let outcome = self
|
||||
.link
|
||||
.as_ref()
|
||||
.and_then(|link| link.handshake.try_recv().ok());
|
||||
match outcome {
|
||||
Some(Ok(ack)) => {
|
||||
info!(
|
||||
protocol_version = ack.protocol_version,
|
||||
"handshake complete"
|
||||
);
|
||||
handshake_done = true;
|
||||
self.connected = true;
|
||||
}
|
||||
Ok(Err(reason)) => {
|
||||
Some(Err(reason)) => {
|
||||
warn!("handshake failed: {reason}");
|
||||
handshake_done = true;
|
||||
self.link = None;
|
||||
}
|
||||
// Empty: not ready yet. Disconnected: the network thread ended.
|
||||
Err(_) => {}
|
||||
// No outcome yet (empty), or the network thread ended (disconnected).
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
if handshake_done {
|
||||
self.handshake_rx = None;
|
||||
}
|
||||
|
||||
// Derive delta-time from the previous frame so movement is framerate-independent. The first frame has no predecessor and therefore advances by zero seconds.
|
||||
let now = Instant::now();
|
||||
|
|
@ -256,10 +248,24 @@ impl ApplicationHandler for App {
|
|||
f64::from(pos.y),
|
||||
f64::from(pos.z),
|
||||
);
|
||||
if let (Some(chunks), Some(renderer)) =
|
||||
(self.chunks.as_mut(), self.renderer.as_mut())
|
||||
{
|
||||
chunks.update(center, renderer);
|
||||
|
||||
// Subscribe to the region around the camera whenever the center chunk changes, so the server streams the matching set. The client subscribes with its own load radius so the server's resident set aligns with what the client keeps.
|
||||
if self.connected && self.last_center != Some(center) {
|
||||
if let Some(link) = self.link.as_ref() {
|
||||
let radius = u16::try_from(chunks::LOAD_RADIUS).unwrap_or(u16::MAX);
|
||||
link.subscribe
|
||||
.send(shared::protocol::chunk::ChunkSubscribe { center, radius });
|
||||
}
|
||||
self.last_center = Some(center);
|
||||
}
|
||||
|
||||
// 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.renderer.as_mut(),
|
||||
) {
|
||||
chunks.update(center, &link.chunks, renderer);
|
||||
}
|
||||
|
||||
let view = self.camera.view_matrix();
|
||||
|
|
|
|||
Loading…
Reference in a new issue