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