mod gpu;
pub(crate) use gpu::{HydrologyDispatch, HydrologyPipelines};
pub(crate) mod model;
mod water;
mod water_gpu;
pub use water::{
WaterExtension, WaterMaterial, WaterParams, WaterPatch, WaterPlugin, WaterSettings, WaterState,
};
use std::collections::VecDeque;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use bevy::asset::embedded_asset;
use bevy::ecs::system::SystemParam;
use bevy::math::UVec4;
use bevy::prelude::*;
use bevy::render::render_resource::ShaderType;
use bevy::render::storage::ShaderBuffer;
use crate::climate::{Climate, LAPSE_RATE_C_PER_M, Weather};
use crate::gpu::grid::zeroed_buffer;
use crate::gpu::readback::{ReadbackTick, Readbacks, decode};
use crate::plugin::{
ElementsQuality, ElementsSystems, ElementsUnits, ElementsView, ensure_gpu_core,
};
use crate::precipitation::{BlockerGpu, PrecipitationBlocker, nearest_blockers};
use crate::query::EnvironmentSnapshot;
use crate::terrain::{TerrainGpu, TerrainGrid, TerrainState};
pub use model::{MAX_WATER_SOURCES, evaporation_mm_h};
use model::{
MM_H_TO_M_S, QuantumAccumulator, Scheduler, THERMAL_PERIOD, depth_cap_m, source_entry,
speed_cap_ms, step_fraction, wet_depth_m,
};
const DEPTH_READBACK_MAX_CELLS: u32 = 1024;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Reflect)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum HydrologyBoundary {
#[default]
Open,
Closed,
}
#[derive(Resource, Clone, Debug, PartialEq, Reflect)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct HydrologySettings {
pub step_s: f32,
pub time_scale: f32,
pub boundary: HydrologyBoundary,
pub evaporation: bool,
pub manning_n: f32,
}
impl Default for HydrologySettings {
fn default() -> Self {
Self {
step_s: 0.02,
time_scale: 1.0,
boundary: HydrologyBoundary::Open,
evaporation: true,
manning_n: 0.035,
}
}
}
#[derive(Resource, Clone, Debug, PartialEq, Reflect)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ErosionSettings {
pub enabled: bool,
pub capacity: f32,
pub dissolve: f32,
pub deposit: f32,
pub min_tilt: f32,
pub talus_deg: f32,
pub thermal_rate: f32,
pub strength: f32,
}
#[derive(Resource, Clone, Copy, Debug, Default)]
#[cfg_attr(not(feature = "isomesh"), allow(dead_code))]
pub(crate) struct ErosionLocked;
pub(crate) fn erosion_enabled(erosion: &ErosionSettings, locked: bool) -> bool {
erosion.enabled && !locked
}
impl Default for ErosionSettings {
fn default() -> Self {
Self {
enabled: true,
capacity: 0.6,
dissolve: 0.03,
deposit: 0.03,
min_tilt: 0.05,
talus_deg: 35.0,
thermal_rate: 0.15,
strength: 1.0,
}
}
}
#[derive(Component, Clone, Copy, Debug, PartialEq, Reflect)]
#[require(Transform)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct WaterSource {
pub rate_m3_s: f32,
pub radius_m: f32,
}
#[derive(Component, Clone, Copy, Debug, PartialEq, Reflect)]
#[require(Transform)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct WaterSink {
pub rate_m3_s: f32,
pub radius_m: f32,
}
#[derive(Message, Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ErodeTerrain {
pub duration_s: f32,
pub rain_mm_h: f32,
}
#[derive(Message, Clone, Copy, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct TerrainBaked {
pub duration_s: f32,
}
#[derive(Resource, Clone, Debug, Default, PartialEq)]
pub struct HydrologyStats {
pub substeps: u64,
pub simulated_s: f64,
pub volume_m3: f64,
pub lost_m3: f64,
pub baking: bool,
pub dropped: u64,
}
#[derive(Clone, Debug)]
pub(crate) struct WaterDepthSnapshot {
pub grid: TerrainGrid,
pub depth: Arc<[f32]>,
pub volume_m3: f64,
pub wet_cells: u32,
pub request: u64,
pub input_m3: f64,
}
#[derive(Clone, Debug)]
pub(crate) struct WaterVelocitySnapshot {
pub grid: TerrainGrid,
pub u: Arc<[f32]>,
pub v: Arc<[f32]>,
}
impl WaterDepthSnapshot {
pub(crate) fn depth_at(&self, p: Vec2) -> Option<f32> {
self.grid
.contains(p)
.then(|| self.grid.sample(&self.depth, p).max(0.0))
}
}
impl WaterVelocitySnapshot {
pub(crate) fn velocity_at(&self, p: Vec2) -> Option<Vec2> {
self.grid
.contains(p)
.then(|| Vec2::new(self.grid.sample(&self.u, p), self.grid.sample(&self.v, p)))
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, ShaderType)]
pub(crate) struct HydroParams {
origin: Vec2,
cell: f32,
pad0: f32,
size: UVec2,
pad1: UVec2,
sim: Vec4,
weather: Vec4,
misc: Vec4,
flags: UVec4,
erosion: Vec4,
thermal: Vec4,
extra: Vec4,
}
impl HydroParams {
pub(crate) fn avalanche(
grid: &TerrainGrid,
talus_deg: f32,
bedrock_extra_deg: f32,
rate: f32,
) -> Self {
let bedrock_angle = talus_deg + bedrock_extra_deg;
let tan_bedrock = if bedrock_angle >= 89.9 {
model::UNREACHABLE_TAN
} else {
bedrock_angle.to_radians().tan()
};
Self {
origin: grid.origin,
cell: grid.cell_m,
size: grid.size,
thermal: Vec4::new(
talus_deg.to_radians().tan(),
tan_bedrock,
rate.clamp(0.0, 1.0),
0.0,
),
..Self::default()
}
}
}
#[derive(Clone, Debug)]
pub(crate) struct ReadbackBuffers {
pub depth: Handle<ShaderBuffer>,
pub velocity: Handle<ShaderBuffer>,
pub depth_ds: u32,
}
#[derive(Resource, Clone)]
pub(crate) struct HydrologyGpu {
pub buffers: ReadbackBuffers,
pub params: HydroParams,
pub substeps: u32,
pub thermal_phase: u32,
pub thermal: bool,
pub sources: Vec<Vec4>,
pub blockers: Vec<BlockerGpu>,
pub source_epoch: u64,
pub source_built: Arc<AtomicU64>,
pub downsample: bool,
pub executed: Arc<AtomicU64>,
pub dropped: Arc<AtomicU64>,
pub ready: Arc<AtomicBool>,
pub live: Arc<AtomicBool>,
}
#[derive(Clone, Copy, Debug)]
struct Bake {
request: ErodeTerrain,
remaining: u64,
done_at: u64,
}
#[derive(Clone, Debug, Default, PartialEq)]
struct SourceInputs {
grid: Option<(Vec2, f32, UVec2)>,
entries: Vec<(Vec2, f32, f32)>,
}
#[derive(Resource, Default)]
struct HydrologyRuntime {
built_for: Option<AssetId<ShaderBuffer>>,
scheduler: Scheduler,
rain: QuantumAccumulator,
evaporation: QuantumAccumulator,
bake: Option<Bake>,
queue: VecDeque<ErodeTerrain>,
scheduled: u64,
source_epoch: u64,
inputs: SourceInputs,
sources: Vec<Vec4>,
blockers: Vec<BlockerGpu>,
warned_cap: bool,
input_m3: f64,
requests: u64,
processed: u64,
baseline: Option<(f64, f64)>,
wet_area_m2: f64,
}
impl HydrologyRuntime {
fn take_requests(&mut self) -> VecDeque<ErodeTerrain> {
let mut queue = std::mem::take(&mut self.queue);
if let Some(bake) = self.bake.take() {
queue.push_front(bake.request);
}
queue
}
}
pub struct HydrologyPlugin;
impl Plugin for HydrologyPlugin {
fn build(&self, app: &mut App) {
ensure_gpu_core(app);
embedded_asset!(app, "substep.wesl");
embedded_asset!(app, "thermal.wesl");
embedded_asset!(app, "source_map.wesl");
embedded_asset!(app, "readback.wesl");
app.init_resource::<HydrologySettings>()
.init_resource::<ErosionSettings>()
.init_resource::<HydrologyStats>()
.init_resource::<Climate>()
.init_resource::<HydrologyRuntime>()
.add_message::<ErodeTerrain>()
.add_message::<TerrainBaked>()
.add_systems(
Update,
(ensure_hydrology, run_hydrology)
.chain()
.in_set(ElementsSystems::Simulation),
)
.add_systems(
Update,
(read_water, update_stats)
.chain()
.in_set(ElementsSystems::Readback),
);
gpu::plugin(app);
}
}
fn depth_downsample(size: UVec2) -> u32 {
if size.max_element() > DEPTH_READBACK_MAX_CELLS {
2
} else {
1
}
}
#[allow(clippy::too_many_arguments)]
fn ensure_hydrology(
mut commands: Commands,
terrain: Option<Res<TerrainState>>,
gpu: Option<Res<HydrologyGpu>>,
mut runtime: ResMut<HydrologyRuntime>,
mut stats: ResMut<HydrologyStats>,
mut snapshot: ResMut<EnvironmentSnapshot>,
mut buffers: ResMut<Assets<ShaderBuffer>>,
) {
let Some(terrain) = terrain else {
if gpu.is_some() {
commands.remove_resource::<HydrologyGpu>();
let queue = runtime.take_requests();
*runtime = HydrologyRuntime {
queue,
..HydrologyRuntime::default()
};
*stats = HydrologyStats::default();
snapshot.water_depth = None;
snapshot.water_velocity = None;
}
return;
};
if runtime.built_for == Some(terrain.water.id()) && gpu.is_some() {
return;
}
let size = terrain.grid.size;
let ds = depth_downsample(size);
let depth_cells = u64::from(size.x / ds) * u64::from(size.y / ds);
let velocity_cells = u64::from(size.x / 2) * u64::from(size.y / 2);
let readback = ReadbackBuffers {
depth: zeroed_buffer(
&mut buffers,
"elements_hydrology_depth_readback",
depth_cells * 4,
),
velocity: zeroed_buffer(
&mut buffers,
"elements_hydrology_velocity_readback",
velocity_cells * 8,
),
depth_ds: ds,
};
*runtime = HydrologyRuntime {
built_for: Some(terrain.water.id()),
source_epoch: 1,
queue: runtime.take_requests(),
..HydrologyRuntime::default()
};
*stats = HydrologyStats::default();
snapshot.water_depth = None;
snapshot.water_velocity = None;
commands.insert_resource(HydrologyGpu {
buffers: readback,
params: HydroParams::default(),
substeps: 0,
thermal_phase: 0,
thermal: false,
sources: Vec::new(),
blockers: Vec::new(),
source_epoch: 1,
source_built: Arc::new(AtomicU64::new(0)),
downsample: false,
executed: Arc::new(AtomicU64::new(0)),
dropped: Arc::new(AtomicU64::new(0)),
ready: Arc::new(AtomicBool::new(false)),
live: Arc::new(AtomicBool::new(false)),
});
}
#[derive(SystemParam)]
struct Inputs<'w> {
settings: Res<'w, HydrologySettings>,
erosion: Res<'w, ErosionSettings>,
quality: Res<'w, ElementsQuality>,
weather: Res<'w, Weather>,
climate: Res<'w, Climate>,
view: Res<'w, ElementsView>,
time: Res<'w, Time<Real>>,
units: Res<'w, ElementsUnits>,
erosion_locked: Option<Res<'w, ErosionLocked>>,
}
fn collect_sources(
units: &ElementsUnits,
sources: &Query<(Entity, &WaterSource, &GlobalTransform)>,
sinks: &Query<(Entity, &WaterSink, &GlobalTransform)>,
) -> (Vec<(Vec2, f32, f32)>, bool) {
fn take<'a>(
items: impl Iterator<Item = (Entity, Vec2, f32, f32)> + 'a,
) -> (Vec<(Vec2, f32, f32)>, bool) {
let mut all: Vec<_> = items.collect();
all.sort_by_key(|item| item.0);
let cut = all.len() > MAX_WATER_SOURCES;
all.truncate(MAX_WATER_SOURCES);
(all.into_iter().map(|(_, p, r, q)| (p, r, q)).collect(), cut)
}
let (mut entries, cut_sources) = take(sources.iter().map(|(e, s, t)| {
(
e,
units.point_to_metres(t.translation()).xz(),
s.radius_m,
s.rate_m3_s,
)
}));
let (sink_entries, cut_sinks) = take(sinks.iter().map(|(e, s, t)| {
(
e,
units.point_to_metres(t.translation()).xz(),
s.radius_m,
-s.rate_m3_s,
)
}));
entries.extend(sink_entries);
(entries, cut_sources || cut_sinks)
}
#[allow(clippy::too_many_arguments)]
fn run_hydrology(
inputs: Inputs,
terrain: Option<Res<TerrainState>>,
terrain_gpu: Option<ResMut<TerrainGpu>>,
gpu: Option<ResMut<HydrologyGpu>>,
mut runtime: ResMut<HydrologyRuntime>,
mut stats: ResMut<HydrologyStats>,
sources: Query<(Entity, &WaterSource, &GlobalTransform)>,
sinks: Query<(Entity, &WaterSink, &GlobalTransform)>,
blockers: Query<(Entity, &PrecipitationBlocker, &GlobalTransform)>,
tick: Res<ReadbackTick>,
mut requests: MessageReader<ErodeTerrain>,
mut baked: MessageWriter<TerrainBaked>,
) {
runtime.queue.extend(requests.read().copied());
let (Some(terrain), Some(mut terrain_gpu), Some(mut gpu)) = (terrain, terrain_gpu, gpu) else {
return;
};
let Inputs {
settings,
erosion,
quality,
weather,
climate,
view,
time,
erosion_locked,
units,
} = inputs;
let erosion_on = erosion_enabled(&erosion, erosion_locked.is_some());
let grid = terrain.grid;
let step_s = settings.step_s.max(1e-4);
let executed = gpu.executed.load(Ordering::Relaxed);
let dropped = gpu.dropped.load(Ordering::Relaxed);
stats.substeps = executed;
stats.simulated_s = executed as f64 * f64::from(step_s);
stats.dropped = dropped;
if let Some(bake) = runtime.bake
&& bake.remaining == 0
&& executed + dropped >= bake.done_at
{
baked.write(TerrainBaked {
duration_s: bake.request.duration_s,
});
runtime.bake = None;
runtime.scheduler.reset();
}
if runtime.bake.is_none()
&& let Some(request) = runtime.queue.pop_front()
{
let steps = f64::from(request.duration_s.max(0.0)) / f64::from(step_s);
let total = (steps - 1e-3).ceil().max(0.0) as u64;
let done_at = runtime.scheduled + total;
if total == 0 {
baked.write(TerrainBaked {
duration_s: request.duration_s,
});
} else {
runtime.bake = Some(Bake {
request,
remaining: total,
done_at,
});
}
}
stats.baking = runtime.bake.is_some();
let (entries, cut) = collect_sources(&units, &sources, &sinks);
if cut && !runtime.warned_cap {
warn!(
"more than {MAX_WATER_SOURCES} WaterSources or WaterSinks exist; the extra ones (highest Entity) are ignored"
);
runtime.warned_cap = true;
}
let source_inputs = SourceInputs {
grid: Some((grid.origin, grid.cell_m, grid.size)),
entries,
};
if runtime.inputs != source_inputs {
runtime.sources = source_inputs
.entries
.iter()
.filter_map(|&(p, radius, rate)| {
source_entry(grid.origin, grid.cell_m, grid.size, p, radius, rate)
})
.collect();
runtime.inputs = source_inputs;
runtime.source_epoch += 1;
}
let selected = nearest_blockers(
view.position,
blockers.iter().filter_map(|(entity, blocker, transform)| {
BlockerGpu::new(blocker.half_extents, &units.to_metres(transform)).map(|b| (entity, b))
}),
);
if runtime.blockers != selected {
runtime.blockers = selected;
runtime.source_epoch += 1;
}
let ready = gpu.ready.load(Ordering::Relaxed)
&& terrain.is_live()
&& terrain_gpu.generated.load(Ordering::Relaxed);
let max = quality.hydrology_substeps();
let n = if !ready {
0
} else if let Some(bake) = runtime.bake.as_mut() {
let n = bake.remaining.min(u64::from(max));
bake.remaining -= n;
n as u32
} else {
let dt = time.delta_secs_f64();
runtime.scheduler.step(dt, settings.time_scale, step_s, max)
};
let baking = runtime.bake.is_some();
let rain_mm_h = match runtime.bake {
Some(bake) => bake.request.rain_mm_h,
None => weather.precipitation_mm_h,
};
let (rain_quantum, evaporation_quantum) = if n > 0 {
let rain_rate = f64::from(rain_mm_h.max(0.0)) * MM_H_TO_M_S;
let evap_rate = if settings.evaporation {
f64::from(evaporation_mm_h(
weather.wind_speed_ms,
weather.temperature_c,
weather.relative_humidity,
)) * MM_H_TO_M_S
} else {
0.0
};
let quanta = (runtime.rain.take(), runtime.evaporation.take());
runtime.rain.owe(rain_rate, step_s, n);
runtime.evaporation.owe(evap_rate, step_s, n);
let seconds = f64::from(step_s) * f64::from(n);
let area = f64::from(grid.extent().x) * f64::from(grid.extent().y);
let liquid = if baking {
1.0
} else {
f64::from(1.0 - weather.snow_fraction())
};
let source_rate: f64 = runtime.inputs.entries.iter().map(|e| f64::from(e.2)).sum();
runtime.input_m3 +=
seconds * (rain_rate * liquid * area - evap_rate * runtime.wet_area_m2 + source_rate);
quanta
} else {
(0.0, 0.0)
};
let cell = grid.cell_m;
let tan_talus = erosion.talus_deg.to_radians().tan();
let tan_bedrock = if runtime.bake.is_some() {
(erosion.talus_deg + model::BEDROCK_EXTRA_DEG)
.to_radians()
.tan()
} else {
model::UNREACHABLE_TAN
};
gpu.params = HydroParams {
origin: grid.origin,
cell,
pad0: 0.0,
size: grid.size,
pad1: UVec2::ZERO,
sim: Vec4::new(
step_s,
settings.manning_n.max(0.0),
depth_cap_m(cell, step_s),
wet_depth_m(cell),
),
weather: Vec4::new(
rain_quantum,
evaporation_quantum,
grid.y0 - climate.reference_elevation_m,
weather.temperature_c,
),
misc: Vec4::new(
LAPSE_RATE_C_PER_M,
speed_cap_ms(cell, step_s),
f32::from(u8::from(runtime.bake.is_some())),
weather.snow_possible,
),
flags: UVec4::new(
u32::from(settings.boundary == HydrologyBoundary::Closed),
u32::from(erosion_on),
runtime.sources.len() as u32,
runtime.blockers.len() as u32,
),
erosion: Vec4::new(
erosion.capacity,
erosion.min_tilt,
step_fraction(erosion.dissolve, erosion.strength, step_s),
step_fraction(erosion.deposit, erosion.strength, step_s),
),
thermal: Vec4::new(
tan_talus,
tan_bedrock,
(erosion.thermal_rate * erosion.strength).clamp(0.0, 1.0),
0.0,
),
extra: Vec4::new(grid.y0, gpu.buffers.depth_ds as f32, 0.0, 0.0),
};
gpu.substeps = n;
gpu.thermal_phase = (runtime.scheduled % THERMAL_PERIOD) as u32;
gpu.thermal = erosion_on;
gpu.sources = if runtime.sources.is_empty() {
vec![Vec4::ZERO]
} else {
runtime.sources.clone()
};
gpu.blockers = if runtime.blockers.is_empty() {
vec![BlockerGpu::default()]
} else {
runtime.blockers.clone()
};
gpu.source_epoch = runtime.source_epoch;
gpu.downsample = tick.is_due() || !gpu.live.load(Ordering::Relaxed);
runtime.scheduled += u64::from(n);
if n > 0 {
terrain_gpu.changes += 1;
}
}
fn read_water(
terrain: Option<Res<TerrainState>>,
gpu: Option<Res<HydrologyGpu>>,
mut runtime: ResMut<HydrologyRuntime>,
tick: Res<ReadbackTick>,
mut readbacks: Readbacks,
) {
let (Some(terrain), Some(gpu)) = (terrain, gpu) else {
return;
};
if !gpu.live.load(Ordering::Relaxed) || !tick.is_due() {
return;
}
let ds = gpu.buffers.depth_ds;
let grid = terrain.grid;
let wet = wet_depth_m(grid.cell_m);
let depth_grid = TerrainGrid {
cell_m: grid.cell_m * ds as f32,
size: grid.size / ds,
..grid
};
let expected = (depth_grid.size.x * depth_grid.size.y) as usize;
runtime.requests += 1;
let (request, input_m3) = (runtime.requests, runtime.input_m3);
readbacks.request(
"water_depth",
gpu.buffers.depth.clone(),
move |bytes, snapshot| {
let Some(depth) = decode::<f32>(bytes) else {
return;
};
if depth.len() < expected {
return;
}
let depth = &depth[..expected];
let area = f64::from(depth_grid.cell_m) * f64::from(depth_grid.cell_m);
snapshot.water_depth = Some(WaterDepthSnapshot {
grid: depth_grid,
volume_m3: depth.iter().map(|d| f64::from(*d)).sum::<f64>() * area,
wet_cells: depth.iter().filter(|d| **d > wet).count() as u32,
depth: depth.into(),
request,
input_m3,
});
},
);
let velocity_grid = TerrainGrid {
cell_m: grid.cell_m * 2.0,
size: grid.size / 2,
..grid
};
let expected = (velocity_grid.size.x * velocity_grid.size.y) as usize;
readbacks.request(
"water_velocity",
gpu.buffers.velocity.clone(),
move |bytes, snapshot| {
let Some(velocity) = decode::<[f32; 2]>(bytes) else {
return;
};
if velocity.len() < expected {
return;
}
let velocity = &velocity[..expected];
snapshot.water_velocity = Some(WaterVelocitySnapshot {
grid: velocity_grid,
u: velocity.iter().map(|v| v[0]).collect(),
v: velocity.iter().map(|v| v[1]).collect(),
});
},
);
}
fn update_stats(
snapshot: Res<EnvironmentSnapshot>,
mut runtime: ResMut<HydrologyRuntime>,
mut stats: ResMut<HydrologyStats>,
) {
let Some(water) = snapshot.water_depth.as_ref() else {
return;
};
if water.request == runtime.processed {
return;
}
runtime.processed = water.request;
let (volume0, input0) = *runtime
.baseline
.get_or_insert((water.volume_m3, water.input_m3));
let cell = f64::from(water.grid.cell_m);
runtime.wet_area_m2 = f64::from(water.wet_cells) * cell * cell;
stats.volume_m3 = water.volume_m3;
stats.lost_m3 = volume0 + (water.input_m3 - input0) - water.volume_m3;
}
#[cfg(test)]
mod tests {
use bevy::ecs::system::RunSystemOnce;
use super::*;
#[test]
fn an_interrupted_bake_restarts_before_the_queue() {
let a = ErodeTerrain {
duration_s: 60.0,
rain_mm_h: 10.0,
};
let b = ErodeTerrain {
duration_s: 30.0,
rain_mm_h: 5.0,
};
let mut runtime = HydrologyRuntime {
bake: Some(Bake {
request: a,
remaining: 5,
done_at: 9,
}),
queue: VecDeque::from([b]),
..default()
};
assert_eq!(runtime.take_requests(), VecDeque::from([a, b]));
assert!(runtime.bake.is_none());
assert!(runtime.queue.is_empty());
}
#[test]
fn a_source_in_feet_is_read_at_its_position_in_metres() {
let mut world = World::new();
world.insert_resource(ElementsUnits {
units_per_metre: 3.2808,
});
let at = |x: f32, z: f32| GlobalTransform::from_xyz(x * 3.2808, 7.0, z * 3.2808);
world.spawn((
WaterSource {
rate_m3_s: 0.5,
radius_m: 2.0,
},
at(10.0, 20.0),
));
world.spawn((
WaterSink {
rate_m3_s: 0.25,
radius_m: 1.0,
},
at(-4.0, 6.0),
));
let (entries, cut) = world
.run_system_once(
|units: Res<ElementsUnits>,
sources: Query<(Entity, &WaterSource, &GlobalTransform)>,
sinks: Query<(Entity, &WaterSink, &GlobalTransform)>| {
collect_sources(&units, &sources, &sinks)
},
)
.expect("the system runs");
assert!(!cut);
assert_eq!(entries.len(), 2);
let (source, sink) = (entries[0], entries[1]);
assert!(
source.0.distance(Vec2::new(10.0, 20.0)) < 1e-3,
"{source:?}"
);
assert_eq!((source.1, source.2), (2.0, 0.5));
assert!(sink.0.distance(Vec2::new(-4.0, 6.0)) < 1e-3, "{sink:?}");
assert_eq!((sink.1, sink.2), (1.0, -0.25));
}
}