use super::Heightmap;
use crate::compiled::real::LawMinMax;
use crate::crispy::fast_normalize_2d;
#[derive(Debug, Clone)]
pub struct ErosionConfig {
pub iterations: u32,
pub rain_amount: f32,
pub sediment_capacity: f32,
pub erosion_rate: f32,
pub deposition_rate: f32,
pub evaporation_rate: f32,
pub gravity: f32,
pub thermal_angle: f32,
pub thermal_rate: f32,
pub max_steps: u32,
pub min_water: f32,
pub seed: u64,
}
impl Default for ErosionConfig {
fn default() -> Self {
Self {
iterations: 10000,
rain_amount: 1.0,
sediment_capacity: 4.0,
erosion_rate: 0.3,
deposition_rate: 0.3,
evaporation_rate: 0.01,
gravity: 4.0,
thermal_angle: 0.6, thermal_rate: 0.5,
max_steps: 64,
min_water: 0.01,
seed: 42,
}
}
}
pub fn erode(heightmap: &mut Heightmap, config: &ErosionConfig) {
hydraulic_erosion(heightmap, config);
if config.thermal_rate > 0.0 {
thermal_erosion(heightmap, config);
}
}
fn hydraulic_erosion(heightmap: &mut Heightmap, config: &ErosionConfig) {
let w = heightmap.width as f32;
let d = heightmap.depth as f32;
let mut rng = config.seed;
for _ in 0..config.iterations {
rng = lcg_next(rng);
let mut px = lcg_float(rng) * (w - 2.0) + 1.0;
rng = lcg_next(rng);
let mut pz = lcg_float(rng) * (d - 2.0) + 1.0;
let mut water = config.rain_amount;
let mut sediment = 0.0f32;
let mut speed = 1.0f32;
for _ in 0..config.max_steps {
if water < config.min_water {
break;
}
let ix = px.floor() as i32;
let iz = pz.floor() as i32;
if ix < 1
|| ix >= (heightmap.width as i32 - 1)
|| iz < 1
|| iz >= (heightmap.depth as i32 - 1)
{
break;
}
let (gx, gz) = compute_gradient(heightmap, px, pz);
let (nx, nz) = fast_normalize_2d(gx, gz);
if nx == 0.0 && nz == 0.0 {
break;
}
let new_px = px - nx;
let new_pz = pz - nz;
let old_h = sample_height(heightmap, px, pz);
let new_h = sample_height(heightmap, new_px, new_pz);
let dh = new_h - old_h;
let capacity = (-dh).law_max(0.0) * speed * water * config.sediment_capacity;
if sediment > capacity || dh > 0.0 {
let deposit = if dh > 0.0 {
(sediment).law_min(dh)
} else {
(sediment - capacity) * config.deposition_rate
};
deposit_at(heightmap, px, pz, deposit);
sediment -= deposit;
} else {
let erode_amount = ((capacity - sediment) * config.erosion_rate).law_min(-dh);
erode_at(heightmap, px, pz, erode_amount);
sediment += erode_amount;
}
speed = (speed * speed + (dh * config.gravity)).abs().sqrt();
water *= 1.0 - config.evaporation_rate;
px = new_px;
pz = new_pz;
}
}
}
fn thermal_erosion(heightmap: &mut Heightmap, config: &ErosionConfig) {
let w = heightmap.width;
let d = heightmap.depth;
let cell_size = heightmap.world_width * (1.0 / w as f32);
let max_slope = alice_det_math::tan(config.thermal_angle) * cell_size;
let passes = (config.iterations / 100).max(1);
for _ in 0..passes {
for z in 1..(d - 1) {
for x in 1..(w - 1) {
let h = heightmap.get_height(x, z);
let mut max_diff = 0.0f32;
let mut total_diff = 0.0f32;
let mut lower_count = 0u32;
let neighbors = [
(x.wrapping_sub(1), z),
(x + 1, z),
(x, z.wrapping_sub(1)),
(x, z + 1),
];
for &(nx, nz) in &neighbors {
if nx < w && nz < d {
let nh = heightmap.get_height(nx, nz);
let diff = h - nh;
if diff > max_slope {
max_diff = max_diff.law_max(diff);
total_diff += diff - max_slope;
lower_count += 1;
}
}
}
if lower_count > 0 && total_diff > 0.0 {
let transfer = (max_diff - max_slope) * 0.5 * config.thermal_rate;
heightmap.set_height(x, z, h - transfer);
let inv_total_diff = 1.0 / total_diff;
for &(nx, nz) in &neighbors {
if nx < w && nz < d {
let nh = heightmap.get_height(nx, nz);
let diff = h - nh;
if diff > max_slope {
let ratio = (diff - max_slope) * inv_total_diff;
heightmap.set_height(nx, nz, nh + transfer * ratio);
}
}
}
}
}
}
}
}
fn sample_height(hm: &Heightmap, gx: f32, gz: f32) -> f32 {
let x0 = gx.floor() as u32;
let z0 = gz.floor() as u32;
let x1 = (x0 + 1).min(hm.width - 1);
let z1 = (z0 + 1).min(hm.depth - 1);
let tx = gx - gx.floor();
let tz = gz - gz.floor();
let h00 = hm.get_height(x0, z0);
let h10 = hm.get_height(x1, z0);
let h01 = hm.get_height(x0, z1);
let h11 = hm.get_height(x1, z1);
let h0 = (h10 - h00) * tx + h00;
let h1 = (h11 - h01) * tx + h01;
(h1 - h0) * tz + h0
}
fn compute_gradient(hm: &Heightmap, gx: f32, gz: f32) -> (f32, f32) {
let x0 = (gx - 0.5).law_max(0.0);
let x1 = (gx + 0.5).law_min((hm.width - 1) as f32);
let z0 = (gz - 0.5).law_max(0.0);
let z1 = (gz + 0.5).law_min((hm.depth - 1) as f32);
let dx = sample_height(hm, x1, gz) - sample_height(hm, x0, gz);
let dz = sample_height(hm, gx, z1) - sample_height(hm, gx, z0);
(dx, dz)
}
fn deposit_at(hm: &mut Heightmap, gx: f32, gz: f32, amount: f32) {
let x0 = gx.floor() as u32;
let z0 = gz.floor() as u32;
let x1 = (x0 + 1).min(hm.width - 1);
let z1 = (z0 + 1).min(hm.depth - 1);
let tx = gx - gx.floor();
let tz = gz - gz.floor();
let w00 = (1.0 - tx) * (1.0 - tz);
let w10 = tx * (1.0 - tz);
let w01 = (1.0 - tx) * tz;
let w11 = tx * tz;
hm.set_height(x0, z0, amount * w00 + hm.get_height(x0, z0));
hm.set_height(x1, z0, amount * w10 + hm.get_height(x1, z0));
hm.set_height(x0, z1, amount * w01 + hm.get_height(x0, z1));
hm.set_height(x1, z1, amount * w11 + hm.get_height(x1, z1));
}
fn erode_at(hm: &mut Heightmap, gx: f32, gz: f32, amount: f32) {
deposit_at(hm, gx, gz, -amount);
}
#[inline]
const fn lcg_next(state: u64) -> u64 {
state
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407)
}
#[inline]
fn lcg_float(state: u64) -> f32 {
((state >> 16) as u32 as f32) / (u32::MAX as f32)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_erosion_modifies_terrain() {
let mut hm = Heightmap::new(64, 64, 64.0, 64.0);
hm.generate_fbm(4, 0.5, 2.0, 42);
hm.scale_heights(10.0);
let heights_before: Vec<f32> = hm.heights.clone();
let config = ErosionConfig {
iterations: 1000,
..Default::default()
};
erode(&mut hm, &config);
let mut changed = 0;
for (a, b) in heights_before.iter().zip(hm.heights.iter()) {
if (a - b).abs() > 1e-6 {
changed += 1;
}
}
assert!(changed > 0, "Erosion should modify terrain heights");
}
#[test]
fn test_thermal_erosion_does_not_increase_roughness() {
for seed in [42u64, 7, 2024] {
for iterations in [100u32, 1000, 5000] {
let mut hm = Heightmap::new(32, 32, 32.0, 32.0);
hm.generate_fbm(4, 0.5, 2.0, seed);
hm.scale_heights(10.0);
let before = compute_roughness(&hm);
erode(
&mut hm,
&ErosionConfig {
iterations,
rain_amount: 0.0,
thermal_rate: 1.0,
..Default::default()
},
);
let after = compute_roughness(&hm);
assert!(
after <= before * (1.0 + 1e-6),
"seed={seed} iterations={iterations}: thermal erosion raised roughness {before} -> {after}"
);
}
}
}
#[test]
fn test_erosion_config_default() {
let config = ErosionConfig::default();
assert_eq!(config.iterations, 10000);
assert!(config.erosion_rate > 0.0);
assert!(config.deposition_rate > 0.0);
}
#[test]
fn test_thermal_only() {
let mut hm = Heightmap::new(16, 16, 16.0, 16.0);
hm.set_height(8, 8, 10.0);
let config = ErosionConfig {
iterations: 100,
thermal_rate: 1.0,
erosion_rate: 0.0,
..Default::default()
};
erode(&mut hm, &config);
let cell_size = 16.0f32 / 16.0;
let max_slope = alice_det_math::tan(config.thermal_angle) * cell_size;
let want = 10.0 - (10.0 - max_slope) * 0.5 * config.thermal_rate;
let peak = hm.get_height(8, 8);
assert!(
(peak - want).abs() < 1e-5,
"Peak after one thermal pass: got {peak}, closed form {want}"
);
}
fn compute_roughness(hm: &Heightmap) -> f32 {
let mut total = 0.0f32;
let mut count = 0u32;
for z in 1..(hm.depth - 1) {
for x in 1..(hm.width - 1) {
let h = hm.get_height(x, z);
let avg_neighbor = (hm.get_height(x - 1, z)
+ hm.get_height(x + 1, z)
+ hm.get_height(x, z - 1)
+ hm.get_height(x, z + 1))
/ 4.0;
total += (h - avg_neighbor).abs();
count += 1;
}
}
if count > 0 {
total / count as f32
} else {
0.0
}
}
}