symbios-ground 0.3.0

An algorithmic terrain engine.
Documentation
use crate::HeightMap;

/// Thermal erosion (talus / slope-smoothing) simulation.
///
/// Iteratively redistributes material from steep slopes to their downhill
/// neighbours until no slope exceeds the configured talus angle, producing
/// the characteristic scree-smoothed hillsides of real-world terrain.
#[derive(Debug, Clone)]
pub struct ThermalErosion {
    /// Number of smoothing passes.
    pub iterations: u32,
    /// Maximum stable height difference between adjacent cells.
    /// Material above this threshold slides downhill.
    pub talus_angle: f32,
    /// Fraction of excess material moved per iteration.
    ///
    /// Must be in `(0.0, 0.25]`. With a 4-connected neighbourhood each cell
    /// can transfer to up to 4 neighbours simultaneously, so values above 0.25
    /// can remove more material than exists (`4 * fraction * excess > excess`),
    /// causing the simulation to oscillate and tear the heightmap. Values
    /// above 0.25 are clamped automatically in [`ThermalErosion::erode`].
    pub fraction: f32,
    /// Height threshold below which no erosion occurs, simulating a water level.
    pub water_level: f32,
    /// Talus angle for underwater regions.
    pub underwater_talus_angle: f32,
}

impl ThermalErosion {
    /// Create a `ThermalErosion` simulator with sensible defaults:
    /// 50 iterations, talus angle `0.05`, fraction `0.25`.
    pub fn new() -> Self {
        Self {
            iterations: 50,
            talus_angle: 0.05,
            fraction: 0.25,
            water_level: 0.0,
            underwater_talus_angle: 0.1,
        }
    }

    /// Set the number of erosion passes.
    ///
    /// More iterations produce smoother slopes at the cost of runtime.
    pub fn with_iterations(mut self, n: u32) -> Self {
        self.iterations = n;
        self
    }

    /// Set the maximum stable height difference between adjacent cells.
    ///
    /// Smaller values produce gentler, more smoothed terrain; larger values
    /// leave steeper cliffs intact.
    pub fn with_talus_angle(mut self, angle: f32) -> Self {
        self.talus_angle = angle;
        self
    }

    /// Same for underwater regions.
    pub fn with_underwater_talus_angle(mut self, angle: f32) -> Self {
        self.underwater_talus_angle = angle;
        self
    }

    /// Water level setter.
    pub fn with_water_level(mut self, level: f32) -> Self {
        self.water_level = level;
        self
    }

    /// Apply thermal erosion to `heightmap` in-place.
    pub fn erode(&self, heightmap: &mut HeightMap) {
        // A fraction above 0.25 removes more material per step than a cell
        // holds (4 neighbours × fraction × excess), causing oscillation.
        let fraction = self.fraction.clamp(f32::EPSILON, 0.25);

        let w = heightmap.width();
        let h = heightmap.height();

        // Offsets for the 4 cardinal neighbours.
        const NEIGHBOURS: [(i32, i32); 4] = [(1, 0), (-1, 0), (0, 1), (0, -1)];

        // Allocate the delta buffer once and zero it each iteration to avoid
        // 50+ separate heap allocations inside the loop.
        let mut delta = vec![0.0_f32; w * h];

        for _ in 0..self.iterations {
            // Zero the reused buffer.
            delta.fill(0.0);

            for z in 0..h {
                for x in 0..w {
                    let h_here = heightmap.get(x, z);
                    let mut total_excess = 0.0_f32;
                    let mut excess = [0.0_f32; 4];

                    for (i, &(dx, dz)) in NEIGHBOURS.iter().enumerate() {
                        let nx = x as i32 + dx;
                        let nz = z as i32 + dz;
                        if nx < 0 || nx >= w as i32 || nz < 0 || nz >= h as i32 {
                            continue;
                        }
                        let h_nb = heightmap.get(nx as usize, nz as usize);
                        let diff = h_here - h_nb;

                        let current_talus =
                            if h_here <= self.water_level && h_nb <= self.water_level {
                                self.underwater_talus_angle
                            } else {
                                self.talus_angle
                            };

                        if diff > current_talus {
                            excess[i] = diff - current_talus;
                            total_excess += excess[i];
                        }
                    }

                    if total_excess <= 0.0 {
                        continue;
                    }

                    // Move a fraction of the total excess to each downhill neighbour.
                    for (i, &(dx, dz)) in NEIGHBOURS.iter().enumerate() {
                        if excess[i] <= 0.0 {
                            continue;
                        }
                        let nx = x as i32 + dx;
                        let nz = z as i32 + dz;
                        if nx < 0 || nx >= w as i32 || nz < 0 || nz >= h as i32 {
                            continue;
                        }
                        let transfer = fraction * excess[i];
                        delta[z * w + x] -= transfer;
                        delta[nz as usize * w + nx as usize] += transfer;
                    }
                }
            }

            // Apply the accumulated delta.
            for (v, d) in heightmap.data_mut().iter_mut().zip(delta.iter()) {
                *v += d;
            }
        }
    }
}

impl Default for ThermalErosion {
    fn default() -> Self {
        Self::new()
    }
}