Skip to main content

celestialsim/
noise_provider.rs

1//! [`NoiseProvider`] — a built-in CPU fBm-terrain [`CpuSurfaceProvider`].
2//!
3//! Self-contained (no streaming): every chunk's colour / height / normal is
4//! computed on the bake worker from 3-D fractal Perlin noise over the chunk's
5//! world directions — continents and hills comparable in character to the GPU
6//! `terrain_noise_3d.slang` path (it need NOT be byte-identical). Colour is a
7//! height-based ramp (blue seas → green → brown → white peaks); the normal is a
8//! finite difference of the height field. The provider is parametrised by the
9//! same `CesBuilder` exports that drive the GPU noise, so the CPU-noise planet
10//! is tunable like the GPU one.
11//!
12//! Height is stored as a signed displacement FRACTION of the radius, so
13//! [`height_scale`](CpuSurfaceProvider::height_scale) is `1.0` and the GPU
14//! surface radius becomes `radius · (1 + height)`.
15
16use godot::builtin::Vector3;
17
18use celestial_algo::clipmap::FaceFrame;
19use celestial_algo::quadtree::Chunk;
20
21use crate::surface::{ChunkSurface, CpuSurfaceProvider};
22use crate::water::HEIGHT_CENTER_SCALE;
23
24/// The FIXED elevation the terrain is centred on, in normalized 0..1 height —
25/// mirroring `terrain_noise_3d.slang`'s `LAND_CENTER`. Terrain shape does NOT
26/// depend on `water_height`; the water level simply floods this fixed relief.
27const LAND_CENTER: f32 = 0.5;
28
29/// Normalized-height swing per unit of `amp` around [`LAND_CENTER`]. Tuned so the
30/// default `amp` (0.25) spans ±0.275 — the SAME 0..1 band the GPU produces (it
31/// compresses its base by 0.55 around its land centre). Matching the band matters:
32/// the rock/snow colour bands sit at absolute heights (0.58 / 0.66), so too small a
33/// swing never reaches them and the planet comes out uniformly green and flat.
34const AMP_RELIEF: f32 = 2.2;
35
36/// Elevation above [`LAND_CENTER`] at which ridged erosion reaches full strength.
37const RIDGE_FADE: f32 = 0.10;
38
39/// Normalized-height contribution per unit of `ridge_strength`. The default
40/// (0.05) lands on ~0.16, matching the GPU's `mountains * 0.16`.
41const RIDGE_RELIEF: f32 = 3.2;
42
43/// How far below the water line the seabed fades from pale shore sand to the
44/// darker deep tone, in normalized height.
45const SEABED_FADE: f32 = 0.10;
46
47/// Height ABOVE the water line over which the dry beach stays fully sand, in
48/// normalized height. Small ⇒ a thin shore strip rather than a sandy coastal belt.
49const BEACH_FULL: f32 = 0.004;
50
51/// Height over which that beach then fades out into green.
52const BEACH_FADE: f32 = 0.006;
53
54/// Plain, `Send + Sync` noise parameters (derived from `CesBuilder` exports).
55#[derive(Clone, Copy, Debug)]
56pub struct NoiseParams {
57    /// Base noise frequency (tiles across the unit sphere).
58    pub tiles: f32,
59    /// fBm octave count.
60    pub octaves: u32,
61    /// Per-octave amplitude gain.
62    pub gain: f32,
63    /// Per-octave frequency growth.
64    pub lacunarity: f32,
65    /// Overall land amplitude (extra contrast on the raised land).
66    pub amp: f32,
67    /// Peak displacement as a fraction of the radius (land at the highest fBm).
68    pub height_scale: f32,
69    /// Normalized sea level in `[0, 1]`: fBm below this is flat sea.
70    pub water_height: f32,
71    /// Base frequency of the ridged EROSION layer (mountain ridge networks).
72    pub ridge_tiles: f32,
73    /// Ridged-erosion octave count (more ⇒ finer ridge/valley detail).
74    pub ridge_octaves: u32,
75    /// Ridged per-octave amplitude gain.
76    pub ridge_gain: f32,
77    /// Ridged per-octave frequency growth.
78    pub ridge_lacunarity: f32,
79    /// Erosion ridge amplitude as a fraction of the radius, added on land and
80    /// weighted toward higher ground — the visible mountain-carving detail.
81    pub ridge_strength: f32,
82    /// Planet radius (world units) — for the finite-difference normal.
83    pub radius: f32,
84}
85
86impl Default for NoiseParams {
87    fn default() -> Self {
88        NoiseParams {
89            tiles: 2.0,
90            octaves: 6,
91            // Low gain ⇒ the continental base dominates with fine detail on top
92            // (gain near 0.5 gives choppy "random noise", not continents).
93            gain: 0.12,
94            lacunarity: 2.0,
95            amp: 0.25,
96            height_scale: 0.06,
97            water_height: 0.5,
98            ridge_tiles: 3.242,
99            ridge_octaves: 6,
100            ridge_gain: 0.5,
101            ridge_lacunarity: 1.8,
102            ridge_strength: 0.05,
103            radius: 6371.0,
104        }
105    }
106}
107
108/// A built-in CPU fBm-terrain provider.
109pub struct NoiseProvider {
110    p: NoiseParams,
111}
112
113impl NoiseProvider {
114    pub fn new(p: NoiseParams) -> Self {
115        NoiseProvider { p }
116    }
117
118    /// NORMALIZED terrain height in `[0, 1]` — the SAME convention as the GPU's
119    /// `terrain_noise_3d.slang::terrain_height`.
120    ///
121    /// Crucially this is **independent of `water_height`**: the noise is centred
122    /// on the FIXED [`LAND_CENTER`], exactly as the GPU recentres on its own fixed
123    /// `LAND_CENTER`. That is what makes the water level a real sea-level slider —
124    /// raising it FLOODS this fixed terrain. (The old version derived the terrain
125    /// itself from `water_height`, so moving the slider re-shaped and rigidly
126    /// lifted the whole planet along with the sea, and nothing ever flooded.)
127    fn height01(&self, dir: Vector3) -> f32 {
128        // Continental base, fBm in [-1, 1] → a swing around the fixed land centre.
129        // `amp` is a true AMPLITUDE: larger ⇒ larger swing ⇒ TALLER mountains and
130        // deeper basins. (It used to be a power exponent on a 0..1 value, so
131        // raising it made mountains SMALLER — the inverted-amplitude bug.)
132        let raw = fbm(dir, self.p.tiles, self.p.octaves, self.p.gain, self.p.lacunarity);
133        let mut h = LAND_CENTER + 0.5 * raw * self.p.amp.max(0.0) * AMP_RELIEF;
134
135        // Ridged erosion, carved onto the higher ground only. Gated on elevation
136        // ABOVE THE LAND CENTRE (not above the water level), so the geometry stays
137        // water-independent — a water edit then needs no geometry re-bake at all.
138        let land = ((h - LAND_CENTER) / RIDGE_FADE).clamp(0.0, 1.0);
139        if land > 0.0 {
140            let ridged = ridged_fbm(
141                dir,
142                self.p.ridge_tiles,
143                self.p.ridge_octaves,
144                self.p.ridge_gain,
145                self.p.ridge_lacunarity,
146            );
147            h += (ridged - 0.5).max(0.0) * land * self.p.ridge_strength * RIDGE_RELIEF;
148        }
149        h.clamp(0.0, 1.0)
150    }
151
152    /// The value stored in the height buffer (what `ChunkRealize` displaces by).
153    ///
154    /// `ChunkRealize` puts a CPU-surface vertex at `radius · (1 + h · height_scale())`,
155    /// and [`Self::height_scale`] is `1.0`, so returning
156    /// `(h01 − 0.5) · HEIGHT_CENTER_SCALE · height_scale` reproduces the GPU's
157    /// `radius · (1 + (h01 − 0.5) · 2.4 · height_scale)` EXACTLY. Sea level
158    /// (`h01 == water_height`) therefore lands precisely on
159    /// [`crate::water::water_radius`] for any `water_height`, with no offset hack.
160    fn height(&self, dir: Vector3) -> f32 {
161        (self.height01(dir) - 0.5) * HEIGHT_CENTER_SCALE * self.p.height_scale
162    }
163}
164
165impl CpuSurfaceProvider for NoiseProvider {
166    fn bake(&self, frame: &FaceFrame, chunk: &Chunk, tile_res: u32) -> ChunkSurface {
167        let n = tile_res;
168        let res_f = n as f32;
169        let mut color = vec![0u8; (n * n * 4) as usize];
170        let mut height = vec![0f32; (n * n) as usize];
171
172        // Chunk-local (u, v) → world direction (the cheap gnomonic the fill /
173        // realize path uses): barycentric in the chunk → face barycentric →
174        // linear blend of the face corners, renormalised.
175        let dir_at = |u: f32, v: f32| -> Vector3 {
176            let wa = 1.0 - u - v;
177            let wb = wa * chunk.bary[0].wb + u * chunk.bary[1].wb + v * chunk.bary[2].wb;
178            let wc = wa * chunk.bary[0].wc + u * chunk.bary[1].wc + v * chunk.bary[2].wc;
179            (frame.a * (1.0 - wb - wc) + frame.b * wb + frame.c * wc).normalized()
180        };
181
182        // Fill the WHOLE square (out-of-triangle texels project onto the diagonal
183        // so edge vertices and the bilinear atlas read valid data — the same
184        // convention `build_patch` uses).
185        for ty in 0..n {
186            for tx in 0..n {
187                let mut u = (tx as f32 + 0.5) / res_f;
188                let mut v = (ty as f32 + 0.5) / res_f;
189                if u + v > 1.0 {
190                    let s = u + v;
191                    u /= s;
192                    v /= s;
193                }
194                let dir = dir_at(u, v);
195                // ONE normalized height drives both: the colour ramp keys off it
196                // against `water_height` (same as the GPU albedo), and the height
197                // buffer stores the displacement the GPU realizes it by.
198                let h01 = self.height01(dir);
199                let idx = (ty * n + tx) as usize;
200                height[idx] = (h01 - 0.5) * HEIGHT_CENTER_SCALE * self.p.height_scale;
201                let c = surface_color(h01, self.p.water_height);
202                color[idx * 4..idx * 4 + 4].copy_from_slice(&c);
203            }
204        }
205
206        // ---- Normals (finite difference of the height field, curvature-correct).
207        // Same construction as `build_patch`: chunk texel chords bent into the
208        // local tangent plane, plus the radial height delta over a small stride.
209        let mut normal = vec![0u8; (n * n * 4) as usize];
210        if frame.radius > 0.0 {
211            let [c0, c1, c2] = chunk.corners;
212            let ex = (c1 - c0) / res_f; // world step of one +tx texel
213            let ey = (c2 - c0) / res_f;
214            // Height buffer is a fraction of radius, so world height = h · radius.
215            let k = frame.radius;
216            let ni = n as i32;
217            let stride = 1i32;
218            for ty in 0..n {
219                for tx in 0..n {
220                    let idx = (ty * n + tx) as usize;
221                    let mut u = ((tx as f32 + 0.5) / res_f).min(1.0);
222                    let mut v = ((ty as f32 + 0.5) / res_f).min(1.0);
223                    if u + v > 1.0 {
224                        let s = u + v;
225                        u /= s;
226                        v /= s;
227                    }
228                    let dir = dir_at(u, v);
229
230                    let sample = |x: i32, y: i32| -> f32 {
231                        let x = x.clamp(0, ni - 1) as u32;
232                        let y = y.clamp(0, ni - 1) as u32;
233                        height[(y * n + x) as usize]
234                    };
235                    let (xi, yi) = (tx as i32, ty as i32);
236                    let (xm, xp) = ((xi - stride).max(0), (xi + stride).min(ni - 1));
237                    let (ym, yp) = ((yi - stride).max(0), (yi + stride).min(ni - 1));
238                    let span_x = (xp - xm).max(1) as f32;
239                    let span_y = (yp - ym).max(1) as f32;
240
241                    let exl = ex - dir * dir.dot(ex);
242                    let eyl = ey - dir * dir.dot(ey);
243                    let dpx = exl * span_x + dir * (k * (sample(xp, yi) - sample(xm, yi)));
244                    let dpy = eyl * span_y + dir * (k * (sample(xi, yp) - sample(xi, ym)));
245                    let mut nrm = dpx.cross(dpy).normalized();
246                    if nrm.dot(dir) < 0.0 {
247                        nrm = -nrm;
248                    }
249                    let pack = |c: f32| ((c * 0.5 + 0.5).clamp(0.0, 1.0) * 255.0).round() as u8;
250                    normal[idx * 4] = pack(nrm.x);
251                    normal[idx * 4 + 1] = pack(nrm.y);
252                    normal[idx * 4 + 2] = pack(nrm.z);
253                    normal[idx * 4 + 3] = 255;
254                }
255            }
256        }
257
258        ChunkSurface { color, height, normal }
259    }
260
261    fn sample_height(&self, dir: Vector3) -> Option<f32> {
262        // The STORED height (relief + sea offset) — i.e. exactly what the GPU
263        // displaces the geometry by, so LOD/ground queries match the render.
264        Some(self.height(dir))
265    }
266
267    fn height_scale(&self) -> f32 {
268        // `height()` already returns the FULL displacement fraction in the GPU's
269        // convention — `(h01 − 0.5) · 2.4 · height_scale` — so `ChunkRealize`'s
270        // `radius · (1 + h · height_scale())` must not rescale it. Any factor other
271        // than 1.0 here would scale the terrain but NOT the analytic water sphere,
272        // and the sea would stop meeting the shore. Amplitude is controlled by the
273        // builder's `height_scale` / `amp` knobs instead.
274        1.0
275    }
276}
277
278/// Colour ramp keyed on the NORMALIZED height `h01` against `water_height` — the
279/// same convention as the GPU albedo (`terrain_noise_3d.slang`): sandy seabed →
280/// thin sand beach at the waterline → green → brown → white peaks.
281///
282/// Because both the bands and the analytic ocean key off `water_height`, the beach
283/// always sits exactly on the waterline, and raising the water level re-colours
284/// the terrain (more sea, less land) instead of moving it.
285fn surface_color(h01: f32, water_height: f32) -> [u8; 4] {
286    let pack = |r: f32, g: f32, b: f32| {
287        [
288            (r.clamp(0.0, 1.0) * 255.0) as u8,
289            (g.clamp(0.0, 1.0) * 255.0) as u8,
290            (b.clamp(0.0, 1.0) * 255.0) as u8,
291            255,
292        ]
293    };
294    let lerp3 = |a: [f32; 3], b: [f32; 3], s: f32| {
295        [a[0] + (b[0] - a[0]) * s, a[1] + (b[1] - a[1]) * s, a[2] + (b[2] - a[2]) * s]
296    };
297    let smooth = |x: f32| {
298        let s = x.clamp(0.0, 1.0);
299        s * s * (3.0 - 2.0 * s)
300    };
301
302    if h01 <= water_height {
303        // Submerged ground is SAND, not blue. The analytic ocean shader supplies
304        // all the blue (it tints whatever is seen through it with a Beer-Lambert
305        // depth falloff), so a blue seabed would double up. Mirrors the GPU
306        // example's SEABED_SHORE_COLOR / SEABED_DEEP_COLOR: pale warm lagoon sand
307        // at the shore fading to a cooler grey-tan into the deep basins.
308        let seabed_shore = [0.87, 0.79, 0.64];
309        let seabed_deep = [0.50, 0.50, 0.47];
310        let depth = (water_height - h01) / SEABED_FADE;
311        let c = lerp3(seabed_shore, seabed_deep, smooth(depth / 0.85));
312        return pack(c[0], c[1], c[2]);
313    }
314
315    let sand = [0.80, 0.70, 0.60];
316    let green = [0.20, 0.45, 0.16];
317    let brown = [0.42, 0.32, 0.20];
318    let white = [0.95, 0.95, 0.97];
319
320    // Height ABOVE the waterline drives the shore bands; absolute height drives
321    // the rock/snow bands (as on the GPU, where snow is an absolute elevation).
322    let above = h01 - water_height;
323    // A VERY thin dry sand beach hugging the waterline, then straight to green.
324    // Kept narrow deliberately: the pale SUBMERGED sand already reads as a wide
325    // bright shallows band, so a wide dry beach on top of it swamps the green.
326    let land = lerp3(sand, green, smooth((above - BEACH_FULL) / BEACH_FADE));
327    let c = if h01 < 0.58 {
328        land
329    } else if h01 < 0.66 {
330        lerp3(land, brown, smooth((h01 - 0.58) / 0.04))
331    } else {
332        lerp3(brown, white, smooth((h01 - 0.66) / 0.06))
333    };
334    pack(c[0], c[1], c[2])
335}
336
337// ---- 3-D fractal Perlin noise --------------------------------------------
338
339/// Fractal Brownian motion of [`perlin3`] in `[-1, 1]` (amplitude-normalised).
340fn fbm(dir: Vector3, tiles: f32, octaves: u32, gain: f32, lacunarity: f32) -> f32 {
341    let mut freq = tiles;
342    let mut amp = 1.0f32;
343    let mut sum = 0.0f32;
344    let mut norm = 0.0f32;
345    for _ in 0..octaves.max(1) {
346        sum += amp * perlin3(dir * freq);
347        norm += amp;
348        freq *= lacunarity.max(1.0e-3);
349        amp *= gain;
350    }
351    if norm > 0.0 {
352        (sum / norm).clamp(-1.0, 1.0)
353    } else {
354        0.0
355    }
356}
357
358/// Ridged multifractal in `[0, 1]`: each octave is `(1 - |perlin|)²` (sharp
359/// ridges where the noise crosses zero), amplitude-normalised. Produces the
360/// mountain ridge / valley networks that read as erosion.
361fn ridged_fbm(dir: Vector3, tiles: f32, octaves: u32, gain: f32, lacunarity: f32) -> f32 {
362    let mut freq = tiles;
363    let mut amp = 1.0f32;
364    let mut sum = 0.0f32;
365    let mut norm = 0.0f32;
366    for _ in 0..octaves.max(1) {
367        let r = 1.0 - perlin3(dir * freq).abs();
368        sum += amp * r * r;
369        norm += amp;
370        freq *= lacunarity.max(1.0e-3);
371        amp *= gain;
372    }
373    if norm > 0.0 {
374        (sum / norm).clamp(0.0, 1.0)
375    } else {
376        0.0
377    }
378}
379
380#[inline]
381fn fade(t: f32) -> f32 {
382    t * t * t * (t * (t * 6.0 - 15.0) + 10.0)
383}
384
385#[inline]
386fn lerp(a: f32, b: f32, t: f32) -> f32 {
387    a + t * (b - a)
388}
389
390/// Integer hash → pseudo-random `u32`.
391#[inline]
392fn hash3(x: i32, y: i32, z: i32) -> u32 {
393    let mut h = (x.wrapping_mul(374_761_393))
394        .wrapping_add(y.wrapping_mul(668_265_263))
395        .wrapping_add(z.wrapping_mul(1_274_126_177)) as u32;
396    h = (h ^ (h >> 13)).wrapping_mul(1_274_126_177);
397    h ^ (h >> 16)
398}
399
400/// Classic Perlin gradient dot-product for a hashed corner.
401#[inline]
402fn grad(hash: u32, x: f32, y: f32, z: f32) -> f32 {
403    let h = hash & 15;
404    let u = if h < 8 { x } else { y };
405    let v = if h < 4 {
406        y
407    } else if h == 12 || h == 14 {
408        x
409    } else {
410        z
411    };
412    (if h & 1 == 0 { u } else { -u }) + (if h & 2 == 0 { v } else { -v })
413}
414
415/// 3-D Perlin noise in ~`[-1, 1]`.
416fn perlin3(p: Vector3) -> f32 {
417    let xi = p.x.floor();
418    let yi = p.y.floor();
419    let zi = p.z.floor();
420    let (x0, y0, z0) = (xi as i32, yi as i32, zi as i32);
421    let (fx, fy, fz) = (p.x - xi, p.y - yi, p.z - zi);
422    let (u, v, w) = (fade(fx), fade(fy), fade(fz));
423
424    let g = |dx: i32, dy: i32, dz: i32| -> f32 {
425        grad(hash3(x0 + dx, y0 + dy, z0 + dz), fx - dx as f32, fy - dy as f32, fz - dz as f32)
426    };
427
428    let x00 = lerp(g(0, 0, 0), g(1, 0, 0), u);
429    let x10 = lerp(g(0, 1, 0), g(1, 1, 0), u);
430    let x01 = lerp(g(0, 0, 1), g(1, 0, 1), u);
431    let x11 = lerp(g(0, 1, 1), g(1, 1, 1), u);
432    let y0l = lerp(x00, x10, v);
433    let y1l = lerp(x01, x11, v);
434    lerp(y0l, y1l, w)
435}
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440    use celestial_algo::quadtree::{base_face_frames, select_chunks};
441
442    #[test]
443    fn perlin_is_bounded_and_varies() {
444        let mut min = f32::MAX;
445        let mut max = f32::MIN;
446        for i in 0..2000 {
447            let a = i as f32 * 0.137;
448            let p = Vector3::new(a.sin() * 7.3, a.cos() * 3.1, (a * 1.7).sin() * 5.0);
449            let n = perlin3(p);
450            assert!(n.is_finite() && n.abs() <= 1.5, "perlin out of range: {n}");
451            min = min.min(n);
452            max = max.max(n);
453        }
454        assert!(max - min > 0.5, "perlin should vary across samples ({min}..{max})");
455    }
456
457    #[test]
458    fn bake_fills_square_and_has_water_and_land() {
459        let p = NoiseParams::default();
460        let provider = NoiseProvider::new(p);
461        let frames = base_face_frames(p.radius);
462        // A coarse cut so the chunk spans a big area (both sea and land likely).
463        let cam = Vector3::new(0.0, 0.0, p.radius * 2.0);
464        let cut = select_chunks(&frames, cam, 0.5, 20, 3, None);
465        let chunk = cut[0];
466        let frame = &frames[chunk.id.face as usize];
467
468        let tr = 32u32;
469        let s = provider.bake(frame, &chunk, tr);
470        assert_eq!(s.color.len(), (tr * tr * 4) as usize);
471        assert_eq!(s.height.len(), (tr * tr) as usize);
472        assert_eq!(s.normal.len(), (tr * tr * 4) as usize);
473        // Every texel colour has full alpha; height is finite and bounded by the
474        // normalized 0..1 field mapped through the GPU displacement convention.
475        let bound = 0.5 * HEIGHT_CENTER_SCALE * p.height_scale + 1e-4;
476        for i in 0..(tr * tr) as usize {
477            assert_eq!(s.color[i * 4 + 3], 255);
478            assert!(s.height[i].is_finite());
479            assert!(s.height[i].abs() <= bound, "height {} out of bounds", s.height[i]);
480        }
481    }
482
483    /// A sampling of directions, for whole-field comparisons.
484    fn probe_dirs() -> Vec<Vector3> {
485        (0..400)
486            .map(|i| {
487                let a = i as f32 * 0.21;
488                Vector3::new(a.cos(), (a * 0.7).sin(), a.sin()).normalized()
489            })
490            .collect()
491    }
492
493    /// THE sea-level invariant: a point at the water line (`h01 == water_height`)
494    /// must be displaced by `ChunkRealize` to EXACTLY the analytic water sphere's
495    /// radius, for ANY `water_height`. Otherwise the ocean reads as a plane at the
496    /// wrong level.
497    #[test]
498    fn cpu_sea_level_matches_analytic_water_radius() {
499        for wh in [0.35f32, 0.45, 0.5, 0.62] {
500            let p = NoiseParams { water_height: wh, ..NoiseParams::default() };
501            let provider = NoiseProvider::new(p);
502            // The stored height of a texel exactly at the water line:
503            let stored_at_sea = (wh - 0.5) * HEIGHT_CENTER_SCALE * p.height_scale;
504            // What ChunkRealize does with a stored height h: r = R·(1 + h·scale).
505            let realized = p.radius * (1.0 + stored_at_sea * provider.height_scale());
506            let expected = crate::water::water_radius(p.radius, wh, p.height_scale);
507            assert!(
508                (realized - expected).abs() < 1e-2,
509                "water_height {wh}: sea level realized at {realized}, water sphere at {expected}"
510            );
511        }
512    }
513
514    /// The water level must FLOOD fixed terrain, never move it. Changing
515    /// `water_height` must leave every displaced height byte-for-byte identical —
516    /// the terrain is centred on a FIXED `LAND_CENTER`, exactly like the GPU.
517    ///
518    /// Regression test: an earlier fix added a `water_height`-derived offset to
519    /// every height, which rigidly lifted the whole planet along with the sea, so
520    /// raising the water level flooded nothing.
521    #[test]
522    fn water_height_does_not_move_the_terrain() {
523        let lo = NoiseProvider::new(NoiseParams { water_height: 0.35, ..NoiseParams::default() });
524        let hi = NoiseProvider::new(NoiseParams { water_height: 0.62, ..NoiseParams::default() });
525        for d in probe_dirs() {
526            assert!(
527                (lo.height(d) - hi.height(d)).abs() < 1e-6,
528                "water_height moved the terrain at {d:?}"
529            );
530        }
531    }
532
533    /// Raising the water level must SUBMERGE more of the (fixed) terrain.
534    #[test]
535    fn raising_water_height_floods_more_land() {
536        let dirs = probe_dirs();
537        let submerged = |wh: f32| {
538            let p = NoiseParams { water_height: wh, ..NoiseParams::default() };
539            let prov = NoiseProvider::new(p);
540            // Submerged ⇔ the terrain sits below the water sphere.
541            let sea = crate::water::water_radius(p.radius, wh, p.height_scale);
542            dirs.iter()
543                .filter(|d| p.radius * (1.0 + prov.height(**d) * prov.height_scale()) < sea)
544                .count()
545        };
546        let (low, high) = (submerged(0.40), submerged(0.60));
547        assert!(high > low, "raising water_height must flood more land ({low} → {high})");
548    }
549
550    /// `amp` is an AMPLITUDE: more of it must make mountains BIGGER.
551    ///
552    /// Regression test: it used to be a power exponent on a 0..1 value
553    /// (`land.powf(1.0 + amp)`), so raising it made mountains SMALLER.
554    #[test]
555    fn higher_amp_makes_bigger_mountains() {
556        let relief = |amp: f32| {
557            let prov = NoiseProvider::new(NoiseParams { amp, ..NoiseParams::default() });
558            let (mut lo, mut hi) = (f32::INFINITY, f32::NEG_INFINITY);
559            for d in probe_dirs() {
560                let h = prov.height(d);
561                lo = lo.min(h);
562                hi = hi.max(h);
563            }
564            hi - lo
565        };
566        let small = relief(0.15);
567        let large = relief(0.45);
568        assert!(large > small, "higher amp must raise relief ({small} → {large})");
569    }
570}