Skip to main content

terrain_generator/
render.rs

1//! Rendering of generated terrain to RGB buffers and PNG files.
2//!
3//! [`to_rgb`] has no dependencies and is always available; [`save_png`]
4//! (and [`RenderError`]) require the `png` cargo feature.
5
6use crate::Terrain;
7
8/// A single output layer of a [`Terrain`], for writing one file per layer.
9///
10/// [`Shaded`](Self::Shaded) is a picture; the rest are *data* layers meant
11/// to be read back by other tools, so they are written losslessly:
12/// elevation and flow as 16-bit greyscale (8 bits would quantise elevation
13/// into visible terraces), masks as 8-bit black/white.
14#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15pub enum Layer {
16    /// The shaded relief render — biome colours, hillshading, water.
17    Shaded,
18    /// Elevation, 16-bit greyscale, `0` = 0.0 and `65535` = 1.0.
19    Height,
20    /// Normalised flow accumulation, 16-bit greyscale.
21    Flow,
22    /// River mask, 8-bit: `255` where a river runs.
23    Rivers,
24    /// Lake mask, 8-bit: `255` where a lake stands.
25    Lakes,
26    /// Ocean mask, 8-bit: `255` where the cell is sea.
27    Ocean,
28}
29
30impl Layer {
31    /// Every layer, in a stable order.
32    pub const ALL: [Layer; 6] = [
33        Layer::Shaded,
34        Layer::Height,
35        Layer::Flow,
36        Layer::Rivers,
37        Layer::Lakes,
38        Layer::Ocean,
39    ];
40
41    /// The lowercase name used by [`FromStr`](std::str::FromStr) and
42    /// suitable as a filename suffix.
43    pub fn name(self) -> &'static str {
44        match self {
45            Layer::Shaded => "shaded",
46            Layer::Height => "height",
47            Layer::Flow => "flow",
48            Layer::Rivers => "rivers",
49            Layer::Lakes => "lakes",
50            Layer::Ocean => "ocean",
51        }
52    }
53}
54
55impl std::fmt::Display for Layer {
56    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57        f.write_str(self.name())
58    }
59}
60
61/// Returned when a string does not name a [`Layer`].
62#[derive(Clone, Debug, PartialEq, Eq)]
63pub struct UnknownLayer(pub String);
64
65impl std::fmt::Display for UnknownLayer {
66    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67        write!(
68            f,
69            "unknown layer '{}' (expected one of: shaded, height, flow, rivers, lakes, ocean)",
70            self.0
71        )
72    }
73}
74
75impl std::error::Error for UnknownLayer {}
76
77impl std::str::FromStr for Layer {
78    type Err = UnknownLayer;
79
80    fn from_str(s: &str) -> Result<Self, Self::Err> {
81        Layer::ALL
82            .into_iter()
83            .find(|l| l.name() == s)
84            .ok_or_else(|| UnknownLayer(s.to_string()))
85    }
86}
87
88/// How a [`Terrain`] is turned into an image.
89#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
90pub enum RenderStyle {
91    /// Biome-like colours (ocean, beach, grass, forest, rock, snow) with
92    /// hillshading, rivers and lakes drawn in blue.
93    #[default]
94    Shaded,
95    /// Plain greyscale heightmap (black = lowest, white = highest).
96    Greyscale,
97}
98
99type Rgb = [f32; 3];
100
101fn lerp(a: Rgb, b: Rgb, t: f32) -> Rgb {
102    [
103        a[0] + (b[0] - a[0]) * t,
104        a[1] + (b[1] - a[1]) * t,
105        a[2] + (b[2] - a[2]) * t,
106    ]
107}
108
109/// Piecewise-linear colour ramp over `(position, colour)` stops.
110fn ramp(stops: &[(f32, Rgb)], t: f32) -> Rgb {
111    let t = t.clamp(0.0, 1.0);
112    for pair in stops.windows(2) {
113        let (t0, c0) = pair[0];
114        let (t1, c1) = pair[1];
115        if t <= t1 {
116            let f = if t1 > t0 { (t - t0) / (t1 - t0) } else { 0.0 };
117            return lerp(c0, c1, f.clamp(0.0, 1.0));
118        }
119    }
120    stops.last().unwrap().1
121}
122
123const WATER_RAMP: &[(f32, Rgb)] = &[
124    (0.0, [0.05, 0.15, 0.30]), // deep ocean
125    (0.7, [0.15, 0.35, 0.55]),
126    (1.0, [0.30, 0.55, 0.75]), // shallows
127];
128
129const LAND_RAMP: &[(f32, Rgb)] = &[
130    (0.00, [0.80, 0.73, 0.55]), // beach sand
131    (0.04, [0.55, 0.65, 0.35]), // lowland grass
132    (0.30, [0.25, 0.45, 0.22]), // forest
133    (0.55, [0.45, 0.42, 0.36]), // bare rock
134    (0.75, [0.60, 0.58, 0.55]), // high rock
135    (0.90, [0.93, 0.93, 0.95]), // snow line
136    (1.00, [1.00, 1.00, 1.00]), // peaks
137];
138
139const RIVER_COLOR: Rgb = [0.20, 0.45, 0.70];
140const LAKE_COLOR: Rgb = [0.17, 0.40, 0.62];
141
142/// Renders `terrain` to a row-major RGB byte buffer (`width * height * 3`).
143pub fn to_rgb(terrain: &Terrain, style: RenderStyle) -> Vec<u8> {
144    let w = terrain.width();
145    let h = terrain.height();
146    let sea = terrain.sea_level;
147    let mut data = Vec::with_capacity(w * h * 3);
148
149    for y in 0..h {
150        for x in 0..w {
151            let v = terrain.heightmap[(x, y)];
152
153            let rgb = match style {
154                RenderStyle::Greyscale => {
155                    let g = v.clamp(0.0, 1.0);
156                    [g, g, g]
157                }
158                RenderStyle::Shaded => {
159                    if terrain.rivers[(x, y)] == 1 {
160                        // slightly darker in steep terrain so rivers read as
161                        // carved into the landscape
162                        let shade = 0.8 + 0.2 * hillshade(terrain, x, y);
163                        [
164                            RIVER_COLOR[0] * shade,
165                            RIVER_COLOR[1] * shade,
166                            RIVER_COLOR[2] * shade,
167                        ]
168                    } else if terrain.lakes[(x, y)] == 1 {
169                        LAKE_COLOR
170                    } else if terrain.ocean[(x, y)] == 1 {
171                        ramp(WATER_RAMP, if sea > 0.0 { v / sea } else { 0.0 })
172                    } else {
173                        let t = (v - sea) / (1.0 - sea);
174                        let base = ramp(LAND_RAMP, t);
175                        let shade = 0.55 + 0.45 * hillshade(terrain, x, y);
176                        [base[0] * shade, base[1] * shade, base[2] * shade]
177                    }
178                }
179            };
180
181            data.push((rgb[0].clamp(0.0, 1.0) * 255.0 + 0.5) as u8);
182            data.push((rgb[1].clamp(0.0, 1.0) * 255.0 + 0.5) as u8);
183            data.push((rgb[2].clamp(0.0, 1.0) * 255.0 + 0.5) as u8);
184        }
185    }
186
187    data
188}
189
190/// Lambertian hillshade in `[0, 1]` from central differences, with a
191/// north-western light source.
192fn hillshade(terrain: &Terrain, x: usize, y: usize) -> f32 {
193    let w = terrain.width();
194    let h = terrain.height();
195    let hm = &terrain.heightmap;
196
197    let xl = x.saturating_sub(1);
198    let xr = (x + 1).min(w - 1);
199    let yu = y.saturating_sub(1);
200    let yd = (y + 1).min(h - 1);
201
202    // vertical exaggeration keeps the relief legible at any resolution
203    let z_scale = 0.15 * w.max(h) as f32;
204
205    let dzdx = (hm[(xr, y)] - hm[(xl, y)]) / (xr - xl).max(1) as f32 * z_scale;
206    let dzdy = (hm[(x, yd)] - hm[(x, yu)]) / (yd - yu).max(1) as f32 * z_scale;
207
208    // light from the north-west, 45 degrees above the horizon
209    let light = [-0.5f32, -0.5, std::f32::consts::FRAC_1_SQRT_2];
210
211    let norm = 1.0 / (dzdx * dzdx + dzdy * dzdy + 1.0).sqrt();
212    let dot = (-dzdx * light[0] - dzdy * light[1] + light[2]) * norm;
213
214    dot.clamp(0.0, 1.0)
215}
216
217#[cfg(feature = "png")]
218mod png_output {
219    use std::fs::File;
220    use std::io::BufWriter;
221    use std::path::Path;
222
223    use super::{to_rgb, RenderStyle};
224    use crate::Terrain;
225
226    /// Errors that can occur while writing an image.
227    #[derive(Debug)]
228    pub enum RenderError {
229        Io(std::io::Error),
230        Png(png::EncodingError),
231    }
232
233    impl std::fmt::Display for RenderError {
234        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
235            match self {
236                RenderError::Io(e) => write!(f, "io error: {e}"),
237                RenderError::Png(e) => write!(f, "png encoding error: {e}"),
238            }
239        }
240    }
241
242    impl std::error::Error for RenderError {}
243
244    impl From<std::io::Error> for RenderError {
245        fn from(e: std::io::Error) -> Self {
246            RenderError::Io(e)
247        }
248    }
249
250    impl From<png::EncodingError> for RenderError {
251        fn from(e: png::EncodingError) -> Self {
252            RenderError::Png(e)
253        }
254    }
255
256    /// Writes one [`Layer`](super::Layer) of `terrain` as a PNG file.
257    ///
258    /// Data layers are written losslessly — see [`Layer`](super::Layer).
259    pub fn save_layer_png<P: AsRef<Path>>(
260        terrain: &Terrain,
261        layer: super::Layer,
262        path: P,
263    ) -> Result<(), RenderError> {
264        use super::Layer;
265
266        let (w, h) = (terrain.width() as u32, terrain.height() as u32);
267
268        match layer {
269            Layer::Shaded => save_png(terrain, path, RenderStyle::Shaded),
270            Layer::Height => write_gray16(&terrain.heightmap, w, h, path),
271            Layer::Flow => write_gray16(&terrain.flow, w, h, path),
272            Layer::Rivers => write_mask(&terrain.rivers, w, h, path),
273            Layer::Lakes => write_mask(&terrain.lakes, w, h, path),
274            Layer::Ocean => write_mask(&terrain.ocean, w, h, path),
275        }
276    }
277
278    /// 16-bit greyscale from a `[0, 1]` grid, big-endian as PNG requires.
279    fn write_gray16<P: AsRef<Path>>(
280        grid: &crate::Grid<f32>,
281        w: u32,
282        h: u32,
283        path: P,
284    ) -> Result<(), RenderError> {
285        let mut data = Vec::with_capacity(grid.len() * 2);
286        for v in grid.iter() {
287            let q = (v.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16;
288            data.extend_from_slice(&q.to_be_bytes());
289        }
290        write_png(
291            path,
292            w,
293            h,
294            png::ColorType::Grayscale,
295            png::BitDepth::Sixteen,
296            &data,
297        )
298    }
299
300    /// 8-bit black/white from a 0/1 mask grid.
301    fn write_mask<P: AsRef<Path>>(
302        grid: &crate::Grid<u8>,
303        w: u32,
304        h: u32,
305        path: P,
306    ) -> Result<(), RenderError> {
307        let data: Vec<u8> = grid.iter().map(|v| if *v == 0 { 0 } else { 255 }).collect();
308        write_png(
309            path,
310            w,
311            h,
312            png::ColorType::Grayscale,
313            png::BitDepth::Eight,
314            &data,
315        )
316    }
317
318    fn write_png<P: AsRef<Path>>(
319        path: P,
320        w: u32,
321        h: u32,
322        color: png::ColorType,
323        depth: png::BitDepth,
324        data: &[u8],
325    ) -> Result<(), RenderError> {
326        let file = File::create(path)?;
327        let mut encoder = png::Encoder::new(BufWriter::new(file), w, h);
328        encoder.set_color(color);
329        encoder.set_depth(depth);
330        encoder.write_header()?.write_image_data(data)?;
331        Ok(())
332    }
333
334    /// Renders `terrain` and writes it as a PNG file.
335    pub fn save_png<P: AsRef<Path>>(
336        terrain: &Terrain,
337        path: P,
338        style: RenderStyle,
339    ) -> Result<(), RenderError> {
340        let data = to_rgb(terrain, style);
341
342        let file = File::create(path)?;
343        let writer = BufWriter::new(file);
344
345        let mut encoder =
346            png::Encoder::new(writer, terrain.width() as u32, terrain.height() as u32);
347        encoder.set_color(png::ColorType::Rgb);
348        encoder.set_depth(png::BitDepth::Eight);
349
350        let mut png_writer = encoder.write_header()?;
351        png_writer.write_image_data(&data)?;
352
353        Ok(())
354    }
355}
356
357#[cfg(feature = "png")]
358pub use png_output::{save_layer_png, save_png, RenderError};
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363    use crate::Terrain;
364
365    fn small_terrain() -> Terrain {
366        Terrain::builder().size(32, 32).seed(1).generate()
367    }
368
369    #[test]
370    fn rgb_buffer_has_expected_size() {
371        let t = small_terrain();
372        assert_eq!(to_rgb(&t, RenderStyle::Shaded).len(), 32 * 32 * 3);
373        assert_eq!(to_rgb(&t, RenderStyle::Greyscale).len(), 32 * 32 * 3);
374    }
375
376    #[test]
377    fn rendering_is_deterministic() {
378        let a = to_rgb(&small_terrain(), RenderStyle::Shaded);
379        let b = to_rgb(&small_terrain(), RenderStyle::Shaded);
380        assert_eq!(a, b);
381    }
382
383    #[test]
384    fn greyscale_encodes_elevation() {
385        let t = small_terrain();
386        let rgb = to_rgb(&t, RenderStyle::Greyscale);
387        let expected = (t.heightmap[(0, 0)].clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
388        assert_eq!(rgb[0], expected);
389        assert_eq!(rgb[1], expected);
390        assert_eq!(rgb[2], expected);
391    }
392}