#![allow(clippy::chunks_exact_to_as_chunks)]
#[path = "../../tests/support/mod.rs"]
mod support;
use std::time::Duration;
use bevy::app::AppExit;
use bevy::camera::Hdr;
use bevy::camera::RenderTarget;
use bevy::core_pipeline::tonemapping::{DebandDither, Tonemapping};
use bevy::prelude::*;
use bevy::render::render_resource::{TextureFormat, TextureUsages};
use bevy::time::TimeUpdateStrategy;
use super::model::{self, MirrorParams};
use super::{CloudDebug, CloudGpu, CloudQuality, CloudWeatherMap};
use crate::climate::{WeatherDirector, WeatherPreset};
use crate::flash::LightningFlash;
use crate::gpu::noise_mirror::{cloud_base_texel, cloud_detail_texel};
use crate::{
CivilDate, ElementsCamera, ElementsPlugins, ElementsQuality, LightningPlugin,
PrecipitationPlugin, WorldClock,
};
#[derive(Clone)]
struct Case {
preset: WeatherPreset,
hour: f64,
position: Vec3,
look: Vec3,
quality: ElementsQuality,
debug: CloudDebug,
wall: bool,
linear_target: bool,
date: CivilDate,
fov_deg: Option<f32>,
}
impl Case {
fn sky(preset: WeatherPreset) -> Self {
Self {
preset,
hour: 12.0,
position: Vec3::new(0.0, 2.0, 0.0),
look: Vec3::Y,
quality: ElementsQuality::High,
debug: CloudDebug::default(),
wall: false,
linear_target: false,
date: CivilDate::new(2026, 6, 21),
fov_deg: None,
}
}
}
#[derive(Resource)]
struct Target(Handle<Image>);
fn app(case: Case) -> App {
let mut app = App::new();
let plugins = ElementsPlugins
.build()
.disable::<LightningPlugin>()
.disable::<PrecipitationPlugin>();
app.add_plugins((support::headless_plugins(), plugins))
.insert_resource(TimeUpdateStrategy::ManualDuration(Duration::from_secs_f64(
1.0 / 60.0,
)))
.insert_resource(WorldClock {
paused: true,
..WorldClock::new(case.date).at_hour(case.hour)
})
.insert_resource(case.quality)
.insert_resource(case.debug.clone())
.add_systems(
Startup,
move |mut commands: Commands,
mut images: ResMut<Assets<Image>>,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
mut director: ResMut<WeatherDirector>| {
let target = if case.linear_target {
let mut image =
Image::new_target_texture(64, 64, TextureFormat::Rgba16Float, None);
image.texture_descriptor.usage |= TextureUsages::COPY_SRC;
let handle = images.add(image);
commands.insert_resource(Target(handle.clone()));
RenderTarget::Image(handle.into())
} else {
support::image_target(&mut images)
};
let up = if case.look.abs().dot(Vec3::Y) > 0.99 {
Vec3::Z
} else {
Vec3::Y
};
let mut camera = commands.spawn((
Camera3d::default(),
target,
ElementsCamera {
auto_exposure: false,
..default()
},
Transform::from_translation(case.position).looking_to(case.look, up),
));
if let Some(fov) = case.fov_deg {
camera.insert(Projection::Perspective(PerspectiveProjection {
fov: fov.to_radians(),
..default()
}));
}
if case.linear_target {
camera.insert((Tonemapping::None, DebandDither::Disabled, Hdr));
}
if case.wall {
commands.spawn((
Mesh3d(meshes.add(Cuboid::new(2000.0, 300.0, 10.0))),
MeshMaterial3d(materials.add(StandardMaterial {
base_color: Color::srgb(0.6, 0.5, 0.4),
unlit: true,
..default()
})),
Transform::from_xyz(0.0, 150.0, -500.0),
));
}
director.force(case.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 set_debug(app: &mut App, f: impl FnOnce(&mut CloudDebug)) {
f(&mut app.world_mut().resource_mut::<CloudDebug>());
}
fn march_texture(app: &mut App) -> (Vec<[f32; 4]>, UVec2) {
let gpu: CloudGpu = app.world().resource::<CloudGpu>().clone();
let view = gpu.view.expect("the view textures exist");
let low = UVec2::new(gpu.params.sizes.z, gpu.params.sizes.w);
let bytes = support::read_texture(app, view.march, low.x as usize * 8, low.y as usize);
(rgba16(&bytes), low)
}
fn history_texture(app: &mut App, index: usize) -> (Vec<[f32; 4]>, UVec2) {
let gpu: CloudGpu = app.world().resource::<CloudGpu>().clone();
let view = gpu.view.expect("the view textures exist");
let size = view.size;
let bytes = support::read_texture(
app,
view.history[index].clone(),
size.x as usize * 8,
size.y as usize,
);
(rgba16(&bytes), size)
}
fn history_pair(app: &mut App) -> (Vec<[f32; 4]>, Vec<[f32; 4]>) {
let gpu: CloudGpu = app.world().resource::<CloudGpu>().clone();
let view = gpu.view.expect("the view textures exist");
let (row, rows) = (view.size.x as usize * 8, view.size.y as usize);
let mut both = support::read_textures(
app,
vec![
(view.history[0].clone(), row, rows),
(view.history[1].clone(), row, rows),
],
);
let b = rgba16(&both.pop().expect("two textures"));
let a = rgba16(&both.pop().expect("two textures"));
(a, b)
}
fn rgba16(bytes: &[u8]) -> Vec<[f32; 4]> {
bytes
.chunks_exact(8)
.map(|t| [0, 2, 4, 6].map(|i| support::half(u16::from_le_bytes([t[i], t[i + 1]]))))
.collect()
}
fn luminance(c: [f32; 4]) -> f32 {
0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]
}
fn centre(texels: &[[f32; 4]], size: UVec2) -> [f32; 4] {
texels[(size.y / 2 * size.x + size.x / 2) as usize]
}
fn plane_difference(texels: &[u8], n: usize, axis: usize, a: usize, b: usize) -> f32 {
let at =
|x: usize, y: usize, z: usize, c: usize| f32::from(texels[((z * n + y) * n + x) * 4 + c]);
let mut total = 0.0;
for u in 0..n {
for v in 0..n {
for c in 0..4 {
let (pa, pb) = match axis {
0 => (at(a, u, v, c), at(b, u, v, c)),
1 => (at(u, a, v, c), at(u, b, v, c)),
_ => (at(u, v, a, c), at(u, v, b, c)),
};
total += (pa - pb).abs();
}
}
}
total / (n * n * 4) as f32
}
#[test]
#[ignore = "needs a GPU"]
fn the_noise_volumes_tile_and_match_the_cpu_mirror() {
let mut app = app(Case::sky(WeatherPreset::PartlyCloudy));
run(&mut app, 12);
let gpu: CloudGpu = app.world().resource::<CloudGpu>().clone();
let base = support::read_texture(&mut app, gpu.base.clone(), 128 * 4, 128 * 128);
let detail = support::read_texture(&mut app, gpu.detail.clone(), 32 * 4, 32 * 32);
finish(&mut app);
for (name, texels, n) in [("base", &base, 128usize), ("detail", &detail, 32)] {
for axis in 0..3 {
let wrap = plane_difference(texels, n, axis, 0, n - 1);
let interior = (0..n - 1)
.map(|i| plane_difference(texels, n, axis, i, i + 1))
.sum::<f32>()
/ (n - 1) as f32;
println!("{name} axis {axis}: wrap {wrap:.3}, interior {interior:.3}");
assert!(
wrap <= 1.5 * interior,
"{name} has a seam along axis {axis}: {wrap} vs {interior}"
);
}
}
let mut state = 0x9E37_79B9_7F4A_7C15_u64;
let mut next = |m: usize| {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
(state >> 24) as usize % m
};
let mut worst = 0.0_f32;
for _ in 0..4096 {
let (x, y, z) = (next(128), next(128), next(128));
let expect = cloud_base_texel(x as u32, y as u32, z as u32);
let at = ((z * 128 + y) * 128 + x) * 4;
for c in 0..4 {
worst = worst.max((f32::from(base[at + c]) / 255.0 - expect[c]).abs());
}
let (x, y, z) = (next(32), next(32), next(32));
let expect = cloud_detail_texel(x as u32, y as u32, z as u32);
let at = ((z * 32 + y) * 32 + x) * 4;
for c in 0..3 {
worst = worst.max((f32::from(detail[at + c]) / 255.0 - expect[c]).abs());
}
}
println!(
"worst texel difference to the mirror: {:.2}/255",
worst * 255.0
);
assert!(
worst <= 3.0 / 255.0,
"worst difference {} / 255",
worst * 255.0
);
let mean_a = base.chunks_exact(4).map(|t| f32::from(t[3])).sum::<f32>()
/ (base.len() / 4) as f32
/ 255.0;
let mean_b = detail.chunks_exact(4).map(|t| f32::from(t[2])).sum::<f32>()
/ (detail.len() / 4) as f32
/ 255.0;
println!("channel means: base A {mean_a:.4}, detail B {mean_b:.4}");
assert!((mean_a - 0.4987).abs() < 0.05, "base A mean {mean_a}");
assert!((mean_b - 0.4764).abs() < 0.05, "detail B mean {mean_b}");
}
#[test]
#[ignore = "needs a GPU"]
fn the_weather_map_follows_the_drift_and_the_cpu_field() {
let cover = 0.4;
let mut case = Case::sky(WeatherPreset::Clear);
case.debug.cover = Some(cover);
let mut app = app(case);
for drift in [[0.0_f32, 0.0], [7_000.0, 0.0], [31_000.0, 12_000.0]] {
set_debug(&mut app, |d| d.drift = Some(Vec2::from(drift)));
run(&mut app, 8);
let map: CloudWeatherMap = app.world().resource::<CloudWeatherMap>().clone();
let bytes = support::read_texture(&mut app, map.map.clone(), 512 * 4, 512);
let covered = bytes.chunks_exact(4).filter(|t| t[0] >= 128).count() as f32 / 262_144.0;
let mirror = MirrorParams::new(
1500.0,
3300.0,
0.5,
f64::from(cover),
[f64::from(drift[0]), f64::from(drift[1])],
);
let mut worst = 0.0_f64;
for j in (0..512).step_by(5) {
for i in (0..512).step_by(5) {
let xz = [
f64::from(map.origin.x) + (i as f64 + 0.5) * 62.5,
f64::from(map.origin.y) + (j as f64 + 0.5) * 62.5,
];
let (r, dome) = mirror.weather_at(xz);
let t = &bytes[(j * 512 + i) * 4..][..4];
worst = worst
.max((f64::from(t[0]) / 255.0 - r).abs())
.max((f64::from(t[3]) / 255.0 - dome).abs());
}
}
println!(
"drift {drift:?}: fraction {covered:.4}, worst texel difference to the mirror {worst:.3}"
);
assert!(
(covered - cover).abs() <= 0.02,
"{covered} at drift {drift:?}"
);
assert!(
worst <= 0.08,
"mirror disagrees by {worst} at drift {drift:?}"
);
}
finish(&mut app);
}
#[test]
#[ignore = "needs a GPU"]
fn the_march_transmits_exp_minus_sigma_d_through_a_uniform_slab() {
let mut case = Case::sky(WeatherPreset::Clear);
case.debug = CloudDebug {
mode: 1.0,
sigma: 0.002,
layer: Some((500.0, 1500.0)),
..default()
};
let mut app = app(case);
for (sigma, expect) in [(0.002_f32, (-2.0_f32).exp()), (0.0004, (-0.4_f32).exp())] {
set_debug(&mut app, |d| d.sigma = sigma);
run(&mut app, 8);
let (texels, size) = march_texture(&mut app);
let t = centre(&texels, size)[3];
println!("sigma {sigma}: T {t:.4}, expected {expect:.4}");
assert!((t / expect - 1.0).abs() <= 0.01, "T {t}, expected {expect}");
}
set_debug(&mut app, |d| d.sigma = 0.05);
run(&mut app, 8);
let (texels, size) = march_texture(&mut app);
let c = centre(&texels, size);
println!("opaque slab: {c:?}");
assert!(c.iter().all(|v| v.is_finite()));
assert!((0.0..=0.01).contains(&c[3]), "T {}", c[3]);
finish(&mut app);
}
#[test]
#[ignore = "needs a GPU"]
fn the_light_at_the_layer_is_the_atmospheric_transmittance_and_the_planet_shadow() {
let mut case = Case::sky(WeatherPreset::Clear);
case.debug = CloudDebug {
mode: 2.0,
layer: Some((1500.0, 3300.0)),
light: Some(Vec3::Y),
light_e: Some(Vec3::ONE),
..default()
};
let mut app = app(case);
let sun = |elevation_deg: f32| {
let e = elevation_deg.to_radians();
Vec3::new(e.cos(), e.sin(), 0.0)
};
let read = |app: &mut App, base: f32, elevation_deg: f32| {
set_debug(app, |d| {
d.layer = Some((base, base + 1800.0));
d.light = Some(sun(elevation_deg));
});
run(app, 8);
let (texels, size) = march_texture(app);
centre(&texels, size)
};
let altitude = |base: f32| f64::from(base);
for (base, elevation, tolerance) in [
(1500.0, 90.0, 0.03),
(3300.0, 90.0, 0.03),
(1500.0, 10.0, 0.20),
] {
let got = read(&mut app, base, elevation);
let mu = f64::from(elevation).to_radians().sin();
let expect = model::bevy_optical_transmittance(altitude(base), mu);
println!("h {base} sun {elevation}°: kernel {got:?}, integral {expect:?}");
for c in 0..3 {
assert!(
(f64::from(got[c]) / expect[c] - 1.0).abs() <= tolerance,
"channel {c}: {} vs {}",
got[c],
expect[c]
);
}
}
assert!(read(&mut app, 1500.0, -1.0)[0] > 0.0);
assert_eq!(read(&mut app, 1500.0, -1.5)[0], 0.0);
assert!(read(&mut app, 9500.0, -3.0)[0] > 0.0);
assert_eq!(read(&mut app, 9500.0, -3.6)[0], 0.0);
finish(&mut app);
}
fn bits(target: &[u8]) -> &[u8] {
target
}
#[test]
#[ignore = "needs a GPU"]
fn the_composite_leaves_nearer_surfaces_and_clear_sky_untouched() {
let mut case = Case::sky(WeatherPreset::Overcast);
case.look = Vec3::new(0.0, 0.364, -1.0).normalize();
case.wall = true;
case.linear_target = true;
let mut app = app(case);
run(&mut app, 700);
let target = app.world().resource::<Target>().0.clone();
let with = support::read_texture(&mut app, target.clone(), 64 * 8, 64);
set_debug(&mut app, |d| d.mode = 4.0);
run(&mut app, 8);
let without = support::read_texture(&mut app, target, 64 * 8, 64);
finish(&mut app);
let a = rgba16(bits(&with));
let b = rgba16(bits(&without));
let mut wall_identical = 0;
let mut sky_changed = 0;
for y in 0..64 {
for x in 0..64 {
let (pa, pb) = (a[y * 64 + x], b[y * 64 + x]);
if (41..59).contains(&y) {
let delta = (0..4).map(|c| (pa[c] - pb[c]).abs()).fold(0.0, f32::max);
assert!(
delta <= 0.01,
"wall pixel ({x}, {y}) changed by {delta}: {pa:?} vs {pb:?}"
);
wall_identical += 1;
} else if y < 6 && pa != pb {
sky_changed += 1;
}
}
}
println!(
"{wall_identical} wall pixels identical, {sky_changed} sky pixels changed by the cloud"
);
assert!(wall_identical > 1000);
assert!(
sky_changed > 100,
"the overcast deck should show in the sky"
);
}
#[test]
#[ignore = "needs a GPU"]
fn the_weather_air_makes_the_sky_the_fog_colour() {
let mut case = Case::sky(WeatherPreset::Blizzard);
case.linear_target = true;
let mut app = app(case);
run(&mut app, 400);
let haze = app.world().resource::<CloudGpu>().params.haze;
let air = app.world().resource::<CloudGpu>().params.air.x;
assert!(air > 0.0, "Blizzard's air must be above clear air");
let target = app.world().resource::<Target>().0.clone();
let texels = rgba16(&support::read_texture(&mut app, target.clone(), 64 * 8, 64));
let fogged = mean_rgb(&texels);
set_debug(&mut app, |d| d.air = Some(0.0));
run(&mut app, 8);
let texels = rgba16(&support::read_texture(&mut app, target, 64 * 8, 64));
let deck = mean_rgb(&texels);
finish(&mut app);
let fog = [haze.x, haze.y, haze.z];
let difference = (0..3).map(|c| (fogged[c] - fog[c]).abs()).fold(0.0, f32::max);
let luminance = luminance([fog[0], fog[1], fog[2], 0.0]);
println!(
"air {air:.5} m⁻¹: fogged {fogged:?} vs fog {fog:?} (max Δ {difference:.4} of {luminance:.3}); \
deck {deck:?}",
);
assert!(
difference <= 0.01 * luminance,
"the sky is not the fog colour: {fogged:?} vs {fog:?}"
);
let step = (0..3).map(|c| (deck[c] - fog[c]).abs()).fold(0.0, f32::max);
assert!(
step >= 0.1 * luminance,
"the deck should not be the fog colour: {deck:?} vs {fog:?}"
);
}
fn mean_rgb(texels: &[[f32; 4]]) -> [f32; 3] {
let mut sum = [0.0f32; 3];
for texel in texels {
for c in 0..3 {
sum[c] += texel[c];
}
}
sum.map(|v| v / texels.len() as f32)
}
#[test]
#[ignore = "needs a GPU"]
fn the_cloud_textures_stay_finite_at_every_quality() {
for quality in [
ElementsQuality::Low,
ElementsQuality::Medium,
ElementsQuality::High,
ElementsQuality::Ultra,
] {
for (preset, hour) in [
(WeatherPreset::PartlyCloudy, 12.0),
(WeatherPreset::Thunderstorm, 19.0),
(WeatherPreset::Overcast, 5.0),
] {
let mut case = Case::sky(preset);
case.quality = quality;
case.hour = hour;
case.look = Vec3::new(1.0, 0.2, 0.3).normalize();
let mut app = app(case);
run(&mut app, 40);
let window = CloudQuality::of(quality).window as usize;
run(&mut app, 2 * window);
let (march, _) = march_texture(&mut app);
let (h0, _) = history_texture(&mut app, 0);
let (h1, _) = history_texture(&mut app, 1);
finish(&mut app);
for (name, texels) in [("march", &march), ("history 0", &h0), ("history 1", &h1)] {
for t in texels {
assert!(
t.iter().all(|v| v.is_finite())
&& t[3] >= -1e-3
&& t[3] <= 1.001
&& t[..3].iter().all(|v| *v >= -1e-3),
"{name} at {quality:?}/{preset:?}: {t:?}"
);
}
}
println!(
"{quality:?} / {preset:?}: all {} texels finite",
march.len() + h0.len() + h1.len()
);
}
}
}
#[test]
#[ignore = "needs a GPU"]
fn a_still_camera_gives_a_steady_history() {
for quality in [ElementsQuality::High, ElementsQuality::Low] {
let mut case = Case::sky(WeatherPreset::PartlyCloudy);
case.quality = quality;
case.look = Vec3::new(1.0, 0.25, 0.4).normalize();
case.debug.drift = Some(Vec2::new(3_000.0, 2_000.0));
let mut app = app(case);
let window = CloudQuality::of(quality).window as usize;
run(&mut app, 40 + 6 * window);
let (a, b) = history_pair(&mut app);
finish(&mut app);
let mean = a.iter().map(|t| luminance(*t)).sum::<f32>() / a.len() as f32;
let rms = (a
.iter()
.zip(&b)
.map(|(x, y)| (luminance(*x) - luminance(*y)).powi(2))
.sum::<f32>()
/ a.len() as f32)
.sqrt();
println!(
"{quality:?}: mean luminance {mean:.4}, consecutive-frame rms {rms:.5} ({:.2} %)",
100.0 * rms / mean
);
assert!(mean > 0.0);
assert!(rms / mean <= 0.10, "{quality:?}: {}", rms / mean);
}
}
#[test]
#[ignore = "needs a GPU"]
fn a_flash_lights_the_cloud_at_dusk_and_not_at_noon() {
for (hour, minimum_ratio, maximum_change) in [(21.0, 50.0, f32::MAX), (12.0, 0.0, 0.05)] {
let mut case = Case::sky(WeatherPreset::Thunderstorm);
case.hour = hour;
let mut app = app(case);
app.insert_resource(LightningFlash::default());
run(&mut app, 80);
let centre_mean = |texels: &[[f32; 4]], size: UVec2| {
let mut sum = 0.0;
let mut n = 0.0;
for y in size.y / 2 - 8..size.y / 2 + 8 {
for x in size.x / 2 - 8..size.x / 2 + 8 {
sum += luminance(texels[(y * size.x + x) as usize]);
n += 1.0;
}
}
sum / n
};
let (off, size) = march_texture(&mut app);
let dark = centre_mean(&off, size);
*app.world_mut().resource_mut::<LightningFlash>() = LightningFlash {
position: Vec3::new(0.0, 1500.0, 0.0),
intensity_cd: 7.96e6,
};
run(&mut app, 8);
let (on, _) = march_texture(&mut app);
finish(&mut app);
let lit = centre_mean(&on, size);
println!(
"hour {hour}: no flash {dark:.5}, flash {lit:.5}, ratio {:.1}",
lit / dark.max(1e-9)
);
assert!(lit / dark.max(1e-9) >= minimum_ratio);
assert!((lit - dark).abs() / dark.max(1e-9) <= maximum_change);
}
}
fn entry_elevation_deg(base_m: f64, camera_m: f64, distance_m: f64) -> f64 {
let r = model::PLANET_RADIUS_M;
let (o, s) = (r + camera_m, r + base_m);
((s * s - o * o - distance_m * distance_m) / (2.0 * o * distance_m))
.asin()
.to_degrees()
}
fn chroma(c: [f32; 3]) -> f32 {
let max = c.iter().copied().fold(0.0, f32::max);
let min = c.iter().copied().fold(f32::MAX, f32::min);
(max - min) / max.max(1e-9)
}
fn rows_mean(
texels: &[[f32; 4]],
pitch_deg: f64,
fov_deg: f64,
from_deg: f64,
to_deg: f64,
) -> [f32; 3] {
let half = (fov_deg.to_radians() / 2.0).tan();
let mut sum = [0.0f32; 3];
let mut n = 0.0;
for y in 0..64 {
let ndc = 1.0 - 2.0 * (y as f64 + 0.5) / 64.0;
let elevation = pitch_deg + (ndc * half).atan().to_degrees();
if elevation >= from_deg && elevation <= to_deg {
for x in 0..64 {
for c in 0..3 {
sum[c] += texels[y * 64 + x][c];
}
}
n += 64.0;
}
}
assert!(n > 0.0, "no rows between {from_deg}° and {to_deg}°");
sum.map(|v| v / n)
}
fn tint(c: [f32; 3]) -> f32 {
(c[2] - c[0]) / (c[0] + c[1] + c[2]).max(1e-9)
}
#[test]
#[ignore = "needs a GPU"]
fn the_far_veil_continues_the_marched_deck_at_full_cover() {
let fov_deg = 12.0_f64;
let camera_m = 2.0_f32;
for (preset, date, hour, sun_range) in [
(
WeatherPreset::Snow,
CivilDate::new(2026, 5, 1),
12.0,
58.0..63.0,
),
(
WeatherPreset::Snow,
CivilDate::new(2026, 1, 15),
10.5,
17.0..23.0,
),
(
WeatherPreset::Overcast,
CivilDate::new(2026, 4, 20),
12.0,
55.0..58.0,
),
(
WeatherPreset::Overcast,
CivilDate::new(2026, 1, 15),
10.5,
17.0..23.0,
),
] {
let mut case = Case::sky(preset);
case.date = date;
case.hour = hour;
case.linear_target = true;
case.fov_deg = Some(fov_deg as f32);
case.debug.air = Some(0.0);
let mut app = app(case);
run(&mut app, 4);
let sun = *app.world().resource::<crate::celestial::SunState>();
let base_m = app.world().resource::<crate::clouds::CloudLayer>().base_m;
assert!(
sun_range.contains(&sun.altitude_deg),
"sun {}°",
sun.altitude_deg
);
let boundary = entry_elevation_deg(f64::from(base_m), f64::from(camera_m), 32_000.0);
for rel_azimuth_deg in [180.0_f32, 0.0] {
let pitch = (boundary as f32).to_radians();
let azimuth = (sun.azimuth_deg + rel_azimuth_deg).to_radians();
let look = Dir3::new(Vec3::new(
azimuth.sin() * pitch.cos(),
pitch.sin(),
-azimuth.cos() * pitch.cos(),
))
.expect("a unit look direction");
let mut cameras = app
.world_mut()
.query_filtered::<&mut Transform, With<ElementsCamera>>();
for mut transform in cameras.iter_mut(app.world_mut()) {
*transform = Transform::from_translation(Vec3::new(0.0, camera_m, 0.0))
.looking_to(look, Vec3::Y);
}
run(&mut app, 400);
let target = app.world().resource::<Target>().0.clone();
let texels = rgba16(&support::read_texture(&mut app, target, 64 * 8, 64));
let rows = |from: f64, to: f64| {
rows_mean(&texels, boundary, fov_deg, boundary + from, boundary + to)
};
let (deck, veil) = (rows(0.15, 0.6), rows(-0.6, -0.15));
let (deck_far, veil_far) = (rows(1.5, 2.1), rows(-2.1, -1.5));
let lum = |c: [f32; 3]| luminance([c[0], c[1], c[2], 0.0]);
let ratio = lum(veil) / lum(deck);
println!(
"{preset:?} sun {:.1}° rel {rel_azimuth_deg}: boundary {boundary:.2}°, luminance deck {:.3} veil {:.3} (ratio {ratio:.3}); tint deck {:.3} veil {:.3}, chroma deck {:.3} veil {:.3}; 1.5–2.1° away: tint {:.3} / {:.3}, chroma {:.3} / {:.3}",
sun.altitude_deg,
lum(deck),
lum(veil),
tint(deck),
tint(veil),
chroma(deck),
chroma(veil),
tint(deck_far),
tint(veil_far),
chroma(deck_far),
chroma(veil_far),
);
assert!(
(0.85..=1.2).contains(&ratio),
"{preset:?}: luminance ratio {ratio}"
);
assert!((tint(veil) - tint(deck)).abs() <= 0.03, "{preset:?}: tint");
assert!(
(chroma(veil) - chroma(deck)).abs() <= 0.05,
"{preset:?}: chroma"
);
assert!(
(tint(veil_far) - tint(deck_far)).abs() <= 0.08,
"{preset:?}: far tint"
);
assert!(
(chroma(veil_far) - chroma(deck_far)).abs() <= 0.15,
"{preset:?}: far chroma"
);
}
finish(&mut app);
}
}
#[test]
#[ignore = "needs a GPU"]
fn the_horizon_sky_is_the_fog_colour_under_a_deck() {
let mut case = Case::sky(WeatherPreset::Snow);
case.position = Vec3::new(0.0, 6.0, 0.0);
let up = 1.0f32.to_radians().sin();
let forward = 1.0f32.to_radians().cos();
case.look = Vec3::new(0.0, 6.0 + 100.0 * up, -100.0 * forward);
case.linear_target = true;
let mut app = app(case);
run(&mut app, 400);
let haze = app.world().resource::<CloudGpu>().params.haze;
let target = app.world().resource::<Target>().0.clone();
let texels = rgba16(&support::read_texture(&mut app, target, 64 * 8, 64));
finish(&mut app);
let fog = [haze.x, haze.y, haze.z];
let luminance = luminance([fog[0], fog[1], fog[2], 0.0]);
let band = |rows: std::ops::Range<usize>| {
let mut sum = [0.0f32; 3];
let mut n = 0.0;
for y in rows {
for x in 0..64 {
let p = texels[y * 64 + x];
sum[0] += p[0];
sum[1] += p[1];
sum[2] += p[2];
n += 1.0;
}
}
[sum[0] / n, sum[1] / n, sum[2] / n]
};
let above = band(28..33);
let below = band(34..45);
println!("haze {fog:?}; sky just above the horizon {above:?}; just below {below:?}");
for (name, band) in [("above", above), ("below", below)] {
let d = (0..3).map(|c| (band[c] - fog[c]).abs()).fold(0.0f32, f32::max);
assert!(
d <= 0.05 * luminance,
"the {name}-horizon sky is not the fog colour: {band:?} vs {fog:?} (Δ {d:.4})"
);
}
}
#[test]
#[ignore = "needs a GPU"]
fn the_air_past_the_ground_is_the_sky_in_its_own_column() {
for (hour, sun_note) in [
(12.0, "noon"),
(17.5, "golden hour"),
(19.0, "low sun"),
(20.0, "after sunset"),
(0.5, "midnight"),
] {
for rel_azimuth_deg in [0.0f32, 90.0, 180.0] {
let mut case = Case::sky(WeatherPreset::Clear);
case.hour = hour;
case.position = Vec3::new(0.0, 2.0, 0.0);
case.fov_deg = Some(20.0);
case.linear_target = true;
case.debug.air = Some(0.0);
let mut app = app(case);
run(&mut app, 30);
let sun = *app.world().resource::<crate::celestial::SunState>();
let azimuth = (sun.azimuth_deg + rel_azimuth_deg).to_radians();
let look = Dir3::new(Vec3::new(azimuth.sin() * 1e-4, 0.0, -azimuth.cos()))
.expect("a level look");
let mut cameras = app
.world_mut()
.query_filtered::<&mut Transform, With<ElementsCamera>>();
for mut transform in cameras.iter_mut(app.world_mut()) {
*transform =
Transform::from_translation(Vec3::new(0.0, 2.0, 0.0)).looking_to(look, Vec3::Y);
}
run(&mut app, 400);
let target = app.world().resource::<Target>().0.clone();
let texels = rgba16(&support::read_texture(&mut app, target, 64 * 8, 64));
finish(&mut app);
let band = |rows: std::ops::Range<usize>| {
let mut sum = [0.0f32; 3];
let mut n = 0.0;
for y in rows {
for x in 0..64 {
let p = texels[y * 64 + x];
sum[0] += p[0];
sum[1] += p[1];
sum[2] += p[2];
n += 1.0;
}
}
[sum[0] / n, sum[1] / n, sum[2] / n]
};
let sky = band(30..31);
let slab = band(36..45);
let upper = band(28..29);
let horizon = [0, 1, 2].map(|c| 2.0 * sky[c] - upper[c]);
let slab_lum = luminance([slab[0], slab[1], slab[2], 0.0]);
let sky_lum = luminance([horizon[0], horizon[1], horizon[2], 0.0]);
if sky_lum <= 1e-6 {
assert!(
slab_lum <= 1e-6,
"{sun_note}, rel {rel_azimuth_deg}°: the sky at the horizon is black but the \
slab is not: {slab:?} at a luminance of {slab_lum:.3e}"
);
println!(
"sun {:.1}°, rel {rel_azimuth_deg:.0}° ({sun_note}): sky {sky:?} horizon \
{horizon:?} slab {slab:?}; both black (luminance {slab_lum:.3e})",
sun.altitude_deg,
);
continue;
}
let d_lum = (slab_lum - sky_lum).abs() / sky_lum;
let warmth = |c: [f32; 3], l: f32| (c[0] - c[2]) / l;
let d_hue = (warmth(slab, slab_lum) - warmth(horizon, sky_lum)).abs();
println!(
"sun {:.1}°, rel {rel_azimuth_deg:.0}° ({sun_note}): sky {sky:?} horizon {horizon:?} \
slab {slab:?}; Δ luminance {:.3}, Δ warmth {d_hue:.3}",
sun.altitude_deg, d_lum,
);
assert!(
d_lum <= 0.08 && d_hue <= 0.15,
"{sun_note}, rel {rel_azimuth_deg}°: the slab is not the sky at the horizon: \
{slab:?} against {horizon:?} (Δ luminance {d_lum:.3}, Δ warmth {d_hue:.3})"
);
}
}
}