Skip to main content

steel_worldgen/biomes/
climate_sampler.rs

1//! Climate sampler for overworld world generation.
2//!
3//! Uses the compiled overworld density functions from steel-registry for fast
4//! evaluation, bypassing the runtime tree interpreter entirely.
5//!
6//! This is overworld-specific because it uses `OverworldNoises` and the overworld
7//! noise router (`router_temperature`, `router_vegetation`, etc.). Other dimensions
8//! need their own climate samplers with their own transpiled density functions.
9
10use std::f32::consts::TAU;
11
12use steel_utils::BlockPos;
13use steel_utils::climate::{Parameter, ParameterPoint, TargetPoint, quantize_coord};
14use steel_utils::random::{Random, xoroshiro::Xoroshiro};
15use steel_worldgen::density_functions::overworld::{self, OverworldColumnCache, OverworldNoises};
16use steel_worldgen::noise_parameters::get_noise_parameters;
17
18/// Climate sampler for the overworld using compiled density functions.
19///
20/// Evaluates the overworld noise router (temperature, vegetation, continentalness,
21/// erosion, depth, ridges) to produce `TargetPoint` values for biome lookup.
22///
23pub struct OverworldClimateSampler {
24    /// All noise generators needed by the overworld density functions.
25    /// Boxed because `OverworldNoises` is ~5600 bytes (35 `NormalNoise` fields).
26    noises: Box<OverworldNoises>,
27}
28
29impl OverworldClimateSampler {
30    /// Create a new overworld climate sampler with the given seed.
31    #[must_use]
32    pub fn new(seed: u64) -> Self {
33        let mut rng = Xoroshiro::from_seed(seed);
34        let splitter = rng.next_positional();
35        let noise_params = get_noise_parameters();
36        let noises = OverworldNoises::create(seed, &splitter, &noise_params);
37
38        Self {
39            noises: Box::new(noises),
40        }
41    }
42
43    /// Sample climate at a quart position.
44    ///
45    /// The `cache` holds column-level (xz-only) precomputed values.
46    /// It should persist across calls for the same chunk to avoid redundant
47    /// noise evaluations when only `y` changes.
48    #[must_use]
49    pub fn sample(
50        &self,
51        quart_x: i32,
52        quart_y: i32,
53        quart_z: i32,
54        cache: &mut OverworldColumnCache,
55    ) -> TargetPoint {
56        let block_x = quart_x << 2;
57        let block_y = quart_y << 2;
58        let block_z = quart_z << 2;
59
60        // Ensure column cache is populated for this (x, z)
61        cache.ensure(block_x, block_z, &self.noises);
62
63        let block_x = f64::from(block_x);
64        let block_y = f64::from(block_y);
65        let block_z = f64::from(block_z);
66
67        let temp = overworld::router_temperature(&self.noises, cache, block_x, block_y, block_z);
68        let humidity = overworld::router_vegetation(&self.noises, cache, block_x, block_y, block_z);
69        let cont =
70            overworld::router_continentalness(&self.noises, cache, block_x, block_y, block_z);
71        let erosion = overworld::router_erosion(&self.noises, cache, block_x, block_y, block_z);
72        let depth = overworld::router_depth(&self.noises, cache, block_x, block_y, block_z);
73        let weirdness = overworld::router_ridges(&self.noises, cache, block_x, block_y, block_z);
74
75        TargetPoint::new(
76            quantize_coord(f64::from(temp)),
77            quantize_coord(f64::from(humidity)),
78            quantize_coord(f64::from(cont)),
79            quantize_coord(f64::from(erosion)),
80            quantize_coord(f64::from(depth)),
81            quantize_coord(f64::from(weirdness)),
82        )
83    }
84
85    /// Pre-populate a column cache's flat-noise grid for a chunk's quart columns.
86    ///
87    /// The biome stage samples every quart cell in a chunk (1536 for the
88    /// overworld) in `section → x → y → z` order. Without a grid, the cache is a
89    /// single-entry lazy cache that misses on every cell (the innermost `z` loop
90    /// changes column each step), so the expensive flat (xz-only) climate noise
91    /// is recomputed per cell. Pre-computing the grid once — exactly as the noise
92    /// stage's `fill_from_noise` does — turns those into O(1) lookups. Bit-identical
93    /// because the grid evaluates the same functions at the same quart coordinates.
94    pub fn init_column_grid(
95        &self,
96        cache: &mut OverworldColumnCache,
97        chunk_block_x: i32,
98        chunk_block_z: i32,
99    ) {
100        cache.init_grid(chunk_block_x, chunk_block_z, &self.noises);
101    }
102
103    /// Finds the climate-biased overworld spawn origin.
104    ///
105    /// This mirrors vanilla's `Climate.Sampler.findSpawnPosition()` with
106    /// `OverworldBiomeBuilder.spawnTarget()`.
107    #[must_use]
108    pub fn find_spawn_position(&self) -> BlockPos {
109        let spawn_targets = overworld_spawn_targets();
110        let mut result = self.spawn_position_and_fitness(&spawn_targets, 0, 0);
111        result = self.radial_spawn_search(&spawn_targets, result, 2048.0, 512.0);
112        self.radial_spawn_search(&spawn_targets, result, 512.0, 32.0)
113            .pos
114    }
115
116    fn radial_spawn_search(
117        &self,
118        spawn_targets: &[ParameterPoint; 2],
119        mut result: SpawnSearchResult,
120        max_radius: f32,
121        radius_increment: f32,
122    ) -> SpawnSearchResult {
123        let mut angle = 0.0_f32;
124        let mut radius = radius_increment;
125        let origin = result.pos;
126
127        while radius <= max_radius {
128            let x = origin.x() + (angle.sin() * radius) as i32;
129            let z = origin.z() + (angle.cos() * radius) as i32;
130            let candidate = self.spawn_position_and_fitness(spawn_targets, x, z);
131            if candidate.fitness < result.fitness {
132                result = candidate;
133            }
134
135            angle += radius_increment / radius;
136            if angle > TAU {
137                angle = 0.0;
138                radius += radius_increment;
139            }
140        }
141
142        result
143    }
144
145    fn spawn_position_and_fitness(
146        &self,
147        spawn_targets: &[ParameterPoint; 2],
148        block_x: i32,
149        block_z: i32,
150    ) -> SpawnSearchResult {
151        let mut cache = OverworldColumnCache::new();
152        let target = self.sample(block_x >> 2, 0, block_z >> 2, &mut cache);
153        let zero_depth_target = TargetPoint::new(
154            target.temperature,
155            target.humidity,
156            target.continentalness,
157            target.erosion,
158            0,
159            target.weirdness,
160        );
161        let min_fitness = spawn_targets
162            .iter()
163            .map(|point| point.fitness(&zero_depth_target))
164            .min()
165            .unwrap_or(i64::MAX);
166        let distance_bias =
167            i64::from(block_x) * i64::from(block_x) + i64::from(block_z) * i64::from(block_z);
168
169        SpawnSearchResult {
170            pos: BlockPos::new(block_x, 0, block_z),
171            fitness: min_fitness * 2048_i64 * 2048_i64 + distance_bias,
172        }
173    }
174}
175
176#[derive(Clone, Copy)]
177struct SpawnSearchResult {
178    pos: BlockPos,
179    fitness: i64,
180}
181
182fn overworld_spawn_targets() -> [ParameterPoint; 2] {
183    let full_range = Parameter::span(-1.0, 1.0);
184    let inland_continentalness = Parameter::span(-0.11, 0.55);
185    let continentalness = Parameter::span_params(&inland_continentalness, &full_range);
186    let surface_depth = Parameter::point(0.0);
187
188    [
189        ParameterPoint::new(
190            full_range,
191            full_range,
192            continentalness,
193            full_range,
194            surface_depth,
195            Parameter::span(-1.0, -0.16),
196            0,
197        ),
198        ParameterPoint::new(
199            full_range,
200            full_range,
201            continentalness,
202            full_range,
203            surface_depth,
204            Parameter::span(0.16, 1.0),
205            0,
206        ),
207    ]
208}