115 lines
4 KiB
Rust
115 lines
4 KiB
Rust
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
//! A bounded cache of regenerated chunk baselines, shared across the worker pool.
|
|
|
|
use std::num::NonZeroUsize;
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
use lru::LruCache;
|
|
use shared::generator::VoxelGenerator;
|
|
use shared::world::{Chunk, ChunkPos};
|
|
|
|
/// A least-recently-used cache of chunk baselines, cloneable so every worker shares one store.
|
|
#[derive(Clone)]
|
|
pub struct ChunkCache {
|
|
/// The shared LRU store.
|
|
inner: Arc<Mutex<LruCache<ChunkPos, Chunk>>>,
|
|
}
|
|
|
|
impl ChunkCache {
|
|
/// Creates an empty cache holding at most `capacity` baselines before evicting the least-recently-used entry.
|
|
#[must_use]
|
|
pub fn new(capacity: NonZeroUsize) -> Self {
|
|
Self {
|
|
inner: Arc::new(Mutex::new(LruCache::new(capacity))),
|
|
}
|
|
}
|
|
|
|
/// Returns the baseline for `pos`, generating and caching it on a miss.
|
|
#[must_use]
|
|
pub fn get_or_generate(&self, pos: ChunkPos, generator: &VoxelGenerator) -> Chunk {
|
|
// A poisoned lock cannot yield a usable cache; recovering the guard lets generation proceed rather than propagating a panic across every worker that shares this cache.
|
|
let mut guard = self
|
|
.inner
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
if let Some(hit) = guard.get(&pos) {
|
|
return hit.clone();
|
|
}
|
|
let chunk = generator.generate_chunk(pos);
|
|
guard.put(pos, chunk.clone());
|
|
chunk
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
impl ChunkCache {
|
|
/// Number of baselines currently resident. Test-only introspection.
|
|
fn len(&self) -> usize {
|
|
self.inner
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
.len()
|
|
}
|
|
|
|
/// Whether a baseline for `pos` is currently resident. Test-only introspection.
|
|
fn contains(&self, pos: ChunkPos) -> bool {
|
|
self.inner
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
.contains(&pos)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use shared::generator::{VoxelGenerator, WorldGenConfig};
|
|
use shared::world::{BlockId, ChunkPos};
|
|
use std::num::NonZeroUsize;
|
|
|
|
/// Builds a generator with a small, cheap terrain configuration for cache tests.
|
|
fn test_generator() -> VoxelGenerator {
|
|
let config = WorldGenConfig {
|
|
base_height: 8,
|
|
noise_scale: 0.05,
|
|
surface_block: BlockId(1),
|
|
subsurface_block: BlockId(2),
|
|
stone_block: BlockId(3),
|
|
};
|
|
VoxelGenerator::new(config, 42)
|
|
}
|
|
|
|
/// A second lookup of the same position must be served from the store, not regenerated.
|
|
#[test]
|
|
fn repeated_lookup_is_a_cache_hit() {
|
|
let generator = test_generator();
|
|
let cache = ChunkCache::new(NonZeroUsize::new(4).unwrap_or(NonZeroUsize::MIN));
|
|
let pos = ChunkPos::new(0, 0, 0);
|
|
|
|
let first = cache.get_or_generate(pos, &generator);
|
|
let second = cache.get_or_generate(pos, &generator);
|
|
|
|
// Determinism guarantees identical output, and a single resident entry can only hold if the second call was a hit rather than a fresh generation-and-insert of a distinct value.
|
|
assert_eq!(first.blocks, second.blocks);
|
|
assert_eq!(cache.len(), 1);
|
|
}
|
|
|
|
/// Inserting beyond capacity evicts the least-recently-used entry.
|
|
#[test]
|
|
fn exceeding_capacity_evicts_oldest() {
|
|
let generator = test_generator();
|
|
let cache = ChunkCache::new(NonZeroUsize::new(2).unwrap_or(NonZeroUsize::MIN));
|
|
|
|
let _ = cache.get_or_generate(ChunkPos::new(0, 0, 0), &generator);
|
|
let _ = cache.get_or_generate(ChunkPos::new(1, 0, 0), &generator);
|
|
// Touch the first so the second becomes the least-recently-used before the overflow.
|
|
let _ = cache.get_or_generate(ChunkPos::new(0, 0, 0), &generator);
|
|
let _ = cache.get_or_generate(ChunkPos::new(2, 0, 0), &generator);
|
|
|
|
assert_eq!(cache.len(), 2);
|
|
assert!(cache.contains(ChunkPos::new(0, 0, 0)));
|
|
assert!(!cache.contains(ChunkPos::new(1, 0, 0)));
|
|
}
|
|
}
|