mod gpu;
use bevy::asset::RenderAssetUsages;
use bevy::asset::embedded_asset;
use bevy::camera::Exposure;
use bevy::light::FogVolume;
use bevy::pbr::{DistanceFog, FogFalloff};
use bevy::prelude::*;
use bevy::render::render_resource::{Extent3d, ShaderType, TextureDimension, TextureFormat};
use crate::celestial::{CelestialFrame, CloudLight, SunState, cloud_light, dusk_tint, scene_illuminance};
use crate::climate::{Weather, WeatherDirector, WeatherPreset};
use crate::clouds::{CloudShadows, CloudWeatherMap};
use crate::gpu::grid::storage_volume;
use crate::plugin::{ElementsCamera, ElementsSystems, ElementsUnits, ElementsView, ensure_gpu_core};
use crate::precipitation::model::{
marshall_palmer_lambda, precipitation_split, rain_extinction, snow_extinction,
water_weighted_fall_speed,
};
use crate::query::EnvironmentSnapshot;
pub const KOSCHMIEDER: f32 = 3.912;
pub const CLEAR_VISIBILITY_M: f32 = 40_000.0;
pub const CLEAR_AIR_EXTINCTION: f32 = KOSCHMIEDER / CLEAR_VISIBILITY_M;
pub const GROUND_FOG_EXTENT: Vec3 = Vec3::new(240.0, 120.0, 240.0);
pub const GROUND_FOG_TEXELS: UVec3 = UVec3::new(128, 32, 128);
const FOG_COLOUR: Vec3 = Vec3::new(0.75, 0.80, 0.85);
pub(crate) const DUST_ALBEDO: Vec3 = Vec3::new(0.76, 0.60, 0.42);
const FOG_SCALE_HEIGHT_M: f32 = 25.0;
const HAZE_SCALE_HEIGHT_M: f32 = 60.0;
const SCALE_HEIGHT_TAU_S: f32 = 600.0;
const FOG_ALBEDO: f32 = 0.95;
const DUST_SINGLE_SCATTER: f32 = 0.9;
pub(crate) const FBM2_MAX: f32 = 0.522;
const MEAN_NOISE_FACTOR: f32 = 0.8;
const MIN_SUN_SINE: f32 = 0.05;
const OFFSET_WRAP_M: f32 = 80.0 * 4096.0;
#[derive(Component, Clone, Copy, Debug, Default, Reflect)]
pub struct GroundFog;
pub fn extinction_for_visibility(visibility_m: f32) -> f32 {
KOSCHMIEDER / visibility_m.max(1.0)
}
pub fn sky_air_extinction(fog_extinction_m: f32) -> f32 {
(fog_extinction_m - CLEAR_AIR_EXTINCTION).max(0.0)
}
pub fn distance_fog_falloff(visibility_m: f32) -> FogFalloff {
let k = Vec3::splat(extinction_for_visibility(visibility_m));
FogFalloff::Atmospheric {
extinction: k,
inscattering: k,
}
}
pub fn orthographic_fog_falloff(visibility_m: f32, near_depth_m: f32) -> FogFalloff {
let start = near_depth_m.max(0.0);
FogFalloff::Linear {
start,
end: start + 1.0 / extinction_for_visibility(visibility_m),
}
}
pub fn falloff_in_world_units(falloff: FogFalloff, units: f32) -> FogFalloff {
match falloff {
FogFalloff::Linear { start, end } => FogFalloff::Linear {
start: start * units,
end: end * units,
},
FogFalloff::Atmospheric {
extinction,
inscattering,
} => FogFalloff::Atmospheric {
extinction: extinction / units,
inscattering: inscattering / units,
},
other => other,
}
}
pub fn orthographic_near_depth(eye: Vec3, forward: Vec3, focus: Vec3, view_height_m: f32) -> f32 {
let down = (-forward.y).clamp(10f32.to_radians().sin(), 1.0);
let cot = (1.0 - down * down).sqrt() / down;
(focus - eye).dot(forward) - 0.5 * view_height_m * cot
}
pub(crate) const HORIZON_RING: usize = 32;
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct HorizonRing(pub [Vec3; HORIZON_RING]);
impl HorizonRing {
pub(crate) fn toward(&self, forward: Vec3) -> Vec3 {
let heading = forward.x.atan2(-forward.z);
let share = ((forward.x.hypot(forward.z) - 0.05) / 0.45).clamp(0.0, 1.0);
let (mut sum, mut weights) = (Vec3::ZERO, 0.0);
for (i, radiance) in self.0.iter().enumerate() {
let azimuth = (i as f32 + 0.5) / HORIZON_RING as f32 * std::f32::consts::TAU;
let facing = (azimuth - heading).cos().max(0.0);
let weight = 1.0 - share + share * facing * facing;
sum += *radiance * weight;
weights += weight;
}
sum / weights.max(1e-6)
}
}
pub(crate) fn horizon_share(cloud_cover: f32, visibility_m: f32) -> f32 {
let air = 1.0 - (-sky_air_extinction(extinction_for_visibility(visibility_m)) * 32_000.0).exp();
(1.0 - air) * (1.0 - cloud_cover.clamp(0.0, 1.0))
}
pub fn fog_hue(sun_altitude_deg: f32, dust: f32) -> Vec3 {
let tinted = FOG_COLOUR * dusk_tint(sun_altitude_deg);
tinted.lerp(DUST_ALBEDO, dust.clamp(0.0, 1.0))
}
pub(crate) fn haze_albedo(dust: f32) -> f32 {
FOG_ALBEDO + (DUST_SINGLE_SCATTER - FOG_ALBEDO) * dust.clamp(0.0, 1.0)
}
pub fn volume_light_compensation(
visibility_m: f32,
scale_height_m: f32,
camera_height_m: f32,
sun_altitude_deg: f32,
volume_extent: Vec3,
) -> f32 {
let sigma_v = extinction_for_visibility(visibility_m);
let falloff = (-camera_height_m.max(0.0) / scale_height_m).exp();
let sigma_camera = sigma_v * falloff * MEAN_NOISE_FACTOR;
let radius = (volume_extent * 0.5).length();
let sine = sun_altitude_deg.to_radians().sin().max(MIN_SUN_SINE);
let tau_layer = sigma_v * MEAN_NOISE_FACTOR * scale_height_m * falloff / sine;
((sigma_camera * radius - tau_layer).exp()).clamp(1.0, MAX_VOLUME_LIGHT_GAIN)
}
pub(crate) fn volume_density_share(cloud: CloudLight) -> f32 {
let total = cloud.total();
if total <= 0.0 {
return 0.0;
}
(cloud.direct / total).clamp(0.0, 1.0)
}
pub(crate) fn volume_light_gain(cloud: CloudLight) -> f32 {
let total = cloud.total();
if cloud.direct <= 0.0 || total <= 0.0 {
return 1.0;
}
(total / cloud.direct).clamp(1.0, MAX_VOLUME_LIGHT_GAIN)
}
pub(crate) const MAX_VOLUME_LIGHT_GAIN: f32 = 100.0;
#[derive(Clone, Copy, Debug, Default, ShaderType)]
pub(crate) struct FogParams {
origin: Vec4,
extent: Vec4,
shape: Vec4,
size: UVec4,
precip: Vec4,
slant: Vec4,
}
#[derive(Resource, Clone)]
pub(crate) struct FogGpu {
pub texture: Handle<Image>,
pub weather_map: Handle<Image>,
pub params: FogParams,
pub active: bool,
}
#[derive(Resource)]
struct FogRuntime {
scale_height: f32,
wind_offset: Vec2,
neutral_map: Handle<Image>,
}
impl Default for FogRuntime {
fn default() -> Self {
Self {
scale_height: HAZE_SCALE_HEIGHT_M,
wind_offset: Vec2::ZERO,
neutral_map: Handle::default(),
}
}
}
pub struct FogPlugin;
impl Plugin for FogPlugin {
fn build(&self, app: &mut App) {
ensure_gpu_core(app);
embedded_asset!(app, "density.wesl");
app.init_resource::<FogRuntime>()
.init_resource::<WeatherDirector>()
.init_resource::<CelestialFrame>()
.add_systems(Startup, spawn_volume)
.add_systems(
Update,
(
update_volume.after(crate::clouds::CloudGpuUpdate),
update_distance_fog.before(crate::clouds::CloudGpuUpdate),
)
.in_set(ElementsSystems::Simulation),
);
gpu::plugin(app);
}
}
fn spawn_volume(
mut commands: Commands,
mut images: ResMut<Assets<Image>>,
mut runtime: ResMut<FogRuntime>,
) {
let texture = storage_volume(&mut images, GROUND_FOG_TEXELS);
runtime.neutral_map = images.add(Image::new_fill(
Extent3d::default(),
TextureDimension::D2,
&[0, 255, 0, 255],
TextureFormat::Rgba8Unorm,
RenderAssetUsages::RENDER_WORLD,
));
commands.spawn((
Name::new("Ground fog"),
GroundFog,
FogVolume {
density_factor: 0.0,
density_texture: Some(texture.clone()),
absorption: 1.0 - FOG_ALBEDO,
scattering: FOG_ALBEDO,
..default()
},
Transform::from_scale(GROUND_FOG_EXTENT),
));
commands.insert_resource(FogGpu {
texture,
weather_map: Handle::default(),
params: FogParams::default(),
active: false,
});
}
fn volume_centre(camera: Vec3, ground: f32) -> Vec3 {
let texel = GROUND_FOG_EXTENT / GROUND_FOG_TEXELS.as_vec3();
let xz = (camera.xz() / texel.xz()).round() * texel.xz();
let floor = (ground / texel.y).floor() * texel.y;
Vec3::new(xz.x, floor + GROUND_FOG_EXTENT.y * 0.5, xz.y)
}
#[allow(clippy::too_many_arguments)]
fn update_volume(
time: Res<Time<Virtual>>,
view: Res<ElementsView>,
sun: Res<SunState>,
weather: Res<Weather>,
director: Res<WeatherDirector>,
clock: Res<crate::celestial::WorldClock>,
shadows: Option<Res<CloudShadows>>,
mut runtime: ResMut<FogRuntime>,
gpu: Option<ResMut<FogGpu>>,
cloud_map: Option<Res<CloudWeatherMap>>,
mut volumes: Query<(&mut FogVolume, &mut Transform), With<GroundFog>>,
cameras: Query<&Projection>,
snapshot: Res<EnvironmentSnapshot>,
units: Res<ElementsUnits>,
) {
let Some(mut gpu) = gpu else {
return;
};
let Ok((mut volume, mut transform)) = volumes.single_mut() else {
return;
};
let Some(camera) = view.entity.map(|_| view.position) else {
gpu.active = false;
volume.density_factor = 0.0;
return;
};
let dt = time.delta_secs();
let game_dt = dt * clock.time_scale;
let goal = if director.current() == WeatherPreset::Fog {
FOG_SCALE_HEIGHT_M
} else {
HAZE_SCALE_HEIGHT_M
};
runtime.scale_height +=
(goal - runtime.scale_height) * (1.0 - (-game_dt / SCALE_HEIGHT_TAU_S).exp());
let wind = weather.wind_direction * weather.wind_speed_ms;
runtime.wind_offset = (runtime.wind_offset + wind * dt).rem_euclid(Vec2::splat(OFFSET_WRAP_M));
let ground = snapshot.ground_height(camera.xz());
let centre = volume_centre(camera, ground);
let u = units.per_metre();
*transform = Transform::from_translation(centre * u).with_scale(GROUND_FOG_EXTENT * u);
if view
.entity
.and_then(|e| cameras.get(e).ok())
.is_some_and(|p| matches!(p, Projection::Orthographic(_)))
{
gpu.active = false;
volume.density_factor = 0.0;
return;
}
let dust = weather.dust.clamp(0.0, 1.0);
let snow_fraction = weather.snow_fraction();
let split = precipitation_split(
weather.visibility_m,
weather.precipitation_mm_h,
snow_fraction,
);
let precipitation_share =
split.precipitation / (MEAN_NOISE_FACTOR * split.haze + split.precipitation).max(1e-9);
let haze_albedo = haze_albedo(dust);
let albedo = haze_albedo + (1.0 - haze_albedo) * precipitation_share;
volume.absorption = 1.0 - albedo;
volume.scattering = albedo;
let cloud = cloud_light(weather.cloud_cover, shadows.as_deref());
volume.density_factor = volume_density_share(cloud) / u;
volume.light_intensity = volume_light_gain(cloud)
* volume_light_compensation(
weather.visibility_m,
runtime.scale_height,
camera.y - ground,
sun.altitude_deg,
GROUND_FOG_EXTENT,
);
volume.fog_color = Color::linear_rgb(1.0, 1.0, 1.0).mix(
&Color::linear_rgb(DUST_ALBEDO.x, DUST_ALBEDO.y, DUST_ALBEDO.z),
dust,
);
let snow_rate = weather.precipitation_mm_h * snow_fraction;
let rain_rate = weather.precipitation_mm_h - snow_rate;
let (rain_sigma, snow_sigma) = (rain_extinction(rain_rate), snow_extinction(snow_rate));
let slant = if split.precipitation > 0.0 {
let rain_speed = water_weighted_fall_speed(marshall_palmer_lambda(rain_rate));
(rain_sigma / rain_speed + snow_sigma) / (rain_sigma + snow_sigma)
} else {
0.0
};
let (map, map_origin, map_extent) =
precipitation_map(cloud_map.as_deref(), &runtime.neutral_map);
gpu.weather_map = map;
gpu.params = FogParams {
origin: (centre - GROUND_FOG_EXTENT * 0.5).extend(1.0 / FBM2_MAX),
extent: GROUND_FOG_EXTENT.extend(split.haze),
shape: Vec4::new(
runtime.scale_height,
ground,
runtime.wind_offset.x,
runtime.wind_offset.y,
),
size: GROUND_FOG_TEXELS.extend(0),
precip: Vec4::new(
split.precipitation,
slant,
map_extent,
(weather.precipitation_mm_h / 25.0).clamp(0.0, 1.0),
),
slant: Vec4::new(wind.x, wind.y, map_origin.x, map_origin.y),
};
gpu.active = true;
}
fn precipitation_map(
clouds: Option<&CloudWeatherMap>,
neutral: &Handle<Image>,
) -> (Handle<Image>, Vec2, f32) {
match clouds {
Some(map) => (map.map.clone(), map.origin, map.extent),
None => (neutral.clone(), Vec2::ZERO, 1.0),
}
}
type FogCameras<'w, 's> = Query<
'w,
's,
(
Entity,
&'static ElementsCamera,
&'static mut DistanceFog,
Option<&'static Exposure>,
Option<&'static Projection>,
&'static GlobalTransform,
),
>;
#[allow(clippy::too_many_arguments)]
fn update_distance_fog(
weather: Res<Weather>,
sun: Res<SunState>,
frame: Res<CelestialFrame>,
view: Res<ElementsView>,
units: Res<ElementsUnits>,
shadows: Option<Res<CloudShadows>>,
snapshot: Option<Res<EnvironmentSnapshot>>,
mut cameras: FogCameras,
) {
let luminance = scene_illuminance(
&sun,
frame.moon_lux,
cloud_light(weather.cloud_cover, shadows.as_deref()),
) / std::f32::consts::PI;
let hue = fog_hue(sun.altitude_deg, weather.dust);
for (entity, dressing, mut fog, exposure, projection, transform) in &mut cameras {
if !dressing.distance_fog {
continue;
}
let exposure = exposure.copied().unwrap_or_default().exposure();
let brightness = luminance * exposure;
let analytic = hue * brightness;
let c = match snapshot.as_deref().and_then(|s| s.horizon.as_ref()) {
Some(ring) => {
let sky = ring.toward(*transform.forward()) * exposure;
analytic.lerp(
sky,
horizon_share(weather.cloud_cover, weather.visibility_m),
)
}
None => analytic,
};
fog.color = Color::linear_rgb(c.x, c.y, c.z);
fog.falloff = match projection {
Some(Projection::Orthographic(o)) => {
let eye = units.point_to_metres(transform.translation());
let focus = if view.entity == Some(entity) {
view.position
} else {
eye
};
let near = orthographic_near_depth(
eye,
*transform.forward(),
focus,
units.metres(o.area.height()),
);
falloff_in_world_units(
orthographic_fog_falloff(weather.visibility_m, near),
units.per_metre(),
)
}
_ => falloff_in_world_units(
distance_fog_falloff(weather.visibility_m),
units.per_metre(),
),
};
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_falloff_in_feet_fogs_the_same_metres() {
let feet = 3.2808_f32;
let metres = distance_fog_falloff(300.0);
assert_eq!(
format!("{:?}", falloff_in_world_units(metres.clone(), 1.0)),
format!("{metres:?}")
);
let (
FogFalloff::Atmospheric {
extinction: m,
inscattering: mi,
},
FogFalloff::Atmospheric {
extinction: f,
inscattering: fi,
},
) = (metres, falloff_in_world_units(distance_fog_falloff(300.0), feet))
else {
unreachable!("distance fog is atmospheric");
};
let (tm, tf) = ((-m * 300.0).exp(), (-f * 300.0 * feet).exp());
assert!((tm - tf).abs().max_element() < 1e-6, "{tm} {tf}");
assert!((fi - mi / feet).abs().max_element() < 1e-9);
let FogFalloff::Linear { start, end } =
falloff_in_world_units(orthographic_fog_falloff(300.0, 1500.0), feet)
else {
unreachable!("an orthographic view fogs linearly");
};
assert!((start / feet - 1500.0).abs() < 1e-2);
assert!((end / feet - (1500.0 + 300.0 / KOSCHMIEDER)).abs() < 1e-2);
}
#[test]
fn an_orthographic_view_fogs_from_the_bottom_of_its_ground() {
let focus = Vec3::new(3.0, 0.0, -4.0);
let at = |down_deg: f32| {
let d = down_deg.to_radians();
let forward = Vec3::new(0.0, -d.sin(), -d.cos());
orthographic_near_depth(focus - forward * 1500.0, forward, focus, 40.0)
};
assert!(
(at(30.0) - (1500.0 - 20.0 * 3f32.sqrt())).abs() < 1e-2,
"{}",
at(30.0)
);
assert!((at(90.0) - 1500.0).abs() < 1e-2, "{}", at(90.0));
assert!((at(2.0) - at(10.0)).abs() < 1e-2);
}
#[test]
fn the_fog_volume_stands_on_the_ground() {
let texel = GROUND_FOG_EXTENT / GROUND_FOG_TEXELS.as_vec3();
let camera = Vec3::new(13.7, 500.0, -2.2);
for ground in [412.3, -37.0, 0.0] {
let centre = volume_centre(camera, ground);
let bottom = centre.y - GROUND_FOG_EXTENT.y * 0.5;
assert!(
bottom <= ground && bottom > ground - texel.y,
"bottom {bottom} for ground {ground}"
);
let cells = centre.xz() / texel.xz();
assert!(
(cells - cells.round()).abs().max_element() < 1e-3,
"centre {centre} is not on a texel"
);
}
}
#[test]
fn the_skys_air_is_the_cameras_fog_extinction_above_clear_air() {
let air = |visibility: f32| sky_air_extinction(extinction_for_visibility(visibility));
assert_eq!(air(CLEAR_VISIBILITY_M), 0.0);
assert_eq!(air(80_000.0), 0.0);
assert!((air(200.0) - 1.946_22e-2).abs() < 1e-9);
assert!((air(300.0) - 1.294_22e-2).abs() < 1e-9);
assert!((air(1_500.0) - 2.510_2e-3).abs() < 1e-9);
assert!((air(20_000.0) - 9.78e-5).abs() < 1e-9);
let mut last = f32::MAX;
for visibility in [200.0, 300.0, 1_500.0, 5_000.0, 20_000.0, 40_000.0, 80_000.0] {
let a = air(visibility);
assert!(a <= last, "{a} > {last} at {visibility} m");
last = a;
}
}
#[test]
fn every_channel_reaches_two_percent_at_the_visibility_and_energy_is_conserved() {
let FogFalloff::Atmospheric {
extinction,
inscattering,
} = distance_fog_falloff(300.0)
else {
unreachable!("distance fog is always atmospheric");
};
assert!((extinction - Vec3::splat(0.013_040)).abs().max_element() < 1e-6);
assert_eq!(extinction, inscattering);
let t = (-extinction * 300.0).exp();
assert!((t - Vec3::splat(0.0200)).abs().max_element() < 2e-4);
for d in [0.0, 50.0, 300.0, 5000.0] {
let scene = (-extinction * d).exp();
let fog = Vec3::ONE - (-inscattering * d).exp();
assert!(((scene + fog) - Vec3::ONE).abs().max_element() < 1e-6);
}
}
#[test]
fn the_volume_light_matches_the_layer_attenuation_at_the_camera() {
let k = volume_light_compensation(300.0, 25.0, 2.0, 48.0, GROUND_FOG_EXTENT);
let sigma_v = 3.912_f32 / 300.0;
let falloff = (-2.0_f32 / 25.0).exp();
let radius = (GROUND_FOG_EXTENT * 0.5).length();
let expected = (sigma_v * falloff * 0.8 * radius
- sigma_v * 0.8 * 25.0 * falloff / 48f32.to_radians().sin())
.exp();
assert!((k - expected).abs() < 1e-3 * expected, "{k} vs {expected}");
let clear = volume_light_compensation(40_000.0, 60.0, 2.0, 48.0, GROUND_FOG_EXTENT);
assert!((1.0..1.01).contains(&clear), "{clear}");
}
#[test]
fn the_volume_goes_out_where_bevy_cannot_light_it() {
assert_eq!(volume_density_share(CloudLight::CLEAR), 1.0);
assert_eq!(volume_density_share(CloudLight::from_beam(0.0)), 0.0);
let half = volume_density_share(CloudLight::from_beam(0.5));
assert!((half - 0.5 / 0.625).abs() < 1e-6, "{half}");
let mut last = -1.0;
for i in 0..=20 {
let share = volume_density_share(CloudLight::from_beam(i as f32 / 20.0));
assert!(share >= last, "not monotone at {i}");
last = share;
}
}
#[test]
fn the_volumes_light_and_its_density_are_reciprocal() {
for i in 0..=20 {
let beam = 0.05 + i as f32 / 20.0 * 0.95;
let cloud = CloudLight::from_beam(beam);
let product = volume_density_share(cloud) * volume_light_gain(cloud);
assert!((product - 1.0).abs() < 1e-3, "beam {beam}: {product}");
}
assert_eq!(volume_light_gain(CloudLight::CLEAR), 1.0);
assert_eq!(
volume_light_gain(CloudLight::from_beam(1e-6)),
MAX_VOLUME_LIGHT_GAIN
);
}
#[test]
fn the_horizon_ring_is_averaged_toward_the_heading() {
let constant = HorizonRing([Vec3::new(0.2, 0.4, 0.6); HORIZON_RING]);
for forward in [Vec3::NEG_Z, Vec3::X, Vec3::new(0.3, -0.2, 0.9).normalize()] {
assert!((constant.toward(forward) - Vec3::new(0.2, 0.4, 0.6)).length() < 1e-6);
}
let mut ring = [Vec3::ZERO; HORIZON_RING];
for (i, slot) in ring.iter_mut().enumerate() {
let a = (i as f32 + 0.5) / HORIZON_RING as f32 * std::f32::consts::TAU;
if (a - std::f32::consts::FRAC_PI_2).abs() < 0.4 {
*slot = Vec3::ONE;
}
}
let ring = HorizonRing(ring);
let toward = ring.toward(Vec3::X).x;
let away = ring.toward(Vec3::NEG_X).x;
let down = ring.toward(Vec3::NEG_Y).x;
let mean = ring.0.iter().map(|v| v.x).sum::<f32>() / HORIZON_RING as f32;
assert!(toward > 0.4, "facing the glow: {toward}");
assert!(away < 1e-6, "facing away: {away}");
assert!(
(down - mean).abs() < 1e-6,
"looking down: {down} against {mean}"
);
}
#[test]
fn the_sky_gives_way_to_cloud_and_thick_air() {
assert!((horizon_share(0.0, CLEAR_VISIBILITY_M) - 1.0).abs() < 1e-6);
assert_eq!(horizon_share(1.0, CLEAR_VISIBILITY_M), 0.0);
assert!(horizon_share(0.0, 300.0) < 1e-6);
let partial = horizon_share(0.4, CLEAR_VISIBILITY_M);
assert!((partial - 0.6).abs() < 1e-6, "{partial}");
}
#[test]
fn the_hue_tints_at_dusk_and_in_dust() {
assert_eq!(fog_hue(90.0, 0.0), FOG_COLOUR);
let dusk = fog_hue(5.0, 0.0) / FOG_COLOUR;
assert!((dusk - Vec3::new(1.0, 0.775, 0.65)).length() < 1e-5);
assert!((fog_hue(45.0, 1.0) - DUST_ALBEDO).length() < 1e-6);
assert_eq!(fog_hue(0.0, 0.0), FOG_COLOUR * crate::celestial::DUSK_TINT);
assert_eq!(fog_hue(-20.0, 0.0), FOG_COLOUR);
}
#[test]
fn a_dust_storm_changes_the_haze_colour_and_albedo_not_its_extinction() {
use crate::precipitation::model::precipitation_split;
let sigma_v = extinction_for_visibility(800.0);
assert!((sigma_v - 4.890e-3).abs() < 1e-6, "{sigma_v}");
let split = precipitation_split(800.0, 0.0, 0.0);
assert_eq!(split.haze, sigma_v);
assert_eq!(split.precipitation, 0.0);
assert_eq!(haze_albedo(0.0), FOG_ALBEDO);
assert!(
(haze_albedo(0.9) - 0.9).abs() < 0.006,
"{}",
haze_albedo(0.9)
);
assert!((haze_albedo(1.0) - DUST_SINGLE_SCATTER).abs() < 1e-6);
let colour = Vec3::ONE.lerp(DUST_ALBEDO, 0.9);
assert!(
(colour - Vec3::new(0.784, 0.64, 0.478)).length() < 1e-3,
"{colour}"
);
let hue = fog_hue(45.0, 0.9);
assert!(hue.x > hue.y && hue.y > hue.z, "{hue}");
}
fn kernel(params: &FogParams, p: Vec3, wind_offset: Vec2) -> (f32, f32) {
use crate::gpu::noise_mirror::fbm3;
let unit = |n: f32| (0.5 + 0.5 * n * params.origin.w).clamp(0.0, 1.0);
let h = (p.y - params.shape.y).max(0.0);
let q = (p - Vec3::new(wind_offset.x, 0.0, wind_offset.y)) / 80.0;
let haze = params.extent.w * (-h / params.shape.x).exp() * (0.6 + 0.4 * unit(fbm3(q, 2)));
let y_ref = params.origin.y + 0.5 * params.extent.y;
let x_ref = p.xz() + params.slant.xy() * params.precip.y * (p.y - y_ref);
let coverage = (1.0 / params.precip.w.max(1e-4)).clamp(0.0, 1.0);
let qs = Vec3::new(
(x_ref.x - wind_offset.x) / 120.0,
0.37,
(x_ref.y - wind_offset.y) / 120.0,
);
let s = coverage * (0.4 + 1.2 * unit(fbm3(qs, 2)));
(haze, params.precip.x * s)
}
#[test]
fn the_kernel_with_the_shaft_term_matches_the_note() {
use crate::gpu::noise_mirror::samples;
let split = precipitation_split(3000.0, 4.0, 0.0);
let params = FogParams {
origin: Vec3::new(-120.0, 0.0, -120.0).extend(1.0 / FBM2_MAX),
extent: GROUND_FOG_EXTENT.extend(split.haze),
shape: Vec4::new(HAZE_SCALE_HEIGHT_M, 0.0, 0.0, 0.0),
size: GROUND_FOG_TEXELS.extend(0),
precip: Vec4::new(split.precipitation, 0.0, 1.0, 4.0 / 25.0),
slant: Vec4::ZERO,
};
let n = 100_000;
let (mut total, mut shaft_factor) = (0.0_f64, 0.0_f64);
for p in samples(n, 20_000.0) {
let p = Vec3::new(p.x, 60.0, p.z);
let (haze, shafts) = kernel(¶ms, p, Vec2::ZERO);
total += f64::from(haze + shafts);
shaft_factor += f64::from(shafts / split.precipitation);
}
let (total, shaft_factor) = (total / n as f64, shaft_factor / n as f64);
let expected = (-1.0_f64).exp() * 0.8 * 7.598e-4 + 5.442e-4;
println!(
"slab mean {total:.4e} (expected {expected:.4e}), shaft factor mean {shaft_factor:.4}"
);
assert!((expected - 7.68e-4).abs() < 1e-6, "{expected}");
assert!(
(total / expected - 1.0).abs() < 0.03,
"{total:e} vs {expected:e}"
);
assert!(
(shaft_factor - 1.0).abs() < 0.02,
"shaft factor mean {shaft_factor}"
);
let optical_depth = f64::from(split.precipitation) * 120.0;
assert!((optical_depth - 0.065).abs() < 0.0005, "{optical_depth}");
}
}