Skip to main content

rustial_engine/terrain/
hillshade.rs

1//! CPU-side hillshade preparation from DEM grids.
2
3use crate::tile_source::DecodedImage;
4use rustial_math::{tile_bounds_world, ElevationGrid, TileId};
5use std::sync::Arc;
6
7/// Prepared hillshade raster derived from a DEM tile.
8///
9/// The texture stores an encoded normal vector in RGBA8:
10/// - R = normal.x mapped from `[-1, 1]` to `[0, 255]`
11/// - G = normal.y mapped from `[-1, 1]` to `[0, 255]`
12/// - B = normal.z mapped from `[0, 1]` to `[0, 255]`
13/// - A = 255
14///
15/// Renderers can sample this in a dedicated hillshade pass and apply
16/// style-layer colours and opacity independently from the preparation step.
17#[derive(Debug, Clone)]
18pub struct PreparedHillshadeRaster {
19    /// Tile this prepared hillshade raster corresponds to.
20    pub tile: TileId,
21    /// Elevation-data generation used to produce the raster.
22    pub generation: u64,
23    /// Encoded hillshade normal texture.
24    pub image: DecodedImage,
25}
26
27/// Prepare a DEM-derived hillshade raster for a tile.
28pub fn prepare_hillshade_raster(
29    elevation: &ElevationGrid,
30    vertical_exaggeration: f64,
31    generation: u64,
32) -> PreparedHillshadeRaster {
33    let width = elevation.width.max(1);
34    let height = elevation.height.max(1);
35    let mut data = vec![0u8; width as usize * height as usize * 4];
36
37    let bounds = tile_bounds_world(&elevation.tile);
38    let step_x = if width > 1 {
39        (bounds.max.position.x - bounds.min.position.x) / (width - 1) as f64
40    } else {
41        1.0
42    };
43    let step_y = if height > 1 {
44        (bounds.max.position.y - bounds.min.position.y) / (height - 1) as f64
45    } else {
46        1.0
47    };
48
49    for y in 0..height {
50        for x in 0..width {
51            let idx = (y * width + x) as usize;
52            let sample = |sx: i32, sy: i32| -> f64 {
53                let xx = sx.clamp(0, width.saturating_sub(1) as i32) as u32;
54                let yy = sy.clamp(0, height.saturating_sub(1) as i32) as u32;
55                elevation.data[(yy * width + xx) as usize] as f64 * vertical_exaggeration
56            };
57
58            let left = sample(x as i32 - 1, y as i32);
59            let right = sample(x as i32 + 1, y as i32);
60            let up = sample(x as i32, y as i32 - 1);
61            let down = sample(x as i32, y as i32 + 1);
62
63            let dzdx = ((right - left) / (2.0 * step_x.max(1e-6))) as f32;
64            let dzdy = ((up - down) / (2.0 * step_y.max(1e-6))) as f32;
65
66            let nx = -dzdx;
67            let ny = -dzdy;
68            let nz = 1.0f32;
69            let len = (nx * nx + ny * ny + nz * nz).sqrt().max(1e-6);
70            let normal = [nx / len, ny / len, nz / len];
71
72            let o = idx * 4;
73            data[o] = encode_signed_unit(normal[0]);
74            data[o + 1] = encode_signed_unit(normal[1]);
75            data[o + 2] = ((normal[2].clamp(0.0, 1.0) * 255.0).round()) as u8;
76            data[o + 3] = 255;
77        }
78    }
79
80    PreparedHillshadeRaster {
81        tile: elevation.tile,
82        generation,
83        image: DecodedImage {
84            width,
85            height,
86            data: Arc::new(data),
87        },
88    }
89}
90
91#[inline]
92fn encode_signed_unit(v: f32) -> u8 {
93    ((((v.clamp(-1.0, 1.0) * 0.5) + 0.5) * 255.0).round()) as u8
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99    use rustial_math::ElevationGrid;
100
101    #[test]
102    fn flat_grid_encodes_upward_normal() {
103        let grid = ElevationGrid::flat(TileId::new(2, 1, 1), 2, 2);
104        let raster = prepare_hillshade_raster(&grid, 1.0, 7);
105        assert_eq!(raster.generation, 7);
106        assert_eq!(raster.image.width, 2);
107        assert_eq!(raster.image.height, 2);
108        for px in raster.image.data.chunks_exact(4) {
109            assert!((px[0] as i32 - 128).abs() <= 1);
110            assert!((px[1] as i32 - 128).abs() <= 1);
111            assert!(px[2] >= 254);
112            assert_eq!(px[3], 255);
113        }
114    }
115}