use bevy::math::{IVec2, IVec3, Mat2, UVec2, Vec2, Vec3};
use super::{F_HALF, GAIN, LACUNARITY, OCTAVE_ROTATION, R_LOW, R_SPAN, octave_count, seed_mix};
use crate::gpu::noise_mirror::{hash33, perlin3_d};
use crate::terrain::ProceduralTerrain;
pub(crate) fn noise_sums(local_xz: Vec2, feature_m: f32, octaves: u32, seed: u64) -> (f32, f32) {
let p = local_xz / feature_m;
let mix = seed_mix(seed);
let (mut f_sum, mut r_sum, mut norm, mut gain) = (0.0, 0.0, 0.0, 1.0);
let mut weight = 1.0_f32;
for k in 0..octaves {
let (s, c) = (OCTAVE_ROTATION * k as f32).sin_cos();
let q = Vec2::new(c * p.x - s * p.y, s * p.x + c * p.y) * LACUNARITY.powi(k as i32);
let v = ((mix + k * 1031) % 4096) as f32;
let n = perlin3_d(Vec3::new(q.x + v, q.y + v, 0.5 + k as f32)).x;
let ridge = (1.0 - n.abs()).max(0.0);
let s_k = weight * ridge * ridge;
f_sum += gain * n;
r_sum += gain * s_k;
norm += gain;
weight = (2.0 * s_k).clamp(0.0, 1.0);
gain *= GAIN;
}
(f_sum / norm, r_sum / norm)
}
pub(crate) fn unit_height(f: f32, r: f32, ridged: f32) -> f32 {
let u_f = (0.5 + f / (2.0 * F_HALF)).clamp(0.0, 1.0);
let u_r = ((r - R_LOW) / R_SPAN).clamp(0.0, 1.0);
(1.0 - ridged) * u_f + ridged * u_r
}
pub(crate) fn surface_height(p: &ProceduralTerrain, octaves: u32, local_xz: Vec2) -> f32 {
let (f, r) = noise_sums(local_xz, p.feature_size_m, octaves, p.seed);
p.base_height_m + p.relief_m * unit_height(f, r, p.ridged.clamp(0.0, 1.0))
}
pub(crate) fn generate_surface(p: &ProceduralTerrain, size: UVec2, cell_m: f32) -> Vec<f32> {
let octaves = octave_count(p.octaves, p.feature_size_m, cell_m);
let mut out = Vec::with_capacity((size.x * size.y) as usize);
for j in 0..size.y {
for i in 0..size.x {
let local = (Vec2::new(i as f32, j as f32) + 0.5) * cell_m;
out.push(surface_height(p, octaves, local));
}
}
out
}
pub(crate) fn slope_deg(surface: &[f32], size: UVec2, cell_m: f32, i: u32, j: u32) -> f32 {
let at = |x: u32, z: u32| surface[(z * size.x + x) as usize];
let (xl, xh) = (i.saturating_sub(1), (i + 1).min(size.x - 1));
let (zl, zh) = (j.saturating_sub(1), (j + 1).min(size.y - 1));
let gx = (at(xh, j) - at(xl, j)) / ((xh - xl) as f32 * cell_m);
let gz = (at(i, zh) - at(i, zl)) / ((zh - zl) as f32 * cell_m);
(gx * gx + gz * gz).sqrt().atan().to_degrees()
}
pub(crate) fn percentile(values: &mut [f32], q: f32) -> f32 {
values.sort_by(f32::total_cmp);
values[((values.len() - 1) as f32 * q).round() as usize]
}
pub(crate) fn smoothstep(a: f32, b: f32, x: f32) -> f32 {
let t = ((x - a) / (b - a)).clamp(0.0, 1.0);
t * t * (3.0 - 2.0 * t)
}
pub(crate) fn overlay(mask: f32, detail: f32) -> f32 {
if mask < 0.5 {
2.0 * mask * detail
} else {
1.0 - 2.0 * (1.0 - mask) * (1.0 - detail)
}
}
pub(crate) const DETAIL_COARSE_M: f32 = 1.25;
pub(crate) const DETAIL_FINE_M: f32 = 0.125;
pub(crate) fn detail_keep(feature_m: f32, footprint_m: f32) -> f32 {
let t = ((footprint_m / feature_m - 0.5) / 0.5).clamp(0.0, 1.0);
1.0 - t * t * (3.0 - 2.0 * t)
}
pub(crate) fn layer_weights(slope_deg: f32, sediment_m: f32, detail: f32) -> [f32; 4] {
let rock = overlay(smoothstep(30.0, 38.0, slope_deg), detail);
let sand = overlay(smoothstep(0.3, 0.7, sediment_m), detail)
* (1.0 - smoothstep(26.0, 30.0, slope_deg))
* (1.0 - rock);
let band = overlay(smoothstep(16.0, 26.0, slope_deg), detail) * (1.0 - smoothstep(22.0, 29.0, slope_deg));
let rest = (1.0 - rock) * (1.0 - sand);
[rock, sand, rest * band, rest * (1.0 - band)]
}
pub(crate) const LAYER_VARIATION_M: f32 = 45.0;
pub(crate) const LAYER_VARIATION_DEPTH: f32 = 0.25;
pub(crate) const NOISE_RMS: f32 = 0.2268;
pub(crate) fn layer_variation(field: f32, w: [f32; 4]) -> [f32; 4] {
let field = field.clamp(-1.0, 1.0);
let shift = field * LAYER_VARIATION_DEPTH * w[3];
let mut v = w;
let taken = shift.clamp(-v[2], v[3]);
v[2] += taken;
v[3] -= taken;
let total = v.iter().sum::<f32>();
v.map(|x| x / total.max(1e-6))
}
pub(crate) const TURF_FRESH: [f32; 3] = [1.00, 1.00, 1.00];
pub(crate) const TURF_DRY: [f32; 3] = [1.35, 1.20, 0.69];
pub(crate) const TURF_STRAW: [f32; 3] = [1.55, 1.37, 0.82];
pub(crate) const TURF_WORN: [f32; 3] = [1.13, 0.95, 0.73];
pub(crate) const TURF_STRENGTH: f32 = 0.85;
pub(crate) fn turf_palette(field: f32, clump: f32) -> ([f32; 3], [f32; 4]) {
let p = 2.0f32;
let mut dry = (field.max(0.0) * (0.5 + clump).max(0.0) * 1.6).powf(p).min(1.0);
let mut straw = ((field - 0.45).max(0.0) * 3.0 * (0.5 + clump).max(0.0) * 1.6)
.powf(p)
.min(1.0);
let mut worn = ((-field - 0.55).max(0.0) * 3.0 * (0.5 - clump).max(0.0) * 1.6)
.powf(p)
.min(1.0);
let others = dry + straw + worn;
if others > 1.0 {
let k = 1.0 / others;
dry *= k;
straw *= k;
worn *= k;
}
let fresh = (1.0 - dry - straw - worn).max(0.0);
let shares = [fresh, dry, straw, worn];
let mut mixed = [0.0f32; 3];
for c in 0..3 {
mixed[c] = fresh * TURF_FRESH[c]
+ dry * TURF_DRY[c]
+ straw * TURF_STRAW[c]
+ worn * TURF_WORN[c];
mixed[c] = 1.0 + (mixed[c] - 1.0) * TURF_STRENGTH;
}
(mixed, shares)
}
pub(crate) const ROCK_BLOCK_M: f32 = 0.95;
pub(crate) const ROCK_BLOCK_TABULAR: f32 = 2.6;
pub(crate) const ROCK_JOINT_WIDTH: f32 = 0.19;
pub(crate) const ROCK_JOINT2_NORMAL: Vec3 = Vec3::new(0.71, 0.46, -0.53);
pub(crate) const ROCK_JOINT2_MIN_M: f32 = 0.55;
pub(crate) const ROCK_JOINT2_MAX_M: f32 = 2.60;
pub(crate) const ROCK_FORMATION_M: f32 = 46.0;
pub(crate) const ROCK_JOINT2_WANDER: f32 = 0.48;
pub(crate) const ROCK_JOINT2_SEGMENT_M: f32 = 1.9;
pub(crate) const ROCK_JOINT2_BREAK: f32 = 0.30;
pub(crate) const ROCK_JOINT2_WIDTH: f32 = 0.075;
pub(crate) const ROCK_BED_REGION_M: f32 = 260.0;
pub(crate) const ROCK_BLOCK_WARP_M: f32 = 0.30;
pub(crate) const ROCK_BLOCK_WARP_CELLS: f32 = 4.0;
pub(crate) const ROCK_BLOCK_WEIBULL: f32 = 1.6;
pub(crate) const ROCK_BLOCK_WEIBULL_SPREAD: f32 = 0.85;
pub(crate) const ROCK_BLOCK_WEIBULL_MAX: f32 = 2.0;
fn wesl_fract(v: Vec3) -> Vec3 {
v - v.floor()
}
pub(crate) fn rock_block_coords(p: Vec3) -> Vec3 {
let normal = Vec3::new(0.32, 0.90, 0.28).normalize();
let along = p - normal * p.dot(normal) * (1.0 - 1.0 / ROCK_BLOCK_TABULAR);
let warp = crate::gpu::noise_mirror::perlin3_d(p / (ROCK_BLOCK_M * ROCK_BLOCK_WARP_CELLS));
let gradient = Vec3::new(warp.y, warp.z, warp.w);
(along + gradient * ROCK_BLOCK_WARP_M) / ROCK_BLOCK_M
}
pub(crate) const ROCK_TONES: [Vec3; 4] = [
Vec3::new(0.848, 1.015, 1.309),
Vec3::new(1.223, 1.083, 0.832),
Vec3::new(1.214, 1.270, 1.256),
Vec3::new(0.715, 0.632, 0.603),
];
pub(crate) const ROCK_PALETTE_EDGE: f32 = 0.12;
pub(crate) const BED_REGION_BIAS: f32 = 0.30;
pub(crate) const ROCK_PALETTE_STRENGTH: f32 = 0.90;
pub(crate) fn rock_palette(p: Vec3) -> Vec3 {
let q = rock_block_coords(p);
let cell = crate::gpu::noise_mirror::cell3(q);
let sel_w = smoothstep(
0.5 - ROCK_PALETTE_EDGE,
0.5 + ROCK_PALETTE_EDGE,
cell.factor,
);
let reg_w = smoothstep(
0.5 - ROCK_PALETTE_EDGE,
0.5 + ROCK_PALETTE_EDGE,
cell.factor2,
);
let shares = [
(1.0 - sel_w) * (1.0 - reg_w),
(1.0 - sel_w) * reg_w,
sel_w * (1.0 - reg_w),
sel_w * reg_w,
];
let total: f32 = shares.iter().sum();
let mut mixed = Vec3::ZERO;
for (share, tone) in shares.iter().zip(ROCK_TONES.iter()) {
mixed += *tone * (*share / total);
}
Vec3::ONE.lerp(mixed, ROCK_PALETTE_STRENGTH)
}
pub(crate) const BED_TONES: [Vec3; 4] = ROCK_TONES;
pub(crate) const BED_PALETTE_EDGE: f32 = ROCK_PALETTE_EDGE;
pub(crate) const BED_PALETTE_STRENGTH: f32 = ROCK_PALETTE_STRENGTH;
pub(crate) fn bed_palette(index: f32, region: f32) -> Vec3 {
let i = index.floor() as i32;
let h = crate::gpu::noise_mirror::hash31(IVec3::new(i, 7, 0));
let h2 = crate::gpu::noise_mirror::hash31(IVec3::new(i, 11, 0));
let sel = smoothstep(
0.5 - BED_PALETTE_EDGE,
0.5 + BED_PALETTE_EDGE,
(h + region * BED_REGION_BIAS).clamp(0.0, 1.0),
);
let reg = smoothstep(
0.5 - BED_PALETTE_EDGE,
0.5 + BED_PALETTE_EDGE,
(h2 + region * BED_REGION_BIAS).clamp(0.0, 1.0),
);
let shares = [
(1.0 - sel) * (1.0 - reg),
(1.0 - sel) * reg,
sel * (1.0 - reg),
sel * reg,
];
let total: f32 = shares.iter().sum();
let mut mixed = Vec3::ZERO;
for (share, tone) in shares.iter().zip(BED_TONES.iter()) {
mixed += *tone * (*share / total);
}
Vec3::ONE.lerp(mixed, BED_PALETTE_STRENGTH)
}
pub(crate) fn rock_joint2_spacing(p: Vec3) -> f32 {
let formation = (p / ROCK_FORMATION_M).floor();
let h = crate::gpu::noise_mirror::hash31(formation.as_ivec3());
let weibull = (-(1.0 - h).max(1e-6).ln())
.powf(1.0 / ROCK_BLOCK_WEIBULL)
.min(ROCK_BLOCK_WEIBULL_MAX);
ROCK_JOINT2_MIN_M + (ROCK_JOINT2_MAX_M - ROCK_JOINT2_MIN_M) * weibull / ROCK_BLOCK_WEIBULL_MAX
}
pub(crate) fn rock_bed_region(p: Vec3, colour_keep: f32) -> f32 {
if colour_keep > 0.0 {
crate::gpu::noise_mirror::perlin3_d(p / ROCK_BED_REGION_M).x
} else {
0.0
}
}
pub(crate) fn rock_joint(p: Vec3) -> f32 {
let q = rock_block_coords(p);
let cell = crate::gpu::noise_mirror::cell3(q);
let to_centre = cell.point - wesl_fract(q);
let len = to_centre.length().max(1e-6);
let outward = to_centre / len;
let inside = wesl_fract(q);
let to_wall = Vec3::new(
if outward.x < 0.0 { inside.x } else { 1.0 - inside.x },
if outward.y < 0.0 { inside.y } else { 1.0 - inside.y },
if outward.z < 0.0 { inside.z } else { 1.0 - inside.z },
);
let half = to_wall.x.min(to_wall.y).min(to_wall.z);
let weibull = (-(1.0 - cell.factor).max(1e-6).ln())
.powf(1.0 / ROCK_BLOCK_WEIBULL)
.min(ROCK_BLOCK_WEIBULL_MAX);
let width = ROCK_JOINT_WIDTH * (0.25 + ROCK_BLOCK_WEIBULL_SPREAD * weibull);
let joint = 1.0 - smoothstep(0.0, width, half);
let spacing = rock_joint2_spacing(p);
let cross = p.dot(ROCK_JOINT2_NORMAL.normalize());
let wander = crate::gpu::noise_mirror::perlin3_d(p / (ROCK_BLOCK_M * ROCK_BLOCK_WARP_CELLS)).x
* spacing
* ROCK_JOINT2_WANDER;
let cross_w = cross + wander;
let phase = cross_w / spacing;
let to_plane = (phase - phase.round()).abs() * spacing;
let segment = crate::gpu::noise_mirror::perlin3_d(p / (spacing * ROCK_JOINT2_SEGMENT_M)).x;
let present2 = smoothstep(-ROCK_JOINT2_BREAK, ROCK_JOINT2_BREAK, segment);
let joint2 = (1.0 - smoothstep(0.0, spacing * ROCK_JOINT2_WIDTH, to_plane)) * present2;
joint.max(joint2)
}
pub(crate) const ROCK_BED_M: f32 = 4.5;
pub(crate) const BED_WARP_AMP: f32 = 1.0;
pub(crate) fn bed_warp(index: f32) -> f32 {
let cell = IVec3::new(index as i32, 3, 0);
let h = crate::gpu::noise_mirror::hash31(cell);
BED_WARP_AMP
* (0.60 * (index * 0.21).sin()
+ 0.28 * (index * 0.53 + 1.3).sin()
+ 0.12 * (index * 1.17 + 2.6).sin())
+ 0.10 * (h - 0.5)
}
pub(crate) fn bed_boundary(index: f32) -> f32 {
index + bed_warp(index)
}
pub(crate) fn bed_structure(bed: f32) -> (f32, f32, f32) {
let mut index = bed;
for _ in 0..4 {
index = bed - bed_warp(index);
}
let mut bed_index = index.floor();
let mut bottom = bed_boundary(bed_index);
if bottom > bed {
bed_index -= 1.0;
bottom = bed_boundary(bed_index);
}
let top = bed_boundary(bed_index + 1.0);
(bed_index, bottom, top)
}
pub(crate) const SCREE_CELL_M: f32 = 0.45;
pub(crate) const SCREE_SIZE_MIN: f32 = 0.35;
pub(crate) const SCREE_SIZE_MAX: f32 = 2.20;
pub(crate) const SCREE_FILL: f32 = 0.72;
pub(crate) const SCREE_FINE_SCALE: f32 = 0.38;
pub(crate) const SCREE_BAND_DEG: [f32; 4] = [16.0, 26.0, 31.0, 40.0];
pub(crate) fn scree_weight(slope_deg: f32) -> f32 {
smoothstep(SCREE_BAND_DEG[0], SCREE_BAND_DEG[1], slope_deg)
* (1.0 - smoothstep(SCREE_BAND_DEG[2], SCREE_BAND_DEG[3], slope_deg))
}
pub(crate) fn scree_scale_coverage(xz: Vec2, cell_m: f32, size_min: f32, size_max: f32) -> f32 {
let cell = crate::gpu::noise_mirror::cell2(xz / cell_m);
let size = size_min + (size_max - size_min) * cell.factor.powi(3);
let radius = 0.5 * size * SCREE_FILL;
1.0 - smoothstep(radius * 0.80, radius, cell.distance)
}
pub(crate) fn scree_coverage(xz: Vec2) -> f32 {
let coarse = scree_scale_coverage(xz, SCREE_CELL_M, SCREE_SIZE_MIN, SCREE_SIZE_MAX);
let fine = scree_scale_coverage(
xz,
SCREE_CELL_M * SCREE_FINE_SCALE,
SCREE_SIZE_MIN * 0.8,
SCREE_SIZE_MAX * 0.8,
);
coarse.max(fine * (1.0 - coarse))
}
pub(crate) const HEX_SCALE: f32 = 3.464_101_6;
pub(crate) const DETAIL_GATE: f32 = 0.01;
pub(crate) const HEIGHT_BLEND: f32 = 8.0;
pub(crate) const HEIGHT_BLEND_RESOLVED_M: f32 = 0.01;
pub(crate) const HEIGHT_BLEND_LOST_M: f32 = 0.03;
pub(crate) fn hex_grid(st: Vec2) -> ([IVec2; 3], Vec3) {
let s = st * HEX_SCALE;
let skewed = Vec2::new(s.x - 0.577_350_27 * s.y, 1.154_700_5 * s.y);
let base = skewed.floor();
let f = skewed - base;
let base = base.as_ivec2();
let z = 1.0 - f.x - f.y;
let up = if z <= 0.0 { 1.0 } else { 0.0 };
let flip = 2.0 * up - 1.0;
let u = up as i32;
let w = Vec3::new(-z * flip, up - f.y * flip, up - f.x * flip);
let vertices = [
base + IVec2::new(u, u),
base + IVec2::new(u, 1 - u),
base + IVec2::new(1 - u, u),
];
(vertices, w)
}
pub(crate) fn hex_centre(v: IVec2) -> Vec2 {
let p = v.as_vec2();
Vec2::new(p.x + 0.5 * p.y, 0.866_025_4 * p.y) / HEX_SCALE
}
pub(crate) const HEX_CONTRAST: f32 = 0.6;
pub(crate) const TAP_MIN: f32 = 0.00125;
fn hex_shares(bary: Vec3) -> Vec3 {
let w2 = bary * bary;
let b7 = w2 * w2 * w2 * bary;
b7 / (b7.x + b7.y + b7.z)
}
pub(crate) fn hex_tap_weights(bary: Vec3, heights: Vec3, all: bool) -> Vec3 {
let shares = hex_shares(bary);
let live = if all { Vec3::ONE } else { Vec3::select(shares.cmpgt(Vec3::splat(TAP_MIN)), Vec3::ONE, Vec3::ZERO) };
let weighted = shares * (Vec3::ONE + (2.0 * heights - Vec3::ONE) * HEX_CONTRAST) * live;
weighted / (weighted.x + weighted.y + weighted.z).max(1e-12)
}
pub(crate) fn hex_tap(v: IVec2, layer: u32, st: Vec2) -> (Vec2, Mat2) {
let h = hash33(IVec3::new(v.x, v.y, layer as i32));
let k = (h.z * 8.0) as u32;
let swap = k & 1 != 0;
let signs = Vec2::new(
if k & 2 != 0 { -1.0 } else { 1.0 },
if k & 4 != 0 { -1.0 } else { 1.0 },
);
let (c0, c1) = if swap { (Vec2::Y, Vec2::X) } else { (Vec2::X, Vec2::Y) };
let rot = Mat2::from_cols(c0 * signs, c1 * signs);
let centre = hex_centre(v);
(rot * (st - centre) + centre + Vec2::new(h.x, h.y), rot)
}
pub(crate) fn tap_slope(texture_slope: Vec2, rot: Mat2) -> Vec2 {
rot.transpose() * texture_slope
}
pub(crate) fn height_sharpness(footprint_m: f32) -> f32 {
HEIGHT_BLEND * (1.0 - smoothstep(HEIGHT_BLEND_RESOLVED_M, HEIGHT_BLEND_LOST_M, footprint_m))
}
pub(crate) fn height_blend(w: Vec3, h: Vec3, sharpness: f32) -> Vec3 {
let total = w.x + w.y + w.z;
let lift = |h: f32| (sharpness * (h - 0.5)).exp();
let e = (w - Vec3::splat(DETAIL_GATE)).max(Vec3::ZERO) * Vec3::new(lift(h.x), lift(h.y), lift(h.z));
let sum = e.x + e.y + e.z;
if sum <= 0.0 {
return w;
}
e * (total / sum)
}
pub(crate) fn grain_share(b: Vec3, tile: Vec3, grain: f32) -> f32 {
let kept = Vec3::select(tile.cmpgt(Vec3::ZERO), Vec3::splat(grain), Vec3::ONE);
let total = b.x + b.y + b.z;
if total > 1e-6 { b.dot(kept) / total } else { 1.0 }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_beds_are_bands_and_not_blotches() {
let mut crossings = 0;
let mut spacings = Vec::new();
let mut last = None;
let mut last_y = 0.0_f32;
for i in 0..1000 {
let y = i as f32 * 0.04;
let bed = (y * 0.90) / ROCK_BED_M;
let (index, _, _) = bed_structure(bed);
if let Some(was) = last
&& was != index
{
crossings += 1;
if crossings > 1 {
spacings.push(y - last_y);
}
last_y = y;
}
last = Some(index);
}
assert!(
crossings >= 4,
"the beds are bands: {crossings} crossings in 40 m"
);
let longest = spacings.iter().copied().fold(0.0_f32, f32::max);
let shortest = spacings.iter().copied().fold(f32::INFINITY, f32::min);
assert!(
longest < 3.0 * ROCK_BED_M,
"the beds' spacing is bounded: the longest is {longest} m"
);
assert!(
shortest > 0.2 * ROCK_BED_M,
"the beds' spacing is bounded: the shortest is {shortest} m"
);
}
#[test]
fn the_beds_thicknesses_vary_and_stack() {
let mut thicknesses = Vec::new();
for i in 0..2000 {
thicknesses.push(bed_boundary(i as f32 + 1.0) - bed_boundary(i as f32));
}
thicknesses.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let p10 = thicknesses[thicknesses.len() / 10];
let p90 = thicknesses[thicknesses.len() * 9 / 10];
assert!(
p90 > p10 * 1.25,
"the beds' thicknesses span only {:.2}:1", p90 / p10
);
assert!(p10 > 0.35, "the beds' thicknesses are bounded: {p10}");
let mut increasing = 0;
for i in 0..2000 {
if bed_boundary(i as f32 + 1.0) > bed_boundary(i as f32) {
increasing += 1;
}
}
assert_eq!(increasing, 2000, "the beds stack");
}
#[test]
fn the_blocks_are_discrete() {
let mut joint = Vec::new();
for i in 0..150 {
let p = Vec3::new(i as f32 * 0.2, 12.0, 30.0);
joint.push(rock_joint(p));
}
let mut crossings = 0;
for w in joint.windows(2) {
if (w[0] > 0.5) != (w[1] > 0.5) {
crossings += 1;
}
}
assert!(
crossings >= 3,
"the blocks are discrete, not a wash: {crossings} crossings in 30 m"
);
let low = joint.iter().filter(|j| **j < 0.15).count();
assert!(
low * 2 > joint.len(),
"the joint is a line, not a field: {low} of {} below 0.15",
joint.len()
);
}
#[test]
fn the_blocks_are_of_many_sizes() {
let mut boundaries = Vec::new();
let mut last = rock_joint(Vec3::new(0.0, 20.0, 0.0)) > 0.5;
for i in 1..4000 {
let x = i as f32 * 0.02;
let now = rock_joint(Vec3::new(x, 20.0, 0.0)) > 0.5;
if now != last {
boundaries.push(x);
last = now;
}
}
assert!(
boundaries.len() >= 8,
"the cut crosses enough blocks to measure: {}",
boundaries.len()
);
let sizes: Vec<f32> = boundaries
.windows(2)
.map(|w| w[1] - w[0])
.filter(|s| *s > 0.05)
.collect();
assert!(sizes.len() >= 6, "the sizes to measure: {}", sizes.len());
let mut sorted = sizes.clone();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
let p10 = sorted[sorted.len() / 10];
let p90 = sorted[sorted.len() * 9 / 10];
let ratio = p90 / p10.max(1e-6);
assert!(
ratio >= 2.0,
"the blocks are of many sizes: the p90/p10 ratio is {ratio:.2} over {} blocks \
({p10:.2} m to {p90:.2} m)",
sizes.len()
);
}
#[test]
fn the_second_joint_sets_spacing_varies() {
let mut spacings = Vec::new();
for i in 0..400 {
let p = Vec3::new(i as f32 * 3.1, 18.0 + (i as f32) * 0.91, 0.0);
spacings.push(rock_joint2_spacing(p));
}
let mut sorted = spacings.clone();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
let p10 = sorted[sorted.len() / 10];
let p90 = sorted[sorted.len() * 9 / 10];
let ratio = p90 / p10.max(1e-6);
assert!(
ratio >= 2.5,
"the second set's spacing varies: the p90/p10 ratio is {ratio:.2} over {} \
formations ({p10:.2} m to {p90:.2} m)",
spacings.len()
);
}
#[test]
fn a_gate_skips_the_evaluation_and_not_the_value() {
let mut same = 0;
let mut samples = 0;
for i in 0..200 {
let p = Vec3::new(i as f32 * 0.37, 18.0 + (i as f32) * 0.11, 0.0);
let gated = rock_bed_region(p, 1.0);
let ungated = crate::gpu::noise_mirror::perlin3_d(p / ROCK_BED_REGION_M).x;
if (gated - ungated).abs() < 1e-6 {
same += 1;
}
samples += 1;
}
assert_eq!(same, samples, "the gate does not change the field's value");
let closed = rock_bed_region(Vec3::new(1.0, 18.0, 0.0), 0.0);
assert_eq!(closed, 0.0, "the closed gate is the neutral value");
}
#[test]
fn the_palette_does_not_move_the_mean() {
let mut mean = Vec3::ZERO;
for tone in ROCK_TONES.iter() {
mean += *tone;
}
mean /= 4.0;
assert!(
(mean - Vec3::ONE).length() < 1e-3,
"the palette's equal-weight mean is {mean:?}, not (1, 1, 1)"
);
}
#[test]
fn the_fragments_are_of_different_rock() {
let hues: Vec<f32> = ROCK_TONES
.iter()
.map(|t| {
let max = t.x.max(t.y).max(t.z);
let min = t.x.min(t.y).min(t.z);
let d = max - min;
if d < 1e-6 { return 0.0; }
let h = if max == t.x {
((t.y - t.z) / d).rem_euclid(6.0)
} else if max == t.y {
(t.z - t.x) / d + 2.0
} else {
(t.x - t.y) / d + 4.0
};
h * 60.0
})
.collect();
let mut sorted = hues.clone();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
let spread = sorted[3] - sorted[0];
assert!(
spread > 60.0,
"the palette's hues are different colours: the spread is {spread:.1} degrees"
);
let mut differ = 0;
let mut samples = 0;
for i in 0..200 {
let p = Vec3::new(i as f32 * 0.37, 20.0 + (i as f32) * 0.11, 0.0);
let a = rock_palette(p);
let b = rock_palette(p + Vec3::new(ROCK_BLOCK_M * 0.9, 0.0, 0.0));
if (a - b).length() > 0.10 {
differ += 1;
}
samples += 1;
}
assert!(
differ * 100 / samples > 60,
"the fragments are of different rock: {differ} of {samples} neighbours differ"
);
}
#[test]
fn the_beds_are_of_different_rock() {
let mut hues = Vec::new();
for i in 0..200 {
let c = bed_palette(i as f32, 0.0);
let max = c.x.max(c.y).max(c.z);
let min = c.x.min(c.y).min(c.z);
let d = max - min;
if d < 1e-6 {
continue;
}
let h = if max == c.x {
((c.y - c.z) / d).rem_euclid(6.0)
} else if max == c.y {
(c.z - c.x) / d + 2.0
} else {
(c.x - c.y) / d + 4.0
};
hues.push(h * 60.0);
}
assert!(hues.len() > 150, "the hues to measure: {}", hues.len());
let mut sorted = hues.clone();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
let spread = sorted[sorted.len() - 1] - sorted[0];
assert!(
spread > 60.0,
"the beds are of different rock: the hue's spread is {spread:.1} degrees"
);
}
#[test]
fn the_region_shifts_the_beds_colour() {
let mut differ = 0;
let mut samples = 0;
for i in 0..200 {
let a = bed_palette(i as f32, -0.5);
let b = bed_palette(i as f32, 0.5);
if (a - b).length() > 0.02 {
differ += 1;
}
samples += 1;
}
assert!(
differ * 100 / samples > 50,
"the region shifts the beds' colour: {differ} of {samples} beds differ"
);
}
#[test]
fn the_blocks_are_tabular() {
let normal = Vec3::new(0.32, 0.90, 0.28).normalize();
let in_plane = (Vec3::new(1.0, 0.0, 0.0) - normal * normal.x).normalize();
let across = normal.cross(in_plane).normalize();
let extent = |dir: Vec3| {
let q_dir = rock_block_coords(dir * 100.0) - rock_block_coords(Vec3::ZERO);
q_dir.length()
};
let along = extent(in_plane);
let across_cut = extent(across);
assert!(
along > across_cut,
"the blocks stretch along the bedding: the cell is {along:.3} along it and \
{across_cut:.3} across it"
);
assert!(
normal.y > 0.8,
"the bedding plane is a near-horizontal one: {normal:?}"
);
}
#[test]
fn the_blocks_are_in_the_rocks_own_frame() {
let mut same = 0;
for i in 0..200 {
let x = i as f32 * 0.37;
let a = rock_block_coords(Vec3::new(x, 10.0, 40.0)).floor();
let b = rock_block_coords(Vec3::new(x, 14.0, 40.0)).floor();
if a == b {
same += 1;
}
}
assert!(
same < 100,
"the blocks are in the rock's 3D frame: {same} of 200 are in the same cell \
across 4 m of height"
);
}
#[test]
fn the_scree_is_discrete_and_not_a_wash() {
let mut coverage = Vec::new();
for i in 0..200 {
coverage.push(scree_coverage(Vec2::new(i as f32 * 0.04, 3.0)));
}
let mut crossings = 0;
for w in coverage.windows(2) {
let a = w[0] > 0.5;
let b = w[1] > 0.5;
if a != b {
crossings += 1;
}
}
assert!(
crossings >= 6,
"the scree is a scatter, not a wash: {crossings} crossings in 8 m"
);
assert!(
coverage.iter().all(|c| *c <= 1.0),
"the coverage never exceeds one"
);
let below_half = coverage.iter().filter(|c| **c < 0.5).count();
assert!(
below_half * 2 > coverage.len(),
"the turf shows between the stones: {below_half} of {} below half",
coverage.len()
);
}
#[test]
fn the_stones_have_many_sizes() {
let mut areas = Vec::new();
let mut offsets = Vec::new();
for j in 0..200 {
for i in 0..200 {
let p = Vec2::new(i as f32 * 0.1, j as f32 * 0.1);
let cell = crate::gpu::noise_mirror::cell2(p / SCREE_CELL_M);
let size = SCREE_SIZE_MIN + (SCREE_SIZE_MAX - SCREE_SIZE_MIN) * cell.factor.powi(3);
let radius = 0.5 * size * SCREE_FILL;
areas.push(radius * radius);
offsets.push(cell.point.fract());
}
}
areas.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let p10 = areas[areas.len() / 10];
let p90 = areas[areas.len() * 9 / 10];
assert!(
p90 > p10 * 4.0,
"the stones' areas span only {:.1}:1", p90 / p10
);
let mean = offsets.iter().fold(Vec2::ZERO, |a, b| a + *b) / offsets.len() as f32;
assert!(
mean.x > 0.35 && mean.x < 0.65 && mean.y > 0.35 && mean.y < 0.65,
"the stones sit in the middle of their cells on average: {mean:?}"
);
let spread = offsets
.iter()
.fold(Vec2::ZERO, |a, b| a + (*b - mean).abs())
/ offsets.len() as f32;
assert!(
spread.x > 0.2 && spread.y > 0.2,
"the stones are spread inside their cells: {spread:?}"
);
}
#[test]
fn the_scree_lies_at_the_rocks_foot() {
assert_eq!(scree_weight(0.0), 0.0, "the meadow has no stones");
assert_eq!(scree_weight(15.0), 0.0, "the meadow has no stones");
assert_eq!(scree_weight(45.0), 0.0, "the cliff has no stones");
let peak = (14..42)
.map(|d| (d as f32, scree_weight(d as f32)))
.max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
.map(|(d, _)| d)
.unwrap_or(0.0);
assert!(
(22.0..=33.0).contains(&peak),
"the scree peaks at {peak} degrees, at the rock's foot"
);
let w = layer_weights(28.0, 0.0, 0.5);
assert!(w[0] < 0.1, "the rock has not begun at 28 degrees: {:?}", w);
let wide = (14..42)
.filter(|d| scree_weight(*d as f32) > 0.5)
.count();
assert!(wide >= 6, "the scree's band is {wide} degrees wide, too narrow");
}
#[test]
fn the_turf_palette_shares_sum_to_one_and_keep_the_mean_and_the_hue() {
let mut hues = Vec::new();
for field in (-30..=30).map(|k| k as f32 / 20.0) {
for clump in (-20..=20).map(|k| k as f32 / 20.0) {
let (mixed, shares) = turf_palette(field, clump);
assert!(
(shares.iter().sum::<f32>() - 1.0).abs() < 1e-5,
"the shares do not sum to one at {field}/{clump}: {shares:?}"
);
assert!(
shares.iter().all(|s| (0.0..=1.0).contains(s)),
"a share is out of range at {field}/{clump}: {shares:?}"
);
assert!(
mixed.iter().all(|c| (0.5..=2.0).contains(c)),
"the mixture left the tones' range at {field}/{clump}: {mixed:?}"
);
hues.push(mixed[1] / mixed[0]);
}
}
let min = hues.iter().cloned().fold(f32::INFINITY, f32::min);
let max = hues.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
assert!(
min < 0.95 && max <= 1.0 + 1e-5,
"the palette's hue does not move: the G/R ratio spans {min} to {max}"
);
let (mixed, _) = turf_palette(0.0, 0.0);
for c in mixed {
assert!((c - 1.0).abs() < 1e-5, "the fresh tone is not the identity: {mixed:?}");
}
}
#[test]
fn the_detail_band_limit_keeps_resolved_octaves_and_cancels_the_rest() {
assert_eq!(detail_keep(DETAIL_COARSE_M, 0.0), 1.0);
assert_eq!(detail_keep(DETAIL_COARSE_M, DETAIL_COARSE_M / 2.0), 1.0);
assert_eq!(detail_keep(DETAIL_COARSE_M, DETAIL_COARSE_M), 0.0);
assert_eq!(detail_keep(DETAIL_COARSE_M, 10.0), 0.0);
let mut last = 1.0;
for k in 0..=400 {
let f = f64::from(k) / 400.0 * f64::from(DETAIL_COARSE_M);
let w = detail_keep(DETAIL_COARSE_M, f as f32);
assert!(w <= last + 1e-6, "not monotone at {f}");
assert!((last - w) < 0.02, "a step of {} at {f}", last - w);
last = w;
}
let half_fine = DETAIL_FINE_M * 0.5;
let half_coarse = DETAIL_COARSE_M * 0.5;
assert!(detail_keep(DETAIL_FINE_M, half_fine) > 0.99);
assert_eq!(detail_keep(DETAIL_COARSE_M, half_coarse), 1.0);
assert!(half_fine < half_coarse);
}
fn fnv1a(bytes: &[u8]) -> u64 {
bytes.iter().fold(0xcbf2_9ce4_8422_2325, |h, b| {
(h ^ u64::from(*b)).wrapping_mul(0x0000_0100_0000_01b3)
})
}
fn stats(p: &ProceduralTerrain) -> (f32, f32, f32, f32) {
let size = UVec2::splat(1024);
let surface = generate_surface(p, size, 2.0);
let mut heights: Vec<f32> = surface.iter().map(|h| h - p.base_height_m).collect();
let spread = percentile(&mut heights, 0.995) - percentile(&mut heights, 0.005);
let mut slopes: Vec<f32> = (0..size.y)
.flat_map(|j| (0..size.x).map(move |i| (i, j)))
.map(|(i, j)| slope_deg(&surface, size, 2.0, i, j))
.collect();
let steep = slopes.iter().filter(|s| **s > 40.0).count() as f32 / slopes.len() as f32;
(
spread,
percentile(&mut slopes, 0.5),
percentile(&mut slopes, 0.95),
steep * 100.0,
)
}
#[test]
fn the_noise_sums_fill_their_unit_ranges() {
let (mut f, mut r) = (Vec::new(), Vec::new());
for seed in 0..32u64 {
for j in 0..96 {
for i in 0..96 {
let p = Vec2::new(i as f32, j as f32) * 0.31 + Vec2::splat(0.1);
let (a, b) = noise_sums(p, 1.0, 8, seed * 7 + 1);
f.push(a);
r.push(b);
}
}
}
let f_lo = percentile(&mut f, 0.005);
let f_hi = percentile(&mut f, 0.995);
let r_lo = percentile(&mut r, 0.005);
let r_hi = percentile(&mut r, 0.995);
println!("f {f_lo:.3} {f_hi:.3} r {r_lo:.3} {r_hi:.3}");
assert!(
(f_lo + F_HALF).abs() < 0.03 && (f_hi - F_HALF).abs() < 0.03,
"f {f_lo} {f_hi}"
);
assert!(
(r_lo - R_LOW).abs() < 0.03 && (r_hi - (R_LOW + R_SPAN)).abs() < 0.03,
"r {r_lo} {r_hi}"
);
}
#[test]
fn the_presets_have_the_measured_statistics() {
let table = [
(ProceduralTerrain::hills(42), 91.8, 12.4, 25.5, 0.0),
(ProceduralTerrain::mountains(42), 549.5, 41.1, 61.9, 52.6),
(ProceduralTerrain::dunes(42), 39.9, 9.1, 18.6, 0.0),
(ProceduralTerrain::plains(42), 13.7, 1.4, 2.9, 0.0),
];
let mut misses = Vec::new();
for (preset, spread, p50, p95, steep) in table {
let (s, m, h, e) = stats(&preset);
println!(
"R {} F {}: spread {s:.1} (want {spread}) slope p50 {m:.1} ({p50}) p95 {h:.1} ({p95}) >40° {e:.2} % ({steep})",
preset.relief_m, preset.feature_size_m
);
if (s / spread - 1.0).abs() >= 0.10
|| (m / p50 - 1.0).abs() >= 0.15
|| (h / p95 - 1.0).abs() >= 0.15
|| (e - steep).abs() >= 3.0
{
misses.push(format!("R {} F {}", preset.relief_m, preset.feature_size_m));
}
}
assert!(misses.is_empty(), "{misses:?}");
}
#[test]
fn the_terrain_is_frozen() {
let surface = generate_surface(&ProceduralTerrain::hills(42), UVec2::splat(256), 8.0);
let mm: Vec<i32> = surface
.iter()
.map(|h| (h * 1000.0).round() as i32)
.collect();
let hash = fnv1a(bytemuck::cast_slice(&mm));
println!("the_terrain_is_frozen: {hash:#018x}");
assert_eq!(
hash, GOLDEN_HILLS_256,
"the generator changed: re-bless once, deliberately"
);
}
const GOLDEN_HILLS_256: u64 = 0x6f04_892c_64bc_b5eb;
#[test]
fn the_layer_weights_sum_to_one_and_pick_the_right_layer() {
for a in (0..90).step_by(1) {
for s in [0.0, 0.2, 0.4, 0.5, 0.6, 0.8, 1.0, 4.0] {
for d in [0.0, 0.25, 0.5, 0.75, 1.0] {
let w = layer_weights(a as f32, s, d);
assert!(
(w.iter().sum::<f32>() - 1.0).abs() < 1e-6,
"{a}° {s} m detail {d}: {w:?}"
);
assert!(w.iter().all(|x| (0.0..=1.0).contains(x)));
}
}
}
assert!(layer_weights(45.0, 0.0, 0.5)[0] >= 0.99);
assert!(layer_weights(20.0, 1.0, 0.5)[1] >= 0.99);
assert_eq!(layer_weights(10.0, 0.0, 0.5)[3], 1.0);
let soil = layer_weights(21.0, 0.0, 0.5)[2];
assert!((0.3..0.7).contains(&soil), "soil at 21°: {soil}");
assert!(layer_weights(31.0, 0.0, 0.5)[2] < 0.4, "the soil must be gone by 31°");
assert!(layer_weights(28.0, 0.0, 0.5)[2] < 0.35, "the soil must taper into the rock");
for d in [0.0, 0.3, 0.7, 1.0] {
assert_eq!(layer_weights(50.0, 0.0, d)[0], 1.0);
assert_eq!(layer_weights(10.0, 0.0, d)[3], 1.0);
}
assert_eq!(layer_weights(34.0, 0.0, 0.0)[0], 0.0);
assert_eq!(layer_weights(34.0, 0.0, 1.0)[0], 1.0);
assert!(layer_weights(34.0, 0.0, 0.5)[0] > 0.3, "the rock must be there by 34°");
assert!(layer_weights(31.0, 0.0, 0.5)[0] < 0.2, "the rock must not creep below 30°");
}
#[test]
fn the_layer_variation_reaches_a_plain_and_keeps_the_weights_summing_to_one() {
let plain = layer_weights(4.0, 0.0, 0.5);
assert_eq!(plain[2], 0.0, "a plain has no soil to start with");
assert_eq!(plain[3], 1.0);
let mut max_soil = 0.0f32;
let mut min_soil = 1.0f32;
for k in 0..200 {
let p = Vec3::new(k as f32 * 3.0, 0.0, 0.0);
let field = crate::gpu::noise_mirror::perlin3_d(
p / LAYER_VARIATION_M + crate::gpu::noise_mirror::seed_offset(71),
)
.x
/ NOISE_RMS;
let w = layer_variation(field, plain);
assert!(
(w.iter().sum::<f32>() - 1.0).abs() < 1e-5,
"the weights do not sum to one at {p:?}: {w:?}"
);
assert!(w.iter().all(|x| (0.0..=1.0).contains(x)), "{w:?}");
max_soil = max_soil.max(w[2]);
min_soil = min_soil.min(w[2]);
}
assert!(
max_soil > 0.05,
"the variation never reaches a plain (the most soil was {max_soil})"
);
assert!(
min_soil < 0.02,
"the variation never gives the soil back (the least soil was {min_soil})"
);
let steep = layer_weights(50.0, 0.0, 0.5);
let field = crate::gpu::noise_mirror::perlin3_d(
Vec3::new(30.0, 0.0, 0.0) / LAYER_VARIATION_M + crate::gpu::noise_mirror::seed_offset(71),
)
.x
/ NOISE_RMS;
let w = layer_variation(field, steep);
assert_eq!(w[0], 1.0, "the variation moved the rock");
}
#[test]
fn the_slope_of_a_plane_is_exact_up_to_the_edge() {
let size = UVec2::new(16, 8);
let plane: Vec<f32> = (0..size.y)
.flat_map(|_| (0..size.x).map(|i| 0.5 * (i as f32 + 0.5) * 2.0))
.collect();
for j in 0..size.y {
for i in 0..size.x {
let a = slope_deg(&plane, size, 2.0, i, j);
assert!((a - 26.565).abs() < 0.01, "cell {i},{j}: {a}");
}
}
assert_eq!(slope_deg(&vec![3.0; 128], size, 2.0, 0, 0), 0.0);
}
#[test]
fn the_hex_weights_are_the_points_barycentric_coordinates() {
for i in 0..2000 {
let st = Vec2::new(i as f32 * 0.013_7 - 13.0, (i * 7919 % 2000) as f32 * 0.011_3 - 11.0);
let (v, w) = hex_grid(st);
assert!(w.min_element() >= -1e-6, "{st}: a negative weight {w}");
assert!((w.x + w.y + w.z - 1.0).abs() < 1e-5, "{st}: weights {w}");
let rebuilt = w.x * hex_centre(v[0]) + w.y * hex_centre(v[1]) + w.z * hex_centre(v[2]);
assert!((rebuilt - st).length() < 1e-4, "{st} rebuilt as {rebuilt}");
}
}
#[test]
fn a_tap_that_is_not_fetched_carries_almost_nothing() {
let mut state = 12_345u32;
let mut next = || {
state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
(state >> 8) as f32 / 16_777_216.0
};
let mut skipped = 0;
for i in 0..4000 {
let st = Vec2::new(i as f32 * 0.009_1 - 18.0, (i * 7919 % 4000) as f32 * 0.008_3 - 16.0);
let (_, bary) = hex_grid(st);
for _ in 0..4 {
let heights = Vec3::new(next(), next(), next());
let full = hex_tap_weights(bary, heights, true);
let cut = hex_tap_weights(bary, heights, false);
let shares = hex_shares(bary);
for k in 0..3 {
if shares[k] <= TAP_MIN {
skipped += 1;
assert!(full[k] <= 0.005, "{st} {heights}: a skipped tap weighed {}", full[k]);
}
}
assert!((full - cut).abs().max_element() <= 0.005, "{st}: {full} against {cut}");
}
}
assert!(skipped > 4000, "the cut-off skips taps: {skipped}");
}
#[test]
fn a_taps_slope_turns_back_into_the_world() {
let rise = Vec2::new(0.3, -0.7);
let e = 1e-3;
for vx in -4..4 {
for vy in -4..4 {
let v = IVec2::new(vx, vy);
let st = hex_centre(v) + Vec2::new(0.01, 0.02);
let height = |p: Vec2| rise.dot(hex_tap(v, 2, p).0);
let finite = Vec2::new(
height(st + Vec2::X * e) - height(st - Vec2::X * e),
height(st + Vec2::Y * e) - height(st - Vec2::Y * e),
) / (2.0 * e);
let (_, rot) = hex_tap(v, 2, st);
let slope = tap_slope(rise, rot);
assert!((slope - finite).length() < 2e-3, "{v}: {slope} vs {finite}");
}
}
}
#[test]
fn the_height_blend_keeps_the_ground_and_starts_at_the_gate() {
let w = Vec3::new(0.0, 0.3, 0.5);
for h in [Vec3::new(0.5, 0.2, 0.9), Vec3::new(0.5, 0.9, 0.1), Vec3::splat(0.5)] {
let b = height_blend(w, h, HEIGHT_BLEND);
assert!((b.x + b.y + b.z - 0.8).abs() < 1e-6, "{h}: {b}");
}
let b = height_blend(Vec3::new(0.0, 0.5, 0.5), Vec3::new(0.5, 0.9, 0.2), HEIGHT_BLEND);
assert!(b.y > 0.95, "the taller soil shows: {b}");
let tall = Vec3::new(1.0, 0.5, 0.5);
let at_gate = height_blend(Vec3::new(DETAIL_GATE, 0.0, 1.0 - DETAIL_GATE), tall, HEIGHT_BLEND);
assert_eq!(at_gate.x, 0.0, "{at_gate}");
let above = height_blend(
Vec3::new(DETAIL_GATE + 1e-4, 0.0, 1.0 - DETAIL_GATE - 1e-4),
tall,
HEIGHT_BLEND,
);
assert!(above.x > 0.0 && above.x < 0.01, "{above}");
}
#[test]
fn the_height_blend_fades_where_the_heights_are_not_resolved() {
assert_eq!(height_sharpness(0.002), HEIGHT_BLEND);
assert_eq!(height_sharpness(HEIGHT_BLEND_LOST_M), 0.0);
let w = Vec3::new(0.0, 0.5, 0.5);
let far = height_sharpness(0.05);
let a = height_blend(w, Vec3::new(0.5, 0.55, 0.45), far);
let b = height_blend(w, Vec3::new(0.5, 0.45, 0.55), far);
assert_eq!(a, b, "far: the heights decide nothing");
let near = height_sharpness(0.002);
let a = height_blend(w, Vec3::new(0.5, 0.55, 0.45), near);
let b = height_blend(w, Vec3::new(0.5, 0.45, 0.55), near);
assert!(a.y > b.y + 0.3, "near: the taller layer shows ({a} against {b})");
}
#[test]
fn an_untextured_layer_keeps_its_whole_grain() {
let set = Vec3::new(0.0, 2.0, 2.0);
assert_eq!(grain_share(Vec3::new(0.9, 0.0, 0.0), set, 0.3), 1.0);
assert!((grain_share(Vec3::new(0.0, 0.2, 0.7), set, 0.3) - 0.3).abs() < 1e-6);
assert!((grain_share(Vec3::new(0.5, 0.0, 0.5), set, 0.3) - 0.65).abs() < 1e-6);
assert!((grain_share(Vec3::new(0.5, 0.0, 0.5), Vec3::splat(2.0), 0.3) - 0.3).abs() < 1e-6);
assert_eq!(grain_share(Vec3::ZERO, set, 0.3), 1.0, "no ground: no change");
}
}