mod support;
use std::time::Duration;
use bevy::app::AppExit;
use bevy::camera::{Hdr, RenderTarget};
use bevy::core_pipeline::tonemapping::{DebandDither, Tonemapping};
use bevy::light::AtmosphereEnvironmentMapLight;
use bevy::prelude::*;
use bevy::render::render_resource::{TextureFormat, TextureUsages};
use bevy::time::TimeUpdateStrategy;
use bevy_elements::*;
const SIZE: u32 = 32;
#[derive(Resource)]
struct Target(Handle<Image>);
#[derive(Component)]
struct Deck;
fn deck(hour: f64, sky_probe: bool, projection: Projection) -> Vec3 {
let mut app = App::new();
let plugins = ElementsPlugins
.build()
.disable::<PrecipitationPlugin>()
.disable::<LightningPlugin>();
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(CivilDate::new(2026, 6, 21)).at_hour(hour)
})
.add_systems(
Startup,
move |mut commands: Commands,
mut images: ResMut<Assets<Image>>,
mut director: ResMut<WeatherDirector>| {
let mut image =
Image::new_target_texture(SIZE, SIZE, TextureFormat::Rgba16Float, None);
image.texture_descriptor.usage |= TextureUsages::COPY_SRC;
let handle = images.add(image);
commands.insert_resource(Target(handle.clone()));
commands.spawn((
Camera3d::default(),
RenderTarget::Image(handle.into()),
ElementsCamera {
auto_exposure: false,
..default()
},
Tonemapping::None,
DebandDither::Disabled,
Hdr,
Deck,
projection.clone(),
Transform::from_xyz(0.0, 2.0, 0.0).looking_to(Vec3::Y, Vec3::Z),
));
director.force(WeatherPreset::Overcast, Duration::ZERO);
},
);
if !sky_probe {
app.add_systems(
PostStartup,
|mut commands: Commands, cameras: Query<Entity, With<Deck>>| {
for camera in &cameras {
commands
.entity(camera)
.remove::<AtmosphereEnvironmentMapLight>();
}
},
);
}
support::finish(&mut app);
for _ in 0..200 {
app.update();
}
let image = app.world().resource::<Target>().0.clone();
let bytes = support::read_texture(&mut app, image, SIZE as usize * 8, SIZE as usize);
let mut sum = Vec3::ZERO;
for texel in bytes.as_chunks::<8>().0 {
let c = |k: usize| support::half(u16::from_le_bytes([texel[k], texel[k + 1]]));
sum += Vec3::new(c(0), c(2), c(4));
}
app.world_mut().write_message(AppExit::Success);
app.update();
sum / (SIZE * SIZE) as f32
}
fn blueness(c: Vec3) -> f32 {
c.z / c.x.max(1e-9)
}
fn purpleness(c: Vec3) -> f32 {
c.x / c.y.max(1e-9)
}
#[test]
#[ignore = "needs a GPU"]
fn the_deck_takes_the_colour_of_its_light() {
let perspective = Projection::default;
let (noon, noon_neutral) = (
deck(12.0, true, perspective()),
deck(12.0, false, perspective()),
);
let (dusk, dusk_neutral) = (
deck(20.2, true, perspective()),
deck(20.2, false, perspective()),
);
println!("noon {noon} (neutral {noon_neutral}); dusk {dusk} (neutral {dusk_neutral})");
assert!(
purpleness(dusk) > purpleness(dusk_neutral) * 1.1,
"dusk: {dusk} against {dusk_neutral}"
);
let ratio = blueness(noon) / blueness(noon_neutral);
assert!(
ratio > 1.0 && ratio < 1.2,
"noon: {noon} against {noon_neutral} (blue/red ratio {ratio})"
);
}
#[test]
#[ignore = "needs a GPU"]
fn an_orthographic_camera_sees_the_deck_a_perspective_one_does() {
let perspective = deck(12.0, true, Projection::default());
let orthographic = deck(
12.0,
true,
Projection::from(OrthographicProjection {
scaling_mode: bevy::camera::ScalingMode::FixedVertical {
viewport_height: 200.0,
},
..OrthographicProjection::default_3d()
}),
);
println!("perspective {perspective}, orthographic {orthographic}");
let ratio = luminance(orthographic) / luminance(perspective);
assert!(
(0.85..=1.15).contains(&ratio),
"orthographic {orthographic} against perspective {perspective} ({ratio})"
);
}
fn luminance(c: Vec3) -> f32 {
c.dot(Vec3::new(0.2126, 0.7152, 0.0722))
}