Skip to main content

celestialsim/
water_runtime.rs

1//! Scene-side water surface: one transparent proxy sphere per planet driving the
2//! analytic `water_surface.gdshader`.
3//!
4//! The proxy mesh is geometry-irrelevant — the shader computes the true smooth
5//! sea-level sphere analytically. The proxy just needs to over-cover that
6//! sphere's silhouette, so a low-segment `SphereMesh` at `water_radius * margin`
7//! is plenty. Per-frame we push the planet center, the live `water_radius`
8//! (recomputed from the builder's water level), the sun direction, and the look
9//! uniforms (all sourced from the active `CesBuilder`).
10
11use godot::classes::fast_noise_lite::NoiseType;
12use godot::classes::{
13    FastNoiseLite, MeshInstance3D, Node3D, NoiseTexture2D, Shader, ShaderMaterial, SphereMesh,
14    Texture2D,
15};
16use godot::prelude::*;
17
18use crate::water::proxy_radius;
19
20const WATER_SHADER: &str = "res://addons/celestialsim/water_surface.gdshader";
21
22/// Tunable look params pushed to the water material each frame (read from the
23/// active builder's water exports; the rest use the shader's own defaults).
24#[derive(Clone, Copy)]
25pub struct WaterParams {
26    pub deep_color: Color,
27    pub shallow_color: Color,
28    pub wave_strength: f32,
29    pub wave_scale: f32,
30    pub wave_speed: f32,
31    pub underwater_color: Color,
32    pub underwater_density: f32,
33    /// World-space direction pointing TOWARD the sun (found from the scene's
34    /// `DirectionalLight3D`). Drives the analytic diffuse + specular glint.
35    pub sun_dir: Vector3,
36}
37
38pub struct WaterRuntime {
39    mmi: Gd<MeshInstance3D>,
40    mesh: Gd<SphereMesh>,
41    material: Gd<ShaderMaterial>,
42    /// Proxy radius currently baked into the SphereMesh (rebuilt only when the
43    /// water radius changes meaningfully — cheap, but not worth doing per frame).
44    proxy_r: f32,
45}
46
47impl WaterRuntime {
48    /// A tiling normal map for the wave detail. Generated here so the water is
49    /// self-sufficient (the shader's `wave_normal_*` are sampled triplanar; no
50    /// scene/HUD needs to feed them). Seamless simplex bumped into a normal map.
51    fn make_wave_normal_tex(seed: i32, frequency: f32) -> Gd<Texture2D> {
52        let mut noise = FastNoiseLite::new_gd();
53        noise.set_noise_type(NoiseType::SIMPLEX_SMOOTH);
54        noise.set_frequency(frequency);
55        noise.set_seed(seed);
56        let mut tex = NoiseTexture2D::new_gd();
57        tex.set_width(256);
58        tex.set_height(256);
59        tex.set_seamless(true);
60        tex.set_as_normal_map(true);
61        tex.set_bump_strength(4.0);
62        tex.set_noise(&noise);
63        tex.upcast()
64    }
65
66    /// Build the proxy sphere child under `parent` and attach the water material.
67    pub fn create(parent: &mut Gd<Node3D>) -> Self {
68        let shader = godot::tools::load::<Shader>(WATER_SHADER);
69        let mut material = ShaderMaterial::new_gd();
70        material.set_shader(&shader);
71        // Two differently-seeded seamless normal maps => a livelier, non-repeating
72        // swell when the shader scrolls + RNM-blends them (Lague uses two maps).
73        let wave_a = Self::make_wave_normal_tex(1337, 0.015);
74        let wave_b = Self::make_wave_normal_tex(9001, 0.026);
75        material.set_shader_parameter("wave_normal_a", &wave_a.to_variant());
76        material.set_shader_parameter("wave_normal_b", &wave_b.to_variant());
77
78        let mut mesh = SphereMesh::new_gd();
79        // Coarse: the surface is analytic, so segments only affect silhouette
80        // coverage, not smoothness.
81        mesh.set_radial_segments(24);
82        mesh.set_rings(16);
83        mesh.set_material(&material);
84
85        let mut mmi = MeshInstance3D::new_alloc();
86        mmi.set_mesh(&mesh);
87        // GPU transforms aside, the proxy is a plain sphere; let it always draw
88        // (its own bounds are correct) and never cast shadows.
89        mmi.set_cast_shadows_setting(godot::classes::geometry_instance_3d::ShadowCastingSetting::OFF);
90        parent.add_child(&mmi);
91
92        Self { mmi, mesh, material, proxy_r: 0.0 }
93    }
94
95    /// Push per-frame state: planet world center, the live analytic water radius,
96    /// the look params, and visibility. Rebuilds the proxy sphere only when the
97    /// radius changed.
98    pub fn update(&mut self, center: Vector3, water_radius: f32, p: WaterParams, visible: bool) {
99        self.mmi.set_visible(visible);
100        if !visible {
101            return;
102        }
103        let pr = proxy_radius(water_radius);
104        if (pr - self.proxy_r).abs() > self.proxy_r.max(1.0) * 1e-3 {
105            self.mesh.set_radius(pr);
106            self.mesh.set_height(pr * 2.0);
107            self.proxy_r = pr;
108        }
109        self.mmi.set_global_position(center);
110
111        let m = &mut self.material;
112        m.set_shader_parameter("planet_center", &center.to_variant());
113        m.set_shader_parameter("water_radius", &water_radius.to_variant());
114        m.set_shader_parameter("deep_color", &p.deep_color.to_variant());
115        m.set_shader_parameter("shallow_color", &p.shallow_color.to_variant());
116        m.set_shader_parameter("wave_strength", &p.wave_strength.to_variant());
117        m.set_shader_parameter("wave_scale", &p.wave_scale.to_variant());
118        m.set_shader_parameter("wave_speed", &p.wave_speed.to_variant());
119        m.set_shader_parameter("underwater_color", &p.underwater_color.to_variant());
120        m.set_shader_parameter("underwater_density", &p.underwater_density.to_variant());
121        m.set_shader_parameter("sun_direction", &p.sun_dir.to_variant());
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    #[test]
128    fn proxy_margin_is_applied() {
129        // The proxy must strictly over-cover the analytic sphere.
130        assert!(crate::water::PROXY_MARGIN > 1.0);
131    }
132}