Skip to main content

celestialsim/
water.rs

1//! Analytic water-surface geometry helpers.
2//!
3//! The water is rendered analytically in a fragment shader (no tessellated
4//! surface): a coarse proxy sphere spawns fragments, and the shader
5//! ray-intersects the *mathematical* sea-level sphere whose radius is computed
6//! here. The level matches the exact radius at which the terrain noise crosses
7//! `water_height`, so seas/lakes line up with the terrain's own water coloring.
8//!
9//! Mirror of the displacement math in `ChunkRealize.slang::displace`:
10//! `pos = dir * (radius + (h - 0.5) * 2.4 * radius * height_scale)`. Evaluated
11//! at `h = water_height` that is the sea-level radius below. The same formula is
12//! used for every builder (noise or custom) so the water level slider means the
13//! same thing everywhere — `water_height = 0.5` is the baseline (the noise
14//! midpoint AND the custom `h = 0` line), where the sea sits exactly at `radius`.
15
16/// The displacement shader maps a normalized height `h` to a centered offset
17/// `(h - 0.5) * HEIGHT_CENTER_SCALE`. Keep in sync with `displace` in
18/// `ChunkRealize.slang` (and `ChunkTileBake` / `TileViewer` / `ScatterPlace`).
19pub const HEIGHT_CENTER_SCALE: f32 = 2.4;
20
21/// World radius of the analytic sea-level sphere: the terrain surface radius
22/// evaluated at `water_height`. `water_height = 0.5` is the baseline — the noise
23/// midpoint / custom `h = 0` line — at which the sea sits exactly at `radius`. At
24/// the shipped defaults (`CesBuilder::water_height` 0.549, `CesGPUNoiseExample`'s
25/// `height_scale` 0.0585) it sits slightly above, at `1.0069 * radius`, so terrain
26/// pokes through to form seas/lakes wherever the height rises above water.
27/// Lowering the level drains the seas; raising it floods low land.
28pub fn water_radius(radius: f32, water_height: f32, height_scale: f32) -> f32 {
29    let centered = (water_height - 0.5) * HEIGHT_CENTER_SCALE;
30    radius * (1.0 + centered * height_scale)
31}
32
33/// Radius of the coarse render-proxy sphere: the analytic water radius inflated
34/// by a small margin so the proxy's faceted silhouette always covers the true
35/// (smooth) sphere's silhouette — rays that miss the analytic sphere are
36/// discarded in-shader, so the proxy only needs to over-cover, never under.
37pub const PROXY_MARGIN: f32 = 1.02;
38
39/// Convenience: proxy sphere radius from the analytic water radius.
40pub fn proxy_radius(water_radius: f32) -> f32 {
41    water_radius * PROXY_MARGIN
42}
43
44#[cfg(test)]
45mod tests {
46    use super::*;
47
48    #[test]
49    fn water_below_the_midpoint_sits_inside_the_surface() {
50        // water_height 0.45, height_scale 0.25 => (0.45-0.5)*2.4*0.25 = -0.03.
51        // (NOT the default: `CesBuilder::water_height` defaults to 0.5.)
52        let r = water_radius(1000.0, 0.45, 0.25);
53        assert!((r - 970.0).abs() < 1e-3, "expected 970.0, got {r}");
54    }
55
56    #[test]
57    fn water_height_one_half_is_mean_surface() {
58        // h = 0.5 is the noise midpoint / custom baseline: water sits at radius.
59        let r = water_radius(1234.0, 0.5, 0.25);
60        assert!((r - 1234.0).abs() < 1e-3, "expected base radius, got {r}");
61    }
62
63    #[test]
64    fn water_radius_is_monotonic_in_water_height() {
65        // Higher water level => larger sea-level sphere.
66        let lo = water_radius(1000.0, 0.40, 0.25);
67        let mid = water_radius(1000.0, 0.50, 0.25);
68        let hi = water_radius(1000.0, 0.60, 0.25);
69        assert!(lo < mid && mid < hi, "not monotonic: {lo} {mid} {hi}");
70    }
71
72    #[test]
73    fn proxy_radius_over_covers() {
74        let wr = water_radius(1000.0, 0.45, 0.25);
75        assert!(proxy_radius(wr) > wr);
76        assert!((proxy_radius(wr) - wr * 1.02).abs() < 1e-3);
77    }
78}