Skip to main content

concinnity_core/geometry/
heightfield.rs

1// Subdivided terrain grid driven by a grayscale heightmap image.
2//
3// Sibling of terrain.rs. Same XZ grid + smooth-normal pass; the only difference
4// is the height function: instead of three octaves of LCG-hash noise, this
5// generator samples pre-decoded heightmap pixels and maps the red channel
6// through the configured elevation range. Image decoding is the caller's
7// problem (the cook crate's, in practice) -- this crate links no image decoders.
8
9use alloc::format;
10use alloc::string::String;
11use alloc::vec;
12use alloc::vec::Vec;
13
14use super::Vert;
15use crate::math::floor;
16use crate::math::vec3::{vec3_add, vec3_face_normal, vec3_normalise};
17
18/// The field a heightmap image displaces: grid extents, resolution, and the
19/// elevation range the red channel maps into.
20pub struct HeightfieldField {
21    /// Half the terrain extent along X, in world units.
22    pub half_width: f32,
23    /// Half the terrain extent along Z, in world units.
24    pub half_depth: f32,
25    /// Grid resolution per axis, clamped to 4..=255.
26    pub subdivisions: u32,
27    /// World Y a red-channel value of 0 maps to.
28    pub elevation_min: f32,
29    /// World Y a red-channel value of 255 maps to.
30    pub elevation_max: f32,
31}
32
33/// Build a heightfield grid from decoded RGBA pixels: bilinear-sample the
34/// image's red channel across the field's grid and map it through the
35/// elevation range.
36pub fn build_heightfield_from_pixels(
37    field: &HeightfieldField,
38    img_w: u32,
39    img_h: u32,
40    rgba: &[u8],
41) -> Result<(Vec<Vert>, Vec<u16>), String> {
42    let HeightfieldField {
43        half_width,
44        half_depth,
45        subdivisions,
46        elevation_min,
47        elevation_max,
48    } = *field;
49    let subdivisions = subdivisions.clamp(4, 255) as usize;
50
51    if img_w == 0 || img_h == 0 {
52        return Err("heightfield source image has zero extent".into());
53    }
54
55    let needed = (img_w as usize) * (img_h as usize) * 4;
56    if rgba.len() < needed {
57        return Err(format!(
58            "heightfield source image buffer too small: have {}, need {} for {}x{}",
59            rgba.len(),
60            needed,
61            img_w,
62            img_h
63        ));
64    }
65
66    let cols = subdivisions + 1;
67    let rows = subdivisions + 1;
68
69    if cols * rows > 65536 {
70        return Err(format!(
71            "heightfield subdivisions {} produces {} vertices, exceeding the u16 limit; use subdivisions ≤ 255",
72            subdivisions,
73            cols * rows
74        ));
75    }
76
77    let color = [0.55f32, 0.62, 0.42];
78
79    // Pre-sample the heightmap to per-vertex Y. Bilinear filter so the mesh
80    // doesn't inherit the heightmap's pixel grid when subdivisions and image
81    // resolution differ.
82    let mut positions: Vec<[f32; 3]> = Vec::with_capacity(cols * rows);
83    for row in 0..rows {
84        for col in 0..cols {
85            let s = col as f32 / subdivisions as f32;
86            let t = row as f32 / subdivisions as f32;
87            let x = -half_width + s * half_width * 2.0;
88            let z = -half_depth + t * half_depth * 2.0;
89            let y = sample_height_bilinear(rgba, img_w, img_h, s, t, elevation_min, elevation_max);
90            positions.push([x, y, z]);
91        }
92    }
93
94    let mut normals: Vec<[f32; 3]> = vec![[0.0, 0.0, 0.0]; cols * rows];
95    for row in 0..subdivisions {
96        for col in 0..subdivisions {
97            let tl = row * cols + col;
98            let tr = tl + 1;
99            let bl = tl + cols;
100            let br = bl + 1;
101            let n1 = vec3_face_normal(positions[tl], positions[bl], positions[tr]);
102            vec3_add(&mut normals[tl], n1);
103            vec3_add(&mut normals[bl], n1);
104            vec3_add(&mut normals[tr], n1);
105            let n2 = vec3_face_normal(positions[tr], positions[bl], positions[br]);
106            vec3_add(&mut normals[tr], n2);
107            vec3_add(&mut normals[bl], n2);
108            vec3_add(&mut normals[br], n2);
109        }
110    }
111
112    let mut idxs: Vec<u16> = Vec::with_capacity(subdivisions * subdivisions * 6);
113    let mut verts: Vec<Vert> = Vec::with_capacity(cols * rows);
114
115    for i in 0..cols * rows {
116        let [x, y, z] = positions[i];
117        let normal = vec3_normalise(normals[i]);
118        verts.push(([x, y, z], normal, color, [x, z]));
119    }
120
121    for row in 0..subdivisions {
122        for col in 0..subdivisions {
123            let tl = (row * cols + col) as u16;
124            let tr = tl + 1;
125            let bl = tl + cols as u16;
126            let br = bl + 1;
127            idxs.extend_from_slice(&[tl, bl, tr, tr, bl, br]);
128        }
129    }
130
131    Ok((verts, idxs))
132}
133
134// Bilinear-sample the heightmap's red channel at normalised UV (s, t) in [0,1]
135// and map [0, 255] to [elevation_min, elevation_max].
136fn sample_height_bilinear(
137    rgba: &[u8],
138    img_w: u32,
139    img_h: u32,
140    s: f32,
141    t: f32,
142    elevation_min: f32,
143    elevation_max: f32,
144) -> f32 {
145    let fx = s.clamp(0.0, 1.0) * (img_w - 1) as f32;
146    let fy = t.clamp(0.0, 1.0) * (img_h - 1) as f32;
147    let x0 = floor(fx) as u32;
148    let y0 = floor(fy) as u32;
149    let x1 = (x0 + 1).min(img_w - 1);
150    let y1 = (y0 + 1).min(img_h - 1);
151    let sx = fx - x0 as f32;
152    let sy = fy - y0 as f32;
153
154    let r = |x: u32, y: u32| -> f32 {
155        let idx = (y * img_w + x) as usize * 4;
156        rgba[idx] as f32 / 255.0
157    };
158    let top = r(x0, y0) + (r(x1, y0) - r(x0, y0)) * sx;
159    let bot = r(x0, y1) + (r(x1, y1) - r(x0, y1)) * sx;
160    let h = top + (bot - top) * sy;
161    elevation_min + h * (elevation_max - elevation_min)
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    // A `w`x`h` grayscale-RGBA buffer whose red channel ramps 0..255 across X
169    // so the generated mesh has real elevation variation to sample.
170    fn ramp_rgba(w: u32, h: u32) -> Vec<u8> {
171        let mut out = Vec::with_capacity((w * h * 4) as usize);
172        for _ in 0..h {
173            for x in 0..w {
174                let v = if w > 1 { (x * 255 / (w - 1)) as u8 } else { 0 };
175                out.extend_from_slice(&[v, v, v, 255]);
176            }
177        }
178        out
179    }
180
181    #[test]
182    fn bilinear_sample_recovers_corner_values() {
183        let rgba = ramp_rgba(4, 4);
184        let h_min = sample_height_bilinear(&rgba, 4, 4, 0.0, 0.0, -1.0, 1.0);
185        let h_max = sample_height_bilinear(&rgba, 4, 4, 1.0, 0.0, -1.0, 1.0);
186        assert!((h_min - -1.0).abs() < 1e-5, "h_min = {}", h_min);
187        assert!((h_max - 1.0).abs() < 1e-5, "h_max = {}", h_max);
188    }
189
190    fn field(half: f32, subdivisions: u32, elevation_max: f32) -> HeightfieldField {
191        HeightfieldField {
192            half_width: half,
193            half_depth: half,
194            subdivisions,
195            elevation_min: 0.0,
196            elevation_max,
197        }
198    }
199
200    #[test]
201    fn rejects_zero_extent_and_short_pixel_buffers() {
202        let err = build_heightfield_from_pixels(&field(64.0, 3, 1.0), 0, 0, &[]).unwrap_err();
203        assert!(err.contains("zero extent"), "got: {}", err);
204        // 8x8 RGBA needs 256 bytes; hand it 100.
205        let err =
206            build_heightfield_from_pixels(&field(64.0, 4, 1.0), 8, 8, &[0u8; 100]).unwrap_err();
207        assert!(err.contains("have 100, need 256"), "got: {err}");
208    }
209
210    #[test]
211    fn vertex_and_index_counts_match_grid() {
212        // subdivisions=4 -> 5x5 = 25 verts, 4*4*2 = 32 tris -> 96 indices.
213        let rgba = ramp_rgba(8, 8);
214        let (verts, idxs) =
215            build_heightfield_from_pixels(&field(5.0, 4, 10.0), 8, 8, &rgba).expect("builds");
216        assert_eq!(verts.len(), 5 * 5);
217        assert_eq!(idxs.len(), 4 * 4 * 6);
218
219        // The ramp gives real elevation variation bracketed by the range.
220        let mut min_y = f32::INFINITY;
221        let mut max_y = f32::NEG_INFINITY;
222        for v in &verts {
223            min_y = min_y.min(v.0[1]);
224            max_y = max_y.max(v.0[1]);
225        }
226        assert!(min_y >= 0.0);
227        assert!(max_y <= 10.0);
228        assert!(
229            max_y > min_y,
230            "expected variation but got flat at {}",
231            max_y
232        );
233    }
234}