#![allow(clippy::chunks_exact_to_as_chunks)]
mod support;
use std::time::Duration;
use bevy::app::AppExit;
use bevy::light::light_consts::lux;
use bevy::prelude::*;
use bevy::render::render_resource::TextureFormat;
use bevy::time::TimeUpdateStrategy;
use bevy_elements::*;
#[derive(Resource, Clone, Copy, Default)]
struct CoverOverride(Option<f32>);
fn force_cover(over: Res<CoverOverride>, mut weather: ResMut<Weather>) {
if let Some(cover) = over.0 {
weather.cloud_cover = cover;
}
}
#[derive(Resource, Default)]
struct CreatedShadow {
format: Option<TextureFormat>,
size: (u32, u32),
first_texels: Vec<u8>,
}
fn record_created_shadow(
shadows: Res<CloudShadows>,
images: Res<Assets<Image>>,
mut created: ResMut<CreatedShadow>,
) {
if let Some(image) = images.get(&shadows.map) {
created.format = Some(image.texture_descriptor.format);
created.size = (image.width(), image.height());
created.first_texels = image.data.clone().unwrap_or_default();
}
}
#[derive(Clone, Copy)]
struct Setup {
preset: WeatherPreset,
position: Vec3,
quality: ElementsQuality,
}
fn app(setup: Setup) -> App {
let mut app = App::new();
app.add_plugins((support::headless_plugins(), ElementsPlugins))
.insert_resource(TimeUpdateStrategy::ManualDuration(Duration::from_secs_f64(
1.0 / 60.0,
)))
.insert_resource(WorldClock {
paused: true,
..WorldClock::new(CivilDate::new(2026, 6, 21)).at_hour(12.0)
})
.insert_resource(setup.quality)
.init_resource::<CoverOverride>()
.init_resource::<CreatedShadow>()
.add_systems(
Update,
force_cover
.after(ElementsSystems::Weather)
.before(ElementsSystems::Celestial),
)
.add_systems(PostStartup, record_created_shadow)
.add_systems(
Startup,
move |mut commands: Commands,
mut images: ResMut<Assets<Image>>,
mut director: ResMut<WeatherDirector>| {
commands.spawn((
Camera3d::default(),
support::image_target(&mut images),
ElementsCamera::default(),
Transform::from_translation(setup.position).looking_to(Vec3::Y, Vec3::Z),
));
director.force(setup.preset, Duration::ZERO);
},
);
support::finish(&mut app);
app
}
fn run(app: &mut App, frames: usize) {
for _ in 0..frames {
app.update();
}
}
fn finish(app: &mut App) {
app.world_mut().write_message(AppExit::Success);
}
fn covered_fraction(app: &mut App) -> f32 {
let map = app.world().resource::<CloudWeatherMap>().map.clone();
let bytes = support::read_texture(app, map, 512 * 4, 512);
let covered = bytes.chunks_exact(4).filter(|t| t[0] >= 128).count();
covered as f32 / (512.0 * 512.0)
}
fn shadow_transmittance(app: &mut App) -> Vec<f32> {
let map = app.world().resource::<CloudShadows>().map.clone();
let bytes = support::read_texture(app, map, 1024 * 8, 1024);
bytes
.chunks_exact(8)
.map(|t| support::half(u16::from_le_bytes([t[0], t[1]])))
.collect()
}
fn sun_light(app: &mut App) -> f32 {
let mut lights = app
.world_mut()
.query_filtered::<&DirectionalLight, With<Sun>>();
let light = lights.single(app.world()).expect("one sun");
light.illuminance / lux::RAW_SUNLIGHT
}
fn mean_light_factor(t: &[f32]) -> f32 {
let mut state = 0x2545_F491_4F6C_DD1D_u64;
let mut next = || {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
(state >> 33) as usize
};
let mut total = 0.0;
for _ in 0..64 {
let (cx, cy) = (60 + next() % 900, 60 + next() % 900);
let mut sum = 0.0;
for y in cy - 4..=cy + 4 {
for x in cx - 4..=cx + 4 {
sum += t[y * 1024 + x];
}
}
total += 0.25 + 0.75 * sum / 81.0;
}
total / 64.0
}
fn dark_fraction(t: &[f32], below: f32) -> f32 {
t.iter().filter(|&&v| v <= below).count() as f32 / t.len() as f32
}
fn mean(t: &[f32]) -> f32 {
t.iter().sum::<f32>() / t.len() as f32
}
fn setup(preset: WeatherPreset) -> Setup {
Setup {
preset,
position: Vec3::new(0.0, 6.0, 0.0),
quality: ElementsQuality::High,
}
}
#[test]
#[ignore = "needs a GPU"]
fn the_weather_map_holds_the_requested_sky_fraction() {
let mut app = app(setup(WeatherPreset::Clear));
for position in [
Vec3::new(0.0, 6.0, 0.0),
Vec3::new(10_000.0, 6.0, 0.0),
Vec3::new(0.0, 6.0, 10_000.0),
] {
let mut cameras = app
.world_mut()
.query_filtered::<&mut Transform, With<ElementsCamera>>();
for mut transform in cameras.iter_mut(app.world_mut()) {
transform.translation = position;
}
let measure = |app: &mut App, cover: f32| {
app.world_mut().resource_mut::<CoverOverride>().0 = Some(cover);
run(app, 6);
covered_fraction(app)
};
for cover in [0.05, 0.4, 0.6, 0.95] {
let fraction = measure(&mut app, cover);
println!("camera {position}: cover {cover} -> fraction {fraction:.4}");
assert!(
(fraction - cover).abs() <= 0.02,
"cover {cover} gave {fraction} at {position}"
);
}
assert_eq!(measure(&mut app, 0.0), 0.0, "no cloud at cover 0");
assert_eq!(measure(&mut app, 1.0), 1.0, "all cloud at cover 1");
let mut last = -1.0;
for i in 0..20 {
let cover = 0.025 + 0.05 * i as f32;
let fraction = measure(&mut app, cover);
assert!(
fraction > last,
"fraction {fraction} at cover {cover} is not above {last}"
);
last = fraction;
}
}
finish(&mut app);
}
#[test]
#[ignore = "needs a GPU"]
fn the_shadow_map_and_the_sun_factor_follow_the_clouds() {
let mut app = app(setup(WeatherPreset::Clear));
let measure = |app: &mut App, preset: WeatherPreset, cover: Option<f32>| {
app.world_mut().resource_mut::<CoverOverride>().0 = cover;
app.world_mut()
.resource_mut::<WeatherDirector>()
.force(preset, Duration::ZERO);
run(app, 420);
let t = shadow_transmittance(app);
let camera = app.world().resource::<CloudShadows>().mean_transmittance;
let sun = sun_light(app);
(t, camera, sun)
};
let (clear, clear_camera, clear_sun) = measure(&mut app, WeatherPreset::Clear, None);
let clear_dark = dark_fraction(&clear, 0.5);
let clear_factor = mean_light_factor(&clear);
println!(
"Clear: dark {clear_dark:.3}, factor {clear_factor:.3}, mean T {:.3}",
mean(&clear)
);
assert!((0.001..=0.12).contains(&clear_dark), "{clear_dark}");
assert!((0.93..=1.0).contains(&clear_factor), "{clear_factor}");
let (overcast, overcast_camera, overcast_sun) =
measure(&mut app, WeatherPreset::Overcast, None);
let overcast_dark = dark_fraction(&overcast, 0.2);
let overcast_factor = mean_light_factor(&overcast);
println!("Overcast: dark {overcast_dark:.3}, factor {overcast_factor:.3}");
assert!(overcast_dark >= 0.9, "{overcast_dark}");
assert!(
(0.25..=0.37).contains(&overcast_factor),
"{overcast_factor}"
);
let (storm, _, _) = measure(&mut app, WeatherPreset::Thunderstorm, None);
let storm_factor = mean_light_factor(&storm);
println!("Thunderstorm: factor {storm_factor:.3}");
assert!((0.25..=0.30).contains(&storm_factor), "{storm_factor}");
for (camera, map, direct) in [
(clear_camera, mean(&clear), clear_sun),
(overcast_camera, mean(&overcast), overcast_sun),
] {
println!("sun beam {direct:.4}, mean transmittance {camera:.4}, map mean {map:.4}");
assert!(
(direct - camera).abs() < 5e-3,
"sun beam {direct} for mean transmittance {camera}"
);
assert!(
(direct - map).abs() <= 0.01,
"sun beam {direct} for a map mean {map}"
);
}
let ratio = overcast_sun / clear_sun;
println!("sun beam overcast / clear = {ratio:.3}");
assert!(ratio <= 0.05, "{ratio}");
let mut means = Vec::new();
for cover in [0.05, 0.4, 0.95] {
let (t, _, _) = measure(&mut app, WeatherPreset::Clear, Some(cover));
means.push(mean(&t));
}
println!("mean T at cover 0.05 / 0.4 / 0.95: {means:?}");
assert!(
means[0] - means[1] >= 0.1 && means[1] - means[2] >= 0.1,
"{means:?}"
);
finish(&mut app);
}
#[test]
#[ignore = "needs a GPU"]
fn a_cloud_over_the_camera_leaves_the_lights_alone() {
use bevy::ecs::system::RunSystemOnce;
let mut app = app(setup(WeatherPreset::Clear));
app.world_mut().resource_mut::<CoverOverride>().0 = Some(0.4);
run(&mut app, 420);
let t = shadow_transmittance(&mut app);
let shadows = app.world().resource::<CloudShadows>().clone();
let texel = shadows.extent / 1024.0;
let block = |x: usize, y: usize| -> f32 {
let mut sum = 0.0;
for yy in y - 10..=y + 10 {
for xx in x - 10..=x + 10 {
sum += t[yy * 1024 + xx];
}
}
sum / 441.0
};
let mut pair = None;
'search: for y in (360..664).step_by(4) {
for x in (360..664).step_by(4) {
if block(x, y) > 0.05 {
continue;
}
for by in (y - 19..=y + 19).step_by(1) {
for bx in (x - 19..=x + 19).step_by(1) {
if block(bx, by) > 0.95 {
pair = Some(((x, y), (bx, by)));
break 'search;
}
}
}
}
}
let ((dx, dy), (bx, by)) = pair.expect("a dark and a sunlit spot within 150 m");
let world = |x: usize, y: usize| {
let at = shadows.origin + (Vec2::new(x as f32, y as f32) + 0.5) * texel;
Vec3::new(at.x, 6.0, at.y)
};
let at = |app: &mut App, position: Vec3| {
let mut cameras = app
.world_mut()
.query_filtered::<&mut Transform, With<ElementsCamera>>();
for mut transform in cameras.iter_mut(app.world_mut()) {
transform.translation = position;
}
run(app, 120);
let local = app
.world_mut()
.run_system_once(move |env: Environment| env.cloud_shadow_at(position))
.expect("the query runs")
.expect("a readback has completed");
let mean = app.world().resource::<CloudShadows>().mean_transmittance;
(local, mean, sun_light(app))
};
let (dark_local, dark_mean, dark_direct) = at(&mut app, world(dx, dy));
let (lit_local, lit_mean, lit_direct) = at(&mut app, world(bx, by));
finish(&mut app);
println!("shade: local {dark_local:.3}, mean {dark_mean:.3}, beam {dark_direct:.4}");
println!("sun: local {lit_local:.3}, mean {lit_mean:.3}, beam {lit_direct:.4}");
assert!(
dark_local < 0.2 && lit_local > 0.8,
"{dark_local} / {lit_local}"
);
assert!(
(dark_direct - lit_direct).abs() <= 0.05 * lit_direct,
"beam {dark_direct} in the shade, {lit_direct} in the sun"
);
}
#[test]
#[ignore = "needs a GPU"]
fn the_shadow_map_starts_unshadowed() {
let mut app = app(setup(WeatherPreset::Clear));
app.update();
finish(&mut app);
let created = app.world().resource::<CreatedShadow>();
assert_eq!(created.format, Some(TextureFormat::Rgba16Float));
assert_eq!(created.size, (1024, 1024));
assert_eq!(created.first_texels.len(), 1024 * 1024 * 8);
for texel in created.first_texels.chunks_exact(8) {
assert_eq!(support::half(u16::from_le_bytes([texel[0], texel[1]])), 1.0);
}
}
#[test]
#[ignore = "needs a GPU"]
fn the_cloud_shadow_query_reads_the_shadow_map() {
use bevy::ecs::system::RunSystemOnce;
let mut app = app(setup(WeatherPreset::Clear));
app.world_mut().resource_mut::<CoverOverride>().0 = Some(0.4);
run(&mut app, 420);
let t = shadow_transmittance(&mut app);
let shadows = app.world().resource::<CloudShadows>().clone();
let texel = shadows.extent / 1024.0;
let samples: Vec<(usize, Vec3)> = (0..256usize)
.step_by(5)
.flat_map(|ky| (0..256usize).step_by(5).map(move |kx| (kx, ky)))
.map(|(kx, ky)| {
let (x, y) = (4 * kx + 2, 4 * ky + 2);
let at = shadows.origin + (Vec2::new(x as f32, y as f32) + 0.5) * texel;
(y * 1024 + x, Vec3::new(at.x, 0.0, at.y))
})
.collect();
let points: Vec<Vec3> = samples.iter().map(|s| s.1).collect();
let (answers, lights) = app
.world_mut()
.run_system_once(move |env: Environment| {
let answers: Vec<Option<f32>> =
points.iter().map(|&p| env.cloud_shadow_at(p)).collect();
let lights: Vec<f32> = points.iter().map(|&p| env.illuminance_at(p)).collect();
(answers, lights)
})
.expect("the query runs");
finish(&mut app);
let (mut error, mut lit, mut dark) = (0.0, 0, 0);
for ((index, _), answer) in samples.iter().zip(&answers) {
let answer = answer.expect("a readback has completed");
error += (answer - t[*index]).abs();
lit += usize::from(answer > 0.9);
dark += usize::from(answer < 0.1);
}
let error = error / samples.len() as f32;
println!(
"{} samples: mean |query − map| {error:.4}, lit {lit}, dark {dark}",
samples.len()
);
assert!(error < 0.02, "{error}");
assert!(lit > 0 && dark > 0, "lit {lit}, dark {dark}");
let (lo, hi) = lights
.iter()
.fold((f32::MAX, 0.0f32), |(lo, hi), &e| (lo.min(e), hi.max(e)));
println!("illuminance {lo:.0}–{hi:.0} lx");
assert!((lo / hi - 0.25).abs() < 0.03, "{lo} / {hi}");
}
#[test]
#[ignore = "needs a GPU"]
fn changing_the_quality_keeps_the_weather_and_shadow_maps() {
let mut app = app(setup(WeatherPreset::PartlyCloudy));
run(&mut app, 60);
let weather = app.world().resource::<CloudWeatherMap>().map.clone();
let shadow = app.world().resource::<CloudShadows>().map.clone();
let before = covered_fraction(&mut app);
for quality in [
ElementsQuality::Low,
ElementsQuality::Medium,
ElementsQuality::Ultra,
ElementsQuality::High,
] {
*app.world_mut().resource_mut::<ElementsQuality>() = quality;
run(&mut app, 30);
assert_eq!(app.world().resource::<CloudWeatherMap>().map, weather);
assert_eq!(app.world().resource::<CloudShadows>().map, shadow);
let after = covered_fraction(&mut app);
assert!(
(after - before).abs() < 0.02,
"{quality:?}: {before} -> {after}"
);
}
finish(&mut app);
}
#[test]
fn the_shadow_lookup_follows_the_ray_toward_the_light() {
let shadows = CloudShadows {
map: Handle::default(),
origin: Vec2::new(-4000.0, -3500.0),
extent: 8000.0,
light_direction: Vec3::new(0.5, 0.5, -0.3).normalize() * Vec3::new(1.0, 1.0, 1.0),
mean_transmittance: 1.0,
};
let l = Vec3::new(0.866_025_4 * 0.6, 0.5, 0.866_025_4 * 0.8);
let shadows = CloudShadows {
light_direction: l,
..shadows
};
let p = Vec3::new(120.0, 80.0, -40.0);
let uv = shadows.uv(p);
let formula = (p.xz() - l.xz() * p.y / l.y - shadows.origin) / shadows.extent;
assert!((uv - formula).abs().max_element() < 1e-6);
let q = uv * shadows.extent + shadows.origin;
let t = p.y / l.y;
let along = Vec3::new(q.x, 0.0, q.y) + l * t;
assert!((along - p).length() < 1e-3, "{along} vs {p}");
let ground = Vec3::new(10.0, 0.0, 20.0);
let uv = shadows.uv(ground);
assert!((uv * shadows.extent + shadows.origin - ground.xz()).length() < 1e-3);
}