symbios_ground/heightmap.rs
1use std::sync::OnceLock;
2
3use serde::{Deserialize, Serialize};
4
5/// A 2D heightmap stored as a flat row-major `Vec<f32>` buffer.
6///
7/// Covers world space `[0, width * scale) × [0, height * scale)`.
8/// `width` and `height` are grid-cell counts; `scale` is world units per cell.
9///
10/// The invariant `data.len() == width * height` is enforced by all constructors
11/// and mutation methods; fields are private to prevent external corruption.
12///
13/// A grid-cell normal cache is computed lazily on first read and reused across
14/// repeated queries (e.g. by [`SplatMapper`](crate::SplatMapper)). It is
15/// invalidated whenever the heights change via [`HeightMap::set`],
16/// [`HeightMap::get_mut`], [`HeightMap::data_mut`], or [`HeightMap::normalize`].
17#[derive(Debug, Serialize, Deserialize)]
18pub struct HeightMap {
19 data: Vec<f32>,
20 width: usize,
21 height: usize,
22 scale: f32,
23 /// Pooled lakes detected by hydraulic erosion. Empty until erosion writes
24 /// to it; persisted across serialisation so renderers can visualise water
25 /// without re-running the simulation.
26 #[serde(default)]
27 lakes: Vec<Lake>,
28 /// Lazy per-grid-cell central-difference normals. Skipped from serde so the
29 /// cache stays consistent with the height data after deserialisation.
30 #[serde(skip)]
31 normals: OnceLock<Vec<[f32; 3]>>,
32}
33
34/// A pooled body of standing water produced by [`HydraulicErosion`](crate::HydraulicErosion).
35///
36/// Built up at points where droplet velocity drops below the configured
37/// threshold while still above `water_level`, marking realistic basins and
38/// depressions in the heightmap.
39#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
40pub struct Lake {
41 /// Row-major index into the heightmap (`z * width + x`).
42 pub index: usize,
43 /// Accumulated water depth at this cell.
44 pub depth: f32,
45 /// World-space area attributed to this lake cell — `scale * scale`.
46 pub area: f32,
47}
48
49impl Clone for HeightMap {
50 fn clone(&self) -> Self {
51 Self {
52 data: self.data.clone(),
53 width: self.width,
54 height: self.height,
55 scale: self.scale,
56 lakes: self.lakes.clone(),
57 normals: OnceLock::new(),
58 }
59 }
60}
61
62impl HeightMap {
63 /// Create a heightmap of `width × height` cells, all initialised to `0.0`.
64 ///
65 /// `scale` is the world-unit size of each cell (must be `> 0`).
66 ///
67 /// # Panics
68 ///
69 /// Panics if `width == 0`, `height == 0`, or `scale <= 0.0`.
70 pub fn new(width: usize, height: usize, scale: f32) -> Self {
71 assert!(width > 0 && height > 0, "dimensions must be positive");
72 assert!(scale > 0.0, "scale must be positive");
73 Self {
74 data: vec![0.0; width * height],
75 width,
76 height,
77 scale,
78 lakes: Vec::new(),
79 normals: OnceLock::new(),
80 }
81 }
82
83 /// Grid-cell width.
84 #[inline]
85 pub fn width(&self) -> usize {
86 self.width
87 }
88
89 /// Grid-cell depth.
90 #[inline]
91 pub fn height(&self) -> usize {
92 self.height
93 }
94
95 /// World units per grid cell.
96 #[inline]
97 pub fn scale(&self) -> f32 {
98 self.scale
99 }
100
101 /// Read-only view of the flat row-major height buffer.
102 #[inline]
103 pub fn data(&self) -> &[f32] {
104 &self.data
105 }
106
107 /// Mutable view of the flat row-major height buffer.
108 ///
109 /// The caller must not change the slice length; only element values may be
110 /// modified. Dimensions (`width`, `height`) are unaffected. Invalidates
111 /// the normal cache.
112 #[inline]
113 pub fn data_mut(&mut self) -> &mut [f32] {
114 self.invalidate_caches();
115 &mut self.data
116 }
117
118 /// Return the height at grid cell `(x, z)`.
119 ///
120 /// # Panics
121 ///
122 /// Panics if `x >= width` or `z >= height`.
123 #[inline]
124 pub fn get(&self, x: usize, z: usize) -> f32 {
125 self.data[z * self.width + x]
126 }
127
128 /// Return a mutable reference to the height at grid cell `(x, z)`.
129 /// Invalidates the normal cache.
130 ///
131 /// # Panics
132 ///
133 /// Panics if `x >= width` or `z >= height`.
134 #[inline]
135 pub fn get_mut(&mut self, x: usize, z: usize) -> &mut f32 {
136 self.invalidate_caches();
137 &mut self.data[z * self.width + x]
138 }
139
140 /// Set the height at grid cell `(x, z)` to `val`. Invalidates the normal
141 /// cache.
142 ///
143 /// # Panics
144 ///
145 /// Panics if `x >= width` or `z >= height`.
146 #[inline]
147 pub fn set(&mut self, x: usize, z: usize, val: f32) {
148 self.invalidate_caches();
149 self.data[z * self.width + x] = val;
150 }
151
152 /// Return the height at grid cell `(x, z)`, clamping coordinates to the
153 /// valid range instead of panicking on out-of-bounds indices.
154 #[inline]
155 pub fn get_clamped(&self, x: i32, z: i32) -> f32 {
156 let cx = x.clamp(0, self.width as i32 - 1) as usize;
157 let cz = z.clamp(0, self.height as i32 - 1) as usize;
158 self.get(cx, cz)
159 }
160
161 /// Sample height at world position using bilinear interpolation.
162 /// Clamps to heightmap boundaries.
163 pub fn get_height_at(&self, world_x: f32, world_z: f32) -> f32 {
164 let gx = world_x / self.scale;
165 let gz = world_z / self.scale;
166
167 let x0 = gx.floor() as i32;
168 let z0 = gz.floor() as i32;
169 let fx = gx - x0 as f32;
170 let fz = gz - z0 as f32;
171
172 let h00 = self.get_clamped(x0, z0);
173 let h10 = self.get_clamped(x0 + 1, z0);
174 let h01 = self.get_clamped(x0, z0 + 1);
175 let h11 = self.get_clamped(x0 + 1, z0 + 1);
176
177 let h0 = h00 + (h10 - h00) * fx;
178 let h1 = h01 + (h11 - h01) * fx;
179 h0 + (h1 - h0) * fz
180 }
181
182 /// Compute surface normal at world position using central differences.
183 /// Returns a normalized `[x, y, z]` vector where `y` is up.
184 ///
185 /// `scale` is always > 0 (enforced by the constructor), so the `2*scale`
186 /// divisor and the `len` (≥ 1.0 since ny = 1) are both safe.
187 pub fn get_normal_at(&self, world_x: f32, world_z: f32) -> [f32; 3] {
188 let step = self.scale;
189 let hl = self.get_height_at(world_x - step, world_z);
190 let hr = self.get_height_at(world_x + step, world_z);
191 let hd = self.get_height_at(world_x, world_z - step);
192 let hu = self.get_height_at(world_x, world_z + step);
193
194 let dhdx = (hr - hl) / (2.0 * step);
195 let dhdz = (hu - hd) / (2.0 * step);
196
197 let nx = -dhdx;
198 let ny = 1.0_f32;
199 let nz = -dhdz;
200 let len = (nx * nx + ny * ny + nz * nz).sqrt();
201 // If scale is a denormal (< ~1e-38), dhdx/dhdz can overflow to ±INF,
202 // making len = INF and the division NaN. Return a flat up-normal instead.
203 if !len.is_finite() {
204 return [0.0, 1.0, 0.0];
205 }
206 [nx / len, ny / len, nz / len]
207 }
208
209 /// Cached central-difference normal at grid cell `(x, z)`.
210 ///
211 /// Equivalent to `get_normal_at(x as f32 * scale, z as f32 * scale)` for
212 /// in-bounds cells, but reads from a lazily populated cache so callers that
213 /// scan the whole grid (e.g. [`SplatMapper`](crate::SplatMapper)) avoid
214 /// recomputing four central differences per pixel.
215 ///
216 /// # Panics
217 ///
218 /// Panics if `x >= width` or `z >= height`.
219 pub fn normal_at_grid(&self, x: usize, z: usize) -> [f32; 3] {
220 self.normals_grid()[z * self.width + x]
221 }
222
223 /// Lazily populated per-grid-cell normal table; row-major, length
224 /// `width * height`.
225 pub fn normals_grid(&self) -> &[[f32; 3]] {
226 self.normals.get_or_init(|| self.compute_normals())
227 }
228
229 fn compute_normals(&self) -> Vec<[f32; 3]> {
230 let w = self.width;
231 let h = self.height;
232 let mut out = Vec::with_capacity(w * h);
233 let step = self.scale;
234 let inv_2step = 1.0_f32 / (2.0 * step);
235 for z in 0..h {
236 for x in 0..w {
237 let hl = self.get_clamped(x as i32 - 1, z as i32);
238 let hr = self.get_clamped(x as i32 + 1, z as i32);
239 let hd = self.get_clamped(x as i32, z as i32 - 1);
240 let hu = self.get_clamped(x as i32, z as i32 + 1);
241
242 let dhdx = (hr - hl) * inv_2step;
243 let dhdz = (hu - hd) * inv_2step;
244
245 let nx = -dhdx;
246 let ny = 1.0_f32;
247 let nz = -dhdz;
248 let len = (nx * nx + ny * ny + nz * nz).sqrt();
249 if !len.is_finite() {
250 out.push([0.0, 1.0, 0.0]);
251 } else {
252 out.push([nx / len, ny / len, nz / len]);
253 }
254 }
255 }
256 out
257 }
258
259 /// Lakes detected by the most recent hydraulic erosion pass. Empty until
260 /// [`HydraulicErosion::erode`](crate::HydraulicErosion::erode) populates
261 /// it.
262 pub fn lakes(&self) -> &[Lake] {
263 &self.lakes
264 }
265
266 /// Internal: replace the lake list (used by erosion).
267 pub(crate) fn set_lakes(&mut self, lakes: Vec<Lake>) {
268 self.lakes = lakes;
269 }
270
271 /// Internal: clear all derived caches that depend on `data`.
272 fn invalidate_caches(&mut self) {
273 self.normals.take();
274 }
275
276 /// Normalize all height values to `[0.0, 1.0]`.
277 pub fn normalize(&mut self) {
278 let min = self.data.iter().cloned().fold(f32::INFINITY, f32::min);
279 let max = self.data.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
280 let range = max - min;
281 if range > f32::EPSILON {
282 for v in &mut self.data {
283 *v = (*v - min) / range;
284 }
285 }
286 self.invalidate_caches();
287 }
288
289 /// World-space width of the heightmap.
290 pub fn world_width(&self) -> f32 {
291 self.width as f32 * self.scale
292 }
293
294 /// World-space depth of the heightmap.
295 pub fn world_depth(&self) -> f32 {
296 self.height as f32 * self.scale
297 }
298}