Skip to main content

bevy_elements/hydrology/
mod.rs

1//! Hydrology and erosion: flooding, rivers and lakes as one shallow-water field on the terrain
2//! grid (Mei et al. 2007 virtual pipes with a depth-dependent pipe area), hydraulic erosion with
3//! flux-form sediment transport, and thermal erosion. See `docs/research/hydrology.md` and
4//! `docs/research/erosion.md`.
5//!
6//! The simulation runs on the [`Terrain`](crate::Terrain)'s grid and writes its `water`, `flux`,
7//! `velocity`, `suspended`, `bedrock` and `sediment` layers. It idles while there is no terrain.
8//! Each frame runs a whole number of fixed substeps ([`HydrologySettings::step_s`]) scheduled
9//! from real time; [`ErodeTerrain`] bakes erosion at the quality's full rate instead.
10//!
11//! Gameplay reads the water through
12//! [`Environment::water_depth`](crate::Environment::water_depth) and
13//! [`Environment::water_velocity`](crate::Environment::water_velocity); both are up to one
14//! readback interval old.
15
16mod 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
54/// Largest water readback edge: a larger simulation grid is box-averaged down by 2 first.
55const DEPTH_READBACK_MAX_CELLS: u32 = 1024;
56
57/// What happens at the edge of the terrain.
58#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Reflect)]
59#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
60pub enum HydrologyBoundary {
61    /// Water leaves over the edge as a free overfall at the edge cell's own bed level, and
62    /// nothing enters. Sediment leaves with it.
63    #[default]
64    Open,
65    /// The edge is a wall: no water or sediment crosses it.
66    Closed,
67}
68
69/// The water simulation's settings. Insert it before the plugin to override the defaults.
70#[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    /// The simulation step, s. Default 0.02.
78    pub step_s: f32,
79    /// Simulated seconds per real second, independent of the `WorldClock`, `Time<Virtual>` and
80    /// pausing. The substeps per frame are capped by the quality level (2, 4, 4 and 16 from
81    /// `Low` to `Ultra`, [`ElementsQuality::hydrology_substeps`]), so a large scale makes the
82    /// simulation run slower than asked rather than stall the frame. Default 1.
83    pub time_scale: f32,
84    /// The terrain's edge. Default [`HydrologyBoundary::Open`].
85    pub boundary: HydrologyBoundary,
86    /// Whether open water evaporates, at [`evaporation_mm_h`]'s rate for the weather. Default
87    /// `true`.
88    pub evaporation: bool,
89    /// Manning's roughness `n` of the ground, s m⁻¹ᐟ³ (natural channels 0.03–0.05). Default
90    /// 0.035.
91    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/// Hydraulic and thermal erosion. The water flows whether or not erosion is enabled.
107#[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    /// Whether the flow dissolves, carries and deposits sediment and the slopes relax. Default
115    /// `true`.
116    pub enabled: bool,
117    /// Capacity constant `K_c`, s: the water can carry `K_c · max(sin α, min_tilt) · |v|` metres
118    /// of solids per cell area. Default 0.6.
119    pub capacity: f32,
120    /// Dissolve fraction `K_s` per 20 ms step of the difference to capacity; scaled by
121    /// `step_s / 0.02` and capped at 1 per step. Default 0.03.
122    pub dissolve: f32,
123    /// Deposit fraction `K_d`, as `dissolve`. Default 0.03.
124    pub deposit: f32,
125    /// The smallest slope sine used for the capacity, so flat water still carries a little.
126    /// Default 0.05 (2.9°).
127    pub min_tilt: f32,
128    /// The angle of repose, degrees: thermal erosion moves loose sediment from slopes steeper
129    /// than this. Bedrock moves only above this plus 10°, and only during an [`ErodeTerrain`]
130    /// bake: a rock face stands at any angle on game time. Default 35.
131    pub talus_deg: f32,
132    /// The fraction of the largest stable transfer that one thermal pass (every 4th substep)
133    /// moves. Default 0.15.
134    pub thermal_rate: f32,
135    /// Multiplies `dissolve`, `deposit` and `thermal_rate` (each capped at 1). Default 1.
136    pub strength: f32,
137}
138
139/// Present while a voxel ground owns the terrain (`VoxelGround`, feature `isomesh`): erosion is
140/// off whatever [`ErosionSettings::enabled`] says, because the volume's field is the truth and
141/// erosion would rewrite only the heightfield under it.
142#[derive(Resource, Clone, Copy, Debug, Default)]
143#[cfg_attr(not(feature = "isomesh"), allow(dead_code))]
144pub(crate) struct ErosionLocked;
145
146/// Whether the hydrology kernels erode: the setting, unless a voxel ground locks it off.
147pub(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/// Pours water into the terrain: `rate_m3_s` spread evenly over the cells within `radius_m` of
167/// the entity's position (at least the cell under it). A source off the terrain does nothing. At
168/// most [`crate::MAX_WATER_SOURCES`] sources and as many [`WaterSink`]s are used (lowest
169/// `Entity` first).
170#[derive(Component, Clone, Copy, Debug, PartialEq, Reflect)]
171#[require(Transform)]
172#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
173pub struct WaterSource {
174    /// Volume per second, m³/s.
175    pub rate_m3_s: f32,
176    /// Radius of the footprint, m.
177    pub radius_m: f32,
178}
179
180/// Drains water from the terrain: up to `rate_m3_s` from the cells within `radius_m` of the
181/// entity's position, never more than is there. See [`WaterSource`].
182#[derive(Component, Clone, Copy, Debug, PartialEq, Reflect)]
183#[require(Transform)]
184#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
185pub struct WaterSink {
186    /// Volume per second, m³/s.
187    pub rate_m3_s: f32,
188    /// Radius of the footprint, m.
189    pub radius_m: f32,
190}
191
192/// Start an erosion bake: hydrology runs at the quality's full substep rate with `rain_mm_h` of
193/// uniform liquid rain (the weather's rain is ignored) until `duration_s` of simulated time has
194/// passed, then [`TerrainBaked`] is written. A bake requested during a bake starts when the
195/// first one ends; a bake interrupted by the terrain being rebuilt or removed restarts in full
196/// on the next terrain.
197#[derive(Message, Clone, Copy, Debug, PartialEq)]
198#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
199pub struct ErodeTerrain {
200    /// Simulated time to bake, s.
201    pub duration_s: f32,
202    /// Uniform rain over the whole terrain, mm/h.
203    pub rain_mm_h: f32,
204}
205
206/// A bake finished: its `duration_s` of simulated time has run.
207#[derive(Message, Clone, Copy, Debug, PartialEq)]
208#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
209pub struct TerrainBaked {
210    /// The simulated time that was baked, s.
211    pub duration_s: f32,
212}
213
214/// What the simulation has done, for tests and examples that wait on simulated time. Reset when
215/// the terrain is rebuilt.
216#[derive(Resource, Clone, Debug, Default, PartialEq)]
217pub struct HydrologyStats {
218    /// Substeps the GPU has been asked to run and has recorded.
219    pub substeps: u64,
220    /// `substeps · step_s`, s.
221    pub simulated_s: f64,
222    /// Water on the terrain at the last depth readback, m³.
223    pub volume_m3: f64,
224    /// An estimate of the water that left through an open boundary or a sink, m³: the volume at
225    /// the first readback plus the nominal input since (rain over the whole extent at the
226    /// reference elevation's phase, sources at their rate, sinks at their rate, evaporation over
227    /// the cells that were wet at the last readback) minus `volume_m3`. It is up to one readback
228    /// interval old and does not know the per-cell snow split or the blockers' shade.
229    pub lost_m3: f64,
230    /// A bake is running.
231    pub baking: bool,
232    /// Substeps that were scheduled but could not be recorded (a pipeline or buffer was not
233    /// ready on the render side that frame). Zero in normal running; their rain and evaporation
234    /// are lost with them.
235    pub dropped: u64,
236}
237
238/// The depth readback: `Environment::water_depth` answers from it.
239#[derive(Clone, Debug)]
240pub(crate) struct WaterDepthSnapshot {
241    /// The readback grid (the simulation grid box-averaged by 2 above 1024²).
242    pub grid: TerrainGrid,
243    pub depth: Arc<[f32]>,
244    /// `Σ depth · cell²` over the readback, m³.
245    pub volume_m3: f64,
246    /// Cells deeper than the wet threshold.
247    pub wet_cells: u32,
248    /// The request this was read for, and the nominal input at that request.
249    pub request: u64,
250    pub input_m3: f64,
251}
252
253/// The velocity readback at half resolution, depth-weighted.
254#[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/// Uniform of the hydrology kernels. Mirrors `HydroParams` of `substep.wesl`.
278#[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    /// x: Δt. y: Manning n. z: pipe depth cap, m. w: wet threshold, m.
286    sim: Vec4,
287    /// x: rain quantum, m. y: evaporation quantum, m. z: terrain y0 − reference elevation, m.
288    /// w: air temperature at the reference elevation, °C.
289    weather: Vec4,
290    /// x: lapse, °C/m. y: speed clamp, m/s. z: 1 in a bake. w: `Weather::snow_possible`.
291    misc: Vec4,
292    /// x: closed boundary. y: erosion on. z: sources uploaded. w: blockers uploaded.
293    flags: UVec4,
294    /// x: K_c. y: min tilt. z: dissolve per step. w: deposit per step.
295    erosion: Vec4,
296    /// x: tan talus. y: tan (talus + 10°). z: thermal fraction per pass.
297    thermal: Vec4,
298    /// x: terrain y0. y: depth readback downsample.
299    extra: Vec4,
300}
301
302impl HydroParams {
303    /// The uniform of a thermal pass that is not hydrology's: the grid, the talus and the
304    /// thermal fraction. Bedrock moves only above `talus_deg + bedrock_extra_deg`, and an
305    /// excess that reaches 90° pins the threshold out of reach (no tangent), so the pass moves
306    /// loose sediment only.
307    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/// The readback targets of the active terrain.
335#[derive(Clone, Debug)]
336pub(crate) struct ReadbackBuffers {
337    /// Box-averaged water depth, `array<f32>`.
338    pub depth: Handle<ShaderBuffer>,
339    /// Depth-weighted velocity at half resolution, `array<vec2<f32>>`.
340    pub velocity: Handle<ShaderBuffer>,
341    /// Depth readback downsample factor.
342    pub depth_ds: u32,
343}
344
345/// This frame's work and the GPU's progress; extracted to the render world every frame.
346#[derive(Resource, Clone)]
347pub(crate) struct HydrologyGpu {
348    pub buffers: ReadbackBuffers,
349    pub params: HydroParams,
350    /// Substeps to run this frame.
351    pub substeps: u32,
352    /// Substeps run since the start (so the thermal passes keep their phase across frames).
353    pub thermal_phase: u32,
354    pub thermal: bool,
355    /// Source entries (xy centre, z radius, w depth rate) and blockers for `source_map_build`.
356    pub sources: Vec<Vec4>,
357    pub blockers: Vec<BlockerGpu>,
358    /// The source map is current when `source_built` has caught up with this.
359    pub source_epoch: u64,
360    pub source_built: Arc<AtomicU64>,
361    /// Run the readback kernels this frame.
362    pub downsample: bool,
363    /// Substeps recorded, and substeps asked for that could not be.
364    pub executed: Arc<AtomicU64>,
365    pub dropped: Arc<AtomicU64>,
366    /// The render world has every pipeline and buffer it needs.
367    pub ready: Arc<AtomicBool>,
368    /// A first dispatch has run: the buffers hold simulation state.
369    pub live: Arc<AtomicBool>,
370}
371
372/// A running bake.
373#[derive(Clone, Copy, Debug)]
374struct Bake {
375    request: ErodeTerrain,
376    /// Substeps not yet scheduled.
377    remaining: u64,
378    /// `executed + dropped` at which the last substep has been recorded.
379    done_at: u64,
380}
381
382/// What the sources, sinks and blockers looked like when their footprints were last rasterised.
383#[derive(Clone, Debug, Default, PartialEq)]
384struct SourceInputs {
385    grid: Option<(Vec2, f32, UVec2)>,
386    /// (world XZ, radius, signed rate) of every source and sink.
387    entries: Vec<(Vec2, f32, f32)>,
388}
389
390/// Main-world bookkeeping of the simulation.
391#[derive(Resource, Default)]
392struct HydrologyRuntime {
393    /// The water buffer the current GPU resources were built for.
394    built_for: Option<AssetId<ShaderBuffer>>,
395    scheduler: Scheduler,
396    rain: QuantumAccumulator,
397    evaporation: QuantumAccumulator,
398    bake: Option<Bake>,
399    queue: VecDeque<ErodeTerrain>,
400    /// Substeps scheduled since the terrain was built.
401    scheduled: u64,
402    source_epoch: u64,
403    inputs: SourceInputs,
404    sources: Vec<Vec4>,
405    blockers: Vec<BlockerGpu>,
406    warned_cap: bool,
407    /// Nominal water input since the start, m³ (see `HydrologyStats::lost_m3`).
408    input_m3: f64,
409    /// The readback requests issued, and the first processed one's volume and input.
410    requests: u64,
411    processed: u64,
412    baseline: Option<(f64, f64)>,
413    /// Wet cells at the last readback.
414    wet_area_m2: f64,
415}
416
417impl HydrologyRuntime {
418    /// The bake requests to keep across a rebuild: the running bake first (it restarts in full on
419    /// the new layers), then the queue.
420    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
429/// Hydrology, erosion and the water readback.
430///
431/// Needs a [`Terrain`](crate::Terrain); idles without one. Inserts, if absent,
432/// [`HydrologySettings`], [`ErosionSettings`] and [`HydrologyStats`].
433pub 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
465/// Readback sizes of a grid: the depth downsample factor.
466fn depth_downsample(size: UVec2) -> u32 {
467    if size.max_element() > DEPTH_READBACK_MAX_CELLS {
468        2
469    } else {
470        1
471    }
472}
473
474/// Create the readback targets when a terrain appears or is rebuilt, and drop everything when it
475/// goes.
476#[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            // A bake asked for before the terrain exists (or while it is rebuilt) waits for it.
490            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/// The settings and environment the schedule reads.
549#[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
563/// The raw descriptions of the sources and sinks (positions in the metre frame, from their world
564/// translations through `units`), in a deterministic order, and whether the cap cut any off.
565fn 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/// Schedule this frame's substeps and fill the uniform, the sources and the bake bookkeeping.
600#[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    // Finish a bake whose last substep has been recorded; start the next one.
642    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        // `step_s` is an f32, so 600 s / 0.02 s is 30 000.0007: allow a thousandth of a step.
656        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    // Footprints of the sources and sinks, and the blockers; the map is rebuilt when they change.
674    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    // This frame's substeps: the bake's, or the real-time schedule's.
708    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            // Nothing to simulate: no substeps, and no backlog to burst through on resuming.
737            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        // The nominal water input of these substeps (see `HydrologyStats::lost_m3`).
764        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    // Rock faces collapse on geological time, so only a bake moves bedrock (erosion.md
782    // Deviation 15); on game time thermal erosion is the slumping of loose sediment.
783    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    // Never an empty binding: the counts say how many entries are real.
838    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    // Until a readback kernel has run once the readback buffers hold nothing: keep running them.
850    gpu.downsample = tick.is_due() || !gpu.live.load(Ordering::Relaxed);
851    runtime.scheduled += u64::from(n);
852    if n > 0 {
853        // The pack, bounds and height readback run once this frame, after the substeps.
854        terrain_gpu.changes += 1;
855    }
856}
857
858/// Request the water readbacks once the kernels have produced state.
859fn 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
931/// Fold a new depth readback into [`HydrologyStats`].
932fn 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    /// A source and a sink placed in feet land at the metre-frame cell the same scene in metres
984    /// has them in; their radius and rate stay in m and m³/s.
985    #[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}