use crate::perlin::PerlinNoise3D;
use crate::rng::SplitMix64;
use crate::{CaveParams, MAXZDIM};
const PERLIN_LOWEST_FREQUENCY: f32 = 1.0 / 16.0;
const PERLIN_VOXEL_SCALE: f32 = 8.0;
#[derive(Debug, Clone, Copy)]
pub struct Seed {
pub pos: [f32; 3],
pub is_air: bool,
}
#[must_use]
#[allow(
clippy::cast_possible_truncation,
clippy::cast_precision_loss,
clippy::cast_sign_loss
)]
pub fn place_seeds(params: &CaveParams, vsid: u32) -> Vec<Seed> {
let mut rng = SplitMix64::new(params.seed);
let total = params.seed_count;
let n_air = ((total as f32) * params.air_ratio).round() as usize;
let n_air = n_air.min(total);
let xy_max = vsid as f32;
let z_max = MAXZDIM as f32;
let mut seeds = Vec::with_capacity(total);
for i in 0..total {
let pos_x = rng.next_f32_unit() * xy_max;
let pos_y = rng.next_f32_unit() * xy_max;
let pos_z = rng.next_f32_unit() * z_max;
seeds.push(Seed {
pos: [pos_x, pos_y, pos_z],
is_air: i < n_air,
});
}
seeds
}
#[must_use]
#[allow(clippy::cast_precision_loss)]
pub fn classify_voxel(seeds: &[Seed], x: u32, y: u32, z: i32, anisotropy: f32) -> bool {
let p = [x as f32, y as f32, z as f32];
let mut d_air_sq = f32::INFINITY;
let mut d_solid_sq = f32::INFINITY;
for seed in seeds {
let d_sq = anisotropic_dist_sq(p, seed.pos, anisotropy);
if seed.is_air {
if d_sq < d_air_sq {
d_air_sq = d_sq;
}
} else if d_sq < d_solid_sq {
d_solid_sq = d_sq;
}
}
d_air_sq >= d_solid_sq
}
#[must_use]
#[allow(clippy::cast_precision_loss, clippy::too_many_arguments)]
pub fn classify_voxel_with_perlin(
seeds: &[Seed],
perlin: &PerlinNoise3D,
x: u32,
y: u32,
z: i32,
anisotropy: f32,
perlin_octaves: u32,
perlin_amplitude: f32,
) -> bool {
let p = [x as f32, y as f32, z as f32];
let mut d_air_sq = f32::INFINITY;
let mut d_solid_sq = f32::INFINITY;
for seed in seeds {
let d_sq = anisotropic_dist_sq(p, seed.pos, anisotropy);
if seed.is_air {
if d_sq < d_air_sq {
d_air_sq = d_sq;
}
} else if d_sq < d_solid_sq {
d_solid_sq = d_sq;
}
}
if perlin_octaves == 0 || perlin_amplitude == 0.0 {
return d_air_sq >= d_solid_sq;
}
let d_air = d_air_sq.sqrt();
let d_solid = d_solid_sq.sqrt();
let overlay = perlin.fbm(p[0], p[1], p[2], perlin_octaves, PERLIN_LOWEST_FREQUENCY)
* perlin_amplitude
* PERLIN_VOXEL_SCALE;
(d_air + overlay) >= d_solid
}
#[must_use]
#[allow(clippy::cast_sign_loss)]
pub fn worley_classify_grid(params: &CaveParams, vsid: u32) -> Vec<u8> {
let seeds = place_seeds(params, vsid);
let perlin_active = params.perlin_octaves > 0 && params.perlin_amplitude > 0.0;
let perlin = if perlin_active {
Some(PerlinNoise3D::new(
params.seed.wrapping_mul(0x9E37_79B9_7F4A_7C15),
))
} else {
None
};
let vsid_u = vsid as usize;
let maxzdim_u = MAXZDIM as usize;
let n_voxels = vsid_u * vsid_u * maxzdim_u;
let mut grid = vec![0u8; n_voxels];
for y in 0..vsid {
for x in 0..vsid {
for z in 0..MAXZDIM {
let idx = (y as usize * vsid_u + x as usize) * maxzdim_u + z as usize;
let solid = if let Some(ref p) = perlin {
classify_voxel_with_perlin(
&seeds,
p,
x,
y,
z,
params.anisotropy,
params.perlin_octaves,
params.perlin_amplitude,
)
} else {
classify_voxel(&seeds, x, y, z, params.anisotropy)
};
if solid {
grid[idx] = 1;
}
}
}
}
grid
}
#[inline]
pub(crate) fn anisotropic_dist_sq(a: [f32; 3], b: [f32; 3], anisotropy: f32) -> f32 {
let dx = a[0] - b[0];
let dy = a[1] - b[1];
let dz = (a[2] - b[2]) * anisotropy;
dx * dx + dy * dy + dz * dz
}
#[cfg(test)]
#[allow(
clippy::cast_possible_truncation,
clippy::cast_precision_loss,
clippy::cast_sign_loss
)]
mod tests {
use super::*;
fn test_params(seed: u64, seed_count: usize, air_ratio: f32) -> CaveParams {
CaveParams {
seed,
seed_count,
air_ratio,
anisotropy: 1.0,
perlin_octaves: 0,
perlin_amplitude: 0.0,
}
}
#[test]
fn place_seeds_deterministic_in_seed() {
let p = test_params(42, 16, 0.5);
let a = place_seeds(&p, 64);
let b = place_seeds(&p, 64);
assert_eq!(a.len(), b.len());
for (sa, sb) in a.iter().zip(b.iter()) {
assert_eq!(sa.pos[0].to_bits(), sb.pos[0].to_bits(), "x");
assert_eq!(sa.pos[1].to_bits(), sb.pos[1].to_bits(), "y");
assert_eq!(sa.pos[2].to_bits(), sb.pos[2].to_bits(), "z");
assert_eq!(sa.is_air, sb.is_air);
}
}
#[test]
fn place_seeds_different_seed_yields_different_seeds() {
let a = place_seeds(&test_params(1, 16, 0.5), 64);
let b = place_seeds(&test_params(2, 16, 0.5), 64);
let same = a
.iter()
.zip(b.iter())
.filter(|(x, y)| x.pos[0].to_bits() == y.pos[0].to_bits())
.count();
assert!(same * 4 < a.len(), "too many coincident x positions");
}
#[test]
fn place_seeds_air_ratio_split() {
let p = test_params(7, 100, 0.4);
let seeds = place_seeds(&p, 64);
let n_air = seeds.iter().filter(|s| s.is_air).count();
assert_eq!(n_air, 40, "40% of 100 seeds tagged air");
}
#[test]
fn place_seeds_within_volume_bounds() {
let p = test_params(7, 256, 0.5);
let seeds = place_seeds(&p, 64);
for s in &seeds {
assert!((0.0..64.0).contains(&s.pos[0]), "x in bounds");
assert!((0.0..64.0).contains(&s.pos[1]), "y in bounds");
assert!((0.0..MAXZDIM as f32).contains(&s.pos[2]), "z in bounds");
}
}
#[test]
fn classify_at_air_seed_returns_air() {
let seeds = vec![
Seed {
pos: [10.0, 20.0, 30.0],
is_air: true,
},
Seed {
pos: [100.0, 100.0, 100.0],
is_air: false,
},
];
assert!(!classify_voxel(&seeds, 10, 20, 30, 1.0), "should be air");
}
#[test]
fn classify_at_solid_seed_returns_solid() {
let seeds = vec![
Seed {
pos: [100.0, 100.0, 100.0],
is_air: true,
},
Seed {
pos: [10.0, 20.0, 30.0],
is_air: false,
},
];
assert!(classify_voxel(&seeds, 10, 20, 30, 1.0), "should be solid");
}
#[test]
fn classify_anisotropy_squishes_caves_vertically() {
let seeds = vec![
Seed {
pos: [10.0, 20.0, 30.0],
is_air: true,
},
Seed {
pos: [10.0, 20.0, 50.0],
is_air: false,
},
];
assert!(
!classify_voxel(&seeds, 10, 20, 39, 1.0),
"isotropic — closer to air seed"
);
assert!(
!classify_voxel(&seeds, 10, 20, 39, 2.0),
"aniso=2 — closer to air seed"
);
}
#[test]
#[allow(clippy::naive_bytecount)]
fn worley_classify_grid_air_ratio_roughly_matches() {
let p = test_params(7, 32, 0.5);
let vsid = 16u32;
let grid = worley_classify_grid(&p, vsid);
let n_air = grid.iter().filter(|&&b| b == 0).count();
let total = grid.len();
let ratio = n_air as f32 / total as f32;
assert!(
(0.30..=0.70).contains(&ratio),
"expected ~50% air, got {:.2}",
ratio * 100.0
);
}
#[test]
fn worley_classify_grid_deterministic_in_seed() {
let p = test_params(1234, 32, 0.5);
let g1 = worley_classify_grid(&p, 16);
let g2 = worley_classify_grid(&p, 16);
assert_eq!(g1, g2, "same seed → byte-stable grid");
}
#[test]
fn perlin_overlay_perturbs_classification() {
let no_perlin = test_params(99, 32, 0.5);
let with_perlin = CaveParams {
perlin_octaves: 3,
perlin_amplitude: 0.4, ..no_perlin
};
let g1 = worley_classify_grid(&no_perlin, 16);
let g2 = worley_classify_grid(&with_perlin, 16);
let diffs = g1.iter().zip(g2.iter()).filter(|(a, b)| a != b).count();
assert!(
diffs > 0,
"Perlin overlay should perturb the air/solid boundary"
);
let total = g1.len();
assert!(
diffs * 100 / total < 30,
"Perlin shifts boundary, doesn't randomise: {diffs} of {total} flipped"
);
}
#[test]
fn perlin_overlay_byte_stable() {
let p = CaveParams {
seed: 7,
seed_count: 32,
air_ratio: 0.5,
anisotropy: 1.0,
perlin_octaves: 3,
perlin_amplitude: 0.15,
};
let g1 = worley_classify_grid(&p, 16);
let g2 = worley_classify_grid(&p, 16);
assert_eq!(g1, g2);
}
#[test]
fn perlin_disabled_when_amplitude_zero() {
let no_perlin = test_params(11, 32, 0.5);
let zero_amplitude = CaveParams {
perlin_octaves: 3,
perlin_amplitude: 0.0,
..no_perlin
};
let g1 = worley_classify_grid(&no_perlin, 16);
let g2 = worley_classify_grid(&zero_amplitude, 16);
assert_eq!(g1, g2, "amplitude=0 should disable overlay");
}
}