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}