1mod gpu;
17pub(crate) use gpu::{HydrologyDispatch, HydrologyPipelines};
18pub(crate) mod model;
19mod water;
20mod water_gpu;
21
22pub(crate) use water::{NO_STILL_LEVEL, WATER_SKIRT_REACH_M};
23pub use water::{
24 StillWater, WaterExtension, WaterMaterial, WaterParams, WaterPatch, WaterPlugin, WaterSettings,
25 WaterState,
26};
27
28use std::collections::VecDeque;
29use std::sync::Arc;
30use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
31
32use bevy::asset::embedded_asset;
33use bevy::ecs::system::SystemParam;
34use bevy::math::UVec4;
35use bevy::prelude::*;
36use bevy::render::render_resource::ShaderType;
37use bevy::render::storage::ShaderBuffer;
38
39use crate::climate::{Climate, LAPSE_RATE_C_PER_M, Weather};
40use crate::gpu::grid::zeroed_buffer;
41use crate::gpu::readback::{ReadbackTick, Readbacks, decode};
42use crate::plugin::{
43 ElementsQuality, ElementsSystems, ElementsUnits, ElementsView, ensure_gpu_core,
44};
45use crate::precipitation::{BlockerGpu, PrecipitationBlocker, nearest_blockers};
46use crate::query::EnvironmentSnapshot;
47use crate::terrain::{TerrainGpu, TerrainGrid, TerrainState};
48pub use model::{MAX_WATER_SOURCES, evaporation_mm_h};
49use model::{
50 MM_H_TO_M_S, QuantumAccumulator, Scheduler, THERMAL_PERIOD, depth_cap_m, source_entry,
51 speed_cap_ms, step_fraction, wet_depth_m,
52};
53
54const DEPTH_READBACK_MAX_CELLS: u32 = 1024;
56
57#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Reflect)]
59#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
60pub enum HydrologyBoundary {
61 #[default]
64 Open,
65 Closed,
67}
68
69#[derive(Resource, Clone, Debug, PartialEq, Reflect)]
71#[cfg_attr(
72 feature = "serde",
73 derive(serde::Serialize, serde::Deserialize),
74 serde(default)
75)]
76pub struct HydrologySettings {
77 pub step_s: f32,
79 pub time_scale: f32,
84 pub boundary: HydrologyBoundary,
86 pub evaporation: bool,
89 pub manning_n: f32,
92}
93
94impl Default for HydrologySettings {
95 fn default() -> Self {
96 Self {
97 step_s: 0.02,
98 time_scale: 1.0,
99 boundary: HydrologyBoundary::Open,
100 evaporation: true,
101 manning_n: 0.035,
102 }
103 }
104}
105
106#[derive(Resource, Clone, Debug, PartialEq, Reflect)]
108#[cfg_attr(
109 feature = "serde",
110 derive(serde::Serialize, serde::Deserialize),
111 serde(default)
112)]
113pub struct ErosionSettings {
114 pub enabled: bool,
117 pub capacity: f32,
120 pub dissolve: f32,
123 pub deposit: f32,
125 pub min_tilt: f32,
128 pub talus_deg: f32,
132 pub thermal_rate: f32,
135 pub strength: f32,
137}
138
139#[derive(Resource, Clone, Copy, Debug, Default)]
143#[cfg_attr(not(feature = "isomesh"), allow(dead_code))]
144pub(crate) struct ErosionLocked;
145
146pub(crate) fn erosion_enabled(erosion: &ErosionSettings, locked: bool) -> bool {
148 erosion.enabled && !locked
149}
150
151impl Default for ErosionSettings {
152 fn default() -> Self {
153 Self {
154 enabled: true,
155 capacity: 0.6,
156 dissolve: 0.03,
157 deposit: 0.03,
158 min_tilt: 0.05,
159 talus_deg: 35.0,
160 thermal_rate: 0.15,
161 strength: 1.0,
162 }
163 }
164}
165
166#[derive(Component, Clone, Copy, Debug, PartialEq, Reflect)]
171#[require(Transform)]
172#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
173pub struct WaterSource {
174 pub rate_m3_s: f32,
176 pub radius_m: f32,
178}
179
180#[derive(Component, Clone, Copy, Debug, PartialEq, Reflect)]
183#[require(Transform)]
184#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
185pub struct WaterSink {
186 pub rate_m3_s: f32,
188 pub radius_m: f32,
190}
191
192#[derive(Message, Clone, Copy, Debug, PartialEq)]
198#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
199pub struct ErodeTerrain {
200 pub duration_s: f32,
202 pub rain_mm_h: f32,
204}
205
206#[derive(Message, Clone, Copy, Debug, PartialEq)]
208#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
209pub struct TerrainBaked {
210 pub duration_s: f32,
212}
213
214#[derive(Resource, Clone, Debug, Default, PartialEq)]
217pub struct HydrologyStats {
218 pub substeps: u64,
220 pub simulated_s: f64,
222 pub volume_m3: f64,
224 pub lost_m3: f64,
230 pub baking: bool,
232 pub dropped: u64,
236}
237
238#[derive(Clone, Debug)]
240pub(crate) struct WaterDepthSnapshot {
241 pub grid: TerrainGrid,
243 pub depth: Arc<[f32]>,
244 pub volume_m3: f64,
246 pub wet_cells: u32,
248 pub request: u64,
250 pub input_m3: f64,
251}
252
253#[derive(Clone, Debug)]
255pub(crate) struct WaterVelocitySnapshot {
256 pub grid: TerrainGrid,
257 pub u: Arc<[f32]>,
258 pub v: Arc<[f32]>,
259}
260
261impl WaterDepthSnapshot {
262 pub(crate) fn depth_at(&self, p: Vec2) -> Option<f32> {
263 self.grid
264 .contains(p)
265 .then(|| self.grid.sample(&self.depth, p).max(0.0))
266 }
267}
268
269impl WaterVelocitySnapshot {
270 pub(crate) fn velocity_at(&self, p: Vec2) -> Option<Vec2> {
271 self.grid
272 .contains(p)
273 .then(|| Vec2::new(self.grid.sample(&self.u, p), self.grid.sample(&self.v, p)))
274 }
275}
276
277#[derive(Clone, Copy, Debug, Default, PartialEq, ShaderType)]
279pub(crate) struct HydroParams {
280 origin: Vec2,
281 cell: f32,
282 pad0: f32,
283 size: UVec2,
284 pad1: UVec2,
285 sim: Vec4,
287 weather: Vec4,
290 misc: Vec4,
292 flags: UVec4,
294 erosion: Vec4,
296 thermal: Vec4,
298 extra: Vec4,
300}
301
302impl HydroParams {
303 pub(crate) fn avalanche(
308 grid: &TerrainGrid,
309 talus_deg: f32,
310 bedrock_extra_deg: f32,
311 rate: f32,
312 ) -> Self {
313 let bedrock_angle = talus_deg + bedrock_extra_deg;
314 let tan_bedrock = if bedrock_angle >= 89.9 {
315 model::UNREACHABLE_TAN
316 } else {
317 bedrock_angle.to_radians().tan()
318 };
319 Self {
320 origin: grid.origin,
321 cell: grid.cell_m,
322 size: grid.size,
323 thermal: Vec4::new(
324 talus_deg.to_radians().tan(),
325 tan_bedrock,
326 rate.clamp(0.0, 1.0),
327 0.0,
328 ),
329 ..Self::default()
330 }
331 }
332}
333
334#[derive(Clone, Debug)]
336pub(crate) struct ReadbackBuffers {
337 pub depth: Handle<ShaderBuffer>,
339 pub velocity: Handle<ShaderBuffer>,
341 pub depth_ds: u32,
343}
344
345#[derive(Resource, Clone)]
347pub(crate) struct HydrologyGpu {
348 pub buffers: ReadbackBuffers,
349 pub params: HydroParams,
350 pub substeps: u32,
352 pub thermal_phase: u32,
354 pub thermal: bool,
355 pub sources: Vec<Vec4>,
357 pub blockers: Vec<BlockerGpu>,
358 pub source_epoch: u64,
360 pub source_built: Arc<AtomicU64>,
361 pub downsample: bool,
363 pub executed: Arc<AtomicU64>,
365 pub dropped: Arc<AtomicU64>,
366 pub ready: Arc<AtomicBool>,
368 pub live: Arc<AtomicBool>,
370}
371
372#[derive(Clone, Copy, Debug)]
374struct Bake {
375 request: ErodeTerrain,
376 remaining: u64,
378 done_at: u64,
380}
381
382#[derive(Clone, Debug, Default, PartialEq)]
384struct SourceInputs {
385 grid: Option<(Vec2, f32, UVec2)>,
386 entries: Vec<(Vec2, f32, f32)>,
388}
389
390#[derive(Resource, Default)]
392struct HydrologyRuntime {
393 built_for: Option<AssetId<ShaderBuffer>>,
395 scheduler: Scheduler,
396 rain: QuantumAccumulator,
397 evaporation: QuantumAccumulator,
398 bake: Option<Bake>,
399 queue: VecDeque<ErodeTerrain>,
400 scheduled: u64,
402 source_epoch: u64,
403 inputs: SourceInputs,
404 sources: Vec<Vec4>,
405 blockers: Vec<BlockerGpu>,
406 warned_cap: bool,
407 input_m3: f64,
409 requests: u64,
411 processed: u64,
412 baseline: Option<(f64, f64)>,
413 wet_area_m2: f64,
415}
416
417impl HydrologyRuntime {
418 fn take_requests(&mut self) -> VecDeque<ErodeTerrain> {
421 let mut queue = std::mem::take(&mut self.queue);
422 if let Some(bake) = self.bake.take() {
423 queue.push_front(bake.request);
424 }
425 queue
426 }
427}
428
429pub struct HydrologyPlugin;
434
435impl Plugin for HydrologyPlugin {
436 fn build(&self, app: &mut App) {
437 ensure_gpu_core(app);
438 embedded_asset!(app, "substep.wesl");
439 embedded_asset!(app, "thermal.wesl");
440 embedded_asset!(app, "source_map.wesl");
441 embedded_asset!(app, "readback.wesl");
442 app.init_resource::<HydrologySettings>()
443 .init_resource::<ErosionSettings>()
444 .init_resource::<HydrologyStats>()
445 .init_resource::<Climate>()
446 .init_resource::<HydrologyRuntime>()
447 .add_message::<ErodeTerrain>()
448 .add_message::<TerrainBaked>()
449 .add_systems(
450 Update,
451 (ensure_hydrology, run_hydrology)
452 .chain()
453 .in_set(ElementsSystems::Simulation),
454 )
455 .add_systems(
456 Update,
457 (read_water, update_stats)
458 .chain()
459 .in_set(ElementsSystems::Readback),
460 );
461 gpu::plugin(app);
462 }
463}
464
465fn depth_downsample(size: UVec2) -> u32 {
467 if size.max_element() > DEPTH_READBACK_MAX_CELLS {
468 2
469 } else {
470 1
471 }
472}
473
474#[allow(clippy::too_many_arguments)]
477fn ensure_hydrology(
478 mut commands: Commands,
479 terrain: Option<Res<TerrainState>>,
480 gpu: Option<Res<HydrologyGpu>>,
481 mut runtime: ResMut<HydrologyRuntime>,
482 mut stats: ResMut<HydrologyStats>,
483 mut snapshot: ResMut<EnvironmentSnapshot>,
484 mut buffers: ResMut<Assets<ShaderBuffer>>,
485) {
486 let Some(terrain) = terrain else {
487 if gpu.is_some() {
488 commands.remove_resource::<HydrologyGpu>();
489 let queue = runtime.take_requests();
491 *runtime = HydrologyRuntime {
492 queue,
493 ..HydrologyRuntime::default()
494 };
495 *stats = HydrologyStats::default();
496 snapshot.water_depth = None;
497 snapshot.water_velocity = None;
498 }
499 return;
500 };
501 if runtime.built_for == Some(terrain.water.id()) && gpu.is_some() {
502 return;
503 }
504 let size = terrain.grid.size;
505 let ds = depth_downsample(size);
506 let depth_cells = u64::from(size.x / ds) * u64::from(size.y / ds);
507 let velocity_cells = u64::from(size.x / 2) * u64::from(size.y / 2);
508 let readback = ReadbackBuffers {
509 depth: zeroed_buffer(
510 &mut buffers,
511 "elements_hydrology_depth_readback",
512 depth_cells * 4,
513 ),
514 velocity: zeroed_buffer(
515 &mut buffers,
516 "elements_hydrology_velocity_readback",
517 velocity_cells * 8,
518 ),
519 depth_ds: ds,
520 };
521 *runtime = HydrologyRuntime {
522 built_for: Some(terrain.water.id()),
523 source_epoch: 1,
524 queue: runtime.take_requests(),
525 ..HydrologyRuntime::default()
526 };
527 *stats = HydrologyStats::default();
528 snapshot.water_depth = None;
529 snapshot.water_velocity = None;
530 commands.insert_resource(HydrologyGpu {
531 buffers: readback,
532 params: HydroParams::default(),
533 substeps: 0,
534 thermal_phase: 0,
535 thermal: false,
536 sources: Vec::new(),
537 blockers: Vec::new(),
538 source_epoch: 1,
539 source_built: Arc::new(AtomicU64::new(0)),
540 downsample: false,
541 executed: Arc::new(AtomicU64::new(0)),
542 dropped: Arc::new(AtomicU64::new(0)),
543 ready: Arc::new(AtomicBool::new(false)),
544 live: Arc::new(AtomicBool::new(false)),
545 });
546}
547
548#[derive(SystemParam)]
550struct Inputs<'w> {
551 settings: Res<'w, HydrologySettings>,
552 erosion: Res<'w, ErosionSettings>,
553 quality: Res<'w, ElementsQuality>,
554 weather: Res<'w, Weather>,
555 climate: Res<'w, Climate>,
556 view: Res<'w, ElementsView>,
557 time: Res<'w, Time<Real>>,
558 units: Res<'w, ElementsUnits>,
559 erosion_locked: Option<Res<'w, ErosionLocked>>,
560 still: Option<Res<'w, StillWater>>,
561}
562
563fn collect_sources(
566 units: &ElementsUnits,
567 sources: &Query<(Entity, &WaterSource, &GlobalTransform)>,
568 sinks: &Query<(Entity, &WaterSink, &GlobalTransform)>,
569) -> (Vec<(Vec2, f32, f32)>, bool) {
570 fn take<'a>(
571 items: impl Iterator<Item = (Entity, Vec2, f32, f32)> + 'a,
572 ) -> (Vec<(Vec2, f32, f32)>, bool) {
573 let mut all: Vec<_> = items.collect();
574 all.sort_by_key(|item| item.0);
575 let cut = all.len() > MAX_WATER_SOURCES;
576 all.truncate(MAX_WATER_SOURCES);
577 (all.into_iter().map(|(_, p, r, q)| (p, r, q)).collect(), cut)
578 }
579 let (mut entries, cut_sources) = take(sources.iter().map(|(e, s, t)| {
580 (
581 e,
582 units.point_to_metres(t.translation()).xz(),
583 s.radius_m,
584 s.rate_m3_s,
585 )
586 }));
587 let (sink_entries, cut_sinks) = take(sinks.iter().map(|(e, s, t)| {
588 (
589 e,
590 units.point_to_metres(t.translation()).xz(),
591 s.radius_m,
592 -s.rate_m3_s,
593 )
594 }));
595 entries.extend(sink_entries);
596 (entries, cut_sources || cut_sinks)
597}
598
599#[allow(clippy::too_many_arguments)]
601fn run_hydrology(
602 inputs: Inputs,
603 terrain: Option<Res<TerrainState>>,
604 terrain_gpu: Option<ResMut<TerrainGpu>>,
605 gpu: Option<ResMut<HydrologyGpu>>,
606 mut runtime: ResMut<HydrologyRuntime>,
607 mut stats: ResMut<HydrologyStats>,
608 sources: Query<(Entity, &WaterSource, &GlobalTransform)>,
609 sinks: Query<(Entity, &WaterSink, &GlobalTransform)>,
610 blockers: Query<(Entity, &PrecipitationBlocker, &GlobalTransform)>,
611 tick: Res<ReadbackTick>,
612 mut requests: MessageReader<ErodeTerrain>,
613 mut baked: MessageWriter<TerrainBaked>,
614) {
615 runtime.queue.extend(requests.read().copied());
616 let (Some(terrain), Some(mut terrain_gpu), Some(mut gpu)) = (terrain, terrain_gpu, gpu) else {
617 return;
618 };
619 let Inputs {
620 settings,
621 erosion,
622 quality,
623 weather,
624 climate,
625 view,
626 time,
627 erosion_locked,
628 still,
629 units,
630 } = inputs;
631 let erosion_on = erosion_enabled(&erosion, erosion_locked.is_some());
632 let grid = terrain.grid;
633 let step_s = settings.step_s.max(1e-4);
634
635 let executed = gpu.executed.load(Ordering::Relaxed);
636 let dropped = gpu.dropped.load(Ordering::Relaxed);
637 stats.substeps = executed;
638 stats.simulated_s = executed as f64 * f64::from(step_s);
639 stats.dropped = dropped;
640
641 if let Some(bake) = runtime.bake
643 && bake.remaining == 0
644 && executed + dropped >= bake.done_at
645 {
646 baked.write(TerrainBaked {
647 duration_s: bake.request.duration_s,
648 });
649 runtime.bake = None;
650 runtime.scheduler.reset();
651 }
652 if runtime.bake.is_none()
653 && let Some(request) = runtime.queue.pop_front()
654 {
655 let steps = f64::from(request.duration_s.max(0.0)) / f64::from(step_s);
657 let total = (steps - 1e-3).ceil().max(0.0) as u64;
658 let done_at = runtime.scheduled + total;
659 if total == 0 {
660 baked.write(TerrainBaked {
661 duration_s: request.duration_s,
662 });
663 } else {
664 runtime.bake = Some(Bake {
665 request,
666 remaining: total,
667 done_at,
668 });
669 }
670 }
671 stats.baking = runtime.bake.is_some();
672
673 let (entries, cut) = collect_sources(&units, &sources, &sinks);
675 if cut && !runtime.warned_cap {
676 warn!(
677 "more than {MAX_WATER_SOURCES} WaterSources or WaterSinks exist; the extra ones (highest Entity) are ignored"
678 );
679 runtime.warned_cap = true;
680 }
681 let source_inputs = SourceInputs {
682 grid: Some((grid.origin, grid.cell_m, grid.size)),
683 entries,
684 };
685 if runtime.inputs != source_inputs {
686 runtime.sources = source_inputs
687 .entries
688 .iter()
689 .filter_map(|&(p, radius, rate)| {
690 source_entry(grid.origin, grid.cell_m, grid.size, p, radius, rate)
691 })
692 .collect();
693 runtime.inputs = source_inputs;
694 runtime.source_epoch += 1;
695 }
696 let selected = nearest_blockers(
697 view.position,
698 blockers.iter().filter_map(|(entity, blocker, transform)| {
699 BlockerGpu::new(blocker.half_extents, &units.to_metres(transform)).map(|b| (entity, b))
700 }),
701 );
702 if runtime.blockers != selected {
703 runtime.blockers = selected;
704 runtime.source_epoch += 1;
705 }
706
707 let ready = gpu.ready.load(Ordering::Relaxed)
709 && terrain.is_live()
710 && terrain_gpu.generated.load(Ordering::Relaxed);
711 let max = quality.hydrology_substeps();
712 let n = if !ready {
713 0
714 } else if let Some(bake) = runtime.bake.as_mut() {
715 let n = bake.remaining.min(u64::from(max));
716 bake.remaining -= n;
717 n as u32
718 } else {
719 let dt = time.delta_secs_f64();
720 let area = f64::from(grid.extent().x) * f64::from(grid.extent().y);
721 let source_rate: f64 = runtime
722 .inputs
723 .entries
724 .iter()
725 .map(|e| f64::from(e.2))
726 .filter(|r| *r > 0.0)
727 .sum();
728 let mean_depth = (runtime.processed > 0).then(|| stats.volume_m3 / area);
729 if model::still_idle(
730 still.is_some(),
731 false,
732 weather.precipitation_mm_h,
733 source_rate,
734 mean_depth,
735 ) {
736 runtime.scheduler.reset();
738 0
739 } else {
740 runtime.scheduler.step(dt, settings.time_scale, step_s, max)
741 }
742 };
743
744 let baking = runtime.bake.is_some();
745 let rain_mm_h = match runtime.bake {
746 Some(bake) => bake.request.rain_mm_h,
747 None => weather.precipitation_mm_h,
748 };
749 let (rain_quantum, evaporation_quantum) = if n > 0 {
750 let rain_rate = f64::from(rain_mm_h.max(0.0)) * MM_H_TO_M_S;
751 let evap_rate = if settings.evaporation {
752 f64::from(evaporation_mm_h(
753 weather.wind_speed_ms,
754 weather.temperature_c,
755 weather.relative_humidity,
756 )) * MM_H_TO_M_S
757 } else {
758 0.0
759 };
760 let quanta = (runtime.rain.take(), runtime.evaporation.take());
761 runtime.rain.owe(rain_rate, step_s, n);
762 runtime.evaporation.owe(evap_rate, step_s, n);
763 let seconds = f64::from(step_s) * f64::from(n);
765 let area = f64::from(grid.extent().x) * f64::from(grid.extent().y);
766 let liquid = if baking {
767 1.0
768 } else {
769 f64::from(1.0 - weather.snow_fraction())
770 };
771 let source_rate: f64 = runtime.inputs.entries.iter().map(|e| f64::from(e.2)).sum();
772 runtime.input_m3 +=
773 seconds * (rain_rate * liquid * area - evap_rate * runtime.wet_area_m2 + source_rate);
774 quanta
775 } else {
776 (0.0, 0.0)
777 };
778
779 let cell = grid.cell_m;
780 let tan_talus = erosion.talus_deg.to_radians().tan();
781 let tan_bedrock = if runtime.bake.is_some() {
784 (erosion.talus_deg + model::BEDROCK_EXTRA_DEG)
785 .to_radians()
786 .tan()
787 } else {
788 model::UNREACHABLE_TAN
789 };
790 gpu.params = HydroParams {
791 origin: grid.origin,
792 cell,
793 pad0: 0.0,
794 size: grid.size,
795 pad1: UVec2::ZERO,
796 sim: Vec4::new(
797 step_s,
798 settings.manning_n.max(0.0),
799 depth_cap_m(cell, step_s),
800 wet_depth_m(cell),
801 ),
802 weather: Vec4::new(
803 rain_quantum,
804 evaporation_quantum,
805 grid.y0 - climate.reference_elevation_m,
806 weather.temperature_c,
807 ),
808 misc: Vec4::new(
809 LAPSE_RATE_C_PER_M,
810 speed_cap_ms(cell, step_s),
811 f32::from(u8::from(runtime.bake.is_some())),
812 weather.snow_possible,
813 ),
814 flags: UVec4::new(
815 u32::from(settings.boundary == HydrologyBoundary::Closed),
816 u32::from(erosion_on),
817 runtime.sources.len() as u32,
818 runtime.blockers.len() as u32,
819 ),
820 erosion: Vec4::new(
821 erosion.capacity,
822 erosion.min_tilt,
823 step_fraction(erosion.dissolve, erosion.strength, step_s),
824 step_fraction(erosion.deposit, erosion.strength, step_s),
825 ),
826 thermal: Vec4::new(
827 tan_talus,
828 tan_bedrock,
829 (erosion.thermal_rate * erosion.strength).clamp(0.0, 1.0),
830 0.0,
831 ),
832 extra: Vec4::new(grid.y0, gpu.buffers.depth_ds as f32, 0.0, 0.0),
833 };
834 gpu.substeps = n;
835 gpu.thermal_phase = (runtime.scheduled % THERMAL_PERIOD) as u32;
836 gpu.thermal = erosion_on;
837 gpu.sources = if runtime.sources.is_empty() {
839 vec![Vec4::ZERO]
840 } else {
841 runtime.sources.clone()
842 };
843 gpu.blockers = if runtime.blockers.is_empty() {
844 vec![BlockerGpu::default()]
845 } else {
846 runtime.blockers.clone()
847 };
848 gpu.source_epoch = runtime.source_epoch;
849 gpu.downsample = tick.is_due() || !gpu.live.load(Ordering::Relaxed);
851 runtime.scheduled += u64::from(n);
852 if n > 0 {
853 terrain_gpu.changes += 1;
855 }
856}
857
858fn read_water(
860 terrain: Option<Res<TerrainState>>,
861 gpu: Option<Res<HydrologyGpu>>,
862 mut runtime: ResMut<HydrologyRuntime>,
863 tick: Res<ReadbackTick>,
864 mut readbacks: Readbacks,
865) {
866 let (Some(terrain), Some(gpu)) = (terrain, gpu) else {
867 return;
868 };
869 if !gpu.live.load(Ordering::Relaxed) || !tick.is_due() {
870 return;
871 }
872 let ds = gpu.buffers.depth_ds;
873 let grid = terrain.grid;
874 let wet = wet_depth_m(grid.cell_m);
875 let depth_grid = TerrainGrid {
876 cell_m: grid.cell_m * ds as f32,
877 size: grid.size / ds,
878 ..grid
879 };
880 let expected = (depth_grid.size.x * depth_grid.size.y) as usize;
881 runtime.requests += 1;
882 let (request, input_m3) = (runtime.requests, runtime.input_m3);
883 readbacks.request(
884 "water_depth",
885 gpu.buffers.depth.clone(),
886 move |bytes, snapshot| {
887 let Some(depth) = decode::<f32>(bytes) else {
888 return;
889 };
890 if depth.len() < expected {
891 return;
892 }
893 let depth = &depth[..expected];
894 let area = f64::from(depth_grid.cell_m) * f64::from(depth_grid.cell_m);
895 snapshot.water_depth = Some(WaterDepthSnapshot {
896 grid: depth_grid,
897 volume_m3: depth.iter().map(|d| f64::from(*d)).sum::<f64>() * area,
898 wet_cells: depth.iter().filter(|d| **d > wet).count() as u32,
899 depth: depth.into(),
900 request,
901 input_m3,
902 });
903 },
904 );
905 let velocity_grid = TerrainGrid {
906 cell_m: grid.cell_m * 2.0,
907 size: grid.size / 2,
908 ..grid
909 };
910 let expected = (velocity_grid.size.x * velocity_grid.size.y) as usize;
911 readbacks.request(
912 "water_velocity",
913 gpu.buffers.velocity.clone(),
914 move |bytes, snapshot| {
915 let Some(velocity) = decode::<[f32; 2]>(bytes) else {
916 return;
917 };
918 if velocity.len() < expected {
919 return;
920 }
921 let velocity = &velocity[..expected];
922 snapshot.water_velocity = Some(WaterVelocitySnapshot {
923 grid: velocity_grid,
924 u: velocity.iter().map(|v| v[0]).collect(),
925 v: velocity.iter().map(|v| v[1]).collect(),
926 });
927 },
928 );
929}
930
931fn update_stats(
933 snapshot: Res<EnvironmentSnapshot>,
934 mut runtime: ResMut<HydrologyRuntime>,
935 mut stats: ResMut<HydrologyStats>,
936) {
937 let Some(water) = snapshot.water_depth.as_ref() else {
938 return;
939 };
940 if water.request == runtime.processed {
941 return;
942 }
943 runtime.processed = water.request;
944 let (volume0, input0) = *runtime
945 .baseline
946 .get_or_insert((water.volume_m3, water.input_m3));
947 let cell = f64::from(water.grid.cell_m);
948 runtime.wet_area_m2 = f64::from(water.wet_cells) * cell * cell;
949 stats.volume_m3 = water.volume_m3;
950 stats.lost_m3 = volume0 + (water.input_m3 - input0) - water.volume_m3;
951}
952
953#[cfg(test)]
954mod tests {
955 use bevy::ecs::system::RunSystemOnce;
956
957 use super::*;
958
959 #[test]
960 fn an_interrupted_bake_restarts_before_the_queue() {
961 let a = ErodeTerrain {
962 duration_s: 60.0,
963 rain_mm_h: 10.0,
964 };
965 let b = ErodeTerrain {
966 duration_s: 30.0,
967 rain_mm_h: 5.0,
968 };
969 let mut runtime = HydrologyRuntime {
970 bake: Some(Bake {
971 request: a,
972 remaining: 5,
973 done_at: 9,
974 }),
975 queue: VecDeque::from([b]),
976 ..default()
977 };
978 assert_eq!(runtime.take_requests(), VecDeque::from([a, b]));
979 assert!(runtime.bake.is_none());
980 assert!(runtime.queue.is_empty());
981 }
982
983 #[test]
986 fn a_source_in_feet_is_read_at_its_position_in_metres() {
987 let mut world = World::new();
988 world.insert_resource(ElementsUnits {
989 units_per_metre: 3.2808,
990 });
991 let at = |x: f32, z: f32| GlobalTransform::from_xyz(x * 3.2808, 7.0, z * 3.2808);
992 world.spawn((
993 WaterSource {
994 rate_m3_s: 0.5,
995 radius_m: 2.0,
996 },
997 at(10.0, 20.0),
998 ));
999 world.spawn((
1000 WaterSink {
1001 rate_m3_s: 0.25,
1002 radius_m: 1.0,
1003 },
1004 at(-4.0, 6.0),
1005 ));
1006 let (entries, cut) = world
1007 .run_system_once(
1008 |units: Res<ElementsUnits>,
1009 sources: Query<(Entity, &WaterSource, &GlobalTransform)>,
1010 sinks: Query<(Entity, &WaterSink, &GlobalTransform)>| {
1011 collect_sources(&units, &sources, &sinks)
1012 },
1013 )
1014 .expect("the system runs");
1015 assert!(!cut);
1016 assert_eq!(entries.len(), 2);
1017 let (source, sink) = (entries[0], entries[1]);
1018 assert!(
1019 source.0.distance(Vec2::new(10.0, 20.0)) < 1e-3,
1020 "{source:?}"
1021 );
1022 assert_eq!((source.1, source.2), (2.0, 0.5));
1023 assert!(sink.0.distance(Vec2::new(-4.0, 6.0)) < 1e-3, "{sink:?}");
1024 assert_eq!((sink.1, sink.2), (1.0, -0.25));
1025 }
1026}