Skip to main content

alice_physics/
heightfield.rs

1//! Height Field Collision
2//!
3//! Grid-based terrain collider with bilinear interpolation.
4//! Efficient for large flat terrain with vertical displacement.
5
6use crate::collider::Contact;
7use crate::math::{Fix128, Vec3Fix};
8
9#[cfg(not(feature = "std"))]
10use alloc::vec;
11#[cfg(not(feature = "std"))]
12use alloc::vec::Vec;
13
14/// Height field terrain collider
15///
16/// Heights stored as a 2D grid in row-major order (Z-major):
17/// `index = x + z * width`
18#[derive(Clone, Debug, PartialEq, Eq)]
19pub struct HeightField {
20    /// Height values (Fix128)
21    pub heights: Vec<Fix128>,
22    /// Grid width (number of columns, X axis)
23    pub width: u32,
24    /// Grid depth (number of rows, Z axis)
25    pub depth: u32,
26    /// World-space spacing between grid points
27    pub spacing: Fix128,
28    /// World-space origin (min corner)
29    pub origin: Vec3Fix,
30}
31
32impl HeightField {
33    /// Create a height field from a grid of heights
34    #[must_use]
35    pub fn new(
36        heights: Vec<Fix128>,
37        width: u32,
38        depth: u32,
39        spacing: Fix128,
40        origin: Vec3Fix,
41    ) -> Self {
42        debug_assert_eq!(heights.len(), (width * depth) as usize);
43        Self {
44            heights,
45            width,
46            depth,
47            spacing,
48            origin,
49        }
50    }
51
52    /// Create a flat height field at a given Y level
53    #[must_use]
54    pub fn flat(width: u32, depth: u32, spacing: Fix128, origin: Vec3Fix, height: Fix128) -> Self {
55        let heights = vec![height; (width * depth) as usize];
56        Self::new(heights, width, depth, spacing, origin)
57    }
58
59    /// Get height at grid coordinates (clamped to bounds)
60    #[inline]
61    #[must_use]
62    pub fn get_height(&self, gx: u32, gz: u32) -> Fix128 {
63        if self.width == 0 || self.depth == 0 {
64            // No grid points at all: nothing to clamp to.
65            return Fix128::ZERO;
66        }
67        let gx = gx.min(self.width - 1);
68        let gz = gz.min(self.depth - 1);
69        self.heights[(gx + gz * self.width) as usize]
70    }
71
72    /// Set height at grid coordinates
73    #[inline]
74    pub fn set_height(&mut self, gx: u32, gz: u32, height: Fix128) {
75        if gx < self.width && gz < self.depth {
76            self.heights[(gx + gz * self.width) as usize] = height;
77        }
78    }
79
80    /// World-space to grid coordinates (fractional)
81    fn world_to_grid(&self, world_pos: Vec3Fix) -> (Fix128, Fix128) {
82        let local_x = world_pos.x - self.origin.x;
83        let local_z = world_pos.z - self.origin.z;
84
85        if self.spacing.is_zero() {
86            return (Fix128::ZERO, Fix128::ZERO);
87        }
88
89        (local_x / self.spacing, local_z / self.spacing)
90    }
91
92    /// Sample height at world-space XZ with bilinear interpolation
93    #[must_use]
94    pub fn sample_height(&self, world_x: Fix128, world_z: Fix128) -> Fix128 {
95        let local_x = world_x - self.origin.x;
96        let local_z = world_z - self.origin.z;
97
98        if self.spacing.is_zero() {
99            return Fix128::ZERO;
100        }
101
102        let gx_f = local_x / self.spacing;
103        let gz_f = local_z / self.spacing;
104
105        // Integer grid coords (clamped to valid range for u32 conversion)
106        let max_gx = (self.width as i64).saturating_sub(2).max(0);
107        let max_gz = (self.depth as i64).saturating_sub(2).max(0);
108        let gx0 = if gx_f.is_negative() {
109            0u32
110        } else {
111            (gx_f.hi.min(max_gx).max(0)) as u32
112        };
113        let gz0 = if gz_f.is_negative() {
114            0u32
115        } else {
116            (gz_f.hi.min(max_gz).max(0)) as u32
117        };
118
119        let gx1 = gx0 + 1;
120        let gz1 = gz0 + 1;
121
122        // Fractional part for interpolation
123        let fx = gx_f - Fix128::from_int(gx0 as i64);
124        let fz = gz_f - Fix128::from_int(gz0 as i64);
125
126        // Clamp fractions to [0, 1]
127        let fx = clamp01(fx);
128        let fz = clamp01(fz);
129
130        // Sample 4 corners
131        let h00 = self.get_height(gx0, gz0);
132        let h10 = self.get_height(gx1, gz0);
133        let h01 = self.get_height(gx0, gz1);
134        let h11 = self.get_height(gx1, gz1);
135
136        // Bilinear interpolation
137        let one = Fix128::ONE;
138        let h0 = h00 * (one - fx) + h10 * fx;
139        let h1 = h01 * (one - fx) + h11 * fx;
140        h0 * (one - fz) + h1 * fz
141    }
142
143    /// Compute surface normal at world-space XZ via central difference
144    #[must_use]
145    pub fn sample_normal(&self, world_x: Fix128, world_z: Fix128) -> Vec3Fix {
146        let eps = self.spacing.half();
147        // The two samples on each axis are kept on the grid: past an edge the
148        // height is clamped flat, and a central difference reaching over it
149        // halved the slope at the border points.
150        let span = |lo: Fix128, cells: u32, at: Fix128| {
151            let hi = lo + self.spacing * Fix128::from_int(i64::from(cells.saturating_sub(1)));
152            let a = if at - eps < lo { lo } else { at - eps };
153            let b = if at + eps > hi { hi } else { at + eps };
154            (a, b)
155        };
156        let (x0, x1) = span(self.origin.x, self.width, world_x);
157        let (z0, z1) = span(self.origin.z, self.depth, world_z);
158        let slope = |h0: Fix128, h1: Fix128, a: Fix128, b: Fix128| {
159            if b > a {
160                (h1 - h0) / (b - a)
161            } else {
162                Fix128::ZERO
163            }
164        };
165        let sx = slope(
166            self.sample_height(x0, world_z),
167            self.sample_height(x1, world_z),
168            x0,
169            x1,
170        );
171        let sz = slope(
172            self.sample_height(world_x, z0),
173            self.sample_height(world_x, z1),
174            z0,
175            z1,
176        );
177
178        // Normal = (-dh/dx, 1, -dh/dz) normalized
179        Vec3Fix::new(-sx, Fix128::ONE, -sz).normalize()
180    }
181
182    /// Sphere vs `HeightField` collision
183    ///
184    /// The sphere is clear of the surface when its centre is at least `radius`
185    /// away **along the surface normal** — the distance to the tangent plane, which
186    /// is `Δy · n_y` for a centre `Δy` above the surface point below it. (Measuring
187    /// `Δy` itself, as this used to, buries a sphere on a slope by `r·(1/cosθ − 1)`.)
188    /// A field with no grid points, or a non-positive spacing, has no surface.
189    #[must_use]
190    pub fn collide_sphere(&self, center: Vec3Fix, radius: Fix128) -> Option<Contact> {
191        if self.width == 0 || self.depth == 0 || self.spacing <= Fix128::ZERO {
192            return None;
193        }
194        let (gx_f, gz_f) = self.world_to_grid(center);
195
196        // Check if within bounds (with margin)
197        // the same 2 cells past the first and the last grid point (index
198        // `width - 1`, not `width`)
199        let margin = Fix128::from_int(2);
200        let last = |n: u32| Fix128::from_int(i64::from(n) - 1);
201        if gx_f < -margin || gx_f > last(self.width) + margin {
202            return None;
203        }
204        if gz_f < -margin || gz_f > last(self.depth) + margin {
205            return None;
206        }
207
208        let ground_height = self.sample_height(center.x, center.z);
209        let normal = self.sample_normal(center.x, center.z);
210        // Distance from the centre to the tangent plane at the point below it.
211        let dist = (center.y - ground_height) * normal.y;
212
213        if dist < radius {
214            let depth = radius - dist;
215            let point_on_surface = Vec3Fix::new(center.x, ground_height, center.z);
216
217            Some(Contact {
218                depth,
219                normal,
220                point_a: center - normal * radius,
221                point_b: point_on_surface,
222            })
223        } else {
224            None
225        }
226    }
227
228    /// Point vs `HeightField`: get signed distance (positive = above)
229    #[inline]
230    #[must_use]
231    pub fn signed_distance(&self, point: Vec3Fix) -> Fix128 {
232        let ground_height = self.sample_height(point.x, point.z);
233        point.y - ground_height
234    }
235
236    /// Get world-space AABB of the height field
237    #[must_use]
238    pub fn aabb(&self) -> crate::collider::AABB {
239        if self.heights.is_empty() || self.spacing <= Fix128::ZERO {
240            // No grid points, or a spacing that is not positive (collide_sphere
241            // treats both as having no surface): a degenerate point AABB at the
242            // origin. A negative spacing would otherwise give max < min.
243            return crate::collider::AABB::new(self.origin, self.origin);
244        }
245        let mut min_h = self.heights[0];
246        let mut max_h = self.heights[0];
247        for &h in &self.heights[1..] {
248            if h < min_h {
249                min_h = h;
250            }
251            if h > max_h {
252                max_h = h;
253            }
254        }
255
256        let max_x = self.origin.x + self.spacing * Fix128::from_int((self.width - 1) as i64);
257        let max_z = self.origin.z + self.spacing * Fix128::from_int((self.depth - 1) as i64);
258
259        crate::collider::AABB::new(
260            Vec3Fix::new(self.origin.x, min_h, self.origin.z),
261            Vec3Fix::new(max_x, max_h, max_z),
262        )
263    }
264}
265
266#[inline]
267fn clamp01(v: Fix128) -> Fix128 {
268    if v < Fix128::ZERO {
269        Fix128::ZERO
270    } else if v > Fix128::ONE {
271        Fix128::ONE
272    } else {
273        v
274    }
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280
281    #[test]
282    fn test_flat_heightfield() {
283        let hf = HeightField::flat(10, 10, Fix128::ONE, Vec3Fix::ZERO, Fix128::ZERO);
284        let h = hf.sample_height(Fix128::from_int(5), Fix128::from_int(5));
285        assert!(
286            h.abs() < Fix128::from_ratio(1, 10),
287            "Flat field should have height ~0"
288        );
289    }
290
291    #[test]
292    fn test_heightfield_interpolation() {
293        // Create a simple slope: heights increase with X
294        let mut heights = Vec::new();
295        for z in 0..4u32 {
296            for x in 0..4u32 {
297                let _ = z;
298                heights.push(Fix128::from_int(x as i64));
299            }
300        }
301        let hf = HeightField::new(heights, 4, 4, Fix128::ONE, Vec3Fix::ZERO);
302
303        // At x=0, height should be 0
304        let h0 = hf.sample_height(Fix128::ZERO, Fix128::ONE);
305        assert!(h0.abs() < Fix128::ONE, "Height at x=0 should be ~0");
306
307        // At x=2, height should be ~2
308        let h2 = hf.sample_height(Fix128::from_int(2), Fix128::ONE);
309        let error = (h2 - Fix128::from_int(2)).abs();
310        assert!(error < Fix128::ONE, "Height at x=2 should be ~2");
311    }
312
313    #[test]
314    fn test_sphere_heightfield_collision() {
315        let hf = HeightField::flat(10, 10, Fix128::ONE, Vec3Fix::ZERO, Fix128::ZERO);
316
317        // Sphere penetrating ground
318        let contact = hf.collide_sphere(
319            Vec3Fix::new(
320                Fix128::from_int(5),
321                Fix128::from_ratio(1, 2),
322                Fix128::from_int(5),
323            ),
324            Fix128::ONE,
325        );
326
327        assert!(
328            contact.is_some(),
329            "Sphere at y=0.5 with r=1 should penetrate ground at y=0"
330        );
331        let c = contact.unwrap();
332        assert!(c.depth > Fix128::ZERO);
333    }
334
335    #[test]
336    fn test_sphere_above_heightfield() {
337        let hf = HeightField::flat(10, 10, Fix128::ONE, Vec3Fix::ZERO, Fix128::ZERO);
338
339        let contact = hf.collide_sphere(
340            Vec3Fix::new(
341                Fix128::from_int(5),
342                Fix128::from_int(5),
343                Fix128::from_int(5),
344            ),
345            Fix128::ONE,
346        );
347
348        assert!(
349            contact.is_none(),
350            "Sphere at y=5 should not collide with ground"
351        );
352    }
353
354    #[test]
355    fn test_normal_on_flat() {
356        let hf = HeightField::flat(10, 10, Fix128::ONE, Vec3Fix::ZERO, Fix128::ZERO);
357        let normal = hf.sample_normal(Fix128::from_int(5), Fix128::from_int(5));
358
359        // On flat terrain, normal should point up (0, 1, 0)
360        assert!(
361            normal.y > Fix128::from_ratio(9, 10),
362            "Normal should point up on flat terrain"
363        );
364    }
365
366    #[test]
367    fn test_signed_distance() {
368        let hf = HeightField::flat(10, 10, Fix128::ONE, Vec3Fix::ZERO, Fix128::ZERO);
369
370        let above = hf.signed_distance(Vec3Fix::from_int(5, 3, 5));
371        assert!(
372            above > Fix128::ZERO,
373            "Point above should have positive distance"
374        );
375
376        let below = hf.signed_distance(Vec3Fix::from_int(5, -2, 5));
377        assert!(
378            below < Fix128::ZERO,
379            "Point below should have negative distance"
380        );
381    }
382
383    #[test]
384    fn test_aabb() {
385        let hf = HeightField::flat(10, 10, Fix128::ONE, Vec3Fix::ZERO, Fix128::from_int(5));
386        let aabb = hf.aabb();
387        assert_eq!(aabb.min.y.hi, 5);
388        assert_eq!(aabb.max.y.hi, 5);
389    }
390
391    #[test]
392    fn set_height_updates_one_cell_and_feeds_bilinear_sampling() {
393        // 4 (width, x) × 3 (depth, z) の平地、spacing 1、origin 原点
394        let mut hf = HeightField::flat(4, 3, Fix128::ONE, Vec3Fix::ZERO, Fix128::ZERO);
395        hf.set_height(2, 1, Fix128::from_int(8));
396        // 該当 cell だけが変わる (index = gx + gz * width の取り違えを検出)
397        for gz in 0..3u32 {
398            for gx in 0..4u32 {
399                let want = if (gx, gz) == (2, 1) {
400                    Fix128::from_int(8)
401                } else {
402                    Fix128::ZERO
403                };
404                assert_eq!(hf.get_height(gx, gz), want, "({gx}, {gz})");
405            }
406        }
407        // 頂点上のサンプルはその高さ、隣接頂点との中点は線形補間 = 4、cell 中央は 8/4 = 2
408        assert_eq!(
409            hf.sample_height(Fix128::from_int(2), Fix128::from_int(1)),
410            Fix128::from_int(8)
411        );
412        assert_eq!(
413            hf.sample_height(Fix128::from_ratio(5, 2), Fix128::from_int(1)),
414            Fix128::from_int(4)
415        );
416        assert_eq!(
417            hf.sample_height(Fix128::from_int(2), Fix128::from_ratio(1, 2)),
418            Fix128::from_int(4)
419        );
420        assert_eq!(
421            hf.sample_height(Fix128::from_ratio(3, 2), Fix128::from_ratio(1, 2)),
422            Fix128::from_int(2)
423        );
424        // 2 cell 以上離れた点には影響しない
425        assert_eq!(
426            hf.sample_height(Fix128::from_ratio(1, 2), Fix128::from_ratio(3, 2)),
427            Fix128::ZERO
428        );
429        // 上書きできる
430        hf.set_height(2, 1, Fix128::from_int(-3));
431        assert_eq!(hf.get_height(2, 1), Fix128::from_int(-3));
432        // 範囲外 (gx == width / gz == depth) は無視され、他の cell も壊さない
433        hf.set_height(4, 0, Fix128::from_int(99));
434        hf.set_height(0, 3, Fix128::from_int(99));
435        hf.set_height(u32::MAX, u32::MAX, Fix128::from_int(99));
436        let mut sum = Fix128::ZERO;
437        for gz in 0..3u32 {
438            for gx in 0..4u32 {
439                sum = sum + hf.get_height(gx, gz);
440            }
441        }
442        assert_eq!(sum, Fix128::from_int(-3));
443        // AABB の y 範囲は set_height に追従する
444        let aabb = hf.aabb();
445        assert_eq!(aabb.min.y, Fix128::from_int(-3));
446        assert_eq!(aabb.max.y, Fix128::ZERO);
447    }
448}