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 = -snz.mul_add(fz, snx.mul_add(fx, sny * fy));
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#[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 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, 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 modifier.set_exposure_at(0.0, 0.0, 0.0, 1.0, 0.5);
389
390 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, 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), ..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 let exp_windward = modifier.exposure.sample(1.0, 0.0, 0.0);
446 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 #[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 #[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 #[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 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 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 #[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 #[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 #[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}