feat(world): procedural overworld generator — continents, climate, rivers
A new `world` generation domain in reikhelm-core, sibling to the dungeon
passes. Builds continuous fields (elevation, temperature, moisture, flow)
and classifies them into biomes — a general-purpose world generator any
game can consume, the foundation for the "generate a world for any game"
app idea.
Stages (each forks its own RNG sub-stream, like the dungeon pipeline):
- noise: own Perlin + fBm + domain warp (no `noise` crate, same reason
we own range/shuffle — version drift would change every seed)
- elevation: domain-warped fBm shaped by edge falloff + redistribution
- hydrology: ocean/lake flood, priority-flood depression fill, D8 flow
accumulation → dendritic rivers that always reach the sea
- climate: latitude temperature w/ altitude lapse; prevailing-wind
moisture sweep with orographic rain + emergent rain shadows
- biome: Whittaker classifier over temperature × moisture
Determinism: same (config, seed) → byte-identical World on every machine.
The whole generator is transcendental-free (only + - * / and comparisons),
so even the f32 fields reproduce exactly cross-platform (spec §7 ethos).
Plus `examples/world_png.rs`: a zero-dependency PNG exporter (hand-rolled
stored-DEFLATE zlib) with biome palette, ocean-depth shading, and hillshade
— a viewer for eyeballing output, kept out of the pure core.
24 new tests (determinism, rain-shadow, depression-filling, river density,
Whittaker corners); full suite 169 passing, clippy clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a12bcbbcad
commit
df02911a1b
7 changed files with 1799 additions and 0 deletions
237
reikhelm-core/examples/world_png.rs
Normal file
237
reikhelm-core/examples/world_png.rs
Normal file
|
|
@ -0,0 +1,237 @@
|
||||||
|
//! Renders a generated [`World`] to a PNG — a *viewer* for eyeballing the
|
||||||
|
//! generator, deliberately kept out of the pure library (the core stores no
|
||||||
|
//! color; presentation lives here, exactly like `reikhelm-viz` owns the dungeon
|
||||||
|
//! palette).
|
||||||
|
//!
|
||||||
|
//! The PNG encoder is hand-rolled with **no dependencies**: 8-bit truecolor,
|
||||||
|
//! filter 0, and a zlib stream of uncompressed ("stored") DEFLATE blocks. That's
|
||||||
|
//! all std, so the same code path works headless or in a future WASM/server
|
||||||
|
//! target without pulling an image crate.
|
||||||
|
//!
|
||||||
|
//! Usage:
|
||||||
|
//! ```text
|
||||||
|
//! cargo run --release --example world_png -p reikhelm-core [seed] [pixel_scale]
|
||||||
|
//! ```
|
||||||
|
//! Writes `world.png` in the current directory.
|
||||||
|
|
||||||
|
use std::env;
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io::{self, Write};
|
||||||
|
|
||||||
|
use reikhelm_core::world::biome::Biome;
|
||||||
|
use reikhelm_core::world::{generate, World, WorldConfig};
|
||||||
|
use reikhelm_core::geometry::Point;
|
||||||
|
|
||||||
|
fn main() -> io::Result<()> {
|
||||||
|
let mut args = env::args().skip(1);
|
||||||
|
let seed: u64 = args.next().and_then(|s| s.parse().ok()).unwrap_or(2026);
|
||||||
|
let scale: u32 = args.next().and_then(|s| s.parse().ok()).unwrap_or(4).max(1);
|
||||||
|
|
||||||
|
let cfg = WorldConfig::default();
|
||||||
|
let world = generate(cfg, seed).expect("default WorldConfig is valid");
|
||||||
|
|
||||||
|
let pixels = render(&world, scale);
|
||||||
|
let (pw, ph) = (world.width * scale, world.height * scale);
|
||||||
|
let mut file = File::create("world.png")?;
|
||||||
|
write_png(&mut file, pw, ph, &pixels)?;
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"wrote world.png ({}x{} px) — seed {seed}, {:.0}% land, wind {:?}",
|
||||||
|
pw,
|
||||||
|
ph,
|
||||||
|
world.land_fraction() * 100.0,
|
||||||
|
cfg.wind
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders the world into a row-major RGB buffer, upscaled by `scale` so each
|
||||||
|
/// world cell becomes a `scale × scale` block of pixels.
|
||||||
|
fn render(world: &World, scale: u32) -> Vec<u8> {
|
||||||
|
let (w, h) = (world.width, world.height);
|
||||||
|
let (pw, ph) = (w * scale, h * scale);
|
||||||
|
let mut buf = vec![0u8; (pw as usize) * (ph as usize) * 3];
|
||||||
|
|
||||||
|
for cy in 0..h as i32 {
|
||||||
|
for cx in 0..w as i32 {
|
||||||
|
let p = Point::new(cx, cy);
|
||||||
|
let biome = world.biome.get(p).copied().unwrap_or(Biome::DeepOcean);
|
||||||
|
let [r, g, b] = shade(world, p, palette(biome));
|
||||||
|
|
||||||
|
// Splat the cell color across its scale×scale pixel block.
|
||||||
|
for sy in 0..scale {
|
||||||
|
let py = cy as u32 * scale + sy;
|
||||||
|
let row = (py as usize) * (pw as usize) * 3;
|
||||||
|
for sx in 0..scale {
|
||||||
|
let px = cx as u32 * scale + sx;
|
||||||
|
let i = row + (px as usize) * 3;
|
||||||
|
buf[i] = r;
|
||||||
|
buf[i + 1] = g;
|
||||||
|
buf[i + 2] = b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The base color for each biome (relief/depth shading is applied separately).
|
||||||
|
fn palette(b: Biome) -> [u8; 3] {
|
||||||
|
match b {
|
||||||
|
Biome::DeepOcean => [28, 52, 94],
|
||||||
|
Biome::ShallowOcean => [54, 94, 140],
|
||||||
|
Biome::Lake => [58, 108, 158],
|
||||||
|
Biome::River => [72, 122, 178],
|
||||||
|
Biome::Beach => [208, 196, 150],
|
||||||
|
Biome::Snow => [240, 244, 248],
|
||||||
|
Biome::Tundra => [168, 172, 158],
|
||||||
|
Biome::Taiga => [76, 110, 92],
|
||||||
|
Biome::Bare => [128, 120, 110],
|
||||||
|
Biome::Grassland => [132, 168, 92],
|
||||||
|
Biome::Shrubland => [162, 160, 104],
|
||||||
|
Biome::TemperateForest => [78, 132, 76],
|
||||||
|
Biome::TemperateRainforest => [54, 110, 78],
|
||||||
|
Biome::Desert => [214, 198, 134],
|
||||||
|
Biome::Savanna => [186, 178, 100],
|
||||||
|
Biome::TropicalSeasonalForest => [102, 152, 70],
|
||||||
|
Biome::TropicalRainforest => [40, 112, 58],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Modulates a base color by terrain: ocean darkens with depth, land gets a
|
||||||
|
/// gentle NW hillshade from the elevation gradient so ridges and valleys read.
|
||||||
|
fn shade(world: &World, p: Point, base: [u8; 3]) -> [u8; 3] {
|
||||||
|
let e = *world.elevation.get(p).unwrap_or(&0.0);
|
||||||
|
let sea = world.config.sea_level;
|
||||||
|
|
||||||
|
let factor = if world.biome.get(p).map(|b| b.is_water()).unwrap_or(false) {
|
||||||
|
// Deeper water → darker. Normalize depth against sea level.
|
||||||
|
let depth = ((sea - e) / sea).clamp(0.0, 1.0);
|
||||||
|
0.95 - 0.45 * depth
|
||||||
|
} else {
|
||||||
|
// Elevation tint (higher = a touch brighter) times a hillshade.
|
||||||
|
let tint = 0.88 + 0.30 * ((e - sea) / (1.0 - sea)).clamp(0.0, 1.0);
|
||||||
|
tint * hillshade(world, p)
|
||||||
|
};
|
||||||
|
|
||||||
|
[
|
||||||
|
(base[0] as f32 * factor).clamp(0.0, 255.0) as u8,
|
||||||
|
(base[1] as f32 * factor).clamp(0.0, 255.0) as u8,
|
||||||
|
(base[2] as f32 * factor).clamp(0.0, 255.0) as u8,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A simple Lambert hillshade with the light from the north-west, returning a
|
||||||
|
/// brightness multiplier around 1.0. Steep slopes facing the light brighten;
|
||||||
|
/// those facing away darken, which is what makes mountains pop.
|
||||||
|
fn hillshade(world: &World, p: Point) -> f32 {
|
||||||
|
let at = |q: Point| *world.elevation.get(q).unwrap_or(world.elevation.get(p).unwrap_or(&0.0));
|
||||||
|
// Central differences for the elevation gradient (vertical exaggeration k).
|
||||||
|
let k = 12.0;
|
||||||
|
let dx = (at(p.offset(1, 0)) - at(p.offset(-1, 0))) * k;
|
||||||
|
let dy = (at(p.offset(0, 1)) - at(p.offset(0, -1))) * k;
|
||||||
|
|
||||||
|
// Surface normal (-dx, -dy, 1) dotted with a normalized NW-up light.
|
||||||
|
let (lx, ly, lz) = (-0.45, -0.45, 0.77);
|
||||||
|
let nlen = (dx * dx + dy * dy + 1.0).sqrt();
|
||||||
|
let dot = (-dx * lx - dy * ly + lz) / nlen;
|
||||||
|
// Map the dot product into a tame [0.7, 1.15] brightness range.
|
||||||
|
(0.70 + 0.55 * dot.clamp(0.0, 1.0)).clamp(0.70, 1.15)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Zero-dependency PNG encoder -----------------------------------------
|
||||||
|
|
||||||
|
/// Writes an 8-bit RGB PNG (`pixels` is row-major, `w*h*3` bytes) to `out`.
|
||||||
|
fn write_png(out: &mut impl Write, w: u32, h: u32, pixels: &[u8]) -> io::Result<()> {
|
||||||
|
out.write_all(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A])?;
|
||||||
|
|
||||||
|
// IHDR: width, height, bit depth 8, color type 2 (RGB), no compression/
|
||||||
|
// filter/interlace.
|
||||||
|
let mut ihdr = Vec::with_capacity(13);
|
||||||
|
ihdr.extend_from_slice(&w.to_be_bytes());
|
||||||
|
ihdr.extend_from_slice(&h.to_be_bytes());
|
||||||
|
ihdr.extend_from_slice(&[8, 2, 0, 0, 0]);
|
||||||
|
write_chunk(out, b"IHDR", &ihdr)?;
|
||||||
|
|
||||||
|
// IDAT: zlib(stored DEFLATE) of the filtered scanlines (filter byte 0 per row).
|
||||||
|
let mut raw = Vec::with_capacity((w as usize * 3 + 1) * h as usize);
|
||||||
|
for y in 0..h as usize {
|
||||||
|
raw.push(0); // filter type 0 (None)
|
||||||
|
let start = y * w as usize * 3;
|
||||||
|
raw.extend_from_slice(&pixels[start..start + w as usize * 3]);
|
||||||
|
}
|
||||||
|
write_chunk(out, b"IDAT", &zlib_stored(&raw))?;
|
||||||
|
|
||||||
|
write_chunk(out, b"IEND", &[])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emits one PNG chunk: length, type, data, CRC32(type ++ data).
|
||||||
|
fn write_chunk(out: &mut impl Write, kind: &[u8; 4], data: &[u8]) -> io::Result<()> {
|
||||||
|
out.write_all(&(data.len() as u32).to_be_bytes())?;
|
||||||
|
out.write_all(kind)?;
|
||||||
|
out.write_all(data)?;
|
||||||
|
let mut crc = Crc::new();
|
||||||
|
crc.update(kind);
|
||||||
|
crc.update(data);
|
||||||
|
out.write_all(&crc.finish().to_be_bytes())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wraps `data` as a zlib stream whose DEFLATE body is uncompressed "stored"
|
||||||
|
/// blocks — valid zlib that any PNG decoder accepts, with no compression code.
|
||||||
|
fn zlib_stored(data: &[u8]) -> Vec<u8> {
|
||||||
|
let mut out = Vec::with_capacity(data.len() + data.len() / 65535 * 5 + 16);
|
||||||
|
out.extend_from_slice(&[0x78, 0x01]); // zlib header (deflate, 32K window)
|
||||||
|
|
||||||
|
let mut chunks = data.chunks(0xFFFF).peekable();
|
||||||
|
if chunks.peek().is_none() {
|
||||||
|
// Empty image still needs one final empty stored block.
|
||||||
|
out.extend_from_slice(&[0x01, 0x00, 0x00, 0xFF, 0xFF]);
|
||||||
|
}
|
||||||
|
while let Some(chunk) = chunks.next() {
|
||||||
|
let last = chunks.peek().is_none();
|
||||||
|
out.push(if last { 1 } else { 0 }); // BFINAL, BTYPE=00 (stored)
|
||||||
|
let len = chunk.len() as u16;
|
||||||
|
out.extend_from_slice(&len.to_le_bytes());
|
||||||
|
out.extend_from_slice(&(!len).to_le_bytes()); // one's complement of len
|
||||||
|
out.extend_from_slice(chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
out.extend_from_slice(&adler32(data).to_be_bytes());
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adler-32 checksum (the zlib trailer).
|
||||||
|
fn adler32(data: &[u8]) -> u32 {
|
||||||
|
const MOD: u32 = 65521;
|
||||||
|
let (mut a, mut b) = (1u32, 0u32);
|
||||||
|
for &byte in data {
|
||||||
|
a = (a + byte as u32) % MOD;
|
||||||
|
b = (b + a) % MOD;
|
||||||
|
}
|
||||||
|
(b << 16) | a
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CRC-32 (the PNG chunk checksum), computed with the standard reflected
|
||||||
|
/// polynomial — no lookup table, the bit-at-a-time form is plenty fast here.
|
||||||
|
struct Crc {
|
||||||
|
value: u32,
|
||||||
|
}
|
||||||
|
impl Crc {
|
||||||
|
fn new() -> Self {
|
||||||
|
Crc { value: 0xFFFF_FFFF }
|
||||||
|
}
|
||||||
|
fn update(&mut self, data: &[u8]) {
|
||||||
|
for &byte in data {
|
||||||
|
self.value ^= byte as u32;
|
||||||
|
for _ in 0..8 {
|
||||||
|
let mask = (self.value & 1).wrapping_neg();
|
||||||
|
self.value = (self.value >> 1) ^ (0xEDB8_8320 & mask);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn finish(self) -> u32 {
|
||||||
|
self.value ^ 0xFFFF_FFFF
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,3 +14,4 @@ pub mod passes;
|
||||||
pub mod recipes;
|
pub mod recipes;
|
||||||
pub mod region;
|
pub mod region;
|
||||||
pub mod rng;
|
pub mod rng;
|
||||||
|
pub mod world;
|
||||||
|
|
|
||||||
242
reikhelm-core/src/world/biome.rs
Normal file
242
reikhelm-core/src/world/biome.rs
Normal file
|
|
@ -0,0 +1,242 @@
|
||||||
|
//! Biome classification — the world's analogue of the dungeon's `room_themer`:
|
||||||
|
//! a pure classifier that reads finished terrain/climate fields and labels each
|
||||||
|
//! cell, changing no underlying data.
|
||||||
|
//!
|
||||||
|
//! Water biomes (ocean/lake/river) are decided by [`crate::world::hydrology`]
|
||||||
|
//! and passed in as a [`Surface`]; everything else is a Whittaker-style lookup
|
||||||
|
//! on temperature × moisture, with elevation carving out beaches and the
|
||||||
|
//! bare/snow high country on top.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// What kind of water (if any) occupies a cell, decided before biome
|
||||||
|
/// classification by the hydrology stage.
|
||||||
|
///
|
||||||
|
/// `Land` cells fall through to the temperature/moisture classifier; the water
|
||||||
|
/// variants short-circuit to their corresponding [`Biome`].
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum Surface {
|
||||||
|
/// Dry land — classify by climate.
|
||||||
|
Land,
|
||||||
|
/// Open sea below the deep-water cutoff.
|
||||||
|
DeepOcean,
|
||||||
|
/// Sea shallow enough to read as a lighter shelf.
|
||||||
|
ShallowOcean,
|
||||||
|
/// An inland body of standing water (a below-sea-level basin with no path to
|
||||||
|
/// the open ocean).
|
||||||
|
Lake,
|
||||||
|
/// A flowing watercourse traced by the flow-accumulation stage.
|
||||||
|
River,
|
||||||
|
/// Dry land immediately fringing the ocean.
|
||||||
|
Beach,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A terrain/climate classification for a single world cell.
|
||||||
|
///
|
||||||
|
/// The land biomes are ordered cold→hot and dry→wet within each temperature
|
||||||
|
/// band, mirroring the Whittaker diagram the classifier implements. Water and
|
||||||
|
/// relief biomes (the oceans, lake, river, beach, bare rock, and snow) bracket
|
||||||
|
/// the climate table.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||||
|
pub enum Biome {
|
||||||
|
/// Open sea, below the deep-water cutoff.
|
||||||
|
DeepOcean,
|
||||||
|
/// Coastal shelf sea.
|
||||||
|
ShallowOcean,
|
||||||
|
/// Inland standing water.
|
||||||
|
Lake,
|
||||||
|
/// A flowing river.
|
||||||
|
River,
|
||||||
|
/// Sandy shore fringing the ocean.
|
||||||
|
Beach,
|
||||||
|
/// Permanent ice/snow — the cold high country and the polar caps.
|
||||||
|
Snow,
|
||||||
|
/// Frozen, treeless ground.
|
||||||
|
Tundra,
|
||||||
|
/// Cold coniferous forest (boreal).
|
||||||
|
Taiga,
|
||||||
|
/// Exposed rock above the tree line but below the snow line.
|
||||||
|
Bare,
|
||||||
|
/// Cool-to-temperate open grassland.
|
||||||
|
Grassland,
|
||||||
|
/// Dry temperate scrub.
|
||||||
|
Shrubland,
|
||||||
|
/// Temperate broadleaf/mixed forest.
|
||||||
|
TemperateForest,
|
||||||
|
/// Cool, very wet temperate forest.
|
||||||
|
TemperateRainforest,
|
||||||
|
/// Hot, arid desert.
|
||||||
|
Desert,
|
||||||
|
/// Hot grassland with sparse trees.
|
||||||
|
Savanna,
|
||||||
|
/// Hot forest with a pronounced dry season.
|
||||||
|
TropicalSeasonalForest,
|
||||||
|
/// Hot, perpetually wet jungle.
|
||||||
|
TropicalRainforest,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Classifies one cell from its surface, elevation, and climate.
|
||||||
|
///
|
||||||
|
/// `elev`, `temp`, and `moist` are all normalized to `[0, 1]`. `high_alt` is the
|
||||||
|
/// elevation above which land becomes bare rock or snow (the alpine band),
|
||||||
|
/// expressed on the same `[0, 1]` scale. The decision order is:
|
||||||
|
///
|
||||||
|
/// 1. Any non-`Land` [`Surface`] maps straight to its water/beach biome.
|
||||||
|
/// 2. Alpine land (`elev >= high_alt`) is [`Snow`](Biome::Snow) when cold, else
|
||||||
|
/// [`Bare`](Biome::Bare) rock.
|
||||||
|
/// 3. Otherwise a Whittaker lookup on `(temp, moist)`.
|
||||||
|
pub fn classify(surface: Surface, elev: f32, temp: f32, moist: f32, high_alt: f32) -> Biome {
|
||||||
|
// 1. Water and shoreline are already decided by hydrology.
|
||||||
|
match surface {
|
||||||
|
Surface::DeepOcean => return Biome::DeepOcean,
|
||||||
|
Surface::ShallowOcean => return Biome::ShallowOcean,
|
||||||
|
Surface::Lake => return Biome::Lake,
|
||||||
|
Surface::River => return Biome::River,
|
||||||
|
Surface::Beach => return Biome::Beach,
|
||||||
|
Surface::Land => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. The alpine band: snow caps the cold peaks, bare rock the rest.
|
||||||
|
if elev >= high_alt {
|
||||||
|
return if temp < 0.35 { Biome::Snow } else { Biome::Bare };
|
||||||
|
}
|
||||||
|
|
||||||
|
// The very coldest lowlands read as snow/ice regardless of moisture (polar
|
||||||
|
// caps), so the temperature poles always look frozen.
|
||||||
|
if temp < 0.12 {
|
||||||
|
return Biome::Snow;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Whittaker: temperature picks the band, moisture the member.
|
||||||
|
if temp < 0.30 {
|
||||||
|
// Cold.
|
||||||
|
if moist < 0.35 {
|
||||||
|
Biome::Tundra
|
||||||
|
} else {
|
||||||
|
Biome::Taiga
|
||||||
|
}
|
||||||
|
} else if temp < 0.65 {
|
||||||
|
// Temperate.
|
||||||
|
if moist < 0.20 {
|
||||||
|
Biome::Shrubland
|
||||||
|
} else if moist < 0.50 {
|
||||||
|
Biome::Grassland
|
||||||
|
} else if moist < 0.80 {
|
||||||
|
Biome::TemperateForest
|
||||||
|
} else {
|
||||||
|
Biome::TemperateRainforest
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Hot.
|
||||||
|
if moist < 0.20 {
|
||||||
|
Biome::Desert
|
||||||
|
} else if moist < 0.40 {
|
||||||
|
Biome::Savanna
|
||||||
|
} else if moist < 0.70 {
|
||||||
|
Biome::TropicalSeasonalForest
|
||||||
|
} else {
|
||||||
|
Biome::TropicalRainforest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Biome {
|
||||||
|
/// `true` if this biome is any kind of water (ocean, lake, or river).
|
||||||
|
///
|
||||||
|
/// Handy for renderers and for game logic that treats all water as
|
||||||
|
/// impassable terrain.
|
||||||
|
pub fn is_water(self) -> bool {
|
||||||
|
matches!(
|
||||||
|
self,
|
||||||
|
Biome::DeepOcean | Biome::ShallowOcean | Biome::Lake | Biome::River
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single-character glyph for ASCII previews (one stable char per biome).
|
||||||
|
///
|
||||||
|
/// Used by the world's debug `to_ascii` render and tests; it is *not* a
|
||||||
|
/// rendering policy (colors live in the renderer, never the core).
|
||||||
|
pub fn glyph(self) -> char {
|
||||||
|
match self {
|
||||||
|
Biome::DeepOcean => '~',
|
||||||
|
Biome::ShallowOcean => '-',
|
||||||
|
Biome::Lake => 'o',
|
||||||
|
Biome::River => '+',
|
||||||
|
Biome::Beach => '.',
|
||||||
|
Biome::Snow => '*',
|
||||||
|
Biome::Tundra => ',',
|
||||||
|
Biome::Taiga => 't',
|
||||||
|
Biome::Bare => '^',
|
||||||
|
Biome::Grassland => '"',
|
||||||
|
Biome::Shrubland => ':',
|
||||||
|
Biome::TemperateForest => 'f',
|
||||||
|
Biome::TemperateRainforest => 'F',
|
||||||
|
Biome::Desert => 'd',
|
||||||
|
Biome::Savanna => 's',
|
||||||
|
Biome::TropicalSeasonalForest => 'j',
|
||||||
|
Biome::TropicalRainforest => 'J',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Each water surface short-circuits to its matching biome regardless of
|
||||||
|
/// climate.
|
||||||
|
#[test]
|
||||||
|
fn water_surfaces_map_directly() {
|
||||||
|
let hot = (0.9, 0.9);
|
||||||
|
assert_eq!(classify(Surface::DeepOcean, 0.1, hot.0, hot.1, 0.8), Biome::DeepOcean);
|
||||||
|
assert_eq!(classify(Surface::ShallowOcean, 0.3, hot.0, hot.1, 0.8), Biome::ShallowOcean);
|
||||||
|
assert_eq!(classify(Surface::Lake, 0.3, hot.0, hot.1, 0.8), Biome::Lake);
|
||||||
|
assert_eq!(classify(Surface::River, 0.5, hot.0, hot.1, 0.8), Biome::River);
|
||||||
|
assert_eq!(classify(Surface::Beach, 0.45, hot.0, hot.1, 0.8), Biome::Beach);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Alpine land is snow when cold and bare rock when not.
|
||||||
|
#[test]
|
||||||
|
fn alpine_band_is_snow_or_bare() {
|
||||||
|
// elev above high_alt (0.8).
|
||||||
|
assert_eq!(classify(Surface::Land, 0.95, 0.1, 0.5, 0.8), Biome::Snow);
|
||||||
|
assert_eq!(classify(Surface::Land, 0.95, 0.9, 0.5, 0.8), Biome::Bare);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Whittaker corners land where expected: cold/dry tundra, hot/dry
|
||||||
|
/// desert, hot/wet rainforest, temperate/mid grassland.
|
||||||
|
#[test]
|
||||||
|
fn whittaker_corners() {
|
||||||
|
let a = 0.4; // mid elevation, below high_alt
|
||||||
|
assert_eq!(classify(Surface::Land, a, 0.20, 0.10, 0.8), Biome::Tundra);
|
||||||
|
assert_eq!(classify(Surface::Land, a, 0.90, 0.05, 0.8), Biome::Desert);
|
||||||
|
assert_eq!(classify(Surface::Land, a, 0.90, 0.95, 0.8), Biome::TropicalRainforest);
|
||||||
|
assert_eq!(classify(Surface::Land, a, 0.50, 0.35, 0.8), Biome::Grassland);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The frozen-pole shortcut: extremely cold lowland is snow even when wet.
|
||||||
|
#[test]
|
||||||
|
fn freezing_lowland_is_snow() {
|
||||||
|
assert_eq!(classify(Surface::Land, 0.5, 0.05, 0.9, 0.8), Biome::Snow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every biome has a distinct ASCII glyph (no two collide), so previews are
|
||||||
|
/// unambiguous.
|
||||||
|
#[test]
|
||||||
|
fn glyphs_are_distinct() {
|
||||||
|
let all = [
|
||||||
|
Biome::DeepOcean, Biome::ShallowOcean, Biome::Lake, Biome::River, Biome::Beach,
|
||||||
|
Biome::Snow, Biome::Tundra, Biome::Taiga, Biome::Bare, Biome::Grassland,
|
||||||
|
Biome::Shrubland, Biome::TemperateForest, Biome::TemperateRainforest, Biome::Desert,
|
||||||
|
Biome::Savanna, Biome::TropicalSeasonalForest, Biome::TropicalRainforest,
|
||||||
|
];
|
||||||
|
let mut glyphs: Vec<char> = all.iter().map(|b| b.glyph()).collect();
|
||||||
|
glyphs.sort_unstable();
|
||||||
|
let unique = {
|
||||||
|
let mut g = glyphs.clone();
|
||||||
|
g.dedup();
|
||||||
|
g
|
||||||
|
};
|
||||||
|
assert_eq!(glyphs.len(), unique.len(), "biome glyphs must be distinct");
|
||||||
|
}
|
||||||
|
}
|
||||||
239
reikhelm-core/src/world/climate.rs
Normal file
239
reikhelm-core/src/world/climate.rs
Normal file
|
|
@ -0,0 +1,239 @@
|
||||||
|
//! Climate fields: temperature and moisture.
|
||||||
|
//!
|
||||||
|
//! Temperature is the cheap, legible part — a latitude band cooled by altitude.
|
||||||
|
//! Moisture is where a world stops looking like noise and starts looking like
|
||||||
|
//! geography: we sweep **prevailing wind** across the map, evaporating moisture
|
||||||
|
//! over water, raining it out over land, and dumping extra rain where the wind
|
||||||
|
//! is forced *up* a slope. The leeward side of a mountain range then falls into
|
||||||
|
//! a dry **rain shadow** — emergent deserts, not painted ones.
|
||||||
|
//!
|
||||||
|
//! Like the rest of the world generator this stays transcendental-free, so a
|
||||||
|
//! seed reproduces the same climate on every machine.
|
||||||
|
|
||||||
|
use crate::geometry::Point;
|
||||||
|
use crate::grid::Grid;
|
||||||
|
|
||||||
|
use super::hydrology::WaterBody;
|
||||||
|
use super::noise::Noise;
|
||||||
|
use super::{WorldConfig, Wind};
|
||||||
|
|
||||||
|
/// Computes the normalized `[0, 1]` temperature field.
|
||||||
|
///
|
||||||
|
/// Each cell starts from a triangular latitude band (1 at the equatorial middle
|
||||||
|
/// row, 0 at the poles), is cooled by `lapse_rate` for every unit of elevation
|
||||||
|
/// above sea level, and is nudged by a little fBm so the isotherms wander
|
||||||
|
/// instead of ruling dead-straight across the map.
|
||||||
|
pub fn temperature(elevation: &Grid<f32>, noise: &Noise, cfg: &WorldConfig) -> Grid<f32> {
|
||||||
|
let (w, h) = (elevation.width(), elevation.height());
|
||||||
|
// Avoid a divide-by-zero for a 1-row world; such a row is "the equator".
|
||||||
|
let span = (h.max(1) - 1).max(1) as f32;
|
||||||
|
let scale = cfg.climate_scale / w.max(1) as f32;
|
||||||
|
|
||||||
|
Grid::from_fn(w, h, |p: Point| {
|
||||||
|
let elev = *elevation.get(p).unwrap_or(&0.0);
|
||||||
|
|
||||||
|
// Triangular latitude band: warmest at the middle row, coldest at the
|
||||||
|
// top and bottom edges.
|
||||||
|
let lat = p.y as f32 / span; // 0 at top .. 1 at bottom
|
||||||
|
let band = 1.0 - (2.0 * lat - 1.0).abs();
|
||||||
|
|
||||||
|
// Altitude lapse: only land above sea level cools (the sea is uniform).
|
||||||
|
let above = (elev - cfg.sea_level).max(0.0);
|
||||||
|
let cooled = band - cfg.lapse_rate * above;
|
||||||
|
|
||||||
|
// Wandering isotherms.
|
||||||
|
let jitter = cfg.temp_jitter * noise.fbm(p.x as f32 * scale, p.y as f32 * scale, 3, 2.0, 0.5);
|
||||||
|
|
||||||
|
(cooled + jitter).clamp(0.0, 1.0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Computes the normalized `[0, 1]` moisture field via a prevailing-wind sweep.
|
||||||
|
///
|
||||||
|
/// Air enters each scan line saturated and travels along `cfg.wind`. Over water
|
||||||
|
/// it re-evaporates toward saturation; over land it rains out a fraction of what
|
||||||
|
/// it carries (`rainfall`) plus an orographic burst proportional to how steeply
|
||||||
|
/// the wind climbs (`orographic`). Carried moisture that has rained out cannot
|
||||||
|
/// come back until the wind reaches water again — that depletion is the rain
|
||||||
|
/// shadow. Land cells are finally rescaled to span `[0, 1]` so biome thresholds
|
||||||
|
/// are meaningful regardless of the absolute rainfall constants.
|
||||||
|
pub fn moisture(
|
||||||
|
elevation: &Grid<f32>,
|
||||||
|
water: &Grid<WaterBody>,
|
||||||
|
noise: &Noise,
|
||||||
|
cfg: &WorldConfig,
|
||||||
|
) -> Grid<f32> {
|
||||||
|
let (w, h) = (elevation.width(), elevation.height());
|
||||||
|
let mut raw = Grid::new(w, h, 0.0f32);
|
||||||
|
|
||||||
|
let scale = cfg.climate_scale / w.max(1) as f32;
|
||||||
|
let is_water = |p: Point| matches!(water.get(p), Some(WaterBody::Ocean | WaterBody::Lake));
|
||||||
|
|
||||||
|
// One pass of the wind over a single scan line of cells, in travel order.
|
||||||
|
// `line` is the ordered list of points the wind crosses.
|
||||||
|
let sweep_line = |line: &[Point], raw: &mut Grid<f32>| {
|
||||||
|
let mut carried = 1.0f32;
|
||||||
|
let mut prev_elev = line.first().map(|&p| *elevation.get(p).unwrap_or(&0.0)).unwrap_or(0.0);
|
||||||
|
for &p in line {
|
||||||
|
let elev = *elevation.get(p).unwrap_or(&0.0);
|
||||||
|
let m = if is_water(p) {
|
||||||
|
carried = (carried + cfg.evaporation).min(1.0);
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
// Rain out a *fraction* of the carried moisture: a baseline
|
||||||
|
// drizzle plus an orographic burst that grows with the uphill
|
||||||
|
// climb. Capping the fraction below 1 means the wind can never
|
||||||
|
// fully desiccate in a single cell, so moisture still reaches
|
||||||
|
// inland ranges — and the deficit it leaves behind a peak is the
|
||||||
|
// rain shadow.
|
||||||
|
let rise = (elev - prev_elev).max(0.0);
|
||||||
|
let rate = (cfg.rainfall + cfg.orographic * rise).min(0.9);
|
||||||
|
let drop = carried * rate;
|
||||||
|
carried -= drop;
|
||||||
|
drop
|
||||||
|
};
|
||||||
|
// A little texture so rainfall isn't perfectly smooth along the wind.
|
||||||
|
let jitter = cfg.moisture_jitter * noise.fbm(p.x as f32 * scale, p.y as f32 * scale, 3, 2.0, 0.5);
|
||||||
|
raw.set(p, m + jitter);
|
||||||
|
prev_elev = elev;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build each scan line in wind-travel order, then sweep it.
|
||||||
|
match cfg.wind {
|
||||||
|
Wind::West | Wind::East => {
|
||||||
|
for y in 0..h as i32 {
|
||||||
|
let mut line: Vec<Point> = (0..w as i32).map(|x| Point::new(x, y)).collect();
|
||||||
|
if matches!(cfg.wind, Wind::East) {
|
||||||
|
line.reverse(); // wind from the east travels right→left
|
||||||
|
}
|
||||||
|
sweep_line(&line, &mut raw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Wind::North | Wind::South => {
|
||||||
|
for x in 0..w as i32 {
|
||||||
|
let mut line: Vec<Point> = (0..h as i32).map(|y| Point::new(x, y)).collect();
|
||||||
|
if matches!(cfg.wind, Wind::North) {
|
||||||
|
line.reverse(); // wind from the north travels bottom→top
|
||||||
|
}
|
||||||
|
sweep_line(&line, &mut raw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
normalize_land(&raw, water)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rescales the raw moisture so the **land** cells span `[0, 1]`.
|
||||||
|
///
|
||||||
|
/// Water cells get a constant 1.0 (their moisture is irrelevant — the biome
|
||||||
|
/// stage overwrites them with a water biome), and they are excluded from the
|
||||||
|
/// min/max so they can't compress the land range. A flat field (all land equal)
|
||||||
|
/// maps to a uniform 0.5 rather than dividing by zero.
|
||||||
|
fn normalize_land(raw: &Grid<f32>, water: &Grid<WaterBody>) -> Grid<f32> {
|
||||||
|
let is_land = |p: Point| matches!(water.get(p), Some(WaterBody::Land));
|
||||||
|
|
||||||
|
let mut lo = f32::INFINITY;
|
||||||
|
let mut hi = f32::NEG_INFINITY;
|
||||||
|
for (p, &v) in raw.iter() {
|
||||||
|
if is_land(p) {
|
||||||
|
lo = lo.min(v);
|
||||||
|
hi = hi.max(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let span = hi - lo;
|
||||||
|
Grid::from_fn(raw.width(), raw.height(), |p| {
|
||||||
|
if !is_land(p) {
|
||||||
|
return 1.0;
|
||||||
|
}
|
||||||
|
if span <= 0.0 {
|
||||||
|
0.5
|
||||||
|
} else {
|
||||||
|
((*raw.get(p).unwrap_or(&lo) - lo) / span).clamp(0.0, 1.0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::rng::Rng;
|
||||||
|
|
||||||
|
fn noise(seed: u64) -> Noise {
|
||||||
|
let mut rng = Rng::from_seed(seed);
|
||||||
|
Noise::new(&mut rng)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A flat sea-level world: the equator (middle row) is warmer than the poles
|
||||||
|
/// (top/bottom rows).
|
||||||
|
#[test]
|
||||||
|
fn temperature_peaks_at_the_equator() {
|
||||||
|
let cfg = WorldConfig { temp_jitter: 0.0, ..WorldConfig::default() };
|
||||||
|
let elev = Grid::new(8, 9, cfg.sea_level); // all at sea level, no lapse
|
||||||
|
let t = temperature(&elev, &noise(1), &cfg);
|
||||||
|
|
||||||
|
let mid = *t.get(Point::new(4, 4)).unwrap();
|
||||||
|
let top = *t.get(Point::new(4, 0)).unwrap();
|
||||||
|
let bot = *t.get(Point::new(4, 8)).unwrap();
|
||||||
|
assert!(mid > top, "equator {mid} should beat north pole {top}");
|
||||||
|
assert!(mid > bot, "equator {mid} should beat south pole {bot}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Altitude cools: a tall mountain cell is colder than a sea-level cell on
|
||||||
|
/// the same latitude. Uses the equatorial row (y=2 of 5) so the latitude
|
||||||
|
/// band is warm enough for the lapse rate to bite.
|
||||||
|
#[test]
|
||||||
|
fn altitude_cools_temperature() {
|
||||||
|
let cfg = WorldConfig { temp_jitter: 0.0, ..WorldConfig::default() };
|
||||||
|
let mut elev = Grid::new(5, 5, cfg.sea_level);
|
||||||
|
elev.set(Point::new(2, 2), 0.95); // a peak on the equator
|
||||||
|
let t = temperature(&elev, &noise(2), &cfg);
|
||||||
|
let lowland = *t.get(Point::new(0, 2)).unwrap();
|
||||||
|
let peak = *t.get(Point::new(2, 2)).unwrap();
|
||||||
|
assert!(peak < lowland, "peak {peak} should be colder than lowland {lowland}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A windward/leeward test: with a west wind and a single mountain wall, the
|
||||||
|
/// windward (upwind) flank is wetter than the leeward rain shadow behind it.
|
||||||
|
#[test]
|
||||||
|
fn mountains_cast_a_rain_shadow() {
|
||||||
|
let cfg = WorldConfig {
|
||||||
|
wind: Wind::West,
|
||||||
|
moisture_jitter: 0.0,
|
||||||
|
..WorldConfig::default()
|
||||||
|
};
|
||||||
|
let w = 12u32;
|
||||||
|
// Ocean on the upwind (left) edge so the wind starts wet; a mountain
|
||||||
|
// wall in the middle; dry-able lowland after it.
|
||||||
|
let mut elev = Grid::new(w, 1, cfg.sea_level + 0.1); // lowland
|
||||||
|
elev.set(Point::new(0, 0), 0.1); // ocean column (below sea level)
|
||||||
|
elev.set(Point::new(5, 0), 0.9); // the mountain
|
||||||
|
elev.set(Point::new(6, 0), 0.85);
|
||||||
|
|
||||||
|
let mut water = Grid::new(w, 1, WaterBody::Land);
|
||||||
|
water.set(Point::new(0, 0), WaterBody::Ocean);
|
||||||
|
|
||||||
|
let m = moisture(&elev, &water, &noise(3), &cfg);
|
||||||
|
let windward = *m.get(Point::new(4, 0)).unwrap(); // just upwind of peak
|
||||||
|
let leeward = *m.get(Point::new(8, 0)).unwrap(); // behind the peak
|
||||||
|
assert!(
|
||||||
|
windward > leeward,
|
||||||
|
"windward {windward} should be wetter than the rain shadow {leeward}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Same seed and config ⇒ identical climate fields.
|
||||||
|
#[test]
|
||||||
|
fn climate_is_deterministic() {
|
||||||
|
let cfg = WorldConfig::default();
|
||||||
|
let elev = Grid::from_fn(16, 16, |p| (p.x + p.y) as f32 / 32.0);
|
||||||
|
let water = Grid::new(16, 16, WaterBody::Land);
|
||||||
|
let t1 = temperature(&elev, &noise(9), &cfg);
|
||||||
|
let t2 = temperature(&elev, &noise(9), &cfg);
|
||||||
|
let m1 = moisture(&elev, &water, &noise(9), &cfg);
|
||||||
|
let m2 = moisture(&elev, &water, &noise(9), &cfg);
|
||||||
|
assert_eq!(t1, t2);
|
||||||
|
assert_eq!(m1, m2);
|
||||||
|
}
|
||||||
|
}
|
||||||
328
reikhelm-core/src/world/hydrology.rs
Normal file
328
reikhelm-core/src/world/hydrology.rs
Normal file
|
|
@ -0,0 +1,328 @@
|
||||||
|
//! Hydrology: where the water sits and where it runs.
|
||||||
|
//!
|
||||||
|
//! Three jobs, in order:
|
||||||
|
//!
|
||||||
|
//! 1. [`water_bodies`] floods the sea inward from the map border so that
|
||||||
|
//! below-sea-level basins that *aren't* connected to the open ocean become
|
||||||
|
//! inland **lakes**.
|
||||||
|
//! 2. [`flow_accumulation`] first **fills depressions** with a priority-flood
|
||||||
|
//! (Barnes, Lehman & Mulla 2014) so every cell has a strictly downhill path
|
||||||
|
//! to the border — without this, rivers dead-end in noise pits. It then routes
|
||||||
|
//! each cell's rainfall downhill (steepest D8 descent) and sums the drainage
|
||||||
|
//! passing through every cell.
|
||||||
|
//! 3. [`river_cells`] thresholds that drainage at a chosen density to pick out
|
||||||
|
//! the channels that read as rivers.
|
||||||
|
//!
|
||||||
|
//! No randomness and no transcendental math: given the same elevation/moisture,
|
||||||
|
//! the water is identical on every machine. Float ordering for the priority
|
||||||
|
//! queue uses `f32::total_cmp` with the cell index as a deterministic tie-break.
|
||||||
|
|
||||||
|
use std::cmp::{Ordering, Reverse};
|
||||||
|
use std::collections::{BinaryHeap, VecDeque};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::geometry::Point;
|
||||||
|
use crate::grid::Grid;
|
||||||
|
|
||||||
|
use super::WorldConfig;
|
||||||
|
|
||||||
|
/// Which kind of standing water (if any) fills a cell, before rivers are traced.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum WaterBody {
|
||||||
|
/// Dry land (elevation at or above sea level).
|
||||||
|
Land,
|
||||||
|
/// Below sea level and connected to the map edge — the open sea.
|
||||||
|
Ocean,
|
||||||
|
/// Below sea level but walled off from the open sea — an inland lake.
|
||||||
|
Lake,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Classifies every cell as [`Land`](WaterBody::Land), [`Ocean`](WaterBody::Ocean),
|
||||||
|
/// or [`Lake`](WaterBody::Lake).
|
||||||
|
///
|
||||||
|
/// A cell is water when its elevation is below `cfg.sea_level`. The open ocean is
|
||||||
|
/// the set of water cells reachable (4-connected) from any below-sea-level cell
|
||||||
|
/// on the map border; water cells the flood can't reach are inland lakes. This is
|
||||||
|
/// what lets a high plateau hold a sea without it silently merging with the coast.
|
||||||
|
pub fn water_bodies(elevation: &Grid<f32>, cfg: &WorldConfig) -> Grid<WaterBody> {
|
||||||
|
let (w, h) = (elevation.width(), elevation.height());
|
||||||
|
let is_water = |p: Point| *elevation.get(p).unwrap_or(&1.0) < cfg.sea_level;
|
||||||
|
|
||||||
|
let mut body = Grid::new(w, h, WaterBody::Land);
|
||||||
|
// Mark all water cells as Lake first; the flood promotes the connected ones.
|
||||||
|
for (p, _) in elevation.iter() {
|
||||||
|
if is_water(p) {
|
||||||
|
body.set(p, WaterBody::Lake);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BFS the open ocean inward from every below-sea border cell.
|
||||||
|
let mut queue: VecDeque<Point> = VecDeque::new();
|
||||||
|
let on_border = |p: Point| p.x == 0 || p.y == 0 || p.x == w as i32 - 1 || p.y == h as i32 - 1;
|
||||||
|
for (p, _) in elevation.iter() {
|
||||||
|
if on_border(p) && is_water(p) {
|
||||||
|
body.set(p, WaterBody::Ocean);
|
||||||
|
queue.push_back(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while let Some(p) = queue.pop_front() {
|
||||||
|
// Collect first: `neighbors4` borrows `body`, and we mutate `body` below.
|
||||||
|
let neighbors: Vec<Point> = body.neighbors4(p).collect();
|
||||||
|
for n in neighbors {
|
||||||
|
if is_water(n) && body.get(n) == Some(&WaterBody::Lake) {
|
||||||
|
body.set(n, WaterBody::Ocean);
|
||||||
|
queue.push_back(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A cell waiting in the priority-flood queue, ordered by its spill elevation
|
||||||
|
/// (lowest first), with the flat cell index as a deterministic tie-break.
|
||||||
|
struct FloodCell {
|
||||||
|
spill: f32,
|
||||||
|
idx: u32,
|
||||||
|
}
|
||||||
|
impl PartialEq for FloodCell {
|
||||||
|
fn eq(&self, o: &Self) -> bool {
|
||||||
|
self.idx == o.idx && self.spill.to_bits() == o.spill.to_bits()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Eq for FloodCell {}
|
||||||
|
impl Ord for FloodCell {
|
||||||
|
fn cmp(&self, o: &Self) -> Ordering {
|
||||||
|
// total_cmp gives a total order over f32 (no NaN surprises); the index
|
||||||
|
// tie-break makes equal-elevation pops fully deterministic.
|
||||||
|
self.spill.total_cmp(&o.spill).then(self.idx.cmp(&o.idx))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl PartialOrd for FloodCell {
|
||||||
|
fn partial_cmp(&self, o: &Self) -> Option<Ordering> {
|
||||||
|
Some(self.cmp(o))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fills depressions and returns the drainage **accumulation** field: each
|
||||||
|
/// cell's value is the total upstream rainfall that drains through it.
|
||||||
|
///
|
||||||
|
/// Rainfall per cell is `1 + river_moisture_weight * moisture`, so wetter
|
||||||
|
/// uplands feed larger rivers, but every land cell contributes at least its own
|
||||||
|
/// unit (drainage area). Water cells originate no flow but pass it through to the
|
||||||
|
/// sea. The returned grid shares the input's dimensions.
|
||||||
|
pub fn flow_accumulation(
|
||||||
|
elevation: &Grid<f32>,
|
||||||
|
moisture: &Grid<f32>,
|
||||||
|
water: &Grid<WaterBody>,
|
||||||
|
cfg: &WorldConfig,
|
||||||
|
) -> Grid<f32> {
|
||||||
|
let (w, h) = (elevation.width(), elevation.height());
|
||||||
|
let n = (w as usize) * (h as usize);
|
||||||
|
if n == 0 {
|
||||||
|
return Grid::new(w, h, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let to_pt = |i: u32| Point::new((i % w) as i32, (i / w) as i32);
|
||||||
|
let to_idx = |p: Point| (p.y as u32) * w + p.x as u32;
|
||||||
|
|
||||||
|
// --- 1. Priority-flood (+epsilon) depression fill ----------------------
|
||||||
|
//
|
||||||
|
// `filled` is the elevation raised just enough that every cell has a
|
||||||
|
// strictly downhill route to the border. We never display it — it only
|
||||||
|
// defines flow direction — so the tiny epsilon creep is harmless.
|
||||||
|
const EPS: f32 = 1e-6;
|
||||||
|
let mut filled = vec![f32::INFINITY; n];
|
||||||
|
let mut closed = vec![false; n];
|
||||||
|
let mut heap: BinaryHeap<Reverse<FloodCell>> = BinaryHeap::new();
|
||||||
|
|
||||||
|
let on_border = |p: Point| p.x == 0 || p.y == 0 || p.x == w as i32 - 1 || p.y == h as i32 - 1;
|
||||||
|
for (p, &e) in elevation.iter() {
|
||||||
|
if on_border(p) {
|
||||||
|
let i = to_idx(p) as usize;
|
||||||
|
filled[i] = e;
|
||||||
|
closed[i] = true;
|
||||||
|
heap.push(Reverse(FloodCell { spill: e, idx: to_idx(p) }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while let Some(Reverse(cell)) = heap.pop() {
|
||||||
|
let cp = to_pt(cell.idx);
|
||||||
|
for np in elevation.neighbors8(cp) {
|
||||||
|
let ni = to_idx(np) as usize;
|
||||||
|
if closed[ni] {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
closed[ni] = true;
|
||||||
|
// Spill level: at least the neighbor's own ground, but never below
|
||||||
|
// the cell we came from (plus epsilon to guarantee descent).
|
||||||
|
let raised = elevation.get(np).copied().unwrap_or(0.0).max(cell.spill + EPS);
|
||||||
|
filled[ni] = raised;
|
||||||
|
heap.push(Reverse(FloodCell { spill: raised, idx: to_idx(np) }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 2. Flow direction: steepest D8 descent on the filled surface ------
|
||||||
|
//
|
||||||
|
// `downstream[i]` is the flat index this cell drains into, or `i` itself for
|
||||||
|
// a sink (a border outlet with no lower neighbor).
|
||||||
|
let mut downstream = vec![0u32; n];
|
||||||
|
for (p, _) in elevation.iter() {
|
||||||
|
let i = to_idx(p) as usize;
|
||||||
|
let here = filled[i];
|
||||||
|
let mut best = here;
|
||||||
|
let mut best_idx = to_idx(p);
|
||||||
|
for np in elevation.neighbors8(p) {
|
||||||
|
let ni = to_idx(np) as usize;
|
||||||
|
if filled[ni] < best {
|
||||||
|
best = filled[ni];
|
||||||
|
best_idx = to_idx(np);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
downstream[i] = best_idx;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 3. Accumulate rainfall from peaks down --------------------------
|
||||||
|
//
|
||||||
|
// Processing cells in descending filled-elevation order guarantees that when
|
||||||
|
// we hand a cell's water downstream, every cell upstream of it has already
|
||||||
|
// contributed.
|
||||||
|
let precip = |p: Point| -> f32 {
|
||||||
|
match water.get(p) {
|
||||||
|
Some(WaterBody::Land) => 1.0 + cfg.river_moisture_weight * *moisture.get(p).unwrap_or(&0.0),
|
||||||
|
_ => 0.0, // open water originates no river flow
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut acc = vec![0.0f32; n];
|
||||||
|
for (p, _) in elevation.iter() {
|
||||||
|
acc[to_idx(p) as usize] = precip(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut order: Vec<u32> = (0..n as u32).collect();
|
||||||
|
order.sort_by(|&a, &b| {
|
||||||
|
// Highest filled first; index tie-break keeps it deterministic.
|
||||||
|
filled[b as usize]
|
||||||
|
.total_cmp(&filled[a as usize])
|
||||||
|
.then(a.cmp(&b))
|
||||||
|
});
|
||||||
|
for &i in &order {
|
||||||
|
let d = downstream[i as usize];
|
||||||
|
if d != i {
|
||||||
|
acc[d as usize] += acc[i as usize];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Grid::from_fn(w, h, |p| acc[to_idx(p) as usize])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Picks the river channels from an accumulation field at a given `density`.
|
||||||
|
///
|
||||||
|
/// `density` is the fraction of **land** cells that should read as river
|
||||||
|
/// (`0.05` ≈ the top 5% most-drained land cells). Because flow accumulation only
|
||||||
|
/// grows downstream, the chosen set is naturally contiguous — each river runs
|
||||||
|
/// unbroken from its headwater down to the sea. Returns a boolean mask the size
|
||||||
|
/// of the inputs; lakes and oceans are never rivers.
|
||||||
|
pub fn river_cells(acc: &Grid<f32>, water: &Grid<WaterBody>, density: f32) -> Grid<bool> {
|
||||||
|
let is_land = |p: Point| matches!(water.get(p), Some(WaterBody::Land));
|
||||||
|
|
||||||
|
// Sort land accumulations to find the density-th percentile threshold.
|
||||||
|
let mut land_acc: Vec<f32> = acc
|
||||||
|
.iter()
|
||||||
|
.filter(|(p, _)| is_land(*p))
|
||||||
|
.map(|(_, &v)| v)
|
||||||
|
.collect();
|
||||||
|
if land_acc.is_empty() {
|
||||||
|
return Grid::new(acc.width(), acc.height(), false);
|
||||||
|
}
|
||||||
|
land_acc.sort_by(|a, b| a.total_cmp(b));
|
||||||
|
|
||||||
|
let d = density.clamp(0.0, 1.0);
|
||||||
|
// Index of the first cell that qualifies: (1 - d) of the way up the sorted
|
||||||
|
// list. A density of 0 yields a threshold above every cell (no rivers).
|
||||||
|
let cut = (((1.0 - d) * land_acc.len() as f32) as usize).min(land_acc.len() - 1);
|
||||||
|
let threshold = land_acc[cut];
|
||||||
|
|
||||||
|
Grid::from_fn(acc.width(), acc.height(), |p| {
|
||||||
|
is_land(p) && *acc.get(p).unwrap_or(&0.0) >= threshold && d > 0.0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// An inland basin below sea level becomes a Lake, while a below-sea cell
|
||||||
|
/// touching the border (and its connected neighbors) becomes Ocean.
|
||||||
|
#[test]
|
||||||
|
fn lakes_are_disconnected_from_the_ocean() {
|
||||||
|
let cfg = WorldConfig { sea_level: 0.5, ..WorldConfig::default() };
|
||||||
|
// 5x5 of high land, with a single low cell in the dead center (a lake)
|
||||||
|
// and the whole left column low (ocean reaching the border).
|
||||||
|
let mut elev = Grid::new(5, 5, 0.8);
|
||||||
|
elev.set(Point::new(2, 2), 0.1); // landlocked low cell
|
||||||
|
for y in 0..5 {
|
||||||
|
elev.set(Point::new(0, y), 0.1); // low border column → ocean
|
||||||
|
}
|
||||||
|
let body = water_bodies(&elev, &cfg);
|
||||||
|
assert_eq!(body.get(Point::new(2, 2)), Some(&WaterBody::Lake));
|
||||||
|
assert_eq!(body.get(Point::new(0, 2)), Some(&WaterBody::Ocean));
|
||||||
|
assert_eq!(body.get(Point::new(3, 3)), Some(&WaterBody::Land));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accumulation strictly increases downstream: a cell's drainage is at least
|
||||||
|
/// the sum it received, so the coast carries more water than the headwaters.
|
||||||
|
/// We build a simple tilted plane draining to the right edge and check the
|
||||||
|
/// mouth out-drains the source.
|
||||||
|
#[test]
|
||||||
|
fn accumulation_grows_toward_the_outlet() {
|
||||||
|
let cfg = WorldConfig { sea_level: 0.0, river_moisture_weight: 0.0, ..WorldConfig::default() };
|
||||||
|
// Elevation decreasing left→right so everything drains east.
|
||||||
|
let w = 10u32;
|
||||||
|
let elev = Grid::from_fn(w, 3, |p| (w as i32 - p.x) as f32 / w as f32);
|
||||||
|
let moist = Grid::new(w, 3, 0.0);
|
||||||
|
let water = Grid::new(w, 3, WaterBody::Land);
|
||||||
|
let acc = flow_accumulation(&elev, &moist, &water, &cfg);
|
||||||
|
|
||||||
|
// Elevation decreases left→right, so everything drains east: the eastern
|
||||||
|
// outlet out-drains a western headwater cell.
|
||||||
|
let source = *acc.get(Point::new(1, 1)).unwrap();
|
||||||
|
let mouth = *acc.get(Point::new(8, 1)).unwrap();
|
||||||
|
assert!(mouth > source, "mouth {mouth} should out-drain source {source}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A pit in the middle of a slope doesn't swallow the river: the priority
|
||||||
|
/// flood fills it so flow still reaches the outlet (accumulation downstream
|
||||||
|
/// of the pit exceeds the pit's own rainfall).
|
||||||
|
#[test]
|
||||||
|
fn depressions_are_filled_so_flow_passes_through() {
|
||||||
|
let cfg = WorldConfig { sea_level: 0.0, river_moisture_weight: 0.0, ..WorldConfig::default() };
|
||||||
|
let w = 10u32;
|
||||||
|
let mut elev = Grid::from_fn(w, 3, |p| (w as i32 - p.x) as f32 / w as f32);
|
||||||
|
elev.set(Point::new(5, 1), 0.0); // a deep pit mid-slope
|
||||||
|
let moist = Grid::new(w, 3, 0.0);
|
||||||
|
let water = Grid::new(w, 3, WaterBody::Land);
|
||||||
|
let acc = flow_accumulation(&elev, &moist, &water, &cfg);
|
||||||
|
// Cells east of (downstream of) the pit still accumulate a lot of water,
|
||||||
|
// proving the river wasn't swallowed by the depression.
|
||||||
|
let downstream = *acc.get(Point::new(8, 1)).unwrap();
|
||||||
|
assert!(downstream > 5.0, "flow should pass the pit, got {downstream}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `river_cells` honors density: a higher density marks at least as many
|
||||||
|
/// river cells as a lower one, and density 0 marks none.
|
||||||
|
#[test]
|
||||||
|
fn river_density_is_monotonic() {
|
||||||
|
let cfg = WorldConfig { sea_level: 0.0, river_moisture_weight: 0.0, ..WorldConfig::default() };
|
||||||
|
let w = 16u32;
|
||||||
|
let elev = Grid::from_fn(w, w, |p| (w as i32 - p.x) as f32 / w as f32);
|
||||||
|
let moist = Grid::new(w, w, 0.5);
|
||||||
|
let water = Grid::new(w, w, WaterBody::Land);
|
||||||
|
let acc = flow_accumulation(&elev, &moist, &water, &cfg);
|
||||||
|
|
||||||
|
let count = |d: f32| river_cells(&acc, &water, d).iter().filter(|(_, &b)| b).count();
|
||||||
|
assert_eq!(count(0.0), 0, "density 0 marks no rivers");
|
||||||
|
assert!(count(0.20) >= count(0.05), "more density ⇒ at least as many river cells");
|
||||||
|
assert!(count(0.20) > 0, "a positive density should mark some rivers");
|
||||||
|
}
|
||||||
|
}
|
||||||
511
reikhelm-core/src/world/mod.rs
Normal file
511
reikhelm-core/src/world/mod.rs
Normal file
|
|
@ -0,0 +1,511 @@
|
||||||
|
//! Procedural **world** generation — continents, climate, and rivers.
|
||||||
|
//!
|
||||||
|
//! This is a sibling to the dungeon generator: where `recipes::dungeon` carves a
|
||||||
|
//! discrete `Tile` grid through a [`Pass`](crate::pass::Pass) pipeline, the world
|
||||||
|
//! generator builds *continuous fields* (elevation, temperature, moisture, water
|
||||||
|
//! flow) and classifies them into [`Biome`]s. The output is a [`World`] — a stack
|
||||||
|
//! of aligned [`Grid`]s any game or renderer can read.
|
||||||
|
//!
|
||||||
|
//! ## Stages (the recipe)
|
||||||
|
//!
|
||||||
|
//! [`generate`] runs a fixed sequence, each stage drawing from its own forked
|
||||||
|
//! RNG sub-stream (the same determinism discipline the dungeon pipeline uses):
|
||||||
|
//!
|
||||||
|
//! 1. **Elevation** — domain-warped fBm shaped into continents by an edge
|
||||||
|
//! falloff and a redistribution curve ([`elevation`]).
|
||||||
|
//! 2. **Water bodies** — flood the sea inward; landlocked basins become lakes
|
||||||
|
//! ([`hydrology::water_bodies`]).
|
||||||
|
//! 3. **Temperature** — latitude band cooled by altitude ([`climate::temperature`]).
|
||||||
|
//! 4. **Moisture** — prevailing-wind sweep with orographic rain and rain shadows
|
||||||
|
//! ([`climate::moisture`]).
|
||||||
|
//! 5. **Rivers** — depression-filled flow accumulation, thresholded by density
|
||||||
|
//! ([`hydrology::flow_accumulation`] / [`hydrology::river_cells`]).
|
||||||
|
//! 6. **Surface & biomes** — fold the above into a per-cell [`Surface`] and a
|
||||||
|
//! [`Biome`] ([`biome::classify`]).
|
||||||
|
//!
|
||||||
|
//! ## Determinism
|
||||||
|
//!
|
||||||
|
//! Same `(config, seed)` ⇒ byte-identical [`World`] on every machine. All
|
||||||
|
//! randomness flows through [`Rng`]; the whole generator avoids transcendental
|
||||||
|
//! functions (only `+ - * /` and comparisons), so even the floating-point fields
|
||||||
|
//! reproduce exactly across platforms — the same guarantee `conventions.md` makes
|
||||||
|
//! for the dungeon core.
|
||||||
|
|
||||||
|
pub mod biome;
|
||||||
|
pub mod climate;
|
||||||
|
pub mod hydrology;
|
||||||
|
pub mod noise;
|
||||||
|
|
||||||
|
use std::error::Error;
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::geometry::Point;
|
||||||
|
use crate::grid::Grid;
|
||||||
|
use crate::rng::Rng;
|
||||||
|
|
||||||
|
pub use biome::{Biome, Surface};
|
||||||
|
pub use hydrology::WaterBody;
|
||||||
|
use noise::Noise;
|
||||||
|
|
||||||
|
/// The prevailing wind direction, naming the edge the wind blows *from*.
|
||||||
|
///
|
||||||
|
/// Moisture-bearing air enters from this edge, so windward coasts on this side
|
||||||
|
/// are wet and the far (leeward) interiors fall into rain shadows.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum Wind {
|
||||||
|
/// Wind out of the west, traveling left→right.
|
||||||
|
West,
|
||||||
|
/// Wind out of the east, traveling right→left.
|
||||||
|
East,
|
||||||
|
/// Wind out of the north, traveling top→bottom.
|
||||||
|
North,
|
||||||
|
/// Wind out of the south, traveling bottom→top.
|
||||||
|
South,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every knob that shapes a [`World`]. One flat struct (rather than per-stage
|
||||||
|
/// configs) so the whole generator is configured — and later exposed over an
|
||||||
|
/// API — through a single serializable object.
|
||||||
|
///
|
||||||
|
/// [`Default`] produces a recognizable continent-and-sea world on a 256×160
|
||||||
|
/// canvas; the field docs describe what each knob does and the direction it
|
||||||
|
/// pushes.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct WorldConfig {
|
||||||
|
/// Canvas width in cells.
|
||||||
|
pub width: u32,
|
||||||
|
/// Canvas height in cells.
|
||||||
|
pub height: u32,
|
||||||
|
|
||||||
|
// --- elevation ------------------------------------------------------
|
||||||
|
/// Elevation below which a cell is water. Higher ⇒ more sea.
|
||||||
|
pub sea_level: f32,
|
||||||
|
/// Base fBm cycles across the map width. Higher ⇒ more, smaller landmasses.
|
||||||
|
pub continent_scale: f32,
|
||||||
|
/// Number of fBm octaves for elevation detail.
|
||||||
|
pub elevation_octaves: u32,
|
||||||
|
/// Frequency multiplier between octaves.
|
||||||
|
pub lacunarity: f32,
|
||||||
|
/// Amplitude multiplier between octaves.
|
||||||
|
pub gain: f32,
|
||||||
|
/// Domain-warp strength (in coordinate cycles). Higher ⇒ more sinuous coasts.
|
||||||
|
pub warp: f32,
|
||||||
|
/// How hard the map borders are pulled down to ocean, `0.0`..=`1.0`. Higher
|
||||||
|
/// ⇒ the world is an island surrounded by sea; `0.0` lets land run off the
|
||||||
|
/// edge.
|
||||||
|
pub edge_falloff: f32,
|
||||||
|
/// Lowland-deepening: blends elevation toward its square, `0.0`..=`1.0`.
|
||||||
|
/// Higher ⇒ broader lowlands/seas with sharper, rarer peaks.
|
||||||
|
pub redistribution: f32,
|
||||||
|
|
||||||
|
// --- climate --------------------------------------------------------
|
||||||
|
/// Cooling per unit of elevation above sea level.
|
||||||
|
pub lapse_rate: f32,
|
||||||
|
/// Amplitude of the fBm wobble applied to temperature isotherms.
|
||||||
|
pub temp_jitter: f32,
|
||||||
|
/// Prevailing wind direction (drives the moisture sweep).
|
||||||
|
pub wind: Wind,
|
||||||
|
/// Fraction of carried moisture rained out per land cell (baseline drizzle).
|
||||||
|
pub rainfall: f32,
|
||||||
|
/// Moisture re-gained toward saturation per water cell the wind crosses.
|
||||||
|
pub evaporation: f32,
|
||||||
|
/// Multiplier on orographic (uphill) rain — how sharply slopes wring out the
|
||||||
|
/// wind and deepen the rain shadow behind them.
|
||||||
|
pub orographic: f32,
|
||||||
|
/// Amplitude of the fBm wobble applied to moisture.
|
||||||
|
pub moisture_jitter: f32,
|
||||||
|
/// Noise scale (cycles across width) for the temperature/moisture wobble.
|
||||||
|
pub climate_scale: f32,
|
||||||
|
|
||||||
|
// --- hydrology & biomes --------------------------------------------
|
||||||
|
/// How much moisture boosts a cell's river contribution (drainage area is
|
||||||
|
/// always counted; this adds rainfall weighting on top).
|
||||||
|
pub river_moisture_weight: f32,
|
||||||
|
/// Fraction of land cells that read as river, `0.0`..=`1.0`.
|
||||||
|
pub river_density: f32,
|
||||||
|
/// Elevation at/above which land becomes bare rock or snow (the alpine band).
|
||||||
|
pub high_alt: f32,
|
||||||
|
/// Ocean shallower than this depth below sea level renders as a coastal shelf.
|
||||||
|
pub shallow_depth: f32,
|
||||||
|
/// Land within this elevation band above sea level, and touching the ocean,
|
||||||
|
/// is beach.
|
||||||
|
pub beach_width: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for WorldConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
WorldConfig {
|
||||||
|
width: 256,
|
||||||
|
height: 160,
|
||||||
|
|
||||||
|
sea_level: 0.38,
|
||||||
|
continent_scale: 2.5,
|
||||||
|
elevation_octaves: 6,
|
||||||
|
lacunarity: 2.0,
|
||||||
|
gain: 0.5,
|
||||||
|
warp: 0.55,
|
||||||
|
edge_falloff: 0.82,
|
||||||
|
redistribution: 0.35,
|
||||||
|
|
||||||
|
lapse_rate: 0.65,
|
||||||
|
temp_jitter: 0.06,
|
||||||
|
wind: Wind::West,
|
||||||
|
rainfall: 0.04,
|
||||||
|
evaporation: 0.20,
|
||||||
|
orographic: 3.0,
|
||||||
|
moisture_jitter: 0.06,
|
||||||
|
climate_scale: 3.5,
|
||||||
|
|
||||||
|
river_moisture_weight: 0.6,
|
||||||
|
river_density: 0.06,
|
||||||
|
high_alt: 0.80,
|
||||||
|
shallow_depth: 0.05,
|
||||||
|
beach_width: 0.015,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why a [`WorldConfig`] was rejected (returned instead of panicking, so a
|
||||||
|
/// caller — e.g. a future interactive editor — can surface and fix it).
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub enum WorldConfigError {
|
||||||
|
/// Width or height is zero — nothing to generate.
|
||||||
|
ZeroDimension,
|
||||||
|
/// `sea_level` is not strictly inside `(0, 1)`, so the world is all land or
|
||||||
|
/// all sea.
|
||||||
|
SeaLevelOutOfRange,
|
||||||
|
/// `elevation_octaves` is zero — fBm needs at least one octave.
|
||||||
|
NoOctaves,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for WorldConfigError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
WorldConfigError::ZeroDimension => {
|
||||||
|
write!(f, "world width and height must both be greater than zero")
|
||||||
|
}
|
||||||
|
WorldConfigError::SeaLevelOutOfRange => {
|
||||||
|
write!(f, "sea_level must be strictly between 0 and 1")
|
||||||
|
}
|
||||||
|
WorldConfigError::NoOctaves => {
|
||||||
|
write!(f, "elevation_octaves must be at least 1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Error for WorldConfigError {}
|
||||||
|
|
||||||
|
impl WorldConfig {
|
||||||
|
/// Validates the config, returning the first problem found or `Ok`.
|
||||||
|
pub fn validate(&self) -> Result<(), WorldConfigError> {
|
||||||
|
if self.width == 0 || self.height == 0 {
|
||||||
|
return Err(WorldConfigError::ZeroDimension);
|
||||||
|
}
|
||||||
|
if !(self.sea_level > 0.0 && self.sea_level < 1.0) {
|
||||||
|
return Err(WorldConfigError::SeaLevelOutOfRange);
|
||||||
|
}
|
||||||
|
if self.elevation_octaves == 0 {
|
||||||
|
return Err(WorldConfigError::NoOctaves);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A generated world: a stack of aligned `width × height` field grids plus the
|
||||||
|
/// derived [`Surface`] and [`Biome`] classifications.
|
||||||
|
///
|
||||||
|
/// All grids share the same dimensions and indexing, so a cell `(x, y)` reads
|
||||||
|
/// the same location in every layer. The continuous fields are normalized to
|
||||||
|
/// `[0, 1]`; `flow` is raw drainage accumulation (unbounded, larger toward the
|
||||||
|
/// coast).
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct World {
|
||||||
|
/// Canvas width in cells.
|
||||||
|
pub width: u32,
|
||||||
|
/// Canvas height in cells.
|
||||||
|
pub height: u32,
|
||||||
|
/// The seed this world was generated from.
|
||||||
|
pub seed: u64,
|
||||||
|
/// The config that produced this world (carried so consumers can read the
|
||||||
|
/// parameters — sea level, wind, etc. — that the fields were built against).
|
||||||
|
pub config: WorldConfig,
|
||||||
|
/// Normalized elevation, `0.0` (deepest sea) .. `1.0` (highest peak).
|
||||||
|
pub elevation: Grid<f32>,
|
||||||
|
/// Normalized temperature, `0.0` (polar) .. `1.0` (equatorial).
|
||||||
|
pub temperature: Grid<f32>,
|
||||||
|
/// Normalized moisture, `0.0` (arid) .. `1.0` (saturated).
|
||||||
|
pub moisture: Grid<f32>,
|
||||||
|
/// Standing-water classification (ocean/lake/land) before rivers.
|
||||||
|
pub water: Grid<WaterBody>,
|
||||||
|
/// Drainage accumulation — total upstream rainfall passing through a cell.
|
||||||
|
pub flow: Grid<f32>,
|
||||||
|
/// Per-cell surface (land/ocean/lake/river/beach) feeding biome choice.
|
||||||
|
pub surface: Grid<Surface>,
|
||||||
|
/// The final biome classification per cell.
|
||||||
|
pub biome: Grid<Biome>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generates a [`World`] from `cfg` and `seed`, or a [`WorldConfigError`] if the
|
||||||
|
/// config is invalid.
|
||||||
|
///
|
||||||
|
/// Each stage forks its own RNG sub-stream from the seed (`"elevation"`,
|
||||||
|
/// `"temperature"`, `"moisture"`), so the noise fields are decorrelated and —
|
||||||
|
/// because [`Rng::fork`] keys only on `(seed, label)` — reordering or inserting a
|
||||||
|
/// stage never reshuffles another's randomness (spec §7 in spirit).
|
||||||
|
pub fn generate(cfg: WorldConfig, seed: u64) -> Result<World, WorldConfigError> {
|
||||||
|
cfg.validate()?;
|
||||||
|
|
||||||
|
let root = Rng::from_seed(seed);
|
||||||
|
let elevation_noise = Noise::new(&mut root.fork("elevation"));
|
||||||
|
let temperature_noise = Noise::new(&mut root.fork("temperature"));
|
||||||
|
let moisture_noise = Noise::new(&mut root.fork("moisture"));
|
||||||
|
|
||||||
|
// 1. Elevation → continents.
|
||||||
|
let elevation = elevation(&cfg, &elevation_noise);
|
||||||
|
|
||||||
|
// 2. Where the standing water sits (ocean vs landlocked lake).
|
||||||
|
let water = hydrology::water_bodies(&elevation, &cfg);
|
||||||
|
|
||||||
|
// 3 & 4. Climate fields.
|
||||||
|
let temperature = climate::temperature(&elevation, &temperature_noise, &cfg);
|
||||||
|
let moisture = climate::moisture(&elevation, &water, &moisture_noise, &cfg);
|
||||||
|
|
||||||
|
// 5. Rivers from depression-filled flow accumulation.
|
||||||
|
let flow = hydrology::flow_accumulation(&elevation, &moisture, &water, &cfg);
|
||||||
|
let rivers = hydrology::river_cells(&flow, &water, cfg.river_density);
|
||||||
|
|
||||||
|
// 6. Fold everything into surfaces and biomes.
|
||||||
|
let surface = assemble_surface(&elevation, &water, &rivers, &cfg);
|
||||||
|
let biome = Grid::from_fn(cfg.width, cfg.height, |p| {
|
||||||
|
biome::classify(
|
||||||
|
*surface.get(p).unwrap_or(&Surface::Land),
|
||||||
|
*elevation.get(p).unwrap_or(&0.0),
|
||||||
|
*temperature.get(p).unwrap_or(&0.5),
|
||||||
|
*moisture.get(p).unwrap_or(&0.5),
|
||||||
|
cfg.high_alt,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(World {
|
||||||
|
width: cfg.width,
|
||||||
|
height: cfg.height,
|
||||||
|
seed,
|
||||||
|
config: cfg,
|
||||||
|
elevation,
|
||||||
|
temperature,
|
||||||
|
moisture,
|
||||||
|
water,
|
||||||
|
flow,
|
||||||
|
surface,
|
||||||
|
biome,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the normalized elevation field: domain-warped fBm, shaped into
|
||||||
|
/// continents by an edge falloff (pull the borders down to sea) and a
|
||||||
|
/// redistribution curve (deepen lowlands so coastlines and mountains read).
|
||||||
|
fn elevation(cfg: &WorldConfig, noise: &Noise) -> Grid<f32> {
|
||||||
|
let (w, h) = (cfg.width, cfg.height);
|
||||||
|
// Sample both axes against the *width* so features stay square (not stretched
|
||||||
|
// when the canvas isn't). Guard the 1-px degenerate dimensions.
|
||||||
|
let scale = cfg.continent_scale / w.max(1) as f32;
|
||||||
|
let denom_x = (w.max(2) - 1) as f32;
|
||||||
|
let denom_y = (h.max(2) - 1) as f32;
|
||||||
|
|
||||||
|
Grid::from_fn(w, h, |p: Point| {
|
||||||
|
let nx = p.x as f32 * scale;
|
||||||
|
let ny = p.y as f32 * scale;
|
||||||
|
let base = noise.fbm_warped(nx, ny, cfg.elevation_octaves, cfg.lacunarity, cfg.gain, cfg.warp);
|
||||||
|
let mut e = 0.5 + 0.5 * base; // [-1,1] → [0,1]
|
||||||
|
|
||||||
|
// Edge falloff: distance to the nearest border, 0 center → 1 edge.
|
||||||
|
let fx = (2.0 * p.x as f32 / denom_x - 1.0).abs();
|
||||||
|
let fy = (2.0 * p.y as f32 / denom_y - 1.0).abs();
|
||||||
|
let edge = fx.max(fy);
|
||||||
|
// Keep full height until FALLOFF_START, then ramp to 0 by the border.
|
||||||
|
const FALLOFF_START: f32 = 0.55;
|
||||||
|
let ramp = smoothstep(FALLOFF_START, 1.0, edge); // 0 inland → 1 at edge
|
||||||
|
e *= 1.0 - cfg.edge_falloff * ramp;
|
||||||
|
|
||||||
|
// Lowland-deepening: blend toward e² (exact, transcendental-free).
|
||||||
|
e = lerp(e, e * e, cfg.redistribution);
|
||||||
|
|
||||||
|
e.clamp(0.0, 1.0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Folds elevation, water bodies, and the river mask into a per-cell [`Surface`].
|
||||||
|
///
|
||||||
|
/// Ocean depth splits deep vs. shelf sea; lakes pass through; land is river where
|
||||||
|
/// the flow mask says so, beach where it is low and touches the ocean, otherwise
|
||||||
|
/// plain land.
|
||||||
|
fn assemble_surface(
|
||||||
|
elevation: &Grid<f32>,
|
||||||
|
water: &Grid<WaterBody>,
|
||||||
|
rivers: &Grid<bool>,
|
||||||
|
cfg: &WorldConfig,
|
||||||
|
) -> Grid<Surface> {
|
||||||
|
let touches_ocean = |p: Point| {
|
||||||
|
water
|
||||||
|
.neighbors8(p)
|
||||||
|
.any(|n| water.get(n) == Some(&WaterBody::Ocean))
|
||||||
|
};
|
||||||
|
|
||||||
|
Grid::from_fn(elevation.width(), elevation.height(), |p| {
|
||||||
|
let elev = *elevation.get(p).unwrap_or(&0.0);
|
||||||
|
match water.get(p) {
|
||||||
|
Some(WaterBody::Ocean) => {
|
||||||
|
if cfg.sea_level - elev < cfg.shallow_depth {
|
||||||
|
Surface::ShallowOcean
|
||||||
|
} else {
|
||||||
|
Surface::DeepOcean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(WaterBody::Lake) => Surface::Lake,
|
||||||
|
_ => {
|
||||||
|
if *rivers.get(p).unwrap_or(&false) {
|
||||||
|
Surface::River
|
||||||
|
} else if elev - cfg.sea_level < cfg.beach_width && touches_ocean(p) {
|
||||||
|
Surface::Beach
|
||||||
|
} else {
|
||||||
|
Surface::Land
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `smoothstep(a, b, x)`: 0 below `a`, 1 above `b`, and a smooth Hermite ramp
|
||||||
|
/// between. Degenerate `a == b` returns a hard step at the threshold.
|
||||||
|
fn smoothstep(a: f32, b: f32, x: f32) -> f32 {
|
||||||
|
if b <= a {
|
||||||
|
return if x < a { 0.0 } else { 1.0 };
|
||||||
|
}
|
||||||
|
let t = ((x - a) / (b - a)).clamp(0.0, 1.0);
|
||||||
|
t * t * (3.0 - 2.0 * t)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Linear interpolation from `a` to `b` by `t`.
|
||||||
|
fn lerp(a: f32, b: f32, t: f32) -> f32 {
|
||||||
|
a + t * (b - a)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl World {
|
||||||
|
/// Renders the biome map as ASCII (one [`Biome::glyph`] per cell, rows
|
||||||
|
/// separated by newlines) for quick eyeballing under `cargo test --
|
||||||
|
/// --nocapture`. Presentation only — the renderer owns color.
|
||||||
|
pub fn to_ascii(&self) -> String {
|
||||||
|
let mut out = String::with_capacity((self.width as usize + 1) * self.height as usize);
|
||||||
|
for y in 0..self.height as i32 {
|
||||||
|
for x in 0..self.width as i32 {
|
||||||
|
let b = self.biome.get(Point::new(x, y)).copied().unwrap_or(Biome::DeepOcean);
|
||||||
|
out.push(b.glyph());
|
||||||
|
}
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The fraction of cells that are land (not any water biome) — a quick
|
||||||
|
/// land/sea ratio for sanity checks and tuning.
|
||||||
|
pub fn land_fraction(&self) -> f32 {
|
||||||
|
let total = (self.width as usize * self.height as usize).max(1);
|
||||||
|
let land = self.biome.iter().filter(|(_, b)| !b.is_water()).count();
|
||||||
|
land as f32 / total as f32
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
|
/// Same seed + config ⇒ byte-identical worlds (the determinism contract).
|
||||||
|
#[test]
|
||||||
|
fn same_seed_produces_identical_worlds() {
|
||||||
|
let cfg = WorldConfig::default();
|
||||||
|
let a = generate(cfg, 2026).unwrap();
|
||||||
|
let b = generate(cfg, 2026).unwrap();
|
||||||
|
assert_eq!(a, b, "same seed must reproduce a byte-identical World");
|
||||||
|
assert_eq!(a.to_ascii(), b.to_ascii());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Different seeds produce different worlds.
|
||||||
|
#[test]
|
||||||
|
fn distinct_seeds_diverge() {
|
||||||
|
let cfg = WorldConfig::default();
|
||||||
|
let a = generate(cfg, 1).unwrap();
|
||||||
|
let b = generate(cfg, 2).unwrap();
|
||||||
|
assert_ne!(a.biome, b.biome);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A default world is a believable mix: it has both sea and land, the land
|
||||||
|
/// isn't a trivial sliver, and several distinct biomes appear.
|
||||||
|
#[test]
|
||||||
|
fn default_world_is_a_varied_mix() {
|
||||||
|
let cfg = WorldConfig::default();
|
||||||
|
for seed in [1u64, 7, 42, 100, 2026] {
|
||||||
|
let world = generate(cfg, seed).unwrap();
|
||||||
|
let land = world.land_fraction();
|
||||||
|
assert!(
|
||||||
|
(0.15..0.85).contains(&land),
|
||||||
|
"seed {seed}: land fraction {land} is not a believable mix"
|
||||||
|
);
|
||||||
|
|
||||||
|
let biomes: BTreeSet<Biome> = world.biome.iter().map(|(_, &b)| b).collect();
|
||||||
|
assert!(
|
||||||
|
biomes.len() >= 5,
|
||||||
|
"seed {seed}: expected a varied world, saw only {} biomes",
|
||||||
|
biomes.len()
|
||||||
|
);
|
||||||
|
// There is open ocean and there is dry land.
|
||||||
|
assert!(biomes.iter().any(|b| b.is_water()), "seed {seed}: no water");
|
||||||
|
assert!(biomes.iter().any(|b| !b.is_water()), "seed {seed}: no land");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rivers appear and they are made of River-surface land cells (the flow
|
||||||
|
/// accumulation actually carved channels).
|
||||||
|
#[test]
|
||||||
|
fn default_world_has_rivers() {
|
||||||
|
let cfg = WorldConfig::default();
|
||||||
|
let mut total_river = 0usize;
|
||||||
|
for seed in 1..=8u64 {
|
||||||
|
let world = generate(cfg, seed).unwrap();
|
||||||
|
total_river += world.biome.iter().filter(|(_, &b)| b == Biome::River).count();
|
||||||
|
}
|
||||||
|
assert!(total_river > 0, "expected some river cells across 8 seeds");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Invalid configs are rejected, never panic.
|
||||||
|
#[test]
|
||||||
|
fn invalid_configs_are_rejected() {
|
||||||
|
let zero = WorldConfig { width: 0, ..WorldConfig::default() };
|
||||||
|
assert_eq!(generate(zero, 0).err(), Some(WorldConfigError::ZeroDimension));
|
||||||
|
|
||||||
|
let bad_sea = WorldConfig { sea_level: 1.5, ..WorldConfig::default() };
|
||||||
|
assert_eq!(generate(bad_sea, 0).err(), Some(WorldConfigError::SeaLevelOutOfRange));
|
||||||
|
|
||||||
|
let no_oct = WorldConfig { elevation_octaves: 0, ..WorldConfig::default() };
|
||||||
|
assert_eq!(generate(no_oct, 0).err(), Some(WorldConfigError::NoOctaves));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prints one world for eyeball confirmation under `--nocapture`.
|
||||||
|
#[test]
|
||||||
|
fn print_one_world_for_eyeballing() {
|
||||||
|
// A small canvas so the ASCII fits a terminal.
|
||||||
|
let cfg = WorldConfig { width: 100, height: 50, ..WorldConfig::default() };
|
||||||
|
let world = generate(cfg, 2026).unwrap();
|
||||||
|
println!(
|
||||||
|
"\n--- world seed=2026 {}x{} (land {:.0}%) ---\n{}--- end world ---",
|
||||||
|
cfg.width,
|
||||||
|
cfg.height,
|
||||||
|
world.land_fraction() * 100.0,
|
||||||
|
world.to_ascii()
|
||||||
|
);
|
||||||
|
assert!(!world.to_ascii().is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
241
reikhelm-core/src/world/noise.rs
Normal file
241
reikhelm-core/src/world/noise.rs
Normal file
|
|
@ -0,0 +1,241 @@
|
||||||
|
//! Deterministic coherent noise: seeded Perlin gradient noise, fractal Brownian
|
||||||
|
//! motion (fBm), and domain warping.
|
||||||
|
//!
|
||||||
|
//! We **own the noise** rather than pull a `noise` crate — same reasoning the
|
||||||
|
//! project applies to `range`/`shuffle` (see `conventions.md`): a third-party
|
||||||
|
//! generator's output can drift across versions, which would silently change
|
||||||
|
//! every world for a given seed. This implementation is a pure function of a
|
||||||
|
//! permutation table built from our [`Rng`], and uses only `+ - * /` and
|
||||||
|
//! comparisons — **no transcendental functions** — so a seed reproduces the same
|
||||||
|
//! field on every machine (spec §7 in spirit).
|
||||||
|
//!
|
||||||
|
//! The classic 2D Perlin construction: lattice corners each carry a pseudo-random
|
||||||
|
//! gradient (selected by a hashed permutation), the sample's offset from each
|
||||||
|
//! corner is dotted with that corner's gradient, and the four corner
|
||||||
|
//! contributions are interpolated with Perlin's quintic fade curve.
|
||||||
|
|
||||||
|
use crate::rng::Rng;
|
||||||
|
|
||||||
|
/// A seeded 2D coherent-noise sampler.
|
||||||
|
///
|
||||||
|
/// Build one with [`Noise::new`] (consuming randomness from a forked [`Rng`]),
|
||||||
|
/// then sample with [`perlin`](Noise::perlin) for a single octave or
|
||||||
|
/// [`fbm`](Noise::fbm) for layered fractal detail. Two `Noise` values built from
|
||||||
|
/// distinct RNG sub-streams are decorrelated, which is how the generator keeps
|
||||||
|
/// elevation, moisture, and warp fields independent.
|
||||||
|
pub struct Noise {
|
||||||
|
/// The classic Perlin permutation, doubled to 512 so corner lookups
|
||||||
|
/// (`perm[x] + y`) never need a modulo. The first 256 entries are a
|
||||||
|
/// shuffle of `0..256`; entries `256..512` repeat them.
|
||||||
|
perm: [u8; 512],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Noise {
|
||||||
|
/// Builds a sampler whose gradient layout is determined by `rng`.
|
||||||
|
///
|
||||||
|
/// Draws exactly one shuffle of `0..256` from `rng`, so the permutation —
|
||||||
|
/// and therefore every sample — is a deterministic function of the stream
|
||||||
|
/// `rng` was forked from.
|
||||||
|
pub fn new(rng: &mut Rng) -> Self {
|
||||||
|
let mut base: Vec<u8> = (0..=255).collect();
|
||||||
|
rng.shuffle(&mut base);
|
||||||
|
|
||||||
|
let mut perm = [0u8; 512];
|
||||||
|
for i in 0..512 {
|
||||||
|
perm[i] = base[i & 255];
|
||||||
|
}
|
||||||
|
Noise { perm }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Samples a single Perlin octave at `(x, y)`, returning roughly `[-1, 1]`.
|
||||||
|
///
|
||||||
|
/// Integer lattice cells are unit-sized, so callers scale their coordinates
|
||||||
|
/// to choose a feature size (a smaller multiplier ⇒ broader features).
|
||||||
|
pub fn perlin(&self, x: f32, y: f32) -> f32 {
|
||||||
|
// Lattice cell origin (wrapped into the permutation's 0..256 domain) and
|
||||||
|
// the in-cell fractional offset.
|
||||||
|
let xi = (fast_floor(x) & 255) as usize;
|
||||||
|
let yi = (fast_floor(y) & 255) as usize;
|
||||||
|
let xf = x - fast_floor(x) as f32;
|
||||||
|
let yf = y - fast_floor(y) as f32;
|
||||||
|
|
||||||
|
// Quintic fade for each axis.
|
||||||
|
let u = fade(xf);
|
||||||
|
let v = fade(yf);
|
||||||
|
|
||||||
|
// Hash the four cell corners. The doubled `perm` makes `+1` lookups safe
|
||||||
|
// without wrapping arithmetic.
|
||||||
|
let p = &self.perm;
|
||||||
|
let aa = p[p[xi] as usize + yi] as usize;
|
||||||
|
let ab = p[p[xi] as usize + yi + 1] as usize;
|
||||||
|
let ba = p[p[xi + 1] as usize + yi] as usize;
|
||||||
|
let bb = p[p[xi + 1] as usize + yi + 1] as usize;
|
||||||
|
|
||||||
|
// Dot each corner's gradient with the offset *from that corner*, then
|
||||||
|
// bilinearly interpolate with the faded weights.
|
||||||
|
let x1 = lerp(grad(aa, xf, yf), grad(ba, xf - 1.0, yf), u);
|
||||||
|
let x2 = lerp(grad(ab, xf, yf - 1.0), grad(bb, xf - 1.0, yf - 1.0), u);
|
||||||
|
lerp(x1, x2, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Samples layered fBm at `(x, y)`, normalized to `[-1, 1]`.
|
||||||
|
///
|
||||||
|
/// Sums `octaves` Perlin octaves, each `lacunarity`× the previous frequency
|
||||||
|
/// and `gain`× the previous amplitude (the standard fractal sum). The result
|
||||||
|
/// is divided by the total amplitude so it stays in `[-1, 1]` regardless of
|
||||||
|
/// the octave count, which keeps downstream thresholds (sea level, etc.)
|
||||||
|
/// stable when the detail level changes.
|
||||||
|
pub fn fbm(&self, x: f32, y: f32, octaves: u32, lacunarity: f32, gain: f32) -> f32 {
|
||||||
|
let mut freq = 1.0;
|
||||||
|
let mut amp = 1.0;
|
||||||
|
let mut sum = 0.0;
|
||||||
|
let mut norm = 0.0;
|
||||||
|
for _ in 0..octaves {
|
||||||
|
sum += amp * self.perlin(x * freq, y * freq);
|
||||||
|
norm += amp;
|
||||||
|
amp *= gain;
|
||||||
|
freq *= lacunarity;
|
||||||
|
}
|
||||||
|
if norm == 0.0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
sum / norm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// fBm with **domain warping**: the sample point is displaced by an
|
||||||
|
/// independent fBm vector before sampling, which bends otherwise-round
|
||||||
|
/// features into organic, wandering coastlines and ridges.
|
||||||
|
///
|
||||||
|
/// `warp` scales the displacement (0.0 disables it, reproducing
|
||||||
|
/// [`fbm`](Noise::fbm)). The warp vector is sampled from the *same* field at
|
||||||
|
/// large fixed coordinate offsets, so it is decorrelated from the base
|
||||||
|
/// lookup without needing a second sampler.
|
||||||
|
pub fn fbm_warped(
|
||||||
|
&self,
|
||||||
|
x: f32,
|
||||||
|
y: f32,
|
||||||
|
octaves: u32,
|
||||||
|
lacunarity: f32,
|
||||||
|
gain: f32,
|
||||||
|
warp: f32,
|
||||||
|
) -> f32 {
|
||||||
|
// Two decorrelated fBm samples (offset far apart) form the warp vector.
|
||||||
|
let wx = self.fbm(x + 113.7, y + 31.4, octaves, lacunarity, gain);
|
||||||
|
let wy = self.fbm(x + 271.9, y + 157.2, octaves, lacunarity, gain);
|
||||||
|
self.fbm(x + warp * wx, y + warp * wy, octaves, lacunarity, gain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Perlin's quintic fade `6t⁵ − 15t⁴ + 10t³`, which has zero first and second
|
||||||
|
/// derivatives at 0 and 1 so abutting lattice cells blend without creases.
|
||||||
|
fn fade(t: f32) -> f32 {
|
||||||
|
t * t * t * (t * (t * 6.0 - 15.0) + 10.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Linear interpolation from `a` to `b` by `t`.
|
||||||
|
fn lerp(a: f32, b: f32, t: f32) -> f32 {
|
||||||
|
a + t * (b - a)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `floor` as an integer without calling the libm `floor` intrinsic.
|
||||||
|
///
|
||||||
|
/// For the negative case we subtract one when the truncation rounded *up*
|
||||||
|
/// toward zero. Keeping this in integer/comparison space (no `f32::floor`) is
|
||||||
|
/// part of staying transcendental-free for cross-machine determinism.
|
||||||
|
fn fast_floor(x: f32) -> i32 {
|
||||||
|
let t = x as i32;
|
||||||
|
if x < t as f32 {
|
||||||
|
t - 1
|
||||||
|
} else {
|
||||||
|
t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maps a corner's hash to one of eight gradient directions and dots it with the
|
||||||
|
/// offset `(x, y)`.
|
||||||
|
///
|
||||||
|
/// The eight directions are the axis and diagonal unit-ish vectors; this is the
|
||||||
|
/// compact 2D gradient set (cheaper than projecting 3D gradients and visually
|
||||||
|
/// indistinguishable at fBm scale).
|
||||||
|
fn grad(hash: usize, x: f32, y: f32) -> f32 {
|
||||||
|
match hash & 7 {
|
||||||
|
0 => x + y,
|
||||||
|
1 => x - y,
|
||||||
|
2 => -x + y,
|
||||||
|
3 => -x - y,
|
||||||
|
4 => x,
|
||||||
|
5 => -x,
|
||||||
|
6 => y,
|
||||||
|
_ => -y,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn sampler(seed: u64) -> Noise {
|
||||||
|
let mut rng = Rng::from_seed(seed);
|
||||||
|
Noise::new(&mut rng)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Same seed ⇒ identical permutation ⇒ identical samples at probe points.
|
||||||
|
#[test]
|
||||||
|
fn same_seed_reproduces_samples() {
|
||||||
|
let a = sampler(42);
|
||||||
|
let b = sampler(42);
|
||||||
|
for &(x, y) in &[(0.3, 0.7), (12.5, -4.25), (100.1, 100.1), (-7.0, 3.0)] {
|
||||||
|
assert_eq!(a.perlin(x, y), b.perlin(x, y));
|
||||||
|
assert_eq!(
|
||||||
|
a.fbm(x, y, 5, 2.0, 0.5),
|
||||||
|
b.fbm(x, y, 5, 2.0, 0.5),
|
||||||
|
"fbm must reproduce too"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Distinct seeds give distinct fields (overwhelmingly).
|
||||||
|
#[test]
|
||||||
|
fn distinct_seeds_diverge() {
|
||||||
|
let a = sampler(1);
|
||||||
|
let b = sampler(2);
|
||||||
|
let sa: Vec<f32> = (0..32).map(|i| a.fbm(i as f32 * 0.3, 1.0, 4, 2.0, 0.5)).collect();
|
||||||
|
let sb: Vec<f32> = (0..32).map(|i| b.fbm(i as f32 * 0.3, 1.0, 4, 2.0, 0.5)).collect();
|
||||||
|
assert_ne!(sa, sb);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Perlin is exactly zero on integer lattice points: every corner offset is
|
||||||
|
/// zero there, so every gradient dot product vanishes.
|
||||||
|
#[test]
|
||||||
|
fn perlin_is_zero_on_lattice() {
|
||||||
|
let n = sampler(7);
|
||||||
|
for y in -3..3 {
|
||||||
|
for x in -3..3 {
|
||||||
|
assert_eq!(n.perlin(x as f32, y as f32), 0.0, "lattice point ({x},{y})");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// fBm stays within `[-1, 1]` across a sweep (the amplitude normalization
|
||||||
|
/// holds), so sea-level thresholds downstream are meaningful.
|
||||||
|
#[test]
|
||||||
|
fn fbm_is_bounded() {
|
||||||
|
let n = sampler(123);
|
||||||
|
for i in 0..1000 {
|
||||||
|
let x = i as f32 * 0.137;
|
||||||
|
let y = i as f32 * 0.071;
|
||||||
|
let v = n.fbm(x, y, 6, 2.0, 0.5);
|
||||||
|
assert!((-1.0..=1.0).contains(&v), "fbm out of range: {v}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `fast_floor` matches the mathematical floor on both signs.
|
||||||
|
#[test]
|
||||||
|
fn fast_floor_matches_floor() {
|
||||||
|
let cases = [(-2.0, -2), (-1.5, -2), (-0.1, -1), (0.0, 0), (0.9, 0), (3.0, 3), (3.2, 3)];
|
||||||
|
for (x, want) in cases {
|
||||||
|
assert_eq!(fast_floor(x), want, "floor({x})");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue