Skip to main content

steel_worldgen/biomes/
biome_source.rs

1//! Biome source abstraction for dimension-agnostic biome generation.
2//!
3//! Mirrors vanilla's `BiomeSource` hierarchy:
4//! - `MultiNoiseBiomeSource` — Overworld and Nether (climate parameter matching via `RTree`)
5//! - `TheEndBiomeSource` — The End (spatial + erosion threshold)
6//!
7//! Each dimension creates a different `BiomeSourceKind` variant. The chunk generator
8//! calls `chunk_sampler()` per chunk to get a `ChunkBiomeSampler` that holds per-chunk
9//! caches (column cache, R-tree warm-start index).
10//!
11//! ## R-tree cache strategy
12//!
13//! Vanilla uses `ThreadLocal<Leaf>` which persists the warm-start index across chunks,
14//! making tie-breaking at equidistant biome boundaries depend on chunk generation order.
15//! We use a per-sampler cache instead: reset per chunk, deterministic regardless of
16//! generation order, and better L1 locality since the cache lives on the sampler struct
17//! alongside the column cache. The only cost is one cold start per chunk (1/1536 lookups).
18
19use rustc_hash::FxHashSet;
20use steel_registry::biome::BiomeRef;
21use steel_registry::vanilla_biomes;
22use steel_utils::random::Random as _;
23use steel_utils::random::legacy_random::LegacyRandom;
24use steel_utils::{BlockPos, Identifier};
25use steel_worldgen::density_functions::nether::NetherColumnCache;
26use steel_worldgen::density_functions::overworld::OverworldColumnCache;
27use steel_worldgen::multi_noise::{
28    NETHER_BIOME_PARAMETERS, OVERWORLD_BIOME_PARAMETERS, get_nether_biome_cached,
29    get_overworld_biome_cached,
30};
31
32use super::{NetherClimateSampler, OverworldClimateSampler};
33use steel_worldgen::noise::EndIslands;
34
35/// Dimension-specific biome source.
36///
37/// Each variant holds shared state (noise generators, parameter lists) and
38/// creates per-chunk samplers via [`chunk_sampler`](BiomeSourceKind::chunk_sampler).
39#[non_exhaustive]
40pub enum BiomeSourceKind {
41    /// Overworld biome source (multi-noise climate matching).
42    Overworld(OverworldBiomeSource),
43    /// Nether biome source (multi-noise temperature/vegetation matching).
44    Nether(NetherBiomeSource),
45    /// End biome source (spatial distance + erosion threshold).
46    ///
47    /// Boxed because `EndIslands` is ~2KB (simplex noise permutation table),
48    /// while the other variants are pointer-sized.
49    End(Box<EndBiomeSource>),
50}
51
52impl BiomeSourceKind {
53    /// Create an overworld biome source with the given world seed.
54    #[must_use]
55    pub fn overworld(seed: u64) -> Self {
56        Self::Overworld(OverworldBiomeSource::new(seed))
57    }
58
59    /// Create a nether biome source with the given world seed.
60    #[must_use]
61    pub fn nether(seed: u64) -> Self {
62        Self::Nether(NetherBiomeSource::new(seed))
63    }
64
65    /// Create an end biome source with the given world seed.
66    #[must_use]
67    pub fn end(seed: u64) -> Self {
68        Self::End(Box::new(EndBiomeSource::new(seed)))
69    }
70
71    /// Every biome this source can produce, preserving the source's parameter-list order.
72    ///
73    /// Feature sorting depends on biome iteration order because vanilla assigns global
74    /// feature order while walking all possible biomes. The set-returning
75    /// [`possible_biomes`](Self::possible_biomes) is kept for callers that only need
76    /// membership tests.
77    #[must_use]
78    pub fn possible_biome_refs(&self) -> Vec<BiomeRef> {
79        let biomes: Vec<BiomeRef> = match self {
80            Self::Overworld(_) => OVERWORLD_BIOME_PARAMETERS
81                .values()
82                .iter()
83                .map(|(_, biome)| *biome)
84                .collect(),
85            Self::Nether(_) => NETHER_BIOME_PARAMETERS
86                .values()
87                .iter()
88                .map(|(_, biome)| *biome)
89                .collect(),
90            Self::End(_) => vec![
91                &vanilla_biomes::THE_END,
92                &vanilla_biomes::END_HIGHLANDS,
93                &vanilla_biomes::END_MIDLANDS,
94                &vanilla_biomes::SMALL_END_ISLANDS,
95                &vanilla_biomes::END_BARRENS,
96            ],
97        };
98
99        distinct_biome_refs(biomes)
100    }
101
102    /// Every biome this source can produce. Used to filter structure sets whose
103    /// resolved `allowed_biomes` are from a different dimension.
104    #[must_use]
105    pub fn possible_biomes(&self) -> FxHashSet<Identifier> {
106        self.possible_biome_refs()
107            .into_iter()
108            .map(|biome| biome.key.clone())
109            .collect()
110    }
111
112    /// Create a per-chunk biome sampler.
113    ///
114    /// The returned sampler holds per-chunk caches and should be dropped after
115    /// the chunk's biomes are fully populated.
116    #[must_use]
117    pub fn chunk_sampler(&self) -> ChunkBiomeSampler<'_> {
118        match self {
119            Self::Overworld(source) => source.chunk_sampler(),
120            Self::Nether(source) => source.chunk_sampler(),
121            Self::End(source) => source.chunk_sampler(),
122        }
123    }
124
125    /// Vanilla's `BiomeSource.findBiomeHorizontal(findClosest=false, skipSteps=1)`.
126    /// Reservoir-samples at y=0. Returns `Some((block_x, block_z))` if found.
127    #[must_use]
128    pub fn find_biome_horizontal(
129        &self,
130        origin_x: i32,
131        origin_z: i32,
132        search_radius: i32,
133        allowed: &dyn Fn(BiomeRef) -> bool,
134        rng: &mut LegacyRandom,
135    ) -> Option<(i32, i32)> {
136        let mut sampler = self.chunk_sampler();
137        // QuartPos.fromBlock; origin_y = 0 so noise_y = 0.
138        let noise_center_x = origin_x >> 2;
139        let noise_center_z = origin_z >> 2;
140        let noise_radius = search_radius >> 2;
141
142        let mut result: Option<(i32, i32)> = None;
143        let mut found = 0;
144        for z in -noise_radius..=noise_radius {
145            for x in -noise_radius..=noise_radius {
146                let nx = noise_center_x + x;
147                let nz = noise_center_z + z;
148                if allowed(sampler.sample(nx, 0, nz)) {
149                    // Reservoir: replace with probability 1/(found+1).
150                    if result.is_none() || rng.next_i32_bounded(found + 1) == 0 {
151                        result = Some((nx << 2, nz << 2));
152                    }
153                    found += 1;
154                }
155            }
156        }
157        result
158    }
159
160    /// Returns vanilla's climate-based initial spawn search origin for dimensions that define one.
161    #[must_use]
162    pub fn initial_spawn_search_origin(&self) -> BlockPos {
163        match self {
164            Self::Overworld(source) => source.climate_sampler().find_spawn_position(),
165            Self::Nether(_) | Self::End(_) => BlockPos::new(0, 0, 0),
166        }
167    }
168}
169
170fn distinct_biome_refs(biomes: Vec<BiomeRef>) -> Vec<BiomeRef> {
171    let mut seen = FxHashSet::default();
172    let mut distinct = Vec::with_capacity(biomes.len());
173
174    for biome in biomes {
175        if seen.insert(&biome.key) {
176            distinct.push(biome);
177        }
178    }
179
180    distinct
181}
182
183/// Per-chunk biome sampler with internal caches.
184///
185/// Created by [`BiomeSourceKind::chunk_sampler`] for each chunk. Holds per-chunk
186/// caches: column-level density function values and an R-tree warm-start index
187/// that resets each chunk for deterministic biome selection.
188///
189/// Uses enum dispatch instead of `dyn` to avoid vtable overhead on the hot
190/// per-quart sampling path (1536 calls per overworld chunk).
191pub enum ChunkBiomeSampler<'a> {
192    /// Overworld sampler (climate → R-tree lookup).
193    Overworld(Box<OverworldChunkBiomeSampler<'a>>),
194    /// Nether sampler (climate → R-tree lookup).
195    Nether(Box<NetherChunkBiomeSampler<'a>>),
196    /// End sampler (spatial distance thresholds).
197    End(Box<EndChunkBiomeSampler<'a>>),
198}
199
200impl ChunkBiomeSampler<'_> {
201    /// Get the biome at the given quart position.
202    #[inline]
203    pub fn sample(&mut self, quart_x: i32, quart_y: i32, quart_z: i32) -> BiomeRef {
204        match self {
205            Self::Overworld(s) => s.sample(quart_x, quart_y, quart_z),
206            Self::Nether(s) => s.sample(quart_x, quart_y, quart_z),
207            Self::End(s) => s.sample(quart_x, quart_y, quart_z),
208        }
209    }
210
211    /// Pre-populate the per-chunk flat-noise grid so a full-chunk biome fill
212    /// reuses vanilla's `NoiseChunk.FlatCache` (O(1) column lookups) instead of
213    /// recomputing flat (xz-only) climate noise for every quart cell.
214    ///
215    /// Call once per chunk, before sampling, passing the chunk's minimum block
216    /// coordinates. No-op for the End, which selects biomes spatially and has no
217    /// column cache. Samplers used for sparse, scattered lookups (e.g.
218    /// `find_biome_horizontal`) intentionally skip this and keep lazy caching.
219    pub fn init_grid(&mut self, chunk_block_x: i32, chunk_block_z: i32) {
220        match self {
221            Self::Overworld(s) => s.init_grid(chunk_block_x, chunk_block_z),
222            Self::Nether(s) => s.init_grid(chunk_block_x, chunk_block_z),
223            Self::End(_) => {}
224        }
225    }
226}
227
228/// Multi-noise biome source for the overworld.
229///
230/// Uses compiled overworld density functions to sample climate parameters, then
231/// looks up the biome in the overworld parameter list (`RTree`).
232///
233/// Equivalent to vanilla's `MultiNoiseBiomeSource` with the overworld preset.
234pub struct OverworldBiomeSource {
235    seed: u64,
236    climate_sampler: OverworldClimateSampler,
237}
238
239impl OverworldBiomeSource {
240    /// Create a new overworld biome source with the given world seed.
241    #[must_use]
242    pub fn new(seed: u64) -> Self {
243        Self {
244            seed,
245            climate_sampler: OverworldClimateSampler::new(seed),
246        }
247    }
248
249    /// World seed used to initialize this biome source.
250    #[must_use]
251    pub const fn seed(&self) -> u64 {
252        self.seed
253    }
254
255    /// Access the underlying climate sampler (for tests, spawn point search, etc.).
256    #[must_use]
257    pub const fn climate_sampler(&self) -> &OverworldClimateSampler {
258        &self.climate_sampler
259    }
260
261    fn chunk_sampler(&self) -> ChunkBiomeSampler<'_> {
262        ChunkBiomeSampler::Overworld(Box::new(OverworldChunkBiomeSampler {
263            source: self,
264            column_cache: OverworldColumnCache::new(),
265            biome_cache: None,
266        }))
267    }
268}
269
270pub struct OverworldChunkBiomeSampler<'a> {
271    source: &'a OverworldBiomeSource,
272    column_cache: OverworldColumnCache,
273    biome_cache: Option<usize>,
274}
275
276impl OverworldChunkBiomeSampler<'_> {
277    fn sample(&mut self, quart_x: i32, quart_y: i32, quart_z: i32) -> BiomeRef {
278        let target =
279            self.source
280                .climate_sampler
281                .sample(quart_x, quart_y, quart_z, &mut self.column_cache);
282        get_overworld_biome_cached(&target, &mut self.biome_cache)
283    }
284
285    fn init_grid(&mut self, chunk_block_x: i32, chunk_block_z: i32) {
286        self.source.climate_sampler.init_column_grid(
287            &mut self.column_cache,
288            chunk_block_x,
289            chunk_block_z,
290        );
291    }
292}
293
294// ── Nether ──────────────────────────────────────────────────────────────────
295
296/// Multi-noise biome source for the nether.
297///
298/// Uses compiled nether density functions to sample temperature and vegetation,
299/// then looks up the biome in the nether parameter list (`RTree`).
300///
301/// Equivalent to vanilla's `MultiNoiseBiomeSource` with the nether preset.
302pub struct NetherBiomeSource {
303    seed: u64,
304    climate_sampler: NetherClimateSampler,
305}
306
307impl NetherBiomeSource {
308    /// Create a new nether biome source with the given world seed.
309    #[must_use]
310    pub fn new(seed: u64) -> Self {
311        Self {
312            seed,
313            climate_sampler: NetherClimateSampler::new(seed),
314        }
315    }
316
317    /// World seed used to initialize this biome source.
318    #[must_use]
319    pub const fn seed(&self) -> u64 {
320        self.seed
321    }
322
323    fn chunk_sampler(&self) -> ChunkBiomeSampler<'_> {
324        ChunkBiomeSampler::Nether(Box::new(NetherChunkBiomeSampler {
325            source: self,
326            column_cache: NetherColumnCache::new(),
327            biome_cache: None,
328        }))
329    }
330}
331
332pub struct NetherChunkBiomeSampler<'a> {
333    source: &'a NetherBiomeSource,
334    column_cache: NetherColumnCache,
335    biome_cache: Option<usize>,
336}
337
338impl NetherChunkBiomeSampler<'_> {
339    fn sample(&mut self, quart_x: i32, quart_y: i32, quart_z: i32) -> BiomeRef {
340        let target =
341            self.source
342                .climate_sampler
343                .sample(quart_x, quart_y, quart_z, &mut self.column_cache);
344        get_nether_biome_cached(&target, &mut self.biome_cache)
345    }
346
347    fn init_grid(&mut self, chunk_block_x: i32, chunk_block_z: i32) {
348        self.source.climate_sampler.init_column_grid(
349            &mut self.column_cache,
350            chunk_block_x,
351            chunk_block_z,
352        );
353    }
354}
355
356// ── The End ───────────────────────────────────────────────────────────────────
357
358/// Biome source for The End dimension.
359///
360/// Uses spatial distance from origin and the `EndIslands` density function for
361/// biome selection. Does NOT use climate parameters — biome choice is based on:
362///
363/// 1. **Central island** (`chunkX² + chunkZ² ≤ 4096`): always `the_end`
364/// 2. **Outer islands** (erosion from `EndIslands` at transformed coordinates):
365///    - `> 0.25` → `end_highlands`
366///    - `≥ -0.0625` → `end_midlands`
367///    - `< -0.21875` → `small_end_islands`
368///    - otherwise → `end_barrens`
369///
370/// Matches vanilla's `TheEndBiomeSource`.
371pub struct EndBiomeSource {
372    seed: u64,
373    end_islands: EndIslands,
374}
375
376impl EndBiomeSource {
377    /// Create a new End biome source with the given world seed.
378    ///
379    /// The `EndIslands` density function is initialized with the world seed,
380    /// matching vanilla's `RandomState.NoiseWiringHelper.wrapNew()` which replaces
381    /// the default seed-0 instance with `EndIslandDensityFunction(worldSeed)`.
382    #[must_use]
383    pub fn new(seed: u64) -> Self {
384        Self {
385            seed,
386            end_islands: EndIslands::new(seed),
387        }
388    }
389
390    /// World seed used to initialize this biome source.
391    #[must_use]
392    pub const fn seed(&self) -> u64 {
393        self.seed
394    }
395
396    fn chunk_sampler(&self) -> ChunkBiomeSampler<'_> {
397        ChunkBiomeSampler::End(Box::new(EndChunkBiomeSampler {
398            source: self,
399            cached_erosion: None,
400        }))
401    }
402}
403
404pub struct EndChunkBiomeSampler<'a> {
405    source: &'a EndBiomeSource,
406    /// Cached erosion value keyed by (`chunk_x`, `chunk_z`).
407    ///
408    /// All quart positions within a chunk produce the same chunk coordinates,
409    /// and `EndIslands::sample` ignores `block_y`, so the erosion is constant
410    /// per chunk. This avoids redundant 25×25 simplex neighborhood scans.
411    cached_erosion: Option<(i32, i32, f64)>,
412}
413
414impl EndChunkBiomeSampler<'_> {
415    fn get_erosion(&mut self, chunk_x: i32, chunk_z: i32) -> f64 {
416        if let Some((cx, cz, erosion)) = self.cached_erosion
417            && cx == chunk_x
418            && cz == chunk_z
419        {
420            return erosion;
421        }
422        let weird_block_x = f64::from((chunk_x * 2 + 1) * 8);
423        let weird_block_z = f64::from((chunk_z * 2 + 1) * 8);
424        let erosion = self
425            .source
426            .end_islands
427            .sample(weird_block_x, 0.0, weird_block_z);
428        self.cached_erosion = Some((chunk_x, chunk_z, erosion));
429        erosion
430    }
431
432    fn sample(&mut self, quart_x: i32, _quart_y: i32, quart_z: i32) -> BiomeRef {
433        let block_x = quart_x << 2;
434        let block_z = quart_z << 2;
435        let chunk_x = block_x >> 4;
436        let chunk_z = block_z >> 4;
437
438        // Central island: if within 64 chunks of origin
439        if i64::from(chunk_x) * i64::from(chunk_x) + i64::from(chunk_z) * i64::from(chunk_z) <= 4096
440        {
441            return &vanilla_biomes::THE_END;
442        }
443
444        let erosion = self.get_erosion(chunk_x, chunk_z);
445
446        if erosion > 0.25 {
447            &vanilla_biomes::END_HIGHLANDS
448        } else if erosion >= -0.0625 {
449            &vanilla_biomes::END_MIDLANDS
450        } else if erosion < -0.21875 {
451            &vanilla_biomes::SMALL_END_ISLANDS
452        } else {
453            &vanilla_biomes::END_BARRENS
454        }
455    }
456}
457
458#[cfg(test)]
459mod tests {
460    use super::*;
461
462    #[test]
463    fn end_possible_biomes_follow_vanilla_order() {
464        let source = BiomeSourceKind::end(0);
465        let keys = source
466            .possible_biome_refs()
467            .into_iter()
468            .map(|biome| &biome.key)
469            .collect::<Vec<_>>();
470
471        assert_eq!(
472            keys,
473            vec![
474                &vanilla_biomes::THE_END.key,
475                &vanilla_biomes::END_HIGHLANDS.key,
476                &vanilla_biomes::END_MIDLANDS.key,
477                &vanilla_biomes::SMALL_END_ISLANDS.key,
478                &vanilla_biomes::END_BARRENS.key,
479            ]
480        );
481    }
482}