Flint Engine / Guide / API Reference

flint_core/
ocean.rs

1//! Gerstner ocean wave spectrum — the single source of truth for wave math.
2//!
3//! The CPU generates a deterministic, seeded spectrum of trochoidal (Gerstner)
4//! waves with deep-water dispersion. The renderer uploads the *same* wave
5//! array to the GPU (see `flint-render/src/ocean_shader.wgsl`), which only
6//! sums it — so a `sample_height()` query and the rendered surface can never
7//! disagree, which is what makes script-driven buoyancy believable.
8//!
9//! Per-frame wave phases are computed here in f64 and reduced mod 2π before
10//! upload, so f32 `sin` precision never degrades over long sessions.
11//!
12//! Conventions: Y is up; waves displace in XZ. For wave *i* with direction
13//! `d`, wavenumber `k = 2π/λ`, amplitude `A`, steepness `Q`, and phase
14//! `θ = k·(d·p) − ω·t + φ₀`:
15//!
16//! ```text
17//! offset.xz = Σ Q·A·d·cos θ        offset.y = Σ A·sin θ
18//! normal    = normalize(−Σ d.x·k·A·cos θ,  1 − Σ Q·k·A·sin θ,  −Σ d.y·k·A·cos θ)
19//! ```
20//!
21//! Steepness is normalized (`Qᵢ = choppiness / (kᵢ·Aᵢ·N)`) so the summed
22//! steepness never exceeds `choppiness ≤ 1` — no self-intersecting loops.
23
24use crate::toml_util::toml_f32;
25
26fn toml_i64(v: &toml::Value) -> Option<i64> {
27    match v {
28        toml::Value::Integer(i) => Some(*i),
29        toml::Value::Float(f) => Some(*f as i64),
30        _ => None,
31    }
32}
33
34/// Gravitational acceleration for deep-water dispersion ω = √(g·k).
35pub const GRAVITY: f64 = 9.81;
36
37/// Maximum waves in a spectrum (mirrors the WGSL uniform array size).
38pub const MAX_WAVES: usize = 16;
39
40/// Simulation parameters — everything that shapes the spectrum.
41/// These map 1:1 to the `ocean` component fields (see schemas/components/ocean.toml).
42#[derive(Debug, Clone, PartialEq)]
43pub struct OceanParams {
44    /// RNG seed; same seed + params → identical spectrum.
45    pub seed: i64,
46    /// Number of waves (clamped to 1..=MAX_WAVES).
47    pub num_waves: usize,
48    /// Shortest wavelength in meters.
49    pub wavelength_min: f32,
50    /// Longest wavelength in meters.
51    pub wavelength_max: f32,
52    /// Total wave amplitude in meters: Σ Aᵢ (max possible crest height).
53    pub amplitude: f32,
54    /// 0 = rolling swells, 1 = maximum-sharp trochoids (pre-cusp).
55    pub choppiness: f32,
56    /// Wind/primary travel direction in degrees (0 = +Z, 90 = +X).
57    pub direction_deg: f32,
58    /// Directional spread in degrees; small waves wander more than large.
59    pub spread_deg: f32,
60    /// Multiplier on time — a "becalmed" slider (1 = physical speed).
61    pub speed_scale: f32,
62    /// Wind speed in m/s — shapes the JONSWAP energy distribution
63    /// (which wavelengths carry the sea's energy). Total height stays
64    /// governed by `amplitude`; this shifts WHERE that height lives.
65    pub wind_speed: f32,
66    /// Wind fetch in kilometers (how far the wind has blown over open
67    /// water). Longer fetch → energy concentrates in longer swells.
68    pub fetch_km: f32,
69    /// JONSWAP peak-enhancement γ: 1 = broad Pierson-Moskowitz sea,
70    /// 3.3 = typical North Sea, higher = narrow single-swell character.
71    pub peak_enhancement: f32,
72}
73
74impl Default for OceanParams {
75    fn default() -> Self {
76        Self {
77            seed: 7,
78            num_waves: 12,
79            wavelength_min: 4.0,
80            wavelength_max: 55.0,
81            amplitude: 0.85,
82            choppiness: 0.7,
83            direction_deg: 25.0,
84            spread_deg: 40.0,
85            speed_scale: 1.0,
86            wind_speed: 7.0,
87            fetch_km: 60.0,
88            peak_enhancement: 3.3,
89        }
90    }
91}
92
93impl OceanParams {
94    /// Read params from an `ocean` component TOML table; missing fields keep defaults.
95    pub fn from_component(value: &toml::Value) -> Self {
96        let d = Self::default();
97        let f = |name: &str, dv: f32| value.get(name).and_then(toml_f32).unwrap_or(dv);
98        Self {
99            seed: value.get("seed").and_then(toml_i64).unwrap_or(d.seed),
100            num_waves: value
101                .get("num_waves")
102                .and_then(toml_i64)
103                .map(|n| n.clamp(1, MAX_WAVES as i64) as usize)
104                .unwrap_or(d.num_waves),
105            wavelength_min: f("wavelength_min", d.wavelength_min).max(0.5),
106            wavelength_max: f("wavelength_max", d.wavelength_max).max(1.0),
107            amplitude: f("amplitude", d.amplitude).max(0.0),
108            choppiness: f("choppiness", d.choppiness).clamp(0.0, 1.0),
109            direction_deg: f("direction_deg", d.direction_deg),
110            spread_deg: f("spread_deg", d.spread_deg).clamp(0.0, 180.0),
111            speed_scale: f("speed_scale", d.speed_scale).clamp(0.0, 8.0),
112            wind_speed: f("wind_speed", d.wind_speed).clamp(0.5, 40.0),
113            fetch_km: f("fetch_km", d.fetch_km).clamp(1.0, 2000.0),
114            peak_enhancement: f("peak_enhancement", d.peak_enhancement).clamp(1.0, 10.0),
115        }
116    }
117}
118
119/// JONSWAP spectral density S(ω) — energy per unit angular frequency for a
120/// wind-driven sea (Hasselmann et al., Joint North Sea Wave Project).
121/// Absolute scale is irrelevant here (amplitudes are renormalized to the
122/// user's `amplitude`); what matters is the SHAPE: a sharp peak at ωₚ set
123/// by wind speed + fetch, a steep low-frequency cutoff, and an ω⁻⁵ tail.
124fn jonswap_density(omega: f64, wind_speed: f64, fetch_m: f64, gamma: f64) -> f64 {
125    if omega <= 1e-6 {
126        return 0.0;
127    }
128    let g = GRAVITY;
129    // Peak angular frequency from wind speed U and fetch F.
130    let omega_p = 22.0 * (g * g / (wind_speed * fetch_m)).powf(1.0 / 3.0);
131    // Phillips "constant" with fetch dependence.
132    let alpha = 0.076 * (wind_speed * wind_speed / (fetch_m * g)).powf(0.22);
133    // Peak-enhancement exponent (σ narrower below the peak than above).
134    let sigma = if omega <= omega_p { 0.07 } else { 0.09 };
135    let r = (-((omega - omega_p) * (omega - omega_p)) / (2.0 * sigma * sigma * omega_p * omega_p))
136        .exp();
137    alpha * g * g / omega.powi(5) * (-1.25 * (omega_p / omega).powi(4)).exp() * gamma.powf(r)
138}
139
140/// One Gerstner wave. All fields precomputed at spectrum generation.
141#[derive(Debug, Clone, Copy)]
142pub struct GerstnerWave {
143    /// Unit travel direction in XZ.
144    pub dir: [f32; 2],
145    /// Wavenumber k = 2π/λ.
146    pub k: f32,
147    /// Amplitude in meters.
148    pub amp: f32,
149    /// Angular frequency ω = √(g·k) (rad/s), stored f64 for phase precision.
150    pub omega: f64,
151    /// Initial phase offset φ₀ in radians.
152    pub phase0: f64,
153    /// Steepness Q (already normalized against the whole spectrum).
154    pub q: f32,
155}
156
157/// A generated spectrum plus the params that produced it.
158#[derive(Debug, Clone)]
159pub struct WaveSpectrum {
160    pub params: OceanParams,
161    pub waves: Vec<GerstnerWave>,
162}
163
164/// SplitMix64 — tiny deterministic RNG, no dependencies.
165struct SplitMix64(u64);
166
167impl SplitMix64 {
168    fn next_u64(&mut self) -> u64 {
169        self.0 = self.0.wrapping_add(0x9E3779B97F4A7C15);
170        let mut z = self.0;
171        z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
172        z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
173        z ^ (z >> 31)
174    }
175
176    /// Uniform in [0, 1).
177    fn next_f64(&mut self) -> f64 {
178        (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
179    }
180
181    /// Uniform in [lo, hi).
182    fn range(&mut self, lo: f64, hi: f64) -> f64 {
183        lo + (hi - lo) * self.next_f64()
184    }
185}
186
187impl WaveSpectrum {
188    /// Deterministically generate a spectrum from params.
189    pub fn generate(params: &OceanParams) -> Self {
190        let n = params.num_waves.clamp(1, MAX_WAVES);
191        let mut rng = SplitMix64(params.seed as u64 ^ 0xF10A7);
192        let lmin = params.wavelength_min.min(params.wavelength_max) as f64;
193        let lmax = params.wavelength_max.max(params.wavelength_min) as f64;
194        let wind = (params.direction_deg as f64).to_radians();
195        let spread = (params.spread_deg as f64).to_radians();
196
197        let wind_speed = params.wind_speed as f64;
198        let fetch_m = params.fetch_km as f64 * 1000.0;
199        let gamma = params.peak_enhancement as f64;
200
201        let mut waves = Vec::with_capacity(n);
202        let mut amp_sum = 0.0_f64;
203        for i in 0..n {
204            // Log-spaced wavelengths with jitter: even coverage of the octaves.
205            let u = if n == 1 {
206                0.5
207            } else {
208                i as f64 / (n - 1) as f64
209            };
210            let jitter = rng.range(-0.5, 0.5) / n.max(2) as f64;
211            let lambda = lmin * (lmax / lmin).powf((u + jitter).clamp(0.0, 1.0));
212            let k = std::f64::consts::TAU / lambda;
213
214            // Longer waves hew to the wind; short chop wanders across the
215            // full spread. `u` runs small→large wavelength, so weight by it.
216            let wander = 1.0 - 0.65 * u;
217            let angle = wind + rng.range(-1.0, 1.0) * spread * wander;
218            let dir = [angle.sin() as f32, angle.cos() as f32]; // 0° = +Z
219
220            // Amplitude from the JONSWAP energy in this wave's frequency bin
221            // (A²/2 = S(ω)·Δω), so energy concentrates around the wind/fetch
222            // peak instead of spreading evenly. The whole set is normalized
223            // to Σ A = params.amplitude below — JONSWAP shapes WHERE the
224            // height lives, `amplitude` still says how much there is.
225            let half_bin = 0.5 / n.max(2) as f64;
226            let edge = |uu: f64| -> f64 {
227                let l = lmin * (lmax / lmin).powf(uu.clamp(0.0, 1.0));
228                (GRAVITY * std::f64::consts::TAU / l).sqrt() // ω = √(g·k)
229            };
230            let d_omega = (edge(u - half_bin) - edge(u + half_bin)).abs().max(1e-6);
231            let omega_center = (GRAVITY * k).sqrt();
232            let energy = jonswap_density(omega_center, wind_speed, fetch_m, gamma) * d_omega;
233            let amp = (2.0 * energy).sqrt() * rng.range(0.9, 1.1);
234
235            // omega derives from the f32-rounded k so stored fields are
236            // exactly consistent with what the GPU receives.
237            let k32 = k as f32;
238            waves.push(GerstnerWave {
239                dir,
240                k: k32,
241                amp: amp as f32,
242                omega: (GRAVITY * k32 as f64).sqrt(),
243                phase0: rng.range(0.0, std::f64::consts::TAU),
244                q: 0.0,
245            });
246            amp_sum += amp;
247        }
248
249        // Normalize amplitudes to the requested total, then distribute
250        // steepness by ENERGY SHARE: Qᵢ = choppiness/(kᵢ·ΣA), which keeps
251        // Σ Qᵢ·kᵢ·Aᵢ = choppiness (≤ 1 ⇒ no cusps/loops) while making each
252        // wave's horizontal pinch Qᵢ·Aᵢ ∝ Aᵢ. (An equal per-wave split gave
253        // every wave the same pinch regardless of amplitude, so JONSWAP's
254        // near-dead short waves foamed the whole surface into mist.)
255        let amp_scale = if amp_sum > 0.0 {
256            params.amplitude as f64 / amp_sum
257        } else {
258            0.0
259        };
260        let amp_total = params.amplitude.max(1e-6);
261        for w in &mut waves {
262            w.amp = (w.amp as f64 * amp_scale) as f32;
263            w.q = if w.k > 1e-6 {
264                params.choppiness / (w.k * amp_total)
265            } else {
266                0.0
267            };
268        }
269
270        Self {
271            params: params.clone(),
272            waves,
273        }
274    }
275
276    /// Per-wave phase `ωᵢ·t − φ₀ᵢ`, computed in f64 and wrapped to [0, 2π).
277    /// The shader (and the CPU sampler) evaluate θ = k·(d·p) − phase_t.
278    pub fn phases_at(&self, time: f64) -> Vec<f32> {
279        let t = time * self.params.speed_scale as f64;
280        self.waves
281            .iter()
282            .map(|w| ((w.omega * t - w.phase0).rem_euclid(std::f64::consts::TAU)) as f32)
283            .collect()
284    }
285
286    /// Lagrangian Gerstner offset of the parameter point (x, z) at the given
287    /// pre-computed phases. Returns [dx, dy, dz].
288    pub fn displacement(&self, x: f32, z: f32, phases: &[f32]) -> [f32; 3] {
289        let mut off = [0.0_f32; 3];
290        for (w, &ph) in self.waves.iter().zip(phases) {
291            let theta = w.k * (w.dir[0] * x + w.dir[1] * z) - ph;
292            let (s, c) = theta.sin_cos();
293            off[0] += w.q * w.amp * w.dir[0] * c;
294            off[1] += w.amp * s;
295            off[2] += w.q * w.amp * w.dir[1] * c;
296        }
297        off
298    }
299
300    /// Surface normal at parameter point (x, z).
301    pub fn normal(&self, x: f32, z: f32, phases: &[f32]) -> [f32; 3] {
302        let mut nx = 0.0_f32;
303        let mut ny = 1.0_f32;
304        let mut nz = 0.0_f32;
305        for (w, &ph) in self.waves.iter().zip(phases) {
306            let theta = w.k * (w.dir[0] * x + w.dir[1] * z) - ph;
307            let (s, c) = theta.sin_cos();
308            let ka = w.k * w.amp;
309            nx -= w.dir[0] * ka * c;
310            ny -= w.q * ka * s;
311            nz -= w.dir[1] * ka * c;
312        }
313        let len = (nx * nx + ny * ny + nz * nz).sqrt().max(1e-6);
314        [nx / len, ny / len, nz / len]
315    }
316
317    /// Eulerian surface height at *world* (x, z) — what buoyancy wants.
318    ///
319    /// Gerstner is Lagrangian (it moves particles horizontally), so we invert
320    /// the horizontal displacement by fixed-point iteration: find the
321    /// parameter point p whose displaced position lands on (x, z). Converges
322    /// because Σ Q·k·A ≤ 1 keeps the map a contraction.
323    pub fn sample_height(&self, x: f32, z: f32, time: f64) -> f32 {
324        let phases = self.phases_at(time);
325        self.sample_height_with_phases(x, z, &phases)
326    }
327
328    /// Like [`sample_height`], reusing precomputed phases (batch queries).
329    pub fn sample_height_with_phases(&self, x: f32, z: f32, phases: &[f32]) -> f32 {
330        let (px, pz) = self.invert_displacement(x, z, phases);
331        self.displacement(px, pz, phases)[1]
332    }
333
334    /// Eulerian surface normal at world (x, z).
335    pub fn sample_normal(&self, x: f32, z: f32, time: f64) -> [f32; 3] {
336        let phases = self.phases_at(time);
337        let (px, pz) = self.invert_displacement(x, z, phases.as_slice());
338        self.normal(px, pz, &phases)
339    }
340
341    /// Vertical velocity (m/s) of the surface at world (x, z): ∂/∂t of the
342    /// height at the inverted parameter point. y = Σ A·sinθ with θ advancing
343    /// at ω·speed_scale, so dy/dt = −Σ A·ω_eff·cosθ. The parameter-drift term
344    /// (Q·horizontal motion) is omitted — exact at choppiness 0, bounded by
345    /// the steepness budget otherwise. Intended for audio/gameplay triggers.
346    pub fn sample_velocity_y(&self, x: f32, z: f32, time: f64) -> f32 {
347        let phases = self.phases_at(time);
348        let (px, pz) = self.invert_displacement(x, z, &phases);
349        let speed = self.params.speed_scale as f64;
350        let mut vy = 0.0_f32;
351        for (w, &ph) in self.waves.iter().zip(&phases) {
352            let theta = w.k * (w.dir[0] * px + w.dir[1] * pz) - ph;
353            vy -= w.amp * (w.omega * speed) as f32 * theta.cos();
354        }
355        vy
356    }
357
358    fn invert_displacement(&self, x: f32, z: f32, phases: &[f32]) -> (f32, f32) {
359        let mut px = x;
360        let mut pz = z;
361        for _ in 0..4 {
362            let d = self.displacement(px, pz, phases);
363            px = x - d[0];
364            pz = z - d[2];
365        }
366        (px, pz)
367    }
368
369    /// Pack waves for the GPU: two vec4s per wave slot, MAX_WAVES slots.
370    /// Slot layout: `[dir.x, dir.y, k, amp]`, `[phase_t, q, omega_eff, 0]`.
371    /// omega_eff = ω·speed_scale — the rate phase_t actually advances at,
372    /// so the shader's analytic surface velocity (contact foam) matches the
373    /// animation. Unused slots are zero (amp 0 contributes nothing).
374    pub fn to_gpu(&self, time: f64) -> [[f32; 4]; MAX_WAVES * 2] {
375        let phases = self.phases_at(time);
376        let speed = self.params.speed_scale as f64;
377        let mut out = [[0.0_f32; 4]; MAX_WAVES * 2];
378        for (i, (w, &ph)) in self.waves.iter().zip(&phases).enumerate().take(MAX_WAVES) {
379            out[i * 2] = [w.dir[0], w.dir[1], w.k, w.amp];
380            out[i * 2 + 1] = [ph, w.q, (w.omega * speed) as f32, 0.0];
381        }
382        out
383    }
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389
390    fn spectrum() -> WaveSpectrum {
391        WaveSpectrum::generate(&OceanParams::default())
392    }
393
394    #[test]
395    fn dispersion_relation_holds() {
396        for w in &spectrum().waves {
397            let expected = (GRAVITY * w.k as f64).sqrt();
398            assert!(
399                (w.omega - expected).abs() < 1e-9,
400                "omega {} != sqrt(g*k) {}",
401                w.omega,
402                expected
403            );
404        }
405    }
406
407    #[test]
408    fn steepness_sum_bounded_by_choppiness() {
409        let params = OceanParams {
410            choppiness: 1.0,
411            ..Default::default()
412        };
413        let spec = WaveSpectrum::generate(&params);
414        let sum: f32 = spec.waves.iter().map(|w| w.q * w.k * w.amp).sum();
415        assert!(sum <= 1.0 + 1e-4, "steepness sum {} exceeds 1", sum);
416        assert!(sum >= 0.99, "steepness sum {} should reach choppiness", sum);
417    }
418
419    #[test]
420    fn amplitude_sum_matches_param() {
421        let spec = spectrum();
422        let sum: f32 = spec.waves.iter().map(|w| w.amp).sum();
423        assert!(
424            (sum - spec.params.amplitude).abs() < 1e-4,
425            "amp sum {} != requested {}",
426            sum,
427            spec.params.amplitude
428        );
429    }
430
431    #[test]
432    fn deterministic_for_seed() {
433        let a = spectrum();
434        let b = spectrum();
435        for (wa, wb) in a.waves.iter().zip(&b.waves) {
436            assert_eq!(wa.dir, wb.dir);
437            assert_eq!(wa.amp, wb.amp);
438            assert_eq!(wa.phase0, wb.phase0);
439        }
440        let c = WaveSpectrum::generate(&OceanParams {
441            seed: 8,
442            ..Default::default()
443        });
444        assert!(a.waves[0].phase0 != c.waves[0].phase0);
445    }
446
447    #[test]
448    fn height_bounded_by_total_amplitude() {
449        let spec = spectrum();
450        let bound = spec.params.amplitude + 1e-3;
451        for i in 0..200 {
452            let x = (i as f32 * 7.3) % 300.0 - 150.0;
453            let z = (i as f32 * 3.9) % 300.0 - 150.0;
454            let h = spec.sample_height(x, z, i as f64 * 0.37);
455            assert!(h.abs() <= bound, "height {} out of bound at ({x},{z})", h);
456        }
457    }
458
459    #[test]
460    fn eulerian_inversion_converges() {
461        // The displaced parameter point must land back on the queried column.
462        let spec = spectrum();
463        let phases = spec.phases_at(11.7);
464        for i in 0..100 {
465            let x = (i as f32 * 5.1) % 200.0 - 100.0;
466            let z = (i as f32 * 9.7) % 200.0 - 100.0;
467            let (px, pz) = spec.invert_displacement(x, z, &phases);
468            let d = spec.displacement(px, pz, &phases);
469            let err = ((px + d[0] - x).powi(2) + (pz + d[2] - z).powi(2)).sqrt();
470            assert!(err < 0.02, "inversion residual {} at ({x},{z})", err);
471        }
472    }
473
474    #[test]
475    fn gpu_packing_matches_cpu_evaluation() {
476        // Transliterate the WGSL summation against the packed array and
477        // compare with the CPU displacement/normal — the parity contract.
478        let spec = spectrum();
479        let t = 42.31;
480        let gpu = spec.to_gpu(t);
481        let phases = spec.phases_at(t);
482
483        let eval_gpu = |x: f32, z: f32| -> ([f32; 3], [f32; 3]) {
484            let mut off = [0.0_f32; 3];
485            let mut n = [0.0_f32, 1.0, 0.0];
486            for i in 0..MAX_WAVES {
487                let a = gpu[i * 2];
488                let b = gpu[i * 2 + 1];
489                let (dx, dy, k, amp) = (a[0], a[1], a[2], a[3]);
490                let (ph, q) = (b[0], b[1]);
491                if amp <= 0.0 {
492                    continue;
493                }
494                let theta = k * (dx * x + dy * z) - ph;
495                let (s, c) = theta.sin_cos();
496                off[0] += q * amp * dx * c;
497                off[1] += amp * s;
498                off[2] += q * amp * dy * c;
499                n[0] -= dx * k * amp * c;
500                n[1] -= q * k * amp * s;
501                n[2] -= dy * k * amp * c;
502            }
503            let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
504            (off, [n[0] / len, n[1] / len, n[2] / len])
505        };
506
507        for i in 0..100 {
508            let x = (i as f32 * 13.7) % 400.0 - 200.0;
509            let z = (i as f32 * 6.1) % 400.0 - 200.0;
510            let (goff, gn) = eval_gpu(x, z);
511            let coff = spec.displacement(x, z, &phases);
512            let cn = spec.normal(x, z, &phases);
513            for a in 0..3 {
514                assert!(
515                    (goff[a] - coff[a]).abs() < 1e-4,
516                    "offset[{a}] diverges: {} vs {}",
517                    goff[a],
518                    coff[a]
519                );
520                assert!(
521                    (gn[a] - cn[a]).abs() < 1e-4,
522                    "normal[{a}] diverges: {} vs {}",
523                    gn[a],
524                    cn[a]
525                );
526            }
527        }
528    }
529
530    #[test]
531    fn velocity_y_matches_finite_difference() {
532        // Central difference of sample_height vs the analytic velocity.
533        // At choppiness 0 the omitted parameter-drift term vanishes → tight.
534        // At default choppiness the drift term bounds the error loosely.
535        let check = |choppiness: f32, abs_tol: f32, rel_tol: f32| {
536            let spec = WaveSpectrum::generate(&OceanParams {
537                choppiness,
538                ..Default::default()
539            });
540            let eps = 1e-3;
541            for i in 0..100 {
542                let x = (i as f32 * 5.1) % 200.0 - 100.0;
543                let z = (i as f32 * 9.7) % 200.0 - 100.0;
544                let t = 3.0 + i as f64 * 0.41;
545                let fd = (spec.sample_height(x, z, t + eps) as f64
546                    - spec.sample_height(x, z, t - eps) as f64)
547                    / (2.0 * eps);
548                let vy = spec.sample_velocity_y(x, z, t) as f64;
549                let err = (vy - fd).abs();
550                let tol = (abs_tol as f64).max(rel_tol as f64 * fd.abs());
551                assert!(
552                    err < tol,
553                    "velocity {} vs finite diff {} (err {}, chop {})",
554                    vy,
555                    fd,
556                    err,
557                    choppiness
558                );
559            }
560        };
561        check(0.0, 5e-3, 0.0);
562        check(OceanParams::default().choppiness, 0.25, 0.30);
563    }
564
565    #[test]
566    fn jonswap_peak_dominates_the_spectrum() {
567        // Defaults: wind 7 m/s, fetch 60 km → ωₚ ≈ 1.35 rad/s (λₚ ≈ 34 m).
568        // The largest-amplitude wave must sit near that peak, not at the
569        // band edges (the old A ∝ λ rule always crowned the longest wave).
570        let spec = spectrum();
571        let omega_p = 22.0
572            * (GRAVITY * GRAVITY
573                / (spec.params.wind_speed as f64 * spec.params.fetch_km as f64 * 1000.0))
574                .powf(1.0 / 3.0);
575        let biggest = spec
576            .waves
577            .iter()
578            .max_by(|a, b| a.amp.partial_cmp(&b.amp).unwrap())
579            .unwrap();
580        let ratio = biggest.omega / omega_p;
581        assert!(
582            (0.6..=1.6).contains(&ratio),
583            "dominant wave ω {} not near JONSWAP peak {} (ratio {})",
584            biggest.omega,
585            omega_p,
586            ratio
587        );
588    }
589
590    #[test]
591    fn stronger_wind_shifts_energy_to_longer_waves() {
592        let calm = WaveSpectrum::generate(&OceanParams {
593            wind_speed: 3.0,
594            ..Default::default()
595        });
596        let storm = WaveSpectrum::generate(&OceanParams {
597            wind_speed: 20.0,
598            ..Default::default()
599        });
600        let mean_lambda = |s: &WaveSpectrum| -> f32 {
601            let total: f32 = s.waves.iter().map(|w| w.amp).sum();
602            s.waves
603                .iter()
604                .map(|w| (std::f32::consts::TAU / w.k) * w.amp / total)
605                .sum()
606        };
607        assert!(
608            mean_lambda(&storm) > mean_lambda(&calm),
609            "storm sea should carry its energy in longer waves"
610        );
611    }
612
613    #[test]
614    fn phases_wrap_and_stay_finite_over_long_sessions() {
615        let spec = spectrum();
616        // 100 hours of session time: phases must remain in [0, 2π).
617        for &t in &[0.0, 60.0, 3600.0, 360_000.0] {
618            for p in spec.phases_at(t) {
619                assert!(p.is_finite());
620                assert!((0.0..std::f32::consts::TAU + 1e-3).contains(&p));
621            }
622        }
623    }
624}