1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
46pub enum ErosionType {
47 Wind,
49 Water,
51 Chemical,
53 Ablation,
55}
56
57#[derive(Clone, Copy, Debug, PartialEq)]
59pub struct ErosionConfig {
60 pub erosion_type: ErosionType,
62 pub rate: f32,
64 pub hardness: f32,
66 pub max_depth: f32,
68 pub smoothing: f32,
70 pub flow_direction: (f32, f32, f32),
72 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
90pub struct ErosionModifier {
96 pub config: ErosionConfig,
98 pub erosion_depth: ScalarField3D,
100 pub exposure: ScalarField3D,
103 pub enabled: bool,
105}
106
107impl ErosionModifier {
108 #[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 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 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 let alignment = windward_alignment((snx, sny, snz), (fx, fy, fz));
170 let exp = crate::det_math::max(alignment, 0.0); let idx = self.exposure.index(ix, iy, iz);
172 self.exposure.data[idx] = exp;
173 }
174 }
175 }
176 }
177 }
178
179 #[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 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, ErosionType::Ablation => base * speed * speed * exposure, }
196 }
197}
198
199pub const WATER_PREFACTOR: f32 = 1.5;
202
203pub 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 }
217
218 fn update(&mut self, dt: f32) {
219 if !self.enabled {
220 return;
221 }
222
223 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 if self.config.smoothing > 0.0 {
237 self.erosion_depth.diffuse(dt, self.config.smoothing);
238 }
239
240 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
254impl ErosionType {
259 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 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
310impl 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#[inline]
378fn windward_alignment(n: (f32, f32, f32), f: (f32, f32, f32)) -> f32 {
379 (-n.2).mul_add(f.2, (-n.0).mul_add(f.0, (-n.1) * f.1))
380}
381
382#[cfg(test)]
383mod tests {
384 use super::*;
385
386 fn negated_result(n: (f32, f32, f32), f: (f32, f32, f32)) -> f32 {
389 -core::hint::black_box(n.2.mul_add(f.2, n.0.mul_add(f.0, n.1 * f.1)))
390 }
391
392 #[test]
393 fn windward_alignment_equals_the_negated_dot_product_and_defines_the_sign_of_a_zero_result() {
394 let s = core::hint::black_box(core::f32::consts::FRAC_1_SQRT_2);
397 let vals = [-1.0, -0.5, -0.0, 0.0, 0.5, 1.0, s, -s];
398 let (mut compared, mut sign_differs, mut cancellations) = (0, 0, 0);
399 for &nx in &vals {
400 for &ny in &vals {
401 for &nz in &vals {
402 for &fx in &vals {
403 for &fy in &vals {
404 for &fz in &vals {
405 let (n, f) = (core::hint::black_box((nx, ny, nz)), (fx, fy, fz));
406 let got = windward_alignment(n, f);
407 let reference = negated_result(n, f);
408 compared += 1;
409 if reference != 0.0 {
410 assert_eq!(got.to_bits(), reference.to_bits(), "{n:?} {f:?}");
411 continue;
412 }
413 let terms = [nx * fx, ny * fy, nz * fz];
414 let all_positive_zero = terms.iter().all(|t| t.to_bits() == 0);
415 let want = if all_positive_zero { 0x8000_0000 } else { 0 };
416 assert_eq!(got.to_bits(), want, "{n:?} {f:?}: zero result");
417 if got.to_bits() != reference.to_bits() {
418 sign_differs += 1;
419 if terms.iter().any(|t| *t != 0.0) {
420 cancellations += 1;
421 }
422 }
423 }
424 }
425 }
426 }
427 }
428 }
429 assert_eq!(compared, 8_usize.pow(6));
430 assert!(cancellations > 0, "no exact cancellation of nonzero terms");
432 assert!(sign_differs > cancellations, "no mixed-sign zero terms");
433 }
434 use crate::sdf_collider::ClosureSdf;
435
436 #[test]
437 fn test_erosion_no_exposure() {
438 let config = ErosionConfig::default();
439 let modifier = ErosionModifier::new(config, 8, (-2.0, -2.0, -2.0), (2.0, 2.0, 2.0));
440
441 let d = modifier.modify_distance(1.0, 0.0, 0.0, 0.5);
443 assert!((d - 0.5).abs() < 0.01, "No erosion without exposure");
444 }
445
446 #[test]
447 fn test_erosion_with_exposure() {
448 let config = ErosionConfig {
449 rate: 1.0,
450 hardness: 0.0, flow_speed: 1.0,
452 ..Default::default()
453 };
454 let mut modifier = ErosionModifier::new(config, 8, (-2.0, -2.0, -2.0), (2.0, 2.0, 2.0));
455
456 modifier.set_exposure_at(0.0, 0.0, 0.0, 1.0, 0.5);
458
459 for _ in 0..30 {
461 modifier.update(0.016);
462 }
463
464 let erosion = modifier.erosion_at(0.0, 0.0, 0.0);
465 assert!(erosion > 0.0, "Exposed area should erode, got {erosion}");
466 }
467
468 #[test]
469 fn test_erosion_max_depth() {
470 let config = ErosionConfig {
471 rate: 100.0, hardness: 0.0,
473 max_depth: 0.5,
474 flow_speed: 1.0,
475 ..Default::default()
476 };
477 let mut modifier = ErosionModifier::new(config, 8, (-2.0, -2.0, -2.0), (2.0, 2.0, 2.0));
478
479 modifier.set_exposure_at(0.0, 0.0, 0.0, 1.0, 0.5);
480
481 for _ in 0..100 {
482 modifier.update(0.016);
483 }
484
485 let erosion = modifier.erosion_at(0.0, 0.0, 0.0);
486 assert!(
487 erosion <= 0.5 + 0.01,
488 "Erosion should be capped at max_depth, got {erosion}"
489 );
490 }
491
492 #[test]
493 fn test_compute_exposure_from_normals() {
494 let sphere = ClosureSdf::new(
495 |x, y, z| z.mul_add(z, x.mul_add(x, y * y)).sqrt() - 1.0,
496 |x, y, z| {
497 let len = z.mul_add(z, x.mul_add(x, y * y)).sqrt();
498 if len < 1e-10 {
499 (0.0, 1.0, 0.0)
500 } else {
501 (x / len, y / len, z / len)
502 }
503 },
504 );
505
506 let config = ErosionConfig {
507 flow_direction: (-1.0, 0.0, 0.0), ..Default::default()
509 };
510 let mut modifier = ErosionModifier::new(config, 16, (-2.0, -2.0, -2.0), (2.0, 2.0, 2.0));
511 modifier.compute_exposure_from_normals(&sphere, 0.3);
512
513 let exp_windward = modifier.exposure.sample(1.0, 0.0, 0.0);
515 let exp_leeward = modifier.exposure.sample(-1.0, 0.0, 0.0);
517
518 assert!(
519 exp_windward > exp_leeward,
520 "Windward face should be more exposed: windward={exp_windward}, leeward={exp_leeward}"
521 );
522 }
523
524 fn chemical(rate: f32) -> ErosionConfig {
525 ErosionConfig {
526 erosion_type: ErosionType::Chemical,
527 rate,
528 hardness: 0.0,
529 max_depth: 10.0,
530 smoothing: 0.0,
531 ..Default::default()
532 }
533 }
534
535 #[test]
540 fn compute_rate_per_erosion_type() {
541 for (ty, want) in [
542 (ErosionType::Wind, 1.5_f32),
543 (ErosionType::Water, 2.25),
544 (ErosionType::Chemical, 0.5),
545 (ErosionType::Ablation, 4.5),
546 ] {
547 let m = ErosionModifier::new(
548 ErosionConfig {
549 erosion_type: ty,
550 rate: 2.0,
551 hardness: 0.5,
552 flow_speed: 3.0,
553 ..Default::default()
554 },
555 2,
556 (0.0, 0.0, 0.0),
557 (1.0, 1.0, 1.0),
558 );
559 assert!((m.compute_rate(0.5) - want).abs() < 1e-6, "{ty:?}");
560 }
561 }
562
563 #[test]
566 fn disabled_erosion_is_inert() {
567 let mut m = ErosionModifier::new(chemical(1.0), 2, (0.0, 0.0, 0.0), (1.0, 1.0, 1.0));
568 m.erosion_depth.data[0] = 0.25;
569 m.exposure.data[0] = 1.0;
570 m.enabled = false;
571 assert!(!m.is_active());
572 assert_eq!(m.modify_distance(0.0, 0.0, 0.0, 0.5), 0.5);
573 m.update(1.0);
574 assert_eq!(m.erosion_depth.data[0], 0.25);
575 assert_eq!(m.exposure.data[0], 1.0);
576 }
577
578 #[test]
582 fn zero_flow_direction_falls_back_to_plus_x() {
583 let ground = ClosureSdf::new(|_x, y, _z| y, |_x, _y, _z| (0.0, 1.0, 0.0));
584 let mut m = ErosionModifier::new(
585 ErosionConfig {
586 flow_direction: (0.0, 0.0, 0.0),
587 ..Default::default()
588 },
589 3,
590 (-1.0, -1.0, -1.0),
591 (1.0, 1.0, 1.0),
592 );
593 m.exposure.data.fill(7.0);
594 m.compute_exposure_from_normals(&ground, 0.5);
595 for iz in 0..3 {
597 for ix in 0..3 {
598 let i = m.exposure.index(ix, 1, iz);
599 assert_eq!(m.exposure.data[i], 0.0, "({ix}, 1, {iz})");
600 }
601 }
602 assert_eq!(m.exposure.data[m.exposure.index(0, 0, 0)], 7.0);
604 }
605
606 fn sample_modifier(ty: ErosionType) -> ErosionModifier {
607 let mut m = ErosionModifier::new(
608 ErosionConfig {
609 erosion_type: ty,
610 rate: 0.75,
611 hardness: 0.25,
612 max_depth: 3.5,
613 smoothing: 0.125,
614 flow_direction: (0.0, 1.0, -1.0),
615 flow_speed: 2.5,
616 },
617 2,
618 (-1.0, -2.0, -3.0),
619 (1.0, 2.0, 3.0),
620 );
621 for (i, v) in m.erosion_depth.data.iter_mut().enumerate() {
622 *v = i as f32 * 0.5;
623 }
624 m.exposure.data[3] = 0.875;
625 m.enabled = false;
626 m
627 }
628
629 #[test]
634 fn participant_state_round_trip() {
635 assert_eq!(
636 ErosionModifier::PARTICIPANT_KIND,
637 ParticipantKind::new(0x4552_4f53)
638 );
639 for ty in [
640 ErosionType::Wind,
641 ErosionType::Water,
642 ErosionType::Chemical,
643 ErosionType::Ablation,
644 ] {
645 let src = sample_modifier(ty);
646 assert_eq!(Participant::kind(&src), ErosionModifier::PARTICIPANT_KIND);
647 let mut bytes = Vec::new();
648 src.write_state(&mut bytes);
649 assert_eq!(src.check_state(&bytes), Ok(()));
650 let mut dst = ErosionModifier::new(
651 ErosionConfig::default(),
652 1,
653 (0.0, 0.0, 0.0),
654 (1.0, 1.0, 1.0),
655 );
656 dst.read_state(&bytes);
657 assert_eq!(dst.config, src.config);
658 assert!(!dst.enabled);
659 assert_eq!(dst.erosion_depth.data, src.erosion_depth.data);
660 assert_eq!(dst.exposure.data, src.exposure.data);
661 assert_eq!(
662 (dst.exposure.min, dst.exposure.max),
663 ((-1.0, -2.0, -3.0), (1.0, 2.0, 3.0))
664 );
665 let mut again = Vec::new();
666 dst.write_state(&mut again);
667 assert_eq!(again, bytes);
668 }
669 }
670
671 #[test]
674 fn participant_state_refusals() {
675 let src = sample_modifier(ErosionType::Wind);
676 let mut bytes = Vec::new();
677 src.write_state(&mut bytes);
678 let mut bad_tag = bytes.clone();
679 bad_tag[4] = 4;
680 assert_eq!(src.check_state(&bad_tag), Err(StateError::InvalidValue));
681 assert!(matches!(
682 src.check_state(&bytes[..bytes.len() - 1]),
683 Err(StateError::Length { .. })
684 ));
685 let mut long = bytes.clone();
686 long.push(0);
687 assert!(matches!(
688 src.check_state(&long),
689 Err(StateError::Length { .. })
690 ));
691 }
692
693 #[test]
694 #[should_panic(expected = "read_state called with a payload check_state refuses")]
695 fn participant_read_state_panics_on_a_refused_payload() {
696 let mut m = sample_modifier(ErosionType::Wind);
697 m.read_state(&[1, 0, 0, 0, 9]);
698 }
699
700 #[test]
705 fn participant_substep_in_a_world() {
706 let mut m = ErosionModifier::new(chemical(1.0), 2, (0.0, 0.0, 0.0), (1.0, 1.0, 1.0));
707 m.exposure.data[0] = 1.0;
708 let mut world = crate::solver::PhysicsWorld::new(crate::solver::SolverConfig {
709 substeps: 1,
710 ..Default::default()
711 });
712 world.add_participant(Box::new(m)).expect("register");
713 world.step(Fix128::from_ratio(1, 4));
714 let Some(crate::world_participant::Observed::Exact(sink)) = world.observe_participant(0)
715 else {
716 panic!("observation");
717 };
718 let quarter = Fix128::from_ratio(1, 4);
719 assert_eq!(sink.values(), &[(0, quarter), (1, quarter)]);
720 }
721}