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 region;
|
||||
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