Skip to main content

proof_engine/worldgen/
erosion.rs

1//! Hydraulic and thermal erosion simulation.
2//!
3//! Hydraulic erosion: virtual raindrops flow downhill, picking up sediment
4//! from steep slopes and depositing it in flat areas. Creates realistic
5//! valleys, ridges, and drainage patterns.
6//!
7//! Thermal erosion: material slumps from steep slopes to neighbors when
8//! the slope exceeds a talus angle threshold.
9
10use super::{Grid2D, Rng};
11
12/// Erosion parameters.
13#[derive(Debug, Clone)]
14pub struct ErosionParams {
15    /// Fraction of steps that are hydraulic (vs thermal).
16    pub hydraulic_ratio: f32,
17    /// Maximum steps per raindrop before it stops.
18    pub max_drop_lifetime: usize,
19    /// Sediment capacity multiplier.
20    pub capacity_mult: f32,
21    /// Erosion rate (how fast material is picked up).
22    pub erosion_rate: f32,
23    /// Deposition rate (how fast sediment is dropped).
24    pub deposition_rate: f32,
25    /// Gravity strength.
26    pub gravity: f32,
27    /// Evaporation rate per step.
28    pub evaporation: f32,
29    /// Minimum slope for erosion to occur.
30    pub min_slope: f32,
31    /// Talus angle for thermal erosion (radians).
32    pub talus_angle: f32,
33    /// Thermal erosion rate.
34    pub thermal_rate: f32,
35    /// Inertia (how much the drop retains its previous direction).
36    pub inertia: f32,
37    /// Initial water volume per drop.
38    pub initial_water: f32,
39    /// Initial velocity per drop.
40    pub initial_speed: f32,
41}
42
43impl Default for ErosionParams {
44    fn default() -> Self {
45        Self {
46            hydraulic_ratio: 0.8,
47            max_drop_lifetime: 64,
48            capacity_mult: 8.0,
49            erosion_rate: 0.3,
50            deposition_rate: 0.3,
51            gravity: 4.0,
52            evaporation: 0.01,
53            min_slope: 0.01,
54            talus_angle: 0.6, // ~34 degrees
55            thermal_rate: 0.5,
56            inertia: 0.3,
57            initial_water: 1.0,
58            initial_speed: 1.0,
59        }
60    }
61}
62
63/// A simulated raindrop for hydraulic erosion.
64struct Drop {
65    x: f32,
66    y: f32,
67    dir_x: f32,
68    dir_y: f32,
69    speed: f32,
70    water: f32,
71    sediment: f32,
72}
73
74/// Run erosion on a heightmap.
75pub fn erode(mut heightmap: Grid2D, iterations: usize, rng: &mut Rng) -> Grid2D {
76    let params = ErosionParams::default();
77    let w = heightmap.width;
78    let h = heightmap.height;
79
80    let hydraulic_iters = (iterations as f32 * params.hydraulic_ratio) as usize;
81    let thermal_iters = iterations - hydraulic_iters;
82
83    // Hydraulic erosion
84    for _ in 0..hydraulic_iters {
85        hydraulic_step(&mut heightmap, rng, &params);
86    }
87
88    // Thermal erosion
89    for _ in 0..thermal_iters {
90        thermal_step(&mut heightmap, &params);
91    }
92
93    heightmap
94}
95
96/// Run erosion with custom parameters.
97pub fn erode_with_params(mut heightmap: Grid2D, iterations: usize, params: &ErosionParams, rng: &mut Rng) -> Grid2D {
98    let hydraulic_iters = (iterations as f32 * params.hydraulic_ratio) as usize;
99    let thermal_iters = iterations - hydraulic_iters;
100
101    for _ in 0..hydraulic_iters {
102        hydraulic_step(&mut heightmap, rng, params);
103    }
104    for _ in 0..thermal_iters {
105        thermal_step(&mut heightmap, params);
106    }
107
108    heightmap
109}
110
111/// Single hydraulic erosion step: simulate one raindrop.
112fn hydraulic_step(grid: &mut Grid2D, rng: &mut Rng, params: &ErosionParams) {
113    let w = grid.width as f32;
114    let h = grid.height as f32;
115
116    let mut drop = Drop {
117        x: rng.range_f32(1.0, w - 2.0),
118        y: rng.range_f32(1.0, h - 2.0),
119        dir_x: 0.0,
120        dir_y: 0.0,
121        speed: params.initial_speed,
122        water: params.initial_water,
123        sediment: 0.0,
124    };
125
126    for _ in 0..params.max_drop_lifetime {
127        let ix = drop.x as usize;
128        let iy = drop.y as usize;
129        if ix < 1 || iy < 1 || ix >= grid.width - 1 || iy >= grid.height - 1 {
130            break;
131        }
132
133        // Compute gradient
134        let (gx, gy) = grid.gradient(ix, iy);
135
136        // Update direction with inertia
137        drop.dir_x = drop.dir_x * params.inertia - gx * (1.0 - params.inertia);
138        drop.dir_y = drop.dir_y * params.inertia - gy * (1.0 - params.inertia);
139
140        // Normalize direction
141        let len = (drop.dir_x * drop.dir_x + drop.dir_y * drop.dir_y).sqrt();
142        if len < 1e-6 {
143            break; // No gradient → pool
144        }
145        drop.dir_x /= len;
146        drop.dir_y /= len;
147
148        // Move
149        let new_x = drop.x + drop.dir_x;
150        let new_y = drop.y + drop.dir_y;
151
152        if new_x < 1.0 || new_y < 1.0 || new_x >= w - 2.0 || new_y >= h - 2.0 {
153            break;
154        }
155
156        // Height difference
157        let old_h = grid.sample(drop.x, drop.y);
158        let new_h = grid.sample(new_x, new_y);
159        let dh = new_h - old_h;
160
161        // Sediment capacity
162        let slope = (-dh).max(params.min_slope);
163        let capacity = slope * drop.speed * drop.water * params.capacity_mult;
164
165        if drop.sediment > capacity || dh > 0.0 {
166            // Deposit sediment
167            let deposit = if dh > 0.0 {
168                // Flowing uphill: deposit enough to fill the pit
169                dh.min(drop.sediment)
170            } else {
171                (drop.sediment - capacity) * params.deposition_rate
172            };
173            drop.sediment -= deposit;
174            grid.add(ix, iy, deposit);
175        } else {
176            // Erode
177            let erode_amount = ((capacity - drop.sediment) * params.erosion_rate).min(-dh);
178            drop.sediment += erode_amount;
179            grid.add(ix, iy, -erode_amount);
180        }
181
182        // Update speed
183        drop.speed = (drop.speed * drop.speed + dh.abs() * params.gravity).sqrt();
184        drop.water *= 1.0 - params.evaporation;
185
186        drop.x = new_x;
187        drop.y = new_y;
188
189        if drop.water < 0.01 {
190            break;
191        }
192    }
193}
194
195/// Single thermal erosion step over the entire grid.
196fn thermal_step(grid: &mut Grid2D, params: &ErosionParams) {
197    let w = grid.width;
198    let h = grid.height;
199    let talus = params.talus_angle.tan(); // convert angle to max height difference per cell
200
201    let mut transfers = Vec::new();
202
203    for y in 1..h - 1 {
204        for x in 1..w - 1 {
205            let center = grid.get(x, y);
206            let mut max_diff = 0.0_f32;
207            let mut total_diff = 0.0_f32;
208            let mut neighbors = Vec::new();
209
210            for &(nx, ny) in &[(x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)] {
211                let nh = grid.get(nx, ny);
212                let diff = center - nh;
213                if diff > talus {
214                    max_diff = max_diff.max(diff);
215                    total_diff += diff - talus;
216                    neighbors.push((nx, ny, diff - talus));
217                }
218            }
219
220            if total_diff > 0.0 {
221                let transfer = max_diff * params.thermal_rate * 0.5;
222                for (nx, ny, weight) in neighbors {
223                    let frac = weight / total_diff;
224                    transfers.push((x, y, nx, ny, transfer * frac));
225                }
226            }
227        }
228    }
229
230    for (sx, sy, dx, dy, amount) in transfers {
231        grid.add(sx, sy, -amount);
232        grid.add(dx, dy, amount);
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239
240    fn test_hill() -> Grid2D {
241        let mut g = Grid2D::new(32, 32);
242        // Gaussian hill in center
243        for y in 0..32 {
244            for x in 0..32 {
245                let dx = x as f32 - 16.0;
246                let dy = y as f32 - 16.0;
247                g.set(x, y, (-0.01 * (dx * dx + dy * dy)).exp());
248            }
249        }
250        g
251    }
252
253    #[test]
254    fn test_hydraulic_erodes() {
255        let before = test_hill();
256        let peak_before = before.get(16, 16);
257        let mut rng = Rng::new(42);
258        let after = erode(before, 5000, &mut rng);
259        let peak_after = after.get(16, 16);
260        assert!(peak_after < peak_before, "erosion should lower the peak");
261    }
262
263    #[test]
264    fn test_thermal_smooths() {
265        let mut g = Grid2D::new(8, 8);
266        g.set(4, 4, 10.0); // spike
267        let params = ErosionParams::default();
268        for _ in 0..100 {
269            thermal_step(&mut g, &params);
270        }
271        assert!(g.get(4, 4) < 10.0, "thermal erosion should reduce spike");
272        assert!(g.get(3, 4) > 0.0, "neighbors should gain material");
273    }
274}