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>
237 lines
8.7 KiB
Rust
237 lines
8.7 KiB
Rust
//! 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
|
||
}
|
||
}
|