sunlight through atmosphere
This commit is contained in:
1 parent
8c0a1439d7
commit
8e57375059
18 files changed
+1921
-134
No files matched your search
@@ -189,6 +189,9 @@ fn render(args: &RenderArgs) -> Result<(), String> {
|
||||
let file = SceneFile::read(path)?;
|
||||
let dir = path.parent().unwrap_or(Path::new("."));
|
||||
let mut camera = file.camera(dir)?;
|
||||
if let (LightingTier::L0, Some(exposure)) = (args.lighting, file.camera.exposure_l0) {
|
||||
camera.exposure = exposure;
|
||||
}
|
||||
if args.noiseless {
|
||||
camera.detector = camera.detector.noiseless();
|
||||
}
|
||||
|
||||
@@ -63,6 +63,10 @@ pub struct CameraFile {
|
||||
pub principal_point: Option<[f64; 2]>,
|
||||
pub f_number: Option<f64>,
|
||||
pub exposure: Option<f64>,
|
||||
/// The exposure at lighting L0 in place of `exposure`, for a scene whose bodies L0
|
||||
/// draws far brighter than L1 does (a lunar eclipse, which L0 draws without Earth's
|
||||
/// shadow).
|
||||
pub exposure_l0: Option<f64>,
|
||||
pub transmission: Option<f64>,
|
||||
pub gain: Option<f64>,
|
||||
pub bias: Option<f64>,
|
||||
@@ -540,7 +544,7 @@ mod tests {
|
||||
#[test]
|
||||
fn camera_overrides_apply() {
|
||||
let file: SceneFile = toml::from_str(&format!(
|
||||
"{MINIMAL}\n[camera]\nwidth = 640\nexposure = 2e-3\nadc_bits = 10\nread_noise = 2.5\n\
|
||||
"{MINIMAL}\n[camera]\nwidth = 640\nexposure = 2e-3\nexposure_l0 = 5e-4\nadc_bits = 10\nread_noise = 2.5\n\
|
||||
dark_current = 0\nshot_noise = false\nprnu = 0.02\nhot_pixels = [1e-3, 20]\n\
|
||||
pattern_seed = 9\nblooming = {{ column = 0.25 }}\n"
|
||||
))
|
||||
@@ -548,6 +552,7 @@ mod tests {
|
||||
let camera = file.camera(Path::new(".")).unwrap();
|
||||
assert_eq!((camera.width, camera.height), (640, 1080));
|
||||
assert_eq!(camera.exposure, 2e-3);
|
||||
assert_eq!(file.camera.exposure_l0, Some(5e-4));
|
||||
assert_eq!(camera.detector.adc_bits, 10);
|
||||
let d = camera.detector;
|
||||
assert_eq!(
|
||||
|
||||
@@ -185,21 +185,24 @@ fn pretty_previews_lift_the_darks_and_leave_space_black() {
|
||||
|
||||
#[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.
|
||||
fn lunar_eclipse_darkens_and_reddens_the_moon() {
|
||||
// Mid-totality: at L1 the whole Moon is in Earth's umbra, lit only through Earth's
|
||||
// atmosphere, red, and brighter at its northern edge, farther from the shadow's
|
||||
// axis, than at its southern. At L0 there are no eclipses on the bodies, and the
|
||||
// full Moon at the scene's L0 exposure, 300 times shorter, is bright but short of
|
||||
// saturation.
|
||||
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"
|
||||
);
|
||||
let full = l0.get_pixel(960, 540).0;
|
||||
assert!(full.iter().all(|&v| v > 1000 && v < 4000), "{full:?}");
|
||||
let [north, center, south] = [260, 540, 820].map(|y| l1.get_pixel(960, y).0);
|
||||
// Measured: 695, 287, 145 DN at the north, 592, 164, 77 at the center, 208, 82, 65
|
||||
// at the south, over a bias of 64.
|
||||
for p in [north, center, south] {
|
||||
assert!(p[0] > p[1] && p[1] >= p[2] && p[2] >= 64, "{p:?}");
|
||||
}
|
||||
assert!(north[0] > center[0] && center[0] > south[0] && south[0] > 100);
|
||||
}
|
||||
|
||||
#[cfg(feature = "astrokit")]
|
||||
|
||||
@@ -29,6 +29,11 @@ use std::sync::{Arc, Mutex};
|
||||
use crate::bodies::EARTH_EQUATORIAL_RADIUS;
|
||||
use crate::radiometry::{Radiometry, AU, SUN_RADIUS};
|
||||
|
||||
mod grazing;
|
||||
pub use grazing::{
|
||||
EarthShadow, Grazing, GRAZING_BOTTOM, GRAZING_SIZE, MAX_BENDING, REFRACTIVITY, SHADOW_SIZE,
|
||||
};
|
||||
|
||||
/// The ground's radius, in meters.
|
||||
pub const BOTTOM_RADIUS: f64 = EARTH_EQUATORIAL_RADIUS;
|
||||
/// Height of the top of the atmosphere above the ground, in meters. Above it the air is
|
||||
@@ -123,6 +128,9 @@ pub struct AtmosphereTables {
|
||||
/// Irradiance from the sky on a horizontal surface, as a fraction of the Sun's,
|
||||
/// [`IRRADIANCE_SIZE`].
|
||||
pub irradiance: Vec<[f32; 4]>,
|
||||
/// Rays that pass through the atmosphere from space to space: their bending and
|
||||
/// transmittance.
|
||||
pub grazing: Grazing,
|
||||
}
|
||||
|
||||
impl AtmosphereTables {
|
||||
@@ -161,6 +169,7 @@ impl AtmosphereTables {
|
||||
rayleigh,
|
||||
mie,
|
||||
irradiance: Vec::new(),
|
||||
grazing: Grazing::new(spectrum),
|
||||
};
|
||||
tables.irradiance = sky_irradiance(&tables);
|
||||
tables
|
||||
@@ -196,9 +205,15 @@ impl AtmosphereTables {
|
||||
/// as the shaders see it: in Earth-fixed axes stretched along the polar axis, where
|
||||
/// Earth's ellipsoid is a sphere of the equatorial radius.
|
||||
pub fn altitude(scene: &crate::Scene, p: nalgebra::Vector3<f64>) -> f64 {
|
||||
stretched(scene, p).norm() - BOTTOM_RADIUS
|
||||
}
|
||||
|
||||
/// Inertial point `p` in Earth-fixed axes stretched along the polar axis, the frame the
|
||||
/// atmosphere is spherical in, in meters.
|
||||
pub fn stretched(scene: &crate::Scene, p: nalgebra::Vector3<f64>) -> nalgebra::Vector3<f64> {
|
||||
let fixed = scene.earth_attitude.inverse() * p;
|
||||
let stretch = BOTTOM_RADIUS / crate::bodies::EARTH_POLAR_RADIUS;
|
||||
nalgebra::Vector3::new(fixed.x, fixed.y, fixed.z * stretch).norm() - BOTTOM_RADIUS
|
||||
nalgebra::Vector3::new(fixed.x, fixed.y, fixed.z * stretch)
|
||||
}
|
||||
|
||||
/// The constants `shaders/atmosphere.wgsl` expects: lengths in kilometers, and the
|
||||
@@ -215,12 +230,16 @@ pub(crate) fn wgsl_constants() -> String {
|
||||
const ATMOSPHERE_SUN_ANGULAR_RADIUS: f32 = {:?};\n\
|
||||
const ATMOSPHERE_TRANSMITTANCE_SIZE = vec2<f32>({tw}.0, {th}.0);\n\
|
||||
const ATMOSPHERE_SCATTERING_SIZE = vec4<f32>({nu}.0, {mu_s}.0, {mu}.0, {r}.0);\n\
|
||||
const ATMOSPHERE_IRRADIANCE_SIZE = vec2<f32>({iw}.0, {ih}.0);\n",
|
||||
const ATMOSPHERE_IRRADIANCE_SIZE = vec2<f32>({iw}.0, {ih}.0);\n\
|
||||
const ATMOSPHERE_GRAZING_BOTTOM: f32 = {:?};\n\
|
||||
const ATMOSPHERE_GRAZING_SIZE: u32 = {GRAZING_SIZE}u;\n\
|
||||
const ATMOSPHERE_SHADOW_SIZE: u32 = {SHADOW_SIZE}u;\n",
|
||||
BOTTOM_RADIUS / 1000.0,
|
||||
TOP_RADIUS / 1000.0,
|
||||
MU_S_MIN,
|
||||
MIE_G,
|
||||
SUN_ANGULAR_RADIUS,
|
||||
GRAZING_BOTTOM / 1000.0,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -307,6 +326,10 @@ impl Spectral {
|
||||
/// Per channel, the sum over wavelengths of `value(wavelength index)` with the
|
||||
/// channels' weights.
|
||||
fn fold(&self, value: impl Fn(usize) -> f64) -> [f32; 4] {
|
||||
self.fold_f64(value).map(|x| x as f32)
|
||||
}
|
||||
|
||||
fn fold_f64(&self, value: impl Fn(usize) -> f64) -> [f64; 4] {
|
||||
let mut out = [0.0f64; 4];
|
||||
for (l, w) in self.weights.iter().enumerate() {
|
||||
let v = value(l);
|
||||
@@ -314,7 +337,7 @@ impl Spectral {
|
||||
out[k] += w[k] * v;
|
||||
}
|
||||
}
|
||||
out.map(|x| x as f32)
|
||||
out
|
||||
}
|
||||
|
||||
/// Optical depth at wavelength index `l` of the column densities `c` (Rayleigh, Mie,
|
||||
|
||||
@@ -0,0 +1,774 @@
|
||||
//! Rays that pass through Earth's atmosphere from space to space: the sunlight that
|
||||
//! reaches a point in space through the limb ([`Grazing::sunlight`]), and how the Sun and
|
||||
//! the Moon look through it (`shaders/bodies.wgsl`, `through_air`).
|
||||
//!
|
||||
//! Air bends light toward the denser air below it, so a ray that passes the limb comes
|
||||
//! out turned toward Earth, by more the lower it passes: 1.25 degrees for a ray that
|
||||
//! skims the ground, against the Sun's diameter of 0.53. Seen through the limb, the Sun
|
||||
//! is raised, and flattened, since its lower edge is raised more than its upper. Rays
|
||||
//! that pass at different heights come out spread over a wider angle than they went in,
|
||||
//! which dims what lies beyond (refractive dilution), and the air along a ray scatters
|
||||
//! and absorbs its light, blue most.
|
||||
//!
|
||||
//! The atmosphere is spherical and its refractivity is in proportion to the air's
|
||||
//! density, so a ray's path depends only on its impact parameter b, the distance of its
|
||||
//! straight line outside the atmosphere from Earth's center. The tables are functions of
|
||||
//! b: the ray's total bending, and its transmittance along the whole curved path. Air's
|
||||
//! refractivity changes by about 2 % across the visible spectrum; it is taken at 550 nm
|
||||
//! for every wavelength, so the Sun's blue and red images at the limb do not separate
|
||||
//! (the green flash).
|
||||
|
||||
use std::f64::consts::PI;
|
||||
|
||||
use nalgebra::Vector3;
|
||||
|
||||
use super::{densities, parallel, Spectral, BOTTOM_RADIUS, RAYLEIGH_SCALE_HEIGHT, TOP_RADIUS};
|
||||
use crate::radiometry::SUN_RADIUS;
|
||||
use crate::sun::{legendre_rule, ring_rule, LimbDarkening};
|
||||
|
||||
/// Air's refractivity, n - 1, at the ground, for 550 nm: dry air at 15 C and
|
||||
/// 101,325 Pa (Edlen 1966). It falls with the air's density, at Rayleigh's scale height.
|
||||
pub const REFRACTIVITY: f64 = 2.778e-4;
|
||||
|
||||
/// The impact parameter of the lowest ray that clears the ground, in meters: its lowest
|
||||
/// point is at the ground, where n r = b.
|
||||
pub const GRAZING_BOTTOM: f64 = (1.0 + REFRACTIVITY) * BOTTOM_RADIUS;
|
||||
|
||||
/// More than any ray's bending, in radians: the lowest ray's is 0.0218.
|
||||
pub const MAX_BENDING: f64 = 0.025;
|
||||
|
||||
/// Entries in the tables, evenly spaced in impact parameter from [`GRAZING_BOTTOM`] to
|
||||
/// the top of the atmosphere: 96 m apart.
|
||||
pub const GRAZING_SIZE: usize = 1024;
|
||||
|
||||
/// Steps along each half of a ray, evenly spaced in the square root of the height above
|
||||
/// its lowest point.
|
||||
const STEPS: usize = 2000;
|
||||
|
||||
/// Rows across the Sun's disk in [`Grazing::sunlight`]. Measured against 512 rows: 2e-5
|
||||
/// at most through a sunset seen from 700 km.
|
||||
const ROWS: usize = 64;
|
||||
|
||||
/// The bending and transmittance of rays through the atmosphere, by impact parameter
|
||||
/// ([`GRAZING_SIZE`] entries from [`GRAZING_BOTTOM`] to the top of the atmosphere).
|
||||
#[derive(Debug)]
|
||||
pub struct Grazing {
|
||||
/// Per entry, the ray's total bending toward Earth, in radians.
|
||||
pub bending: Vec<f64>,
|
||||
/// Per entry, per channel: the transmittance along the whole ray, weighted by
|
||||
/// sunlight over each channel's band, as the transmittance table is.
|
||||
pub transmittance: Vec<[f64; 4]>,
|
||||
}
|
||||
|
||||
/// The impact parameter of entry `i`.
|
||||
fn impact(i: usize) -> f64 {
|
||||
GRAZING_BOTTOM + (TOP_RADIUS - GRAZING_BOTTOM) * i as f64 / (GRAZING_SIZE - 1) as f64
|
||||
}
|
||||
|
||||
/// The path of the ray of impact parameter `b` through an atmosphere whose refractivity
|
||||
/// at the ground is `refractivity`: its total bending, and the column densities along
|
||||
/// it (meters of air, aerosols and ozone at ground or peak density, as `Columns`).
|
||||
///
|
||||
/// Along a ray in a spherical atmosphere n r sin(z) = b, where z is the angle from the
|
||||
/// zenith, so dr / ds = sqrt(n^2 r^2 - b^2) / (n r). The bending is
|
||||
/// -2 b times the integral of (dn/dr) / (n sqrt(n^2 r^2 - b^2)) from the lowest point,
|
||||
/// where n r = b, to the top. Both integrands go as 1 / sqrt(r - r0) at the lowest point
|
||||
/// r0; with r = r0 + x^2 they are smooth in x.
|
||||
pub(crate) fn path(b: f64, refractivity: f64) -> (f64, [f64; 3]) {
|
||||
let n = |r: f64| 1.0 + refractivity * densities(r - BOTTOM_RADIUS)[0];
|
||||
let dn = |r: f64| -refractivity * densities(r - BOTTOM_RADIUS)[0] / RAYLEIGH_SCALE_HEIGHT;
|
||||
// n r grows with r: its derivative, n + r dn/dr, is 0.78 at the ground and more
|
||||
// above, so the lowest point is unique (no ducting).
|
||||
let mut r0 = b;
|
||||
for _ in 0..50 {
|
||||
let step = (n(r0) * r0 - b) / (n(r0) + r0 * dn(r0));
|
||||
r0 -= step;
|
||||
if step.abs() < 1e-9 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let dx = (TOP_RADIUS - r0).max(0.0).sqrt() / STEPS as f64;
|
||||
let mut bending = 0.0;
|
||||
let mut column = [0.0; 3];
|
||||
for step in 0..=STEPS {
|
||||
let x = step as f64 * dx;
|
||||
let r = r0 + x * x;
|
||||
let nr = n(r) * r;
|
||||
// dr / sqrt(n^2 r^2 - b^2) per dx, and its limit at the lowest point, where
|
||||
// n^2 r^2 - b^2 = 2 b (n + r dn/dr) x^2 to first order.
|
||||
let slope = if step == 0 {
|
||||
2.0 / (2.0 * b * (n(r0) + r0 * dn(r0))).sqrt()
|
||||
} else {
|
||||
2.0 * x / ((nr - b) * (nr + b)).sqrt()
|
||||
};
|
||||
// The trapezoid rule, over both halves of the ray.
|
||||
let weight = if step == 0 || step == STEPS { 0.5 } else { 1.0 } * dx * 2.0;
|
||||
bending -= b * dn(r) / n(r) * slope * weight;
|
||||
let rho = densities(r - BOTTOM_RADIUS);
|
||||
for s in 0..3 {
|
||||
column[s] += rho[s] * nr * slope * weight;
|
||||
}
|
||||
}
|
||||
(bending, column)
|
||||
}
|
||||
|
||||
impl Grazing {
|
||||
pub(super) fn new(spectrum: &Spectral) -> Self {
|
||||
let paths = parallel(GRAZING_SIZE, |i| path(impact(i), REFRACTIVITY));
|
||||
Self {
|
||||
bending: paths.iter().map(|(bending, _)| *bending).collect(),
|
||||
transmittance: paths
|
||||
.iter()
|
||||
.map(|(_, c)| spectrum.fold_f64(|l| spectrum.transmittance(l, c)))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The ray of impact parameter `b`, in meters: its bending and its transmittance per
|
||||
/// channel, linear between entries; `None` if it meets the ground. A ray above the
|
||||
/// atmosphere passes straight and whole.
|
||||
pub fn ray(&self, b: f64) -> Option<(f64, [f64; 4])> {
|
||||
if b < GRAZING_BOTTOM {
|
||||
return None;
|
||||
}
|
||||
if b >= TOP_RADIUS {
|
||||
return Some((0.0, [1.0; 4]));
|
||||
}
|
||||
let x = (b - GRAZING_BOTTOM) / (TOP_RADIUS - GRAZING_BOTTOM) * (GRAZING_SIZE - 1) as f64;
|
||||
let i = (x as usize).min(GRAZING_SIZE - 2);
|
||||
let f = x - i as f64;
|
||||
let (t0, t1) = (self.transmittance[i], self.transmittance[i + 1]);
|
||||
Some((
|
||||
self.bending[i] + f * (self.bending[i + 1] - self.bending[i]),
|
||||
std::array::from_fn(|k| t0[k] + f * (t1[k] - t0[k])),
|
||||
))
|
||||
}
|
||||
|
||||
/// Per channel, the fraction of the Sun's light that reaches `point` past Earth and
|
||||
/// through its atmosphere, with the brightness spread over the Sun's disk as `limb`
|
||||
/// gives. `point` and `sun` are relative to Earth's center, in Earth-fixed axes
|
||||
/// stretched along the polar axis ([`super::stretched`]), in meters; `point` must be
|
||||
/// above the atmosphere.
|
||||
///
|
||||
/// A ray seen from `point` at angle `theta` from Earth's center passes it at impact
|
||||
/// parameter `|point| sin(theta)` and arrives from `theta` less its bending, keeping
|
||||
/// its azimuth around the line to Earth's center. Radiance is unchanged along a ray
|
||||
/// but for the transmittance, so the light is the integral of the Sun's radiance
|
||||
/// where each ray comes from, times its transmittance, over the solid angle the
|
||||
/// rays arrive in. That is taken in rows across the Sun's image at constant `theta`,
|
||||
/// each an arc of the circle around Earth's center.
|
||||
pub fn sunlight(
|
||||
&self,
|
||||
point: Vector3<f64>,
|
||||
sun: Vector3<f64>,
|
||||
limb: &LimbDarkening,
|
||||
) -> [f64; 4] {
|
||||
static RULE: std::sync::OnceLock<Vec<(f64, f64)>> = std::sync::OnceLock::new();
|
||||
self.sunlight_by(point, sun, limb, RULE.get_or_init(|| legendre_rule(ROWS)))
|
||||
}
|
||||
|
||||
/// [`Self::sunlight`] with `rule`'s rows across the Sun's image.
|
||||
fn sunlight_by(
|
||||
&self,
|
||||
point: Vector3<f64>,
|
||||
sun: Vector3<f64>,
|
||||
limb: &LimbDarkening,
|
||||
rule: &[(f64, f64)],
|
||||
) -> [f64; 4] {
|
||||
let distance = point.norm();
|
||||
let to_sun = sun - point;
|
||||
let a = (SUN_RADIUS / to_sun.norm()).min(1.0).asin();
|
||||
let down = -point / distance;
|
||||
// The angle from Earth's center to the Sun's.
|
||||
let c = to_sun.cross(&down).norm().atan2(to_sun.dot(&down));
|
||||
let top = (TOP_RADIUS / distance).asin();
|
||||
if c - a >= top {
|
||||
return [1.0; 4];
|
||||
}
|
||||
let ground = (GRAZING_BOTTOM / distance).asin();
|
||||
// Where a ray seen at `theta`, at or above `ground`, comes from: `theta` less its
|
||||
// bending. It grows with `theta`. (At `ground` itself, the impact parameter may
|
||||
// round to below the lowest ray's.)
|
||||
let source = |theta: f64| {
|
||||
theta
|
||||
- self
|
||||
.ray((distance * theta.sin()).max(GRAZING_BOTTOM))
|
||||
.map_or(0.0, |(bend, _)| bend)
|
||||
};
|
||||
// Far enough from Earth, rays that pass low are bent past its center, and come
|
||||
// from the other side of it: a ray seen at azimuth phi from `source` below zero
|
||||
// comes from -`source` at phi + pi. That is how sunlight reaches the axis of
|
||||
// Earth's shadow beyond 290,000 km, where the lowest rays cross it.
|
||||
let lowest = source(ground);
|
||||
// Where rays may come from, as `source`: the circles around Earth's center that
|
||||
// cross the Sun's disk, on this side and past the center.
|
||||
let near = (c - a).max(0.0);
|
||||
let sides = [
|
||||
(near.max(lowest), c + a),
|
||||
(lowest.max(-c - a), (-near).min(0.0)),
|
||||
];
|
||||
if sides.iter().all(|(lo, hi)| lo >= hi) {
|
||||
return [0.0; 4];
|
||||
}
|
||||
// The angle a ray must be seen at to come from `theta`, by bisection.
|
||||
let seen = |theta: f64| {
|
||||
if theta >= top {
|
||||
return theta;
|
||||
}
|
||||
let (mut lo, mut hi) = (ground, top);
|
||||
for _ in 0..60 {
|
||||
let mid = 0.5 * (lo + hi);
|
||||
if source(mid) < theta {
|
||||
lo = mid;
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
0.5 * (lo + hi)
|
||||
};
|
||||
let mut light = [0.0; 4];
|
||||
for (from, to) in sides {
|
||||
if from >= to {
|
||||
continue;
|
||||
}
|
||||
let lo = if from <= lowest { ground } else { seen(from) };
|
||||
let hi = seen(to);
|
||||
// A Gauss-Legendre rule in an angle with the rows at its cosine across the
|
||||
// image, which takes out the square root of the disk's width at its ends.
|
||||
let (mid, half) = (0.5 * (hi + lo), 0.5 * (hi - lo));
|
||||
for &(phi, weight) in rule {
|
||||
let theta = mid - half * phi.cos();
|
||||
let Some((bend, transmittance)) = self.ray(distance * theta.sin()) else {
|
||||
continue;
|
||||
};
|
||||
// A circle past the center is the circle as far on this side.
|
||||
let arc = arc(limb, (theta - bend).abs(), c, a);
|
||||
// The row's solid angle is sin(theta) d(theta) d(azimuth); the whole
|
||||
// disk's light is pi a^2 times its mean radiance.
|
||||
let span = theta.sin() * half * phi.sin() * weight / (PI * a * a);
|
||||
for k in 0..4 {
|
||||
light[k] += arc[k] * transmittance[k] * span;
|
||||
}
|
||||
}
|
||||
}
|
||||
light.map(|x| x.clamp(0.0, 1.0))
|
||||
}
|
||||
}
|
||||
|
||||
/// Entries in each row of an [`EarthShadow`].
|
||||
pub const SHADOW_SIZE: usize = 512;
|
||||
|
||||
/// The sunlight that reaches points around the Moon past Earth and through its
|
||||
/// atmosphere ([`Grazing::sunlight`]), as a table: the GPU lights the eclipsed Moon from
|
||||
/// it.
|
||||
///
|
||||
/// Earth's shadow is symmetric about the line from the Sun through Earth's center (in
|
||||
/// the stretched frame, where Earth is a sphere), so the light at a point depends only
|
||||
/// on its distance from Earth's center and the angle c between the Sun's center and
|
||||
/// Earth's seen from it. The table has two rows, at distances `near` and `far` either
|
||||
/// side of the Moon, of [`SHADOW_SIZE`] entries evenly spaced in c less the angular
|
||||
/// radius of the atmosphere's top seen from that distance, which lines the shadow's
|
||||
/// edges up in both rows; between the rows it is linear in distance. It holds the
|
||||
/// light's natural log, which follows the umbra's falloff, a factor of 14 over 400 km
|
||||
/// at the Moon, more closely than the light itself does. Measured against
|
||||
/// [`Grazing::sunlight`] over the Moon's distances: within 0.5 %.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct EarthShadow {
|
||||
/// The near row's distance from Earth's center, in meters.
|
||||
pub near: f64,
|
||||
/// The far row's distance from Earth's center, in meters.
|
||||
pub far: f64,
|
||||
/// The first entry's c less the atmosphere's angular radius, in radians.
|
||||
pub start: f64,
|
||||
/// The step from one entry to the next, in radians.
|
||||
pub step: f64,
|
||||
/// The Sun's angular radius the table was made for, in radians.
|
||||
pub sun_radius: f64,
|
||||
/// The near row's entries, then the far row's: per channel, the natural log of the
|
||||
/// fraction of the Sun's light.
|
||||
pub light: Vec<[f64; 4]>,
|
||||
}
|
||||
|
||||
impl EarthShadow {
|
||||
/// The table between `near` and `far` meters from Earth's center, where the Sun's
|
||||
/// angular radius is `sun_radius`, with the brightness spread over the Sun's disk as
|
||||
/// `limb` gives.
|
||||
pub fn new(
|
||||
grazing: &Grazing,
|
||||
limb: &LimbDarkening,
|
||||
near: f64,
|
||||
far: f64,
|
||||
sun_radius: f64,
|
||||
) -> Self {
|
||||
// The light's slope jumps where the Sun's far edge reaches the atmosphere's top,
|
||||
// at -sun_radius, and it is whole from where the near edge clears it, at
|
||||
// +sun_radius; entries fall on both, and reach back to the shadow's axis.
|
||||
let last = (SHADOW_SIZE - 1) as f64;
|
||||
let reach = (TOP_RADIUS / near).asin() + sun_radius;
|
||||
let step = 2.0 * sun_radius / (2.0 * sun_radius * last / reach).floor();
|
||||
let start = sun_radius - step * last;
|
||||
let to_sun = SUN_RADIUS / sun_radius.sin();
|
||||
let light = super::parallel(2 * SHADOW_SIZE, |i| {
|
||||
let distance = if i < SHADOW_SIZE { near } else { far };
|
||||
let c = start + step * (i % SHADOW_SIZE) as f64 + (TOP_RADIUS / distance).asin();
|
||||
// Below zero, c is past the axis: the light there is the light as far on
|
||||
// this side.
|
||||
let c = c.abs();
|
||||
let point = Vector3::new(-distance, 0.0, 0.0);
|
||||
let sun = point + to_sun * Vector3::new(c.cos(), c.sin(), 0.0);
|
||||
grazing
|
||||
.sunlight(point, sun, limb)
|
||||
.map(|x| x.max(1e-30).ln())
|
||||
});
|
||||
Self {
|
||||
near,
|
||||
far,
|
||||
start,
|
||||
step,
|
||||
sun_radius,
|
||||
light,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the table holds points from `near` to `far` meters from Earth's center,
|
||||
/// where the Sun's angular radius is `sun_radius`, to 1e-4 of it.
|
||||
pub fn covers(&self, near: f64, far: f64, sun_radius: f64) -> bool {
|
||||
self.near <= near && far <= self.far && (sun_radius / self.sun_radius - 1.0).abs() < 1e-4
|
||||
}
|
||||
|
||||
/// Per channel, the fraction of the Sun's light at `distance` meters from Earth's
|
||||
/// center, where the Sun's center is `c` radians from Earth's, from the table, as
|
||||
/// the GPU reads it (`bending.wgsl`, `earth_shadow`).
|
||||
pub fn light(&self, distance: f64, c: f64) -> [f64; 4] {
|
||||
let last = (SHADOW_SIZE - 1) as f64;
|
||||
let x = (c - (TOP_RADIUS / distance).asin() - self.start) / self.step;
|
||||
if x >= last {
|
||||
return [1.0; 4];
|
||||
}
|
||||
let x = x.max(0.0);
|
||||
let i = (x as usize).min(SHADOW_SIZE - 2);
|
||||
let f = x - i as f64;
|
||||
let w = ((distance - self.near) / (self.far - self.near)).clamp(0.0, 1.0);
|
||||
let row = |r: usize, k: usize| {
|
||||
let (a, b) = (self.light[r + i][k], self.light[r + i + 1][k]);
|
||||
a + f * (b - a)
|
||||
};
|
||||
std::array::from_fn(|k| {
|
||||
let (a, b) = (row(0, k), row(SHADOW_SIZE, k));
|
||||
(a + w * (b - a)).exp()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Per channel, the integral over azimuth around Earth's center of the Sun's radiance
|
||||
/// (over its mean) along the circle `theta` from Earth's center, where the Sun's
|
||||
/// center is `c` from it and its radius is `a`.
|
||||
///
|
||||
/// Angles on the sky from haversines, which keep their precision for a disk this small:
|
||||
/// a point at azimuth `phi` from the Sun's on the circle is `rho` from its center, with
|
||||
/// hav(rho) = hav(theta - c) + sin(theta) sin(c) hav(phi).
|
||||
fn arc(limb: &LimbDarkening, theta: f64, c: f64, a: f64) -> [f64; 4] {
|
||||
let hav = |x: f64| (0.5 * x).sin().powi(2);
|
||||
let across = (theta.sin() * c.sin()).max(1e-300);
|
||||
let edge = (hav(a) - hav(theta - c)) / across;
|
||||
if edge <= 0.0 {
|
||||
return [0.0; 4];
|
||||
}
|
||||
// The arc's half-width in azimuth (all the way around if the disk covers Earth's
|
||||
// center).
|
||||
let width = if edge >= 1.0 {
|
||||
PI
|
||||
} else {
|
||||
2.0 * edge.sqrt().asin()
|
||||
};
|
||||
let mut sum = [0.0; 4];
|
||||
// phi = width cos(psi), which takes out the square root of the profile at the
|
||||
// limb.
|
||||
for &(psi, weight) in ring_rule() {
|
||||
let phi = width * psi.cos();
|
||||
let rho = 2.0 * (hav(theta - c) + across * hav(phi)).sqrt().min(1.0).asin();
|
||||
let mu = (1.0 - (rho / a).powi(2)).max(0.0).sqrt();
|
||||
let p = limb.profile(mu);
|
||||
for k in 0..4 {
|
||||
sum[k] += p[k] * width * psi.sin() * weight;
|
||||
}
|
||||
}
|
||||
sum
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::camera::CameraConfig;
|
||||
use crate::radiometry::{Radiometry, AU};
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn radiometry() -> Radiometry {
|
||||
Radiometry::new(&CameraConfig::example())
|
||||
}
|
||||
|
||||
fn tables() -> Grazing {
|
||||
Grazing::new(&Spectral::new(&radiometry()))
|
||||
}
|
||||
|
||||
/// Column densities along the straight line `b` from Earth's center, by a fine
|
||||
/// direct integral.
|
||||
fn straight(b: f64) -> [f64; 3] {
|
||||
let steps = 200_000;
|
||||
let half = (TOP_RADIUS * TOP_RADIUS - b * b).sqrt();
|
||||
let dx = half / steps as f64;
|
||||
let mut sum = [0.0; 3];
|
||||
for i in 0..steps {
|
||||
let x = (i as f64 + 0.5) * dx;
|
||||
let rho = densities((x * x + b * b).sqrt() - BOTTOM_RADIUS);
|
||||
for s in 0..3 {
|
||||
sum[s] += 2.0 * rho[s] * dx;
|
||||
}
|
||||
}
|
||||
sum
|
||||
}
|
||||
|
||||
/// A ray of impact parameter `b` traced through the atmosphere by its equation of
|
||||
/// motion, d(n t)/ds = grad n with t its unit direction, in steps of `ds` meters:
|
||||
/// its bending and the column of air along it.
|
||||
fn traced(b: f64, ds: f64) -> (f64, f64) {
|
||||
let n = |p: [f64; 2]| 1.0 + REFRACTIVITY * densities((p[0].hypot(p[1])) - BOTTOM_RADIUS)[0];
|
||||
// d/ds of (position, n t).
|
||||
let derivative = |s: [f64; 4]| {
|
||||
let p = [s[0], s[1]];
|
||||
let r = p[0].hypot(p[1]);
|
||||
let index = n(p);
|
||||
let grad = -REFRACTIVITY * densities(r - BOTTOM_RADIUS)[0] / RAYLEIGH_SCALE_HEIGHT;
|
||||
[s[2] / index, s[3] / index, grad * p[0] / r, grad * p[1] / r]
|
||||
};
|
||||
let x0 = -(TOP_RADIUS * TOP_RADIUS - b * b).sqrt();
|
||||
let mut s = [x0, b, n([x0, b]), 0.0];
|
||||
let mut air = 0.0;
|
||||
loop {
|
||||
let k1 = derivative(s);
|
||||
let at = |k: [f64; 4], h: f64| std::array::from_fn(|i| s[i] + h * k[i]);
|
||||
let k2 = derivative(at(k1, ds / 2.0));
|
||||
let k3 = derivative(at(k2, ds / 2.0));
|
||||
let k4 = derivative(at(k3, ds));
|
||||
let r_before = s[0].hypot(s[1]);
|
||||
s = std::array::from_fn(|i| {
|
||||
s[i] + ds / 6.0 * (k1[i] + 2.0 * k2[i] + 2.0 * k3[i] + k4[i])
|
||||
});
|
||||
let r = s[0].hypot(s[1]);
|
||||
air += ds
|
||||
* 0.5
|
||||
* (densities(r_before - BOTTOM_RADIUS)[0] + densities(r - BOTTOM_RADIUS)[0]);
|
||||
if r > TOP_RADIUS && s[0] > 0.0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
((-s[3]).atan2(s[2]), air)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn straight_rays_match_a_direct_integral() {
|
||||
// Without refraction the path is the straight line.
|
||||
for h in [2e3, 10e3, 30e3, 60e3] {
|
||||
let b = BOTTOM_RADIUS + h;
|
||||
let (bending, column) = path(b, 0.0);
|
||||
assert_eq!(bending, 0.0);
|
||||
let want = straight(b);
|
||||
for s in 0..3 {
|
||||
assert_relative_eq!(column[s], want[s], max_relative = 1e-5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bending_matches_a_traced_ray() {
|
||||
// Measured: within 2e-5 in the bending and 3e-5 in the air.
|
||||
for h in [3e3, 15e3, 40e3] {
|
||||
let b = GRAZING_BOTTOM + h;
|
||||
let (bending, column) = path(b, REFRACTIVITY);
|
||||
let (traced_bending, traced_air) = traced(b, 50.0);
|
||||
assert_relative_eq!(bending, traced_bending, max_relative = 1e-4);
|
||||
assert_relative_eq!(column[0], traced_air, max_relative = 1e-4);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_ray_skimming_the_ground_bends_more_than_two_suns() {
|
||||
// Astronomical refraction at the horizon is about 35 arcminutes for an observer
|
||||
// at the ground: half of the bending of a ray from space to space that skims
|
||||
// it, so about 70. The model's 8 km scale height gives 74.8: the real air's
|
||||
// density falls more slowly near the ground, over about 10 km, and bends less.
|
||||
let bending = tables().bending[0];
|
||||
let arcmin = bending.to_degrees() * 60.0;
|
||||
assert!((70.0..80.0).contains(&arcmin), "{arcmin}");
|
||||
assert!(bending < MAX_BENDING);
|
||||
}
|
||||
|
||||
/// A point `altitude` above the ground in the plane of the Sun (along +X), `past`
|
||||
/// radians on in its orbit from where the straight line toward the Sun's center
|
||||
/// grazes the ground (Earth's geometric shadow, taken as a cylinder).
|
||||
fn orbit(altitude: f64, past: f64) -> Vector3<f64> {
|
||||
let r = BOTTOM_RADIUS + altitude;
|
||||
let alpha = PI - (BOTTOM_RADIUS / r).asin() + past;
|
||||
r * Vector3::new(alpha.cos(), alpha.sin(), 0.0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_above_the_air_and_dark_in_the_umbra() {
|
||||
let grazing = tables();
|
||||
let limb = LimbDarkening::new(&radiometry());
|
||||
let sun = Vector3::new(AU, 0.0, 0.0);
|
||||
let r = BOTTOM_RADIUS + 700e3;
|
||||
// Over the day side, and deep in the night.
|
||||
assert_eq!(
|
||||
grazing.sunlight(Vector3::new(0.0, r, 0.0), sun, &limb),
|
||||
[1.0; 4]
|
||||
);
|
||||
assert_eq!(
|
||||
grazing.sunlight(Vector3::new(-r, 0.0, 0.0), sun, &limb),
|
||||
[0.0; 4]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn airless_limb_matches_the_eclipse_model() {
|
||||
// No bending and no air: Earth's hard edge, at the lowest ray's height. The
|
||||
// eclipse model draws that edge as a circle on a flat sky, and measured 1.7e-4
|
||||
// brighter than these rows; a 4000 x 4000 sampling of the disk agreed with the
|
||||
// rows to 3.4e-5.
|
||||
let airless = Grazing {
|
||||
bending: vec![0.0; GRAZING_SIZE],
|
||||
transmittance: vec![[1.0; 4]; GRAZING_SIZE],
|
||||
};
|
||||
let limb = LimbDarkening::new(&radiometry());
|
||||
let sun = Vector3::new(AU, 0.0, 0.0);
|
||||
for past in [-2e-3, -1e-3, 0.0, 1e-3, 2e-3] {
|
||||
let p = orbit(700e3, past);
|
||||
let got = airless.sunlight(p, sun, &limb);
|
||||
let want = crate::eclipse::sun_visible_light(
|
||||
p,
|
||||
sun,
|
||||
&[(Vector3::zeros(), GRAZING_BOTTOM)],
|
||||
&limb,
|
||||
);
|
||||
for k in 0..3 {
|
||||
assert_relative_eq!(got[k], want[k], epsilon = 3e-4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn more_rows_change_nothing() {
|
||||
let grazing = tables();
|
||||
let limb = LimbDarkening::new(&radiometry());
|
||||
let sun = Vector3::new(AU, 0.0, 0.0);
|
||||
let fine = legendre_rule(512);
|
||||
let mut worst = 0.0f64;
|
||||
for i in 0..80 {
|
||||
let p = orbit(700e3, -0.04 + 1e-3 * i as f64);
|
||||
let a = grazing.sunlight(p, sun, &limb);
|
||||
let b = grazing.sunlight_by(p, sun, &limb, &fine);
|
||||
for k in 0..3 {
|
||||
worst = worst.max((a[k] - b[k]).abs());
|
||||
}
|
||||
}
|
||||
// Measured: 2e-5, in the middle of the sunset.
|
||||
assert!(worst < 3e-5, "{worst}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_point_like_sun_is_dimmed_by_the_air_and_by_dilution() {
|
||||
// A Sun 10^4 times farther, 9e-7 rad across, seen through the limb at impact
|
||||
// parameter b: the transmittance there times the refractive dilution, the solid
|
||||
// angle the Sun covers over the solid angle its image covers.
|
||||
let grazing = tables();
|
||||
let limb = LimbDarkening::uniform();
|
||||
let p = Vector3::new(0.0, BOTTOM_RADIUS + 700e3, 0.0);
|
||||
let distance = p.norm();
|
||||
for h in [8e3, 15e3, 25e3] {
|
||||
let b = GRAZING_BOTTOM + h;
|
||||
let seen = (b / distance).asin();
|
||||
// Seen at `seen` below the horizontal (Earth's center is along -Y).
|
||||
let bend = |b: f64| path(b, REFRACTIVITY).0;
|
||||
let from = seen - bend(b);
|
||||
// d(from)/d(seen), from the bending's derivative in b, directly.
|
||||
let db = 1.0;
|
||||
let slope = 1.0 - (bend(b + db) - bend(b - db)) / (2.0 * db) * distance * seen.cos();
|
||||
let dilution = seen.sin() / from.sin() / slope;
|
||||
let direction = Vector3::new(from.sin(), -from.cos(), 0.0);
|
||||
let sun = p + 1e4 * AU * direction;
|
||||
let got = grazing.sunlight(p, sun, &limb);
|
||||
let (_, transmittance) = grazing.ray(b).unwrap();
|
||||
for k in 0..3 {
|
||||
let want = transmittance[k] * dilution;
|
||||
assert_relative_eq!(got[k], want, max_relative = 1e-2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Per channel, the sunlight at `point` by sampling the sky around Earth, each
|
||||
/// sample's ray followed back to where it came from: on an `n` x `n` grid in the
|
||||
/// angle from Earth's center and the azimuth around it, over the rays through the
|
||||
/// atmosphere and again over the straight rays above it, out to the Sun's far edge.
|
||||
fn sampled(
|
||||
grazing: &Grazing,
|
||||
point: Vector3<f64>,
|
||||
sun: Vector3<f64>,
|
||||
limb: &LimbDarkening,
|
||||
n: usize,
|
||||
) -> [f64; 4] {
|
||||
let distance = point.norm();
|
||||
let down = -point / distance;
|
||||
let e1 = down.cross(&Vector3::z()).normalize();
|
||||
let e2 = down.cross(&e1);
|
||||
let to_sun = (sun - point).normalize();
|
||||
let a = (SUN_RADIUS / (sun - point).norm()).asin();
|
||||
let c = to_sun.cross(&down).norm().atan2(to_sun.dot(&down));
|
||||
let (ground, top) = (
|
||||
(GRAZING_BOTTOM / distance).asin(),
|
||||
(TOP_RADIUS / distance).asin(),
|
||||
);
|
||||
let mut light = [0.0; 4];
|
||||
for (lo, hi) in [(ground, top), (top, top.max(c + a))] {
|
||||
let (dt, dp) = ((hi - lo) / n as f64, 2.0 * PI / n as f64);
|
||||
for i in 0..n {
|
||||
let theta = lo + (i as f64 + 0.5) * dt;
|
||||
let (bend, transmittance) = grazing.ray(distance * theta.sin()).unwrap();
|
||||
let from = theta - bend;
|
||||
for j in 0..n {
|
||||
let phi = (j as f64 + 0.5) * dp;
|
||||
let d = from.cos() * down + from.sin() * (phi.cos() * e1 + phi.sin() * e2);
|
||||
let rho = d.cross(&to_sun).norm().atan2(d.dot(&to_sun));
|
||||
if rho < a {
|
||||
let p = limb.profile((1.0 - (rho / a).powi(2)).sqrt());
|
||||
for k in 0..4 {
|
||||
light[k] +=
|
||||
p[k] * transmittance[k] * theta.sin() * dt * dp / (PI * a * a);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
light
|
||||
}
|
||||
|
||||
/// A point `distance` from Earth's center, `off` meters from the axis of its shadow
|
||||
/// (with the Sun along +X).
|
||||
fn behind(distance: f64, off: f64) -> Vector3<f64> {
|
||||
Vector3::new(-(distance * distance - off * off).sqrt(), off, 0.0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_shadow_at_the_moon_matches_sampling_the_sky() {
|
||||
// At the Moon's distance, across the umbra, where the lowest rays are bent past
|
||||
// Earth's center, and the penumbra. Measured: within 5e-4 of 1000 x 1000
|
||||
// samples; 3000 x 3000 agree to 2e-4.
|
||||
let grazing = tables();
|
||||
let limb = LimbDarkening::new(&radiometry());
|
||||
let sun = Vector3::new(AU, 0.0, 0.0);
|
||||
for off in [1000e3, 3000e3, 4600e3, 5000e3, 6000e3] {
|
||||
let p = behind(3.844e8, off);
|
||||
let got = grazing.sunlight(p, sun, &limb);
|
||||
let want = sampled(&grazing, p, sun, &limb, 1000);
|
||||
for k in 0..3 {
|
||||
assert_relative_eq!(got[k], want[k], max_relative = 1e-3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_eclipsed_moon_is_dim_and_red() {
|
||||
// Across the umbra at the Moon's distance. Observed in June 2011 (Ugolnikov et
|
||||
// al., arXiv:1106.6178, with a model of clear air like this one), from the
|
||||
// center out to 0.6 degrees (4000 km): 1e-4 to 1e-3 of the full Moon at 677 nm,
|
||||
// falling to 1e-6 at 503 nm. Measured here, red: 5.9e-5 at the center, 3.1e-4
|
||||
// at 3000 km, 6.5e-4 at 4000 km; green 10 to 2 times less, blue 170 to 3 times
|
||||
// less. The center is darker than the edge: the Moon is too far beyond the
|
||||
// lowest rays' focus, 290,000 km behind Earth, to see them brighten its axis.
|
||||
let grazing = tables();
|
||||
let limb = LimbDarkening::new(&radiometry());
|
||||
let sun = Vector3::new(AU, 0.0, 0.0);
|
||||
let mut last = 0.0;
|
||||
for off in [0.0, 1000e3, 2000e3, 3000e3, 4000e3] {
|
||||
let light = grazing.sunlight(behind(3.844e8, off), sun, &limb);
|
||||
assert!((3e-5..1e-3).contains(&light[0]), "{off}: {light:?}");
|
||||
assert!(
|
||||
light[0] > 2.0 * light[1] && light[1] > light[2],
|
||||
"{light:?}"
|
||||
);
|
||||
assert!(light[0] > last, "{off}: {light:?}");
|
||||
last = light[0];
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_shadow_table_follows_the_sunlight() {
|
||||
// Over the Moon's depth and across the umbra and the penumbra at 380,000 km.
|
||||
// Measured: within 4.9e-3, worst where the Sun's far edge nears the atmosphere's
|
||||
// top and the light rises steeply; 256 entries a row were within 1.7e-2.
|
||||
let grazing = tables();
|
||||
let limb = LimbDarkening::new(&radiometry());
|
||||
let (center, moon) = (3.8e8, 1.7374e6);
|
||||
let sun_radius = (SUN_RADIUS / AU).asin();
|
||||
let table = EarthShadow::new(
|
||||
&grazing,
|
||||
&limb,
|
||||
center - 1.5 * moon,
|
||||
center + 1.5 * moon,
|
||||
sun_radius,
|
||||
);
|
||||
assert!(table.covers(center - moon, center + moon, sun_radius * (1.0 + 5e-5)));
|
||||
assert!(!table.covers(center - 2.0 * moon, center + moon, sun_radius));
|
||||
let mut worst = 0.0f64;
|
||||
for i in 0..=20 {
|
||||
let distance = center - moon + 0.1 * moon * i as f64;
|
||||
let top = (TOP_RADIUS / distance).asin();
|
||||
for j in 0..=400 {
|
||||
let c = (top + 1.1 * sun_radius) * j as f64 / 400.0;
|
||||
let point = Vector3::new(-distance, 0.0, 0.0);
|
||||
let sun = point + AU * Vector3::new(c.cos(), c.sin(), 0.0);
|
||||
let want = grazing.sunlight(point, sun, &limb);
|
||||
let got = table.light(distance, c);
|
||||
for k in 0..3 {
|
||||
worst = worst.max((got[k] / want[k] - 1.0).abs());
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(worst < 6e-3, "{worst}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sunset_dims_blue_first_and_red_last() {
|
||||
// Through a sunset seen from 700 km, 0.02 rad (19 s) of orbit to the geometric
|
||||
// shadow's edge. Each channel dims steadily. Channels 2, 1, 0 are blue, green,
|
||||
// red. Blue dims first, where air scatters it along the ray. Through the ozone
|
||||
// layer, 20 to 30 km up, ozone's Chappuis band absorbs green and red more than
|
||||
// air scatters blue, and blue leads for a while (the turquoise edge of Earth's
|
||||
// shadow in lunar eclipses). Below it red leads again. Refraction still brings
|
||||
// in a twentieth of the red light at the geometric edge.
|
||||
let grazing = tables();
|
||||
let limb = LimbDarkening::new(&radiometry());
|
||||
let sun = Vector3::new(AU, 0.0, 0.0);
|
||||
let mut last = [1.0; 4];
|
||||
let mut blue_led = false;
|
||||
for i in 0..=200 {
|
||||
let past = -0.02 + 1e-4 * i as f64;
|
||||
let light = grazing.sunlight(orbit(700e3, past), sun, &limb);
|
||||
for k in 0..3 {
|
||||
assert!(
|
||||
light[k] <= last[k] + 1e-12,
|
||||
"{past}: {light:?} after {last:?}"
|
||||
);
|
||||
}
|
||||
if light[0] > 0.9 {
|
||||
assert!(
|
||||
light[2] < light[1] && light[1] < light[0],
|
||||
"{past}: {light:?}"
|
||||
);
|
||||
}
|
||||
blue_led |= light[2] > light[0] + 0.03;
|
||||
last = light;
|
||||
}
|
||||
assert!(blue_led);
|
||||
assert!(last[0] > 0.04 && last[2] < 0.5 * last[0], "{last:?}");
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,11 @@
|
||||
|
||||
use nalgebra::Vector3;
|
||||
|
||||
use crate::atmosphere::{self, AtmosphereTables};
|
||||
use crate::bodies::MOON_RADIUS;
|
||||
use crate::radiometry::SUN_RADIUS;
|
||||
use crate::sun::LimbDarkening;
|
||||
use crate::Scene;
|
||||
|
||||
/// The fraction of the solar disk visible from `point`, between 0 and 1, with each
|
||||
/// occulter given as (center, radius). Positions are in meters in any common frame.
|
||||
@@ -49,6 +52,57 @@ pub fn sun_visible_light(
|
||||
covered.map(|x| (1.0 - x).clamp(0.0, 1.0))
|
||||
}
|
||||
|
||||
/// Per channel, the fraction of the Sun's light that reaches inertial `point` past Earth
|
||||
/// and through its atmosphere (bent and dimmed, [`atmosphere::Grazing::sunlight`]), and
|
||||
/// past the Moon, with the solar disk's brightness spread as `limb` gives.
|
||||
///
|
||||
/// A point inside the atmosphere instead takes the transmittance of the straight path
|
||||
/// from it toward the Sun's center, times the part of the disk past Earth's hard edge.
|
||||
pub fn sun_visible_through_air(
|
||||
point: Vector3<f64>,
|
||||
scene: &Scene,
|
||||
tables: &AtmosphereTables,
|
||||
limb: &LimbDarkening,
|
||||
) -> [f64; 4] {
|
||||
let moon = sun_visible_light(point, scene.sun, &[(scene.moon, MOON_RADIUS)], limb);
|
||||
// Where the atmosphere is a sphere.
|
||||
let p = atmosphere::stretched(scene, point);
|
||||
let sun = atmosphere::stretched(scene, scene.sun);
|
||||
let earth = if p.norm() >= atmosphere::TOP_RADIUS {
|
||||
tables.grazing.sunlight(p, sun, limb)
|
||||
} else {
|
||||
let edge = sun_visible_light(
|
||||
p,
|
||||
sun,
|
||||
&[(Vector3::zeros(), atmosphere::BOTTOM_RADIUS)],
|
||||
limb,
|
||||
);
|
||||
let r = p.norm().max(atmosphere::BOTTOM_RADIUS);
|
||||
let air = tables.transmittance(r, p.dot(&(sun - p).normalize()) / p.norm());
|
||||
std::array::from_fn(|k| edge[k] * air[k])
|
||||
};
|
||||
// The Moon and Earth do not overlap on the sky (as `sun_visible_fraction`).
|
||||
std::array::from_fn(|k| (earth[k] + moon[k] - 1.0).clamp(0.0, 1.0))
|
||||
}
|
||||
|
||||
/// When some of the Moon may be in Earth's shadow, lit only through Earth's atmosphere
|
||||
/// or not at all: the distance of the Moon's center from Earth's, and the Sun's
|
||||
/// angular radius there, in the atmosphere's stretched frame
|
||||
/// ([`atmosphere::stretched`]), where [`atmosphere::EarthShadow`] takes them.
|
||||
pub fn moon_near_shadow(scene: &Scene) -> Option<(f64, f64)> {
|
||||
let moon = atmosphere::stretched(scene, scene.moon);
|
||||
let to_sun = atmosphere::stretched(scene, scene.sun) - moon;
|
||||
let distance = moon.norm();
|
||||
let a = (SUN_RADIUS / to_sun.norm()).min(1.0).asin();
|
||||
let c = to_sun.cross(&-moon).norm().atan2(to_sun.dot(&-moon));
|
||||
// Seen from anywhere on the Moon, Earth's center is less than the Moon's angular
|
||||
// radius from Earth (stretched by up to 0.34 %) from where it is seen from the
|
||||
// Moon's center.
|
||||
let near = distance - MOON_RADIUS;
|
||||
let reach = (atmosphere::TOP_RADIUS / near).asin() + a + 1.01 * MOON_RADIUS / near;
|
||||
(c < reach).then_some((distance, a))
|
||||
}
|
||||
|
||||
/// Seen from `point`: the Sun's angular radius, the occulter's, and the angle between
|
||||
/// their centers.
|
||||
fn angles(
|
||||
@@ -240,4 +294,46 @@ mod tests {
|
||||
max_relative = 1e-12
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn through_air_adds_the_atmosphere_to_the_eclipse_model() {
|
||||
let r = crate::radiometry::Radiometry::new(&crate::camera::CameraConfig::example());
|
||||
let tables = AtmosphereTables::new(&r);
|
||||
let limb = LimbDarkening::new(&r);
|
||||
let sun = Vector3::new(AU, 0.0, 0.0);
|
||||
let scene = |moon| Scene {
|
||||
earth_attitude: nalgebra::UnitQuaternion::identity(),
|
||||
sun,
|
||||
moon,
|
||||
moon_attitude: nalgebra::UnitQuaternion::identity(),
|
||||
targets: vec![],
|
||||
velocities: Default::default(),
|
||||
};
|
||||
// Over the day side, with the Moon partly over the Sun: the eclipse model.
|
||||
let day = Vector3::new(0.0, RE + 700e3, 0.0);
|
||||
let moon = day + Vector3::new(3.844e8, MOON_RADIUS, 0.0);
|
||||
let got = sun_visible_through_air(day, &scene(moon), &tables, &limb);
|
||||
let want = sun_visible_light(day, sun, &[(moon, MOON_RADIUS)], &limb);
|
||||
assert!(want[1] > 0.3 && want[1] < 0.9, "{want:?}");
|
||||
for k in 0..3 {
|
||||
assert_relative_eq!(got[k], want[k], max_relative = 1e-12);
|
||||
}
|
||||
// At sunset, with the Moon far from the Sun: the light through the limb.
|
||||
let far = Vector3::new(0.0, 0.0, -3.844e8);
|
||||
let alpha = std::f64::consts::PI - (RE / (RE + 700e3)).asin() - 0.01;
|
||||
let sunset = (RE + 700e3) * Vector3::new(alpha.cos(), alpha.sin(), 0.0);
|
||||
let got = sun_visible_through_air(sunset, &scene(far), &tables, &limb);
|
||||
let want = tables.grazing.sunlight(sunset, sun, &limb);
|
||||
for k in 0..3 {
|
||||
assert_relative_eq!(got[k], want[k], max_relative = 1e-12);
|
||||
}
|
||||
assert!(got[0] > 0.1 && got[0] < 0.9, "{got:?}");
|
||||
// 50 km up with the Sun overhead: the transmittance straight up.
|
||||
let inside = Vector3::new(RE + 50e3, 0.0, 0.0);
|
||||
let got = sun_visible_through_air(inside, &scene(far), &tables, &limb);
|
||||
let want = tables.transmittance(RE + 50e3, 1.0);
|
||||
for k in 0..3 {
|
||||
assert_relative_eq!(got[k], want[k], max_relative = 1e-12);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,10 @@ use super::atmosphere::{self, AtmosphereTextures};
|
||||
use super::profile::PassTimestamps;
|
||||
use super::targets::{FrameTargets, DEPTH_FORMAT, RADIANCE_FORMAT};
|
||||
use super::GpuContext;
|
||||
use crate::atmosphere::AtmosphereTables;
|
||||
use crate::atmosphere::{
|
||||
AtmosphereTables, EarthShadow, BOTTOM_RADIUS, GRAZING_SIZE, MAX_BENDING, SHADOW_SIZE,
|
||||
TOP_RADIUS,
|
||||
};
|
||||
use crate::bodies::{Appearance, EARTH_EQUATORIAL_RADIUS, EARTH_POLAR_RADIUS, MOON_RADIUS};
|
||||
use crate::camera::Intrinsics;
|
||||
use crate::earth::{DetailFormat, DetailLayout, EarthDetail, EarthTexture};
|
||||
@@ -202,6 +205,19 @@ pub struct BodiesUniforms {
|
||||
}
|
||||
|
||||
impl BodiesUniforms {
|
||||
/// Whether this frame needs the variant of the pass with Earth's atmosphere in the
|
||||
/// paths of light (`bending.wgsl`): the Moon or the Sun may be seen through it, or
|
||||
/// the Moon lit through it.
|
||||
fn through_air(&self) -> bool {
|
||||
self.earth_shape[1..].iter().any(|&flag| flag > 0.5)
|
||||
}
|
||||
|
||||
/// Lights the Moon this frame from the table of Earth's shadow last set with
|
||||
/// [`BodiesPass::set_earth_shadow`].
|
||||
pub fn light_moon_from_shadow(&mut self) {
|
||||
self.earth_shape[3] = 1.0;
|
||||
}
|
||||
|
||||
/// The uniforms for `scene` seen from `camera`. Geometry is differenced in f64
|
||||
/// before the cast, as in [`Scene::relative_to`]. With `apparent` (camera C1 and
|
||||
/// up), the bodies are drawn where light time and aberration make them appear
|
||||
@@ -245,8 +261,14 @@ impl BodiesUniforms {
|
||||
earth_axes: columns(rel.earth.rotation.to_rotation_matrix()),
|
||||
earth_shape: [
|
||||
(EARTH_EQUATORIAL_RADIUS / EARTH_POLAR_RADIUS) as f32,
|
||||
0.0,
|
||||
0.0,
|
||||
f32::from(u8::from(
|
||||
radiometry.atmosphere
|
||||
&& behind_air(scene.moon - camera.position, MOON_RADIUS, camera),
|
||||
)),
|
||||
f32::from(u8::from(
|
||||
radiometry.atmosphere
|
||||
&& behind_air(scene.sun - camera.position, SUN_RADIUS, camera),
|
||||
)),
|
||||
0.0,
|
||||
],
|
||||
earth_sun: vec4(sun_from(Vector3::zeros()), 0.0),
|
||||
@@ -288,6 +310,20 @@ impl BodiesUniforms {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether some of a body of `radius` at `offset` from `camera` may be seen through
|
||||
/// Earth's atmosphere: whether its disk comes within the atmosphere's bending of the
|
||||
/// cone of lines of sight that pass through it, with a margin for aberration. Rays come
|
||||
/// from no nearer Earth's center than the ground less the most any ray bends, so a body
|
||||
/// wholly behind that, as the Sun is from a camera in Earth's shadow, cannot be.
|
||||
fn behind_air(offset: Vector3<f64>, radius: f64, camera: &Pose) -> bool {
|
||||
let to_earth = -camera.position;
|
||||
let air = (TOP_RADIUS / to_earth.norm()).min(1.0).asin();
|
||||
let ground = (BOTTOM_RADIUS / to_earth.norm()).min(1.0).asin();
|
||||
let body = (radius / offset.norm()).min(1.0).asin();
|
||||
let apart = offset.cross(&to_earth).norm().atan2(offset.dot(&to_earth));
|
||||
apart < air + body + MAX_BENDING + 1e-3 && apart + body + 1e-3 > ground - MAX_BENDING
|
||||
}
|
||||
|
||||
/// The unit vector toward the Sun from Earth's center in Earth-fixed axes stretched
|
||||
/// along the polar axis, where Earth is a sphere: the frame of `atmosphere.wgsl`.
|
||||
pub(crate) fn sun_stretched(scene: &Scene) -> Vector3<f64> {
|
||||
@@ -304,6 +340,12 @@ pub(crate) fn sun_stretched(scene: &Scene) -> Vector3<f64> {
|
||||
#[derive(Debug)]
|
||||
pub struct BodiesPass {
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
/// The pipeline with the Moon and the Sun seen through Earth's atmosphere
|
||||
/// (`bending.wgsl`), and its bind group, once the atmosphere is set: used in frames
|
||||
/// where either may be.
|
||||
bending: Option<(wgpu::RenderPipeline, wgpu::BindGroup)>,
|
||||
samples: u32,
|
||||
area: bool,
|
||||
uniforms: wgpu::Buffer,
|
||||
sun_disk: wgpu::Buffer,
|
||||
sampler: wgpu::Sampler,
|
||||
@@ -314,6 +356,12 @@ pub struct BodiesPass {
|
||||
atmosphere: AtmosphereTextures,
|
||||
detail: [wgpu::TextureView; DETAIL_GROUPS],
|
||||
detail_sampler: wgpu::Sampler,
|
||||
/// Rays through the atmosphere (`Grazing`), as `bodies.wgsl` declares them: the
|
||||
/// transmittance per entry, then the bending.
|
||||
grazing: wgpu::Buffer,
|
||||
/// Earth's shadow around the Moon ([`EarthShadow`]), for the atmosphere's variant:
|
||||
/// its range, then the light's log per entry, in f32.
|
||||
earth_shadow: wgpu::Buffer,
|
||||
bind_group: wgpu::BindGroup,
|
||||
}
|
||||
|
||||
@@ -340,63 +388,7 @@ impl BodiesPass {
|
||||
/// buffer.
|
||||
pub fn new(ctx: &GpuContext, samples: u32, area: bool) -> Self {
|
||||
let device = &ctx.device;
|
||||
let (radiance, depth) = if samples > 1 {
|
||||
(
|
||||
"texture_multisampled_2d<f32>",
|
||||
"texture_depth_multisampled_2d",
|
||||
)
|
||||
} else {
|
||||
("texture_2d<f32>", "texture_depth_2d")
|
||||
};
|
||||
let positions = sample_positions(samples)
|
||||
.iter()
|
||||
.map(|[x, y]| format!("vec2<f32>({x}, {y})"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let prelude = format!(
|
||||
"const SAMPLES: u32 = {samples}u;\nconst AREA: bool = {area};\n\
|
||||
alias MeshRadiance = {radiance};\nalias MeshDepth = {depth};\n\
|
||||
const SAMPLE_POSITIONS = array<vec2<f32>, {samples}>({positions});\n"
|
||||
);
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("bodies"),
|
||||
source: wgpu::ShaderSource::Wgsl(
|
||||
(prelude
|
||||
+ include_str!("../shaders/common.wgsl")
|
||||
+ &atmosphere::wgsl(5)
|
||||
+ include_str!("../shaders/bodies.wgsl"))
|
||||
.into(),
|
||||
),
|
||||
});
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("bodies"),
|
||||
layout: None,
|
||||
vertex: wgpu::VertexState {
|
||||
module: &module,
|
||||
entry_point: Some("vs_main"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: wgpu::PrimitiveState::default(),
|
||||
// Every pixel it does not discard is written: it has already sorted the
|
||||
// bodies against the meshes.
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
format: DEPTH_FORMAT,
|
||||
depth_write_enabled: Some(true),
|
||||
depth_compare: Some(wgpu::CompareFunction::Always),
|
||||
stencil: Default::default(),
|
||||
bias: Default::default(),
|
||||
}),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &module,
|
||||
entry_point: Some("fs_main"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(RADIANCE_FORMAT.into())],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let pipeline = compile(ctx, samples, area, false);
|
||||
let uniforms = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("bodies uniforms"),
|
||||
size: std::mem::size_of::<BodiesUniforms>() as u64,
|
||||
@@ -431,6 +423,19 @@ impl BodiesPass {
|
||||
..Default::default()
|
||||
});
|
||||
let detail = std::array::from_fn(|_| placeholder_detail(ctx));
|
||||
// Zeros until the atmosphere is set; the shader does not read them then.
|
||||
let grazing = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("grazing rays"),
|
||||
size: (GRAZING_SIZE * 5 * 4) as u64,
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let earth_shadow = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("earth shadow"),
|
||||
size: (16 * (1 + 2 * SHADOW_SIZE)) as u64,
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let bind_group = bind(
|
||||
ctx,
|
||||
&pipeline,
|
||||
@@ -441,9 +446,13 @@ impl BodiesPass {
|
||||
&moon_view,
|
||||
&atmosphere,
|
||||
(&detail, &detail_sampler),
|
||||
None,
|
||||
);
|
||||
Self {
|
||||
pipeline,
|
||||
bending: None,
|
||||
samples,
|
||||
area,
|
||||
uniforms,
|
||||
sun_disk,
|
||||
sampler,
|
||||
@@ -454,6 +463,8 @@ impl BodiesPass {
|
||||
atmosphere,
|
||||
detail,
|
||||
detail_sampler,
|
||||
grazing,
|
||||
earth_shadow,
|
||||
bind_group,
|
||||
}
|
||||
}
|
||||
@@ -576,18 +587,63 @@ impl BodiesPass {
|
||||
/// uniforms' atmosphere flag turns them on.
|
||||
pub fn set_atmosphere(&mut self, ctx: &GpuContext, tables: &AtmosphereTables) {
|
||||
self.atmosphere = AtmosphereTextures::new(ctx, tables);
|
||||
let grazing = &tables.grazing;
|
||||
let values: Vec<f32> = grazing
|
||||
.transmittance
|
||||
.iter()
|
||||
.flatten()
|
||||
.chain(&grazing.bending)
|
||||
.map(|&x| x as f32)
|
||||
.collect();
|
||||
ctx.queue
|
||||
.write_buffer(&self.grazing, 0, bytemuck::cast_slice(&values));
|
||||
let pipeline = compile(ctx, self.samples, self.area, true);
|
||||
let group = self.bind_to(ctx, &pipeline, true);
|
||||
self.bending = Some((pipeline, group));
|
||||
self.rebind(ctx);
|
||||
}
|
||||
|
||||
/// Uploads the table the Moon is lit from in frames that call
|
||||
/// [`BodiesUniforms::light_moon_from_shadow`].
|
||||
pub fn set_earth_shadow(&self, ctx: &GpuContext, shadow: &EarthShadow) {
|
||||
let range = [
|
||||
shadow.start,
|
||||
shadow.step,
|
||||
shadow.near / 1e3,
|
||||
shadow.far / 1e3,
|
||||
];
|
||||
let values: Vec<f32> = range
|
||||
.iter()
|
||||
.chain(shadow.light.iter().flatten())
|
||||
.map(|&x| x as f32)
|
||||
.collect();
|
||||
ctx.queue
|
||||
.write_buffer(&self.earth_shadow, 0, bytemuck::cast_slice(&values));
|
||||
}
|
||||
|
||||
/// The atmosphere's tables (placeholders until set), for the Earthshine pass.
|
||||
pub fn atmosphere(&self) -> &AtmosphereTextures {
|
||||
&self.atmosphere
|
||||
}
|
||||
|
||||
fn rebind(&mut self, ctx: &GpuContext) {
|
||||
self.bind_group = bind(
|
||||
self.bind_group = self.bind_to(ctx, &self.pipeline, false);
|
||||
if let Some((pipeline, _)) = &self.bending {
|
||||
let group = self.bind_to(ctx, pipeline, true);
|
||||
self.bending.as_mut().expect("set").1 = group;
|
||||
}
|
||||
}
|
||||
|
||||
/// The bind group for `pipeline`, which reads the grazing rays if `bending`.
|
||||
fn bind_to(
|
||||
&self,
|
||||
ctx: &GpuContext,
|
||||
pipeline: &wgpu::RenderPipeline,
|
||||
bending: bool,
|
||||
) -> wgpu::BindGroup {
|
||||
bind(
|
||||
ctx,
|
||||
&self.pipeline,
|
||||
pipeline,
|
||||
&self.uniforms,
|
||||
&self.sun_disk,
|
||||
&self.sampler,
|
||||
@@ -595,7 +651,8 @@ impl BodiesPass {
|
||||
&self.moon,
|
||||
&self.atmosphere,
|
||||
(&self.detail, &self.detail_sampler),
|
||||
);
|
||||
bending.then_some((&self.grazing, &self.earth_shadow)),
|
||||
)
|
||||
}
|
||||
|
||||
/// Draws the Sun with `limb`'s spread of brightness, and eclipses on Earth and the
|
||||
@@ -630,9 +687,13 @@ impl BodiesPass {
|
||||
) {
|
||||
ctx.queue
|
||||
.write_buffer(&self.uniforms, 0, bytemuck::bytes_of(uniforms));
|
||||
let (pipeline, group) = match &self.bending {
|
||||
Some((pipeline, group)) if uniforms.through_air() => (pipeline, group),
|
||||
_ => (&self.pipeline, &self.bind_group),
|
||||
};
|
||||
let meshes = ctx.device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("bodies meshes"),
|
||||
layout: &self.pipeline.get_bind_group_layout(1),
|
||||
layout: &pipeline.get_bind_group_layout(1),
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
@@ -672,8 +733,8 @@ impl BodiesPass {
|
||||
timestamp_writes: timestamps.map(PassTimestamps::render),
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(0, &self.bind_group, &[]);
|
||||
pass.set_pipeline(pipeline);
|
||||
pass.set_bind_group(0, group, &[]);
|
||||
pass.set_bind_group(1, &meshes, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
@@ -778,6 +839,88 @@ fn upload<'a>(
|
||||
gpu.create_view(&Default::default())
|
||||
}
|
||||
|
||||
/// The bodies pipeline for mesh targets of `samples` samples per pixel, casting grids of
|
||||
/// rays over pixels on bodies' edges if `area`, and with the Moon and the Sun seen
|
||||
/// through Earth's atmosphere if `bending`.
|
||||
fn compile(ctx: &GpuContext, samples: u32, area: bool, bending: bool) -> wgpu::RenderPipeline {
|
||||
let device = &ctx.device;
|
||||
let (radiance, depth) = if samples > 1 {
|
||||
(
|
||||
"texture_multisampled_2d<f32>",
|
||||
"texture_depth_multisampled_2d",
|
||||
)
|
||||
} else {
|
||||
("texture_2d<f32>", "texture_depth_2d")
|
||||
};
|
||||
let positions = sample_positions(samples)
|
||||
.iter()
|
||||
.map(|[x, y]| format!("vec2<f32>({x}, {y})"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let prelude = format!(
|
||||
"const SAMPLES: u32 = {samples}u;\nconst AREA: bool = {area};\n\
|
||||
alias MeshRadiance = {radiance};\nalias MeshDepth = {depth};\n\
|
||||
const SAMPLE_POSITIONS = array<vec2<f32>, {samples}>({positions});\n"
|
||||
);
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("bodies"),
|
||||
source: wgpu::ShaderSource::Wgsl(
|
||||
(prelude
|
||||
+ include_str!("../shaders/common.wgsl")
|
||||
+ &atmosphere::wgsl(5)
|
||||
+ include_str!("../shaders/bodies.wgsl")
|
||||
+ if bending {
|
||||
include_str!("../shaders/bending.wgsl")
|
||||
} else {
|
||||
STRAIGHT
|
||||
})
|
||||
.into(),
|
||||
),
|
||||
});
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("bodies"),
|
||||
layout: None,
|
||||
vertex: wgpu::VertexState {
|
||||
module: &module,
|
||||
entry_point: Some("vs_main"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: wgpu::PrimitiveState::default(),
|
||||
// Every pixel it does not discard is written: it has already sorted the
|
||||
// bodies against the meshes.
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
format: DEPTH_FORMAT,
|
||||
depth_write_enabled: Some(true),
|
||||
depth_compare: Some(wgpu::CompareFunction::Always),
|
||||
stencil: Default::default(),
|
||||
bias: Default::default(),
|
||||
}),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &module,
|
||||
entry_point: Some("fs_main"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(RADIANCE_FORMAT.into())],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// `through_air` and `earth_shadow` where no body is seen or lit through the
|
||||
/// atmosphere: every ray straight from the camera and whole, and Earth's shadow its hard
|
||||
/// edge's.
|
||||
const STRAIGHT: &str = "
|
||||
fn through_air(d: vec3<f32>, body: u32) -> Bent {
|
||||
return Bent(vec3<f32>(0.0), d, vec4<f32>(1.0));
|
||||
}
|
||||
|
||||
fn earth_shadow(p: vec3<f32>) -> vec4<f32> {
|
||||
return sun_visible(p, u.sun_center, u.earth_center);
|
||||
}
|
||||
";
|
||||
|
||||
/// The bodies pass's bind group.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn bind(
|
||||
@@ -790,6 +933,7 @@ fn bind(
|
||||
moon: &wgpu::TextureView,
|
||||
atmosphere: &AtmosphereTextures,
|
||||
detail: (&[wgpu::TextureView; DETAIL_GROUPS], &wgpu::Sampler),
|
||||
air: Option<(&wgpu::Buffer, &wgpu::Buffer)>,
|
||||
) -> wgpu::BindGroup {
|
||||
let entries = [
|
||||
wgpu::BindGroupEntry {
|
||||
@@ -830,6 +974,18 @@ fn bind(
|
||||
binding: 10 + DETAIL_GROUPS as u32,
|
||||
resource: wgpu::BindingResource::Sampler(detail.1),
|
||||
}])
|
||||
.chain(air.into_iter().flat_map(|(grazing, shadow)| {
|
||||
[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 15,
|
||||
resource: grazing.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 16,
|
||||
resource: shadow.as_entire_binding(),
|
||||
},
|
||||
]
|
||||
}))
|
||||
.collect();
|
||||
ctx.device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("bodies"),
|
||||
@@ -837,3 +993,33 @@ fn bind(
|
||||
entries: &entries,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::radiometry::{AU, SUN_RADIUS};
|
||||
|
||||
#[test]
|
||||
fn the_sun_is_behind_air_only_near_the_limb() {
|
||||
// 7000 km from Earth's center, `alpha` around from the Sun's side, in the plane
|
||||
// of the Sun: the Sun at the limb near alpha = 114.5 degrees.
|
||||
let sun = Vector3::new(AU, 0.0, 0.0);
|
||||
let seen = |alpha: f64| {
|
||||
let alpha = alpha.to_radians();
|
||||
let p = 7.0e6 * Vector3::new(alpha.cos(), alpha.sin(), 0.0);
|
||||
let camera = Pose::looking_at(p, Vector3::zeros(), Vector3::z()).unwrap();
|
||||
behind_air(sun - p, SUN_RADIUS, &camera)
|
||||
};
|
||||
assert!(!seen(0.0));
|
||||
assert!(!seen(100.0));
|
||||
assert!(seen(114.5));
|
||||
assert!(!seen(130.0));
|
||||
// Deep in Earth's shadow: hidden by the ground, however the air bends.
|
||||
assert!(!seen(180.0));
|
||||
// From the Moon's distance the lowest rays cross the shadow's axis, so the Sun
|
||||
// may be seen through the air from anywhere in the shadow.
|
||||
let p = Vector3::new(-3.8e8, 0.0, 0.0);
|
||||
let camera = Pose::looking_at(p, Vector3::zeros(), Vector3::z()).unwrap();
|
||||
assert!(behind_air(sun - p, SUN_RADIUS, &camera));
|
||||
}
|
||||
}
|
||||
@@ -15,9 +15,11 @@ use crate::bodies::{Appearance, MOON_RADIUS};
|
||||
use crate::camera::{Blooming, CameraConfig, Intrinsics, Psf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::atmosphere::AtmosphereTables;
|
||||
use crate::atmosphere::{AtmosphereTables, EarthShadow};
|
||||
use crate::earth::{EarthDetail, EarthTexture};
|
||||
use crate::eclipse::{sun_visible_fraction, sun_visible_light};
|
||||
use crate::eclipse::{
|
||||
moon_near_shadow, sun_visible_fraction, sun_visible_light, sun_visible_through_air,
|
||||
};
|
||||
use crate::fidelity::{CameraTier, Fidelity, LightingTier};
|
||||
use crate::fixed_pattern::FixedPattern;
|
||||
use crate::gpu::blur::BlurPass;
|
||||
@@ -174,6 +176,11 @@ pub struct Renderer {
|
||||
profiler: Option<Profiler>,
|
||||
/// How the Sun's brightness is spread across its disk: uniform below lighting L1.
|
||||
limb: LimbDarkening,
|
||||
/// Earth's atmosphere's tables, when targets' sunlight passes through it.
|
||||
atmosphere: Option<Arc<AtmosphereTables>>,
|
||||
/// The table of Earth's shadow the Moon was last lit from, kept while the Moon stays
|
||||
/// within it.
|
||||
earth_shadow: Option<EarthShadow>,
|
||||
}
|
||||
|
||||
impl Renderer {
|
||||
@@ -427,6 +434,8 @@ impl Renderer {
|
||||
radiance_staging,
|
||||
profiler,
|
||||
limb: LimbDarkening::uniform(),
|
||||
atmosphere: None,
|
||||
earth_shadow: None,
|
||||
};
|
||||
if options.fidelity.lighting >= LightingTier::L1 {
|
||||
renderer.limb = LimbDarkening::new(&renderer.radiometry);
|
||||
@@ -440,6 +449,7 @@ impl Renderer {
|
||||
let tables = AtmosphereTables::new(&renderer.radiometry);
|
||||
renderer.bodies.set_atmosphere(&renderer.ctx, &tables);
|
||||
renderer.body_radiometry.atmosphere = true;
|
||||
renderer.atmosphere = Some(tables);
|
||||
}
|
||||
}
|
||||
if let Some(texture) = &options.earth_texture {
|
||||
@@ -592,7 +602,7 @@ impl Renderer {
|
||||
(scene.moon, MOON_RADIUS),
|
||||
];
|
||||
|
||||
let bodies = BodiesUniforms::new(
|
||||
let mut bodies = BodiesUniforms::new(
|
||||
scene,
|
||||
&camera,
|
||||
&intrinsics,
|
||||
@@ -600,6 +610,30 @@ impl Renderer {
|
||||
&self.body_radiometry,
|
||||
self.fidelity.camera >= CameraTier::C1,
|
||||
);
|
||||
// The Moon in Earth's shadow, lit through the atmosphere (which comes with
|
||||
// lighting L1, and with it eclipses on the bodies): from a table made around the
|
||||
// Moon's distance and kept while the Moon stays within it.
|
||||
if let Some(tables) = &self.atmosphere {
|
||||
if let Some((distance, sun_radius)) = moon_near_shadow(scene) {
|
||||
let (near, far) = (distance - MOON_RADIUS, distance + MOON_RADIUS);
|
||||
if !self
|
||||
.earth_shadow
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.covers(near, far, sun_radius))
|
||||
{
|
||||
let shadow = EarthShadow::new(
|
||||
&tables.grazing,
|
||||
&self.limb,
|
||||
distance - 1.5 * MOON_RADIUS,
|
||||
distance + 1.5 * MOON_RADIUS,
|
||||
sun_radius,
|
||||
);
|
||||
self.bodies.set_earth_shadow(&self.ctx, &shadow);
|
||||
self.earth_shadow = Some(shadow);
|
||||
}
|
||||
bodies.light_moon_from_shadow();
|
||||
}
|
||||
}
|
||||
// Shadow maps, from lighting L1: one per target, with the targets that can
|
||||
// shadow it drawn in.
|
||||
let bounded: Vec<Bounded> = scene
|
||||
@@ -645,11 +679,16 @@ impl Renderer {
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, t)| {
|
||||
// From lighting L1, through the limb-darkened disk, per channel.
|
||||
let visible = if l1 {
|
||||
sun_visible_light(t.pose.position, scene.sun, &occulters, &self.limb)
|
||||
} else {
|
||||
[sun_visible_fraction(t.pose.position, scene.sun, &occulters); 4]
|
||||
// From lighting L1, through the limb-darkened disk, per channel, and
|
||||
// through Earth's atmosphere with it.
|
||||
let visible = match &self.atmosphere {
|
||||
Some(tables) => {
|
||||
sun_visible_through_air(t.pose.position, scene, tables, &self.limb)
|
||||
}
|
||||
None if l1 => {
|
||||
sun_visible_light(t.pose.position, scene.sun, &occulters, &self.limb)
|
||||
}
|
||||
None => [sun_visible_fraction(t.pose.position, scene.sun, &occulters); 4],
|
||||
};
|
||||
let shadow = plans.get(i).map(|plan| ShadowDraw {
|
||||
light_from_body: plan.light_from_body(&bounded[i]),
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// The Moon and the Sun seen through Earth's atmosphere, and the Moon lit through it:
|
||||
// `through_air` and `earth_shadow` for bodies.wgsl, in the variant of the bodies pass
|
||||
// used when any of these may be (bodies.rs). Kept out of the other, where the code
|
||||
// alone, never run, cost the RTX 3060 0.3 ms a frame.
|
||||
|
||||
// Rays through Earth's atmosphere from space to space (atmosphere/grazing.rs), by impact
|
||||
// parameter, ATMOSPHERE_GRAZING_SIZE entries evenly spaced from
|
||||
// ATMOSPHERE_GRAZING_BOTTOM to ATMOSPHERE_TOP: the transmittance along each per channel,
|
||||
// and its bending toward Earth in radians.
|
||||
struct Grazing {
|
||||
transmittance: array<vec4<f32>, ATMOSPHERE_GRAZING_SIZE>,
|
||||
bending: array<f32, ATMOSPHERE_GRAZING_SIZE>,
|
||||
}
|
||||
@group(0) @binding(15) var<storage, read> grazing: Grazing;
|
||||
|
||||
// The ray along `d` toward `body` (1 the Moon, 2 the Sun) through Earth's atmosphere,
|
||||
// if it passes through it: bent toward Earth and dimmed by the air along it, or stopped
|
||||
// if it meets the ground. Rays that miss the atmosphere, and all rays toward a body
|
||||
// that cannot be seen through it this frame, are `d` from the camera, whole.
|
||||
fn through_air(d: vec3<f32>, body: u32) -> Bent {
|
||||
var bent = Bent(vec3<f32>(0.0), d, vec4<f32>(1.0));
|
||||
if u.earth_shape[body] < 0.5 {
|
||||
return bent;
|
||||
}
|
||||
let camera = camera_stretched();
|
||||
let v = normalize(transpose(u.earth_axes) * d * vec3<f32>(1.0, 1.0, u.earth_shape.x));
|
||||
let along = dot(camera, v);
|
||||
// The impact parameter: the line's closest approach to Earth's center, in km.
|
||||
let b = length(camera - along * v);
|
||||
if along >= 0.0 || b >= ATMOSPHERE_TOP {
|
||||
return bent;
|
||||
}
|
||||
let last = f32(ATMOSPHERE_GRAZING_SIZE - 1u);
|
||||
let x = (b - ATMOSPHERE_GRAZING_BOTTOM) / (ATMOSPHERE_TOP - ATMOSPHERE_GRAZING_BOTTOM) * last;
|
||||
if x < 0.0 {
|
||||
bent.transmittance = vec4<f32>(0.0);
|
||||
return bent;
|
||||
}
|
||||
let i = min(u32(x), ATMOSPHERE_GRAZING_SIZE - 2u);
|
||||
let f = x - f32(i);
|
||||
bent.transmittance = mix(grazing.transmittance[i], grazing.transmittance[i + 1u], f);
|
||||
let bend = mix(grazing.bending[i], grazing.bending[i + 1u], f);
|
||||
// The path is symmetric about its lowest point, so the line it leaves on is the
|
||||
// line it came in on turned by `bend` about Earth's center, toward Earth.
|
||||
let e = u.earth_center.xyz;
|
||||
let axis = normalize(cross(d, e));
|
||||
bent.dir = rotate(d, axis, bend);
|
||||
bent.origin = e + rotate(-e, axis, bend);
|
||||
return bent;
|
||||
}
|
||||
|
||||
// `x` rotated by `angle` about the unit `axis`, right-handed.
|
||||
fn rotate(x: vec3<f32>, axis: vec3<f32>, angle: f32) -> vec3<f32> {
|
||||
let c = cos(angle);
|
||||
return x * c + cross(axis, x) * sin(angle) + axis * dot(axis, x) * (1.0 - c);
|
||||
}
|
||||
|
||||
// The sunlight that reaches points around the Moon past Earth and through its
|
||||
// atmosphere (atmosphere/grazing.rs, `EarthShadow`): two rows, at distances `range.z`
|
||||
// and `range.w` km from Earth's center, of ATMOSPHERE_SHADOW_SIZE entries, from
|
||||
// `range.x` radians in steps of `range.y`, of the angle between the Sun's center and
|
||||
// Earth's seen from the point, less the angular radius of the atmosphere's top. Per
|
||||
// channel, the natural log of the fraction of the Sun's light.
|
||||
struct EarthShadow {
|
||||
range: vec4<f32>,
|
||||
light: array<vec4<f32>, 2u * ATMOSPHERE_SHADOW_SIZE>,
|
||||
}
|
||||
@group(0) @binding(16) var<storage, read> shadow: EarthShadow;
|
||||
|
||||
// Per channel, the fraction of the Sun's light that reaches camera-frame point `p` on
|
||||
// the Moon past Earth: through the atmosphere from the table when it is set this frame,
|
||||
// else past Earth's hard edge.
|
||||
fn earth_shadow(p: vec3<f32>) -> vec4<f32> {
|
||||
if u.earth_shape.w < 0.5 {
|
||||
return sun_visible(p, u.sun_center, u.earth_center);
|
||||
}
|
||||
// In the atmosphere's frame, where Earth is a sphere.
|
||||
let to_earth = transpose(u.earth_axes) * (u.earth_center.xyz - p);
|
||||
let down = earth_stretched_km(to_earth, u.earth_shape.x);
|
||||
let stretch = vec3<f32>(1.0, 1.0, u.earth_shape.x);
|
||||
let to_sun = transpose(u.earth_axes) * (u.sun_center.xyz - p) * stretch;
|
||||
let distance = length(down);
|
||||
let c = atan2(length(cross(to_sun, down)), dot(to_sun, down));
|
||||
let last = f32(ATMOSPHERE_SHADOW_SIZE - 1u);
|
||||
let x = (c - asin(ATMOSPHERE_TOP / distance) - shadow.range.x) / shadow.range.y;
|
||||
if x >= last {
|
||||
return vec4<f32>(1.0);
|
||||
}
|
||||
let i = min(u32(max(x, 0.0)), ATMOSPHERE_SHADOW_SIZE - 2u);
|
||||
let f = max(x, 0.0) - f32(i);
|
||||
let w = clamp((distance - shadow.range.z) / (shadow.range.w - shadow.range.z), 0.0, 1.0);
|
||||
let far = ATMOSPHERE_SHADOW_SIZE + i;
|
||||
let near_light = mix(shadow.light[i], shadow.light[i + 1u], f);
|
||||
let far_light = mix(shadow.light[far], shadow.light[far + 1u], f);
|
||||
return exp(mix(near_light, far_light, w));
|
||||
}
|
||||
@@ -10,7 +10,8 @@
|
||||
//
|
||||
// The code that includes this file first declares SAMPLES, AREA, the types of the mesh
|
||||
// targets, MeshRadiance and MeshDepth (multisampled when SAMPLES > 1), and
|
||||
// SAMPLE_POSITIONS, where in a pixel each sample lies (bodies.rs).
|
||||
// SAMPLE_POSITIONS, where in a pixel each sample lies, and adds `through_air` and
|
||||
// `earth_shadow` (bodies.rs).
|
||||
|
||||
struct Uniforms {
|
||||
// fx, fy, cx, cy in pixels.
|
||||
@@ -22,7 +23,9 @@ struct Uniforms {
|
||||
earth_center: vec4<f32>,
|
||||
// Columns: Earth-fixed axes in the camera frame.
|
||||
earth_axes: mat3x3<f32>,
|
||||
// x: equatorial / polar radius.
|
||||
// x: equatorial / polar radius. y, z: 1 if the Moon, the Sun may be seen through
|
||||
// the atmosphere (bodies.rs, `behind_air`). w: 1 if the Moon is lit from the table
|
||||
// of Earth's shadow through the atmosphere (bending.wgsl, `earth_shadow`).
|
||||
earth_shape: vec4<f32>,
|
||||
// xyz: unit vector from Earth toward the Sun.
|
||||
earth_sun: vec4<f32>,
|
||||
@@ -274,17 +277,17 @@ fn moon_radiance(p: vec3<f32>, d: vec3<f32>, n: vec3<f32>, at: MapLookup) -> vec
|
||||
}
|
||||
// Earth's shadow: a lunar eclipse.
|
||||
if u.params.z > 0.5 && lit > 0.0 {
|
||||
radiance *= sun_visible(p, u.sun_center, u.earth_center);
|
||||
radiance *= earth_shadow(p);
|
||||
}
|
||||
return radiance;
|
||||
}
|
||||
|
||||
// The Sun's channel radiance along `d`, which meets it.
|
||||
fn sun_radiance(d: vec3<f32>) -> vec4<f32> {
|
||||
// The Sun's channel radiance along the ray from `o`, relative to the Sun's center, along
|
||||
// `d`, which meets it.
|
||||
fn sun_radiance(o: vec3<f32>, d: vec3<f32>) -> vec4<f32> {
|
||||
// The Sun's surface seen at `mu`, from the ray's miss distance from its center:
|
||||
// mu = sqrt(r^2 - miss^2) / r, which keeps its precision 1 AU away, where the hit
|
||||
// point's normal would not.
|
||||
let o = -u.sun_center.xyz;
|
||||
let miss = length(o - dot(o, d) * d);
|
||||
let r = u.sun_center.w;
|
||||
return u.sun_radiance * disk_profile(sqrt(max((r - miss) * (r + miss), 0.0)) / r);
|
||||
@@ -305,6 +308,26 @@ fn seen(d: vec3<f32>, b: u32) -> vec3<f32> {
|
||||
return d - beta + dot(beta, d) * d;
|
||||
}
|
||||
|
||||
// A ray from the camera toward the Moon or the Sun after Earth's atmosphere: the
|
||||
// straight line it leaves on, as an origin and a unit direction in the camera frame,
|
||||
// and per channel the light it lets through. `through_air(d, body)` gives it for the
|
||||
// ray along `d` toward body 1 (the Moon) or 2 (the Sun): bending.wgsl, or straight
|
||||
// and whole where no body is seen through the atmosphere (bodies.rs).
|
||||
struct Bent {
|
||||
origin: vec3<f32>,
|
||||
dir: vec3<f32>,
|
||||
transmittance: vec4<f32>,
|
||||
}
|
||||
|
||||
// The distance along `d` to the depth of the point `t` along the bent ray: `t` itself
|
||||
// for a ray that is not bent.
|
||||
fn unbent(bent: Bent, d: vec3<f32>, t: f32) -> f32 {
|
||||
if all(bent.origin == vec3<f32>(0.0)) || t <= 0.0 {
|
||||
return t;
|
||||
}
|
||||
return dot(bent.origin + t * bent.dir, d) / dot(d, d);
|
||||
}
|
||||
|
||||
// Earth's center and the ray `d`, both in Earth-fixed axes stretched along the polar
|
||||
// axis so that Earth is a sphere of its equatorial radius (`ellipsoid_hit`).
|
||||
fn earth_stretched(d: vec3<f32>) -> array<vec3<f32>, 2> {
|
||||
@@ -317,8 +340,10 @@ fn earth_stretched(d: vec3<f32>) -> array<vec3<f32>, 2> {
|
||||
fn nearest(d: vec3<f32>) -> f32 {
|
||||
let to_cam = transpose(u.earth_axes) * (-u.earth_center.xyz);
|
||||
let te = ellipsoid_hit(to_cam, transpose(u.earth_axes) * seen(d, 0u), u.earth_center.w, u.earth_shape.x);
|
||||
let tm = sphere_hit(-u.moon_center.xyz, seen(d, 1u), u.moon_center.w);
|
||||
let ts = sphere_hit(-u.sun_center.xyz, seen(d, 2u), u.sun_center.w);
|
||||
let bm = through_air(seen(d, 1u), 1u);
|
||||
let bs = through_air(seen(d, 2u), 2u);
|
||||
let tm = unbent(bm, d, sphere_hit(bm.origin - u.moon_center.xyz, bm.dir, u.moon_center.w));
|
||||
let ts = unbent(bs, d, sphere_hit(bs.origin - u.sun_center.xyz, bs.dir, u.sun_center.w));
|
||||
var best = FAR;
|
||||
if te > 0.0 {
|
||||
best = te;
|
||||
@@ -346,8 +371,12 @@ fn trace(d: vec3<f32>, spacing: f32) -> Hit {
|
||||
let to_cam = transpose(u.earth_axes) * (-u.earth_center.xyz);
|
||||
let dir = transpose(u.earth_axes) * de;
|
||||
let te = ellipsoid_hit(to_cam, dir, u.earth_center.w, u.earth_shape.x);
|
||||
let tm = sphere_hit(-u.moon_center.xyz, dm, u.moon_center.w);
|
||||
let ts = sphere_hit(-u.sun_center.xyz, ds, u.sun_center.w);
|
||||
let bm = through_air(dm, 1u);
|
||||
let bs = through_air(ds, 2u);
|
||||
let hm = sphere_hit(bm.origin - u.moon_center.xyz, bm.dir, u.moon_center.w);
|
||||
let hs = sphere_hit(bs.origin - u.sun_center.xyz, bs.dir, u.sun_center.w);
|
||||
let tm = unbent(bm, d, hm);
|
||||
let ts = unbent(bs, d, hs);
|
||||
var best = FAR;
|
||||
var radiance = vec4<f32>(0.0);
|
||||
if te > 0.0 && te < best {
|
||||
@@ -359,13 +388,14 @@ fn trace(d: vec3<f32>, spacing: f32) -> Hit {
|
||||
}
|
||||
if tm > 0.0 && tm < best {
|
||||
best = tm;
|
||||
let n = normalize(tm * dm - u.moon_center.xyz);
|
||||
let at = map_lookup_at(transpose(u.moon_axes) * n, tm, abs(dot(n, dm)), spacing, u.moon_center.w);
|
||||
radiance = moon_radiance(tm * dm, dm, n, at);
|
||||
let p = bm.origin + hm * bm.dir;
|
||||
let n = normalize(p - u.moon_center.xyz);
|
||||
let at = map_lookup_at(transpose(u.moon_axes) * n, tm, abs(dot(n, bm.dir)), spacing, u.moon_center.w);
|
||||
radiance = moon_radiance(p, bm.dir, n, at) * bm.transmittance;
|
||||
}
|
||||
if ts > 0.0 && ts < best {
|
||||
best = ts;
|
||||
radiance = sun_radiance(ds);
|
||||
radiance = sun_radiance(bs.origin - u.sun_center.xyz, bs.dir) * bs.transmittance;
|
||||
}
|
||||
if !(te > 0.0 && te == best) {
|
||||
radiance += earth_glow(d, best);
|
||||
@@ -433,10 +463,12 @@ fn limb(c: vec3<f32>, r: f32, d: vec3<f32>) -> vec2<f32> {
|
||||
// even, so that each mesh sample is nearest to the same number of rays.
|
||||
fn grid_size(d: vec3<f32>, pixel: f32) -> u32 {
|
||||
let earth = earth_stretched(d);
|
||||
let moon = through_air(seen(d, 1u), 1u);
|
||||
let sun = through_air(seen(d, 2u), 2u);
|
||||
var edges = array<vec2<f32>, 3>(
|
||||
limb(earth[0], u.earth_center.w, earth[1]),
|
||||
limb(u.moon_center.xyz, u.moon_center.w, seen(d, 1u)),
|
||||
limb(u.sun_center.xyz, u.sun_center.w, seen(d, 2u)),
|
||||
limb(u.moon_center.xyz - moon.origin, u.moon_center.w, moon.dir),
|
||||
limb(u.sun_center.xyz - sun.origin, u.sun_center.w, sun.dir),
|
||||
);
|
||||
var n = 1u;
|
||||
for (var i = 0; i < 3; i++) {
|
||||
@@ -474,19 +506,24 @@ fn fs_main(@builtin(position) pos: vec4<f32>) -> Out {
|
||||
radiance = earth_radiance(te * de, n_fixed, earth_at);
|
||||
}
|
||||
|
||||
let tm = sphere_hit(-u.moon_center.xyz, dm, u.moon_center.w);
|
||||
// The Moon and the Sun, each along its ray through Earth's atmosphere.
|
||||
let bm = through_air(dm, 1u);
|
||||
let hm = sphere_hit(bm.origin - u.moon_center.xyz, bm.dir, u.moon_center.w);
|
||||
let tm = unbent(bm, d, hm);
|
||||
// Where the Moon's map would be read, for every pixel, for the same reason as Earth's.
|
||||
let moon_n = normalize(max(tm, 0.0) * dm - u.moon_center.xyz);
|
||||
let moon_p = bm.origin + max(hm, 0.0) * bm.dir;
|
||||
let moon_n = normalize(moon_p - u.moon_center.xyz);
|
||||
let moon_at = map_lookup(transpose(u.moon_axes) * moon_n);
|
||||
if tm > 0.0 && tm < best {
|
||||
best = tm;
|
||||
radiance = moon_radiance(tm * dm, dm, moon_n, moon_at);
|
||||
radiance = moon_radiance(moon_p, bm.dir, moon_n, moon_at) * bm.transmittance;
|
||||
}
|
||||
|
||||
let ts = sphere_hit(-u.sun_center.xyz, ds, u.sun_center.w);
|
||||
let bs = through_air(ds, 2u);
|
||||
let ts = unbent(bs, d, sphere_hit(bs.origin - u.sun_center.xyz, bs.dir, u.sun_center.w));
|
||||
if ts > 0.0 && ts < best {
|
||||
best = ts;
|
||||
radiance = sun_radiance(ds);
|
||||
radiance = sun_radiance(bs.origin - u.sun_center.xyz, bs.dir) * bs.transmittance;
|
||||
}
|
||||
var glow = vec4<f32>(0.0);
|
||||
if !(te > 0.0 && te == best) {
|
||||
|
||||
@@ -86,12 +86,16 @@ pub const SAMPLES: usize = 64;
|
||||
pub const RINGS: usize = 8;
|
||||
|
||||
/// Gauss-Legendre nodes and weights for [`RINGS`] points on [0, pi], as (angle, weight).
|
||||
/// Found once by Newton's method on the Legendre polynomial's roots.
|
||||
pub fn ring_rule() -> &'static [(f64, f64); RINGS] {
|
||||
static RULE: std::sync::OnceLock<[(f64, f64); RINGS]> = std::sync::OnceLock::new();
|
||||
RULE.get_or_init(|| {
|
||||
let n = RINGS;
|
||||
std::array::from_fn(|i| {
|
||||
RULE.get_or_init(|| legendre_rule(RINGS).try_into().expect("one node per ring"))
|
||||
}
|
||||
|
||||
/// Gauss-Legendre nodes and weights for `n` points on [0, pi], as (angle, weight). Found
|
||||
/// by Newton's method on the Legendre polynomial's roots.
|
||||
pub(crate) fn legendre_rule(n: usize) -> Vec<(f64, f64)> {
|
||||
(0..n)
|
||||
.map(|i| {
|
||||
// Root i of P_n, from the usual starting guess.
|
||||
let mut x = (std::f64::consts::PI * (i as f64 + 0.75) / (n as f64 + 0.5)).cos();
|
||||
let mut derivative = 0.0;
|
||||
@@ -114,7 +118,7 @@ pub fn ring_rule() -> &'static [(f64, f64); RINGS] {
|
||||
let half = std::f64::consts::PI / 2.0;
|
||||
(half * (x + 1.0), half * weight)
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// How each camera channel's sunlight is spread over the solar disk.
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
|
||||
use crate::common;
|
||||
|
||||
use resolved::atmosphere::{AtmosphereTables, BOTTOM_RADIUS, TOP_RADIUS};
|
||||
use resolved::atmosphere::{AtmosphereTables, BOTTOM_RADIUS, GRAZING_BOTTOM, TOP_RADIUS};
|
||||
use resolved::bodies::MOON_RADIUS;
|
||||
use resolved::gpu::AdapterChoice;
|
||||
use resolved::nalgebra::{UnitQuaternion, Vector3};
|
||||
use resolved::nalgebra::{Rotation3, Unit, UnitQuaternion, Vector2, Vector3};
|
||||
use resolved::radiometry::{Radiometry, AU};
|
||||
use resolved::sun::LimbDarkening;
|
||||
use std::f64::consts::PI;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -225,3 +227,230 @@ fn a_camera_inside_the_atmosphere_is_refused() {
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Seen from 700 km above 0 N 90 E (+Y), the line of sight in the equatorial plane
|
||||
/// toward -X whose straight line passes `height` above the lowest ray that clears the
|
||||
/// ground, and a pose looking along it with the zenith up the image.
|
||||
fn toward_limb(height: f64) -> (Vector3<f64>, Pose) {
|
||||
let camera = Vector3::new(0.0, BOTTOM_RADIUS + 700e3, 0.0);
|
||||
let seen = ((GRAZING_BOTTOM + height) / camera.norm()).asin();
|
||||
let view = Vector3::new(-seen.sin(), -seen.cos(), 0.0);
|
||||
let pose = Pose::looking_at(camera, camera + view, Vector3::y()).unwrap();
|
||||
(view, pose)
|
||||
}
|
||||
|
||||
/// What `bodies.wgsl`'s `through_air` does to the line of sight through pixel (u, v), in
|
||||
/// f64: the line it leaves the atmosphere on (origin and direction, inertial) and its
|
||||
/// transmittance per channel, or `None` if it meets the ground.
|
||||
fn bent(
|
||||
tables: &AtmosphereTables,
|
||||
pose: &Pose,
|
||||
u: usize,
|
||||
v: usize,
|
||||
) -> Option<(Vector3<f64>, Vector3<f64>, [f64; 4])> {
|
||||
let k = common::example_intrinsics();
|
||||
let d = pose.attitude * k.ray(&Vector2::new(u as f64 + 0.5, v as f64 + 0.5));
|
||||
let c = pose.position;
|
||||
let b = (c - c.dot(&d) * d).norm();
|
||||
let (bend, transmittance) = tables.grazing.ray(b)?;
|
||||
let turn = Rotation3::from_axis_angle(&Unit::new_normalize(d.cross(&-c)), bend);
|
||||
Some((turn * c, turn * d, transmittance))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_sun_through_the_limb_is_bent_and_dimmed() {
|
||||
// The Sun placed where the line of sight through the image center goes after the
|
||||
// atmosphere bends it, behind air 12, 20 and 30 km up. Down the image's middle
|
||||
// column, each pixel takes the Sun's radiance where its bent ray meets the disk,
|
||||
// times its transmittance. At 12 km the bending, 0.3 degrees, is more than the
|
||||
// Sun's radius: the Sun is seen where a straight line would miss it.
|
||||
let _devices = common::device_lock();
|
||||
let (r, _, _) = constants();
|
||||
let tables = AtmosphereTables::new(&r);
|
||||
let limb = LimbDarkening::new(&r);
|
||||
for adapter in &ADAPTERS {
|
||||
let mut renderer = renderer(adapter);
|
||||
let name = renderer.adapter_info().name.clone();
|
||||
for height in [12e3, 20e3, 30e3] {
|
||||
let (_, pose) = toward_limb(height);
|
||||
let (origin, dir, _) = bent(&tables, &pose, 959, 539).unwrap();
|
||||
let sun = origin + AU * dir;
|
||||
let (scene, _) = view(sun, pose.position, pose.position + dir);
|
||||
let frame = renderer.submit(&scene, &pose).unwrap().wait().unwrap();
|
||||
let radiance = frame.radiance.unwrap();
|
||||
let mut compared = 0;
|
||||
for v in 400..680 {
|
||||
let Some((origin, dir, t)) = bent(&tables, &pose, 959, v) else {
|
||||
continue;
|
||||
};
|
||||
let o = origin - sun;
|
||||
let miss = (o - o.dot(&dir) * dir).norm() / resolved::radiometry::SUN_RADIUS;
|
||||
// Inside the disk, away from its edge, where the air lets enough through
|
||||
// that the glow over the limb does not count.
|
||||
if miss > 0.97 || t[0] < 0.05 {
|
||||
continue;
|
||||
}
|
||||
let profile = limb.profile((1.0 - miss * miss).sqrt());
|
||||
let got = radiance[v * 1920 + 959];
|
||||
for k in 0..3 {
|
||||
let want = r.sun_radiance(k) * profile[k] * t[k];
|
||||
let error = f64::from(got[k]) / want - 1.0;
|
||||
// Measured: 7.3e-4 at most.
|
||||
assert!(
|
||||
error.abs() < 2e-3,
|
||||
"{name} {height} m, row {v}, channel {k}: {error}"
|
||||
);
|
||||
}
|
||||
compared += 1;
|
||||
}
|
||||
assert!(compared > 30, "{name} {height} m: {compared} rows");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_moon_through_the_limb_is_bent_and_dimmed() {
|
||||
// The Moon placed as the Sun is above, behind air 15 km up, uniform and lit from
|
||||
// beside the camera and above. Down the image's middle column the light falls from
|
||||
// the disk's center to its edge with the cosine of the Sun's incidence where each
|
||||
// bent ray meets it: each pixel's radiance over its transmittance, that cosine and
|
||||
// the phase curve is the same everywhere if the rays are bent where the shader
|
||||
// bends them. The air's glow, the same with or without the Moon behind it, is
|
||||
// taken out with an image of the Moon moved out of view.
|
||||
let _devices = common::device_lock();
|
||||
let (r, _, _) = constants();
|
||||
let tables = AtmosphereTables::new(&r);
|
||||
for adapter in &ADAPTERS {
|
||||
let mut renderer = renderer(adapter);
|
||||
let name = renderer.adapter_info().name.clone();
|
||||
let (view_dir, pose) = toward_limb(15e3);
|
||||
let (origin, dir, _) = bent(&tables, &pose, 959, 539).unwrap();
|
||||
let moon = origin + 3.844e8 * dir;
|
||||
let to_sun = (Vector3::z() * 2.0 - view_dir).normalize();
|
||||
let scene = Scene {
|
||||
earth_attitude: UnitQuaternion::identity(),
|
||||
sun: moon + AU * to_sun,
|
||||
moon,
|
||||
moon_attitude: UnitQuaternion::identity(),
|
||||
targets: vec![],
|
||||
velocities: Default::default(),
|
||||
};
|
||||
let frame = renderer.submit(&scene, &pose).unwrap().wait().unwrap();
|
||||
let radiance = frame.radiance.unwrap();
|
||||
let away = Scene {
|
||||
moon: -moon,
|
||||
..scene.clone()
|
||||
};
|
||||
let frame = renderer.submit(&away, &pose).unwrap().wait().unwrap();
|
||||
let glow = frame.radiance.unwrap();
|
||||
let mut ratios = vec![];
|
||||
for v in 300..780 {
|
||||
let Some((origin, dir, t)) = bent(&tables, &pose, 959, v) else {
|
||||
continue;
|
||||
};
|
||||
let o = origin - moon;
|
||||
let along = -o.dot(&dir);
|
||||
let miss = (o + along * dir).norm();
|
||||
// Away from the edge, where the cosine changes fastest with where the ray
|
||||
// meets the disk: a 3 m error in f32 in the impact parameter moves the
|
||||
// bending by 1.3e-6 rad, 500 m at the Moon.
|
||||
if miss > 0.9 * MOON_RADIUS || t[0] < 0.05 {
|
||||
continue;
|
||||
}
|
||||
let hit = o + (along - (MOON_RADIUS * MOON_RADIUS - miss * miss).sqrt()) * dir;
|
||||
let lit = (hit / MOON_RADIUS).dot(&to_sun);
|
||||
let phase = to_sun.cross(&-dir).norm().atan2(to_sun.dot(&-dir));
|
||||
let (got, glow) = (radiance[v * 1920 + 959], glow[v * 1920 + 959]);
|
||||
ratios.push(std::array::from_fn::<f64, 3, _>(|k| {
|
||||
f64::from(got[k] - glow[k]) / (t[k] * lit * resolved::moon::phase_curve(phase))
|
||||
}));
|
||||
}
|
||||
assert!(ratios.len() > 30, "{name}: {} rows", ratios.len());
|
||||
for k in 0..3 {
|
||||
let mean = ratios.iter().map(|q| q[k]).sum::<f64>() / ratios.len() as f64;
|
||||
let worst = ratios
|
||||
.iter()
|
||||
.map(|q| (q[k] / mean - 1.0).abs())
|
||||
.fold(0.0, f64::max);
|
||||
// Measured: 1.2e-3 at most, on lavapipe.
|
||||
assert!(worst < 3e-3, "{name} channel {k}: {worst}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_eclipsed_moon_is_lit_through_the_air() {
|
||||
// The uniform Moon in Earth's shadow, across the edge of the umbra (2300 to 5700 km
|
||||
// from its axis), seen from 20,000 km on its sunward side, and again out of the
|
||||
// shadow. Each pixel's radiance over the cosine of the Sun's incidence, the phase
|
||||
// curve and the sunlight that reaches its point on the Moon
|
||||
// (`Grazing::sunlight`, in f64) is the same everywhere, in and out of the shadow,
|
||||
// if the shader looks the light up where it should.
|
||||
let _devices = common::device_lock();
|
||||
let (r, _, _) = constants();
|
||||
let tables = AtmosphereTables::new(&r);
|
||||
let limb = LimbDarkening::new(&r);
|
||||
let k = common::example_intrinsics();
|
||||
let sun = Vector3::new(AU, 0.0, 0.0);
|
||||
for adapter in &ADAPTERS {
|
||||
let mut renderer = renderer(adapter);
|
||||
let name = renderer.adapter_info().name.clone();
|
||||
// Per channel: each sampled pixel's radiance over what it should be, but for a
|
||||
// constant.
|
||||
let mut ratios = |off: f64| {
|
||||
let moon = Vector3::new(-3.844e8, off, 0.0);
|
||||
let scene = Scene {
|
||||
earth_attitude: UnitQuaternion::identity(),
|
||||
sun,
|
||||
moon,
|
||||
moon_attitude: UnitQuaternion::identity(),
|
||||
targets: vec![],
|
||||
velocities: Default::default(),
|
||||
};
|
||||
let pose = Pose::looking_at(moon + Vector3::new(2e7, 0.0, 0.0), moon, Vector3::z());
|
||||
let pose = pose.unwrap();
|
||||
let frame = renderer.submit(&scene, &pose).unwrap().wait().unwrap();
|
||||
let radiance = frame.radiance.unwrap();
|
||||
let to_sun = (sun - moon).normalize();
|
||||
let mut ratios = vec![];
|
||||
for v in (0..1080).step_by(12) {
|
||||
for u in (0..1920).step_by(12) {
|
||||
let d = pose.attitude * k.ray(&Vector2::new(u as f64 + 0.5, v as f64 + 0.5));
|
||||
let o = pose.position - moon;
|
||||
let along = -o.dot(&d);
|
||||
let miss = (o + along * d).norm();
|
||||
if miss > 0.9 * MOON_RADIUS {
|
||||
continue;
|
||||
}
|
||||
let hit = o + (along - (MOON_RADIUS * MOON_RADIUS - miss * miss).sqrt()) * d;
|
||||
let lit = (hit / MOON_RADIUS).dot(&to_sun);
|
||||
let phase = to_sun.cross(&-d).norm().atan2(to_sun.dot(&-d));
|
||||
let light = tables.grazing.sunlight(
|
||||
resolved::atmosphere::stretched(&scene, moon + hit),
|
||||
resolved::atmosphere::stretched(&scene, sun),
|
||||
&limb,
|
||||
);
|
||||
let got = radiance[v * 1920 + u];
|
||||
ratios.push(std::array::from_fn::<f64, 3, _>(|c| {
|
||||
f64::from(got[c]) / (light[c] * lit * resolved::moon::phase_curve(phase))
|
||||
}));
|
||||
}
|
||||
}
|
||||
ratios
|
||||
};
|
||||
let outside = ratios(2e7);
|
||||
let inside = ratios(4e6);
|
||||
assert!(inside.len() > 3000, "{name}: {} pixels", inside.len());
|
||||
for c in 0..3 {
|
||||
let scale = outside.iter().map(|q| q[c]).sum::<f64>() / outside.len() as f64;
|
||||
let worst = inside
|
||||
.iter()
|
||||
.chain(&outside)
|
||||
.map(|q| (q[c] / scale - 1.0).abs())
|
||||
.fold(0.0, f64::max);
|
||||
// Measured: 4.7e-3 on the RTX 3060, 3.6e-3 on lavapipe, the table's
|
||||
// interpolation (`EarthShadow`).
|
||||
assert!(worst < 7e-3, "{name} channel {c}: {worst}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,9 +42,12 @@ fn renderer(adapter: &AdapterChoice, lighting: LightingTier) -> Renderer {
|
||||
moon_texture: Some(common::uniform_moon()),
|
||||
// The same Moon at L0 (flat albedo) and L1 (a uniform map with a flat color), so
|
||||
// the two images differ only by the eclipse and L1's phase factor (`with_phase`).
|
||||
// Without Earth's atmosphere, so Earth's shadow has its hard edge (the shadow
|
||||
// through the air is tested in atmosphere.rs).
|
||||
appearance: Appearance {
|
||||
moon_albedo: Spectrum::constant(0.125),
|
||||
moon_color: Spectrum::constant(1.0),
|
||||
earth_atmosphere: false,
|
||||
..Default::default()
|
||||
},
|
||||
read_radiance: true,
|
||||
|
||||
+186
-11
@@ -1231,8 +1231,9 @@ Limitations:
|
||||
|
||||
- **The camera must be above the atmosphere**, over 100 km up; below that a
|
||||
frame is refused.
|
||||
- **Sunlight reaching the target** through the atmosphere, and the Sun and
|
||||
Moon seen through it at the limb, are not dimmed or reddened yet.
|
||||
- **Sunlight reaching the target**, and the Sun and Moon seen past the limb,
|
||||
pass through the atmosphere as "Sunset through the atmosphere" describes;
|
||||
targets, stars and Earth's own surface seen past the limb do not.
|
||||
- **Clouds** are not drawn: Blue Marble is a cloud-free mosaic.
|
||||
- **The Moon's shadow** in a solar eclipse dims the air along the line of
|
||||
sight as it dims the ground point behind it.
|
||||
@@ -1592,8 +1593,181 @@ the outside than the area alone says.
|
||||
The renderer counts both Earth and the Moon as occulters. The fraction is
|
||||
computed once per target, at its center. The whole target
|
||||
dims together, which is accurate unless the target is a sizable fraction of
|
||||
the penumbra's width. Light reaching the target from Earth itself is the
|
||||
next section but one.
|
||||
the penumbra's width. With Earth's atmosphere (lighting L1), Earth's part is
|
||||
the light that comes through the air past its edge, the next section. Light
|
||||
reaching the target from Earth itself is the section after the next two.
|
||||
|
||||
### Sunset through the atmosphere
|
||||
|
||||
Code: `atmosphere/grazing.rs`, `eclipse.rs` (`sun_visible_through_air`,
|
||||
`moon_near_shadow`), `shaders/bending.wgsl`.
|
||||
|
||||
From low orbit, the Sun sets behind Earth's atmosphere before it sets behind
|
||||
the ground. For the last 20 seconds or so the light reaching a spacecraft has
|
||||
crossed hundreds of kilometers of air edge on, and three things happen to it:
|
||||
|
||||
- **The air takes light out of it**, as anywhere else, but crossing about 70
|
||||
times as much air as a path straight up from the same height. Blue goes first, scattered away by air
|
||||
molecules. Then, through the ozone layer 20 to 30 km up, ozone's absorption
|
||||
band near 600 nm takes more of the red and green than the air takes of the
|
||||
blue, so the light is bluish for a few seconds: the turquoise fringe at the
|
||||
edge of Earth's shadow in photographs of lunar eclipses. Lower down, below
|
||||
about 15 km, the air's scattering wins again and what is left is orange and
|
||||
red, until aerosols near the ground take the rest.
|
||||
- **The air bends it.** Air's refractive index is a little over 1 and grows
|
||||
with its density, so light passing the limb curves toward Earth: by 1.25
|
||||
degrees for a ray that just skims the ground, 0.14 degrees at 15 km, 0.04 at
|
||||
25 km. (An observer on the ground sees the setting Sun raised by half the
|
||||
first figure, the familiar 35 arcminutes of refraction at the horizon.) So
|
||||
the Sun is still seen, raised, after it has geometrically set.
|
||||
- **The bending spreads it out.** Lower rays bend more, so rays that came in
|
||||
parallel leave fanned out downward, and the light spreads over a larger
|
||||
angle: refractive dilution. Seen from a spacecraft, the Sun's image is
|
||||
squeezed into a flattened ellipse, still with the same surface brightness
|
||||
apart from the air's losses, so it delivers less light. From 700 km the
|
||||
squeeze is 0.88 for light passing 30 km up, 0.50 at 15 km and 0.27 at 8 km,
|
||||
as large an effect there as the air's own losses.
|
||||
|
||||
Some numbers for a ray seen from 700 km, by how high above the ground it
|
||||
passes (the example camera's red, green and blue channels):
|
||||
|
||||
| Height | Bending | Squeeze | Red | Green | Blue |
|
||||
|---|---|---|---|---|---|
|
||||
| 40 km | 0.006 degrees | 0.96 | 0.97 | 0.96 | 0.92 |
|
||||
| 30 km | 0.021 | 0.88 | 0.58 | 0.59 | 0.69 |
|
||||
| 20 km | 0.076 | 0.66 | 0.22 | 0.21 | 0.28 |
|
||||
| 15 km | 0.144 | 0.50 | 0.17 | 0.12 | 0.12 |
|
||||
| 8 km | 0.367 | 0.27 | 0.10 | 0.03 | 0.01 |
|
||||
|
||||
Because the atmosphere is taken as a sphere of air whose density depends only
|
||||
on height, a ray's whole path through it depends on one number: its **impact
|
||||
parameter**, how close its straight line outside the atmosphere passes to
|
||||
Earth's center. Two tables of 1024 entries, from the ray that just skims the
|
||||
ground to the top of the atmosphere, hold each ray's total bending and its
|
||||
transmittance per channel. They are integrals along the curved path, with
|
||||
the refractive index proportional to the air's density in the same model as
|
||||
the rest of the atmosphere (1.000278 at the ground, for 550 nm), computed at
|
||||
setup in 2.4 ms on 24 cores.
|
||||
|
||||
**The target's sunlight.** Seen from the target, a ray that arrives from an
|
||||
angle theta from Earth's center passes at an impact parameter that follows
|
||||
from theta, and came from theta less its bending. The renderer adds up the
|
||||
Sun's limb-darkened radiance over the solid angle the Sun's image covers,
|
||||
each ray's from where it came from, times its transmittance: 64 rows across
|
||||
the image, each an arc around Earth's center, 8 points along each. That gives
|
||||
all three effects at once, per channel. It takes 17 to 25 microseconds per
|
||||
target near the limb, and nothing elsewhere. Through a sunset seen from
|
||||
700 km, the light fades from 99 % to 6 % of red, 3 % of blue, over the 0.02
|
||||
radians (19 seconds) of orbit before the straight line to the Sun's center
|
||||
meets the ground. Refraction keeps that last 6 % coming after the geometric
|
||||
edge. The sunset example scene (`examples/scenes/sunset.toml`) shows Landsat 8
|
||||
1.4 s before that point, lit by 7.2 % of the Sun's red light and 4.4 % of its
|
||||
blue, against the twilight at the limb.
|
||||
|
||||
**The Sun and Moon seen through the limb.** The bodies pass bends each line
|
||||
of sight that passes through the atmosphere: it finds the line's impact
|
||||
parameter, turns it toward Earth by its bending about Earth's center (the
|
||||
path is symmetric, so the line it leaves on is the one it came in on turned
|
||||
by the bending), looks for the Moon or the Sun along that, and multiplies
|
||||
what it finds by the transmittance. The Sun near the horizon comes out
|
||||
flattened and red, as in photographs from the International Space Station.
|
||||
Only frames where the Sun or the Moon is near the limb need it, and the code
|
||||
alone, even never run, cost 0.3 ms a frame on the RTX 3060, so it is a second
|
||||
version of the bodies pass that only those frames use. In them it adds
|
||||
0.4 ms on the RTX 3060 and 6 ms on lavapipe.
|
||||
|
||||
**The eclipsed Moon.** The same rays reach into Earth's shadow. The lowest
|
||||
are bent by 1.25 degrees, so they cross the shadow's axis about 290,000 km
|
||||
behind Earth (Earth's radius over that angle). The Moon, at 384,000 km, is
|
||||
past that point, so from anywhere in the umbra it sees a thin ring of red
|
||||
sunlight around Earth's limb. A ray seen low enough comes from the far side
|
||||
of Earth's center, and the target's sum follows it there. Across the umbra
|
||||
at the Moon (the example camera's channels, as fractions of the Sun's light):
|
||||
|
||||
| From the axis | Red | Green | Blue |
|
||||
|---|---|---|---|
|
||||
| 0 km | 5.9e-5 | 6.1e-6 | 3.5e-7 |
|
||||
| 2000 km | 1.6e-4 | 3.2e-5 | 4.7e-6 |
|
||||
| 4000 km | 6.5e-4 | 3.2e-4 | 2.0e-4 |
|
||||
| 4600 km, the umbra's edge | 1.8e-3 | 1.4e-3 | 1.5e-3 |
|
||||
|
||||
Near the axis the light comes from rays that pass below about 5 km; 4000 km
|
||||
out, from rays about 5 to 19 km up. The center is darkest, because the Moon
|
||||
is too far past the point where the lowest rays cross to see them gather on
|
||||
the axis. Photometry of the eclipse of June 2011 (Ugolnikov et al.,
|
||||
arXiv:1106.6178) found 1e-4 to 1e-3 at 677 nm from the center out to 0.6
|
||||
degrees (4000 km), and about 1e-6 at 503 nm. A model of clear air there
|
||||
agreed with the outer umbra and was brighter further in, where aerosols took
|
||||
light out.
|
||||
|
||||
A sum for every pixel of the Moon, at 20 to 40 microseconds each, is too much.
|
||||
The shadow is symmetric about its axis, so the light at a point depends only
|
||||
on two things: its distance from Earth's center, and the angle between the
|
||||
Sun and Earth's center seen from it. When the Moon comes near Earth's shadow,
|
||||
the renderer puts that light in a table (`EarthShadow`), and the bodies pass
|
||||
reads it for each pixel of the Moon.
|
||||
|
||||
- **Layout.** Two rows of 512 entries, on the near and far sides of the Moon.
|
||||
The entries are spaced in the angle less the atmosphere's angular radius,
|
||||
which lines the shadow's edges up in both rows.
|
||||
- **Contents.** It holds the light's logarithm, which follows the umbra's
|
||||
steep falloff better than the light itself does. Entries fall where the
|
||||
Sun's edges cross the atmosphere's top, where the light's slope jumps. It
|
||||
is within 0.5 % of the sum.
|
||||
- **Cost.** Building it takes 1.6 ms on 24 cores and 6.1 ms on 4. It is kept
|
||||
while the Moon stays within it, which is 870 km of the Moon's distance, 3.5
|
||||
hours or more, and while the Sun's angular size stays within 1e-4 of the
|
||||
same. Those frames use the second version of the pass, as for the limb:
|
||||
1.80 ms against 1.45 ms for the lunar eclipse example on the RTX 3060, and
|
||||
4.7 ms more on lavapipe.
|
||||
|
||||
`examples/scenes/lunar-eclipse.toml` shows the eclipse of 2025 March 14 at a
|
||||
0.3 s exposure: copper red, and brightest along the Moon's northern edge,
|
||||
which was farthest from the axis.
|
||||
|
||||
Tests:
|
||||
|
||||
- In `atmosphere/grazing.rs`: straight rays' columns of air, aerosols and
|
||||
ozone against direct integrals (within 1e-5); the bending and the air along
|
||||
the path against rays traced through the atmosphere step by step (within
|
||||
1e-4); with no air and no bending, the target's light against the eclipse
|
||||
model (within 3e-4: the eclipse model draws Earth's edge as a circle on a
|
||||
flat sky, off by 1.7e-4); 64 rows against 512 (2e-5); a point-like Sun
|
||||
dimmed by transmittance and dilution (within 1 %); a sunset that dims
|
||||
every channel steadily, blue first, with the ozone's blue phase; Earth's
|
||||
shadow at the Moon's distance against following 2 million rays back across
|
||||
the sky (within 1e-3, measured 5e-4); the eclipsed Moon's light and color
|
||||
against the 2011 photometry; and the shadow's table against the sum over the
|
||||
Moon's depth (within 6e-3, measured 4.9e-3).
|
||||
- In `tests/gpu/atmosphere.rs`, on the GPU and lavapipe: the Sun behind air
|
||||
12, 20 and 30 km up, pixel by pixel down the image's middle against the
|
||||
same bending and transmittance in f64 (within 7.3e-4); and the Moon behind
|
||||
air 15 km up, where each pixel's light over its transmittance and its
|
||||
shading is constant to 1.2e-3; and the Moon across the umbra's edge, each
|
||||
pixel's light against the sum at its point (within 4.7e-3 on the GPU and
|
||||
3.6e-3 on lavapipe, the table's interpolation).
|
||||
- In the command-line tests: the eclipse of 2025 March 14 is red, and brighter
|
||||
at the Moon's northern edge than at its center and its southern edge.
|
||||
|
||||
Limitations:
|
||||
|
||||
- **One refractive index for every wavelength.** Air's changes by about 2 %
|
||||
across the visible spectrum, which separates the Sun's colors at the limb
|
||||
slightly (the green flash). Not modeled.
|
||||
- **The model's scale height near the ground.** The 8 km scale height bends a
|
||||
ground-skimming ray by 74.8 arcminutes; the real lower atmosphere, whose
|
||||
density falls more slowly, gives about 70.
|
||||
- **No clouds or volcanic aerosols.** In reality clouds block most rays
|
||||
below 10 km or so. At sunset the light that gets through there is already
|
||||
small. In the umbra it is most of the light, so the eclipsed Moon here is as
|
||||
bright as after years of clear air. Real eclipses range over a factor of 100
|
||||
or more, and are darkest after large eruptions.
|
||||
- **No light scattered into the shadow** by the air, which is about 1e-7 of
|
||||
the Sun's.
|
||||
- **Light from the eclipsed Moon onto targets** still uses Earth's hard edge,
|
||||
so it is zero in the umbra.
|
||||
- **A target inside the atmosphere** takes the transmittance straight toward
|
||||
the Sun's center, without bending.
|
||||
|
||||
### Eclipses on Earth and the Moon
|
||||
|
||||
@@ -1606,7 +1780,8 @@ a solar eclipse. For every pixel that sees Earth or the Moon, the shader
|
||||
works out the fraction of the Sun's light that reaches that point on the
|
||||
surface past the other body, with the same overlap of circles and the same
|
||||
limb-darkened disk as for the target, and multiplies the point's sunlight by
|
||||
it, per channel.
|
||||
it, per channel. With Earth's atmosphere, the Moon's points take the light
|
||||
through the air instead, from the table in the previous section.
|
||||
|
||||
The sizes involved:
|
||||
|
||||
@@ -1641,7 +1816,8 @@ the 32-bit rounding at the shadow's edges), and the rest
|
||||
agree to within 1e-4 of the Sun's light (1.2e-6 at most on the GPU, 5e-5 on
|
||||
lavapipe). The command-line tests render two real eclipses from their
|
||||
epochs: the total lunar eclipse of 2025 March 14 at mid-totality, where the
|
||||
whole Moon reads the bias, and the total solar eclipse of 2026 August 12,
|
||||
whole Moon is in the umbra (red through the atmosphere, previous section), and
|
||||
the total solar eclipse of 2026 August 12,
|
||||
where the point of greatest eclipse in NASA's bulletin (65.2 N 25.2 W) is in
|
||||
the umbra (`examples/scenes/lunar-eclipse.toml`, `solar-eclipse.toml`). In
|
||||
the second, the umbra is 220 by 410 km in the image, stretched along the
|
||||
@@ -1659,9 +1835,9 @@ limb-darkened sum (see "The Sun's disk"). On lavapipe, 9.5 ms instead of
|
||||
|
||||
Limitations:
|
||||
|
||||
- **Earth's atmosphere.** It bends sunlight into the umbra, mostly red, so a
|
||||
real eclipsed Moon glows a dim copper color instead of going black, and it
|
||||
makes the umbra about 2 % larger. Neither is modeled yet.
|
||||
- **Earth's atmosphere.** With it, the umbra on the Moon is lit red through
|
||||
the air (previous section). Without it, the umbra is black. The observed
|
||||
umbra is about 2 % larger than the solid Earth's, which is not modeled.
|
||||
- **Smooth shapes.** Earth's shadow is cast by a sphere of its equatorial
|
||||
radius, as for the target's eclipses; its flattening would move the
|
||||
shadow's edge on the Moon by about 20 km. The Moon's mountains and valleys,
|
||||
@@ -1930,6 +2106,5 @@ is a run of the script with `--gaia 16`, at twice the size.
|
||||
- Clouds on Earth
|
||||
- Earth's surface finer than Blue Marble's 460 m: mosaics of tens of meters
|
||||
(Landsat, Sentinel-2) need tiles streamed in as a view needs them
|
||||
- Sunlight through Earth's atmosphere onto targets, and the Sun and Moon seen
|
||||
through it; the atmosphere seen from inside it (a camera below 100 km)
|
||||
- The atmosphere seen from inside it (a camera below 100 km)
|
||||
- Motion blur, rolling shutter, stray light
|
||||
@@ -4,14 +4,18 @@
|
||||
#
|
||||
# resolved render --scene examples/scenes/lunar-eclipse.toml --lighting L1 --preview lunar.png
|
||||
#
|
||||
# At lighting L1 the Moon is inside Earth's shadow and dark. At L0 it is drawn without
|
||||
# Earth's shadow, as a full Moon. Earth's atmosphere bends red light into the shadow
|
||||
# and a real eclipsed Moon glows a dim red; the renderer does not model that yet.
|
||||
# At lighting L1 the Moon is inside Earth's shadow, lit only by the sunlight Earth's
|
||||
# atmosphere bends into it, dim and red, and brightest along its northern edge, farthest
|
||||
# from the shadow's axis. The exposure, 0.3 s, is 3000 times the example camera's, as a
|
||||
# photograph of totality would be. At L0 the Moon is drawn without Earth's shadow, as a
|
||||
# full Moon, thousands of times brighter, so L0 takes its own exposure of 1 ms.
|
||||
|
||||
epoch = "2025-03-14T06:58:44 UTC"
|
||||
|
||||
[camera]
|
||||
focal_length = 0.4
|
||||
exposure = 0.3
|
||||
exposure_l0 = 1e-3
|
||||
|
||||
[observer]
|
||||
position = [-6956600.88, 697838.87, 344565.04]
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# Sunset: Landsat 8 in its 705 km orbit, 1.4 s before the straight line to the
|
||||
# Sun's center touches the ground. Its sunlight crosses the lowest 25 km of the
|
||||
# atmosphere edge on: at lighting L1, 7.2 % of the Sun's red light reaches it,
|
||||
# 4.8 % of its green and 4.4 % of its blue, and it is lit orange-pink. The solar
|
||||
# array faces halfway between the Sun and the camera, an inspector 170 m away
|
||||
# looking 45 degrees across the Sun's direction at Earth's limb, where the
|
||||
# twilight glows orange under blue. At lighting L0, without the atmosphere, 71 %
|
||||
# of the Sun is above Earth's bare edge, and the satellite saturates at this
|
||||
# exposure. Generated by scripts/make-example-scenes.py; the model comes from
|
||||
# scripts/fetch-models.sh.
|
||||
#
|
||||
# resolved render --scene examples/scenes/sunset.toml --lighting L1 --camera C1 --pretty --preview sunset.png
|
||||
|
||||
sun = [149597870700, 0, 0]
|
||||
moon = [0, 0, -3.844e8]
|
||||
|
||||
[camera]
|
||||
exposure = 0.003
|
||||
|
||||
[observer]
|
||||
position = [-3071175.86, 6382776.00, 106.20]
|
||||
look_at = [-3071045.86, 6382750.75, 0.00]
|
||||
up = [-0.433571433, 0.901119200, 0.000000000]
|
||||
|
||||
# At lighting L1, Earth's surface shows July (from scripts/fetch-earth.sh); here it is
|
||||
# on the night side, dark.
|
||||
[appearance]
|
||||
earth_month = 7
|
||||
|
||||
[[target]]
|
||||
model = "../models/nasa/landsat8.glb"
|
||||
scale = 1.0
|
||||
position = [-3071047.1774, 6382746.0333, 1.6152]
|
||||
attitude = [0.108787795, -0.811945207, -0.205585097, -0.535392346]
|
||||
@@ -118,7 +118,8 @@ targets with their models. `examples/scenes/` has commented examples:
|
||||
real eclipses at lighting L1, `full-moon.toml` the Moon's map, `earthrise.toml`
|
||||
and `moonrise.toml` put Earth, the Moon and a spacecraft in one view (from lunar
|
||||
orbit and from geostationary height), `sun-glare.toml` puts the Sun in the frame
|
||||
of a camera whose CCD blooms (camera C1), and the rest use NASA spacecraft models
|
||||
of a camera whose CCD blooms (camera C1), `sunset.toml` lights a spacecraft with
|
||||
the Sun setting behind Earth's atmosphere (lighting L1), and the rest use NASA spacecraft models
|
||||
(Landsat 8, ICESat-2, OSTM/Jason-2, an SSL-1300 and ACE) that
|
||||
`scripts/fetch-models.sh` downloads into `examples/models/nasa/`. That script needs Node (for `npx`),
|
||||
because most NASA models are Draco-compressed and are decoded on the way in.
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
two scenes with Earth, the Moon and a spacecraft together (below):
|
||||
examples/scenes/earthrise.toml from lunar orbit and examples/scenes/moonrise.toml
|
||||
from geostationary height, examples/scenes/sun-glare.toml with the Sun in the frame
|
||||
of a camera whose CCD blooms (camera tier C1), and scenes for the stars of lighting
|
||||
of a camera whose CCD blooms (camera tier C1), examples/scenes/sunset.toml with a
|
||||
spacecraft lit by the Sun setting behind Earth's atmosphere, and scenes for the stars of lighting
|
||||
tier L1: a star field, examples/scenes/stars-pleiades.toml, and a telescope on one
|
||||
geostationary satellite imaging another along the arc,
|
||||
examples/scenes/geo-telescope-*.toml.
|
||||
@@ -531,6 +532,77 @@ attitude = {fmt(quaternion(attitude), 9)}
|
||||
"""
|
||||
|
||||
|
||||
# Sunset: Landsat 8 in its 705 km orbit seconds before it passes into Earth's shadow,
|
||||
# lit by sunlight that crosses the atmosphere edge on, seen from an inspector looking
|
||||
# across the Sun's direction at the limb, where the twilight glows.
|
||||
SUNSET_PAST = -0.0015 # rad of orbit before the line to the Sun's center grazes the ground
|
||||
SUNSET_AZIMUTH = 45.0 # degrees, the boresight's azimuth from the Sun's
|
||||
SUNSET_EXPOSURE = 3e-3 # s, for the satellite in a few percent of sunlight
|
||||
|
||||
|
||||
def sunset():
|
||||
file, scale, center, radius, orbit = "landsat8.glb", 1.0, MODELS[0][3], MODELS[0][4], MODELS[0][5]
|
||||
# The Sun along +X; the satellite in the XY plane, SUNSET_PAST short of where the
|
||||
# straight line from it to the Sun's center touches the ground.
|
||||
sun = [1.0, 0.0, 0.0]
|
||||
alpha = math.pi - math.asin(RE / orbit) + SUNSET_PAST
|
||||
look = [orbit * math.cos(alpha), orbit * math.sin(alpha), 0.0]
|
||||
up = norm(look)
|
||||
toward_sun = norm([sun[i] - dot(sun, up) * up[i] for i in range(3)])
|
||||
a = math.radians(SUNSET_AZIMUTH)
|
||||
across = [math.cos(a) * toward_sun[i] + math.sin(a) * x for i, x in enumerate(cross(up, toward_sun))]
|
||||
# Earth's limb 2 degrees above the image center, as in the model scenes.
|
||||
theta = math.asin(RE / orbit) - math.radians(2.0)
|
||||
boresight = [-math.cos(theta) * up[i] + math.sin(theta) * across[i] for i in range(3)]
|
||||
# The solar array's normal (body +X) halfway between the Sun and the camera, so it
|
||||
# is lit and seen at the same slant, with body +Z across the view.
|
||||
half = norm([sun[i] - boresight[i] for i in range(3)])
|
||||
attitude = triad([1, 0, 0], [0, 0, 1], half, cross(up, half))
|
||||
distance = radius / math.tan(math.radians(2.8))
|
||||
camera = [look[i] - distance * boresight[i] for i in range(3)]
|
||||
offset = mul(attitude, center)
|
||||
position = [look[i] - offset[i] for i in range(3)]
|
||||
fmt = lambda v, d=2: "[" + ", ".join(f"{x:.{d}f}" for x in v) + "]"
|
||||
header = textwrap.fill(
|
||||
"Sunset: Landsat 8 in its 705 km orbit, 1.4 s before the straight line to the "
|
||||
"Sun's center touches the ground. Its sunlight crosses the lowest 25 km of the "
|
||||
"atmosphere edge on: at lighting L1, 7.2 % of the Sun's red light reaches it, "
|
||||
"4.8 % of its green and 4.4 % of its blue, and it is lit orange-pink. The solar "
|
||||
f"array faces halfway between the Sun and the camera, an inspector {distance:.0f} m away "
|
||||
f"looking {SUNSET_AZIMUTH:g} degrees across the Sun's direction at Earth's limb, where "
|
||||
"the twilight glows orange under blue. At lighting L0, without the atmosphere, "
|
||||
"71 % of the Sun is above Earth's bare edge, and the satellite saturates at this "
|
||||
"exposure. Generated by scripts/make-example-scenes.py; the model comes from "
|
||||
"scripts/fetch-models.sh.",
|
||||
width=80, initial_indent="# ", subsequent_indent="# ", break_on_hyphens=False)
|
||||
return f"""{header}
|
||||
#
|
||||
# resolved render --scene examples/scenes/sunset.toml --lighting L1 --camera C1 --pretty --preview sunset.png
|
||||
|
||||
sun = {fmt([AU * x for x in sun], 0)}
|
||||
moon = [0, 0, -3.844e8]
|
||||
|
||||
[camera]
|
||||
exposure = {SUNSET_EXPOSURE}
|
||||
|
||||
[observer]
|
||||
position = {fmt(camera)}
|
||||
look_at = {fmt(look)}
|
||||
up = {fmt(up, 9)}
|
||||
|
||||
# At lighting L1, Earth's surface shows July (from scripts/fetch-earth.sh); here it is
|
||||
# on the night side, dark.
|
||||
[appearance]
|
||||
earth_month = 7
|
||||
|
||||
[[target]]
|
||||
model = "../models/nasa/{file}"
|
||||
scale = {scale}
|
||||
position = {fmt(position, 4)}
|
||||
attitude = {fmt(quaternion(attitude), 9)}
|
||||
"""
|
||||
|
||||
|
||||
# For the long-exposure cameras, which leave the example camera's 1 in 10,000 hot.
|
||||
CALIBRATED = """# Calibrated: hot pixels mapped out, all but 1 value in a million.
|
||||
hot_pixels = [1e-6, 50]
|
||||
@@ -662,6 +734,9 @@ print(f"wrote {path}")
|
||||
path = out / "sun-glare.toml"
|
||||
path.write_text(sun_glare())
|
||||
print(f"wrote {path}")
|
||||
path = out / "sunset.toml"
|
||||
path.write_text(sunset())
|
||||
print(f"wrote {path}")
|
||||
path = out / "stars-pleiades.toml"
|
||||
path.write_text(stars_pleiades())
|
||||
print(f"wrote {path}")
|
||||
|
||||
Reference in new issue
Block a user