1use 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
34pub const GRAVITY: f64 = 9.81;
36
37pub const MAX_WAVES: usize = 16;
39
40#[derive(Debug, Clone, PartialEq)]
43pub struct OceanParams {
44 pub seed: i64,
46 pub num_waves: usize,
48 pub wavelength_min: f32,
50 pub wavelength_max: f32,
52 pub amplitude: f32,
54 pub choppiness: f32,
56 pub direction_deg: f32,
58 pub spread_deg: f32,
60 pub speed_scale: f32,
62 pub wind_speed: f32,
66 pub fetch_km: f32,
69 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 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
119fn 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 let omega_p = 22.0 * (g * g / (wind_speed * fetch_m)).powf(1.0 / 3.0);
131 let alpha = 0.076 * (wind_speed * wind_speed / (fetch_m * g)).powf(0.22);
133 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#[derive(Debug, Clone, Copy)]
142pub struct GerstnerWave {
143 pub dir: [f32; 2],
145 pub k: f32,
147 pub amp: f32,
149 pub omega: f64,
151 pub phase0: f64,
153 pub q: f32,
155}
156
157#[derive(Debug, Clone)]
159pub struct WaveSpectrum {
160 pub params: OceanParams,
161 pub waves: Vec<GerstnerWave>,
162}
163
164struct 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 fn next_f64(&mut self) -> f64 {
178 (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
179 }
180
181 fn range(&mut self, lo: f64, hi: f64) -> f64 {
183 lo + (hi - lo) * self.next_f64()
184 }
185}
186
187impl WaveSpectrum {
188 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 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 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]; 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() };
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 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 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 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 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 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 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 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 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 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 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(¶ms);
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 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 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 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 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 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}