reikhelm/reikhelm-core/examples/world_png.rs
Parley Hatch df02911a1b 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>
2026-06-15 19:11:04 -06:00

237 lines
8.7 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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
}
}