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:
Parley Hatch 2026-06-15 19:11:04 -06:00
parent a12bcbbcad
commit df02911a1b
7 changed files with 1799 additions and 0 deletions

View 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
}
}

View file

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

View 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");
}
}

View 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);
}
}

View 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");
}
}

View 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());
}
}

View 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})");
}
}
}