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        // Density functions return f64 but vanilla truncates to float before quantizing.
68        // The f64→f32→f64 round-trip through quantize_coord is intentional for parity.
69        let temp =
70            overworld::router_temperature(&self.noises, cache, block_x, block_y, block_z) as f32;
71        let humidity =
72            overworld::router_vegetation(&self.noises, cache, block_x, block_y, block_z) as f32;
73        let cont = overworld::router_continentalness(&self.noises, cache, block_x, block_y, block_z)
74            as f32;
75        let erosion =
76            overworld::router_erosion(&self.noises, cache, block_x, block_y, block_z) as f32;
77        let depth = overworld::router_depth(&self.noises, cache, block_x, block_y, block_z) as f32;
78        let weirdness =
79            overworld::router_ridges(&self.noises, cache, block_x, block_y, block_z) as f32;
80
81        TargetPoint::new(
82            quantize_coord(f64::from(temp)),
83            quantize_coord(f64::from(humidity)),
84            quantize_coord(f64::from(cont)),
85            quantize_coord(f64::from(erosion)),
86            quantize_coord(f64::from(depth)),
87            quantize_coord(f64::from(weirdness)),
88        )
89    }
90
91    /// Pre-populate a column cache's flat-noise grid for a chunk's quart columns.
92    ///
93    /// The biome stage samples every quart cell in a chunk (1536 for the
94    /// overworld) in `section → x → y → z` order. Without a grid, the cache is a
95    /// single-entry lazy cache that misses on every cell (the innermost `z` loop
96    /// changes column each step), so the expensive flat (xz-only) climate noise
97    /// is recomputed per cell. Pre-computing the grid once — exactly as the noise
98    /// stage's `fill_from_noise` does — turns those into O(1) lookups. Bit-identical
99    /// because the grid evaluates the same functions at the same quart coordinates.
100    pub fn init_column_grid(
101        &self,
102        cache: &mut OverworldColumnCache,
103        chunk_block_x: i32,
104        chunk_block_z: i32,
105    ) {
106        cache.init_grid(chunk_block_x, chunk_block_z, &self.noises);
107    }
108
109    /// Finds the climate-biased overworld spawn origin.
110    ///
111    /// This mirrors vanilla's `Climate.Sampler.findSpawnPosition()` with
112    /// `OverworldBiomeBuilder.spawnTarget()`.
113    #[must_use]
114    pub fn find_spawn_position(&self) -> BlockPos {
115        let spawn_targets = overworld_spawn_targets();
116        let mut result = self.spawn_position_and_fitness(&spawn_targets, 0, 0);
117        result = self.radial_spawn_search(&spawn_targets, result, 2048.0, 512.0);
118        self.radial_spawn_search(&spawn_targets, result, 512.0, 32.0)
119            .pos
120    }
121
122    fn radial_spawn_search(
123        &self,
124        spawn_targets: &[ParameterPoint; 2],
125        mut result: SpawnSearchResult,
126        max_radius: f32,
127        radius_increment: f32,
128    ) -> SpawnSearchResult {
129        let mut angle = 0.0_f32;
130        let mut radius = radius_increment;
131        let origin = result.pos;
132
133        while radius <= max_radius {
134            let x = origin.x() + (angle.sin() * radius) as i32;
135            let z = origin.z() + (angle.cos() * radius) as i32;
136            let candidate = self.spawn_position_and_fitness(spawn_targets, x, z);
137            if candidate.fitness < result.fitness {
138                result = candidate;
139            }
140
141            angle += radius_increment / radius;
142            if angle > TAU {
143                angle = 0.0;
144                radius += radius_increment;
145            }
146        }
147
148        result
149    }
150
151    fn spawn_position_and_fitness(
152        &self,
153        spawn_targets: &[ParameterPoint; 2],
154        block_x: i32,
155        block_z: i32,
156    ) -> SpawnSearchResult {
157        let mut cache = OverworldColumnCache::new();
158        let target = self.sample(block_x >> 2, 0, block_z >> 2, &mut cache);
159        let zero_depth_target = TargetPoint::new(
160            target.temperature,
161            target.humidity,
162            target.continentalness,
163            target.erosion,
164            0,
165            target.weirdness,
166        );
167        let min_fitness = spawn_targets
168            .iter()
169            .map(|point| point.fitness(&zero_depth_target))
170            .min()
171            .unwrap_or(i64::MAX);
172        let distance_bias =
173            i64::from(block_x) * i64::from(block_x) + i64::from(block_z) * i64::from(block_z);
174
175        SpawnSearchResult {
176            pos: BlockPos::new(block_x, 0, block_z),
177            fitness: min_fitness * 2048_i64 * 2048_i64 + distance_bias,
178        }
179    }
180}
181
182#[derive(Clone, Copy)]
183struct SpawnSearchResult {
184    pos: BlockPos,
185    fitness: i64,
186}
187
188fn overworld_spawn_targets() -> [ParameterPoint; 2] {
189    let full_range = Parameter::span(-1.0, 1.0);
190    let inland_continentalness = Parameter::span(-0.11, 0.55);
191    let continentalness = Parameter::span_params(&inland_continentalness, &full_range);
192    let surface_depth = Parameter::point(0.0);
193
194    [
195        ParameterPoint::new(
196            full_range,
197            full_range,
198            continentalness,
199            full_range,
200            surface_depth,
201            Parameter::span(-1.0, -0.16),
202            0,
203        ),
204        ParameterPoint::new(
205            full_range,
206            full_range,
207            continentalness,
208            full_range,
209            surface_depth,
210            Parameter::span(0.16, 1.0),
211            0,
212        ),
213    ]
214}