Skip to main content

steel_worldgen/noise/
beardifier.rs

1//! Beardifier: terrain density modification around structure pieces.
2//!
3//! Matches vanilla's `Beardifier` class. Modifies terrain density at cell corners
4//! using a gaussian kernel falloff around rigid structure pieces and jigsaw junctions.
5//! This creates the terrain adaptation effects like carving out space for villages
6//! or burying ancient cities.
7
8use std::sync::LazyLock;
9
10use glam::IVec3;
11use steel_math::map_clamped;
12use steel_registry::structure::TerrainAdjustment;
13use steel_registry::template_pool::Projection;
14use steel_utils::BoundingBox;
15
16use crate::structure::StructureStart;
17use crate::structure::jigsaw::JigsawJunction;
18
19/// A rigid structure piece that modifies terrain density.
20#[derive(Debug)]
21struct Rigid {
22    bounding_box: BoundingBox,
23    terrain_adjustment: TerrainAdjustment,
24    ground_level_delta: i32,
25}
26
27const KERNEL_RADIUS: i32 = 12;
28const KERNEL_SIZE: usize = 24;
29const KERNEL_TOTAL: usize = KERNEL_SIZE * KERNEL_SIZE * KERNEL_SIZE; // 13824
30
31/// Pre-computed gaussian beard kernel.
32/// Layout: `[z][x][y]` where indices go from 0..24, representing offsets -12..+11.
33static BEARD_KERNEL: LazyLock<[f32; KERNEL_TOTAL]> = LazyLock::new(|| {
34    let mut kernel = [0.0f32; KERNEL_TOTAL];
35    for zi in 0..KERNEL_SIZE {
36        let dz = zi as i32 - KERNEL_RADIUS;
37        for xi in 0..KERNEL_SIZE {
38            let dx = xi as i32 - KERNEL_RADIUS;
39            for yi in 0..KERNEL_SIZE {
40                let dy = yi as i32 - KERNEL_RADIUS;
41                // dy + 0.5 matches vanilla's computeBeardContribution(int, int, int)
42                let dy_f = f64::from(dy) + 0.5;
43                let dist_sq = f64::from(dx * dx) + dy_f * dy_f + f64::from(dz * dz);
44                let value = (-dist_sq / 16.0).exp();
45                kernel[zi * KERNEL_SIZE * KERNEL_SIZE + xi * KERNEL_SIZE + yi] = value as f32;
46            }
47        }
48    }
49    kernel
50});
51
52/// Vanilla's `Mth.fastInvSqrt` — the Quake III fast inverse square root, ported exactly.
53#[inline]
54fn fast_inv_sqrt(x: f64) -> f64 {
55    let xhalf = 0.5f64 * x;
56    let i = f64::to_bits(x) as i64;
57    let i = 0x5FE6_EB50_C7B5_37A9_i64 - (i >> 1);
58    let mut x = f64::from_bits(i as u64);
59    x *= 1.5f64 - xhalf * x * x;
60    x
61}
62
63#[inline]
64fn is_in_kernel_range(index: i32) -> bool {
65    (0..KERNEL_SIZE as i32).contains(&index)
66}
67
68/// Computes the beard density contribution for a point near a structure piece.
69///
70/// `dx`, `dy`, `dz` are the distances from the query point to the piece for kernel lookup.
71/// `y_to_ground` is the vertical distance from query point to the piece's ground level.
72fn get_beard_contribution(dx: i32, dy: i32, dz: i32, y_to_ground: i32) -> f64 {
73    let xi = dx + KERNEL_RADIUS;
74    let yi = dy + KERNEL_RADIUS;
75    let zi = dz + KERNEL_RADIUS;
76
77    if !is_in_kernel_range(xi) || !is_in_kernel_range(yi) || !is_in_kernel_range(zi) {
78        return 0.0;
79    }
80
81    let dy_with_offset = f64::from(y_to_ground) + 0.5;
82    let dist_sq = f64::from(dx * dx) + dy_with_offset * dy_with_offset + f64::from(dz * dz);
83    let value = -dy_with_offset * fast_inv_sqrt(dist_sq / 2.0) / 2.0;
84    let kernel_idx =
85        zi as usize * KERNEL_SIZE * KERNEL_SIZE + xi as usize * KERNEL_SIZE + yi as usize;
86    value * f64::from(BEARD_KERNEL[kernel_idx])
87}
88
89/// Computes the bury density contribution for a point near a structure piece.
90///
91/// Simple linear falloff: 1.0 at distance 0, 0.0 at distance 6.
92fn get_bury_contribution(dx: f64, dy: f64, dz: f64) -> f64 {
93    let distance = (dx * dx + dy * dy + dz * dz).sqrt();
94    map_clamped(distance, 0.0, 6.0, 1.0, 0.0)
95}
96
97/// Computes terrain density contributions from nearby structure pieces and junctions.
98///
99/// Built per-chunk from the chunk's own starts plus referenced neighbor starts (mirrors
100/// vanilla's `StructureManager.startsForStructure`). Queried per-block by `NoiseChunk::fill`
101/// after the outer density-function ops, matching vanilla's `cacheAllInCell(add(final_density,
102/// beardifier))` integration.
103pub struct Beardifier {
104    rigids: Vec<Rigid>,
105    junctions: Vec<JigsawJunction>,
106    /// Union of all piece/junction bounding boxes inflated by 24.
107    /// Points outside this box get 0.0 without iterating pieces.
108    affected_box: Option<BoundingBox>,
109}
110
111impl Beardifier {
112    /// Collect rigid pieces and junctions from structure starts that affect this chunk.
113    ///
114    /// `starts` should yield every `StructureStart` whose pieces could affect this chunk —
115    /// typically the chunk's own starts plus all referenced neighbor starts (vanilla collects
116    /// these via `StructureManager.startsForStructure`).
117    ///
118    /// `chunk_x` and `chunk_z` are chunk coordinates (not block coordinates).
119    ///
120    /// Mirrors vanilla's `forStructuresInChunk`:
121    /// - Non-jigsaw pieces (`projection: None`) → added as rigid with `ground_level_delta = 0`.
122    /// - Jigsaw RIGID pieces → added as rigid with stored `ground_level_delta`, junctions collected.
123    /// - Jigsaw `TERRAIN_MATCHING` pieces → not added as rigid; junctions still collected.
124    #[must_use]
125    pub fn for_structures_in_chunk<'a, I>(starts: I, chunk_x: i32, chunk_z: i32) -> Self
126    where
127        I: IntoIterator<Item = &'a StructureStart>,
128    {
129        let chunk_start_x = chunk_x * 16;
130        let chunk_start_z = chunk_z * 16;
131
132        let mut rigids = Vec::new();
133        let mut junctions: Vec<JigsawJunction> = Vec::new();
134        let mut encompassing: Option<BoundingBox> = None;
135
136        for start in starts {
137            let terrain_adj = start.terrain_adjustment;
138            if terrain_adj == TerrainAdjustment::None {
139                continue;
140            }
141
142            for piece in &start.pieces {
143                let bb = &piece.bounding_box;
144
145                // Vanilla: piece.isCloseToChunk(chunkPos, 12)
146                if !is_close_to_chunk(bb, chunk_x, chunk_z, 12) {
147                    continue;
148                }
149
150                let is_jigsaw = piece.projection.is_some();
151                let is_rigid = matches!(piece.projection, Some(Projection::Rigid));
152
153                // Vanilla: only non-jigsaw pieces and jigsaw-RIGID pieces become rigids.
154                // Jigsaw TERRAIN_MATCHING pieces are skipped here (junctions still collected
155                // below, and the encompassing box only gets junction positions for them).
156                if !is_jigsaw || is_rigid {
157                    encompassing = Some(match encompassing {
158                        Some(enc) => BoundingBox::encapsulating(&enc, bb),
159                        None => *bb,
160                    });
161
162                    rigids.push(Rigid {
163                        bounding_box: *bb,
164                        terrain_adjustment: terrain_adj,
165                        // Vanilla uses 0 for non-jigsaw pieces regardless of any stored value;
166                        // jigsaw RIGID pieces use the projection-derived delta.
167                        ground_level_delta: if is_jigsaw {
168                            piece.ground_level_delta
169                        } else {
170                            0
171                        },
172                    });
173                }
174
175                // Junctions: vanilla collects them only on jigsaw pieces, and bounds are
176                // strict — exclusive on both sides:
177                // `(chunkStartBlockX - 12, chunkStartBlockX + 15 + 12)`, same for Z.
178                if is_jigsaw {
179                    for junction in &piece.junctions {
180                        let jx = junction.source_pos.x;
181                        let jz = junction.source_pos.z;
182                        if jx > chunk_start_x - 12
183                            && jz > chunk_start_z - 12
184                            && jx < chunk_start_x + 15 + 12
185                            && jz < chunk_start_z + 15 + 12
186                        {
187                            let jy = junction.source_pos.y;
188                            let junction_bb =
189                                BoundingBox::new(IVec3::new(jx, jy, jz), IVec3::new(jx, jy, jz));
190                            encompassing = Some(match encompassing {
191                                Some(enc) => BoundingBox::encapsulating(&enc, &junction_bb),
192                                None => junction_bb,
193                            });
194                            junctions.push(junction.clone());
195                        }
196                    }
197                }
198            }
199        }
200
201        let affected_box = encompassing
202            .map(|bb| bb.inflate_xyz(KERNEL_SIZE as i32, KERNEL_SIZE as i32, KERNEL_SIZE as i32));
203
204        Self {
205            rigids,
206            junctions,
207            affected_box,
208        }
209    }
210
211    /// Returns true if there are no pieces or junctions affecting terrain.
212    #[must_use]
213    pub const fn is_empty(&self) -> bool {
214        self.rigids.is_empty() && self.junctions.is_empty()
215    }
216
217    /// Compute the total density contribution at a world-space block position.
218    ///
219    /// Returns 0.0 if no structures are nearby.
220    #[must_use]
221    pub fn compute(&self, block_x: i32, block_y: i32, block_z: i32) -> f64 {
222        let Some(affected) = &self.affected_box else {
223            return 0.0;
224        };
225        if !affected.contains_xyz(block_x, block_y, block_z) {
226            return 0.0;
227        }
228
229        let mut value = 0.0;
230
231        for rigid in &self.rigids {
232            let bb = &rigid.bounding_box;
233
234            // Horizontal distance to closest edge of bounding box (0 if inside)
235            let dx = 0.max((bb.min_x() - block_x).max(block_x - bb.max_x()));
236            let dz = 0.max((bb.min_z() - block_z).max(block_z - bb.max_z()));
237
238            let ground_y = bb.min_y() + rigid.ground_level_delta;
239            let dy_to_ground = block_y - ground_y;
240
241            match rigid.terrain_adjustment {
242                TerrainAdjustment::None => {}
243                TerrainAdjustment::Bury => {
244                    value += get_bury_contribution(
245                        f64::from(dx),
246                        f64::from(dy_to_ground) / 2.0,
247                        f64::from(dz),
248                    );
249                }
250                TerrainAdjustment::BeardThin => {
251                    value += get_beard_contribution(dx, dy_to_ground, dz, dy_to_ground) * 0.8;
252                }
253                TerrainAdjustment::BeardBox => {
254                    let dy = 0.max((ground_y - block_y).max(block_y - bb.max_y()));
255                    value += get_beard_contribution(dx, dy, dz, dy_to_ground) * 0.8;
256                }
257                TerrainAdjustment::Encapsulate => {
258                    let dy = 0.max((bb.min_y() - block_y).max(block_y - bb.max_y()));
259                    value += get_bury_contribution(
260                        f64::from(dx) / 2.0,
261                        f64::from(dy) / 2.0,
262                        f64::from(dz) / 2.0,
263                    ) * 0.8;
264                }
265            }
266        }
267
268        for junction in &self.junctions {
269            let dx = block_x - junction.source_pos.x;
270            let dy = block_y - junction.source_pos.y;
271            let dz = block_z - junction.source_pos.z;
272            value += get_beard_contribution(dx, dy, dz, dy) * 0.4;
273        }
274
275        value
276    }
277}
278
279/// Check if a bounding box is within `margin` blocks of a chunk.
280///
281/// Matches vanilla's `StructurePiece.isCloseToChunk(ChunkPos, int)`.
282const fn is_close_to_chunk(bb: &BoundingBox, chunk_x: i32, chunk_z: i32, margin: i32) -> bool {
283    let chunk_start_x = chunk_x * 16;
284    let chunk_start_z = chunk_z * 16;
285    let chunk_end_x = chunk_start_x + 15;
286    let chunk_end_z = chunk_start_z + 15;
287
288    bb.max_x() >= chunk_start_x - margin
289        && bb.min_x() <= chunk_end_x + margin
290        && bb.max_z() >= chunk_start_z - margin
291        && bb.min_z() <= chunk_end_z + margin
292}