Skip to main content

phyz_model/
heightfield.rs

1//! Terrain as a regular grid of heights.
2//!
3//! A [`Heightfield`] is the ground the contact solvers test against when a
4//! flat plane is not enough: bumps, cracks, ramps and bowls, sampled
5//! bilinearly between grid nodes. A flat plane is the degenerate 1×1 field,
6//! so callers that used to pass `ground_height` can pass
7//! [`Heightfield::flat`] and get bit-for-bit the same surface.
8
9use phyz_math::Vec3;
10
11/// A regular grid of terrain heights, sampled bilinearly.
12///
13/// Node `(ix, iy)` sits at world `(origin.x + ix·cell, origin.y + iy·cell)`
14/// with surface height `origin.z + heights[iy·nx + ix]`. Between nodes the
15/// surface is the bilinear patch through the four surrounding nodes; outside
16/// the grid the query is clamped to the border, so the terrain extends
17/// flatly (per-edge) to infinity rather than falling away to zero. That
18/// clamp is also what makes a 1×1 field an exact infinite plane.
19///
20/// Heights are stored as `f32` deliberately: the GPU contact shader consumes
21/// this buffer verbatim, and keeping one representation means the CPU and
22/// GPU paths sample *identical* terrain instead of terrain that agrees to
23/// rounding. Construction is a couple of allocations, cheap enough to draw a
24/// fresh field per training iteration.
25#[derive(Debug, Clone, PartialEq)]
26pub struct Heightfield {
27    /// World position of node `(0, 0)`; `origin.z` is added to every height.
28    pub origin: Vec3,
29    /// Grid spacing in metres, same in x and y. Must be positive.
30    pub cell: f64,
31    /// Number of nodes along x. At least 1.
32    pub nx: usize,
33    /// Number of nodes along y. At least 1.
34    pub ny: usize,
35    /// Row-major heights, `ny` rows of `nx`: `heights[iy * nx + ix]`.
36    pub heights: Vec<f32>,
37}
38
39impl Heightfield {
40    /// A heightfield of `nx × ny` nodes spaced `cell` apart, all at height
41    /// zero above `origin`.
42    ///
43    /// # Panics
44    /// If `nx` or `ny` is zero or `cell` is not a positive finite number.
45    pub fn new(origin: Vec3, cell: f64, nx: usize, ny: usize) -> Self {
46        assert!(nx >= 1 && ny >= 1, "heightfield needs at least one node");
47        assert!(
48            cell.is_finite() && cell > 0.0,
49            "heightfield cell must be positive, got {cell}"
50        );
51        Self {
52            origin,
53            cell,
54            nx,
55            ny,
56            heights: vec![0.0; nx * ny],
57        }
58    }
59
60    /// The infinite flat plane at `z = height`: a single node whose border
61    /// clamp covers the whole world.
62    pub fn flat(height: f64) -> Self {
63        Self::new(Vec3::new(0.0, 0.0, height), 1.0, 1, 1)
64    }
65
66    /// The grid index and intra-cell fraction for one world coordinate,
67    /// clamped to the grid. `n` is the node count along that axis.
68    fn locate(&self, w: f64, o: f64, n: usize) -> (usize, f64) {
69        if n < 2 {
70            return (0, 0.0);
71        }
72        let u = ((w - o) / self.cell).clamp(0.0, (n - 1) as f64);
73        // A query exactly on the far border indexes the last cell at t = 1
74        // rather than one past it.
75        let i = (u as usize).min(n - 2);
76        (i, u - i as f64)
77    }
78
79    /// Height of node `(ix, iy)` including the origin offset.
80    fn node(&self, ix: usize, iy: usize) -> f64 {
81        self.origin.z + self.heights[iy * self.nx + ix] as f64
82    }
83
84    /// Surface height at world `(x, y)`, bilinear between nodes, clamped to
85    /// the border outside the grid.
86    pub fn height(&self, x: f64, y: f64) -> f64 {
87        let (ix, tx) = self.locate(x, self.origin.x, self.nx);
88        let (iy, ty) = self.locate(y, self.origin.y, self.ny);
89        if self.nx < 2 && self.ny < 2 {
90            return self.node(0, 0);
91        }
92        let (ix1, iy1) = ((ix + 1).min(self.nx - 1), (iy + 1).min(self.ny - 1));
93        let h00 = self.node(ix, iy);
94        let h10 = self.node(ix1, iy);
95        let h01 = self.node(ix, iy1);
96        let h11 = self.node(ix1, iy1);
97        (h00 * (1.0 - tx) + h10 * tx) * (1.0 - ty) + (h01 * (1.0 - tx) + h11 * tx) * ty
98    }
99
100    /// Unit surface normal at world `(x, y)`.
101    ///
102    /// The analytic normal of the bilinear patch: `(-∂h/∂x, -∂h/∂y, 1)`
103    /// normalized. Outside the grid the clamp makes the surface flat, so the
104    /// normal degrades to `+ẑ` there, and a 1×1 field reports `+ẑ`
105    /// everywhere — exactly the flat-plane contact normal.
106    pub fn normal(&self, x: f64, y: f64) -> Vec3 {
107        let (ix, tx) = self.locate(x, self.origin.x, self.nx);
108        let (iy, ty) = self.locate(y, self.origin.y, self.ny);
109        // Beyond the border the clamped surface is flat, so the slope there
110        // is zero — not the border cell's slope, which would report a normal
111        // the actual (clamped) surface no longer has.
112        let inside = |w: f64, o: f64, n: usize| w >= o && w <= o + (n - 1) as f64 * self.cell;
113        let (mut dhdx, mut dhdy) = (0.0, 0.0);
114        if self.nx >= 2 && inside(x, self.origin.x, self.nx) {
115            let iy1 = (iy + 1).min(self.ny - 1);
116            let d0 = self.node(ix + 1, iy) - self.node(ix, iy);
117            let d1 = self.node(ix + 1, iy1) - self.node(ix, iy1);
118            dhdx = (d0 * (1.0 - ty) + d1 * ty) / self.cell;
119        }
120        if self.ny >= 2 && inside(y, self.origin.y, self.ny) {
121            let ix1 = (ix + 1).min(self.nx - 1);
122            let d0 = self.node(ix, iy + 1) - self.node(ix, iy);
123            let d1 = self.node(ix1, iy + 1) - self.node(ix1, iy);
124            dhdy = (d0 * (1.0 - tx) + d1 * tx) / self.cell;
125        }
126        Vec3::new(-dhdx, -dhdy, 1.0).normalize()
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    #[test]
135    fn flat_field_is_the_plane_everywhere() {
136        let hf = Heightfield::flat(0.25);
137        for &(x, y) in &[(0.0, 0.0), (-1e3, 42.0), (7.5, -7.5)] {
138            assert_eq!(hf.height(x, y), 0.25);
139            assert_eq!(hf.normal(x, y), Vec3::z());
140        }
141    }
142
143    #[test]
144    fn bilinear_interpolates_between_nodes() {
145        let mut hf = Heightfield::new(Vec3::zeros(), 1.0, 2, 2);
146        hf.heights = vec![0.0, 1.0, 2.0, 3.0]; // h(x,y) = x + 2y on [0,1]²
147        assert!((hf.height(0.5, 0.5) - 1.5).abs() < 1e-12);
148        assert!((hf.height(0.25, 0.75) - 1.75).abs() < 1e-12);
149        // Clamped beyond the border: flat continuation of the edge value.
150        assert!((hf.height(5.0, 0.0) - 1.0).abs() < 1e-12);
151        assert!((hf.height(-5.0, -5.0) - 0.0).abs() < 1e-12);
152    }
153
154    #[test]
155    fn normal_matches_the_slope() {
156        // Uniform 45° ramp along x: h = x.
157        let mut hf = Heightfield::new(Vec3::zeros(), 1.0, 3, 2);
158        hf.heights = vec![0.0, 1.0, 2.0, 0.0, 1.0, 2.0];
159        let n = hf.normal(1.0, 0.5);
160        let expect = Vec3::new(-1.0, 0.0, 1.0).normalize();
161        assert!((n - expect).norm() < 1e-12, "normal {n:?}");
162        // Outside the grid the clamp flattens the surface.
163        assert_eq!(hf.normal(100.0, 0.0), Vec3::z());
164    }
165
166    #[test]
167    fn origin_offsets_the_whole_surface() {
168        let hf = Heightfield::new(Vec3::new(10.0, -3.0, 2.0), 0.5, 1, 1);
169        assert_eq!(hf.height(0.0, 0.0), 2.0);
170    }
171}