mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
LevelExtractor.setupRender calls allChanged when the effective render distance changes; LevelRenderer has no allChanged in 26.3. Co-Authored-By: Claude Opus 5.5 <[email protected]>
4511 lines
188 KiB
Rust
4511 lines
188 KiB
Rust
//! Chunk streaming: a worker pool generates, lights and meshes chunk columns
|
|
//! around the player, closest first. The game thread owns the world and only
|
|
//! hands immutable `Arc` snapshots to workers; results carry a sequence number
|
|
//! so stale work is dropped.
|
|
use crate::fast_hash::{FxHashMap, FxHashSet};
|
|
use crate::section_mask::SectionMask;
|
|
use crate::{
|
|
chunk::Chunk,
|
|
chunk_map::ChunkMembershipDelta,
|
|
graphics::LeafCullingMode,
|
|
light::{LightChanges, LightTables, apply_lighting, apply_relighting, light_chunk},
|
|
mesher::{MeshScratch, MeshTables, Mesher, Region, SectionMesh, borrow},
|
|
world::World,
|
|
};
|
|
use horizon_core::biome::BiomeZoom;
|
|
use horizon_threads::jobs::{Class, PerWorker, Rank, Registration, Source};
|
|
use horizon_world::block_render::FaceVisibility;
|
|
use horizon_world::worldgen::decoration_order::DecorationOrder;
|
|
use std::{
|
|
collections::{BinaryHeap, HashSet, VecDeque},
|
|
sync::{Arc, Condvar, Mutex, MutexGuard, RwLock, TryLockError},
|
|
};
|
|
|
|
/// How long a worker waits with nothing to do before it gives back its
|
|
/// generator's scratch buffers ([`ChunkGenerator::idle`]).
|
|
pub const IDLE_RELEASE: std::time::Duration = std::time::Duration::from_secs(5);
|
|
|
|
/// Worker CPU time and job counts by kind, for benchmarks
|
|
/// (`examples/gen_light_bench.rs`). Off unless enabled: a relaxed load per
|
|
/// job then.
|
|
pub mod job_stats {
|
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
|
pub const KINDS: [&str; 7] = [
|
|
"load", "generate", "decorate", "finish", "light", "mesh", "other",
|
|
];
|
|
static ENABLED: AtomicBool = AtomicBool::new(false);
|
|
static NANOS: [AtomicU64; 7] = [const { AtomicU64::new(0) }; 7];
|
|
static COUNT: [AtomicU64; 7] = [const { AtomicU64::new(0) }; 7];
|
|
pub fn enable(on: bool) {
|
|
ENABLED.store(on, Ordering::Relaxed);
|
|
}
|
|
pub(super) fn enabled() -> bool {
|
|
ENABLED.load(Ordering::Relaxed)
|
|
}
|
|
pub(super) fn add(kind: usize, nanos: u64) {
|
|
NANOS[kind].fetch_add(nanos, Ordering::Relaxed);
|
|
COUNT[kind].fetch_add(1, Ordering::Relaxed);
|
|
}
|
|
/// (kind, nanoseconds, count) since the last call; resets them.
|
|
pub fn take() -> Vec<(&'static str, u64, u64)> {
|
|
(0..KINDS.len())
|
|
.map(|i| {
|
|
(
|
|
KINDS[i],
|
|
NANOS[i].swap(0, Ordering::Relaxed),
|
|
COUNT[i].swap(0, Ordering::Relaxed),
|
|
)
|
|
})
|
|
.collect()
|
|
}
|
|
}
|
|
/// A chunk under generation, opaque to the streamer.
|
|
pub type Proto = Box<dyn std::any::Any + Send>;
|
|
/// Chunk generation in vanilla's stages: a neighbour-independent proto chunk
|
|
/// (biomes, noise, surface, carvers), decoration of a 3x3 neighbourhood
|
|
/// (FEATURES, which may write into neighbours), then conversion of a chunk
|
|
/// whose eight neighbours are decorated into a final column. Implementations
|
|
/// must be thread-safe; each worker of the shared pool
|
|
/// (`horizon_threads::jobs`) keeps one `Worker` per streamer.
|
|
pub trait ChunkGenerator: Send + Sync + 'static {
|
|
type Worker: Send + 'static;
|
|
fn worker(&self) -> Self::Worker;
|
|
fn lod_sampler(self: Arc<Self>) -> Box<dyn LodSampler>
|
|
where
|
|
Self: Sized,
|
|
{
|
|
let worker = self.worker();
|
|
Box::new(GeneratorSampler {
|
|
generator: self,
|
|
worker,
|
|
})
|
|
}
|
|
/// An undecorated far-terrain column, without allocating gameplay chunks.
|
|
fn lod_surface(
|
|
&self,
|
|
_worker: &mut Self::Worker,
|
|
_x: i32,
|
|
_z: i32,
|
|
) -> Option<crate::lod::Surface> {
|
|
None
|
|
}
|
|
/// A previously saved final chunk, bypassing generation.
|
|
fn load(&self, _x: i32, _z: i32) -> Option<Chunk> {
|
|
None
|
|
}
|
|
/// What a loaded chunk needs from the generator before the game uses it,
|
|
/// on the worker that loaded it.
|
|
fn prepare_loaded(&self, _worker: &mut Self::Worker, _chunk: &Chunk) {}
|
|
/// The worker has had nothing to do for [`IDLE_RELEASE`]: give back what
|
|
/// it keeps between jobs only to go faster.
|
|
fn idle(&self, _worker: &mut Self::Worker) {}
|
|
/// Every worker has had nothing to do for [`IDLE_RELEASE`]: give back the
|
|
/// caches the workers share, which only spare work for chunks generated
|
|
/// next to each other in a run. Results do not change.
|
|
fn all_idle(&self) {}
|
|
fn proto(&self, worker: &mut Self::Worker, x: i32, z: i32) -> Proto;
|
|
fn decorates(&self) -> bool {
|
|
false
|
|
}
|
|
/// Decorate `region[4]`; the region is row-major (dz, dx) around it.
|
|
fn decorate(&self, _worker: &mut Self::Worker, _region: &mut [Proto; 9]) {}
|
|
/// Whether a chunk next to final ones (loaded from the save, as the
|
|
/// chunks of a world an older version made) is decorated, its features
|
|
/// spilling into them (`proto_beside_final`, `final_writes`), as vanilla's
|
|
/// are; otherwise it is left undecorated.
|
|
fn decorates_beside_final(&self) -> bool {
|
|
false
|
|
}
|
|
/// The proto chunk of a final chunk, for a decoration region to hold: its
|
|
/// blocks are `snapshot`'s.
|
|
fn proto_beside_final(&self, _worker: &mut Self::Worker, _snapshot: &Chunk) -> Option<Proto> {
|
|
None
|
|
}
|
|
/// What decoration wrote into the proto chunk of a final chunk: the blocks
|
|
/// that differ from `snapshot`, as `([x, y, z], state)`.
|
|
fn final_writes(&self, _snapshot: &Chunk, _proto: &Proto) -> Vec<([i32; 3], u32)> {
|
|
Vec::new()
|
|
}
|
|
/// The block entities decoration made at the blocks `writes` put into a
|
|
/// final chunk (a chest of a structure piece, a spawner), with their
|
|
/// contents.
|
|
fn final_block_entities(
|
|
&self,
|
|
_proto: &Proto,
|
|
_writes: &[([i32; 3], u32)],
|
|
) -> Vec<((i32, i32, i32), crate::chunk::block_entity::BlockEntity)> {
|
|
Vec::new()
|
|
}
|
|
fn finish(&self, proto: Proto) -> Chunk;
|
|
/// Whether `proto` has been decorated (a saved one can come back decorated).
|
|
fn is_decorated(&self, _proto: &Proto) -> bool {
|
|
false
|
|
}
|
|
/// What to save of a proto chunk dropped before it became final.
|
|
/// A decorated proto chunk's blocks as a column, to light a finished
|
|
/// neighbour against (vanilla lights with neighbours at FEATURES).
|
|
fn light_view(&self, _proto: &Proto) -> Option<Chunk> {
|
|
None
|
|
}
|
|
/// A decorated proto chunk's column sections (`Chunk::sections`
|
|
/// indexes) holding a block other than air: what its neighbours' light
|
|
/// layers count once it is at INITIALIZE_LIGHT.
|
|
fn non_air_sections(&self, _proto: &Proto) -> Option<SectionMask> {
|
|
None
|
|
}
|
|
/// What the generator's shared caches hold: (name, entries, bytes, if
|
|
/// known), for `horizon-ctl memory`.
|
|
fn cache_census(&self) -> Vec<(&'static str, usize, Option<usize>)> {
|
|
Vec::new()
|
|
}
|
|
/// A proto chunk's heap bytes by part, and its sections by bits per
|
|
/// block, for `horizon-ctl memory` (`ProtoChunk::heap_bytes`).
|
|
fn proto_heap_bytes(&self, _proto: &Proto) -> ([usize; 6], [usize; 16]) {
|
|
([0; 6], [0; 16])
|
|
}
|
|
fn saved_proto(&self, _proto: &Proto) -> Option<crate::chunk::SavedProto> {
|
|
None
|
|
}
|
|
/// Put a saved proto chunk's state back onto `proto`, generated again.
|
|
fn restore_proto(&self, _proto: &mut Proto, _saved: crate::chunk::SavedProto) {}
|
|
/// Save proto chunks decoration wrote to, so that they come back as they
|
|
/// were: generating them again would lose those writes.
|
|
fn save_protos(&self, _protos: &[&Proto]) {}
|
|
/// Save a column that finished after the player moved away: its
|
|
/// neighbours' decoration is done, so it can't be generated again.
|
|
fn save_finished(&self, _chunk: Chunk) {}
|
|
}
|
|
pub type LodDetail = ((i32, i32), Arc<[crate::lod::Column; 256]>);
|
|
|
|
/// Independent, thread-owned far-terrain sampling state.
|
|
pub trait LodSampler: Send {
|
|
/// Independent sampling state; native detail generation stays with the owner.
|
|
fn fork(&self) -> Option<Box<dyn LodSampler>> {
|
|
None
|
|
}
|
|
fn request_detail(&mut self, _chunks: &[(i32, i32)], _center: [i32; 2]) {}
|
|
fn drain_details(&mut self) -> Vec<LodDetail> {
|
|
Vec::new()
|
|
}
|
|
/// A loaded chunk's summary, made on the far-terrain worker.
|
|
fn observed(&mut self, _key: (i32, i32), _columns: &Arc<[crate::lod::Column; 256]>) {}
|
|
/// Reduced far detail (`graphics::FarDetail::Reduced`): `grid` may then
|
|
/// approximate wide cells.
|
|
fn set_approximate(&mut self, _approximate: bool) {}
|
|
fn sample(&mut self, x: i32, z: i32) -> Option<crate::lod::Surface>;
|
|
fn column(&mut self, x: i32, z: i32) -> crate::lod::Column {
|
|
crate::lod::Column::from_surface(self.sample(x, z))
|
|
}
|
|
fn reduced_column(&mut self, x: i32, z: i32, _width: i32) -> crate::lod::Column {
|
|
self.column(x, z)
|
|
}
|
|
/// Whether a column since the last call was left empty because its data
|
|
/// is still on its way (a BlueMap tile downloading, nothing coarser to
|
|
/// stand in): that sample isn't drawn, what it would replace is
|
|
/// (`Lod::finish_any_sample`), and it is taken again later.
|
|
fn waited(&mut self) -> bool {
|
|
false
|
|
}
|
|
/// `side` x `side` columns from (`x`, `z`), `stride` blocks apart; none
|
|
/// when sampling failed and should be tried again later.
|
|
fn grid(&mut self, x: i32, z: i32, stride: i32, side: usize) -> Vec<crate::lod::Column> {
|
|
let mut columns = Vec::with_capacity(side * side);
|
|
for dz in 0..side {
|
|
for dx in 0..side {
|
|
columns.push(self.reduced_column(
|
|
x + dx as i32 * stride,
|
|
z + dz as i32 * stride,
|
|
stride,
|
|
));
|
|
}
|
|
}
|
|
columns
|
|
}
|
|
}
|
|
pub trait LodSource: Send + Sync {
|
|
fn sampler(self: Arc<Self>) -> Box<dyn LodSampler>;
|
|
/// Whether its samplers' `observed` chunks become its own (a server's
|
|
/// kept terrain): tiles sampled without one are sampled again.
|
|
fn keeps_observed(&self) -> bool {
|
|
false
|
|
}
|
|
}
|
|
struct GeneratorSampler<G: ChunkGenerator> {
|
|
generator: Arc<G>,
|
|
worker: G::Worker,
|
|
}
|
|
impl<G: ChunkGenerator> LodSampler for GeneratorSampler<G> {
|
|
fn sample(&mut self, x: i32, z: i32) -> Option<crate::lod::Surface> {
|
|
self.generator.lod_surface(&mut self.worker, x, z)
|
|
}
|
|
}
|
|
impl<G: ChunkGenerator> LodSource for G {
|
|
fn sampler(self: Arc<Self>) -> Box<dyn LodSampler> {
|
|
ChunkGenerator::lod_sampler(self)
|
|
}
|
|
}
|
|
/// The generator's saving, for the streamer (which doesn't know its type).
|
|
trait Saves: LodSource {
|
|
fn save_protos(&self, protos: &[&Proto]);
|
|
fn save_finished(&self, chunk: Chunk);
|
|
}
|
|
impl<G: ChunkGenerator> Saves for G {
|
|
fn save_protos(&self, protos: &[&Proto]) {
|
|
ChunkGenerator::save_protos(self, protos);
|
|
}
|
|
fn save_finished(&self, chunk: Chunk) {
|
|
ChunkGenerator::save_finished(self, chunk);
|
|
}
|
|
}
|
|
/// Per-job mesher inputs that change with the dimension or the options.
|
|
#[derive(Clone)]
|
|
struct MeshSettings {
|
|
nether: bool,
|
|
shader_pack_lighting: bool,
|
|
separate_ao: bool,
|
|
shader_pack: bool,
|
|
zoom: BiomeZoom,
|
|
blend_radius: i32,
|
|
smooth_lighting: bool,
|
|
cutout_leaves: bool,
|
|
translucent_layers: Arc<[bool]>,
|
|
leaf_culling: LeafCullingMode,
|
|
}
|
|
enum Job {
|
|
Generate(i32, i32),
|
|
/// A region to decorate, with the final chunks in it (region index and
|
|
/// the chunk as it is): their slots hold nothing until the worker makes
|
|
/// their proto chunks.
|
|
Decorate(i32, i32, Box<[Proto; 9]>, Vec<(usize, Chunk)>),
|
|
Finish(Proto),
|
|
Light(i32, i32, Box<Region>, u64, bool),
|
|
Mesh(i32, i32, i32, Box<Region>, u64, MeshSettings),
|
|
/// Proto chunks checked out to be saved (`ChunkGenerator::save_protos`),
|
|
/// off the main thread like vanilla's IOWorker.
|
|
Save(Vec<((i32, i32), Proto)>),
|
|
/// A proto chunk checked out for its light view (`light_region`).
|
|
LightView(i32, i32, Proto),
|
|
/// Meshes the renderer is done with (`Streaming::free`): their memory
|
|
/// is given back here, not in the frame.
|
|
Free(Vec<((i32, i32, i32), SectionMesh)>),
|
|
}
|
|
/// What decoration wrote into the final chunks of its region.
|
|
#[derive(Default)]
|
|
pub struct FinalWrites {
|
|
blocks: Vec<([i32; 3], u32)>,
|
|
block_entities: Vec<((i32, i32, i32), crate::chunk::block_entity::BlockEntity)>,
|
|
}
|
|
pub enum Done {
|
|
Generated(Chunk),
|
|
/// A proto chunk, and whether it was decorated (loaded so from the save).
|
|
Proto(i32, i32, Proto, bool),
|
|
/// A decorated region, the region indices of its final chunks and what
|
|
/// decoration wrote into them.
|
|
Decorated(i32, i32, Box<[Proto; 9]>, Vec<usize>, FinalWrites),
|
|
Lit(i32, i32, Vec<crate::chunk::Light>, u64),
|
|
Meshed(i32, i32, i32, SectionMesh, u64),
|
|
Saved(Vec<((i32, i32), Proto)>),
|
|
/// A proto chunk back from `Job::LightView`, with its view.
|
|
LightView(i32, i32, Proto, Option<Chunk>),
|
|
}
|
|
struct Queued {
|
|
priority: i64,
|
|
/// The column the job is for and what it adds to the column's distance
|
|
/// (`Streaming::submit`): the priority is computed again from them when
|
|
/// the centre moves (`Streaming::requeue`). None for saves, which go first.
|
|
at: Option<(i32, i32)>,
|
|
bias: i64,
|
|
order: u64,
|
|
job: Job,
|
|
}
|
|
impl PartialEq for Queued {
|
|
fn eq(&self, o: &Self) -> bool {
|
|
self.priority == o.priority && self.order == o.order
|
|
}
|
|
}
|
|
impl Eq for Queued {}
|
|
impl PartialOrd for Queued {
|
|
fn partial_cmp(&self, o: &Self) -> Option<std::cmp::Ordering> {
|
|
Some(self.cmp(o))
|
|
}
|
|
}
|
|
impl Ord for Queued {
|
|
fn cmp(&self, o: &Self) -> std::cmp::Ordering {
|
|
// BinaryHeap is a max-heap: lower priority value = more urgent.
|
|
o.priority
|
|
.cmp(&self.priority)
|
|
.then(o.order.cmp(&self.order))
|
|
}
|
|
}
|
|
struct Shared {
|
|
queue: Mutex<BinaryHeap<Queued>>,
|
|
/// What meshing reads (block models and sprites), swapped when resource
|
|
/// packs reload; each mesh job takes the current one.
|
|
context: RwLock<Arc<WorkerContext>>,
|
|
done: Mailbox,
|
|
/// Only workers use this pool; frames never wait for its lock. Retaining
|
|
/// cleared buffers avoids racing the renderer for the process heap.
|
|
meshes: Mutex<MeshPool>,
|
|
aborted: std::sync::atomic::AtomicBool,
|
|
}
|
|
#[derive(Default)]
|
|
struct MeshPool {
|
|
meshes: Vec<SectionMesh>,
|
|
bytes: usize,
|
|
}
|
|
impl MeshPool {
|
|
const BYTES: usize = 16 << 20;
|
|
const COUNT: usize = 128;
|
|
fn take(&mut self) -> SectionMesh {
|
|
let mesh = self.meshes.pop().unwrap_or_default();
|
|
self.bytes -= mesh.capacity_bytes();
|
|
mesh
|
|
}
|
|
/// A rejected mesh is dropped by the caller, after releasing the lock.
|
|
fn put(&mut self, mut mesh: SectionMesh) -> Option<SectionMesh> {
|
|
let bytes = mesh.capacity_bytes();
|
|
if bytes == 0 || self.meshes.len() == Self::COUNT || bytes > Self::BYTES - self.bytes {
|
|
return Some(mesh);
|
|
}
|
|
mesh.clear();
|
|
self.bytes += bytes;
|
|
self.meshes.push(mesh);
|
|
None
|
|
}
|
|
}
|
|
|
|
/// Finished jobs, from the workers to the game thread, in the order they
|
|
/// finished. Not an `mpsc` channel: its receiver waits for a sender it finds
|
|
/// half way through a send, and a worker descheduled there on a busy machine
|
|
/// held the frame for a scheduler quantum (10 to 30 ms, the RD 32 ocean
|
|
/// flight's 1% lows). The frame takes what is there without waiting; while a
|
|
/// worker holds the lock, it takes it the next frame.
|
|
#[derive(Default)]
|
|
struct Mailbox {
|
|
done: Mutex<VecDeque<Done>>,
|
|
arrived: Condvar,
|
|
}
|
|
impl Mailbox {
|
|
fn send(&self, done: Done) {
|
|
self.done.lock().unwrap().push_back(done);
|
|
self.arrived.notify_one();
|
|
}
|
|
fn try_lock(&self) -> Option<MutexGuard<'_, VecDeque<Done>>> {
|
|
match self.done.try_lock() {
|
|
Ok(done) => Some(done),
|
|
Err(TryLockError::WouldBlock) => None,
|
|
Err(TryLockError::Poisoned(e)) => panic!("{e}"),
|
|
}
|
|
}
|
|
/// Every finished job, or none while a worker is sending.
|
|
fn take(&self) -> VecDeque<Done> {
|
|
self.try_lock()
|
|
.map(|mut done| std::mem::take(&mut *done))
|
|
.unwrap_or_default()
|
|
}
|
|
fn try_recv(&self) -> Option<Done> {
|
|
self.try_lock()?.pop_front()
|
|
}
|
|
fn recv_timeout(&self, timeout: std::time::Duration) -> Option<Done> {
|
|
let done = self.done.lock().unwrap();
|
|
let (mut done, _) = self
|
|
.arrived
|
|
.wait_timeout_while(done, timeout, |done| done.is_empty())
|
|
.unwrap();
|
|
done.pop_front()
|
|
}
|
|
}
|
|
/// [`Streaming::proto_census`].
|
|
#[derive(Debug, Default)]
|
|
pub struct ProtoCensus {
|
|
pub protos: usize,
|
|
/// Packed blocks, section palettes and lists, heightmaps, other terrain
|
|
/// data, biomes, the box and its structure lists.
|
|
pub bytes: [usize; 6],
|
|
pub sections_by_bits: [usize; 16],
|
|
pub light_views: usize,
|
|
pub light_view_bytes: usize,
|
|
/// `ChunkGenerator::cache_census`.
|
|
pub caches: Vec<(&'static str, usize, Option<usize>)>,
|
|
}
|
|
pub struct Streaming {
|
|
shared: Arc<Shared>,
|
|
/// Columns generated and saved as they were left behind: new when they
|
|
/// next arrive, though read from the save (`World::loaded_chunks`).
|
|
dropped_new: FxHashSet<(i32, i32)>,
|
|
non_air_sections: Arc<dyn Fn(&Proto) -> Option<SectionMask> + Send + Sync>,
|
|
/// `ChunkGenerator::proto_heap_bytes`, for the memory census.
|
|
proto_bytes: Arc<dyn Fn(&Proto) -> ([usize; 6], [usize; 16]) + Send + Sync>,
|
|
/// `ChunkGenerator::cache_census`.
|
|
caches: Arc<dyn Fn() -> Vec<(&'static str, usize, Option<usize>)> + Send + Sync>,
|
|
/// This streamer's place in the shared pool (`horizon_threads::jobs`);
|
|
/// dropping it waits for its running jobs.
|
|
jobs: Registration,
|
|
/// The workers its in-flight work is sized for.
|
|
workers: usize,
|
|
order: u64,
|
|
/// Ticks since the columns and protos that fell out of range were swept.
|
|
scan_age: u8,
|
|
/// The centre and radius the sweep was last reset for.
|
|
scan_area: ((i32, i32), i32),
|
|
generating: FxHashSet<(i32, i32)>,
|
|
generation_scan: GenerationScan,
|
|
/// Proto chunks: `None` while checked out to a decoration or save job.
|
|
protos: FxHashMap<(i32, i32), Option<Proto>>,
|
|
/// The light jobs' views of proto chunks past the area (`light_region`),
|
|
/// built once per proto: each is a neighbour of up to three columns and
|
|
/// waits in failed regions until the last one loads. Dropped whenever
|
|
/// the proto is checked out, replaced or removed.
|
|
light_views: FxHashMap<(i32, i32), Arc<Chunk>>,
|
|
decorated: FxHashSet<(i32, i32)>,
|
|
decorating: usize,
|
|
/// The final chunks a running decoration writes into: another one that
|
|
/// reaches them waits, as vanilla's features run one after the other and
|
|
/// each sees what the one before placed.
|
|
decorating_finals: HashSet<(i32, i32)>,
|
|
/// Save jobs in flight.
|
|
saving: usize,
|
|
/// Proto chunks checked out to a worker for their light view.
|
|
viewing: FxHashSet<(i32, i32)>,
|
|
/// Vanilla's decoration order around the player, and the centre and
|
|
/// radius it was last ranked for.
|
|
decoration_order: DecorationOrder,
|
|
order_area: Option<((i32, i32), i32)>,
|
|
/// What the queued jobs' priorities were computed from (`requeue`): the
|
|
/// centre, the band, and the extra areas.
|
|
queue_basis: (((i32, i32), Option<Band>), Vec<((i32, i32), i32)>),
|
|
finishing: FxHashSet<(i32, i32)>,
|
|
decorates: bool,
|
|
/// `ChunkGenerator::decorates_beside_final`.
|
|
saves_beside_final: bool,
|
|
lighting: FxHashMap<(i32, i32), u64>,
|
|
meshing: FxHashMap<(i32, i32, i32), u64>,
|
|
/// The sequence each section's latest accepted mesh was dispatched at
|
|
/// (`CompiledSectionMesh.compileTaskStartTimeNs`).
|
|
compile_started: FxHashMap<(i32, i32, i32), u64>,
|
|
/// Chunks whose light is current.
|
|
lit: FxHashSet<(i32, i32)>,
|
|
/// Columns whose sections have been meshed at least once.
|
|
meshed: FxHashSet<(i32, i32)>,
|
|
/// Dirty sections of loaded columns past the render distance, meshed once
|
|
/// the player comes closer, and the area they were set aside for.
|
|
waiting: FxHashSet<(i32, i32, i32)>,
|
|
waiting_area: ((i32, i32), i32, usize),
|
|
/// Proto chunks decoration wrote to since they were generated or saved.
|
|
written: FxHashSet<(i32, i32)>,
|
|
saves: Arc<dyn Saves>,
|
|
/// Results received while `save` waited, for the next `update`.
|
|
deferred: Vec<Done>,
|
|
sequence: u64,
|
|
pub center: (i32, i32),
|
|
/// The view distance around `center`; below 0, no area there (a dedicated
|
|
/// server nobody is on).
|
|
pub radius: i32,
|
|
/// `/forceload`ed chunks: kept loaded whatever the distance.
|
|
pub forced: HashSet<(i32, i32)>,
|
|
/// Chunks a native mod's tickets hold (NeoForge's `TicketController`):
|
|
/// kept loaded and ticking as forced ones are, but neither saved with
|
|
/// them nor listed by `/forceload`.
|
|
pub mod_forced: HashSet<(i32, i32)>,
|
|
/// No work is scheduled while paused (title screen).
|
|
pub paused: bool,
|
|
/// Dimension flags: sky light, Nether face shading.
|
|
pub sky_light: bool,
|
|
pub nether_shading: bool,
|
|
/// A shader pack lights the faces: sections mesh without face shading
|
|
/// (`Mesher::shader_pack_lighting`).
|
|
pub shader_pack_lighting: bool,
|
|
/// OptiFine/Iris separate ambient occlusion: vertex RGB has pure tint and alpha holds AO factor
|
|
/// (`Mesher::separate_ao`).
|
|
pub separate_ao: bool,
|
|
/// A `shaders/` pack draws the terrain: fluid tops split as Iris splits them
|
|
/// (`Mesher::shader_pack`).
|
|
pub shader_pack: bool,
|
|
/// `BiomeManager`'s zoom seed (the hashed world seed) and `biomeBlendRadius`.
|
|
pub biome_zoom: BiomeZoom,
|
|
pub biome_blend: i32,
|
|
/// The `ao` and `cutoutLeaves` options.
|
|
pub smooth_lighting: bool,
|
|
pub cutout_leaves: bool,
|
|
/// Sprite layers a shader pack draws in the translucent pass (`[[materials]]
|
|
/// translucent`), by layer; empty without one.
|
|
pub translucent_layers: Arc<[bool]>,
|
|
/// What the mesher does with leaf faces inside a canopy (`LeafCullingMode`).
|
|
pub leaf_culling: LeafCullingMode,
|
|
/// Incremental relighting of block edits on lit chunks (see `light::relight`).
|
|
light_tables: Arc<LightTables>,
|
|
/// Applied edits by sequence, replayed onto light jobs snapshotted before them.
|
|
light_log: Vec<(u64, (i32, i32, i32))>,
|
|
light_edit_sequence: u64,
|
|
/// Edit sequence at each in-flight light job's snapshot.
|
|
light_snapshots: FxHashMap<(i32, i32), u64>,
|
|
pub generated_total: u64,
|
|
pub meshed_total: u64,
|
|
/// Chunks come from a server (`insert_remote`/`forget`): nothing is
|
|
/// generated and columns are only unloaded when the server says so.
|
|
pub remote: bool,
|
|
/// This server sends bulk block edits as whole columns whose light is
|
|
/// from before its relight (a Horizon server): a resent resident column
|
|
/// is lit here. Any other server's packet carries the light vanilla
|
|
/// applies, so it is not lit again (a proxy's next server resends its
|
|
/// whole world at the same coordinates).
|
|
pub relight_resent: bool,
|
|
/// More areas to keep loaded (centre chunk, radius), e.g. around the
|
|
/// players of a server. Each is a square, like vanilla's ticket levels:
|
|
/// chunks finish out to `radius + 1` (Chebyshev) and generation reaches
|
|
/// past that as around the player. They are generated and kept but not meshed.
|
|
pub extra: Vec<((i32, i32), i32)>,
|
|
/// `extra`'s squares as a grid, made again when `extra` changes.
|
|
cover: Option<ExtraCover>,
|
|
/// Mesh the sections for drawing (false on a server thread).
|
|
pub mesh: bool,
|
|
/// The pregeneration band (`set_band`).
|
|
band: Option<Band>,
|
|
/// The columns and the next row the band's decoration ranks were given
|
|
/// for (`set_band`).
|
|
band_ranked: Option<(i32, i32, i32)>,
|
|
}
|
|
/// Snapshot only after a scan found no missing candidates. Public range fields
|
|
/// and `World::chunks` can change without going through Streaming, so compare
|
|
/// their values and structural membership token rather than a frame timeout.
|
|
///
|
|
/// A scan also keeps every candidate it found, nearest first (`plan`): while
|
|
/// the ranges stay the same and no column leaves (`World::chunks` removals, or
|
|
/// `invalidate` for proto chunks dropped), no column becomes wanted, so the
|
|
/// next batches are the plan's entries still missing. A full scan looks at
|
|
/// every column of the areas (thousands at render distance 32), and one ran
|
|
/// whenever a worker finished: it took a quarter of the thread that runs
|
|
/// `Streaming::update` while a world loaded.
|
|
#[derive(Default)]
|
|
struct GenerationScan {
|
|
exhausted: bool,
|
|
center: (i32, i32),
|
|
radius: i32,
|
|
extra: Vec<((i32, i32), i32)>,
|
|
forced: HashSet<(i32, i32)>,
|
|
mod_forced: HashSet<(i32, i32)>,
|
|
band: Option<Band>,
|
|
chunk_revision: u64,
|
|
plan: Vec<(i64, i32, i32)>,
|
|
/// Entries before it are no longer missing.
|
|
next: usize,
|
|
/// What `plan` was made for: the ranges and decoration; the chunk
|
|
/// map's revision is `chunk_revision`.
|
|
plan_basis: Option<bool>,
|
|
}
|
|
impl GenerationScan {
|
|
fn same_ranges(&self, ranges: &Ranges<'_>) -> bool {
|
|
self.center == ranges.center
|
|
&& self.radius == ranges.radius
|
|
&& self.extra == ranges.extra
|
|
&& self.forced == *ranges.forced
|
|
&& self.mod_forced == *ranges.mod_forced
|
|
&& self.band.as_ref() == ranges.band
|
|
}
|
|
fn unchanged(&self, ranges: &Ranges<'_>, world: &World) -> bool {
|
|
self.exhausted
|
|
&& self.same_ranges(ranges)
|
|
&& self.chunk_revision == world.chunks.membership_revision()
|
|
}
|
|
/// A column may be wanted again (a proto chunk was dropped): the next
|
|
/// call scans the areas again.
|
|
fn invalidate(&mut self) {
|
|
self.exhausted = false;
|
|
self.plan_basis = None;
|
|
}
|
|
/// Whether `plan` still holds every candidate: same ranges, and no
|
|
/// column left the chunk map since it was made.
|
|
fn plan_holds(&mut self, input: &GenerationInputs<'_>) -> bool {
|
|
if self.plan_basis != Some(input.decorates) || !self.same_ranges(&input.ranges) {
|
|
return false;
|
|
}
|
|
let revision = input.world.chunks.membership_revision();
|
|
if revision != self.chunk_revision {
|
|
let Some(mut changes) = input.world.chunks.deltas_since(self.chunk_revision) else {
|
|
return false;
|
|
};
|
|
if !changes.all(|(_, change)| matches!(change, ChunkMembershipDelta::Added(_))) {
|
|
return false;
|
|
}
|
|
self.chunk_revision = revision;
|
|
}
|
|
true
|
|
}
|
|
fn remember(&mut self, ranges: Ranges<'_>, world: &World) {
|
|
self.exhausted = true;
|
|
self.center = ranges.center;
|
|
self.radius = ranges.radius;
|
|
self.extra.clear();
|
|
self.extra.extend_from_slice(ranges.extra);
|
|
self.forced.clone_from(ranges.forced);
|
|
self.mod_forced.clone_from(ranges.mod_forced);
|
|
self.band = ranges.band.copied();
|
|
self.chunk_revision = world.chunks.membership_revision();
|
|
}
|
|
}
|
|
struct GenerationInputs<'a> {
|
|
ranges: Ranges<'a>,
|
|
world: &'a World,
|
|
generating: &'a FxHashSet<(i32, i32)>,
|
|
protos: &'a FxHashMap<(i32, i32), Option<Proto>>,
|
|
finishing: &'a FxHashSet<(i32, i32)>,
|
|
decorates: bool,
|
|
remote: bool,
|
|
}
|
|
impl GenerationScan {
|
|
/// None means the scan was skipped; Some preserves the original candidate
|
|
/// ordering and bounded batch, including an empty exhausted scan.
|
|
fn candidates(
|
|
&mut self,
|
|
input: GenerationInputs<'_>,
|
|
capacity: usize,
|
|
) -> Option<Vec<(i64, i32, i32)>> {
|
|
// Generation (load or proto), closest first, bounded in flight.
|
|
let (margin, reach) = Ranges::margins(input.decorates);
|
|
if input.remote
|
|
|| input.generating.len() >= capacity
|
|
|| self.unchanged(&input.ranges, input.world)
|
|
{
|
|
return None;
|
|
}
|
|
let missing = |&(_, x, z): &(i64, i32, i32)| {
|
|
!input.world.chunks.contains_key(&(x, z))
|
|
&& !input.generating.contains(&(x, z))
|
|
&& !input.protos.contains_key(&(x, z))
|
|
&& !input.finishing.contains(&(x, z))
|
|
};
|
|
if self.plan_holds(&input) {
|
|
while self.next < self.plan.len() && !missing(&self.plan[self.next]) {
|
|
self.next += 1;
|
|
}
|
|
let room = capacity - input.generating.len();
|
|
let batch: Vec<_> = self.plan[self.next..]
|
|
.iter()
|
|
.filter(|c| missing(c))
|
|
.take(room)
|
|
.copied()
|
|
.collect();
|
|
// Past the plan's end, the full scan below decides whether
|
|
// anything is left (and remembers when nothing is).
|
|
if !batch.is_empty() {
|
|
self.exhausted = false;
|
|
return Some(batch);
|
|
}
|
|
}
|
|
{
|
|
let mut wanted = Vec::new();
|
|
// Each area's square of columns, as one run per row. Players'
|
|
// areas overlap (a server's players often stand together), so
|
|
// the runs of a row are merged and each column is looked at once.
|
|
let mut runs: Vec<(i32, i32, i32)> = Vec::new();
|
|
for (center, radius) in std::iter::once((input.ranges.center, input.ranges.radius))
|
|
.chain(input.ranges.extra.iter().copied())
|
|
{
|
|
let r = radius + margin.max(reach + 1);
|
|
runs.extend((center.1 - r..=center.1 + r).map(|z| (z, center.0 - r, center.0 + r)));
|
|
}
|
|
if let Some(band) = input.ranges.band {
|
|
let r = margin.max(reach + 1);
|
|
runs.extend((band.z0 - r..=band.z1 + r).map(|z| (z, band.x0 - r, band.x1 + r)));
|
|
}
|
|
runs.sort_unstable();
|
|
let mut runs = runs.into_iter().peekable();
|
|
while let Some((z, x0, mut x1)) = runs.next() {
|
|
while let Some((_, _, next_x1)) =
|
|
runs.next_if(|&(nz, nx0, _)| nz == z && nx0 <= x1.saturating_add(1))
|
|
{
|
|
x1 = x1.max(next_x1);
|
|
}
|
|
for x in x0..=x1 {
|
|
if !input.ranges.requested(x, z, input.decorates)
|
|
|| input.world.chunks.contains_key(&(x, z))
|
|
|| input.generating.contains(&(x, z))
|
|
|| input.protos.contains_key(&(x, z))
|
|
|| input.finishing.contains(&(x, z))
|
|
{
|
|
continue;
|
|
}
|
|
wanted.push((input.ranges.priority(x, z), x, z));
|
|
}
|
|
}
|
|
// Forced chunks and their generation neighbourhood: the ring that
|
|
// is decorated (or finished, without decoration) and the one its
|
|
// 3x3 needs.
|
|
let reach = if input.decorates {
|
|
FORCED_TERRAIN_RING
|
|
} else {
|
|
FORCED_FEATURES_RING
|
|
};
|
|
for &(fx, fz) in input.ranges.forced_chunks() {
|
|
for dz in -reach..=reach {
|
|
for dx in -reach..=reach {
|
|
let (x, z) = (fx + dx, fz + dz);
|
|
if input.world.chunks.contains_key(&(x, z))
|
|
|| input.generating.contains(&(x, z))
|
|
|| input.protos.contains_key(&(x, z))
|
|
|| input.finishing.contains(&(x, z))
|
|
{
|
|
continue;
|
|
}
|
|
wanted.push((input.ranges.priority(x, z), x, z));
|
|
}
|
|
}
|
|
}
|
|
let ranges = Ranges {
|
|
center: input.ranges.center,
|
|
radius: input.ranges.radius,
|
|
extra: input.ranges.extra,
|
|
forced: input.ranges.forced,
|
|
mod_forced: input.ranges.mod_forced,
|
|
cover: input.ranges.cover,
|
|
band: input.ranges.band,
|
|
};
|
|
self.remember(ranges, input.world);
|
|
if wanted.is_empty() {
|
|
self.plan.clear();
|
|
self.plan_basis = None;
|
|
} else {
|
|
// A bounded batch may leave candidates behind: the next calls
|
|
// take them from the plan as capacity opens.
|
|
self.exhausted = false;
|
|
self.plan_basis = Some(input.decorates);
|
|
}
|
|
wanted.sort_unstable();
|
|
wanted.dedup();
|
|
self.plan.clear();
|
|
self.plan.extend_from_slice(&wanted);
|
|
self.next = 0;
|
|
wanted.truncate(capacity - input.generating.len());
|
|
Some(wanted)
|
|
}
|
|
}
|
|
}
|
|
/// What a pool worker keeps for one streamer between its jobs: the
|
|
/// generator's worker, and the face visibility table and its occlusion
|
|
/// cache (like vanilla's per-thread cache: cloning it per job copied every
|
|
/// state and face shape) and the mesher's scratch.
|
|
struct WorkerLocal<W> {
|
|
worker: W,
|
|
faces: Option<(Arc<MeshTables>, FaceVisibility)>,
|
|
mesh_scratch: MeshScratch,
|
|
}
|
|
/// A streamer's jobs, run by the process's shared pool (`horizon_threads::jobs`).
|
|
struct Jobs<G: ChunkGenerator> {
|
|
shared: Arc<Shared>,
|
|
generator: Arc<G>,
|
|
light: Arc<LightTables>,
|
|
locals: PerWorker<WorkerLocal<G::Worker>>,
|
|
}
|
|
impl<G: ChunkGenerator> Source for Jobs<G> {
|
|
fn next(&self) -> Option<Rank> {
|
|
let queue = self.shared.queue.lock().unwrap();
|
|
if self
|
|
.shared
|
|
.aborted
|
|
.load(std::sync::atomic::Ordering::Relaxed)
|
|
{
|
|
for q in queue.iter() {
|
|
if matches!(q.job, Job::Save(..)) {
|
|
return Some(Rank::new(Class::Urgent, q.priority));
|
|
}
|
|
}
|
|
return None;
|
|
}
|
|
queue.peek().map(|q| {
|
|
// Jobs without a column (saves, meshes to free) go first.
|
|
let class = if q.at.is_none() {
|
|
Class::Urgent
|
|
} else {
|
|
Class::Near
|
|
};
|
|
Rank::new(class, q.priority)
|
|
})
|
|
}
|
|
fn run(&self, worker: usize) -> bool {
|
|
let Some(job) = self.shared.queue.lock().unwrap().pop() else {
|
|
return false;
|
|
};
|
|
if self
|
|
.shared
|
|
.aborted
|
|
.load(std::sync::atomic::Ordering::Relaxed)
|
|
&& !matches!(job.job, Job::Save(..))
|
|
{
|
|
return true;
|
|
}
|
|
let started = job_stats::enabled().then(|| {
|
|
let kind = match &job.job {
|
|
Job::Generate(..) => 1,
|
|
Job::Decorate(..) => 2,
|
|
Job::Finish(..) => 3,
|
|
Job::Light(..) => 4,
|
|
Job::Mesh(..) => 5,
|
|
_ => 6,
|
|
};
|
|
// The other jobs (saves, light views, freed meshes) are many and short: counted, not
|
|
// timed, so the clock reads don't cost more than they do.
|
|
let cpu = if kind == 6 {
|
|
std::time::Duration::ZERO
|
|
} else {
|
|
crate::spans::thread_cpu().unwrap_or_default()
|
|
};
|
|
(kind, cpu)
|
|
});
|
|
let _tag = horizon_threads::heap_tag(match &job.job {
|
|
Job::Generate(..) => "stream.generate",
|
|
Job::Decorate(..) => "stream.decorate",
|
|
Job::Finish(..) => "stream.finish",
|
|
Job::Light(..) => "stream.light",
|
|
Job::Mesh(..) => "stream.mesh",
|
|
_ => "stream.other",
|
|
});
|
|
let done = self.locals.with(
|
|
worker,
|
|
|| {
|
|
let _tag = horizon_threads::heap_tag("stream.worker_state");
|
|
WorkerLocal {
|
|
worker: self.generator.worker(),
|
|
faces: None,
|
|
mesh_scratch: MeshScratch::default(),
|
|
}
|
|
},
|
|
|local| self.run_job(job.job, local),
|
|
);
|
|
if let Some((kind, started)) = started {
|
|
// A generation job that found the column saved loaded it.
|
|
let kind = if kind == 1 && matches!(done, Some(Done::Generated(_))) {
|
|
0
|
|
} else {
|
|
kind
|
|
};
|
|
let nanos = if kind == 6 {
|
|
0
|
|
} else {
|
|
let cpu = crate::spans::thread_cpu().unwrap_or_default();
|
|
cpu.saturating_sub(started).as_nanos() as u64
|
|
};
|
|
job_stats::add(kind, nanos);
|
|
}
|
|
if let Some(done) = done {
|
|
self.shared.done.send(done);
|
|
}
|
|
true
|
|
}
|
|
fn idle(&self, worker: usize) {
|
|
// A server nobody is on, a client standing still: free the
|
|
// generator's buffers (and the mesher's face cache and scratch)
|
|
// until the next job needs them.
|
|
self.locals.with_existing(worker, |local| {
|
|
self.generator.idle(&mut local.worker);
|
|
local.faces = None;
|
|
local.mesh_scratch = MeshScratch::default();
|
|
});
|
|
}
|
|
fn all_idle(&self) {
|
|
// No worker needs retained mesh capacity now. Drop it outside the
|
|
// pool lock; the pool then asks the allocator to return freed pages.
|
|
let meshes = std::mem::take(&mut *self.shared.meshes.lock().unwrap());
|
|
drop(meshes);
|
|
self.generator.all_idle();
|
|
}
|
|
}
|
|
impl<G: ChunkGenerator> Jobs<G> {
|
|
fn run_job(&self, job: Job, local: &mut WorkerLocal<G::Worker>) -> Option<Done> {
|
|
let (generator, shared, light) = (&self.generator, &self.shared, &self.light);
|
|
let WorkerLocal {
|
|
worker,
|
|
faces,
|
|
mesh_scratch,
|
|
} = local;
|
|
let done = match job {
|
|
Job::Generate(x, z) => match generator.load(x, z) {
|
|
Some(chunk) => {
|
|
generator.prepare_loaded(&mut *worker, &chunk);
|
|
Done::Generated(chunk)
|
|
}
|
|
None => {
|
|
let proto = generator.proto(&mut *worker, x, z);
|
|
let decorated = generator.is_decorated(&proto);
|
|
Done::Proto(x, z, proto, decorated)
|
|
}
|
|
},
|
|
Job::Decorate(x, z, mut region, finals) => {
|
|
// The final chunks' proto chunks, made here: a
|
|
// generated proto chunk holding their blocks.
|
|
for (i, snapshot) in &finals {
|
|
if let Some(proto) = generator.proto_beside_final(&mut *worker, snapshot) {
|
|
region[*i] = proto;
|
|
}
|
|
}
|
|
generator.decorate(&mut *worker, &mut region);
|
|
let mut writes = FinalWrites::default();
|
|
for (i, snapshot) in &finals {
|
|
let blocks = generator.final_writes(snapshot, ®ion[*i]);
|
|
writes
|
|
.block_entities
|
|
.extend(generator.final_block_entities(®ion[*i], &blocks));
|
|
writes.blocks.extend(blocks);
|
|
region[*i] = Box::new(());
|
|
}
|
|
let finals = finals.into_iter().map(|(i, _)| i).collect();
|
|
Done::Decorated(x, z, region, finals, writes)
|
|
}
|
|
Job::Finish(proto) => {
|
|
let mut chunk = generator.finish(proto);
|
|
chunk.generated = true;
|
|
Done::Generated(chunk)
|
|
}
|
|
Job::Save(protos) => {
|
|
let refs: Vec<&Proto> = protos.iter().map(|(_, p)| p).collect();
|
|
generator.save_protos(&refs);
|
|
Done::Saved(protos)
|
|
}
|
|
Job::LightView(x, z, proto) => {
|
|
let view = generator.light_view(&proto);
|
|
Done::LightView(x, z, proto, view)
|
|
}
|
|
Job::Free(meshes) => {
|
|
for (_, mesh) in meshes {
|
|
let rejected = shared.meshes.lock().unwrap().put(mesh);
|
|
drop(rejected);
|
|
}
|
|
return None;
|
|
}
|
|
Job::Light(x, z, region, seq, sky) => {
|
|
Done::Lit(x, z, light_chunk(&borrow(®ion), light, sky), seq)
|
|
}
|
|
Job::Mesh(x, sy, z, region, seq, settings) => {
|
|
let context = shared.context.read().unwrap().clone();
|
|
let visibility = match faces.take() {
|
|
Some((tables, visibility)) if Arc::ptr_eq(&tables, &context.tables) => {
|
|
visibility
|
|
}
|
|
_ => context.tables.visibility.clone(),
|
|
};
|
|
let mut mesher = Mesher {
|
|
nether_shading: settings.nether,
|
|
shader_pack_lighting: settings.shader_pack_lighting,
|
|
separate_ao: settings.separate_ao,
|
|
shader_pack: settings.shader_pack,
|
|
zoom: settings.zoom,
|
|
blend_radius: settings.blend_radius,
|
|
smooth_lighting: settings.smooth_lighting,
|
|
cutout_leaves: settings.cutout_leaves,
|
|
translucent_layers: &settings.translucent_layers,
|
|
leaf_culling: settings.leaf_culling,
|
|
assets: &context.assets,
|
|
render: &context.render,
|
|
tables: &context.tables,
|
|
biomes: &context.biomes,
|
|
visibility,
|
|
};
|
|
let recycled = shared.meshes.lock().unwrap().take();
|
|
let mut mesh = mesher.mesh_reusing(&borrow(®ion), sy, recycled, mesh_scratch);
|
|
mesh.sort_solid();
|
|
*faces = Some((context.tables.clone(), mesher.visibility));
|
|
Done::Meshed(x, sy, z, mesh, seq)
|
|
}
|
|
};
|
|
Some(done)
|
|
}
|
|
}
|
|
pub struct WorkerContext {
|
|
pub assets: Arc<crate::assets::Assets>,
|
|
pub render: Arc<crate::assets::RenderAssets>,
|
|
pub tables: Arc<MeshTables>,
|
|
pub biomes: Arc<crate::biome::Biomes>,
|
|
pub light: Arc<LightTables>,
|
|
}
|
|
impl Streaming {
|
|
pub fn new<G: ChunkGenerator>(
|
|
generator: Arc<G>,
|
|
context: Arc<WorkerContext>,
|
|
threads: usize,
|
|
) -> Self {
|
|
let shared = Arc::new(Shared {
|
|
queue: Mutex::new(BinaryHeap::new()),
|
|
context: RwLock::new(context.clone()),
|
|
done: Mailbox::default(),
|
|
meshes: Mutex::new(MeshPool::default()),
|
|
aborted: std::sync::atomic::AtomicBool::new(false),
|
|
});
|
|
let light_tables = context.light.clone();
|
|
let saves: Arc<dyn Saves> = generator.clone();
|
|
let pool = horizon_threads::jobs::pool();
|
|
let source = Arc::new(Jobs {
|
|
shared: shared.clone(),
|
|
generator: generator.clone(),
|
|
light: context.light.clone(),
|
|
locals: horizon_threads::jobs::PerWorker::new(pool.threads()),
|
|
});
|
|
let jobs = pool.register(source);
|
|
// The work kept in flight is sized for the workers this streamer may
|
|
// use: the pool's, or fewer when asked (tests, a placeholder).
|
|
let workers = threads.clamp(1, pool.threads());
|
|
let non_air_sections = {
|
|
let generator = generator.clone();
|
|
Arc::new(move |p: &Proto| generator.non_air_sections(p))
|
|
};
|
|
let proto_bytes = {
|
|
let generator = generator.clone();
|
|
Arc::new(move |p: &Proto| generator.proto_heap_bytes(p))
|
|
};
|
|
let caches = {
|
|
let generator = generator.clone();
|
|
Arc::new(move || generator.cache_census())
|
|
};
|
|
Self {
|
|
shared,
|
|
non_air_sections,
|
|
proto_bytes,
|
|
caches,
|
|
jobs,
|
|
workers,
|
|
order: 0,
|
|
scan_age: 0,
|
|
scan_area: ((i32::MIN, i32::MIN), i32::MIN),
|
|
generating: Default::default(),
|
|
generation_scan: GenerationScan::default(),
|
|
protos: Default::default(),
|
|
light_views: Default::default(),
|
|
decorated: Default::default(),
|
|
dropped_new: Default::default(),
|
|
decorating: 0,
|
|
decorating_finals: HashSet::new(),
|
|
saving: 0,
|
|
viewing: Default::default(),
|
|
decoration_order: DecorationOrder::default(),
|
|
order_area: None,
|
|
queue_basis: Default::default(),
|
|
finishing: Default::default(),
|
|
decorates: generator.decorates(),
|
|
saves_beside_final: generator.decorates_beside_final(),
|
|
lighting: Default::default(),
|
|
meshing: Default::default(),
|
|
compile_started: Default::default(),
|
|
lit: Default::default(),
|
|
meshed: Default::default(),
|
|
waiting: Default::default(),
|
|
waiting_area: ((0, 0), 0, 0),
|
|
written: Default::default(),
|
|
saves,
|
|
deferred: Vec::new(),
|
|
sequence: 0,
|
|
center: (0, 0),
|
|
radius: 8,
|
|
forced: HashSet::new(),
|
|
mod_forced: HashSet::new(),
|
|
paused: false,
|
|
sky_light: true,
|
|
nether_shading: false,
|
|
shader_pack_lighting: false,
|
|
separate_ao: false,
|
|
shader_pack: false,
|
|
biome_zoom: BiomeZoom::new(0),
|
|
biome_blend: 2,
|
|
smooth_lighting: true,
|
|
cutout_leaves: true,
|
|
translucent_layers: Arc::from([]),
|
|
leaf_culling: LeafCullingMode::Off,
|
|
light_tables,
|
|
light_log: Vec::new(),
|
|
light_edit_sequence: 0,
|
|
light_snapshots: Default::default(),
|
|
generated_total: 0,
|
|
meshed_total: 0,
|
|
remote: false,
|
|
relight_resent: false,
|
|
extra: Vec::new(),
|
|
cover: None,
|
|
mesh: true,
|
|
band: None,
|
|
band_ranked: None,
|
|
}
|
|
}
|
|
/// Shares generation resources, but gives distant work its own worker state.
|
|
pub fn lod_source(&self) -> Arc<dyn LodSource> {
|
|
self.saves.clone()
|
|
}
|
|
/// Resource packs reloaded (`LevelExtractor.allChanged`): mesh with the new
|
|
/// models and sprites, and rebuild every loaded section. Meshes in flight
|
|
/// were made with the old resources; their results are dropped.
|
|
pub fn reload_resources(&mut self, world: &mut World, context: Arc<WorkerContext>) {
|
|
*self.shared.context.write().unwrap() = context;
|
|
self.reload_sections(world);
|
|
}
|
|
/// `LevelExtractor.allChanged`: discard mesh work made before the reload and
|
|
/// rebuild every loaded, lit section.
|
|
pub fn reload_sections(&mut self, world: &mut World) {
|
|
self.meshing.clear();
|
|
self.compile_started.clear();
|
|
Self::mark_loaded_sections_dirty(world, &self.lit);
|
|
}
|
|
/// The resident sections `LevelExtractor.allChanged` queues again. An
|
|
/// unlit column will queue itself when its light job completes.
|
|
fn mark_loaded_sections_dirty(world: &mut World, lit: &FxHashSet<(i32, i32)>) {
|
|
for &(x, z) in world.chunks.keys() {
|
|
if !lit.contains(&(x, z)) {
|
|
continue;
|
|
}
|
|
for sy in 0..world.layout().sections() as i32 {
|
|
world.dirty_sections.insert((x, sy, z));
|
|
}
|
|
}
|
|
}
|
|
/// A column received from a server, with the light it sent
|
|
/// (`ClientPacketListener.handleLevelChunkWithLight`): nothing is
|
|
/// relit, and it and its neighbours remesh (`enableChunkLight`).
|
|
pub fn insert_remote(&mut self, world: &mut World, chunk: Chunk) {
|
|
let key = (chunk.x, chunk.z);
|
|
self.lighting.remove(&key);
|
|
self.light_snapshots.remove(&key);
|
|
world.chunks.insert(key, Arc::new(chunk));
|
|
self.lit.insert(key);
|
|
self.generated_total += 1;
|
|
for dz in -1..=1 {
|
|
for dx in -1..=1 {
|
|
for sy in 0..world.layout().sections() as i32 {
|
|
world.dirty_sections.insert((key.0 + dx, sy, key.1 + dz));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
/// A resident column a server sent again whole (it changed it in bulk):
|
|
/// its new blocks taken, the sections that did not change kept as they
|
|
/// were, and (for a Horizon server's bulk edit) its light and its
|
|
/// neighbours' worked out again here, off the game thread, as light is for
|
|
/// the blocks a server changes one at a time; the sections that changed
|
|
/// remesh (with theirs) once lit. Until then the sections that changed
|
|
/// show the light the packet carried (a Horizon server sends the light
|
|
/// from before its relight, then the new light; another server's is already
|
|
/// right, which matters where the neighbours this light needs aren't all
|
|
/// here).
|
|
pub fn replace_remote(&mut self, world: &mut World, chunk: Chunk) {
|
|
let key = (chunk.x, chunk.z);
|
|
let Some(old) = world.chunks.get(&key).cloned() else {
|
|
return self.insert_remote(world, chunk);
|
|
};
|
|
if !self.lit.contains(&key) {
|
|
return self.insert_remote(world, chunk);
|
|
}
|
|
take_resent_column(world, key, &old, chunk, self.relight_resent);
|
|
}
|
|
/// A server's light update for a resident column: the sections whose
|
|
/// light changed and their neighbours remesh
|
|
/// (`setSectionDirtyWithNeighbors`). A resent column's sections with new
|
|
/// biomes remesh the same way.
|
|
pub fn light_updated(world: &mut World, x: i32, z: i32, sections: &[usize]) {
|
|
for &sy in sections {
|
|
for dz in -1..=1 {
|
|
for dy in -1..=1 {
|
|
for dx in -1..=1 {
|
|
let s = sy as i32 + dy;
|
|
if (0..world.layout().sections() as i32).contains(&s) {
|
|
world.dirty_sections.insert((x + dx, s, z + dz));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
/// Give back meshes the renderer has copied: a worker frees them, ahead of
|
|
/// other jobs, as vanilla's compile task closes its mesh data on its own
|
|
/// thread once copied (`SectionRenderDispatcher.RenderSection.CompileTask.doTask`).
|
|
/// Freed in the frame, the hundreds of meshes a chunk border brings waited
|
|
/// on the allocator behind every busy worker: up to 20 ms of a frame each
|
|
/// time while flying at render distance 32.
|
|
pub fn free(&mut self, meshes: Vec<((i32, i32, i32), SectionMesh)>) {
|
|
if !meshes.is_empty() {
|
|
self.submit(None, 0, Job::Free(meshes));
|
|
}
|
|
}
|
|
/// The columns the server unloaded (ClientboundForgetLevelChunkPacket),
|
|
/// often thousands at once (a teleport or a moved view): their pending
|
|
/// sections go through [`forget_pending_sections`]. Returns the chunks
|
|
/// they held.
|
|
pub fn forget_columns(
|
|
&mut self,
|
|
world: &mut World,
|
|
gone: &FxHashSet<(i32, i32)>,
|
|
) -> Vec<Arc<Chunk>> {
|
|
// Every column held (a far teleport): no pending section is left to keep, and a
|
|
// pass over them all (80,000 at render distance 32) took 1.7 ms of the frame.
|
|
if world.chunks.keys().all(|column| gone.contains(column)) {
|
|
self.meshing.clear();
|
|
self.compile_started.clear();
|
|
self.waiting.clear();
|
|
} else {
|
|
forget_pending_sections(
|
|
&mut self.meshing,
|
|
&mut self.compile_started,
|
|
&mut self.waiting,
|
|
gone,
|
|
world.layout().sections() as i32,
|
|
);
|
|
}
|
|
// `ChunkTaskPriorityQueue.release` for each of them (`ChunkTaskDispatcher`'s
|
|
// `release(col, true)` from the chunk's unload): the jobs still queued for a
|
|
// column go with it instead of running for a level they no longer belong to.
|
|
if !gone.is_empty()
|
|
&& let Ok(mut queue) = self.shared.queue.lock()
|
|
{
|
|
release_columns(&mut queue, gone);
|
|
}
|
|
let mut chunks = Vec::with_capacity(gone.len());
|
|
for &(x, z) in gone {
|
|
self.lit.remove(&(x, z));
|
|
self.meshed.remove(&(x, z));
|
|
self.lighting.remove(&(x, z));
|
|
world.dirty_sections.take_column(x, z);
|
|
chunks.extend(world.chunks.remove(&(x, z)));
|
|
}
|
|
chunks
|
|
}
|
|
/// Queue a job for the column `at`, nearest first: its distance (`distance`)
|
|
/// times four plus `bias`, which orders a column's jobs. A job without a
|
|
/// column (a save, meshes to free) goes ahead of every other.
|
|
fn submit(&mut self, at: Option<(i32, i32)>, bias: i64, job: Job) {
|
|
self.order += 1;
|
|
let priority = at.map_or(i64::MIN, |(x, z)| self.distance(x, z) * 4 + bias);
|
|
self.shared.queue.lock().unwrap().push(Queued {
|
|
priority,
|
|
at,
|
|
bias,
|
|
order: self.order,
|
|
job,
|
|
});
|
|
self.jobs.notify();
|
|
}
|
|
/// Rank the queued jobs again when what their priorities were computed
|
|
/// from changed: the centre, the extra areas, the band, or the loading
|
|
/// screen (ring order, `Ranges::rings`, whose values are far above the
|
|
/// distances used after it). Vanilla's chunk task queue follows the
|
|
/// current ticket levels in the same way. Without it, generation queued
|
|
/// while the loading screen was up waited behind every job queued after
|
|
/// it, and with it the decoration of the chunks around the player: at
|
|
/// render distance 32 nothing was decorated until every proto chunk had
|
|
/// been generated.
|
|
fn requeue(&mut self) {
|
|
let basis = (self.center, self.band);
|
|
if self.queue_basis.0 == basis && self.queue_basis.1 == self.extra {
|
|
return;
|
|
}
|
|
self.queue_basis = (basis, self.extra.clone());
|
|
let mut queue = self.shared.queue.lock().unwrap();
|
|
rank_again(&mut queue, |x, z| self.distance(x, z));
|
|
}
|
|
fn receive_decorated(
|
|
&mut self,
|
|
x: i32,
|
|
z: i32,
|
|
region: [Proto; 9],
|
|
finals: Vec<usize>,
|
|
writes: FinalWrites,
|
|
world: &mut World,
|
|
) {
|
|
self.generation_scan.exhausted = false;
|
|
self.decorating -= 1;
|
|
for i in &finals {
|
|
self.decorating_finals
|
|
.remove(&(x + (i % 3) as i32 - 1, z + (i / 3) as i32 - 1));
|
|
}
|
|
self.decorated.insert((x, z));
|
|
for (i, proto) in region.into_iter().enumerate() {
|
|
// A final chunk keeps its own chunk: only what decoration wrote
|
|
// into it goes there.
|
|
if finals.contains(&i) {
|
|
continue;
|
|
}
|
|
let key = (x + (i % 3) as i32 - 1, z + (i / 3) as i32 - 1);
|
|
self.light_views.remove(&key);
|
|
self.protos.insert(key, Some(proto));
|
|
self.written.insert(key);
|
|
}
|
|
for ([bx, by, bz], state) in writes.blocks {
|
|
let _ = world.set(bx, by, bz, horizon_world::registry::BlockStateId(state));
|
|
}
|
|
// After the blocks: the write of a block with a block entity made a
|
|
// default one, which the generated one replaces.
|
|
for (pos, entity) in writes.block_entities {
|
|
if world.chunks.contains_key(&(pos.0 >> 4, pos.2 >> 4)) {
|
|
world.block_entities.insert(pos, entity);
|
|
}
|
|
}
|
|
}
|
|
/// Saved proto chunks come back; those now out of range are dropped by
|
|
/// the next `update`.
|
|
fn receive_saved(&mut self, protos: Vec<((i32, i32), Proto)>) {
|
|
self.generation_scan.exhausted = false;
|
|
self.saving -= 1;
|
|
for (key, proto) in protos {
|
|
self.light_views.remove(&key);
|
|
self.protos.insert(key, Some(proto));
|
|
}
|
|
}
|
|
/// Check out the proto chunks at `keys` and save them on a worker.
|
|
fn save_checked_out(&mut self, keys: impl IntoIterator<Item = (i32, i32)>) {
|
|
let protos: Vec<((i32, i32), Proto)> = keys
|
|
.into_iter()
|
|
.filter_map(|k| {
|
|
let proto = self.protos.get_mut(&k)?.take()?;
|
|
self.light_views.remove(&k);
|
|
Some((k, proto))
|
|
})
|
|
.collect();
|
|
if !protos.is_empty() {
|
|
self.saving += 1;
|
|
// Ahead of every other job: a column generated again meanwhile
|
|
// would not find them saved.
|
|
self.submit(None, 0, Job::Save(protos));
|
|
}
|
|
}
|
|
fn receive_generated(&mut self, mut chunk: Chunk, world: &mut World) {
|
|
self.generation_scan.exhausted = false;
|
|
let key = (chunk.x, chunk.z);
|
|
self.generating.remove(&key);
|
|
self.finishing.remove(&key);
|
|
self.decorated.insert(key);
|
|
// A column that finished after the player moved away is dropped
|
|
// whole: its block entities and saved entities too, or they would
|
|
// stay in the world (and the entities spawn again, duplicated, when
|
|
// the column loads later). A generated one is saved first: its
|
|
// neighbours were decorated around it, so generating it again would
|
|
// not give it back.
|
|
if !self.ranges().chunk_kept(key.0, key.1, self.decorates) {
|
|
if chunk.fresh {
|
|
self.dropped_new.insert(key);
|
|
self.saves.save_finished(chunk);
|
|
}
|
|
return;
|
|
}
|
|
// Generated columns are saved like edited ones, as vanilla saves
|
|
// every chunk it generates.
|
|
if chunk.fresh {
|
|
world.modified.insert(key);
|
|
}
|
|
world
|
|
.block_entities
|
|
.load_chunk(std::mem::take(&mut chunk.block_entities));
|
|
if let Some(extras) = &chunk.saved {
|
|
for (pos, attachments) in &extras.block_entity_attachments {
|
|
world
|
|
.block_entities
|
|
.restore_attachments(*pos, attachments.clone());
|
|
}
|
|
}
|
|
world
|
|
.block_names
|
|
.extend(std::mem::take(&mut chunk.block_names));
|
|
let entities = std::mem::take(&mut chunk.entities);
|
|
if !entities.is_empty() {
|
|
world.loaded_entities.push(((chunk.x, chunk.z), entities));
|
|
}
|
|
self.generated_total += 1;
|
|
// A chunk saved with its light needs no light job; its unlit
|
|
// neighbours still light themselves from it.
|
|
let loaded = chunk.light_loaded;
|
|
let new = self.dropped_new.remove(&key) || chunk.generated;
|
|
world.loaded_chunks.push((key.0, key.1, new));
|
|
world.chunks.insert(key, Arc::new(chunk));
|
|
if loaded {
|
|
self.lit.insert(key);
|
|
for sy in 0..world.layout().sections() as i32 {
|
|
world.dirty_sections.insert((key.0, sy, key.1));
|
|
}
|
|
}
|
|
for dz in -1..=1 {
|
|
for dx in -1..=1 {
|
|
let n = (key.0 + dx, key.1 + dz);
|
|
if !loaded || world.chunks.get(&n).is_some_and(|c| !c.light_loaded) {
|
|
world.dirty_light.insert(n);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
/// The order jobs around the player run in, lowest first.
|
|
fn distance(&self, x: i32, z: i32) -> i64 {
|
|
self.ranges().priority(x, z)
|
|
}
|
|
/// The 3x3 a light job reads: the finished columns, and for a neighbour
|
|
/// that will not finish (past the area), its decorated proto chunk, as
|
|
/// vanilla lights its last FULL ring against neighbours at FEATURES.
|
|
/// A proto neighbour without its view yet is checked out to a worker,
|
|
/// which builds the view off the server thread (`Job::LightView`); the
|
|
/// region is asked for again once it is back.
|
|
fn light_region(&mut self, world: &World, x: i32, z: i32) -> Option<Box<Region>> {
|
|
let mut region: Region = Default::default();
|
|
let mut complete = true;
|
|
for dz in 0..3 {
|
|
for dx in 0..3 {
|
|
let n = (x + dx as i32 - 1, z + dz as i32 - 1);
|
|
region[dz][dx] = match world.chunks.get(&n) {
|
|
Some(chunk) => Some(chunk.clone()),
|
|
None if !self.in_range(n.0, n.1, 1) && self.decorated.contains(&n) => {
|
|
if let Some(view) = self.light_views.get(&n) {
|
|
Some(view.clone())
|
|
} else {
|
|
let proto = self.protos.get_mut(&n)?.take()?;
|
|
self.viewing.insert(n);
|
|
self.submit(Some((x, z)), 0, Job::LightView(n.0, n.1, proto));
|
|
complete = false;
|
|
None
|
|
}
|
|
}
|
|
None => return None,
|
|
};
|
|
}
|
|
}
|
|
complete.then(|| Box::new(region))
|
|
}
|
|
/// A proto chunk back from its light view: the view waits in
|
|
/// `light_views` for the light jobs that asked for it.
|
|
fn receive_light_view(&mut self, x: i32, z: i32, proto: Proto, view: Option<Chunk>) {
|
|
self.viewing.remove(&(x, z));
|
|
self.protos.insert((x, z), Some(proto));
|
|
if let Some(view) = view {
|
|
self.light_views.insert((x, z), Arc::new(view));
|
|
}
|
|
}
|
|
/// Whether `light_region` would find all nine columns: the same walk
|
|
/// without building the snapshot, so the scheduler can rank the ready
|
|
/// columns before any region is cloned. A proto neighbour whose light
|
|
/// view turns out empty is skipped when its region is built, and asked
|
|
/// for again, as `light_region` returning `None` always was.
|
|
fn light_region_ready(&self, world: &World, x: i32, z: i32) -> bool {
|
|
light_region_ready(
|
|
world,
|
|
|x, z| self.in_range(x, z, 1),
|
|
&self.decorated,
|
|
&self.protos,
|
|
x,
|
|
z,
|
|
)
|
|
}
|
|
fn region(world: &World, x: i32, z: i32) -> Option<Box<Region>> {
|
|
let mut region: Region = Default::default();
|
|
for dz in 0..3 {
|
|
for dx in 0..3 {
|
|
region[dz][dx] = Some(
|
|
world
|
|
.chunks
|
|
.get(&(x + dx as i32 - 1, z + dz as i32 - 1))?
|
|
.clone(),
|
|
);
|
|
}
|
|
}
|
|
Some(Box::new(region))
|
|
}
|
|
pub fn is_lit(&self, x: i32, z: i32) -> bool {
|
|
self.lit.contains(&(x, z))
|
|
}
|
|
/// The non-air sections of a column that is a decorated proto chunk
|
|
/// here (vanilla's INITIALIZE_LIGHT and on), if it is one: a level
|
|
/// chunk's light layers count them (#4294).
|
|
pub fn proto_sections(&self, x: i32, z: i32) -> Option<SectionMask> {
|
|
if !self.decorated.contains(&(x, z)) {
|
|
return None;
|
|
}
|
|
(self.non_air_sections)(self.protos.get(&(x, z))?.as_ref()?)
|
|
}
|
|
/// `ChunkMap.getLatestStatus` for the loading screen's chunk map: the
|
|
/// furthest step this column has reached, `None` before it is asked for.
|
|
pub fn latest_status(
|
|
&self,
|
|
world: &World,
|
|
x: i32,
|
|
z: i32,
|
|
) -> Option<crate::level_loading::Status> {
|
|
use crate::level_loading::Status;
|
|
let key = (x, z);
|
|
Some(if self.lit.contains(&key) {
|
|
Status::Full
|
|
} else if world.chunks.contains_key(&key) {
|
|
Status::InitializeLight
|
|
} else if self.decorated.contains(&key) || self.finishing.contains(&key) {
|
|
Status::Features
|
|
} else if self.protos.contains_key(&key) {
|
|
Status::Terrain
|
|
} else if self.generating.contains(&key) {
|
|
Status::Empty
|
|
} else {
|
|
return None;
|
|
})
|
|
}
|
|
/// The sequence the next mesh job will be dispatched after: a clock for
|
|
/// `compile_start`, whose jobs all snapshot the world as they are
|
|
/// dispatched (`Util.getNanos()` at the start of a compile task).
|
|
pub fn sequence(&self) -> u64 {
|
|
self.sequence
|
|
}
|
|
/// When the section's latest accepted mesh started compiling, on the
|
|
/// `sequence` clock (`CompiledSectionMesh.getCompileTaskStartTime`).
|
|
pub fn compile_start(&self, section: (i32, i32, i32)) -> Option<u64> {
|
|
self.compile_started.get(§ion).copied()
|
|
}
|
|
pub fn is_meshing(&self, section: (i32, i32, i32)) -> bool {
|
|
self.meshing.contains_key(§ion)
|
|
}
|
|
pub fn pending(&self) -> usize {
|
|
self.shared.queue.lock().unwrap().len()
|
|
}
|
|
/// Whether any chunk is queued or on its way from generation to a mesh.
|
|
pub fn busy(&self) -> bool {
|
|
self.chunks_in_flight() || !self.meshing.is_empty() || self.pending() != 0
|
|
}
|
|
/// Whether the near terrain is still coming in: chunks generating,
|
|
/// decorating, finishing or being lit (or their neighbours' light views),
|
|
/// or sections meshing for the first time. Each of those is counted from
|
|
/// when its job is queued, so queued jobs need no separate count.
|
|
/// Remeshing sections already drawn (block, light and fluid updates) is
|
|
/// not loading: it kept small machines' far terrain held back
|
|
/// (`Lod::set_near_loading`) for as long as anything nearby changed.
|
|
pub fn terrain_loading(&self) -> bool {
|
|
self.chunks_in_flight()
|
|
|| self
|
|
.meshing
|
|
.keys()
|
|
.any(|section| !self.compile_started.contains_key(section))
|
|
}
|
|
fn chunks_in_flight(&self) -> bool {
|
|
!(self.generating.is_empty()
|
|
&& self.decorating == 0
|
|
&& self.viewing.is_empty()
|
|
&& self.finishing.is_empty()
|
|
&& self.lighting.is_empty())
|
|
}
|
|
/// The proto chunks held (not checked out): how many, their heap bytes by
|
|
/// part and their sections by bits per block; and the light views of
|
|
/// them with their bytes. For `horizon-ctl memory`.
|
|
pub fn proto_census(&self) -> ProtoCensus {
|
|
let mut census = ProtoCensus::default();
|
|
for proto in self.protos.values().flatten() {
|
|
let (bytes, bits) = (self.proto_bytes)(proto);
|
|
census.protos += 1;
|
|
for (sum, b) in census.bytes.iter_mut().zip(bytes) {
|
|
*sum += b;
|
|
}
|
|
for (sum, b) in census.sections_by_bits.iter_mut().zip(bits) {
|
|
*sum += b;
|
|
}
|
|
}
|
|
census.light_views = self.light_views.len();
|
|
census.light_view_bytes = self.light_views.values().map(|v| v.heap_bytes()).sum();
|
|
census.caches = (self.caches)();
|
|
census
|
|
}
|
|
/// How much work each stage holds: queued jobs, then what is in flight or
|
|
/// waiting at each step from generation to meshing.
|
|
pub fn stages(&self) -> [(&'static str, usize); 7] {
|
|
[
|
|
("queued", self.pending()),
|
|
("generating", self.generating.len()),
|
|
(
|
|
"protos",
|
|
self.protos.values().filter(|p| p.is_some()).count(),
|
|
),
|
|
("decorating", self.decorating),
|
|
("finishing", self.finishing.len()),
|
|
("lighting", self.lighting.len()),
|
|
("meshing", self.meshing.len()),
|
|
]
|
|
}
|
|
/// Advance streaming: collect finished work, then schedule more.
|
|
/// Returns meshes ready for upload and columns that were unloaded.
|
|
pub fn update(
|
|
&mut self,
|
|
world: &mut World,
|
|
) -> (Vec<((i32, i32, i32), SectionMesh)>, Vec<Arc<Chunk>>) {
|
|
let mut uploads = Vec::new();
|
|
if self.paused {
|
|
return (uploads, Vec::new());
|
|
}
|
|
ExtraCover::refresh(&mut self.cover, &self.extra);
|
|
// The sweeps below walk every loaded column and proto chunk. Run them
|
|
// at once when the view moves and every eighth tick otherwise: a
|
|
// column can stay a little past the edge without anyone noticing.
|
|
let area = (self.center, self.radius);
|
|
let sweep = area != self.scan_area || self.scan_age >= 8;
|
|
self.scan_area = area;
|
|
self.scan_age = if sweep { 0 } else { self.scan_age + 1 };
|
|
self.requeue();
|
|
self.relight_edits(world);
|
|
let deferred = std::mem::take(&mut self.deferred);
|
|
let received = self.shared.done.take();
|
|
// Meshes made stale meanwhile, freed with the uploaded ones (`free`).
|
|
let mut stale = Vec::new();
|
|
for done in deferred.into_iter().chain(received) {
|
|
match done {
|
|
Done::Proto(x, z, proto, decorated) => {
|
|
self.generation_scan.exhausted = false;
|
|
self.generating.remove(&(x, z));
|
|
if self.in_range(x, z, 4) || self.near_finished(x, z, 2) {
|
|
self.light_views.remove(&(x, z));
|
|
self.protos.insert((x, z), Some(proto));
|
|
if decorated || !self.decorates {
|
|
self.decorated.insert((x, z));
|
|
}
|
|
}
|
|
}
|
|
Done::Decorated(x, z, region, finals, writes) => {
|
|
self.receive_decorated(x, z, *region, finals, writes, world)
|
|
}
|
|
Done::Generated(chunk) => self.receive_generated(chunk, world),
|
|
Done::Saved(protos) => self.receive_saved(protos),
|
|
Done::LightView(x, z, proto, view) => self.receive_light_view(x, z, proto, view),
|
|
Done::Lit(x, z, sections, seq) => {
|
|
if self.lighting.get(&(x, z)) != Some(&seq) {
|
|
continue;
|
|
}
|
|
self.lighting.remove(&(x, z));
|
|
let snapshot = self.light_snapshots.remove(&(x, z)).unwrap_or(u64::MAX);
|
|
if let Some(chunk) = world.chunks.get_mut(&(x, z)) {
|
|
let chunk = Arc::make_mut(chunk);
|
|
// Keep blocks edited since the snapshot; take only light.
|
|
// A chunk relit (a neighbour loaded or changed) remeshes
|
|
// only around the light that changed, as vanilla's light
|
|
// updates dirty only their sections (#270).
|
|
let first = self.lit.insert((x, z));
|
|
let mut changed = LightChanges::default();
|
|
if first {
|
|
apply_lighting(chunk, sections);
|
|
} else {
|
|
apply_relighting(chunk, sections, &mut changed);
|
|
}
|
|
// Saved unlit (at the edge of what was loaded), or its light
|
|
// changed: saved again.
|
|
if (first && !chunk.light_loaded) || !changed.is_empty() {
|
|
world.modified.insert((x, z));
|
|
}
|
|
chunk.lit = true;
|
|
if first {
|
|
for dz in -1..=1 {
|
|
for dx in -1..=1 {
|
|
for sy in 0..world.layout().sections() as i32 {
|
|
world.dirty_sections.insert((x + dx, sy, z + dz));
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
Self::mark_light_changes(world, &changed);
|
|
}
|
|
self.replay_light_edits(world, x, z, snapshot);
|
|
}
|
|
}
|
|
Done::Meshed(x, sy, z, mesh, seq) => {
|
|
if self.meshing.get(&(x, sy, z)) != Some(&seq) {
|
|
stale.push(((x, sy, z), mesh));
|
|
continue;
|
|
}
|
|
// Its column unloaded while it was meshed: nothing would
|
|
// ever remove the uploaded section.
|
|
if !world.chunks.contains_key(&(x, z)) {
|
|
self.meshing.remove(&(x, sy, z));
|
|
stale.push(((x, sy, z), mesh));
|
|
continue;
|
|
}
|
|
self.meshing.remove(&(x, sy, z));
|
|
self.compile_started.insert((x, sy, z), seq);
|
|
self.meshed_total += 1;
|
|
uploads.push(((x, sy, z), mesh));
|
|
}
|
|
}
|
|
}
|
|
self.free(stale);
|
|
let mut removed = Vec::new();
|
|
if sweep {
|
|
// Unload far columns.
|
|
let far: Vec<_> = world
|
|
.chunks
|
|
.keys()
|
|
.copied()
|
|
.filter(|&(x, z)| !self.remote && self.ranges().unloaded(x, z, self.decorates))
|
|
.collect();
|
|
for key in &far {
|
|
self.decorated.remove(key);
|
|
}
|
|
removed.reserve(far.len());
|
|
for key in &far {
|
|
if let Some(chunk) = world.chunks.remove(key) {
|
|
removed.push(chunk);
|
|
}
|
|
self.lit.remove(key);
|
|
self.meshed.remove(key);
|
|
self.lighting.remove(key);
|
|
self.light_snapshots.remove(key);
|
|
}
|
|
if !far.is_empty() {
|
|
let far: HashSet<(i32, i32)> = far.iter().copied().collect();
|
|
self.meshing.retain(|k, _| !far.contains(&(k.0, k.2)));
|
|
self.compile_started
|
|
.retain(|k, _| !far.contains(&(k.0, k.2)));
|
|
self.waiting.retain(|k| !far.contains(&(k.0, k.2)));
|
|
world
|
|
.dirty_sections
|
|
.retain_columns(|x, z| !far.contains(&(x, z)));
|
|
}
|
|
// Drop far proto chunks that are not checked out.
|
|
let (center, r4) = (self.center, i64::from(self.radius + 4));
|
|
let ranges = self.ranges();
|
|
let far: Vec<(i32, i32)> = self
|
|
.protos
|
|
.iter()
|
|
.filter(|&(&(x, z), p)| {
|
|
p.is_some()
|
|
&& (self.radius < 0
|
|
|| i64::from(x - center.0).pow(2) + i64::from(z - center.1).pow(2)
|
|
> r4 * r4)
|
|
&& !ranges.in_extra(x, z, 4)
|
|
&& !ranges.in_band(x, z, 4)
|
|
&& !ranges.forced_chunks().any(|f| {
|
|
(x - f.0).abs() <= FORCED_TERRAIN_RING
|
|
&& (z - f.1).abs() <= FORCED_TERRAIN_RING
|
|
})
|
|
})
|
|
.map(|(k, _)| *k)
|
|
.collect();
|
|
if !far.is_empty() {
|
|
self.generation_scan.invalidate();
|
|
}
|
|
// Those decoration wrote to are saved first (see
|
|
// `ChunkGenerator::save_protos`), and dropped once back.
|
|
let mut written = Vec::new();
|
|
for key in far {
|
|
if self.written.remove(&key) {
|
|
written.push(key);
|
|
} else {
|
|
self.protos.remove(&key);
|
|
self.light_views.remove(&key);
|
|
}
|
|
}
|
|
self.save_checked_out(written);
|
|
}
|
|
let (protos, finishing) = (&self.protos, &self.finishing);
|
|
self.decorated.retain(|k| {
|
|
protos.contains_key(k) || finishing.contains(k) || world.chunks.contains_key(k)
|
|
});
|
|
// Finalize chunks whose neighbourhood is decorated.
|
|
let candidates: Vec<(i32, i32)> = self.protos.keys().copied().collect();
|
|
for (x, z) in candidates {
|
|
if world.chunks.contains_key(&(x, z))
|
|
|| self.finishing.contains(&(x, z))
|
|
|| !self.in_range(x, z, 1)
|
|
{
|
|
continue;
|
|
}
|
|
let ready = (-1..=1).all(|dz| {
|
|
(-1..=1).all(|dx| {
|
|
self.decorated.contains(&(x + dx, z + dz))
|
|
|| world.chunks.contains_key(&(x + dx, z + dz))
|
|
})
|
|
});
|
|
if ready && let Some(Some(_)) = self.protos.get(&(x, z)) {
|
|
let proto = self
|
|
.protos
|
|
.remove(&(x, z))
|
|
.flatten()
|
|
.expect("present proto");
|
|
self.light_views.remove(&(x, z));
|
|
self.finishing.insert((x, z));
|
|
self.written.remove(&(x, z));
|
|
self.submit(Some((x, z)), 0, Job::Finish(proto));
|
|
}
|
|
}
|
|
// Decorate chunks whose 3x3 proto neighbourhood is available, in
|
|
// vanilla's order: decoration is order dependent where write regions
|
|
// overlap (see `DecorationOrder`).
|
|
let workers = self.workers;
|
|
if self.decorates && self.order_area != Some((self.center, self.radius)) {
|
|
let (decorated, chunks) = (&self.decorated, &world.chunks);
|
|
// The render distance is the server view distance in singleplayer.
|
|
self.decoration_order
|
|
.reassign(self.center, self.radius, |x, z| {
|
|
decorated.contains(&(x, z)) || chunks.contains_key(&(x, z))
|
|
});
|
|
self.order_area = Some((self.center, self.radius));
|
|
let ranges = Ranges {
|
|
center: self.center,
|
|
radius: self.radius,
|
|
extra: &self.extra,
|
|
forced: &self.forced,
|
|
mod_forced: &self.mod_forced,
|
|
cover: ExtraCover::matching(&self.cover, &self.extra),
|
|
band: self.band.as_ref(),
|
|
};
|
|
let (protos, generating) = (&self.protos, &self.generating);
|
|
self.decoration_order.retain(|x, z| {
|
|
ranges.in_range(x, z, 4)
|
|
|| ranges.near_finished(x, z, 2)
|
|
|| protos.contains_key(&(x, z))
|
|
|| generating.contains(&(x, z))
|
|
});
|
|
}
|
|
if self.decorates && self.decorating < workers * 2 {
|
|
let wants = |s: &Self, x: i32, z: i32| {
|
|
!s.decorated.contains(&(x, z))
|
|
&& !world.chunks.contains_key(&(x, z))
|
|
&& (s.in_range(x, z, 2) || s.near_finished(x, z, 1))
|
|
};
|
|
// A wanted chunk holds back higher-ranked ones only if it can be
|
|
// decorated: its 3x3 neighbourhood is (or will be) generated as
|
|
// protos, none of them already final.
|
|
let waits_for = |s: &Self, x: i32, z: i32| {
|
|
wants(s, x, z)
|
|
&& (-1..=1).all(|dz| {
|
|
(-1..=1).all(|dx| {
|
|
let n = (x + dx, z + dz);
|
|
!world.chunks.contains_key(&n)
|
|
&& (s.protos.contains_key(&n)
|
|
|| s.generating.contains(&n)
|
|
|| s.ranges().requested(n.0, n.1, true))
|
|
})
|
|
})
|
|
};
|
|
let keys: Vec<(i32, i32)> = self
|
|
.protos
|
|
.iter()
|
|
.filter(|(k, p)| p.is_some() && wants(self, k.0, k.1))
|
|
.map(|(k, _)| *k)
|
|
.collect();
|
|
let mut wanted: Vec<(u64, i64, i32, i32)> = keys
|
|
.into_iter()
|
|
.map(|(x, z)| (0, self.distance(x, z), x, z))
|
|
.collect();
|
|
// Chunks no ticket reached (forced and extra areas) go last, nearest first.
|
|
wanted.sort_unstable_by_key(|w| w.1);
|
|
for w in &mut wanted {
|
|
w.0 = self.decoration_order.rank_or_append(w.2, w.3);
|
|
}
|
|
wanted.sort_unstable();
|
|
// Most wanted chunks are blocked, and the same neighbours are
|
|
// asked about for each of them: what `waits_for` says is kept for
|
|
// the pass (nothing it reads changes in it but `decorated`, which
|
|
// updates the entry). Without it, scheduling at render distance 32
|
|
// took most of the thread that runs it while the world loaded.
|
|
let waiting: std::cell::RefCell<FxHashMap<(i32, i32), bool>> = Default::default();
|
|
for (_, _, x, z) in wanted {
|
|
if self.decorating >= workers * 2 {
|
|
break;
|
|
}
|
|
if self.decoration_order.blocked(x, z, |nx, nz| {
|
|
if let Some(&known) = waiting.borrow().get(&(nx, nz)) {
|
|
return known;
|
|
}
|
|
let known = waits_for(self, nx, nz);
|
|
waiting.borrow_mut().insert((nx, nz), known);
|
|
known
|
|
}) {
|
|
continue;
|
|
}
|
|
let keys: Vec<(i32, i32)> =
|
|
(0..9).map(|i| (x + (i % 3) - 1, z + (i / 3) - 1)).collect();
|
|
// A final neighbour cannot be generated again: a generator that
|
|
// can decorates beside it (a chunk of an older world), writing
|
|
// into it as vanilla's features do; the others skip decoration
|
|
// (worlds saved before proto chunks were saved get here: their
|
|
// final chunks' neighbours are generated again).
|
|
if keys.iter().any(|k| self.decorating_finals.contains(k)) {
|
|
continue;
|
|
}
|
|
let finals: Vec<(usize, Chunk)> = keys
|
|
.iter()
|
|
.enumerate()
|
|
.filter_map(|(i, k)| Some((i, (**world.chunks.get(k)?).clone())))
|
|
.collect();
|
|
if !finals.is_empty() && !self.saves_beside_final {
|
|
self.decorated.insert((x, z));
|
|
waiting.borrow_mut().insert((x, z), false);
|
|
continue;
|
|
}
|
|
if !keys.iter().enumerate().all(|(i, k)| {
|
|
finals.iter().any(|(f, _)| *f == i)
|
|
|| matches!(self.protos.get(k), Some(Some(_)))
|
|
}) {
|
|
continue;
|
|
}
|
|
let region: Vec<Proto> = keys
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, k)| {
|
|
if finals.iter().any(|(f, _)| *f == i) {
|
|
return Box::new(()) as Proto;
|
|
}
|
|
self.light_views.remove(k);
|
|
self.protos
|
|
.insert(*k, None)
|
|
.flatten()
|
|
.expect("present proto")
|
|
})
|
|
.collect();
|
|
let region: Box<[Proto; 9]> = match region.try_into() {
|
|
Ok(r) => Box::new(r),
|
|
Err(_) => continue,
|
|
};
|
|
self.decorating += 1;
|
|
self.decorating_finals
|
|
.extend(finals.iter().map(|(i, _)| keys[*i]));
|
|
self.submit(Some((x, z)), 1, Job::Decorate(x, z, region, finals));
|
|
}
|
|
}
|
|
self.schedule_generation(world, workers);
|
|
// Lighting once all neighbours exist, nearest first and only as
|
|
// much of it as the workers can chew. Vanilla's light engine also
|
|
// hands a bounded queue to its workers and reschedules what did
|
|
// not fit by distance; submitted whole, the light jobs of every
|
|
// unlit column at once — each carrying a 3x3 region snapshot —
|
|
// grew with the render distance and outlived the columns they
|
|
// were queued for (#3629). What stays in `dirty_light` is asked
|
|
// for again, from the current centre, as capacity frees.
|
|
let mut light: Vec<(i64, i32, i32)> = Vec::new();
|
|
let mut gone: Vec<(i32, i32)> = Vec::new();
|
|
for &(x, z) in world.dirty_light.iter() {
|
|
if !world.chunks.contains_key(&(x, z)) {
|
|
gone.push((x, z));
|
|
} else if self.light_region_ready(world, x, z) {
|
|
light.push((self.distance(x, z), x, z));
|
|
}
|
|
}
|
|
for key in gone {
|
|
world.dirty_light.remove(&key);
|
|
}
|
|
let room = (self.workers * 4).saturating_sub(self.lighting.len());
|
|
nearest_first(&mut light, room);
|
|
for (_, x, z) in light {
|
|
let Some(region) = self.light_region(world, x, z) else {
|
|
continue;
|
|
};
|
|
world.dirty_light.remove(&(x, z));
|
|
self.sequence += 1;
|
|
self.lighting.insert((x, z), self.sequence);
|
|
self.light_snapshots
|
|
.insert((x, z), self.light_edit_sequence);
|
|
self.submit(
|
|
Some((x, z)),
|
|
1,
|
|
Job::Light(x, z, region, self.sequence, self.sky_light),
|
|
);
|
|
}
|
|
// A server draws nothing: its sections are never meshed.
|
|
if !self.mesh {
|
|
world.dirty_sections.clear();
|
|
return (uploads, removed);
|
|
}
|
|
// Meshing once the column and its neighbours are lit, and the column
|
|
// is within the render distance (`in_view_distance`, the columns
|
|
// vanilla draws). Sections set aside past it come back
|
|
// when the player moves: a column that loads with its light gets no
|
|
// other request (#228).
|
|
let area = (
|
|
self.center,
|
|
self.radius,
|
|
self.forced.len() + self.mod_forced.len(),
|
|
);
|
|
if self.waiting_area != area {
|
|
self.waiting_area = area;
|
|
let ranges = Ranges {
|
|
center: self.center,
|
|
radius: self.radius,
|
|
extra: &self.extra,
|
|
forced: &self.forced,
|
|
mod_forced: &self.mod_forced,
|
|
cover: ExtraCover::matching(&self.cover, &self.extra),
|
|
band: self.band.as_ref(),
|
|
};
|
|
self.waiting.retain(|&(x, sy, z)| {
|
|
let back = ranges.in_view(x, z);
|
|
if back {
|
|
world.dirty_sections.insert((x, sy, z));
|
|
}
|
|
!back
|
|
});
|
|
}
|
|
// Column by column: the checks below hold for all of a column's
|
|
// sections, and columns waiting for light cost one look per frame.
|
|
let mut candidates: Vec<(i64, i32, i32, SectionMask)> = Vec::new();
|
|
let columns: Vec<_> = world.dirty_sections.columns().collect();
|
|
for ((x, z), sections) in columns {
|
|
if !world.chunks.contains_key(&(x, z)) {
|
|
world.dirty_sections.take_column(x, z);
|
|
continue;
|
|
}
|
|
if !self.ranges().in_view(x, z) {
|
|
world.dirty_sections.take_column(x, z);
|
|
for sy in sections.iter().map(|sy| sy as i32) {
|
|
self.waiting.insert((x, sy, z));
|
|
}
|
|
continue;
|
|
}
|
|
// A neighbour being lit dirties this column again when its light
|
|
// job finishes (new chunks relight their eight neighbours), so
|
|
// wait for it rather than mesh once per neighbour. Columns still
|
|
// in `dirty_light` here can't be lit yet (a neighbour of theirs is
|
|
// missing): waiting for them could last until the player comes
|
|
// back.
|
|
let ready = (-1..=1).all(|dz| {
|
|
(-1..=1).all(|dx| {
|
|
let n = (x + dx, z + dz);
|
|
self.lit.contains(&n) && !self.lighting.contains_key(&n)
|
|
})
|
|
});
|
|
if !ready {
|
|
continue;
|
|
}
|
|
candidates.push((self.distance(x, z), x, z, sections));
|
|
}
|
|
// Nearest first, and only as many sections as the workers can chew.
|
|
// Every ready column used to be submitted at once, so at a wide
|
|
// render distance the queue — and `meshing`, and each job's 3x3
|
|
// region snapshot — grew with the number of loaded columns and the
|
|
// jobs went stale long before a worker reached them (#3629). What
|
|
// stays dirty is asked for again, from the current centre, as
|
|
// capacity frees; `requeue` then has a small queue to keep close.
|
|
let room = (self.workers * 32).saturating_sub(self.meshing.len());
|
|
nearest_first(&mut candidates, room);
|
|
for (_, x, z, sections) in candidates {
|
|
let Some(region) = Self::region(world, x, z) else {
|
|
continue;
|
|
};
|
|
world.dirty_sections.take_column(x, z);
|
|
for sy in sections.iter().map(|sy| sy as i32) {
|
|
let first = self.meshed.insert((x, z));
|
|
self.sequence += 1;
|
|
self.meshing.insert((x, sy, z), self.sequence);
|
|
self.submit(
|
|
Some((x, z)),
|
|
if first { 2 } else { 0 },
|
|
Job::Mesh(
|
|
x,
|
|
sy,
|
|
z,
|
|
region.clone(),
|
|
self.sequence,
|
|
MeshSettings {
|
|
nether: self.nether_shading,
|
|
shader_pack_lighting: self.shader_pack_lighting,
|
|
separate_ao: self.separate_ao,
|
|
shader_pack: self.shader_pack,
|
|
zoom: self.biome_zoom,
|
|
blend_radius: self.biome_blend,
|
|
smooth_lighting: self.smooth_lighting,
|
|
cutout_leaves: self.cutout_leaves,
|
|
translucent_layers: self.translucent_layers.clone(),
|
|
leaf_culling: self.leaf_culling,
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
(uploads, removed)
|
|
}
|
|
fn schedule_generation(&mut self, world: &World, workers: usize) {
|
|
let input = GenerationInputs {
|
|
ranges: Ranges {
|
|
center: self.center,
|
|
radius: self.radius,
|
|
extra: &self.extra,
|
|
forced: &self.forced,
|
|
mod_forced: &self.mod_forced,
|
|
cover: ExtraCover::matching(&self.cover, &self.extra),
|
|
band: self.band.as_ref(),
|
|
},
|
|
world,
|
|
generating: &self.generating,
|
|
protos: &self.protos,
|
|
finishing: &self.finishing,
|
|
decorates: self.decorates,
|
|
remote: self.remote,
|
|
};
|
|
if let Some(wanted) = self.generation_scan.candidates(input, workers * 3) {
|
|
for (_, x, z) in wanted {
|
|
self.generating.insert((x, z));
|
|
self.submit(Some((x, z)), 0, Job::Generate(x, z));
|
|
}
|
|
}
|
|
}
|
|
/// Relight block edits on lit chunks in place (vanilla's incremental light
|
|
/// engine) instead of recomputing the 3x3 chunks around each edit.
|
|
fn relight_edits(&mut self, world: &mut World) {
|
|
if world.light_edits.is_empty() {
|
|
return;
|
|
}
|
|
let edits = std::mem::take(&mut world.light_edits);
|
|
let positions: Vec<(i32, i32, i32)> = edits
|
|
.iter()
|
|
.filter(|(_, old, new)| self.light_tables.differs(*old, *new))
|
|
.map(|(p, _, _)| *p)
|
|
.collect();
|
|
if positions.is_empty() {
|
|
return;
|
|
}
|
|
let positions = if positions.len() > INLINE_RELIGHT_MAX {
|
|
self.relight_on_workers(world, positions)
|
|
} else {
|
|
positions
|
|
};
|
|
if positions.is_empty() {
|
|
return;
|
|
}
|
|
let mut changed = LightChanges::default();
|
|
crate::light::relight(
|
|
&mut world.chunks,
|
|
&self.light_tables,
|
|
self.sky_light,
|
|
&positions,
|
|
false,
|
|
&mut changed,
|
|
);
|
|
Self::mark_light_changes(world, &changed);
|
|
Self::relight_beside_unlit(world, &changed);
|
|
if !self.light_snapshots.is_empty() {
|
|
for p in positions {
|
|
self.light_edit_sequence += 1;
|
|
self.light_log.push((self.light_edit_sequence, p));
|
|
}
|
|
}
|
|
}
|
|
/// Hands a big batch of edits (a `/fill` arriving block by block) to the
|
|
/// workers: the columns they touch and their neighbours get full light
|
|
/// jobs, as `World::edit_runs` does, instead of relighting every changed
|
|
/// cell in the frame (clearing a 64x64x16 volume in the air relit the
|
|
/// sky below it: 43 ms of one frame). The sections wait for those jobs
|
|
/// before they are meshed again. Returns the edits left to light in the
|
|
/// frame: those in columns that can't be lit whole yet (the edge of the
|
|
/// loaded area).
|
|
fn relight_on_workers(
|
|
&self,
|
|
world: &mut World,
|
|
positions: Vec<(i32, i32, i32)>,
|
|
) -> Vec<(i32, i32, i32)> {
|
|
let (columns, inline) = whole_relight_columns(positions, |x, z| {
|
|
self.lit.contains(&(x, z)) && self.light_region_ready(world, x, z)
|
|
});
|
|
world.dirty_light.extend(columns);
|
|
inline
|
|
}
|
|
/// A light job computed from blocks older than some edits: redo those
|
|
/// edits on top of its result.
|
|
fn replay_light_edits(&mut self, world: &mut World, x: i32, z: i32, snapshot: u64) {
|
|
let positions: Vec<(i32, i32, i32)> = self
|
|
.light_log
|
|
.iter()
|
|
.filter(|(seq, p)| {
|
|
*seq > snapshot && ((p.0 >> 4) - x).abs() <= 1 && ((p.2 >> 4) - z).abs() <= 1
|
|
})
|
|
.map(|(_, p)| *p)
|
|
.collect();
|
|
if !positions.is_empty() {
|
|
let mut changed = LightChanges::default();
|
|
crate::light::relight(
|
|
&mut world.chunks,
|
|
&self.light_tables,
|
|
self.sky_light,
|
|
&positions,
|
|
true,
|
|
&mut changed,
|
|
);
|
|
Self::mark_light_changes(world, &changed);
|
|
Self::relight_beside_unlit(world, &changed);
|
|
}
|
|
let oldest = self.light_snapshots.values().copied().min();
|
|
match oldest {
|
|
Some(oldest) => self.light_log.retain(|(seq, _)| *seq > oldest),
|
|
None => self.light_log.clear(),
|
|
}
|
|
}
|
|
/// A column whose light an edit changed beside a column not lit yet gets
|
|
/// a full light job ([`crate::light::beside_unlit`]).
|
|
fn relight_beside_unlit(world: &mut World, changed: &LightChanges) {
|
|
let columns = crate::light::beside_unlit(changed, |x, z| {
|
|
world.chunks.get(&(x, z)).is_some_and(|c| !c.lit)
|
|
});
|
|
world.dirty_light.extend(columns);
|
|
}
|
|
/// Sections whose meshes sample a changed light value (faces and corners
|
|
/// read light from neighbouring blocks).
|
|
fn mark_light_changes(world: &mut World, changed: &LightChanges) {
|
|
if let Some(log) = &mut world.light_log {
|
|
for (x, sy, z) in changed.sections() {
|
|
log.entry((x, z)).or_default().insert(sy as usize);
|
|
}
|
|
}
|
|
changed.dirty_sections(|section| {
|
|
world.dirty_sections.insert(section);
|
|
});
|
|
}
|
|
fn ranges(&self) -> Ranges<'_> {
|
|
Ranges {
|
|
center: self.center,
|
|
radius: self.radius,
|
|
extra: &self.extra,
|
|
forced: &self.forced,
|
|
mod_forced: &self.mod_forced,
|
|
cover: ExtraCover::matching(&self.cover, &self.extra),
|
|
band: self.band.as_ref(),
|
|
}
|
|
}
|
|
fn near_finished(&self, x: i32, z: i32, reach: i32) -> bool {
|
|
self.ranges().near_finished(x, z, reach)
|
|
}
|
|
/// Abort unvisited background chunk generation, decoration, and meshing on world exit.
|
|
pub fn abort_generation(&mut self) {
|
|
self.shared
|
|
.aborted
|
|
.store(true, std::sync::atomic::Ordering::SeqCst);
|
|
if let Ok(mut queue) = self.shared.queue.lock() {
|
|
let mut saves = Vec::new();
|
|
for item in std::mem::take(&mut *queue).into_vec() {
|
|
if matches!(item.job, Job::Save(..)) {
|
|
saves.push(item);
|
|
}
|
|
}
|
|
*queue = std::collections::BinaryHeap::from(saves);
|
|
}
|
|
self.generating.clear();
|
|
self.decorating = 0;
|
|
self.decorating_finals.clear();
|
|
self.finishing.clear();
|
|
self.viewing.clear();
|
|
self.waiting.clear();
|
|
self.meshing.clear();
|
|
self.lighting.clear();
|
|
self.jobs.notify_all();
|
|
}
|
|
/// Save what generation holds outside the world: the proto chunks
|
|
/// decoration wrote to. With `wait`, the decorations, columns and saves
|
|
/// in flight are waited for first (their proto chunks are out in the
|
|
/// jobs) and the protos are saved here, before the world's chunks;
|
|
/// otherwise a worker saves them (vanilla's autosave hands its chunks to
|
|
/// the IOWorker too), and those out in jobs are saved later, as they
|
|
/// unload or at the next save.
|
|
pub fn save(&mut self, world: &mut World, wait: bool) {
|
|
if !wait {
|
|
let written: Vec<(i32, i32)> = self.written.drain().collect();
|
|
self.save_checked_out(written);
|
|
return;
|
|
}
|
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
|
|
let aborted = self
|
|
.shared
|
|
.aborted
|
|
.load(std::sync::atomic::Ordering::Relaxed);
|
|
while (self.saving > 0)
|
|
|| (!aborted
|
|
&& (self.decorating > 0 || !self.finishing.is_empty() || !self.viewing.is_empty()))
|
|
{
|
|
let left = deadline.saturating_duration_since(std::time::Instant::now());
|
|
match self.shared.done.recv_timeout(left) {
|
|
Some(Done::Decorated(x, z, region, finals, writes)) => {
|
|
self.receive_decorated(x, z, *region, finals, writes, world)
|
|
}
|
|
Some(Done::Generated(chunk)) => self.receive_generated(chunk, world),
|
|
Some(Done::Saved(protos)) => self.receive_saved(protos),
|
|
Some(Done::LightView(x, z, proto, view)) => {
|
|
self.receive_light_view(x, z, proto, view)
|
|
}
|
|
Some(other) => self.deferred.push(other),
|
|
None => {
|
|
log::warn!("Horizon: chunk generation did not finish before saving");
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
let protos: Vec<&Proto> = self
|
|
.written
|
|
.iter()
|
|
.filter_map(|k| self.protos.get(k)?.as_ref())
|
|
.collect();
|
|
if !protos.is_empty() {
|
|
self.saves.save_protos(&protos);
|
|
}
|
|
self.written.clear();
|
|
}
|
|
/// Finish generation for a closing world without waiting on the event
|
|
/// loop. No new generation is scheduled while the save screen is shown.
|
|
/// Proto snapshots are checked out to workers before chunk writes, just as
|
|
/// in `save(true)`, so older generation cannot overwrite the final chunks.
|
|
pub fn poll_save(&mut self, world: &mut World) -> bool {
|
|
let started = std::time::Instant::now();
|
|
while let Some(done) = self.shared.done.try_recv() {
|
|
match done {
|
|
Done::Decorated(x, z, region, finals, writes) => {
|
|
self.receive_decorated(x, z, *region, finals, writes, world)
|
|
}
|
|
Done::Generated(chunk) => self.receive_generated(chunk, world),
|
|
Done::Saved(protos) => self.receive_saved(protos),
|
|
Done::LightView(x, z, proto, view) => self.receive_light_view(x, z, proto, view),
|
|
other => self.deferred.push(other),
|
|
}
|
|
if started.elapsed() >= std::time::Duration::from_millis(2) {
|
|
return false;
|
|
}
|
|
}
|
|
let aborted = self
|
|
.shared
|
|
.aborted
|
|
.load(std::sync::atomic::Ordering::Relaxed);
|
|
if (self.saving > 0)
|
|
|| (!aborted
|
|
&& (self.decorating > 0 || !self.finishing.is_empty() || !self.viewing.is_empty()))
|
|
{
|
|
return false;
|
|
}
|
|
let written: Vec<_> = self.written.drain().collect();
|
|
self.save_checked_out(written);
|
|
self.saving == 0
|
|
}
|
|
fn in_range(&self, x: i32, z: i32, margin: i32) -> bool {
|
|
self.ranges().in_range(x, z, margin)
|
|
}
|
|
/// Rank the 3x3 a chunk loaded to FULL decorates, in one generation
|
|
/// task's order (`ChunkGenerationTask`: x-outer, z-inner), after every
|
|
/// chunk ranked before.
|
|
pub fn rank_full_task(&mut self, x: i32, z: i32) {
|
|
for dx in -1..=1 {
|
|
for dz in -1..=1 {
|
|
self.decoration_order.rank_or_append(x + dx, z + dz);
|
|
}
|
|
}
|
|
}
|
|
pub fn band(&self) -> Option<Band> {
|
|
self.band
|
|
}
|
|
/// Rank the chunks `/forceload add` over chunks `(x0, z0)..=(x1, z1)`
|
|
/// decorates in vanilla's order (`forced_order`), after every chunk
|
|
/// ranked before.
|
|
pub fn rank_forced(&mut self, x0: i32, z0: i32, x1: i32, z1: i32) {
|
|
for (x, z) in horizon_world::worldgen::decoration_order::forced_order(x0, z0, x1, z1) {
|
|
self.decoration_order.rank_or_append(x, z);
|
|
}
|
|
}
|
|
/// Generate, light and keep the chunks of `band` (none: stop), and let
|
|
/// go of what earlier bands held as it falls out of range. The chunks a
|
|
/// band reaches are ranked for decoration row by row, as they first come
|
|
/// within reach, so that a task decorates in the same order however its
|
|
/// work is timed and shared among the workers.
|
|
pub fn set_band(&mut self, band: Option<Band>) {
|
|
if self.band == band {
|
|
return;
|
|
}
|
|
self.band = band;
|
|
self.generation_scan.exhausted = false;
|
|
let Some(band) = band else {
|
|
return;
|
|
};
|
|
// Decoration reaches two chunks past the band and generation three.
|
|
const REACH: i32 = 4;
|
|
let (x0, x1) = (band.x0 - REACH, band.x1 + REACH);
|
|
let first = match self.band_ranked {
|
|
Some((rx0, rx1, next)) if (rx0, rx1) == (x0, x1) => next,
|
|
_ => band.z0 - REACH,
|
|
};
|
|
let end = band.z1 + REACH;
|
|
for z in first..=end {
|
|
for x in x0..=x1 {
|
|
self.decoration_order.rank_or_append(x, z);
|
|
}
|
|
}
|
|
self.band_ranked = Some((x0, x1, end.max(first - 1) + 1));
|
|
}
|
|
/// What holds up the columns `keys`, for a task that stopped advancing:
|
|
/// how many are not loaded, loaded but unlit with a light job in flight,
|
|
/// and unlit with none (those whose neighbours are all loaded are queued
|
|
/// for light again).
|
|
pub fn unstick_light(&mut self, world: &mut World, keys: &[(i32, i32)]) -> [usize; 3] {
|
|
let mut counts = [0; 3];
|
|
for &key in keys {
|
|
if !world.chunks.contains_key(&key) {
|
|
counts[0] += 1;
|
|
} else if self.lit.contains(&key) {
|
|
} else if self.lighting.contains_key(&key) {
|
|
counts[1] += 1;
|
|
} else {
|
|
counts[2] += 1;
|
|
if self.light_region(world, key.0, key.1).is_some()
|
|
|| (-1..=1).any(|dz| {
|
|
(-1..=1).any(|dx| self.viewing.contains(&(key.0 + dx, key.1 + dz)))
|
|
})
|
|
{
|
|
world.dirty_light.insert(key);
|
|
}
|
|
}
|
|
}
|
|
counts
|
|
}
|
|
/// Wait up to `timeout` for a worker to finish a job; the next `update`
|
|
/// takes it. A server pumps generation with this between its ticks.
|
|
pub fn wait(&mut self, timeout: std::time::Duration) {
|
|
if let Some(done) = self.shared.done.recv_timeout(timeout) {
|
|
self.deferred.push(done);
|
|
}
|
|
}
|
|
}
|
|
/// `ChunkTrackingView.isInViewDistance`: whether the column (x, z) is drawn
|
|
/// at this render distance around `center`, the camera's column
|
|
/// (`SectionOcclusionGraph.isInViewDistance`). Each axis gets a column of
|
|
/// slack, so this reaches past the circle of `radius` columns.
|
|
pub fn in_view_distance(center: (i32, i32), radius: i32, x: i32, z: i32) -> bool {
|
|
let gap = |d: i32| i64::from((d.abs() - 1).max(0));
|
|
let (dx, dz) = (gap(x - center.0), gap(z - center.1));
|
|
dx * dx + dz * dz < i64::from(radius) * i64::from(radius)
|
|
}
|
|
/// How long a view's columns are waited for (`ViewArrival`): a server may never
|
|
/// fill it.
|
|
const VIEW_WAIT: std::time::Duration = std::time::Duration::from_secs(30);
|
|
/// Whether the columns of a view (centre, radius) have all arrived, as a client
|
|
/// sees them come: a server forgets the old view's columns, then sends the new
|
|
/// ones nearest first, so the view's outermost ones come last. Single player
|
|
/// joins its integrated server the same way, so `Streaming::busy` cannot see
|
|
/// what is not sent yet.
|
|
#[derive(Default)]
|
|
pub struct ViewArrival {
|
|
view: ((i32, i32), i32),
|
|
since: Option<std::time::Instant>,
|
|
arrived: bool,
|
|
}
|
|
impl ViewArrival {
|
|
/// Whether columns of `view` have yet to arrive (`has`): its outermost ones,
|
|
/// at both ends of each of its rows and columns (`in_view_distance`). Once
|
|
/// they have, false until the view changes; false too once it is no longer
|
|
/// `recent`, and for a radius below 0 (no view).
|
|
pub fn arriving(
|
|
&mut self,
|
|
view: ((i32, i32), i32),
|
|
now: std::time::Instant,
|
|
has: impl Fn((i32, i32)) -> bool,
|
|
) -> bool {
|
|
if self.since.is_none() || view != self.view {
|
|
(self.view, self.since, self.arrived) = (view, Some(now), false);
|
|
}
|
|
let ((x, z), r) = view;
|
|
if self.arrived || r < 0 || !self.recent(now) {
|
|
return false;
|
|
}
|
|
self.arrived = (-r..=r).all(|d| {
|
|
// How far row (and column) `d` reaches: `in_view_distance` solved for it.
|
|
let gap = i64::from((d.abs() - 1).max(0));
|
|
let room = i64::from(r).pow(2) - gap * gap;
|
|
room <= 0 || {
|
|
let e = (room - 1).isqrt() as i32 + 1;
|
|
// Both ends of row `d`, and of column `d`.
|
|
[-e, e]
|
|
.into_iter()
|
|
.all(|e| has((x + d, z + e)) && has((x + e, z + d)))
|
|
}
|
|
});
|
|
!self.arrived
|
|
}
|
|
/// Whether the view `arriving` was last given changed less than `VIEW_WAIT`
|
|
/// ago: until then sections meshed are its terrain coming in, later an edit's.
|
|
pub fn recent(&self, now: std::time::Instant) -> bool {
|
|
self.since
|
|
.is_some_and(|since| now.saturating_duration_since(since) < VIEW_WAIT)
|
|
}
|
|
}
|
|
/// The rings around a `/forceload`ed chunk, in chunks. Vanilla's FORCED
|
|
/// ticket is level 31 (`ChunkMap.FORCED_TICKET_LEVEL`), the level rises one
|
|
/// per ring (`ChunkTracker.computeLevelFromNeighbor`), and the level picks
|
|
/// the column's status (`ChunkLevel.generationStatus`): full (33) two rings
|
|
/// out, features (34) at three, terrain (35) at four.
|
|
const FORCED_FULL_RING: i32 = 2;
|
|
const FORCED_FEATURES_RING: i32 = 3;
|
|
const FORCED_TERRAIN_RING: i32 = 4;
|
|
/// The areas kept loaded: circles around the player and `extra`, and forced chunks.
|
|
struct Ranges<'a> {
|
|
center: (i32, i32),
|
|
radius: i32,
|
|
extra: &'a [((i32, i32), i32)],
|
|
forced: &'a HashSet<(i32, i32)>,
|
|
/// The chunks native mods' tickets hold, kept as forced ones are.
|
|
mod_forced: &'a HashSet<(i32, i32)>,
|
|
/// `extra` indexed by a grid or buckets: coverage queries without a
|
|
/// scan of every area.
|
|
cover: Option<&'a ExtraCover>,
|
|
band: Option<&'a Band>,
|
|
}
|
|
/// The chunks a pregeneration task (`/pregen`) wants lit now: rows `z0..=z1`
|
|
/// of the columns `x0..=x1`, those within `circle` (a centre and a radius in
|
|
/// chunks) when there is one. Like an extra area, it finishes its chunks and
|
|
/// the ring around them, so that its own chunks can be lit, and generation
|
|
/// reaches past that. A task moves it down a strip of columns a row at a
|
|
/// time, so each chunk is generated once and only the rows in flight stay
|
|
/// loaded.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub struct Band {
|
|
pub x0: i32,
|
|
pub x1: i32,
|
|
pub z0: i32,
|
|
pub z1: i32,
|
|
pub circle: Option<((i32, i32), i32)>,
|
|
}
|
|
impl Band {
|
|
/// Whether (x, z) lies within `margin` (Chebyshev) of one of the band's
|
|
/// chunks: the square of side `2 * margin + 1` around it meets the
|
|
/// rectangle at a point inside the circle.
|
|
pub fn within(&self, x: i32, z: i32, margin: i32) -> bool {
|
|
let (x0, x1) = (
|
|
x.saturating_sub(margin).max(self.x0),
|
|
x.saturating_add(margin).min(self.x1),
|
|
);
|
|
let (z0, z1) = (
|
|
z.saturating_sub(margin).max(self.z0),
|
|
z.saturating_add(margin).min(self.z1),
|
|
);
|
|
if x0 > x1 || z0 > z1 {
|
|
return false;
|
|
}
|
|
self.circle.is_none_or(|((cx, cz), r)| {
|
|
let dx = i64::from(cx.clamp(x0, x1)) - i64::from(cx);
|
|
let dz = i64::from(cz.clamp(z0, z1)) - i64::from(cz);
|
|
dx * dx + dz * dz <= i64::from(r) * i64::from(r)
|
|
})
|
|
}
|
|
/// The band's chunks go after the players' (lower runs first), row by
|
|
/// row, as the task wants them lit.
|
|
fn priority(&self, x: i32, z: i32) -> i64 {
|
|
const AFTER_PLAYERS: i64 = 1 << 40;
|
|
AFTER_PLAYERS
|
|
+ (i64::from(z - self.z0 + 8).max(0) << 20)
|
|
+ i64::from(x - self.x0 + 8).max(0)
|
|
}
|
|
}
|
|
/// For every column near the extra areas, how far it lies outside the
|
|
/// nearest one's square (Chebyshev distance less its radius; 0 inside). A server has an area per player, and streaming asks of every
|
|
/// loaded and wanted column whether one reaches it, several times a tick:
|
|
/// with fifty players, a scan of fifty squares per question.
|
|
///
|
|
/// Areas spread too far apart for that grid (players thousands of blocks from
|
|
/// each other) are found by the square of [`Self::BUCKET`] columns their centre
|
|
/// is in instead, which also answers which centre is nearest a column
|
|
/// (`Ranges::distance`, asked of every wanted column to rank it).
|
|
struct ExtraCover {
|
|
extra: Vec<((i32, i32), i32)>,
|
|
grid: Option<CoverGrid>,
|
|
/// The indices in `extra` of the areas centred in each bucket.
|
|
buckets: FxHashMap<(i32, i32), Vec<u32>>,
|
|
/// The buckets' bounding box, inclusive: (x0, z0, x1, z1).
|
|
bucket_box: (i32, i32, i32, i32),
|
|
/// The largest radius in `extra`.
|
|
radius: i32,
|
|
}
|
|
struct CoverGrid {
|
|
x0: i32,
|
|
z0: i32,
|
|
width: u32,
|
|
depth: u32,
|
|
cells: Box<[i8]>,
|
|
}
|
|
impl ExtraCover {
|
|
/// The largest margin the grid answers for; beyond it (and outside the
|
|
/// grid) a column is further than that from every square.
|
|
const REACH: i32 = 4;
|
|
/// The largest grid, in columns (a mebibyte).
|
|
const CELLS: u64 = 1 << 20;
|
|
/// The side of a bucket, in columns.
|
|
const BUCKET: i32 = 64;
|
|
/// The farthest centre kept in buckets: far enough for any world border.
|
|
const LIMIT: i32 = 1 << 26;
|
|
fn matching<'a>(
|
|
cover: &'a Option<ExtraCover>,
|
|
extra: &[((i32, i32), i32)],
|
|
) -> Option<&'a ExtraCover> {
|
|
cover.as_ref().filter(|c| c.extra == extra)
|
|
}
|
|
/// Rebuild the coverage index if `extra` changed; none for one area or none
|
|
/// (a scan is as quick).
|
|
fn refresh(cover: &mut Option<ExtraCover>, extra: &[((i32, i32), i32)]) {
|
|
if cover.as_ref().is_some_and(|c| c.extra == extra) {
|
|
return;
|
|
}
|
|
*cover = Self::new(extra);
|
|
}
|
|
fn new(extra: &[((i32, i32), i32)]) -> Option<Self> {
|
|
if extra.len() < 2
|
|
|| extra.iter().any(|&((x, z), r)| {
|
|
!(0..=64).contains(&r) || x.abs() > Self::LIMIT || z.abs() > Self::LIMIT
|
|
})
|
|
{
|
|
return None;
|
|
}
|
|
let mut buckets: FxHashMap<(i32, i32), Vec<u32>> = FxHashMap::default();
|
|
let mut bucket_box = (i32::MAX, i32::MAX, i32::MIN, i32::MIN);
|
|
for (i, &((x, z), _)) in extra.iter().enumerate() {
|
|
let key = (x.div_euclid(Self::BUCKET), z.div_euclid(Self::BUCKET));
|
|
buckets.entry(key).or_default().push(i as u32);
|
|
bucket_box = (
|
|
bucket_box.0.min(key.0),
|
|
bucket_box.1.min(key.1),
|
|
bucket_box.2.max(key.0),
|
|
bucket_box.3.max(key.1),
|
|
);
|
|
}
|
|
Some(Self {
|
|
extra: extra.to_vec(),
|
|
grid: CoverGrid::new(extra),
|
|
buckets,
|
|
bucket_box,
|
|
radius: extra.iter().map(|&(_, r)| r).max().unwrap_or(0),
|
|
})
|
|
}
|
|
/// Whether (x, z) is within `margin` of an area's square, for a
|
|
/// `margin` up to `REACH`.
|
|
fn within(&self, x: i32, z: i32, margin: i32) -> bool {
|
|
if let Some(grid) = &self.grid {
|
|
return grid.within(x, z, margin);
|
|
}
|
|
let reach = self.radius + margin;
|
|
let (kx0, kx1) = (
|
|
(x.saturating_sub(reach)).div_euclid(Self::BUCKET),
|
|
(x.saturating_add(reach)).div_euclid(Self::BUCKET),
|
|
);
|
|
let (kz0, kz1) = (
|
|
(z.saturating_sub(reach)).div_euclid(Self::BUCKET),
|
|
(z.saturating_add(reach)).div_euclid(Self::BUCKET),
|
|
);
|
|
(kz0..=kz1).any(|kz| {
|
|
(kx0..=kx1).any(|kx| {
|
|
self.buckets.get(&(kx, kz)).is_some_and(|areas| {
|
|
areas.iter().any(|&i| {
|
|
let ((cx, cz), r) = self.extra[i as usize];
|
|
(i64::from(x) - i64::from(cx))
|
|
.abs()
|
|
.max((i64::from(z) - i64::from(cz)).abs())
|
|
<= i64::from(r + margin)
|
|
})
|
|
})
|
|
})
|
|
})
|
|
}
|
|
/// The squared distance from (x, z) to the nearest area's centre.
|
|
/// Buckets are searched ring by ring outwards: once the nearest found is
|
|
/// no further than the next ring can be, the search stops.
|
|
fn nearest_sq(&self, x: i32, z: i32) -> i64 {
|
|
let d = |i: u32| {
|
|
let (c, _) = self.extra[i as usize];
|
|
let (dx, dz) = (i64::from(x) - i64::from(c.0), i64::from(z) - i64::from(c.1));
|
|
dx * dx + dz * dz
|
|
};
|
|
let (qx, qz) = (x.div_euclid(Self::BUCKET), z.div_euclid(Self::BUCKET));
|
|
let (x0, z0, x1, z1) = self.bucket_box;
|
|
let last = (qx - x0)
|
|
.abs()
|
|
.max((qx - x1).abs())
|
|
.max((qz - z0).abs())
|
|
.max((qz - z1).abs());
|
|
let mut best = i64::MAX;
|
|
let mut looked = 0usize;
|
|
for ring in 0..=last {
|
|
let mut look = |kx: i32, kz: i32| {
|
|
looked += 1;
|
|
if let Some(areas) = self.buckets.get(&(kx, kz)) {
|
|
best = areas.iter().map(|&i| d(i)).fold(best, i64::min);
|
|
}
|
|
};
|
|
if ring == 0 {
|
|
look(qx, qz);
|
|
} else {
|
|
for k in -ring..=ring {
|
|
look(qx + k, qz - ring);
|
|
look(qx + k, qz + ring);
|
|
}
|
|
for k in 1 - ring..ring {
|
|
look(qx - ring, qz + k);
|
|
look(qx + ring, qz + k);
|
|
}
|
|
}
|
|
// Every centre in a further ring is more than `ring` buckets away
|
|
// on one axis.
|
|
let bound = i64::from(ring) * i64::from(Self::BUCKET);
|
|
if best <= bound * bound {
|
|
break;
|
|
}
|
|
// Far from every area: a scan costs less than more empty rings.
|
|
if looked > self.extra.len() * 4 {
|
|
return (0..self.extra.len() as u32).map(d).fold(best, i64::min);
|
|
}
|
|
}
|
|
best
|
|
}
|
|
}
|
|
impl CoverGrid {
|
|
/// None for areas too far apart for [`ExtraCover::CELLS`].
|
|
fn new(extra: &[((i32, i32), i32)]) -> Option<Self> {
|
|
let k = i64::from(ExtraCover::REACH);
|
|
let (mut x0, mut z0, mut x1, mut z1) = (i64::MAX, i64::MAX, i64::MIN, i64::MIN);
|
|
for &((x, z), r) in extra {
|
|
let (x, z, r) = (i64::from(x), i64::from(z), i64::from(r) + k);
|
|
(x0, z0, x1, z1) = (x0.min(x - r), z0.min(z - r), x1.max(x + r), z1.max(z + r));
|
|
}
|
|
let (width, depth) = ((x1 - x0 + 1) as u64, (z1 - z0 + 1) as u64);
|
|
if width * depth > ExtraCover::CELLS {
|
|
return None;
|
|
}
|
|
let (x0, z0, width, depth) = (x0 as i32, z0 as i32, width as u32, depth as u32);
|
|
let mut cells =
|
|
vec![(ExtraCover::REACH + 1) as i8; (width * depth) as usize].into_boxed_slice();
|
|
// Inside a square counts as 0: no margin asked for is below it. Each
|
|
// row of a square is a run of one value with a slope at either end.
|
|
let lower = |run: &mut [i8], v: i8| run.iter_mut().for_each(|c| *c = (*c).min(v));
|
|
for &((cx, cz), r) in extra {
|
|
let reach = r + ExtraCover::REACH;
|
|
for dz in -reach..=reach {
|
|
let start = (cz + dz - z0) as usize * width as usize + (cx - x0) as usize;
|
|
let row = &mut cells[start - reach as usize..=start + reach as usize];
|
|
let flat = dz.abs().max(r);
|
|
let centre = reach as usize;
|
|
lower(
|
|
&mut row[centre - flat as usize..=centre + flat as usize],
|
|
(flat - r) as i8,
|
|
);
|
|
for d in flat + 1..=reach {
|
|
let v = (d - r) as i8;
|
|
lower(&mut row[centre - d as usize..=centre - d as usize], v);
|
|
lower(&mut row[centre + d as usize..=centre + d as usize], v);
|
|
}
|
|
}
|
|
}
|
|
Some(Self {
|
|
x0,
|
|
z0,
|
|
width,
|
|
depth,
|
|
cells,
|
|
})
|
|
}
|
|
fn within(&self, x: i32, z: i32, margin: i32) -> bool {
|
|
let (dx, dz) = (
|
|
x.wrapping_sub(self.x0) as u32,
|
|
z.wrapping_sub(self.z0) as u32,
|
|
);
|
|
dx < self.width
|
|
&& dz < self.depth
|
|
&& i32::from(self.cells[(dz * self.width + dx) as usize]) <= margin
|
|
}
|
|
}
|
|
impl Ranges<'_> {
|
|
/// `distance`, or the band's nearer priority close to it.
|
|
fn priority(&self, x: i32, z: i32) -> i64 {
|
|
let own = self.distance(x, z);
|
|
match self.band {
|
|
Some(band) if band.within(x, z, 4) => own.min(band.priority(x, z)),
|
|
_ => own,
|
|
}
|
|
}
|
|
fn distance(&self, x: i32, z: i32) -> i64 {
|
|
let d = |c: (i32, i32)| {
|
|
let (dx, dz) = (i64::from(x - c.0), i64::from(z - c.1));
|
|
dx * dx + dz * dz
|
|
};
|
|
if let Some(cover) = self.cover {
|
|
return d(self.center).min(cover.nearest_sq(x, z));
|
|
}
|
|
self.extra
|
|
.iter()
|
|
.map(|(c, _)| d(*c))
|
|
.fold(d(self.center), i64::min)
|
|
}
|
|
|
|
/// How far past the view distance chunks are generated, and how far around
|
|
/// a finished chunk (`near_finished`): decorated chunks need their
|
|
/// neighbours generated too.
|
|
fn margins(decorates: bool) -> (i32, i32) {
|
|
if decorates { (3, 2) } else { (1, 1) }
|
|
}
|
|
/// Chunks generation asks for: loaded from the save, or generated as protos.
|
|
fn requested(&self, x: i32, z: i32, decorates: bool) -> bool {
|
|
let (margin, reach) = Self::margins(decorates);
|
|
self.in_range(x, z, margin) || self.near_finished(x, z, reach)
|
|
}
|
|
/// Finished columns kept when they arrive: within two of the view distance
|
|
/// or next to a finished chunk, and every column generation asked for (a
|
|
/// saved one loads finished; dropping it had it asked for and loaded again
|
|
/// every frame).
|
|
fn chunk_kept(&self, x: i32, z: i32, decorates: bool) -> bool {
|
|
self.in_range(x, z, 2) || self.near_finished(x, z, 1) || self.requested(x, z, decorates)
|
|
}
|
|
/// Columns unloaded: past every column kept.
|
|
fn unloaded(&self, x: i32, z: i32, decorates: bool) -> bool {
|
|
!self.in_range(x, z, 3) && !self.near_finished(x, z, Self::margins(decorates).1)
|
|
}
|
|
/// Whether a chunk finished here (`in_range(_, _, 1)`) lies within `reach`
|
|
/// chunks of (x, z) on both axes: its decoration needs its 3x3 neighbours
|
|
/// decorated, which need their own 3x3 neighbours generated. The ranges are
|
|
/// circles, so a fixed margin alone misses the corners of those squares
|
|
/// (render distance 2 never finished the chunks next to the player).
|
|
fn near_finished(&self, x: i32, z: i32, reach: i32) -> bool {
|
|
let gap = |d: i32| i64::from((d.abs() - reach).max(0));
|
|
let (dx, dz) = (gap(x - self.center.0), gap(z - self.center.1));
|
|
let r = i64::from(self.radius + 1);
|
|
// The extra areas are squares: their finished square grown by `reach`.
|
|
(self.radius >= 0 && dx * dx + dz * dz <= r * r)
|
|
|| self.in_extra(x, z, 1 + reach)
|
|
|| self.in_band(x, z, 1 + reach)
|
|
}
|
|
fn in_range(&self, x: i32, z: i32, margin: i32) -> bool {
|
|
self.in_main_range(x, z, margin)
|
|
|| self.in_extra(x, z, margin)
|
|
|| self.in_band(x, z, margin)
|
|
}
|
|
/// Within `margin` of the pregeneration band.
|
|
fn in_band(&self, x: i32, z: i32, margin: i32) -> bool {
|
|
self.band.is_some_and(|b| b.within(x, z, margin))
|
|
}
|
|
/// Within `margin` of an extra area's square (Chebyshev distance, as
|
|
/// vanilla's ticket levels spread).
|
|
fn in_extra(&self, x: i32, z: i32, margin: i32) -> bool {
|
|
if let Some(cover) = self.cover
|
|
&& (0..=ExtraCover::REACH).contains(&margin)
|
|
{
|
|
return cover.within(x, z, margin);
|
|
}
|
|
self.extra
|
|
.iter()
|
|
.any(|&(c, radius)| (x - c.0).abs().max((z - c.1).abs()) <= radius + margin)
|
|
}
|
|
/// Whether a `/forceload` or a native mod's ticket holds the chunk.
|
|
fn is_forced(&self, x: i32, z: i32) -> bool {
|
|
self.forced.contains(&(x, z)) || self.mod_forced.contains(&(x, z))
|
|
}
|
|
/// Every chunk a `/forceload` or a native mod's ticket holds, once each.
|
|
fn forced_chunks(&self) -> impl Iterator<Item = &(i32, i32)> + '_ {
|
|
self.forced
|
|
.iter()
|
|
.chain(self.mod_forced.iter().filter(|c| !self.forced.contains(c)))
|
|
}
|
|
/// The columns meshed for drawing: those vanilla draws around the
|
|
/// player (`in_view_distance`), and forced ones.
|
|
fn in_view(&self, x: i32, z: i32) -> bool {
|
|
in_view_distance(self.center, self.radius, x, z) || self.is_forced(x, z)
|
|
}
|
|
fn in_main_range(&self, x: i32, z: i32, margin: i32) -> bool {
|
|
let r = i64::from(self.radius + margin);
|
|
let (dx, dz) = (i64::from(x - self.center.0), i64::from(z - self.center.1));
|
|
if self.radius >= 0 && dx * dx + dz * dz <= r * r {
|
|
return true;
|
|
}
|
|
// A forced chunk holds a FORCED ticket at level 31
|
|
// (`ChunkMap.FORCED_TICKET_LEVEL`), and the level rises one per ring
|
|
// (`ChunkTracker.computeLevelFromNeighbor`): the margin is the
|
|
// vanilla status ring the caller asks for.
|
|
let reach = match margin {
|
|
1 => FORCED_FULL_RING,
|
|
2 => FORCED_FEATURES_RING,
|
|
3 => FORCED_TERRAIN_RING,
|
|
_ => margin + 1,
|
|
};
|
|
self.forced_chunks()
|
|
.any(|f| (x - f.0).abs() <= reach && (z - f.1).abs() <= reach)
|
|
}
|
|
}
|
|
impl Drop for Streaming {
|
|
fn drop(&mut self) {
|
|
self.abort_generation();
|
|
// Saves in flight hold proto chunks nothing else would save.
|
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
|
|
while self.saving > 0 {
|
|
let left = deadline.saturating_duration_since(std::time::Instant::now());
|
|
match self.shared.done.recv_timeout(left) {
|
|
Some(Done::Saved(_)) => self.saving -= 1,
|
|
Some(_) => {}
|
|
None => break,
|
|
}
|
|
}
|
|
// `jobs` leaves the pool after this, once its running jobs are
|
|
// done; the jobs still queued go with the queue.
|
|
}
|
|
}
|
|
/// The queued jobs' priorities computed again from each one's column
|
|
/// `distance` (`Streaming::submit`).
|
|
fn rank_again(queue: &mut BinaryHeap<Queued>, distance: impl Fn(i32, i32) -> i64) {
|
|
if queue.is_empty() {
|
|
return;
|
|
}
|
|
let mut jobs = std::mem::take(queue).into_vec();
|
|
for job in &mut jobs {
|
|
if let Some((x, z)) = job.at {
|
|
job.priority = distance(x, z) * 4 + job.bias;
|
|
}
|
|
}
|
|
*queue = BinaryHeap::from(jobs);
|
|
}
|
|
/// `ChunkTaskPriorityQueue.release(pos, clearQueue=true)`: the jobs queued for the
|
|
/// `gone` columns are dropped, so a view that moved or shrank does not wait behind the
|
|
/// generation of the chunks it left. A job without a column (a save, meshes to free)
|
|
/// stays, as vanilla keeps the tasks of a chunk that is still loaded. Returns how many
|
|
/// jobs went.
|
|
fn release_columns(queue: &mut BinaryHeap<Queued>, gone: &FxHashSet<(i32, i32)>) -> usize {
|
|
if queue.is_empty() || gone.is_empty() {
|
|
return 0;
|
|
}
|
|
let queued = std::mem::take(queue).into_vec();
|
|
let before = queued.len();
|
|
let kept: Vec<Queued> = queued
|
|
.into_iter()
|
|
.filter(|job| !job.at.is_some_and(|at| gone.contains(&at)))
|
|
.collect();
|
|
let released = before - kept.len();
|
|
*queue = BinaryHeap::from(kept);
|
|
released
|
|
}
|
|
/// Whether the 3x3 a light job for (x, z) reads is all there: every
|
|
/// neighbour a finished column, or a decorated proto chunk past the area
|
|
/// (the last FULL ring lights against neighbours at FEATURES, as
|
|
/// `Streaming::light_region` reads them).
|
|
fn light_region_ready(
|
|
world: &World,
|
|
in_range: impl Fn(i32, i32) -> bool,
|
|
decorated: &FxHashSet<(i32, i32)>,
|
|
protos: &FxHashMap<(i32, i32), Option<Proto>>,
|
|
x: i32,
|
|
z: i32,
|
|
) -> bool {
|
|
for dz in 0..3 {
|
|
for dx in 0..3 {
|
|
let n = (x + dx - 1, z + dz - 1);
|
|
if world.chunks.contains_key(&n)
|
|
|| (!in_range(n.0, n.1)
|
|
&& decorated.contains(&n)
|
|
&& matches!(protos.get(&n), Some(Some(_))))
|
|
{
|
|
continue;
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
true
|
|
}
|
|
/// The nearest `room` of the `candidates`, ordered: the light and mesh
|
|
/// stages submit bounded work, chosen from everything that is ready each
|
|
/// `update`, so what runs is always the nearest of what is left — chosen
|
|
/// again, from the current centre, as capacity frees, the way vanilla's
|
|
/// chunk task queues follow the current ticket levels. The selection is
|
|
/// O(n); the sort orders only what is submitted.
|
|
fn nearest_first<T: Ord>(candidates: &mut Vec<T>, room: usize) {
|
|
if candidates.len() > room {
|
|
candidates.select_nth_unstable(room);
|
|
candidates.truncate(room);
|
|
}
|
|
candidates.sort_unstable();
|
|
}
|
|
/// What `keep_unchanged_sections` found changed.
|
|
#[derive(Debug, Default, PartialEq)]
|
|
struct Resent {
|
|
/// Any section's blocks changed (in a way light or the mesher sees).
|
|
blocks: bool,
|
|
/// The sections whose blocks did not change but whose biomes did.
|
|
biomes: Vec<usize>,
|
|
/// The sections whose blocks did not change but whose light the packet changes.
|
|
light: Vec<usize>,
|
|
}
|
|
/// `new`, a resident column `old` sent again, takes `old`'s sections whose
|
|
/// blocks it holds unchanged, with `new`'s biomes and light; the sections whose blocks
|
|
/// changed keep `old`'s light (until lit again) and their cells' classes go in
|
|
/// `cells`.
|
|
fn keep_unchanged_sections(
|
|
old: &Chunk,
|
|
new: &mut Chunk,
|
|
cells: &mut crate::light::LightChanges,
|
|
) -> Resent {
|
|
let (mut a, mut b) = (Box::new([0u32; 4096]), Box::new([0u32; 4096]));
|
|
let mut out = Resent::default();
|
|
let (x, z) = (new.x, new.z);
|
|
for s in 0..new.sections.len().min(old.sections.len()) {
|
|
// (sections stored alike hold the same blocks: most of a column sent again, found without unpacking)
|
|
let same = old.sections[s].blocks.same_storage(&new.sections[s].blocks) || {
|
|
old.sections[s].unpack(&mut a);
|
|
new.sections[s].unpack(&mut b);
|
|
let classes = crate::light::LightChanges::classes_differing(&a, &b);
|
|
if classes != 0 {
|
|
cells.add((x, s as i32, z), classes);
|
|
}
|
|
classes == 0
|
|
};
|
|
if !same {
|
|
out.blocks = true;
|
|
continue;
|
|
}
|
|
let biomes = new.sections[s].biomes.clone();
|
|
let changed = !same_values(&old.sections[s].biomes, &biomes);
|
|
let light = (!same_light(&old.sections[s].light, &new.sections[s].light))
|
|
.then(|| new.sections[s].light.clone());
|
|
new.sections[s] = old.sections[s].clone();
|
|
if changed {
|
|
Arc::make_mut(&mut new.sections[s]).biomes = biomes;
|
|
out.biomes.push(s);
|
|
}
|
|
if let Some(light) = light {
|
|
Arc::make_mut(&mut new.sections[s]).light = light;
|
|
out.light.push(s);
|
|
}
|
|
}
|
|
out
|
|
}
|
|
/// Takes the server's new `chunk` over the resident column `old`: the sections
|
|
/// whose blocks did not change keep `old`'s (with `chunk`'s biomes and light),
|
|
/// and the sections whose blocks changed remesh. `relight` says the packet's
|
|
/// light is from before the server's relight (a Horizon server's bulk edit), so
|
|
/// the column and its neighbours are lit here; another server's packet already
|
|
/// carries the light vanilla applies, so no light job is queued for it.
|
|
fn take_resent_column(
|
|
world: &mut World,
|
|
key: (i32, i32),
|
|
old: &Chunk,
|
|
mut chunk: Chunk,
|
|
relight: bool,
|
|
) {
|
|
// The cells whose blocks changed, by section: they remesh with the neighbours within a block of them (as
|
|
// vanilla's `setBlocksDirty`), not every section round each section changed (which, for the few blocks a
|
|
// section often changes in a bulk edit, is mostly sections that did not).
|
|
let mut cells = crate::light::LightChanges::default();
|
|
cells.keyed(world.layout());
|
|
let kept = keep_unchanged_sections(old, &mut chunk, &mut cells);
|
|
chunk.light_loaded = old.light_loaded;
|
|
chunk.lit = true;
|
|
world.chunks.insert(key, Arc::new(chunk));
|
|
world.revision = world.revision.wrapping_add(1);
|
|
// A section with new biomes and the same blocks remeshes now (its tint), with its neighbours, whose tint
|
|
// blends across the border: above and below too, as a block's tint can sample the biome quart of the
|
|
// section next to it (`BiomeManager.getBiome`'s zoom reaches a quart beyond the block).
|
|
Streaming::light_updated(world, key.0, key.1, &kept.biomes);
|
|
// A section with the same blocks and new light takes the packet's light (vanilla's
|
|
// `handleLevelChunkWithLight` always applies it) and remeshes as a light update would.
|
|
Streaming::light_updated(world, key.0, key.1, &kept.light);
|
|
if !kept.blocks {
|
|
return;
|
|
}
|
|
if relight {
|
|
for dz in -1..=1 {
|
|
for dx in -1..=1 {
|
|
world.dirty_light.insert((key.0 + dx, key.1 + dz));
|
|
}
|
|
}
|
|
}
|
|
cells.dirty_sections(|section| {
|
|
world.dirty_sections.insert(section);
|
|
});
|
|
}
|
|
|
|
/// Whether two sections' packed light (4,096 cells) holds the same values.
|
|
fn same_light(a: &crate::chunk::Light, b: &crate::chunk::Light) -> bool {
|
|
a.same_as(b)
|
|
}
|
|
/// Whether two biome containers (64 cells) hold the same values.
|
|
fn same_values(
|
|
a: &horizon_core::paletted::PalettedContainer,
|
|
b: &horizon_core::paletted::PalettedContainer,
|
|
) -> bool {
|
|
if a.same_storage(b) {
|
|
return true;
|
|
}
|
|
let (mut x, mut y) = ([0u32; 64], [0u32; 64]);
|
|
a.unpack(&mut x);
|
|
b.unpack(&mut y);
|
|
x == y
|
|
}
|
|
|
|
/// The most changed blocks one frame relights itself (`Streaming::relight_edits`): a 16^3
|
|
/// section's worth. Past it, the columns are lit again whole on the workers.
|
|
const INLINE_RELIGHT_MAX: usize = 4096;
|
|
|
|
/// Splits edits at `positions` into the columns to light again whole and the
|
|
/// edits to relight in place. An edited column is lit whole with its
|
|
/// neighbours (light reaches 15 blocks, never past the next column) when all
|
|
/// nine are `ready` to be lit whole; otherwise its edits stay in place.
|
|
fn whole_relight_columns(
|
|
positions: Vec<(i32, i32, i32)>,
|
|
ready: impl Fn(i32, i32) -> bool,
|
|
) -> (FxHashSet<(i32, i32)>, Vec<(i32, i32, i32)>) {
|
|
let around =
|
|
|(x, z): (i32, i32)| (-1..=1).flat_map(move |dz| (-1..=1).map(move |dx| (x + dx, z + dz)));
|
|
let touched: FxHashSet<(i32, i32)> = positions
|
|
.iter()
|
|
.map(|&(x, _, z)| (x >> 4, z >> 4))
|
|
.collect();
|
|
let whole: FxHashSet<(i32, i32)> = touched
|
|
.into_iter()
|
|
.filter(|&column| around(column).all(|(x, z)| ready(x, z)))
|
|
.collect();
|
|
let columns = whole.iter().flat_map(|&column| around(column)).collect();
|
|
let inline = positions
|
|
.into_iter()
|
|
.filter(|&(x, _, z)| !whole.contains(&(x >> 4, z >> 4)))
|
|
.collect();
|
|
(columns, inline)
|
|
}
|
|
|
|
/// Drops the pending sections (`meshing`, `compile_started`, `waiting`) of
|
|
/// the `gone` columns: one pass over each store when that is cheaper than a
|
|
/// lookup per section of every column (a far teleport at render distance 32
|
|
/// forgets ~3,700 columns, 1.7 ms of lookups), else by key.
|
|
fn forget_pending_sections(
|
|
meshing: &mut FxHashMap<(i32, i32, i32), u64>,
|
|
compile_started: &mut FxHashMap<(i32, i32, i32), u64>,
|
|
waiting: &mut FxHashSet<(i32, i32, i32)>,
|
|
gone: &FxHashSet<(i32, i32)>,
|
|
sections: i32,
|
|
) {
|
|
let lookups = gone.len().saturating_mul(sections.max(0) as usize);
|
|
if lookups > meshing.len() + compile_started.len() + waiting.len() {
|
|
let kept = |&(x, _, z): &(i32, i32, i32)| !gone.contains(&(x, z));
|
|
meshing.retain(|k, _| kept(k));
|
|
compile_started.retain(|k, _| kept(k));
|
|
waiting.retain(kept);
|
|
} else {
|
|
for &(x, z) in gone {
|
|
for sy in 0..sections {
|
|
meshing.remove(&(x, sy, z));
|
|
compile_started.remove(&(x, sy, z));
|
|
waiting.remove(&(x, sy, z));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// No chunk held by a native mod's ticket, for the tests' ranges.
|
|
#[cfg(test)]
|
|
static NO_CHUNKS: std::sync::LazyLock<HashSet<(i32, i32)>> = std::sync::LazyLock::new(HashSet::new);
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn a_resent_column_keeps_its_sections_but_takes_new_biomes() {
|
|
let mut old = Chunk::empty(2, -3);
|
|
old.set(1, 10, 1, 1);
|
|
old.set(1, 40, 1, 1);
|
|
old.set_biome_cell(0, 0, 0, 1);
|
|
let mut new = old.clone();
|
|
// a new biome under the same blocks (section 0), and a block changed (section 6)
|
|
new.set_biome_cell(0, 0, 0, 3);
|
|
new.set(1, 40, 1, 2);
|
|
let mut cells = crate::light::LightChanges::default();
|
|
cells.keyed(crate::chunk::ColumnLayout::VANILLA);
|
|
let kept = keep_unchanged_sections(&old, &mut new, &mut cells);
|
|
assert_eq!(
|
|
kept,
|
|
Resent {
|
|
blocks: true,
|
|
biomes: vec![0],
|
|
light: vec![],
|
|
}
|
|
);
|
|
assert_eq!(new.biome(0, -64, 0), 3);
|
|
assert_eq!(new.block(1, -54, 1).0, 0);
|
|
assert_eq!(new.block(1, 40, 1).0, 2);
|
|
// the sections alike are shared with the old column
|
|
assert!(Arc::ptr_eq(&new.sections[1], &old.sections[1]));
|
|
assert!(!Arc::ptr_eq(&new.sections[0], &old.sections[0]));
|
|
// nothing changed: nothing to do
|
|
let mut same = old.clone();
|
|
let kept = keep_unchanged_sections(&old, &mut same, &mut cells);
|
|
assert_eq!(kept, Resent::default());
|
|
assert!(Arc::ptr_eq(&same.sections[0], &old.sections[0]));
|
|
// the same blocks under new light: the packet's light, as vanilla applies it
|
|
let mut lit = old.clone();
|
|
Arc::make_mut(&mut lit.sections[2]).light = crate::chunk::Light::Uniform(0xF0);
|
|
Arc::make_mut(&mut old.sections[2]).light = crate::chunk::Light::Uniform(0x50);
|
|
let kept = keep_unchanged_sections(&old, &mut lit, &mut cells);
|
|
assert_eq!(
|
|
kept,
|
|
Resent {
|
|
light: vec![2],
|
|
..Resent::default()
|
|
}
|
|
);
|
|
assert_eq!(lit.sections[2].light.get(0), 0xF0);
|
|
assert!(Arc::ptr_eq(&lit.sections[1], &old.sections[1]));
|
|
}
|
|
|
|
#[test]
|
|
fn mesh_pool_reuses_capacity_with_clean_geometry_state() {
|
|
let mut mesh = SectionMesh::default();
|
|
mesh.solid.resize(64, bytemuck::Zeroable::zeroed());
|
|
mesh.translucent.resize(16, bytemuck::Zeroable::zeroed());
|
|
mesh.block_entities.push(([1, 2, 3], 7));
|
|
mesh.lights.push(([1, 2, 3], 7));
|
|
mesh.visibility = 0;
|
|
mesh.sides = crate::mesher::Sides::unsorted(16);
|
|
let capacities = mesh.capacity_bytes();
|
|
let pointers = (mesh.solid.as_ptr(), mesh.translucent.as_ptr());
|
|
let mut pool = MeshPool::default();
|
|
assert!(pool.put(mesh).is_none());
|
|
assert_eq!(pool.bytes, capacities);
|
|
let reused = pool.take();
|
|
assert_eq!(reused.capacity_bytes(), capacities);
|
|
assert_eq!(
|
|
pointers,
|
|
(reused.solid.as_ptr(), reused.translucent.as_ptr())
|
|
);
|
|
assert!(reused.solid.is_empty());
|
|
assert!(reused.translucent.is_empty());
|
|
assert!(reused.block_entities.is_empty());
|
|
assert!(reused.lights.is_empty());
|
|
assert_eq!(reused.visibility, crate::mesher::ALL_VISIBLE);
|
|
assert_eq!(reused.sides, crate::mesher::Sides::default());
|
|
assert_eq!(pool.bytes, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn mesh_pool_caps_retained_count_and_bytes() {
|
|
let mut pool = MeshPool::default();
|
|
for _ in 0..MeshPool::COUNT {
|
|
let mut mesh = SectionMesh::default();
|
|
mesh.solid.reserve_exact(1);
|
|
assert!(pool.put(mesh).is_none());
|
|
}
|
|
let mut extra = SectionMesh::default();
|
|
extra.solid.reserve_exact(1);
|
|
assert!(pool.put(extra).is_some());
|
|
for _ in 0..MeshPool::COUNT {
|
|
pool.take();
|
|
}
|
|
assert_eq!(pool.bytes, 0);
|
|
for i in 0..2 {
|
|
let mut mesh = SectionMesh::default();
|
|
mesh.solid.reserve_exact(
|
|
MeshPool::BYTES / std::mem::size_of::<crate::mesher::TerrainVertex>() / 2 + 1,
|
|
);
|
|
assert_eq!(pool.put(mesh).is_some(), i == 1);
|
|
assert!(pool.bytes <= MeshPool::BYTES);
|
|
assert_eq!(pool.meshes.len(), 1);
|
|
}
|
|
pool.take();
|
|
assert_eq!(pool.bytes, 0);
|
|
let mut huge = SectionMesh::default();
|
|
huge.solid.reserve_exact(
|
|
MeshPool::BYTES / std::mem::size_of::<crate::mesher::TerrainVertex>() + 1,
|
|
);
|
|
assert!(pool.put(huge).is_some());
|
|
assert!(pool.meshes.is_empty());
|
|
assert_eq!(pool.bytes, 0);
|
|
}
|
|
|
|
/// The frame never waits for a worker: while one holds the mailbox, the
|
|
/// frame takes nothing and finds the results, in order, the next time.
|
|
#[test]
|
|
fn the_frame_takes_finished_jobs_without_waiting() {
|
|
let mailbox = Mailbox::default();
|
|
let lit = |seq| Done::Lit(0, 0, Vec::new(), seq);
|
|
mailbox.send(lit(1));
|
|
mailbox.send(lit(2));
|
|
let sending = mailbox.done.lock().unwrap();
|
|
assert!(mailbox.take().is_empty());
|
|
assert!(mailbox.try_recv().is_none());
|
|
drop(sending);
|
|
let seqs: Vec<u64> = mailbox
|
|
.take()
|
|
.into_iter()
|
|
.map(|done| match done {
|
|
Done::Lit(_, _, _, seq) => seq,
|
|
_ => unreachable!(),
|
|
})
|
|
.collect();
|
|
assert_eq!(seqs, [1, 2]);
|
|
assert!(mailbox.take().is_empty());
|
|
let waiting = std::time::Instant::now();
|
|
assert!(
|
|
mailbox
|
|
.recv_timeout(std::time::Duration::from_millis(10))
|
|
.is_none()
|
|
);
|
|
assert!(waiting.elapsed() >= std::time::Duration::from_millis(10));
|
|
}
|
|
|
|
/// Whether the forgotten columns are few (looked up by key) or many
|
|
/// (one pass over each store), exactly their pending sections go.
|
|
#[test]
|
|
fn forgotten_columns_lose_their_pending_sections_either_way() {
|
|
let pending: Vec<(i32, i32, i32)> = (0..4)
|
|
.flat_map(|x| (0..3).map(move |sy| (x, sy, -x)))
|
|
.collect();
|
|
for gone in [
|
|
vec![(1, -1)],
|
|
(0..400)
|
|
.map(|x| (x, -x))
|
|
.filter(|&c| c != (2, -2))
|
|
.collect(),
|
|
] {
|
|
let gone: FxHashSet<(i32, i32)> = gone.into_iter().collect();
|
|
let mut meshing: FxHashMap<_, u64> = pending.iter().map(|&k| (k, 1)).collect();
|
|
let mut started = meshing.clone();
|
|
let mut waiting: FxHashSet<_> = pending.iter().copied().collect();
|
|
forget_pending_sections(&mut meshing, &mut started, &mut waiting, &gone, 3);
|
|
let mut left: Vec<_> = waiting.into_iter().collect();
|
|
left.sort();
|
|
let expected: Vec<_> = pending
|
|
.iter()
|
|
.copied()
|
|
.filter(|&(x, _, z)| !gone.contains(&(x, z)))
|
|
.collect();
|
|
assert_eq!(left, expected, "{} gone", gone.len());
|
|
let mut left: Vec<_> = meshing.into_keys().collect();
|
|
left.sort();
|
|
assert_eq!(left, expected);
|
|
let mut left: Vec<_> = started.into_keys().collect();
|
|
left.sort();
|
|
assert_eq!(left, expected);
|
|
}
|
|
}
|
|
|
|
/// Edits in two neighbouring columns light those and the ten around
|
|
/// them again; a column next to one that can't be lit whole keeps its
|
|
/// edits in the frame.
|
|
#[test]
|
|
fn whole_relights_cover_the_edited_columns_and_their_neighbours() {
|
|
// Blocks in columns (0, 0) and (1, 0).
|
|
let positions = vec![(3, 70, 4), (15, 64, 15), (16, 80, 0), (31, 64, 9)];
|
|
let (columns, inline) = whole_relight_columns(positions.clone(), |_, _| true);
|
|
let mut columns: Vec<_> = columns.into_iter().collect();
|
|
columns.sort();
|
|
let expected: Vec<(i32, i32)> = (-1..=2)
|
|
.flat_map(|x| (-1..=1).map(move |z| (x, z)))
|
|
.collect();
|
|
assert_eq!(columns, expected);
|
|
assert!(inline.is_empty());
|
|
// (2, 1) is next to (1, 0) only: (0, 0) is still lit whole.
|
|
let (columns, inline) = whole_relight_columns(positions, |x, z| (x, z) != (2, 1));
|
|
assert_eq!(inline, vec![(16, 80, 0), (31, 64, 9)]);
|
|
assert_eq!(columns.len(), 9);
|
|
assert!(columns.contains(&(-1, -1)) && columns.contains(&(1, 1)));
|
|
// Negative coordinates floor to their column.
|
|
let (west, _) = whole_relight_columns(vec![(-1, 64, -17)], |_, _| true);
|
|
assert!(west.contains(&(-2, -3)) && west.contains(&(0, -1)) && west.len() == 9);
|
|
}
|
|
|
|
#[test]
|
|
fn queued_jobs_follow_the_centre() {
|
|
let forced = HashSet::new();
|
|
let ranges = |center| Ranges {
|
|
center,
|
|
radius: 32,
|
|
extra: &[],
|
|
forced: &forced,
|
|
mod_forced: &NO_CHUNKS,
|
|
cover: None,
|
|
band: None,
|
|
};
|
|
let generate = |x, z, priority, order| Queued {
|
|
priority,
|
|
at: Some((x, z)),
|
|
bias: 0,
|
|
order,
|
|
job: Job::Generate(x, z),
|
|
};
|
|
// Both queued around an earlier centre, where the farther job came first.
|
|
let earlier = ranges((40, 0));
|
|
let current = ranges((0, 0));
|
|
let queued = || {
|
|
BinaryHeap::from(vec![
|
|
generate(3, 1, earlier.priority(3, 1) * 4, 1),
|
|
generate(20, 0, earlier.priority(20, 0) * 4, 2),
|
|
Queued {
|
|
priority: i64::MIN,
|
|
at: None,
|
|
bias: 0,
|
|
order: 3,
|
|
job: Job::Save(Vec::new()),
|
|
},
|
|
])
|
|
};
|
|
let pops = |mut queue: BinaryHeap<Queued>| {
|
|
std::iter::from_fn(move || queue.pop())
|
|
.map(|q| q.at)
|
|
.collect::<Vec<_>>()
|
|
};
|
|
assert_eq!(
|
|
pops(queued()),
|
|
[None, Some((20, 0)), Some((3, 1))],
|
|
"the stale order"
|
|
);
|
|
let mut queue = queued();
|
|
rank_again(&mut queue, |x, z| current.priority(x, z));
|
|
assert_eq!(pops(queue), [None, Some((3, 1)), Some((20, 0))]);
|
|
// The player moved: the job near the new centre goes first.
|
|
let mut queue = BinaryHeap::from(vec![generate(3, 1, 0, 1), generate(20, 0, 1, 2)]);
|
|
let moved = ranges((19, 0));
|
|
rank_again(&mut queue, |x, z| moved.priority(x, z));
|
|
assert_eq!(queue.pop().and_then(|q| q.at), Some((20, 0)));
|
|
}
|
|
|
|
/// `ChunkTaskPriorityQueue.release`: a column that unloaded keeps no queued job, so
|
|
/// a teleport or a smaller render distance does not spend its workers on the world
|
|
/// it left; the other columns' jobs and the jobs without a column (saves, meshes to
|
|
/// free) stay, in the same order.
|
|
#[test]
|
|
fn a_released_column_keeps_no_queued_job() {
|
|
let generate = |x, z, priority, order| Queued {
|
|
priority,
|
|
at: Some((x, z)),
|
|
bias: 0,
|
|
order,
|
|
job: Job::Generate(x, z),
|
|
};
|
|
let save = |order| Queued {
|
|
priority: i64::MIN,
|
|
at: None,
|
|
bias: 0,
|
|
order,
|
|
job: Job::Save(Vec::new()),
|
|
};
|
|
let queued = || {
|
|
BinaryHeap::from(vec![
|
|
generate(0, 0, 0, 1),
|
|
generate(0, 0, 4, 2),
|
|
generate(1, 0, 8, 3),
|
|
generate(9, 9, 12, 4),
|
|
save(5),
|
|
])
|
|
};
|
|
let pops = |mut queue: BinaryHeap<Queued>| {
|
|
std::iter::from_fn(move || queue.pop())
|
|
.map(|q| q.at)
|
|
.collect::<Vec<_>>()
|
|
};
|
|
// Nothing goes for a column that is still loaded, or for nothing at all.
|
|
let mut queue = queued();
|
|
assert_eq!(release_columns(&mut queue, &FxHashSet::default()), 0);
|
|
assert_eq!(
|
|
release_columns(&mut queue, &[(20, 20)].into_iter().collect()),
|
|
0
|
|
);
|
|
assert_eq!(queue.len(), 5);
|
|
// Both of (0, 0)'s jobs go, its neighbour's and the save stay.
|
|
let gone: FxHashSet<(i32, i32)> = [(0, 0)].into_iter().collect();
|
|
assert_eq!(release_columns(&mut queue, &gone), 2);
|
|
assert_eq!(pops(queue), [None, Some((1, 0)), Some((9, 9))]);
|
|
// Every column leaving at once: only the jobs without one are left.
|
|
let mut queue = queued();
|
|
let all: FxHashSet<(i32, i32)> = [(0, 0), (1, 0), (9, 9)].into_iter().collect();
|
|
assert_eq!(release_columns(&mut queue, &all), 4);
|
|
assert_eq!(pops(queue), [None]);
|
|
}
|
|
|
|
/// The light and mesh stages submit bounded work, the nearest first:
|
|
/// what exceeds the room stays dirty, to be chosen again — from the
|
|
/// current centre — as capacity frees. Submitted whole, the queue once
|
|
/// held every ready column's jobs and grew with the render distance
|
|
/// (#3629); ties break by position, so the choice is deterministic.
|
|
#[test]
|
|
fn bounded_submission_takes_the_nearest_first() {
|
|
let candidates: Vec<(i64, i32, i32, u32)> = (0..40i32)
|
|
.map(|i| {
|
|
let (x, z) = (i / 8 - 2, i % 8 - 3);
|
|
(
|
|
i64::from((x - 3).pow(2) + (z - 2).pow(2)),
|
|
x,
|
|
z,
|
|
1 << (i % 24),
|
|
)
|
|
})
|
|
.collect();
|
|
let mut expected = candidates.clone();
|
|
expected.sort_unstable();
|
|
let mut none = candidates.clone();
|
|
nearest_first(&mut none, 0);
|
|
assert!(none.is_empty());
|
|
let mut nearest = candidates;
|
|
nearest_first(&mut nearest, 5);
|
|
assert_eq!(nearest, expected[..5]);
|
|
let mut past = expected.clone();
|
|
nearest_first(&mut past, expected.len() + 10);
|
|
assert_eq!(past, expected);
|
|
}
|
|
|
|
/// What the scheduler counts as ready to light: every neighbour a
|
|
/// finished column, or a decorated proto chunk past the area — the
|
|
/// walk `light_region` makes without its snapshot.
|
|
#[test]
|
|
fn light_readiness_follows_the_region_a_light_job_reads() {
|
|
let mut world = World::new(horizon_world::registry::BlockStateId(0), 0);
|
|
let chunk = Arc::new(Chunk::empty(0, 0));
|
|
let nine = (-1..=1).flat_map(|dz| (-1..=1).map(move |dx| (dx, dz)));
|
|
for key in nine {
|
|
world.chunks.insert(key, chunk.clone());
|
|
}
|
|
// The kept area is the cross around (0, 0); the corners of the
|
|
// 3x3 light reads lie past it.
|
|
let in_range = |x: i32, z: i32| x.abs() + z.abs() <= 1;
|
|
let none = FxHashSet::<(i32, i32)>::default();
|
|
let empty = FxHashMap::<(i32, i32), Option<Proto>>::default();
|
|
assert!(light_region_ready(&world, in_range, &none, &empty, 0, 0));
|
|
// A corner is missing, and nothing stands in for it yet.
|
|
world.chunks.remove(&(1, 1));
|
|
assert!(!light_region_ready(&world, in_range, &none, &empty, 0, 0));
|
|
// A decorated, checked-in proto chunk past the area lights against.
|
|
let decorated: FxHashSet<(i32, i32)> = [(1, 1)].into_iter().collect();
|
|
let mut protos: FxHashMap<(i32, i32), Option<Proto>> = Default::default();
|
|
protos.insert((1, 1), Some(Box::new(()) as Proto));
|
|
assert!(light_region_ready(
|
|
&world, in_range, &decorated, &protos, 0, 0
|
|
));
|
|
// The proto is checked out to a job: it cannot be read.
|
|
protos.insert((1, 1), None);
|
|
assert!(!light_region_ready(
|
|
&world, in_range, &decorated, &protos, 0, 0
|
|
));
|
|
// A neighbour inside the area waits for the chunk itself.
|
|
world.chunks.remove(&(1, 0));
|
|
let mut inside = decorated.clone();
|
|
inside.insert((1, 0));
|
|
protos.insert((1, 0), Some(Box::new(()) as Proto));
|
|
assert!(!light_region_ready(
|
|
&world, in_range, &inside, &protos, 0, 0
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn view_distance_is_vanillas_shape() {
|
|
// ChunkTrackingView.isInViewDistance at render distance 12: a column
|
|
// of slack on each axis, so 533 columns rather than the 441 of a
|
|
// circle of radius 12.
|
|
let count = |r: i32| {
|
|
(-40..=40)
|
|
.flat_map(|x| (-40..=40).map(move |z| (x, z)))
|
|
.filter(|&(x, z)| in_view_distance((5, -7), r, x + 5, z - 7))
|
|
.count()
|
|
};
|
|
assert_eq!(count(12), 533);
|
|
assert_eq!(count(2), 25);
|
|
for (x, z, inside) in [(12, 0, true), (13, 0, false), (9, 9, true), (10, 9, false)] {
|
|
assert_eq!(in_view_distance((0, 0), 12, x, z), inside, "({x}, {z})");
|
|
assert_eq!(
|
|
in_view_distance((0, 0), 12, -z, -x),
|
|
inside,
|
|
"({}, {})",
|
|
-z,
|
|
-x
|
|
);
|
|
}
|
|
}
|
|
|
|
/// A view has arrived once the columns at both ends of each of its rows and
|
|
/// columns are in: exactly those with a neighbour outside it, at every radius.
|
|
/// Then it stays arrived until it changes; a view is `recent`, and waited for,
|
|
/// `VIEW_WAIT` at most, and one of no radius not at all.
|
|
#[test]
|
|
fn a_view_has_arrived_once_its_outermost_columns_are_in() {
|
|
let start = std::time::Instant::now();
|
|
let center = (3, -4);
|
|
for r in 0..=16 {
|
|
let view: FxHashSet<(i32, i32)> = (-r - 1..=r + 1)
|
|
.flat_map(|x| (-r - 1..=r + 1).map(move |z| (center.0 + x, center.1 + z)))
|
|
.filter(|&(x, z)| in_view_distance(center, r, x, z))
|
|
.collect();
|
|
let arriving = |has: &dyn Fn((i32, i32)) -> bool| {
|
|
ViewArrival::default().arriving((center, r), start, has)
|
|
};
|
|
assert!(!arriving(&|c| view.contains(&c)), "radius {r}");
|
|
for &(x, z) in &view {
|
|
let edge = [(x + 1, z), (x - 1, z), (x, z + 1), (x, z - 1)]
|
|
.iter()
|
|
.any(|c| !view.contains(c));
|
|
let missing = arriving(&|c| c != (x, z) && view.contains(&c));
|
|
assert_eq!(missing, edge, "radius {r}, column ({x}, {z})");
|
|
}
|
|
}
|
|
let mut arrival = ViewArrival::default();
|
|
let (none, all) = (|_| false, |_| true);
|
|
assert!(arrival.arriving(((0, 0), 2), start, none));
|
|
assert!(!arrival.arriving(((0, 0), 2), start, all));
|
|
assert!(!arrival.arriving(((0, 0), 2), start, none));
|
|
// Moved: waited for again, until `VIEW_WAIT` after the move.
|
|
let (late, ms) = (start + VIEW_WAIT, std::time::Duration::from_millis(1));
|
|
assert!(arrival.arriving(((1, 0), 2), start, none));
|
|
assert!(arrival.arriving(((1, 0), 2), late - ms, none));
|
|
assert!(arrival.recent(late - ms) && !arrival.recent(late));
|
|
assert!(!arrival.arriving(((1, 0), 2), late, none));
|
|
assert!(!arrival.arriving(((1, 0), -1), start, none));
|
|
}
|
|
|
|
struct GenerationFixture {
|
|
world: World,
|
|
center: (i32, i32),
|
|
radius: i32,
|
|
extra: Vec<((i32, i32), i32)>,
|
|
forced: HashSet<(i32, i32)>,
|
|
generating: FxHashSet<(i32, i32)>,
|
|
protos: FxHashMap<(i32, i32), Option<Proto>>,
|
|
finishing: FxHashSet<(i32, i32)>,
|
|
decorates: bool,
|
|
remote: bool,
|
|
}
|
|
impl GenerationFixture {
|
|
fn new(radius: i32, decorates: bool) -> Self {
|
|
Self {
|
|
world: World::new(horizon_world::registry::BlockStateId(0), 0),
|
|
center: (0, 0),
|
|
radius,
|
|
extra: Vec::new(),
|
|
forced: HashSet::new(),
|
|
generating: Default::default(),
|
|
protos: Default::default(),
|
|
finishing: Default::default(),
|
|
decorates,
|
|
remote: false,
|
|
}
|
|
}
|
|
fn input(&self) -> GenerationInputs<'_> {
|
|
GenerationInputs {
|
|
ranges: Ranges {
|
|
center: self.center,
|
|
radius: self.radius,
|
|
extra: &self.extra,
|
|
forced: &self.forced,
|
|
mod_forced: &NO_CHUNKS,
|
|
cover: None,
|
|
band: None,
|
|
},
|
|
world: &self.world,
|
|
generating: &self.generating,
|
|
protos: &self.protos,
|
|
finishing: &self.finishing,
|
|
decorates: self.decorates,
|
|
remote: self.remote,
|
|
}
|
|
}
|
|
fn fill(&mut self) {
|
|
// Scheduler only reads membership. Share one real empty column to
|
|
// keep a radius-32 fixture small; chunk content is never consulted.
|
|
let chunk = Arc::new(Chunk::empty(0, 0));
|
|
for (_, x, z) in baseline_candidates(self.input(), usize::MAX) {
|
|
self.world.chunks.insert((x, z), chunk.clone());
|
|
}
|
|
}
|
|
fn fill_steady(&mut self) {
|
|
let chunk = Arc::new(Chunk::empty(0, 0));
|
|
for (_, x, z) in baseline_candidates(self.input(), usize::MAX) {
|
|
if self.input().ranges.in_range(x, z, 1) {
|
|
self.world.chunks.insert((x, z), chunk.clone());
|
|
} else {
|
|
// Generation's outer neighbour margin remains proto;
|
|
// only columns in the finish range become resident.
|
|
self.protos.insert((x, z), Some(Box::new(())));
|
|
}
|
|
}
|
|
}
|
|
fn assert_same(&self, gate: &mut GenerationScan, capacity: usize) -> bool {
|
|
let expected = baseline_candidates(self.input(), capacity);
|
|
let result = gate.candidates(self.input(), capacity);
|
|
assert_eq!(result.as_deref().unwrap_or(&[]), expected);
|
|
result.is_some()
|
|
}
|
|
}
|
|
|
|
// Original scheduling scan, without snapshot bookkeeping, for differential
|
|
// correctness and timing. Keep this independent of the production gate.
|
|
fn baseline_candidates(input: GenerationInputs<'_>, capacity: usize) -> Vec<(i64, i32, i32)> {
|
|
if input.remote || input.generating.len() >= capacity {
|
|
return Vec::new();
|
|
}
|
|
let margin: i32 = if input.decorates { 3 } else { 1 };
|
|
let reach = if input.decorates { 2 } else { 1 };
|
|
let mut wanted = Vec::new();
|
|
let areas: Vec<_> = std::iter::once((input.ranges.center, input.ranges.radius))
|
|
.chain(input.ranges.extra.iter().copied())
|
|
.collect();
|
|
let missing = |x, z| {
|
|
!input.world.chunks.contains_key(&(x, z))
|
|
&& !input.generating.contains(&(x, z))
|
|
&& !input.protos.contains_key(&(x, z))
|
|
&& !input.finishing.contains(&(x, z))
|
|
};
|
|
for (center, radius) in areas {
|
|
let r = radius + margin.max(reach + 1);
|
|
for dz in -r..=r {
|
|
for dx in -r..=r {
|
|
let (x, z) = (center.0 + dx, center.1 + dz);
|
|
if (input.ranges.in_range(x, z, margin)
|
|
|| input.ranges.near_finished(x, z, reach))
|
|
&& missing(x, z)
|
|
{
|
|
wanted.push((input.ranges.distance(x, z), x, z));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let forced_reach = if input.decorates {
|
|
FORCED_TERRAIN_RING
|
|
} else {
|
|
FORCED_FEATURES_RING
|
|
};
|
|
for &(fx, fz) in input.ranges.forced_chunks() {
|
|
for dz in -forced_reach..=forced_reach {
|
|
for dx in -forced_reach..=forced_reach {
|
|
let (x, z) = (fx + dx, fz + dz);
|
|
if missing(x, z) {
|
|
wanted.push((input.ranges.distance(x, z), x, z));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
wanted.sort_unstable();
|
|
wanted.dedup();
|
|
wanted.truncate(capacity - input.generating.len());
|
|
wanted
|
|
}
|
|
|
|
#[test]
|
|
fn generation_gate_tracks_public_ranges_and_exact_membership() {
|
|
for decorates in [false, true] {
|
|
let mut f = GenerationFixture::new(2, decorates);
|
|
f.fill();
|
|
let mut gate = GenerationScan::default();
|
|
assert!(f.assert_same(&mut gate, 6));
|
|
assert!(!f.assert_same(&mut gate, 6));
|
|
// Light/mesh state and block content do not affect generation.
|
|
f.world.dirty_light.insert((0, 0));
|
|
f.world.dirty_sections.insert((0, 0, 0));
|
|
Arc::make_mut(f.world.chunks.get_mut(&(0, 0)).unwrap()).lit = true;
|
|
assert!(!f.assert_same(&mut gate, 6));
|
|
// Same-count removal/replacement must not leave a hole unnoticed.
|
|
let chunk = f.world.chunks.remove(&(0, 0)).unwrap();
|
|
f.world.chunks.insert((100, 100), chunk.clone());
|
|
assert!(f.assert_same(&mut gate, 6));
|
|
f.world.chunks.remove(&(100, 100));
|
|
f.world.chunks.insert((0, 0), chunk);
|
|
assert!(f.assert_same(&mut gate, 6));
|
|
assert!(!f.assert_same(&mut gate, 6));
|
|
f.center = (1, -1);
|
|
assert!(f.assert_same(&mut gate, 6));
|
|
f.fill();
|
|
f.assert_same(&mut gate, 6);
|
|
f.radius += 1;
|
|
assert!(f.assert_same(&mut gate, 6));
|
|
f.fill();
|
|
f.assert_same(&mut gate, 6);
|
|
f.extra.push(((20, -20), 2));
|
|
assert!(f.assert_same(&mut gate, 6));
|
|
f.fill();
|
|
f.assert_same(&mut gate, 6);
|
|
f.forced.insert((40, 40));
|
|
assert!(f.assert_same(&mut gate, 6));
|
|
f.fill();
|
|
f.assert_same(&mut gate, 6);
|
|
f.extra.clear();
|
|
f.forced.clear();
|
|
assert!(f.assert_same(&mut gate, 6));
|
|
f.remote = true;
|
|
assert!(!f.assert_same(&mut gate, 6));
|
|
f.world.chunks.clear();
|
|
f.remote = false;
|
|
assert!(f.assert_same(&mut gate, 6));
|
|
f.fill();
|
|
f.assert_same(&mut gate, 6);
|
|
f.world = World::new(horizon_world::registry::BlockStateId(0), 1);
|
|
assert!(f.assert_same(&mut gate, 6));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn generation_gate_preserves_batches_stage_transitions_and_retries() {
|
|
for decorates in [false, true] {
|
|
let mut f = GenerationFixture::new(2, decorates);
|
|
let mut gate = GenerationScan::default();
|
|
let mut scheduled = FxHashSet::default();
|
|
loop {
|
|
let expected = baseline_candidates(f.input(), 3);
|
|
let batch = gate.candidates(f.input(), 3).unwrap_or_default();
|
|
assert_eq!(batch, expected);
|
|
if batch.is_empty() {
|
|
break;
|
|
}
|
|
for (_, x, z) in batch {
|
|
assert!(scheduled.insert((x, z)), "duplicate scheduling");
|
|
f.generating.insert((x, z));
|
|
}
|
|
if f.generating.len() == 3 {
|
|
assert!(!f.assert_same(&mut gate, 3)); // capacity pressure
|
|
} else {
|
|
f.assert_same(&mut gate, 3);
|
|
}
|
|
for key in std::mem::take(&mut f.generating) {
|
|
// load miss -> proto -> checked out decoration -> finish.
|
|
f.protos.insert(key, None);
|
|
}
|
|
gate.exhausted = false; // Done::Proto/Decorated invalidation
|
|
}
|
|
assert!(!scheduled.is_empty());
|
|
assert!(!f.assert_same(&mut gate, 3));
|
|
let key = (0, 0);
|
|
f.protos.remove(&key);
|
|
f.finishing.insert(key);
|
|
assert!(!f.assert_same(&mut gate, 3)); // eligibility unchanged
|
|
f.finishing.remove(&key);
|
|
f.world.chunks.insert(key, Arc::new(Chunk::empty(0, 0)));
|
|
gate.exhausted = false; // Done::Generated
|
|
assert!(f.assert_same(&mut gate, 3));
|
|
assert!(!f.assert_same(&mut gate, 3));
|
|
// A discarded/pruned proto must be requested again, even without
|
|
// any range or loaded-membership change.
|
|
let key = scheduled.iter().copied().find(|k| *k != (0, 0)).unwrap();
|
|
f.protos.remove(&key);
|
|
gate.exhausted = false;
|
|
assert!(f.assert_same(&mut gate, 3));
|
|
assert_eq!(baseline_candidates(f.input(), 3).len(), 1);
|
|
}
|
|
}
|
|
|
|
/// Batches taken from the plan are the full scan's, as jobs finish,
|
|
/// columns and proto chunks leave and the centre moves.
|
|
#[test]
|
|
fn generation_plan_matches_full_scans_as_columns_arrive() {
|
|
for decorates in [false, true] {
|
|
let mut f = GenerationFixture::new(6, decorates);
|
|
let mut gate = GenerationScan::default();
|
|
let chunk = Arc::new(Chunk::empty(0, 0));
|
|
let mut seed = 0x2545_F491_4F6C_DD1Du64;
|
|
let mut rand = |n: u64| {
|
|
seed ^= seed << 13;
|
|
seed ^= seed >> 7;
|
|
seed ^= seed << 17;
|
|
seed % n
|
|
};
|
|
let mut scheduled = 0;
|
|
for step in 0..600 {
|
|
let capacity = 3 + rand(6) as usize;
|
|
let expected = baseline_candidates(f.input(), capacity);
|
|
let batch = gate.candidates(f.input(), capacity).unwrap_or_default();
|
|
assert_eq!(batch, expected, "step {step}");
|
|
for (_, x, z) in batch {
|
|
f.generating.insert((x, z));
|
|
scheduled += 1;
|
|
}
|
|
// Some jobs finish: as a proto chunk, or a final column.
|
|
let mut done: Vec<_> = f.generating.iter().copied().collect();
|
|
done.sort_unstable();
|
|
for key in done {
|
|
if rand(3) != 0 {
|
|
continue;
|
|
}
|
|
f.generating.remove(&key);
|
|
if rand(2) == 0 {
|
|
f.protos.insert(key, None);
|
|
} else {
|
|
f.world.chunks.insert(key, chunk.clone());
|
|
}
|
|
gate.exhausted = false;
|
|
}
|
|
// Now and then a column or a proto chunk leaves.
|
|
if rand(20) == 0 {
|
|
let mut keys: Vec<_> = f.world.chunks.keys().copied().collect();
|
|
keys.sort_unstable();
|
|
if let Some(key) = keys.get(rand(keys.len().max(1) as u64) as usize) {
|
|
f.world.chunks.remove(key);
|
|
}
|
|
}
|
|
if rand(20) == 0 {
|
|
let mut keys: Vec<_> = f.protos.keys().copied().collect();
|
|
keys.sort_unstable();
|
|
if let Some(key) = keys.get(rand(keys.len().max(1) as u64) as usize) {
|
|
f.protos.remove(key);
|
|
gate.invalidate();
|
|
}
|
|
}
|
|
if step == 300 {
|
|
f.center = (3, -2);
|
|
}
|
|
}
|
|
assert!(scheduled > 200);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn generation_scan_looks_at_overlapping_areas_once() {
|
|
// A server's players: many areas around one spot, some apart, one
|
|
// touching another only at a corner, and part of the land present.
|
|
for decorates in [false, true] {
|
|
let mut f = GenerationFixture::new(10, decorates);
|
|
for i in 0..40 {
|
|
f.extra.push(((i % 7 - 3, i / 7 - 3), 19));
|
|
}
|
|
f.extra.push(((200, -150), 19));
|
|
f.extra.push(((243, -107), 19));
|
|
f.extra.push(((-60, 5), 4));
|
|
f.forced.insert((90, 90));
|
|
let chunk = Arc::new(Chunk::empty(0, 0));
|
|
for x in -40i32..40 {
|
|
for z in -40i32..40 {
|
|
match (x * 7 + z * 13).rem_euclid(5) {
|
|
0 => drop(f.world.chunks.insert((x, z), chunk.clone())),
|
|
1 => drop(f.protos.insert((x, z), None)),
|
|
2 if x % 3 == 0 => drop(f.generating.insert((x, z))),
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
assert!(baseline_candidates(f.input(), usize::MAX).len() > 1000);
|
|
for capacity in [usize::MAX, f.generating.len() + 7] {
|
|
let mut gate = GenerationScan::default();
|
|
assert!(f.assert_same(&mut gate, capacity));
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "reproducible actual generation scheduler benchmark; run with --ignored --nocapture"]
|
|
fn generation_scan_benchmark() {
|
|
use std::{hint::black_box, time::Instant};
|
|
let mut f = GenerationFixture::new(32, true);
|
|
f.fill_steady();
|
|
let mut gate = GenerationScan::default();
|
|
f.assert_same(&mut gate, 12);
|
|
assert!(!f.assert_same(&mut gate, 12));
|
|
const FRAMES: usize = 2000;
|
|
let measure = |optimized: bool, dynamic: bool| {
|
|
let mut samples = Vec::new();
|
|
let mut scans = 0;
|
|
let mut jobs = 0;
|
|
for repetition in 0..9 {
|
|
let mut fixture = GenerationFixture::new(32, true);
|
|
fixture.fill_steady();
|
|
let saved = fixture.world.chunks.get(&(0, 0)).unwrap().clone();
|
|
let mut gate = GenerationScan::default();
|
|
gate.candidates(fixture.input(), 12);
|
|
let start = Instant::now();
|
|
let mut count = 0;
|
|
let mut scheduled = 0;
|
|
for frame in 0..FRAMES {
|
|
// Remove a column, queue generation, then accept a saved
|
|
// load completion. Both schedulers see identical events.
|
|
if dynamic && frame % 32 == 0 {
|
|
fixture.world.chunks.remove(&(0, 0));
|
|
}
|
|
if dynamic && frame % 32 == 1 {
|
|
fixture.generating.remove(&(0, 0));
|
|
fixture.world.chunks.insert((0, 0), saved.clone());
|
|
gate.exhausted = false; // Done::Generated
|
|
}
|
|
let result = if optimized {
|
|
let result = gate.candidates(black_box(fixture.input()), 12);
|
|
count += usize::from(result.is_some());
|
|
result.unwrap_or_default()
|
|
} else {
|
|
count += 1;
|
|
baseline_candidates(black_box(fixture.input()), 12)
|
|
};
|
|
let expected_jobs = usize::from(dynamic && frame % 32 == 0);
|
|
assert_eq!(result.len(), expected_jobs);
|
|
for (_, x, z) in black_box(result) {
|
|
assert_eq!((x, z), (0, 0));
|
|
fixture.generating.insert((x, z));
|
|
scheduled += 1;
|
|
}
|
|
}
|
|
if repetition != 0 {
|
|
samples.push(start.elapsed().as_secs_f64() * 1e9 / FRAMES as f64);
|
|
}
|
|
assert!(fixture.generating.is_empty());
|
|
assert_eq!(fixture.world.chunks.len(), f.world.chunks.len());
|
|
scans = count;
|
|
jobs = scheduled;
|
|
}
|
|
samples.sort_by(f64::total_cmp);
|
|
(samples[samples.len() / 2], scans, jobs)
|
|
};
|
|
println!(
|
|
"radius=32 decorates=true positions/frame=5041 loaded_keys={} proto_keys={} frames/sample={FRAMES} warmup=1 measured=8",
|
|
f.world.chunks.len(),
|
|
f.protos.len()
|
|
);
|
|
for dynamic in [false, true] {
|
|
let (before, baseline_scans, before_jobs) = measure(false, dynamic);
|
|
let (after, optimized_scans, after_jobs) = measure(true, dynamic);
|
|
assert_eq!(before_jobs, after_jobs);
|
|
println!(
|
|
"dynamic={dynamic}: baseline={before:.1}ns/frame optimized={after:.1}ns/frame speedup={:.2}x scans={baseline_scans}/{optimized_scans} jobs={before_jobs}/{after_jobs}",
|
|
before / after
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The ranges `Streaming::update` checks at each step, from generation to
|
|
/// light, must let every chunk the player spawns on finish and light: the
|
|
/// 3x3 columns around the player need their own 3x3 neighbours finished,
|
|
/// each of those its 3x3 neighbours decorated, and each of those its 3x3
|
|
/// neighbours generated and kept. Render distance 2 once stopped short.
|
|
#[test]
|
|
fn spawn_neighbourhood_completes_at_every_distance() {
|
|
let forced = HashSet::new();
|
|
for decorates in [true, false] {
|
|
let (margin, reach) = Ranges::margins(decorates);
|
|
for radius in 2..=32 {
|
|
let r = Ranges {
|
|
center: (5, -7),
|
|
radius,
|
|
extra: &[],
|
|
forced: &forced,
|
|
mod_forced: &NO_CHUNKS,
|
|
cover: None,
|
|
band: None,
|
|
};
|
|
let square = |x: i32, z: i32, n: i32| {
|
|
(-n..=n).flat_map(move |dz| (-n..=n).map(move |dx| (x + dx, z + dz)))
|
|
};
|
|
let generated = |x, z| {
|
|
r.requested(x, z, decorates)
|
|
&& (x - r.center.0).abs() <= radius + margin.max(reach + 1)
|
|
&& (z - r.center.1).abs() <= radius + margin.max(reach + 1)
|
|
};
|
|
let proto_kept = |x, z| r.in_range(x, z, 4) || r.near_finished(x, z, 2);
|
|
let decorated =
|
|
|x, z| !decorates || r.in_range(x, z, 2) || r.near_finished(x, z, 1);
|
|
let chunk_kept = |x, z| r.chunk_kept(x, z, decorates);
|
|
let unloaded = |x, z| r.unloaded(x, z, decorates);
|
|
let finishes = |x: i32, z: i32| {
|
|
r.in_range(x, z, 1)
|
|
&& chunk_kept(x, z)
|
|
&& !unloaded(x, z)
|
|
&& square(x, z, 1).all(|(nx, nz)| {
|
|
decorated(nx, nz)
|
|
&& (!decorates
|
|
|| square(nx, nz, 1)
|
|
.all(|(gx, gz)| generated(gx, gz) && proto_kept(gx, gz)))
|
|
&& generated(nx, nz)
|
|
&& proto_kept(nx, nz)
|
|
})
|
|
};
|
|
let (cx, cz) = r.center;
|
|
for (x, z) in square(cx, cz, 1) {
|
|
for (nx, nz) in square(x, z, 1) {
|
|
assert!(
|
|
finishes(nx, nz),
|
|
"radius {radius} (decorates: {decorates}): ({nx}, {nz}) never finishes"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A column generation asks for is kept when it arrives finished (loaded
|
|
/// from the save) and is not unloaded right after: either way it would be
|
|
/// asked for and loaded again on the next frame, forever (8 such columns
|
|
/// after a teleport at render distance 16 held 8 of the in-flight slots).
|
|
#[test]
|
|
fn requested_columns_stay_loaded() {
|
|
let forced = HashSet::new();
|
|
for decorates in [true, false] {
|
|
for radius in 2..=32 {
|
|
let r = Ranges {
|
|
center: (5, -7),
|
|
radius,
|
|
extra: &[((radius * 2 + 9, 3), 4)],
|
|
forced: &forced,
|
|
mod_forced: &NO_CHUNKS,
|
|
cover: None,
|
|
band: None,
|
|
};
|
|
let span = radius * 3 + 20;
|
|
for x in -span..=span {
|
|
for z in -span..=span {
|
|
if r.requested(x, z, decorates) {
|
|
assert!(
|
|
r.chunk_kept(x, z, decorates),
|
|
"radius {radius} (decorates: {decorates}): ({x}, {z}) is asked for but dropped"
|
|
);
|
|
}
|
|
if r.chunk_kept(x, z, decorates) {
|
|
assert!(
|
|
!r.unloaded(x, z, decorates),
|
|
"radius {radius} (decorates: {decorates}): ({x}, {z}) is kept then unloaded"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// An idle dedicated server keeps no area around its centre: only a spawn
|
|
/// search's chunk (FULL alone: its 3x3 decorated, 5x5 generated) and the
|
|
/// forced chunks.
|
|
#[test]
|
|
fn no_area_around_an_idle_servers_centre() {
|
|
let forced = HashSet::from([(30, 30)]);
|
|
let extra = [((5, -3), -1)];
|
|
let r = Ranges {
|
|
center: (5, -3),
|
|
radius: -1,
|
|
extra: &extra,
|
|
forced: &forced,
|
|
mod_forced: &NO_CHUNKS,
|
|
cover: None,
|
|
band: None,
|
|
};
|
|
for x in -5i32..=15 {
|
|
for z in -13i32..=7 {
|
|
let d = (x - 5).abs().max((z + 3).abs());
|
|
assert_eq!(r.in_range(x, z, 1), d == 0, "({x}, {z})");
|
|
assert_eq!(r.chunk_kept(x, z, true), d <= 2, "({x}, {z})");
|
|
assert_eq!(r.requested(x, z, true), d <= 2, "({x}, {z})");
|
|
}
|
|
}
|
|
assert!(r.in_range(30, 30, 1));
|
|
let idle = Ranges { extra: &[], ..r };
|
|
assert!(!idle.requested(5, -3, true) && idle.unloaded(5, -3, true));
|
|
}
|
|
|
|
/// A server player's area (`lan_update_streaming`: the server view
|
|
/// distance + 1) finishes vanilla's FULL square: PLAYER_LOADING tickets at
|
|
/// level 31 out to the view distance, FULL (33) two chunks past it. Every
|
|
/// chunk the player tracks has its 3x3 neighbourhood finished, so it can
|
|
/// be lit and sent, and generation reaches vanilla's FEATURES (34) and
|
|
/// TERRAIN (35) rings.
|
|
#[test]
|
|
fn server_player_areas_finish_vanillas_full_square() {
|
|
let forced = HashSet::new();
|
|
for view in 2..=32 {
|
|
let extra = [((40, -9), view + 1)];
|
|
let r = Ranges {
|
|
// The window's own area, far away.
|
|
center: (-400, 300),
|
|
radius: 2,
|
|
extra: &extra,
|
|
forced: &forced,
|
|
mod_forced: &NO_CHUNKS,
|
|
cover: None,
|
|
band: None,
|
|
};
|
|
let level = |x: i32, z: i32| 31 + ((x - 40).abs().max((z + 9).abs()) - view).max(0);
|
|
for x in 40 - view - 8..=40 + view + 8 {
|
|
for z in -9 - view - 8..=-9 + view + 8 {
|
|
assert_eq!(
|
|
r.in_range(x, z, 1),
|
|
level(x, z) <= 33,
|
|
"view {view}: ({x}, {z})"
|
|
);
|
|
assert_eq!(
|
|
r.requested(x, z, true),
|
|
level(x, z) <= 35,
|
|
"view {view}: ({x}, {z})"
|
|
);
|
|
assert_eq!(
|
|
r.unloaded(x, z, true),
|
|
level(x, z) > 35,
|
|
"view {view}: ({x}, {z})"
|
|
);
|
|
// ChunkTrackingView.contains, at any view distance the player may ask for.
|
|
let tracked = |v: i64| {
|
|
let gap = |d: i32| i64::from(((d).abs() - 2).max(0));
|
|
gap(x - 40).pow(2) + gap(z + 9).pow(2) < v * v
|
|
};
|
|
if tracked(i64::from(view)) {
|
|
for (dx, dz) in (-1..=1).flat_map(|dx| (-1..=1).map(move |dz| (dx, dz))) {
|
|
assert!(
|
|
r.in_range(x + dx, z + dz, 1),
|
|
"view {view}: ({x}, {z}) tracked, not lit"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A `/forceload`ed chunk holds a FORCED ticket at level 31
|
|
/// (`ChunkMap.FORCED_TICKET_LEVEL`): the level rises one per ring
|
|
/// (`ChunkTracker.computeLevelFromNeighbor`) and picks the column's
|
|
/// status (`ChunkLevel.generationStatus`). So vanilla finishes the two
|
|
/// rings around it (33), gives the third its features (34) and the
|
|
/// fourth its terrain (35), and the rings past that are left alone.
|
|
#[test]
|
|
fn forced_chunks_reach_vanillas_rings() {
|
|
let forced: HashSet<(i32, i32)> = [(40, -40)].into();
|
|
let r = Ranges {
|
|
// The player's own area, far away.
|
|
center: (-400, 300),
|
|
radius: 2,
|
|
extra: &[],
|
|
forced: &forced,
|
|
mod_forced: &NO_CHUNKS,
|
|
cover: None,
|
|
band: None,
|
|
};
|
|
let level = |x: i32, z: i32| 31 + (x - 40).abs().max((z + 40).abs());
|
|
for x in 40 - 6..=40 + 6 {
|
|
for z in -40 - 6..=-40 + 6 {
|
|
// The full ring: the column becomes a level chunk.
|
|
assert_eq!(r.in_range(x, z, 1), level(x, z) <= 33, "({x}, {z})");
|
|
// The features ring, and the terrain ring.
|
|
assert_eq!(r.in_range(x, z, 2), level(x, z) <= 34, "({x}, {z})");
|
|
assert_eq!(r.in_range(x, z, 3), level(x, z) <= 35, "({x}, {z})");
|
|
assert_eq!(r.requested(x, z, true), level(x, z) <= 35, "({x}, {z})");
|
|
assert_eq!(r.chunk_kept(x, z, true), level(x, z) <= 35, "({x}, {z})");
|
|
assert_eq!(r.unloaded(x, z, true), level(x, z) > 35, "({x}, {z})");
|
|
}
|
|
}
|
|
// Generation reaches the terrain ring: the features ring is
|
|
// decorated from it.
|
|
let mut fixture = GenerationFixture::new(2, true);
|
|
fixture.center = (-400, 300);
|
|
fixture.forced = forced;
|
|
let candidates = GenerationScan::default()
|
|
.candidates(fixture.input(), usize::MAX)
|
|
.expect("a scan");
|
|
for (dx, dz) in [(-4, 0), (4, 0), (0, -4), (0, 4), (3, 3)] {
|
|
assert!(
|
|
candidates
|
|
.iter()
|
|
.any(|&(_, x, z)| (x, z) == (40 + dx, -40 + dz)),
|
|
"the terrain ring at ({}, {}) is not generated",
|
|
40 + dx,
|
|
-40 + dz
|
|
);
|
|
}
|
|
}
|
|
|
|
fn resident_column(world: &mut World, light: u8) -> Chunk {
|
|
let mut chunk = Chunk::empty(2, -3);
|
|
chunk.set(1, 10, 1, 1);
|
|
Arc::make_mut(&mut chunk.sections[0]).light = crate::chunk::Light::Uniform(light);
|
|
chunk.lit = true;
|
|
world.chunks.insert((2, -3), Arc::new(chunk.clone()));
|
|
chunk
|
|
}
|
|
|
|
/// A server's resent column uses the light in its packet, as vanilla's
|
|
/// `handleLevelChunkWithLight` does: only a Horizon server's bulk edit,
|
|
/// whose packet light is from before its relight, is relit here. A proxy's
|
|
/// next server resends its whole world at the same coordinates, and
|
|
/// relighting every column and its neighbours is work that world never
|
|
/// asked for (#10273).
|
|
#[test]
|
|
fn a_resent_column_is_relit_only_for_a_horizon_servers_bulk_edit() {
|
|
for relight in [false, true] {
|
|
let mut world = World::new(horizon_world::registry::BlockStateId(0), 0);
|
|
let old = resident_column(&mut world, 0x50);
|
|
let mut new = old.clone();
|
|
new.set(1, 10, 1, 2);
|
|
Arc::make_mut(&mut new.sections[0]).light = crate::chunk::Light::Uniform(0xF0);
|
|
take_resent_column(&mut world, (2, -3), &old, new, relight);
|
|
let chunk = &world.chunks[&(2, -3)];
|
|
assert_eq!(chunk.block(1, 10, 1).0, 2);
|
|
// The packet's light is what the column shows.
|
|
assert_eq!(chunk.sections[0].light.get(0), 0xF0);
|
|
// The changed section remeshes either way.
|
|
assert!(world.dirty_sections.contains(&(2, 0, -3)));
|
|
// Only a Horizon bulk edit queues light work, for the column and
|
|
// its eight neighbours.
|
|
assert_eq!(world.dirty_light.len(), if relight { 9 } else { 0 });
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod cover_tests {
|
|
use super::*;
|
|
|
|
fn areas(seed: &mut u64, n: usize, spread: i32) -> Vec<((i32, i32), i32)> {
|
|
let mut next = |m: i32| {
|
|
*seed ^= *seed << 13;
|
|
*seed ^= *seed >> 7;
|
|
*seed ^= *seed << 17;
|
|
(*seed % (2 * m as u64 + 1)) as i32 - m
|
|
};
|
|
(0..n)
|
|
.map(|_| ((next(spread), next(spread)), 2 + next(14).abs()))
|
|
.collect()
|
|
}
|
|
|
|
/// Every question streaming asks gets the same answer with the grid.
|
|
///
|
|
/// A debug build checks every fourth cell of each row, shifted by row and case so the cases still cover every
|
|
/// cell's position relative to the areas: the full check took 20 of the ~30 s of the crate's tests.
|
|
/// `cargo test --release -p horizon-client --lib the_grid_answers_as_the_scan` checks every cell.
|
|
#[test]
|
|
fn the_grid_answers_as_the_scan() {
|
|
let stride = if cfg!(debug_assertions) { 4 } else { 1 };
|
|
let mut seed = 0x9E37_79B9_7F4A_7C15u64;
|
|
let forced: HashSet<(i32, i32)> = [(40, -40)].into();
|
|
for case in 0..60 {
|
|
let extra = areas(&mut seed, 2 + case % 20, [4, 30, 120][case % 3]);
|
|
let cover = ExtraCover::new(&extra).expect("a grid");
|
|
let ranges = |cover| Ranges {
|
|
center: (3, -2),
|
|
radius: 10,
|
|
extra: &extra,
|
|
forced: &forced,
|
|
mod_forced: &NO_CHUNKS,
|
|
cover,
|
|
band: None,
|
|
};
|
|
let (scan, grid) = (ranges(None), ranges(Some(&cover)));
|
|
let span = [4, 30, 120][case % 3] + 40;
|
|
for z in -span..=span {
|
|
let first = -span + (z + case as i32).rem_euclid(stride);
|
|
for x in (first..=span).step_by(stride as usize) {
|
|
for m in 0..=ExtraCover::REACH + 2 {
|
|
assert_eq!(
|
|
scan.in_extra(x, z, m),
|
|
grid.in_extra(x, z, m),
|
|
"case {case} ({x}, {z}) {m}"
|
|
);
|
|
}
|
|
for d in [false, true] {
|
|
assert_eq!(scan.unloaded(x, z, d), grid.unloaded(x, z, d));
|
|
assert_eq!(scan.requested(x, z, d), grid.requested(x, z, d));
|
|
assert_eq!(scan.chunk_kept(x, z, d), grid.chunk_kept(x, z, d));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Players thousands of blocks apart: no grid fits them, and the buckets
|
|
/// answer what a scan of every area does, the nearest centre included.
|
|
#[test]
|
|
fn the_buckets_answer_as_the_scan() {
|
|
let mut seed = 0x51_7CC1_B727_220Au64;
|
|
let forced: HashSet<(i32, i32)> = HashSet::new();
|
|
for case in 0..12 {
|
|
// Spread over 6000 columns (96 000 blocks), some side by side.
|
|
let mut extra = areas(&mut seed, 2 + case * 9, 3000);
|
|
extra.push((extra[0].0, 10));
|
|
extra.push(((extra[1].0.0 + 5, extra[1].0.1 - 70), 4));
|
|
let cover = ExtraCover::new(&extra).expect("buckets");
|
|
let ranges = |cover| Ranges {
|
|
center: (3, -2),
|
|
radius: 10,
|
|
extra: &extra,
|
|
forced: &forced,
|
|
mod_forced: &NO_CHUNKS,
|
|
cover,
|
|
band: None,
|
|
};
|
|
let (scan, buckets) = (ranges(None), ranges(Some(&cover)));
|
|
// Around each area, and on a coarse lattice over the whole spread.
|
|
let near = extra.iter().flat_map(|&((cx, cz), _)| {
|
|
(-80..=80)
|
|
.step_by(7)
|
|
.flat_map(move |dz| (-80..=80).step_by(7).map(move |dx| (cx + dx, cz + dz)))
|
|
});
|
|
let lattice = (-3200..=3200)
|
|
.step_by(97)
|
|
.flat_map(|z| (-3200..=3200).step_by(89).map(move |x| (x, z)));
|
|
for (x, z) in near.chain(lattice) {
|
|
assert_eq!(
|
|
scan.distance(x, z),
|
|
buckets.distance(x, z),
|
|
"case {case} ({x}, {z})"
|
|
);
|
|
for m in 0..=ExtraCover::REACH {
|
|
assert_eq!(
|
|
scan.in_extra(x, z, m),
|
|
buckets.in_extra(x, z, m),
|
|
"case {case} ({x}, {z}) {m}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_stale_or_impossible_grid_is_not_used() {
|
|
let mut cover = None;
|
|
ExtraCover::refresh(&mut cover, &[((0, 0), 11), ((5, 5), 11)]);
|
|
assert!(ExtraCover::matching(&cover, &[((0, 0), 11), ((5, 5), 11)]).is_some());
|
|
assert!(ExtraCover::matching(&cover, &[((0, 0), 11), ((5, 6), 11)]).is_none());
|
|
// Too far apart for a mebibyte of grid: buckets alone. One area alone: scans.
|
|
let far = ExtraCover::new(&[((0, 0), 11), ((100_000, 0), 11)]).expect("buckets");
|
|
assert!(far.grid.is_none());
|
|
assert!(ExtraCover::new(&[((0, 0), 11)]).is_none());
|
|
assert!(ExtraCover::new(&[((i32::MAX - 1, 0), 11), ((i32::MAX - 3, 0), 11)]).is_none());
|
|
assert!(ExtraCover::new(&[((0, i32::MAX - 1), 11), ((0, i32::MAX - 3), 11)]).is_none());
|
|
assert!(ExtraCover::new(&[((0, i32::MIN + 1), 11), ((0, i32::MIN + 3), 11)]).is_none());
|
|
}
|
|
|
|
/// cargo test --release -p horizon-client --lib cover_tests::cover_cost -- --ignored --nocapture
|
|
#[test]
|
|
#[ignore]
|
|
fn cover_cost() {
|
|
let forced = HashSet::new();
|
|
for players in [10usize, 25, 50] {
|
|
// The benchmark's bots: most near spawn, a fifth flying outwards.
|
|
let extra: Vec<_> = (0..players)
|
|
.map(|i| {
|
|
let a = i as f64 * 2.399;
|
|
let d = if i % 5 == 0 { 30.0 } else { 3.0 };
|
|
(((a.cos() * d) as i32, (a.sin() * d) as i32), 11)
|
|
})
|
|
.collect();
|
|
let cover = ExtraCover::new(&extra).unwrap();
|
|
let ranges = |cover| Ranges {
|
|
center: (0, 0),
|
|
radius: 10,
|
|
extra: &extra,
|
|
forced: &forced,
|
|
mod_forced: &NO_CHUNKS,
|
|
cover,
|
|
band: None,
|
|
};
|
|
// The columns streaming asks about: everything kept, and the
|
|
// generation scan's ring past it.
|
|
let columns: Vec<(i32, i32)> = (-60..=60)
|
|
.flat_map(|z| (-60..=60).map(move |x| (x, z)))
|
|
.filter(|&(x, z)| {
|
|
ranges(None).requested(x, z, true) || !ranges(None).unloaded(x, z, true)
|
|
})
|
|
.collect();
|
|
let rounds = 100;
|
|
let time = |r: &Ranges| {
|
|
let t = std::time::Instant::now();
|
|
let mut n = 0usize;
|
|
for _ in 0..rounds {
|
|
for &(x, z) in &columns {
|
|
n += usize::from(r.unloaded(x, z, true));
|
|
n += usize::from(r.requested(x, z, true));
|
|
n += usize::from(r.in_range(x, z, 1));
|
|
}
|
|
}
|
|
std::hint::black_box(n);
|
|
t.elapsed().as_secs_f64() * 1e3 / f64::from(rounds)
|
|
};
|
|
let t = std::time::Instant::now();
|
|
for _ in 0..rounds {
|
|
std::hint::black_box(ExtraCover::new(std::hint::black_box(&extra)));
|
|
}
|
|
let build = t.elapsed().as_secs_f64() * 1e3 / f64::from(rounds);
|
|
println!(
|
|
"{players} players, {} columns, 3 questions each: scan {:.3} ms, grid {:.3} ms (+ {build:.3} ms to build)",
|
|
columns.len(),
|
|
time(&ranges(None)),
|
|
time(&ranges(Some(&cover))),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod reload_tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn reload_marks_every_section_of_loaded_lit_columns() {
|
|
let mut world = World::new(horizon_world::registry::BlockStateId(0), 0);
|
|
world.chunks.insert((2, -3), Arc::new(Chunk::empty(2, -3)));
|
|
world.chunks.insert((4, 5), Arc::new(Chunk::empty(4, 5)));
|
|
let lit = FxHashSet::from_iter([(2, -3), (99, 99)]);
|
|
|
|
Streaming::mark_loaded_sections_dirty(&mut world, &lit);
|
|
|
|
let sections = world.layout().sections() as i32;
|
|
assert_eq!(world.dirty_sections.len(), sections as usize);
|
|
for sy in 0..sections {
|
|
assert!(world.dirty_sections.contains(&(2, sy, -3)));
|
|
assert!(!world.dirty_sections.contains(&(4, sy, 5)));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn reload_keeps_existing_work_and_ignores_unloaded_lit_columns() {
|
|
let mut world = World::new(horizon_world::registry::BlockStateId(0), 0);
|
|
world.chunks.insert((4, 5), Arc::new(Chunk::empty(4, 5)));
|
|
world.dirty_sections.insert((4, 3, 5));
|
|
let lit = FxHashSet::from_iter([(99, 99)]);
|
|
|
|
Streaming::mark_loaded_sections_dirty(&mut world, &lit);
|
|
|
|
assert_eq!(world.dirty_sections.len(), 1);
|
|
assert!(world.dirty_sections.contains(&(4, 3, 5)));
|
|
}
|
|
}
|