Skip to main content

oxihuman_physics/
wind_force.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4//! Turbulent wind field with Simplex-like value noise for vortex and gusting effects.
5
6// ── Config / Field ────────────────────────────────────────────────────────────
7
8/// Configuration for a turbulent wind field.
9#[allow(dead_code)]
10#[derive(Debug, Clone)]
11pub struct WindConfig {
12    /// Normalised wind direction, e.g. `[1.0, 0.0, 0.0]`.
13    pub base_direction: [f32; 3],
14    /// Base wind speed in m/s, e.g. `5.0`.
15    pub base_speed: f32,
16    /// Turbulence intensity in 0..1, default `0.3`.
17    pub turbulence: f32,
18    /// Gusting frequency in Hz, default `0.5`.
19    pub gust_frequency: f32,
20    /// Rotational (vortex) component strength, default `0.2`.
21    pub vortex_strength: f32,
22    /// Seed for reproducible noise.
23    pub seed: u64,
24}
25
26impl Default for WindConfig {
27    fn default() -> Self {
28        Self {
29            base_direction: [1.0, 0.0, 0.0],
30            base_speed: 5.0,
31            turbulence: 0.3,
32            gust_frequency: 0.5,
33            vortex_strength: 0.2,
34            seed: 42,
35        }
36    }
37}
38
39/// A turbulent wind field that can be sampled at any world position and time.
40#[allow(dead_code)]
41#[derive(Debug, Clone)]
42pub struct WindField {
43    pub config: WindConfig,
44}
45
46/// A single wind measurement at a point in space and time.
47#[allow(dead_code)]
48#[derive(Debug, Clone)]
49pub struct WindSample {
50    /// World-space wind velocity at this point and time (m/s).
51    pub velocity: [f32; 3],
52    /// Dynamic pressure `= 0.5 * rho * v²`, where `rho = 1.225 kg/m³`.
53    pub pressure: f32,
54}
55
56// ── core noise ────────────────────────────────────────────────────────────────
57
58/// LCG hash: maps an integer to a pseudo-random u64.
59#[inline]
60fn lcg_hash(v: u64) -> u64 {
61    v.wrapping_mul(6_364_136_223_846_793_005)
62        .wrapping_add(1_442_695_040_888_963_407)
63}
64
65/// Hash three integer grid coordinates and a seed to a float in `-1..1`.
66#[inline]
67fn hash_corner(ix: i32, iy: i32, iz: i32, seed: u64) -> f32 {
68    let h = lcg_hash(lcg_hash(lcg_hash(seed ^ (ix as u64)) ^ (iy as u64)) ^ (iz as u64));
69    // map 0..u64::MAX → -1..1
70    (h as f32 / u64::MAX as f32) * 2.0 - 1.0
71}
72
73/// Smooth Hermite interpolation (`3t² - 2t³`).
74#[inline]
75fn smoothstep(t: f32) -> f32 {
76    t * t * (3.0 - 2.0 * t)
77}
78
79/// Trilinear interpolation of eight scalar values.
80#[allow(clippy::too_many_arguments)]
81#[inline]
82fn trilerp(
83    c000: f32,
84    c100: f32,
85    c010: f32,
86    c110: f32,
87    c001: f32,
88    c101: f32,
89    c011: f32,
90    c111: f32,
91    tx: f32,
92    ty: f32,
93    tz: f32,
94) -> f32 {
95    let c00 = c000 + tx * (c100 - c000);
96    let c10 = c010 + tx * (c110 - c010);
97    let c01 = c001 + tx * (c101 - c001);
98    let c11 = c011 + tx * (c111 - c011);
99    let c0 = c00 + ty * (c10 - c00);
100    let c1 = c01 + ty * (c11 - c01);
101    c0 + tz * (c1 - c0)
102}
103
104/// Smooth value noise in the range `-1..1` using LCG corner hashing and
105/// trilinear interpolation with Hermite smoothing.
106#[allow(dead_code)]
107pub fn value_noise_3d(x: f32, y: f32, z: f32, seed: u64) -> f32 {
108    let ix = x.floor() as i32;
109    let iy = y.floor() as i32;
110    let iz = z.floor() as i32;
111    let fx = smoothstep(x - ix as f32);
112    let fy = smoothstep(y - iy as f32);
113    let fz = smoothstep(z - iz as f32);
114
115    trilerp(
116        hash_corner(ix, iy, iz, seed),
117        hash_corner(ix + 1, iy, iz, seed),
118        hash_corner(ix, iy + 1, iz, seed),
119        hash_corner(ix + 1, iy + 1, iz, seed),
120        hash_corner(ix, iy, iz + 1, seed),
121        hash_corner(ix + 1, iy, iz + 1, seed),
122        hash_corner(ix, iy + 1, iz + 1, seed),
123        hash_corner(ix + 1, iy + 1, iz + 1, seed),
124        fx,
125        fy,
126        fz,
127    )
128}
129
130// ── dynamic pressure / Beaufort ───────────────────────────────────────────────
131
132/// Dynamic pressure `= 0.5 * 1.225 * speed²` (Pa).
133#[allow(dead_code)]
134pub fn dynamic_pressure(speed: f32) -> f32 {
135    0.5 * 1.225 * speed * speed
136}
137
138/// Convert wind speed (m/s) to Beaufort scale number (0..=12).
139#[allow(dead_code)]
140pub fn wind_speed_beaufort(speed_ms: f32) -> u8 {
141    // Beaufort scale thresholds (upper bound exclusive in m/s)
142    const THRESHOLDS: [f32; 13] = [
143        0.3,
144        1.6,
145        3.4,
146        5.5,
147        8.0,
148        10.8,
149        13.9,
150        17.2,
151        20.8,
152        24.5,
153        28.5,
154        32.7,
155        f32::INFINITY,
156    ];
157    for (i, &upper) in THRESHOLDS.iter().enumerate() {
158        if speed_ms < upper {
159            return i as u8;
160        }
161    }
162    12
163}
164
165// ── WindField impl ────────────────────────────────────────────────────────────
166
167impl WindField {
168    /// Create a new wind field with the given configuration.
169    #[allow(dead_code)]
170    pub fn new(config: WindConfig) -> Self {
171        Self { config }
172    }
173
174    /// Sample wind velocity and pressure at `position` and `time`.
175    ///
176    /// Combines a base flow with turbulence (value noise), sinusoidal gusting,
177    /// and a vortex component perpendicular to the base direction.
178    #[allow(dead_code)]
179    pub fn sample(&self, position: [f32; 3], time: f32) -> WindSample {
180        let cfg = &self.config;
181
182        // Turbulence: one noise sample per axis, scaled and offset by time
183        let scale = 0.5_f32;
184        let tx = value_noise_3d(
185            position[0] * scale + time * 0.1,
186            position[1] * scale,
187            position[2] * scale,
188            cfg.seed,
189        );
190        let ty = value_noise_3d(
191            position[0] * scale,
192            position[1] * scale + time * 0.1,
193            position[2] * scale,
194            cfg.seed.wrapping_add(1),
195        );
196        let tz = value_noise_3d(
197            position[0] * scale,
198            position[1] * scale,
199            position[2] * scale + time * 0.1,
200            cfg.seed.wrapping_add(2),
201        );
202
203        // Gusting: sinusoidal modulation of base speed
204        let gust =
205            1.0 + cfg.turbulence * (2.0 * std::f32::consts::PI * cfg.gust_frequency * time).sin();
206        let speed = cfg.base_speed * gust;
207
208        // Vortex: a vector perpendicular to base_direction
209        let bd = cfg.base_direction;
210        // Simple perpendicular: if bd is not [0,1,0], cross with [0,1,0]
211        let up = if bd[1].abs() < 0.9 {
212            [0.0_f32, 1.0, 0.0]
213        } else {
214            [1.0_f32, 0.0, 0.0]
215        };
216        let vortex = cross3(bd, up);
217
218        let vx = bd[0] * speed
219            + cfg.turbulence * tx * cfg.base_speed
220            + cfg.vortex_strength * vortex[0] * cfg.base_speed;
221        let vy = bd[1] * speed
222            + cfg.turbulence * ty * cfg.base_speed
223            + cfg.vortex_strength * vortex[1] * cfg.base_speed;
224        let vz = bd[2] * speed
225            + cfg.turbulence * tz * cfg.base_speed
226            + cfg.vortex_strength * vortex[2] * cfg.base_speed;
227
228        let velocity = [vx, vy, vz];
229        let spd = (vx * vx + vy * vy + vz * vz).sqrt();
230        let pressure = dynamic_pressure(spd);
231
232        WindSample { velocity, pressure }
233    }
234}
235
236// ── wind force helpers ────────────────────────────────────────────────────────
237
238#[inline]
239fn cross3(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
240    [
241        a[1] * b[2] - a[2] * b[1],
242        a[2] * b[0] - a[0] * b[2],
243        a[0] * b[1] - a[1] * b[0],
244    ]
245}
246
247#[inline]
248fn dot3(a: [f32; 3], b: [f32; 3]) -> f32 {
249    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
250}
251
252#[inline]
253#[allow(dead_code)]
254fn len3(a: [f32; 3]) -> f32 {
255    (a[0] * a[0] + a[1] * a[1] + a[2] * a[2]).sqrt()
256}
257
258/// Aerodynamic pressure force on a face.
259///
260/// `F = pressure * dot(v_norm, face_normal) * area * face_normal`
261/// Only applied when the wind has a component toward the face (`dot > 0`).
262#[allow(dead_code)]
263pub fn wind_force_on_face(wind: &WindSample, face_normal: [f32; 3], face_area: f32) -> [f32; 3] {
264    let spd =
265        (wind.velocity[0].powi(2) + wind.velocity[1].powi(2) + wind.velocity[2].powi(2)).sqrt();
266    if spd < 1e-9 {
267        return [0.0; 3];
268    }
269    let v_norm = [
270        wind.velocity[0] / spd,
271        wind.velocity[1] / spd,
272        wind.velocity[2] / spd,
273    ];
274    let d = dot3(v_norm, face_normal);
275    if d <= 0.0 {
276        return [0.0; 3];
277    }
278    let mag = wind.pressure * d * face_area;
279    [
280        mag * face_normal[0],
281        mag * face_normal[1],
282        mag * face_normal[2],
283    ]
284}
285
286/// Apply wind forces to every non-pinned particle in a cloth simulation.
287///
288/// For each particle, samples the wind at its position, computes a force as
289/// if the particle has a unit-area face with upward normal, then integrates:
290/// `velocity += force * dt / mass`.
291#[allow(dead_code)]
292pub fn apply_wind_to_cloth(
293    cloth: &mut super::cloth::ClothSim,
294    wind: &WindField,
295    time: f32,
296    dt: f32,
297) {
298    for particle in cloth.particles.iter_mut() {
299        if particle.pinned {
300            continue;
301        }
302        let ws = wind.sample(particle.position, time);
303        // Unit upward normal as a proxy for the cloth surface element
304        let normal = [0.0_f32, 1.0, 0.0];
305        let force = wind_force_on_face(&ws, normal, 1.0);
306        let inv_m = if particle.mass > 1e-9 {
307            1.0 / particle.mass
308        } else {
309            0.0
310        };
311        particle.position[0] += force[0] * dt * inv_m;
312        particle.position[1] += force[1] * dt * inv_m;
313        particle.position[2] += force[2] * dt * inv_m;
314    }
315}
316
317// ── tests ─────────────────────────────────────────────────────────────────────
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322    use crate::cloth::{ClothParticle, ClothSim, Spring};
323
324    fn default_config() -> WindConfig {
325        WindConfig::default()
326    }
327
328    // 1. value_noise_3d is deterministic
329    #[test]
330    fn noise_deterministic() {
331        let a = value_noise_3d(1.5, 2.3, 0.7, 42);
332        let b = value_noise_3d(1.5, 2.3, 0.7, 42);
333        assert_eq!(a, b);
334    }
335
336    // 2. different seeds produce different results
337    #[test]
338    fn noise_seed_differs() {
339        let a = value_noise_3d(1.0, 1.0, 1.0, 0);
340        let b = value_noise_3d(1.0, 1.0, 1.0, 99);
341        assert_ne!(a, b);
342    }
343
344    // 3. output is within -1..1
345    #[test]
346    fn noise_range() {
347        for i in 0..20 {
348            let v = value_noise_3d(i as f32 * 0.37, i as f32 * 0.13, i as f32 * 0.71, 7);
349            assert!((-1.0..=1.0).contains(&v), "out of range: {v}");
350        }
351    }
352
353    // 4. nearby samples are close (smoothness)
354    #[test]
355    fn noise_smooth() {
356        let a = value_noise_3d(1.0, 1.0, 1.0, 1);
357        let b = value_noise_3d(1.001, 1.0, 1.0, 1);
358        assert!((a - b).abs() < 0.01, "not smooth: diff={}", (a - b).abs());
359    }
360
361    // 5. dynamic_pressure formula
362    #[test]
363    fn dynamic_pressure_formula() {
364        let p = dynamic_pressure(10.0);
365        let expected = 0.5 * 1.225 * 100.0;
366        assert!((p - expected).abs() < 1e-4, "got {p}, expected {expected}");
367    }
368
369    // 6. dynamic_pressure zero at zero speed
370    #[test]
371    fn dynamic_pressure_zero() {
372        assert_eq!(dynamic_pressure(0.0), 0.0);
373    }
374
375    // 7. Beaufort calm (< 0.3 m/s) → 0
376    #[test]
377    fn beaufort_calm() {
378        assert_eq!(wind_speed_beaufort(0.0), 0);
379        assert_eq!(wind_speed_beaufort(0.2), 0);
380    }
381
382    // 8. Beaufort light breeze 1.6..3.4 → 2
383    #[test]
384    fn beaufort_breeze() {
385        assert_eq!(wind_speed_beaufort(2.5), 2);
386    }
387
388    // 9. Beaufort gale ≥ 17.2 m/s → 8
389    #[test]
390    fn beaufort_gale() {
391        assert_eq!(wind_speed_beaufort(18.0), 8);
392    }
393
394    // 10. Beaufort max: 33 m/s → 12
395    #[test]
396    fn beaufort_max() {
397        assert_eq!(wind_speed_beaufort(33.0), 12);
398    }
399
400    // 11. wind_force_on_face zero when backfacing
401    #[test]
402    fn wind_force_backfacing_zero() {
403        let ws = WindSample {
404            velocity: [1.0, 0.0, 0.0],
405            pressure: dynamic_pressure(1.0),
406        };
407        // normal points away from wind
408        let f = wind_force_on_face(&ws, [-1.0, 0.0, 0.0], 1.0);
409        assert_eq!(f, [0.0; 3]);
410    }
411
412    // 12. wind_force_on_face magnitude proportional to area
413    #[test]
414    fn wind_force_proportional_to_area() {
415        let ws = WindSample {
416            velocity: [1.0, 0.0, 0.0],
417            pressure: dynamic_pressure(1.0),
418        };
419        let f1 = wind_force_on_face(&ws, [1.0, 0.0, 0.0], 1.0);
420        let f2 = wind_force_on_face(&ws, [1.0, 0.0, 0.0], 2.0);
421        let mag1 = len3(f1);
422        let mag2 = len3(f2);
423        assert!((mag2 - 2.0 * mag1).abs() < 1e-5, "mag1={mag1}, mag2={mag2}");
424    }
425
426    // 13. WindSample pressure matches speed via dynamic_pressure
427    #[test]
428    fn wind_sample_pressure_matches_speed() {
429        let cfg = default_config();
430        let field = WindField::new(cfg);
431        let ws = field.sample([0.0, 0.0, 0.0], 0.0);
432        let spd = len3(ws.velocity);
433        let expected = dynamic_pressure(spd);
434        assert!((ws.pressure - expected).abs() < 1e-3, "pressure mismatch");
435    }
436
437    // 14. sample at different times gives different velocities
438    #[test]
439    fn sample_time_varying() {
440        let field = WindField::new(WindConfig {
441            turbulence: 0.5,
442            gust_frequency: 1.0,
443            ..WindConfig::default()
444        });
445        let s0 = field.sample([0.0; 3], 0.0);
446        let s1 = field.sample([0.0; 3], 1.0);
447        // At t=0 and t=1 the gust sinusoid differs, so velocities should differ
448        let diff = len3([
449            s0.velocity[0] - s1.velocity[0],
450            s0.velocity[1] - s1.velocity[1],
451            s0.velocity[2] - s1.velocity[2],
452        ]);
453        assert!(diff > 1e-4, "time-varying expected, diff={diff}");
454    }
455
456    // 15. apply_wind_to_cloth runs without panic on small cloth
457    #[test]
458    fn apply_wind_no_panic() {
459        let mut sim = ClothSim {
460            particles: vec![
461                ClothParticle::new([0.0, 0.0, 0.0], 0.1),
462                ClothParticle::new([0.1, 0.0, 0.0], 0.1),
463            ],
464            springs: vec![Spring {
465                a: 0,
466                b: 1,
467                rest_length: 0.1,
468                stiffness: 100.0,
469                kind: crate::cloth::SpringKind::Structural,
470            }],
471            gravity: [0.0, -9.81, 0.0],
472            damping: 0.99,
473        };
474        let field = WindField::new(WindConfig::default());
475        apply_wind_to_cloth(&mut sim, &field, 0.0, 0.016);
476        // Simply assert positions are finite
477        for p in &sim.particles {
478            assert!(p.position[0].is_finite());
479        }
480    }
481}