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