Earth and moon eclipse

This commit is contained in:
connor committed 2026-10-14 21:56:11 -04:00
1 parent ff2ef10804
commit 85896b17a4
16 files changed
+575 -24

No files matched your search

+4
View File
@@ -27,3 +27,7 @@ serde = { version = "1.0", features = ["derive"] }
toml = "1.1"
image = { version = "0.25", default-features = false, features = ["png"] }
hifitime = { version = "4.3", optional = true }
[dev-dependencies]
# JPEG too, so the tests can write a small stand-in for the Blue Marble images.
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
+2 -2
View File
@@ -45,8 +45,8 @@ struct RenderArgs {
/// An 8-bit sRGB PNG for viewing: bias subtracted, saturation as white.
#[arg(long)]
preview: Option<PathBuf>,
/// Lighting fidelity tier: L0, or L1 (adds Earth's surface, shadows on targets and
/// light from Earth and the Moon; needs Blue Marble data).
/// Lighting fidelity tier: L0, or L1 (adds Earth's surface, shadows on targets,
/// light from Earth and the Moon, and eclipses on them; needs Blue Marble data).
#[arg(long, default_value = "L0")]
lighting: LightingTier,
/// Camera fidelity tier: C0.
+73 -1
View File
@@ -27,8 +27,37 @@ fn resolved(args: &[&str]) -> std::process::Output {
/// Renders `scene` on `adapter` to a 16-bit PNG and returns its pixels as DN (the
/// file holds 12-bit DN shifted up by 4).
fn render(scene: &str, adapter: &str) -> image::ImageBuffer<image::Rgb<u16>, Vec<u16>> {
render_at(scene, adapter, "L0")
}
/// A data directory whose Earth images are small and uniformly gray, for tests at
/// lighting L1 that do not need Blue Marble: loading the real 16384 x 8192 images takes
/// seconds in a debug build, and they are only there once fetched.
fn gray_earth() -> PathBuf {
static DIR: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
DIR.get_or_init(|| {
let dir = out_dir().join("gray-data");
std::fs::create_dir_all(dir.join("earth")).unwrap();
let image = image::RgbImage::from_pixel(64, 32, image::Rgb([128; 3]));
for month in 1..=12 {
image
.save(dir.join(format!("earth/bmng-2004-{month:02}.jpg")))
.unwrap();
}
dir
})
.clone()
}
/// [`render`] at lighting tier `lighting`, with Earth's images from [`gray_earth`].
fn render_at(
scene: &str,
adapter: &str,
lighting: &str,
) -> image::ImageBuffer<image::Rgb<u16>, Vec<u16>> {
let data = gray_earth();
let out = out_dir().join(format!(
"{}-{adapter}.png",
"{}-{lighting}-{adapter}.png",
Path::new(scene).file_stem().unwrap().to_str().unwrap()
));
let preview = out.with_extension("preview.png");
@@ -38,6 +67,10 @@ fn render(scene: &str, adapter: &str) -> image::ImageBuffer<image::Rgb<u16>, Vec
scene,
"--adapter",
adapter,
"--lighting",
lighting,
"--data",
data.to_str().unwrap(),
"--out",
out.to_str().unwrap(),
"--preview",
@@ -92,6 +125,45 @@ fn epoch_scene_renders() {
assert_eq!(frame.get_pixel(0, 0).0, [64; 3]);
}
#[cfg(feature = "astrokit")]
#[test]
fn lunar_eclipse_darkens_the_moon() {
// Mid-totality: at L1 the whole Moon is in Earth's umbra and reads the bias. At L0
// there are no eclipses on the bodies and it is a full Moon.
let scene = "examples/scenes/lunar-eclipse.toml";
let l0 = render_at(scene, "gpu", "L0");
let l1 = render_at(scene, "gpu", "L1");
assert!(
l0.get_pixel(960, 540).0.iter().all(|&v| v > 150),
"{:?}",
l0.get_pixel(960, 540)
);
assert!(
l1.pixels().all(|p| p.0 == [64; 3]),
"every pixel at the bias"
);
}
#[cfg(feature = "astrokit")]
#[test]
fn solar_eclipse_puts_the_umbra_at_greatest_eclipse() {
// The camera looks straight down at the point of greatest eclipse from NASA's
// bulletin (65.2 N 25.2 W, 17:46 UTC): at L1 it is in the Moon's umbra and reads the
// bias, and the image corner, about 3500 km away in the penumbra, is brighter. At L0
// the center is lit.
let scene = "examples/scenes/solar-eclipse.toml";
let l0 = render_at(scene, "gpu", "L0");
let l1 = render_at(scene, "gpu", "L1");
assert!(
l0.get_pixel(960, 540).0.iter().all(|&v| v > 100),
"{:?}",
l0.get_pixel(960, 540)
);
assert_eq!(l1.get_pixel(960, 540).0, [64; 3]);
let corner = l1.get_pixel(40, 1040).0;
assert!(corner.iter().all(|&v| v > 70), "{corner:?}");
}
#[test]
fn errors_are_reported() {
let missing = resolved(&["render", "--scene", "no/such.toml", "--preview", "x.png"]);
+4 -3
View File
@@ -21,9 +21,10 @@ pub enum LightingTier {
/// L0, plus Earth's surface from an image (`RendererOptions::earth_texture`, NASA's
/// Blue Marble) with a stand-in for the atmosphere's blue; shadows that targets
/// cast on themselves and on each other, with the penumbra the Sun's disk gives
/// them; and sunlight reflected onto targets by Earth and the Moon (not shadowed by
/// the targets). The rest of M2 (the Moon's albedo map, limb darkening, eclipses on
/// the bodies) joins this tier as it lands.
/// them; sunlight reflected onto targets by Earth and the Moon (not shadowed by the
/// targets); and Earth and the Moon shadowing each other, lunar and solar eclipses.
/// The rest of M2 (the Moon's albedo map, limb darkening) joins this tier as it
/// lands.
L1,
}
+4 -1
View File
@@ -35,6 +35,8 @@ pub struct BodyRadiometry {
pub earth_haze: [f32; 4],
/// Whether Earth is drawn from a texture.
pub earth_textured: bool,
/// Whether Earth and the Moon shadow each other: lunar and solar eclipses.
pub bodies_eclipsed: bool,
/// Per channel, Earth's albedo from `Appearance::earth_albedo` (no texture).
pub earth_albedo: [f32; 4],
/// The same as `earth_lambert_1au` for the Moon.
@@ -84,6 +86,7 @@ impl BodyRadiometry {
earth_rgb: [basis(0), basis(1), basis(2)],
earth_haze: albedo(&appearance.earth_haze),
earth_textured: false,
bodies_eclipsed: false,
earth_albedo: albedo(&appearance.earth_albedo),
moon_lambert_1au: lambert(radiometry.effective_albedo(&appearance.moon_albedo)),
moon_albedo: albedo(&appearance.moon_albedo),
@@ -144,7 +147,7 @@ impl BodiesUniforms {
params: [
near as f32,
f32::from(u8::from(radiometry.earth_textured)),
0.0,
f32::from(u8::from(radiometry.bodies_eclipsed)),
0.0,
],
earth_center: vec4(rel.earth.translation, EARTH_EQUATORIAL_RADIUS),
+4
View File
@@ -11,6 +11,7 @@ use super::bodies::BodyRadiometry;
use super::profile::PassTimestamps;
use super::GpuContext;
use crate::bodies::{EARTH_EQUATORIAL_RADIUS, EARTH_POLAR_RADIUS, MOON_RADIUS};
use crate::radiometry::SUN_RADIUS;
use crate::scene::{Pose, Scene};
/// Rings of cells over each body's cone, from its center out to its limb.
@@ -68,6 +69,7 @@ pub struct TargetLight {
moon: Source,
// mat3x3 columns, each padded to 16 bytes.
earth_axes: [[f32; 4]; 3],
sun: [f32; 4],
}
impl TargetLight {
@@ -81,10 +83,12 @@ impl TargetLight {
[c.x as f32, c.y as f32, c.z as f32, 0.0]
};
let source = |center, radius| Source::new(point, center, radius, scene.sun, &to_camera);
let sun = to_camera * (scene.sun - point);
Self {
earth: source(Vector3::zeros(), EARTH_EQUATORIAL_RADIUS),
moon: source(scene.moon, MOON_RADIUS),
earth_axes: [column(0), column(1), column(2)],
sun: [sun.x as f32, sun.y as f32, sun.z as f32, SUN_RADIUS as f32],
}
}
}
+2 -1
View File
@@ -111,7 +111,8 @@ impl Renderer {
camera.validate()?;
let ctx = GpuContext::new(&options.adapter)?;
let radiometry = Radiometry::new(&camera);
let body_radiometry = BodyRadiometry::new(&radiometry, &options.appearance)?;
let mut body_radiometry = BodyRadiometry::new(&radiometry, &options.appearance)?;
body_radiometry.bodies_eclipsed = options.fidelity.lighting >= LightingTier::L1;
let targets = FrameTargets::new(
&ctx,
camera.width,
+12 -2
View File
@@ -8,7 +8,8 @@
struct Uniforms {
// fx, fy, cx, cy in pixels.
intrinsics: vec4<f32>,
// x: near clip distance. y: 1 if Earth is drawn from the texture.
// x: near clip distance. y: 1 if Earth is drawn from the texture. z: 1 if Earth
// and the Moon shadow each other.
params: vec4<f32>,
// xyz: Earth center. w: equatorial radius.
earth_center: vec4<f32>,
@@ -102,13 +103,22 @@ fn fs_main(@builtin(position) pos: vec4<f32>) -> Out {
} else {
radiance = u.earth_lambert * lit;
}
// The Moon's shadow: a solar eclipse.
if u.params.z > 0.5 && lit > 0.0 {
radiance *= sun_visible(te * d, u.sun_center, u.moon_center);
}
}
let tm = sphere_hit(-u.moon_center.xyz, d, u.moon_center.w);
if tm > 0.0 && tm < best {
best = tm;
let n = normalize(tm * d - u.moon_center.xyz);
radiance = u.moon_lambert * max(dot(n, u.moon_sun.xyz), 0.0);
let lit = max(dot(n, u.moon_sun.xyz), 0.0);
radiance = u.moon_lambert * lit;
// Earth's shadow: a lunar eclipse.
if u.params.z > 0.5 && lit > 0.0 {
radiance *= sun_visible(tm * d, u.sun_center, u.earth_center);
}
}
let ts = sphere_hit(-u.sun_center.xyz, d, u.sun_center.w);
+30
View File
@@ -57,3 +57,33 @@ fn equirectangular(n: vec3<f32>) -> vec2<f32> {
fn textured_albedo(rgb: vec3<f32>, rgb_to_channels: mat3x4<f32>, haze: vec4<f32>) -> vec4<f32> {
return rgb_to_channels * rgb + haze;
}
// The fraction of the solar disk visible from `p` past one spherical occulter, with
// the Sun and the occulter given as (center, radius), all relative to the same origin.
//
// The overlap of two circles on the sky, as in eclipse.rs (Montenbruck and Gill). The
// lens area uses atan2 where eclipse.rs uses acos: acos loses its precision in f32
// near 1, which is where a sliver of the Sun is covered.
fn sun_visible(p: vec3<f32>, sun: vec4<f32>, occulter: vec4<f32>) -> f32 {
let to_sun = sun.xyz - p;
let to_body = occulter.xyz - p;
let a = asin(min(sun.w / length(to_sun), 1.0));
let b = asin(min(occulter.w / length(to_body), 1.0));
// atan2 keeps precision near zero separation, where acos of a dot product loses it.
let c = atan2(length(cross(to_sun, to_body)), dot(to_sun, to_body));
if c >= a + b {
return 1.0;
}
if c <= b - a {
return 0.0;
}
if c <= a - b {
return 1.0 - (b / a) * (b / a);
}
// A lens-shaped overlap: the circles cross at distance x along the line between
// their centers from the Sun's, y either side of it.
let x = (c * c + (a - b) * (a + b)) / (2.0 * c);
let y = sqrt(max((a - x) * (a + x), 0.0));
let area = a * a * atan2(y, x) + b * b * atan2(y, c - x) - c * y;
return clamp(1.0 - area / (PI * a * a), 0.0, 1.0);
}
+9 -3
View File
@@ -6,8 +6,8 @@
// fixed grid of cells of equal solid angle (rings of equal width in cos(theta)
// around the direction to the body, each split into equal sectors). A ray from the
// target through each cell's center is shaded where it meets the body, as the
// bodies pass shades what the camera sees. Rays that miss (past an ellipsoid's
// flattened poles) or meet the other body first carry nothing.
// bodies pass shades what the camera sees, eclipses included. Rays that miss (past
// an ellipsoid's flattened poles) or meet the other body first carry nothing.
// 2. `irradiance`: for every direction a surface can face, one texel of a small cube
// map per target, the sum of what those cells send onto it:
// E(n) = sum of L * max(n . d, 0) * dOmega.
@@ -38,6 +38,8 @@ struct TargetLight {
moon: Source,
// Columns: Earth-fixed axes in the camera frame.
earth_axes: mat3x3<f32>,
// xyz: the Sun's center relative to the target. w: its radius.
sun: vec4<f32>,
}
struct Globals {
@@ -130,15 +132,19 @@ fn sources(@builtin(global_invocation_id) id: vec3<u32>) {
let tm = sphere_hit(-t.moon.center.xyz, d, t.moon.center.w);
var albedo = vec4<f32>(0.0);
var normal = vec3<f32>(0.0);
// Each body in the other's shadow (eclipses) as the bodies pass draws it.
var visible = 1.0;
if !is_moon && te > 0.0 && !(tm > 0.0 && tm < te) {
let surface = earth_surface(t, d, te);
albedo = surface[0];
normal = surface[1].xyz;
visible = sun_visible(te * d, t.sun, t.moon.center);
} else if is_moon && tm > 0.0 && !(te > 0.0 && te < tm) {
albedo = g.moon_albedo;
normal = normalize(tm * d - t.moon.center.xyz);
visible = sun_visible(tm * d, t.sun, t.earth.center);
}
let lit = max(dot(normal, source.sun.xyz), 0.0);
let lit = max(dot(normal, source.sun.xyz), 0.0) * visible;
var out: Sample;
out.light = albedo * (lit * source.sun.w * source.cone.y);
out.direction = vec4<f32>(d, 0.0);
+34 -1
View File
@@ -291,7 +291,10 @@ fn quadrature(case: &Case, surfaces: &Surfaces, rings: usize, sectors: usize) ->
if other.distance(p, d).is_some_and(|s| s < t) {
continue;
}
let lit = body.normal(p + t * d).dot(&to_sun).max(0.0);
// The other body's shadow on this one: a lunar or a solar eclipse.
let point = p + t * d;
let shadow = sun_visible_fraction(point, case.sun, &[(other.center, other.radius)]);
let lit = body.normal(point).dot(&to_sun).max(0.0) * shadow;
for (s, albedo) in sum.iter_mut().zip(body.albedo) {
*s += albedo * lit / PI * ratio * cell * facing;
}
@@ -534,6 +537,36 @@ fn bodies_hide_each_other() {
);
}
#[test]
fn eclipses_dim_the_light_from_the_bodies() {
// The Moon in Earth's umbra sheds nothing; across the umbra's edge, part. Earth
// with the Moon's shadow on it, seen from inside that shadow.
let eclipsed_moon = |name, offset| {
let moon = Vector3::new(-MOON_DISTANCE, offset, 0.0);
Case {
moon,
..Case::new(
name,
moon + Vector3::new(MOON_RADIUS + 100e3, 0.0, 0.0),
-Vector3::x(),
)
}
};
compare(
Surfaces::WHITE_EARTH,
1e-8,
&[
eclipsed_moon("moon-in-umbra", 0.0),
eclipsed_moon("moon-in-penumbra", 5.5e6),
],
);
let solar = Case {
moon: Vector3::new(3.6e8, 0.0, 0.0),
..Case::new("earth-under-the-moon", LEO, -Vector3::x())
};
compare(Surfaces::WHITE_EARTH, 1e-4, &[solar]);
}
#[test]
fn lighting_l0_has_no_earthshine() {
let _devices = common::device_lock();
+286
View File
@@ -0,0 +1,286 @@
//! Eclipses seen on the bodies at lighting L1, on the GPU and on the CPU (lavapipe):
//! Earth's shadow on the Moon and the Moon's on Earth, pixel by pixel against
//! `eclipse::sun_visible_fraction` at each pixel's surface point.
mod common;
use std::sync::Arc;
use resolved::bodies::{EARTH_EQUATORIAL_RADIUS, EARTH_POLAR_RADIUS, MOON_RADIUS};
use resolved::eclipse::sun_visible_fraction;
use resolved::gpu::AdapterChoice;
use resolved::nalgebra::{UnitQuaternion, Vector2, Vector3};
use resolved::radiometry::AU;
use resolved::{
CameraConfig, EarthTexture, Fidelity, LightingTier, Pose, Renderer, RendererOptions, Scene,
};
const SUN: Vector3<f64> = Vector3::new(AU, 0.0, 0.0);
const MOON_DISTANCE: f64 = 3.844e8;
fn renderer(adapter: &AdapterChoice, lighting: LightingTier) -> Renderer {
let gray = EarthTexture::from_rgba8(1, 1, vec![128, 128, 128, 255]).unwrap();
let options = RendererOptions {
adapter: adapter.clone(),
fidelity: Fidelity {
lighting,
..Default::default()
},
earth_texture: Some(Arc::new(gray)),
read_radiance: true,
..Default::default()
};
Renderer::new(CameraConfig::example(), options).unwrap()
}
fn scene(moon: Vector3<f64>) -> Scene {
Scene {
earth_attitude: UnitQuaternion::identity(),
sun: SUN,
moon,
targets: vec![],
}
}
fn radiance(renderer: &mut Renderer, scene: &Scene, camera: &Pose) -> Vec<[f32; 4]> {
let frame = renderer.submit(scene, camera).unwrap().wait().unwrap();
frame.radiance.unwrap()
}
/// Where the line of sight through the center of pixel (u, v) first meets the sphere
/// or (Z-flattened, identity attitude) ellipsoid at `center`, inertial.
fn hit(
camera: &Pose,
u: usize,
v: usize,
center: Vector3<f64>,
radius: f64,
flattening: f64,
) -> Option<Vector3<f64>> {
let k = CameraConfig::example().intrinsics();
let d = camera.attitude * k.ray(&Vector2::new(u as f64 + 0.5, v as f64 + 0.5));
let stretch = Vector3::new(1.0, 1.0, flattening);
let o = (camera.position - center).component_mul(&stretch);
let ds = d.component_mul(&stretch);
let (a, b, c) = (
ds.norm_squared(),
o.dot(&ds),
o.norm_squared() - radius * radius,
);
let disc = b * b - a * c;
let t = (-b - disc.max(0.0).sqrt()) / a;
(disc > 0.0 && t > 0.0).then(|| camera.position + t * d)
}
/// How the pixels of one body compared: counts of those entirely in shadow, partly and
/// not at all, the least expected fraction among the partly shadowed, and the largest
/// difference between rendered and expected fraction.
#[derive(Debug)]
struct Tally {
dark: usize,
partial: usize,
lit: usize,
least: f64,
worst: f64,
}
/// The pixels compared: every second one each way, which keeps the f64 work quick in a
/// debug build.
fn sampled() -> impl Iterator<Item = (usize, usize)> {
(0..1080)
.step_by(2)
.flat_map(|v| (0..1920).step_by(2).map(move |u| (u, v)))
}
/// For each [`sampled`] pixel, the fraction of the Sun `visible` gives at the surface
/// point `point` finds there, if it finds one. Computed once per scene and shared by
/// both devices, on every core.
fn expected(
point: impl Fn(usize, usize) -> Option<Vector3<f64>> + Sync,
visible: impl Fn(Vector3<f64>) -> f64 + Sync,
) -> Vec<Option<f64>> {
let pixels: Vec<_> = sampled().collect();
let threads = std::thread::available_parallelism().map_or(1, |n| n.get());
let chunk = pixels.len().div_ceil(threads);
std::thread::scope(|scope| {
let workers: Vec<_> = pixels
.chunks(chunk)
.map(|part| {
let (point, visible) = (&point, &visible);
scope.spawn(move || {
part.iter()
.map(|&(u, v)| point(u, v).map(visible))
.collect::<Vec<_>>()
})
})
.collect();
workers
.into_iter()
.flat_map(|w| w.join().unwrap())
.collect()
})
}
/// Compares `eclipsed` with `clear` (the same pixels without the shadow) against
/// `expected` at every [`sampled`] pixel that has a value. Pixels at the body's edge,
/// where `clear` is under 1 % of its largest value, are skipped: a rendered edge and
/// an f64 ray need not agree on which pixels they cover.
fn tally(eclipsed: &[[f32; 4]], clear: &[[f32; 4]], expected: &[Option<f64>]) -> Tally {
let peak = clear.iter().map(|p| p[1]).fold(0.0, f32::max);
let mut t = Tally {
dark: 0,
partial: 0,
lit: 0,
least: 1.0,
worst: 0.0,
};
for ((u, v), want) in sampled().zip(expected) {
let (got, base) = (eclipsed[v * 1920 + u], clear[v * 1920 + u]);
let Some(want) = *want else { continue };
if base[1] < 0.01 * peak {
continue;
}
for k in 0..3 {
let ratio = got[k] as f64 / base[k] as f64;
if want == 0.0 {
assert_eq!(got[k], 0.0, "pixel ({u}, {v}) is in the umbra");
} else if want == 1.0 {
assert_eq!(got[k], base[k], "pixel ({u}, {v}) is outside the shadow");
} else {
t.worst = t.worst.max((ratio - want).abs());
}
}
match want {
0.0 => t.dark += 1,
1.0 => t.lit += 1,
_ => {
t.partial += 1;
t.least = t.least.min(want);
}
}
}
t
}
/// A camera 44,400 km in front of the Moon's sunward face, where the Moon is 712
/// pixels across, with Earth behind the camera.
fn moon_camera(moon: Vector3<f64>) -> Pose {
Pose::looking_at(moon + Vector3::new(4.44e7, 0.0, 0.0), moon, Vector3::z()).unwrap()
}
#[test]
fn earth_shadows_the_moon() {
let earth = [(Vector3::zeros(), EARTH_EQUATORIAL_RADIUS)];
// Straight behind Earth: inside the umbra, which reaches 4600 km from the axis at
// the Moon's distance. Then 5500 km off the axis, across the umbra's edge into the
// penumbra (which ends 8200 km out).
let cases = [("total", 0.0), ("partial", 5.5e6)].map(|(label, offset)| {
let moon = Vector3::new(-MOON_DISTANCE, offset, 0.0);
let camera = moon_camera(moon);
let want = expected(
|u, v| hit(&camera, u, v, moon, MOON_RADIUS, 1.0),
|p| sun_visible_fraction(p, SUN, &earth),
);
(label, moon, camera, want)
});
let _devices = common::device_lock();
for adapter in [AdapterChoice::Gpu, AdapterChoice::Cpu] {
let mut l0 = renderer(&adapter, LightingTier::L0);
let mut l1 = renderer(&adapter, LightingTier::L1);
let name = l1.adapter_info().name.clone();
for (label, moon, camera, want) in &cases {
let clear = radiance(&mut l0, &scene(*moon), camera);
let eclipsed = radiance(&mut l1, &scene(*moon), camera);
common::save(
&format!("eclipse-lunar-{label}"),
l1.adapter_info(),
&eclipsed,
);
let t = tally(&eclipsed, &clear, want);
match *label {
"total" => assert!(
t.dark > 50_000 && t.partial == 0 && t.lit == 0,
"{name}: {t:?}"
),
_ => assert!(t.dark > 1000 && t.partial > 10_000, "{name}: {t:?}"),
}
// Measured: 9e-7 on the RTX 3060, 5e-5 on lavapipe.
assert!(t.worst < 1e-4, "{name} {label}: {t:?}");
}
}
}
#[test]
fn moon_shadows_earth() {
// Looking down at the subsolar point from 150,000 km, where Earth is 775 pixels
// across. Without the eclipse, the Moon sits behind Earth, out of view.
let camera = Pose::looking_at(
Vector3::new(1.5e8, 0.0, 0.0),
Vector3::zeros(),
Vector3::z(),
)
.unwrap();
let hidden = scene(Vector3::new(-MOON_DISTANCE, 0.0, 0.0));
let flattening = EARTH_EQUATORIAL_RADIUS / EARTH_POLAR_RADIUS;
// Near enough for the umbra to reach the ground (a total eclipse); then at the mean
// distance and off the axis, where it falls short (annular).
let cases = [
("total", Vector3::new(3.6e8, 0.0, 0.0)),
("annular", Vector3::new(MOON_DISTANCE, 2.0e6, 1.0e6)),
]
.map(|(label, moon)| {
let want = expected(
|u, v| {
hit(
&camera,
u,
v,
Vector3::zeros(),
EARTH_EQUATORIAL_RADIUS,
flattening,
)
},
|p| sun_visible_fraction(p, SUN, &[(moon, MOON_RADIUS)]),
);
(label, moon, want)
});
let _devices = common::device_lock();
for adapter in [AdapterChoice::Gpu, AdapterChoice::Cpu] {
let mut l1 = renderer(&adapter, LightingTier::L1);
let name = l1.adapter_info().name.clone();
let clear = radiance(&mut l1, &hidden, &camera);
for (label, moon, want) in &cases {
let eclipsed = radiance(&mut l1, &scene(*moon), &camera);
common::save(
&format!("eclipse-solar-{label}"),
l1.adapter_info(),
&eclipsed,
);
let t = tally(&eclipsed, &clear, want);
match *label {
"total" => assert!(t.dark > 10 && t.partial > 10_000, "{name}: {t:?}"),
// In the antumbra the Moon sits inside the Sun's disk and 1 - (b/a)^2,
// about 0.024, of it shows; a partial overlap never gets that low.
_ => assert!(
t.dark == 0 && t.partial > 10_000 && t.least < 0.03,
"{name}: {t:?}"
),
}
// Measured: 6e-7 on the RTX 3060, 1.6e-5 on lavapipe.
assert!(t.worst < 1e-4, "{name} {label}: {t:?}");
}
}
}
#[test]
fn lighting_l0_draws_no_eclipses_on_the_bodies() {
let _devices = common::device_lock();
let moon = Vector3::new(-MOON_DISTANCE, 0.0, 0.0);
let camera = moon_camera(moon);
for adapter in [AdapterChoice::Gpu, AdapterChoice::Cpu] {
let mut l0 = renderer(&adapter, LightingTier::L0);
let image = radiance(&mut l0, &scene(moon), &camera);
// The Moon in Earth's umbra, drawn as a full Moon.
assert!(image[540 * 1920 + 960][1] > 0.0);
}
}