rustial-engine 0.0.1

Framework-agnostic 2.5D map engine for rustial
Documentation
//! CPU-side hillshade preparation from DEM grids.

use crate::tile_source::DecodedImage;
use rustial_math::{tile_bounds_world, ElevationGrid, TileId};
use std::sync::Arc;

/// Prepared hillshade raster derived from a DEM tile.
///
/// The texture stores an encoded normal vector in RGBA8:
/// - R = normal.x mapped from `[-1, 1]` to `[0, 255]`
/// - G = normal.y mapped from `[-1, 1]` to `[0, 255]`
/// - B = normal.z mapped from `[0, 1]` to `[0, 255]`
/// - A = 255
///
/// Renderers can sample this in a dedicated hillshade pass and apply
/// style-layer colours and opacity independently from the preparation step.
#[derive(Debug, Clone)]
pub struct PreparedHillshadeRaster {
    /// Tile this prepared hillshade raster corresponds to.
    pub tile: TileId,
    /// Elevation-data generation used to produce the raster.
    pub generation: u64,
    /// Encoded hillshade normal texture.
    pub image: DecodedImage,
}

/// Prepare a DEM-derived hillshade raster for a tile.
pub fn prepare_hillshade_raster(
    elevation: &ElevationGrid,
    vertical_exaggeration: f64,
    generation: u64,
) -> PreparedHillshadeRaster {
    let width = elevation.width.max(1);
    let height = elevation.height.max(1);
    let mut data = vec![0u8; width as usize * height as usize * 4];

    let bounds = tile_bounds_world(&elevation.tile);
    let step_x = if width > 1 {
        (bounds.max.position.x - bounds.min.position.x) / (width - 1) as f64
    } else {
        1.0
    };
    let step_y = if height > 1 {
        (bounds.max.position.y - bounds.min.position.y) / (height - 1) as f64
    } else {
        1.0
    };

    for y in 0..height {
        for x in 0..width {
            let idx = (y * width + x) as usize;
            let sample = |sx: i32, sy: i32| -> f64 {
                let xx = sx.clamp(0, width.saturating_sub(1) as i32) as u32;
                let yy = sy.clamp(0, height.saturating_sub(1) as i32) as u32;
                elevation.data[(yy * width + xx) as usize] as f64 * vertical_exaggeration
            };

            let left = sample(x as i32 - 1, y as i32);
            let right = sample(x as i32 + 1, y as i32);
            let up = sample(x as i32, y as i32 - 1);
            let down = sample(x as i32, y as i32 + 1);

            let dzdx = ((right - left) / (2.0 * step_x.max(1e-6))) as f32;
            let dzdy = ((up - down) / (2.0 * step_y.max(1e-6))) as f32;

            let nx = -dzdx;
            let ny = -dzdy;
            let nz = 1.0f32;
            let len = (nx * nx + ny * ny + nz * nz).sqrt().max(1e-6);
            let normal = [nx / len, ny / len, nz / len];

            let o = idx * 4;
            data[o] = encode_signed_unit(normal[0]);
            data[o + 1] = encode_signed_unit(normal[1]);
            data[o + 2] = ((normal[2].clamp(0.0, 1.0) * 255.0).round()) as u8;
            data[o + 3] = 255;
        }
    }

    PreparedHillshadeRaster {
        tile: elevation.tile,
        generation,
        image: DecodedImage {
            width,
            height,
            data: Arc::new(data),
        },
    }
}

#[inline]
fn encode_signed_unit(v: f32) -> u8 {
    ((((v.clamp(-1.0, 1.0) * 0.5) + 0.5) * 255.0).round()) as u8
}

#[cfg(test)]
mod tests {
    use super::*;
    use rustial_math::ElevationGrid;

    #[test]
    fn flat_grid_encodes_upward_normal() {
        let grid = ElevationGrid::flat(TileId::new(2, 1, 1), 2, 2);
        let raster = prepare_hillshade_raster(&grid, 1.0, 7);
        assert_eq!(raster.generation, 7);
        assert_eq!(raster.image.width, 2);
        assert_eq!(raster.image.height, 2);
        for px in raster.image.data.chunks_exact(4) {
            assert!((px[0] as i32 - 128).abs() <= 1);
            assert!((px[1] as i32 - 128).abs() <= 1);
            assert!(px[2] >= 254);
            assert_eq!(px[3], 255);
        }
    }
}