Skip to main content

alice_physics/
erosion.rs

1//! Erosion Simulation Modifier
2//!
3//! Surface material removal from sustained forces:
4//! - **Wind Erosion**: Airflow strips exposed surfaces
5//! - **Water Erosion**: Flowing water carves channels
6//! - **Chemical Corrosion**: Reactive agents eat material
7//! - **Ablation**: High-speed flow strips surface layer
8//!
9//! # Physics Model
10//!
11//! Empirical rate law, `depth += rate · (1 − hardness) · vⁿ · exposure · dt`,
12//! capped at `max_depth`; accumulated erosion increases the SDF distance
13//! (surface recedes). The per-type constants are fixed in this version:
14//!
15//! | type | `n` | prefactor | note |
16//! |---|---|---|---|
17//! | `Wind` | 1 | 1 | linear in flow speed |
18//! | `Water` | 1 | 1.5 | water carries more momentum per unit speed |
19//! | `Chemical` | 0 | 1 | speed-independent attack |
20//! | `Ablation` | 2 | 1 | kinetic-energy flux |
21//!
22//! Solid-particle erosion measurements give `n ≈ 2–3` (Finnie 1960,
23// LIMITATION(COV-FRACT-085): so `Wind` / `Water` are game-tuned rather than validated
24//! Bitter 1963), so `Wind` / `Water` are game-tuned rather than validated;
25//! `exposure` is re-read every frame and decays with a fixed `5 /s` when the
26//! caller stops supplying it. Making `n`, the prefactor and the exposure
27//! decay configurable adds fields to [`ErosionConfig`] and is scheduled for
28//! 2.0 (see `docs/ROADMAP.md`).
29//!
30//! Author: Moroya Sakamoto
31
32use crate::math::Fix128;
33use crate::sim_field::ScalarField3D;
34use crate::sim_modifier::PhysicsModifier;
35use crate::sim_modifier::{observe_max, observe_sum, StateReader, StateWriter};
36use crate::world_participant::{
37    ObservationSink, Participant, ParticipantFault, ParticipantKind, StateError, SubstepCtx,
38};
39
40// ============================================================================
41// Configuration
42// ============================================================================
43
44/// Erosion type
45#[derive(Clone, Copy, Debug, PartialEq, Eq)]
46pub enum ErosionType {
47    /// Wind-driven erosion (velocity-dependent)
48    Wind,
49    /// Water-driven erosion (velocity + gravity channeling)
50    Water,
51    /// Chemical corrosion (uniform or spatially varying)
52    Chemical,
53    /// High-speed ablation (velocity-squared dependent)
54    Ablation,
55}
56
57/// Erosion modifier configuration
58#[derive(Clone, Copy, Debug, PartialEq)]
59pub struct ErosionConfig {
60    /// Type of erosion
61    pub erosion_type: ErosionType,
62    /// Base erosion rate (distance/second per unit velocity)
63    pub rate: f32,
64    /// Material hardness (0 = soft, 1 = hard). Harder = less erosion.
65    pub hardness: f32,
66    /// Maximum erosion depth
67    pub max_depth: f32,
68    /// Smoothing rate for erosion field (prevents sharp edges)
69    pub smoothing: f32,
70    /// Flow direction (for wind/water erosion)
71    pub flow_direction: (f32, f32, f32),
72    /// Flow speed
73    pub flow_speed: f32,
74}
75
76impl Default for ErosionConfig {
77    fn default() -> Self {
78        Self {
79            erosion_type: ErosionType::Wind,
80            rate: 0.01,
81            hardness: 0.5,
82            max_depth: 2.0,
83            smoothing: 0.1,
84            flow_direction: (1.0, 0.0, 0.0),
85            flow_speed: 1.0,
86        }
87    }
88}
89
90// ============================================================================
91// Erosion Modifier
92// ============================================================================
93
94/// Erosion modifier that removes surface material over time
95pub struct ErosionModifier {
96    /// Configuration
97    pub config: ErosionConfig,
98    /// Accumulated erosion depth (positive = material removed)
99    pub erosion_depth: ScalarField3D,
100    /// Exposure field: how exposed each point is to the erosion agent
101    /// (computed from SDF surface proximity and flow alignment)
102    pub exposure: ScalarField3D,
103    /// Whether modifier is enabled
104    pub enabled: bool,
105}
106
107impl ErosionModifier {
108    /// Create a new erosion modifier
109    #[must_use]
110    pub fn new(
111        config: ErosionConfig,
112        resolution: usize,
113        min: (f32, f32, f32),
114        max: (f32, f32, f32),
115    ) -> Self {
116        Self {
117            config,
118            erosion_depth: ScalarField3D::new(resolution, resolution, resolution, min, max),
119            exposure: ScalarField3D::new(resolution, resolution, resolution, min, max),
120            enabled: true,
121        }
122    }
123
124    /// Set exposure at a point (call from physics contact detection)
125    ///
126    /// `exposure` = how exposed this point is (0..1)
127    pub fn set_exposure_at(&mut self, x: f32, y: f32, z: f32, exposure_value: f32, radius: f32) {
128        self.exposure.splat(x, y, z, exposure_value, radius);
129    }
130
131    /// Mark surface points as exposed based on flow direction alignment
132    ///
133    /// Points where the SDF normal faces the flow receive higher exposure.
134    pub fn compute_exposure_from_normals(
135        &mut self,
136        sdf: &dyn crate::sdf_collider::SdfField,
137        surface_threshold: f32,
138    ) {
139        let nx = self.exposure.nx;
140        let ny = self.exposure.ny;
141        let nz = self.exposure.nz;
142
143        let (fx, fy, fz) = self.config.flow_direction;
144        let flow_len = fz.mul_add(fz, fx.mul_add(fx, fy * fy)).sqrt();
145        let (fx, fy, fz) = if flow_len > 1e-10 {
146            (fx / flow_len, fy / flow_len, fz / flow_len)
147        } else {
148            (1.0, 0.0, 0.0)
149        };
150
151        for iz in 0..nz {
152            for iy in 0..ny {
153                for ix in 0..nx {
154                    let wx = self.exposure.min.0
155                        + ix as f32 * (self.exposure.max.0 - self.exposure.min.0)
156                            / (nx - 1).max(1) as f32;
157                    let wy = self.exposure.min.1
158                        + iy as f32 * (self.exposure.max.1 - self.exposure.min.1)
159                            / (ny - 1).max(1) as f32;
160                    let wz = self.exposure.min.2
161                        + iz as f32 * (self.exposure.max.2 - self.exposure.min.2)
162                            / (nz - 1).max(1) as f32;
163
164                    let dist = sdf.distance(wx, wy, wz).abs();
165
166                    if dist < surface_threshold {
167                        let (snx, sny, snz) = sdf.normal(wx, wy, wz);
168                        // Dot product: how much the surface faces the flow
169                        let alignment = -snz.mul_add(fz, snx.mul_add(fx, sny * fy));
170                        let exp = crate::det_math::max(alignment, 0.0); // Only windward faces
171                        let idx = self.exposure.index(ix, iy, iz);
172                        self.exposure.data[idx] = exp;
173                    }
174                }
175            }
176        }
177    }
178
179    /// Get current erosion depth at a point
180    #[must_use]
181    pub fn erosion_at(&self, x: f32, y: f32, z: f32) -> f32 {
182        self.erosion_depth.sample(x, y, z)
183    }
184
185    /// Compute erosion rate based on type
186    fn compute_rate(&self, exposure: f32) -> f32 {
187        let speed = self.config.flow_speed;
188        let base = self.config.rate * (1.0 - self.config.hardness);
189
190        match self.config.erosion_type {
191            ErosionType::Wind => base * speed * exposure,
192            ErosionType::Water => base * speed * exposure * WATER_PREFACTOR,
193            ErosionType::Chemical => base * exposure, // speed-independent
194            ErosionType::Ablation => base * speed * speed * exposure, // v² (kinetic flux)
195        }
196    }
197}
198
199/// Water erosion prefactor relative to wind at the same speed (module doc
200/// table).
201pub const WATER_PREFACTOR: f32 = 1.5;
202
203/// Exposure decay rate (1/s) applied every `update` when the caller does not
204/// re-supply exposure.
205pub const EXPOSURE_DECAY_PER_S: f32 = 5.0;
206
207impl PhysicsModifier for ErosionModifier {
208    #[inline]
209    fn modify_distance(&self, x: f32, y: f32, z: f32, original_dist: f32) -> f32 {
210        if !self.enabled {
211            return original_dist;
212        }
213
214        let erosion = self.erosion_depth.sample(x, y, z);
215        original_dist + erosion // Positive erosion = surface recedes
216    }
217
218    fn update(&mut self, dt: f32) {
219        if !self.enabled {
220            return;
221        }
222
223        // Accumulate erosion based on exposure
224        let max_depth = self.config.max_depth;
225        let n = self.exposure.cell_count();
226        for i in 0..n {
227            let exp = self.exposure.data[i];
228            if exp > 0.0 {
229                let rate = self.compute_rate(exp);
230                self.erosion_depth.data[i] =
231                    crate::det_math::min(rate.mul_add(dt, self.erosion_depth.data[i]), max_depth);
232            }
233        }
234
235        // Smooth erosion field (prevents jagged edges)
236        if self.config.smoothing > 0.0 {
237            self.erosion_depth.diffuse(dt, self.config.smoothing);
238        }
239
240        // Decay exposure (needs to be re-applied each frame); the rate is
241        // fixed until `ErosionConfig` gains an `exposure_decay` field (2.0)
242        self.exposure.decay(EXPOSURE_DECAY_PER_S, dt);
243    }
244
245    fn name(&self) -> &'static str {
246        "erosion"
247    }
248
249    fn is_active(&self) -> bool {
250        self.enabled
251    }
252}
253
254// ============================================================================
255// World participant
256// ============================================================================
257
258impl ErosionType {
259    /// Payload tag: `Wind` 0, `Water` 1, `Chemical` 2, `Ablation` 3.
260    const fn state_tag(self) -> u8 {
261        match self {
262            Self::Wind => 0,
263            Self::Water => 1,
264            Self::Chemical => 2,
265            Self::Ablation => 3,
266        }
267    }
268
269    const fn from_state_tag(tag: u8) -> Option<Self> {
270        match tag {
271            0 => Some(Self::Wind),
272            1 => Some(Self::Water),
273            2 => Some(Self::Chemical),
274            3 => Some(Self::Ablation),
275            _ => None,
276        }
277    }
278}
279
280impl ErosionModifier {
281    /// Snapshot tag of this type as a world participant: `"EROS"`, the four
282    /// ASCII bytes read big endian. Never changes.
283    pub const PARTICIPANT_KIND: ParticipantKind =
284        ParticipantKind::new(u32::from_be_bytes(*b"EROS"));
285
286    fn decode_state(bytes: &[u8]) -> Result<Self, StateError> {
287        let mut r = StateReader::new(bytes)?;
288        let config = ErosionConfig {
289            erosion_type: ErosionType::from_state_tag(r.u8()?).ok_or(StateError::InvalidValue)?,
290            rate: r.f32()?,
291            hardness: r.f32()?,
292            max_depth: r.f32()?,
293            smoothing: r.f32()?,
294            flow_direction: r.vec3()?,
295            flow_speed: r.f32()?,
296        };
297        let enabled = r.bool()?;
298        let erosion_depth = r.field()?;
299        let exposure = r.field()?;
300        r.finish()?;
301        Ok(Self {
302            config,
303            erosion_depth,
304            exposure,
305            enabled,
306        })
307    }
308}
309
310/// One `update(h)` per world substep (`h` converted with
311/// [`Fix128::to_f32`]); see the module documentation of
312/// [`crate::sim_modifier`] for the payload and what is not coupled yet.
313///
314/// Observations: channel 0 the deepest erosion of a cell (absent for a grid
315/// without cells), channel 1 the total erosion depth.
316impl Participant for ErosionModifier {
317    fn kind(&self) -> ParticipantKind {
318        Self::PARTICIPANT_KIND
319    }
320
321    fn substep(&mut self, _ctx: &mut SubstepCtx<'_>, h: Fix128) -> Result<(), ParticipantFault> {
322        PhysicsModifier::update(self, h.to_f32());
323        Ok(())
324    }
325
326    fn observe(&self, out: &mut ObservationSink) {
327        observe_max(out, 0, &self.erosion_depth);
328        observe_sum(out, 1, &self.erosion_depth);
329    }
330
331    fn write_state(&self, out: &mut Vec<u8>) {
332        let mut w = StateWriter::new(out);
333        let c = &self.config;
334        w.u8(c.erosion_type.state_tag());
335        w.f32(c.rate);
336        w.f32(c.hardness);
337        w.f32(c.max_depth);
338        w.f32(c.smoothing);
339        w.vec3(c.flow_direction);
340        w.f32(c.flow_speed);
341        w.bool(self.enabled);
342        w.field(&self.erosion_depth);
343        w.field(&self.exposure);
344    }
345
346    fn check_state(&self, bytes: &[u8]) -> Result<(), StateError> {
347        Self::decode_state(bytes).map(drop)
348    }
349
350    fn read_state(&mut self, bytes: &[u8]) {
351        match Self::decode_state(bytes) {
352            Ok(state) => *self = state,
353            Err(e) => panic!("read_state called with a payload check_state refuses: {e:?}"),
354        }
355    }
356}
357
358// ============================================================================
359// Tests
360// ============================================================================
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365    use crate::sdf_collider::ClosureSdf;
366
367    #[test]
368    fn test_erosion_no_exposure() {
369        let config = ErosionConfig::default();
370        let modifier = ErosionModifier::new(config, 8, (-2.0, -2.0, -2.0), (2.0, 2.0, 2.0));
371
372        // No exposure: distance unchanged
373        let d = modifier.modify_distance(1.0, 0.0, 0.0, 0.5);
374        assert!((d - 0.5).abs() < 0.01, "No erosion without exposure");
375    }
376
377    #[test]
378    fn test_erosion_with_exposure() {
379        let config = ErosionConfig {
380            rate: 1.0,
381            hardness: 0.0, // Very soft
382            flow_speed: 1.0,
383            ..Default::default()
384        };
385        let mut modifier = ErosionModifier::new(config, 8, (-2.0, -2.0, -2.0), (2.0, 2.0, 2.0));
386
387        // Set exposure at a point
388        modifier.set_exposure_at(0.0, 0.0, 0.0, 1.0, 0.5);
389
390        // Simulate erosion
391        for _ in 0..30 {
392            modifier.update(0.016);
393        }
394
395        let erosion = modifier.erosion_at(0.0, 0.0, 0.0);
396        assert!(erosion > 0.0, "Exposed area should erode, got {erosion}");
397    }
398
399    #[test]
400    fn test_erosion_max_depth() {
401        let config = ErosionConfig {
402            rate: 100.0, // Very fast
403            hardness: 0.0,
404            max_depth: 0.5,
405            flow_speed: 1.0,
406            ..Default::default()
407        };
408        let mut modifier = ErosionModifier::new(config, 8, (-2.0, -2.0, -2.0), (2.0, 2.0, 2.0));
409
410        modifier.set_exposure_at(0.0, 0.0, 0.0, 1.0, 0.5);
411
412        for _ in 0..100 {
413            modifier.update(0.016);
414        }
415
416        let erosion = modifier.erosion_at(0.0, 0.0, 0.0);
417        assert!(
418            erosion <= 0.5 + 0.01,
419            "Erosion should be capped at max_depth, got {erosion}"
420        );
421    }
422
423    #[test]
424    fn test_compute_exposure_from_normals() {
425        let sphere = ClosureSdf::new(
426            |x, y, z| z.mul_add(z, x.mul_add(x, y * y)).sqrt() - 1.0,
427            |x, y, z| {
428                let len = z.mul_add(z, x.mul_add(x, y * y)).sqrt();
429                if len < 1e-10 {
430                    (0.0, 1.0, 0.0)
431                } else {
432                    (x / len, y / len, z / len)
433                }
434            },
435        );
436
437        let config = ErosionConfig {
438            flow_direction: (-1.0, 0.0, 0.0), // Wind from +X
439            ..Default::default()
440        };
441        let mut modifier = ErosionModifier::new(config, 16, (-2.0, -2.0, -2.0), (2.0, 2.0, 2.0));
442        modifier.compute_exposure_from_normals(&sphere, 0.3);
443
444        // +X face should be exposed (faces into wind)
445        let exp_windward = modifier.exposure.sample(1.0, 0.0, 0.0);
446        // -X face should be sheltered
447        let exp_leeward = modifier.exposure.sample(-1.0, 0.0, 0.0);
448
449        assert!(
450            exp_windward > exp_leeward,
451            "Windward face should be more exposed: windward={exp_windward}, leeward={exp_leeward}"
452        );
453    }
454
455    fn chemical(rate: f32) -> ErosionConfig {
456        ErosionConfig {
457            erosion_type: ErosionType::Chemical,
458            rate,
459            hardness: 0.0,
460            max_depth: 10.0,
461            smoothing: 0.0,
462            ..Default::default()
463        }
464    }
465
466    /// oracle (module doc table): with `base = rate·(1 − hardness)`, wind is
467    /// `base·v·e`, water `1.5·base·v·e`, chemical `base·e` (no speed),
468    /// ablation `base·v²·e`. `rate 2, hardness 1/2, v 3, e 1/2`: base 1, so
469    /// 1.5 / 2.25 / 0.5 / 4.5.
470    #[test]
471    fn compute_rate_per_erosion_type() {
472        for (ty, want) in [
473            (ErosionType::Wind, 1.5_f32),
474            (ErosionType::Water, 2.25),
475            (ErosionType::Chemical, 0.5),
476            (ErosionType::Ablation, 4.5),
477        ] {
478            let m = ErosionModifier::new(
479                ErosionConfig {
480                    erosion_type: ty,
481                    rate: 2.0,
482                    hardness: 0.5,
483                    flow_speed: 3.0,
484                    ..Default::default()
485                },
486                2,
487                (0.0, 0.0, 0.0),
488                (1.0, 1.0, 1.0),
489            );
490            assert!((m.compute_rate(0.5) - want).abs() < 1e-6, "{ty:?}");
491        }
492    }
493
494    /// oracle: a disabled modifier leaves the distance as it is and its
495    /// `update` changes nothing, even with full exposure.
496    #[test]
497    fn disabled_erosion_is_inert() {
498        let mut m = ErosionModifier::new(chemical(1.0), 2, (0.0, 0.0, 0.0), (1.0, 1.0, 1.0));
499        m.erosion_depth.data[0] = 0.25;
500        m.exposure.data[0] = 1.0;
501        m.enabled = false;
502        assert!(!m.is_active());
503        assert_eq!(m.modify_distance(0.0, 0.0, 0.0, 0.5), 0.5);
504        m.update(1.0);
505        assert_eq!(m.erosion_depth.data[0], 0.25);
506        assert_eq!(m.exposure.data[0], 1.0);
507    }
508
509    /// oracle: a zero flow direction falls back to `+x`: on the ground plane
510    /// (`distance = y`, normal `+y`) the windward alignment `−n·f = 0`, so
511    /// every near-surface cell gets exposure 0, the same as for flow `+x`.
512    #[test]
513    fn zero_flow_direction_falls_back_to_plus_x() {
514        let ground = ClosureSdf::new(|_x, y, _z| y, |_x, _y, _z| (0.0, 1.0, 0.0));
515        let mut m = ErosionModifier::new(
516            ErosionConfig {
517                flow_direction: (0.0, 0.0, 0.0),
518                ..Default::default()
519            },
520            3,
521            (-1.0, -1.0, -1.0),
522            (1.0, 1.0, 1.0),
523        );
524        m.exposure.data.fill(7.0);
525        m.compute_exposure_from_normals(&ground, 0.5);
526        // the middle layer (y = 0) is on the surface: exposure 0 there
527        for iz in 0..3 {
528            for ix in 0..3 {
529                let i = m.exposure.index(ix, 1, iz);
530                assert_eq!(m.exposure.data[i], 0.0, "({ix}, 1, {iz})");
531            }
532        }
533        // the layers at y = ±1 are farther than 0.5: untouched
534        assert_eq!(m.exposure.data[m.exposure.index(0, 0, 0)], 7.0);
535    }
536
537    fn sample_modifier(ty: ErosionType) -> ErosionModifier {
538        let mut m = ErosionModifier::new(
539            ErosionConfig {
540                erosion_type: ty,
541                rate: 0.75,
542                hardness: 0.25,
543                max_depth: 3.5,
544                smoothing: 0.125,
545                flow_direction: (0.0, 1.0, -1.0),
546                flow_speed: 2.5,
547            },
548            2,
549            (-1.0, -2.0, -3.0),
550            (1.0, 2.0, 3.0),
551        );
552        for (i, v) in m.erosion_depth.data.iter_mut().enumerate() {
553            *v = i as f32 * 0.5;
554        }
555        m.exposure.data[3] = 0.875;
556        m.enabled = false;
557        m
558    }
559
560    /// oracle: the participant kind is `"EROS"` read big endian; a payload
561    /// written by `write_state` is accepted and read back into a different
562    /// modifier, which then writes the same bytes and has the original
563    /// config, flag and fields, for every erosion type.
564    #[test]
565    fn participant_state_round_trip() {
566        assert_eq!(
567            ErosionModifier::PARTICIPANT_KIND,
568            ParticipantKind::new(0x4552_4f53)
569        );
570        for ty in [
571            ErosionType::Wind,
572            ErosionType::Water,
573            ErosionType::Chemical,
574            ErosionType::Ablation,
575        ] {
576            let src = sample_modifier(ty);
577            assert_eq!(Participant::kind(&src), ErosionModifier::PARTICIPANT_KIND);
578            let mut bytes = Vec::new();
579            src.write_state(&mut bytes);
580            assert_eq!(src.check_state(&bytes), Ok(()));
581            let mut dst = ErosionModifier::new(
582                ErosionConfig::default(),
583                1,
584                (0.0, 0.0, 0.0),
585                (1.0, 1.0, 1.0),
586            );
587            dst.read_state(&bytes);
588            assert_eq!(dst.config, src.config);
589            assert!(!dst.enabled);
590            assert_eq!(dst.erosion_depth.data, src.erosion_depth.data);
591            assert_eq!(dst.exposure.data, src.exposure.data);
592            assert_eq!(
593                (dst.exposure.min, dst.exposure.max),
594                ((-1.0, -2.0, -3.0), (1.0, 2.0, 3.0))
595            );
596            let mut again = Vec::new();
597            dst.write_state(&mut again);
598            assert_eq!(again, bytes);
599        }
600    }
601
602    /// oracle: the erosion type is the byte after the 4-byte version; tag 4
603    /// names no type, a missing last byte or an extra byte is a length error.
604    #[test]
605    fn participant_state_refusals() {
606        let src = sample_modifier(ErosionType::Wind);
607        let mut bytes = Vec::new();
608        src.write_state(&mut bytes);
609        let mut bad_tag = bytes.clone();
610        bad_tag[4] = 4;
611        assert_eq!(src.check_state(&bad_tag), Err(StateError::InvalidValue));
612        assert!(matches!(
613            src.check_state(&bytes[..bytes.len() - 1]),
614            Err(StateError::Length { .. })
615        ));
616        let mut long = bytes.clone();
617        long.push(0);
618        assert!(matches!(
619            src.check_state(&long),
620            Err(StateError::Length { .. })
621        ));
622    }
623
624    #[test]
625    #[should_panic(expected = "read_state called with a payload check_state refuses")]
626    fn participant_read_state_panics_on_a_refused_payload() {
627        let mut m = sample_modifier(ErosionType::Wind);
628        m.read_state(&[1, 0, 0, 0, 9]);
629    }
630
631    /// oracle: in a world of one substep, the participant runs `update(dt)`
632    /// once: chemical erosion with rate 1, hardness 0, exposure 1 on one cell
633    /// and `dt = 1/4` deepens that cell by `1·1·(1/4) = 1/4`, so channel 0
634    /// (deepest cell) and channel 1 (sum of cells) both read 1/4.
635    #[test]
636    fn participant_substep_in_a_world() {
637        let mut m = ErosionModifier::new(chemical(1.0), 2, (0.0, 0.0, 0.0), (1.0, 1.0, 1.0));
638        m.exposure.data[0] = 1.0;
639        let mut world = crate::solver::PhysicsWorld::new(crate::solver::SolverConfig {
640            substeps: 1,
641            ..Default::default()
642        });
643        world.add_participant(Box::new(m)).expect("register");
644        world.step(Fix128::from_ratio(1, 4));
645        let Some(crate::world_participant::Observed::Exact(sink)) = world.observe_participant(0)
646        else {
647            panic!("observation");
648        };
649        let quarter = Fix128::from_ratio(1, 4);
650        assert_eq!(sink.values(), &[(0, quarter), (1, quarter)]);
651    }
652}