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",
|
"net",
|
||||||
"raw-window-handle",
|
"raw-window-handle",
|
||||||
"renderer",
|
"renderer",
|
||||||
"serde_json",
|
|
||||||
"shared",
|
"shared",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,5 @@ renderer = { path = "../renderer" }
|
||||||
glam.workspace = true
|
glam.workspace = true
|
||||||
raw-window-handle.workspace = true
|
raw-window-handle.workspace = true
|
||||||
ash-window.workspace = true
|
ash-window.workspace = true
|
||||||
serde_json.workspace = true
|
|
||||||
shared = { path = "../shared" }
|
shared = { path = "../shared" }
|
||||||
net = { version = "0.1.0", path = "../net" }
|
net = { version = "0.1.0", path = "../net" }
|
||||||
|
|
|
||||||
|
|
@ -4,68 +4,85 @@
|
||||||
|
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
use shared::generator::VoxelGenerator;
|
use shared::protocol::chunk::ChunkMessage;
|
||||||
use shared::world::{CHUNK_SIZE, ChunkPos};
|
use shared::world::{CHUNK_SIZE, Chunk, ChunkData, ChunkPos};
|
||||||
use tracing::{debug, error};
|
use tracing::{debug, error};
|
||||||
|
|
||||||
use crate::meshing;
|
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.
|
// 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.
|
// TODO: move meshing to a worker pool.
|
||||||
const LOADS_PER_UPDATE: usize = 4;
|
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 {
|
pub struct ChunkManager {
|
||||||
/// Deterministic voxel source for locally generated chunks.
|
/// Positions uploaded to the renderer (whether or not they produced a non-empty mesh), so unload and drop can reconcile against the renderer.
|
||||||
generator: VoxelGenerator,
|
|
||||||
/// Positions already generated and reconciled with the renderer, whether or not they produced a non-empty mesh.
|
|
||||||
resident: HashSet<ChunkPos>,
|
resident: HashSet<ChunkPos>,
|
||||||
|
/// Reused all-air baseline that server [`ChunkData`] diffs are materialized against.
|
||||||
|
baseline: Chunk,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ChunkManager {
|
impl ChunkManager {
|
||||||
/// Creates a manager drawing chunks from `generator`, with no chunks yet resident.
|
/// Creates a manager with no chunks yet resident.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn new(generator: VoxelGenerator) -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
generator,
|
|
||||||
resident: HashSet::new(),
|
resident: HashSet::new(),
|
||||||
|
baseline: Chunk::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reconciles the resident chunk set toward the load radius around `center`.
|
/// Reconciles the resident chunk set: evicts chunks outside the load radius around `center`, then applies queued server deliveries under a per-frame meshing budget.
|
||||||
pub fn update(&mut self, center: ChunkPos, renderer: &mut renderer::Renderer) {
|
///
|
||||||
let desired = desired_chunks(center, LOAD_RADIUS);
|
/// 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 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load up to a bounded number of desired chunks that are not yet resident.
|
|
||||||
let mut loaded = 0;
|
let mut loaded = 0;
|
||||||
for &pos in &desired {
|
let mut dropped = 0;
|
||||||
if loaded >= LOADS_PER_UPDATE {
|
// Only chunk deliveries count against the meshing budget; drops are cheap and always applied.
|
||||||
break;
|
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 self.resident.contains(&pos) {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let chunk = self.generator.generate_chunk(pos);
|
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);
|
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.
|
// 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() {
|
if !indices.is_empty() {
|
||||||
// Chunk coordinates and CHUNK_SIZE are small and represent exactly as f32.
|
// Chunk coordinates and CHUNK_SIZE are small and represent exactly as f32.
|
||||||
#[expect(
|
#[expect(
|
||||||
|
|
@ -84,25 +101,44 @@ impl ChunkManager {
|
||||||
if let Err(e) =
|
if let Err(e) =
|
||||||
renderer.insert_mesh((pos.x, pos.y, pos.z), &vertices, &indices, world_offset)
|
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}");
|
error!(?pos, "failed to upload chunk mesh: {e}");
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.resident.insert(pos);
|
self.resident.insert(pos);
|
||||||
loaded += 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if loaded > 0 || !stale.is_empty() {
|
/// Removes one chunk from the renderer on the server's authoritative instruction, returning whether it was resident.
|
||||||
debug!(
|
fn drop_chunk(&mut self, pos: ChunkPos, renderer: &mut renderer::Renderer) -> bool {
|
||||||
loaded,
|
if self.resident.remove(&pos) {
|
||||||
unloaded = stale.len(),
|
renderer.remove_mesh((pos.x, pos.y, pos.z));
|
||||||
resident = self.resident.len(),
|
true
|
||||||
"chunk stream reconciled"
|
} 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 set of chunk positions within the streaming cylinder around `center`.
|
/// Returns the set of chunk positions within the streaming cylinder around `center`.
|
||||||
|
|
|
||||||
|
|
@ -58,9 +58,13 @@ struct App {
|
||||||
input: InputState,
|
input: InputState,
|
||||||
/// Timestamp of the previous frame, used to derive delta-time. `None` before the first frame.
|
/// Timestamp of the previous frame, used to derive delta-time. `None` before the first frame.
|
||||||
last_frame: Option<Instant>,
|
last_frame: Option<Instant>,
|
||||||
/// 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.
|
/// Handles onto the background network connection: the handshake outcome, the chunk-subscription sender, and the chunk-delivery receiver. `None` before the connection is started.
|
||||||
handshake_rx: Option<net::ConnectOutcome>,
|
link: Option<net::ClientLink>,
|
||||||
/// Streams chunk meshes in and out around the camera. `None` until the renderer and worldgen config are initialised on resume.
|
/// 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>,
|
chunks: Option<chunks::ChunkManager>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -77,7 +81,9 @@ impl Default for App {
|
||||||
),
|
),
|
||||||
input: InputState::default(),
|
input: InputState::default(),
|
||||||
last_frame: None,
|
last_frame: None,
|
||||||
handshake_rx: None,
|
link: None,
|
||||||
|
connected: false,
|
||||||
|
last_center: None,
|
||||||
chunks: None,
|
chunks: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -151,23 +157,9 @@ impl ApplicationHandler for App {
|
||||||
self.window = Some(window);
|
self.window = Some(window);
|
||||||
self.renderer = Some(renderer);
|
self.renderer = Some(renderer);
|
||||||
|
|
||||||
#[expect(
|
// The client renders only server-streamed terrain and no longer generates chunks locally.
|
||||||
clippy::expect_used,
|
// TODO: offline/singleplayer via an in-process server would reintroduce a local world source here.
|
||||||
reason = "startup asset load; a missing worldgen config is unrecoverable at launch"
|
self.chunks = Some(chunks::ChunkManager::new());
|
||||||
)]
|
|
||||||
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));
|
|
||||||
|
|
||||||
// Kick off a background connect + handshake to the local server.
|
// Kick off a background connect + handshake to the local server.
|
||||||
let hello = shared::protocol::ClientHello {
|
let hello = shared::protocol::ClientHello {
|
||||||
|
|
@ -182,7 +174,7 @@ impl ApplicationHandler for App {
|
||||||
let server_addr =
|
let server_addr =
|
||||||
std::net::SocketAddr::from((std::net::Ipv4Addr::LOCALHOST, net::DEFAULT_PORT));
|
std::net::SocketAddr::from((std::net::Ipv4Addr::LOCALHOST, net::DEFAULT_PORT));
|
||||||
info!("Connecting to server at {server_addr}");
|
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) {
|
fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
|
||||||
|
|
@ -215,28 +207,28 @@ impl ApplicationHandler for App {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
WindowEvent::RedrawRequested => {
|
WindowEvent::RedrawRequested => {
|
||||||
// Non-blocking check for the handshake outcome.
|
// 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.
|
||||||
let mut handshake_done = false;
|
if !self.connected {
|
||||||
if let Some(rx) = self.handshake_rx.as_ref() {
|
let outcome = self
|
||||||
match rx.try_recv() {
|
.link
|
||||||
Ok(Ok(ack)) => {
|
.as_ref()
|
||||||
|
.and_then(|link| link.handshake.try_recv().ok());
|
||||||
|
match outcome {
|
||||||
|
Some(Ok(ack)) => {
|
||||||
info!(
|
info!(
|
||||||
protocol_version = ack.protocol_version,
|
protocol_version = ack.protocol_version,
|
||||||
"handshake complete"
|
"handshake complete"
|
||||||
);
|
);
|
||||||
handshake_done = true;
|
self.connected = true;
|
||||||
}
|
}
|
||||||
Ok(Err(reason)) => {
|
Some(Err(reason)) => {
|
||||||
warn!("handshake failed: {reason}");
|
warn!("handshake failed: {reason}");
|
||||||
handshake_done = true;
|
self.link = None;
|
||||||
}
|
}
|
||||||
// Empty: not ready yet. Disconnected: the network thread ended.
|
// No outcome yet (empty), or the network thread ended (disconnected).
|
||||||
Err(_) => {}
|
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.
|
// 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();
|
let now = Instant::now();
|
||||||
|
|
@ -256,10 +248,24 @@ impl ApplicationHandler for App {
|
||||||
f64::from(pos.y),
|
f64::from(pos.y),
|
||||||
f64::from(pos.z),
|
f64::from(pos.z),
|
||||||
);
|
);
|
||||||
if let (Some(chunks), Some(renderer)) =
|
|
||||||
(self.chunks.as_mut(), self.renderer.as_mut())
|
// 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) {
|
||||||
chunks.update(center, renderer);
|
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();
|
let view = self.camera.view_matrix();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue