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}