diff --git a/crates/resolved-cli/src/main.rs b/crates/resolved-cli/src/main.rs index a147f13..2e00fbe 100644 --- a/crates/resolved-cli/src/main.rs +++ b/crates/resolved-cli/src/main.rs @@ -57,7 +57,8 @@ struct RenderArgs { #[arg(long, default_value = "L0")] lighting: LightingTier, /// Camera fidelity tier: C0, or C1 (adds pixels that collect light over their area, - /// for graded edges and continuous thin parts, lens distortion and lens blur). + /// for graded edges and continuous thin parts, lens distortion, lens blur, and the + /// Sun, Moon and Earth where light time and aberration make them appear). #[arg(long, default_value = "C0")] camera: CameraTier, /// From camera C1: render each pixel as N x N finer ones (1 to 4; default 2), which diff --git a/crates/resolved-cli/src/scene_file.rs b/crates/resolved-cli/src/scene_file.rs index cdf2ca5..9727099 100644 --- a/crates/resolved-cli/src/scene_file.rs +++ b/crates/resolved-cli/src/scene_file.rs @@ -9,6 +9,7 @@ use std::path::{Path, PathBuf}; use resolved::bodies::Appearance; use resolved::nalgebra::{Quaternion, UnitQuaternion, Vector2, Vector3}; +use resolved::scene::Velocities; use resolved::{ BrownConrady, CameraConfig, LoadOptions, Material, Mesh, ModelId, Pose, Psf, Renderer, Scene, Spectrum, Target, @@ -37,6 +38,12 @@ pub struct SceneFile { /// Moon-fixed to inertial rotation, if not given by `epoch`; by default the Moon /// faces Earth (`Scene::moon_facing_earth`, within about 10 degrees). pub moon_attitude: Option<[f64; 4]>, + /// Earth's velocity relative to the Sun in m/s, if not given by `epoch`, for + /// apparent positions from camera C1; zero by default. + pub earth_velocity: Option<[f64; 3]>, + /// The Moon's velocity relative to Earth in m/s, if not given by `epoch`; zero by + /// default. + pub moon_velocity: Option<[f64; 3]>, /// Earth's and the Moon's albedo. #[serde(default)] pub appearance: AppearanceFile, @@ -84,6 +91,9 @@ pub struct ObserverFile { pub attitude: Option<[f64; 4]>, pub look_at: Option<[f64; 3]>, pub up: Option<[f64; 3]>, + /// Velocity relative to Earth's center in m/s, for apparent positions from camera + /// C1; zero by default. + pub velocity: Option<[f64; 3]>, } /// Flat albedos for Earth and the Moon. @@ -266,7 +276,11 @@ impl SceneFile { pose: Pose::new(Vector3::from(t.position), attitude), }); } - self.bodies(targets, kernels) + let mut scene = self.bodies(targets, kernels)?; + if let Some(v) = self.observer.velocity { + scene.velocities.observer = Vector3::from(v); + } + Ok(scene) } #[cfg(feature = "astrokit")] @@ -276,8 +290,12 @@ impl SceneFile { || self.moon.is_some() || self.earth_attitude.is_some() || self.moon_attitude.is_some() + || self.earth_velocity.is_some() + || self.moon_velocity.is_some() { - return Err("give either epoch or sun, moon and the attitudes, not both".into()); + return Err( + "give either epoch or sun, moon, the attitudes and velocities, not both".into(), + ); } let epoch: hifitime::Epoch = epoch.parse().map_err(|e| format!("epoch {epoch:?}: {e}"))?; @@ -314,6 +332,11 @@ impl SceneFile { .transpose()? .unwrap_or_else(|| Scene::moon_facing_earth(Vector3::from(moon))), targets, + velocities: Velocities { + observer: Vector3::zeros(), + earth: self.earth_velocity.map(Vector3::from).unwrap_or_default(), + moon: self.moon_velocity.map(Vector3::from).unwrap_or_default(), + }, }) } } @@ -375,6 +398,18 @@ mod tests { look_at = [7e6, 0, 0] "#; + #[test] + fn velocities_apply() { + let file: SceneFile = toml::from_str(&format!( + "earth_velocity = [1.0, 2.0, 3.0]\nmoon_velocity = [4.0, 5.0, 6.0]\n{MINIMAL}velocity = [7.0, 8.0, 9.0]\n" + )) + .unwrap(); + let scene = file.explicit_bodies(Vec::new()).unwrap(); + assert_eq!(scene.velocities.earth, Vector3::new(1.0, 2.0, 3.0)); + assert_eq!(scene.velocities.moon, Vector3::new(4.0, 5.0, 6.0)); + assert_eq!(file.observer.velocity, Some([7.0, 8.0, 9.0])); + } + #[test] fn minimal_file_uses_the_example_camera() { let file: SceneFile = toml::from_str(MINIMAL).unwrap(); diff --git a/crates/resolved/benches/render.rs b/crates/resolved/benches/render.rs index 62423be..ac25f29 100644 --- a/crates/resolved/benches/render.rs +++ b/crates/resolved/benches/render.rs @@ -104,6 +104,7 @@ fn limb_view(mesh: &Mesh, model: resolved::ModelId) -> (Scene, Pose) { model, pose: Pose::new(look - attitude * center, attitude), }], + velocities: Default::default(), }; (scene, observer) } diff --git a/crates/resolved/src/ephemeris.rs b/crates/resolved/src/ephemeris.rs index deb550c..3fc3f87 100644 --- a/crates/resolved/src/ephemeris.rs +++ b/crates/resolved/src/ephemeris.rs @@ -3,7 +3,9 @@ //! Available with the `astrokit` feature. Sun and Moon positions come from the JPL //! DE440s ephemeris and Earth's orientation from the ITRF93 Earth orientation kernel, //! both through astrokit's almanac. Positions are geometric: where the bodies are at -//! the epoch, not where light-time and aberration make them appear. +//! the epoch. [`Ephemeris::scene`] also fills in Earth's and the Moon's velocities, from +//! which camera tier C1 and up draws where light time and aberration make them appear; +//! set the observer's own velocity in [`Velocities::observer`]. //! //! astrokit's inertial frame is J2000 (EME2000); this crate's is GCRF. The two differ //! by a fixed frame bias of about 0.02 arcseconds, 0.1 microradians, far below a pixel @@ -17,7 +19,7 @@ use anise::almanac::Almanac; use hifitime::Epoch; use nalgebra::{Matrix3, Rotation3, UnitQuaternion, Vector3}; -use crate::scene::{Pose, Scene, Target}; +use crate::scene::{Pose, Scene, Target, Velocities}; use crate::Error; /// A source of Sun and Moon positions and Earth's orientation. @@ -55,12 +57,22 @@ impl Ephemeris { /// The Sun's position relative to Earth's center at `epoch`, in meters. pub fn sun(&self, epoch: Epoch) -> Result, Error> { - self.position(Body::SUN, epoch) + Ok(self.state(Body::SUN, Body::EARTH, epoch)?.0) } /// The Moon's position relative to Earth's center at `epoch`, in meters. pub fn moon(&self, epoch: Epoch) -> Result, Error> { - self.position(Body::MOON, epoch) + Ok(self.state(Body::MOON, Body::EARTH, epoch)?.0) + } + + /// Earth's and the Moon's velocities at `epoch` ([`Velocities::earth`] and + /// [`Velocities::moon`]), with the observer's left at zero. + pub fn velocities(&self, epoch: Epoch) -> Result { + Ok(Velocities { + observer: Vector3::zeros(), + earth: self.state(Body::EARTH, Body::SUN, epoch)?.1, + moon: self.state(Body::MOON, Body::EARTH, epoch)?.1, + }) } /// The rotation from Earth-fixed (ITRF93) to inertial axes at `epoch`, including @@ -82,7 +94,9 @@ impl Ephemeris { Ok(rotation(dcm)) } - /// The scene at `epoch` with `targets` in it. + /// The scene at `epoch` with `targets` in it, with Earth's and the Moon's + /// velocities. Set `velocities.observer` to the observer's velocity relative to + /// Earth for its apparent positions. pub fn scene(&self, epoch: Epoch, targets: Vec) -> Result { Ok(Scene { earth_attitude: self.earth_attitude(epoch)?, @@ -90,20 +104,31 @@ impl Ephemeris { moon: self.moon(epoch)?, moon_attitude: self.moon_attitude(epoch)?, targets, + velocities: self.velocities(epoch)?, }) } - fn position(&self, body: Body, epoch: Epoch) -> Result, Error> { + /// The position (m) and velocity (m/s) of `body` relative to `center`, in J2000 + /// axes. + fn state( + &self, + body: Body, + center: Body, + epoch: Epoch, + ) -> Result<(Vector3, Vector3), Error> { let state = self .almanac .translate_geometric( Frame::inertial(body).anise(), - Frame::EARTH_J2000.anise(), + Frame::inertial(center).anise(), epoch, ) .map_err(|e| Error::Ephemeris(e.to_string()))?; // ANISE works in kilometers. - Ok(Vector3::from(state.radius_km) * 1e3) + Ok(( + Vector3::from(state.radius_km) * 1e3, + Vector3::from(state.velocity_km_s) * 1e3, + )) } } @@ -130,6 +155,7 @@ mod tests { use crate::bodies::{EARTH_EQUATORIAL_RADIUS, EARTH_POLAR_RADIUS, MOON_RADIUS}; use crate::eclipse::sun_visible_fraction; use crate::radiometry::{AU, SUN_RADIUS}; + use crate::scene::aberrate; use approx::assert_relative_eq; use std::path::Path; @@ -228,6 +254,90 @@ mod tests { } } + /// The angle between two directions, in arcseconds. + fn arcsec(a: &Vector3, b: &Vector3) -> f64 { + a.normalize() + .cross(&b.normalize()) + .norm() + .asin() + .to_degrees() + * 3600.0 + } + + #[test] + fn apparent_sun_and_moon_match_horizons() { + // JPL Horizons (DE441) vectors from Earth's center in ICRF, km, with no + // corrections and with light time and stellar aberration (LT+S), at TDB epochs. + // Through DE440s and the first-order correction, measured under 0.0001 + // arcseconds for the Sun and 0.0017 for the Moon, which are 20.2 and 0.7 + // arcseconds from their geometric directions. + let eph = ephemeris(); + let tdb = + |y, mo, d, h| Epoch::from_gregorian_hms(y, mo, d, h, 0, 0, hifitime::TimeScale::TDB); + for (epoch, sun, moon) in [ + ( + tdb(2025, 7, 16, 0), + [ + -6.033039727893143e7, + 1.280502045018742e8, + 5.550792746971884e7, + ], + [ + 3.738975068190612e5, + -1.962365106985182e4, + -3.672382880794004e3, + ], + ), + ( + tdb(2026, 6, 8, 18), + [ + 3.264137849984731e7, + 1.360583444251143e8, + 5.897875801186711e7, + ], + [ + 3.794368568118024e5, + -5.583123840038168e4, + -1.214325354332336e4, + ], + ), + ] { + let scene = eph.scene(epoch, Vec::new()).unwrap(); + let center = Vector3::zeros(); + let sun = Vector3::from(sun); + let moon = Vector3::from(moon); + assert!(arcsec(&scene.apparent_sun(¢er), &sun) < 0.01); + assert!(arcsec(&scene.apparent_moon(¢er), &moon) < 0.01); + // The corrections are resolved: the geometric Sun is 20 arcseconds off. + assert!(arcsec(&scene.sun, &sun) > 19.0); + } + } + + #[test] + fn annual_aberration_traces_its_ellipse() { + // Over a year Earth's motion moves a star by 20.5 arcseconds (the constant of + // aberration, varying 1.7 % with Earth's speed), around a circle for a star at + // the ecliptic's pole and back and forth along the ecliptic for a star on it. + let eph = ephemeris(); + let obliquity = 23.4393f64.to_radians(); + let pole = Vector3::new(0.0, -obliquity.sin(), obliquity.cos()); + let on_ecliptic = Vector3::x(); + let mut along = (f64::MAX, 0.0f64); + for month in 0..12 { + let epoch = utc(2026, 1, 15, 0, 0, 0) + hifitime::Unit::Day * (30.44 * month as f64); + let beta = eph.velocities(epoch).unwrap().earth / crate::scene::SPEED_OF_LIGHT; + let moved = arcsec(&aberrate(&pole, &beta), &pole); + assert!((20.1..20.9).contains(&moved), "{epoch}: {moved}"); + // On the ecliptic, the shift stays in the ecliptic plane. + let shift = aberrate(&on_ecliptic, &beta) - on_ecliptic; + assert!(shift.dot(&pole).abs() * 206_265.0 < 0.01); + let size = shift.norm() * 206_265.0; + along = (along.0.min(size), along.1.max(size)); + } + // Sweeping from near zero (moving toward or away from the star) to 20.5. + assert!(along.0 < 6.0 && along.1 > 20.0, "{along:?}"); + } + #[test] fn eclipse_model_agrees_with_astrokit() { // Same model, two implementations: sweep through Earth's penumbra. diff --git a/crates/resolved/src/fidelity.rs b/crates/resolved/src/fidelity.rs index b89c3b5..483f10f 100644 --- a/crates/resolved/src/fidelity.rs +++ b/crates/resolved/src/fidelity.rs @@ -42,7 +42,8 @@ pub enum CameraTier { /// features thinner than a pixel stay continuous, the lens's distortion (the camera's /// [`BrownConrady`](crate::BrownConrady)) and its blur (the camera's /// [`Psf`](crate::Psf)). The scene is rendered finer than the pixels, 2 x 2 by - /// default, distorted, blurred, then averaged into pixels. Target meshes are covered at several + /// default, distorted, blurred, then averaged into pixels. Earth, the Moon and the + /// Sun are drawn at their apparent positions ([`Scene::velocities`](crate::Scene::velocities)). Target meshes are covered at several /// points per rendered pixel, and rendered pixels on the edge of Earth, the Moon or /// the Sun cast a grid of rays, 4 x 4 and more for bodies only a few pixels across. /// More of the camera joins this tier as it is modeled. diff --git a/crates/resolved/src/gpu/bodies.rs b/crates/resolved/src/gpu/bodies.rs index f69ea18..f6accf1 100644 --- a/crates/resolved/src/gpu/bodies.rs +++ b/crates/resolved/src/gpu/bodies.rs @@ -178,17 +178,23 @@ pub struct BodiesUniforms { moon_irradiance: [f32; 4], moon_color: [f32; 4], moon_params: [f32; 4], + // Per body, Earth, Moon, Sun: the camera's velocity relative to it over c, in the + // camera frame. + beta: [[f32; 4]; 3], } impl BodiesUniforms { /// The uniforms for `scene` seen from `camera`. Geometry is differenced in f64 - /// before the cast, as in [`Scene::relative_to`]. + /// 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 + /// ([`Scene::velocities`]); their lighting stays geometric. pub fn new( scene: &Scene, camera: &Pose, intrinsics: &Intrinsics, near: f64, radiometry: &BodyRadiometry, + apparent: bool, ) -> Self { let rel = scene.relative_to(camera); let to_camera = camera.attitude.inverse(); @@ -239,6 +245,14 @@ impl BodiesUniforms { moon_irradiance: scaled(radiometry.earth_irradiance_1au, moon_distance), moon_color: radiometry.moon_color, moon_params: [f32::from(u8::from(radiometry.moon_textured)), 0.0, 0.0, 0.0], + beta: scene.velocities.relative().map(|b| { + let b = if apparent { + to_camera * b + } else { + Vector3::zeros() + }; + [b.x as f32, b.y as f32, b.z as f32, 0.0] + }), } } } diff --git a/crates/resolved/src/lib.rs b/crates/resolved/src/lib.rs index 596141f..ddf04c7 100644 --- a/crates/resolved/src/lib.rs +++ b/crates/resolved/src/lib.rs @@ -46,5 +46,5 @@ pub use moon::MoonTexture; pub use preview::{Look, Preview}; pub use radiometry::Radiometry; pub use render::{Frame, FrameView, Pending, Renderer, RendererOptions}; -pub use scene::{CameraRelative, Placement, Pose, Scene, Target}; +pub use scene::{CameraRelative, Placement, Pose, Scene, Target, Velocities}; pub use spectrum::Spectrum; diff --git a/crates/resolved/src/render.rs b/crates/resolved/src/render.rs index 9f7d74d..bfcc589 100644 --- a/crates/resolved/src/render.rs +++ b/crates/resolved/src/render.rs @@ -431,7 +431,14 @@ impl Renderer { (scene.moon, MOON_RADIUS), ]; - let bodies = BodiesUniforms::new(scene, &camera, &intrinsics, near, &self.body_radiometry); + let bodies = BodiesUniforms::new( + scene, + &camera, + &intrinsics, + near, + &self.body_radiometry, + self.fidelity.camera >= CameraTier::C1, + ); // Shadow maps, from lighting L1: one per target, with the targets that can // shadow it drawn in. let bounded: Vec = scene diff --git a/crates/resolved/src/scene.rs b/crates/resolved/src/scene.rs index a3b4ffe..907239b 100644 --- a/crates/resolved/src/scene.rs +++ b/crates/resolved/src/scene.rs @@ -73,6 +73,50 @@ pub struct Target { pub pose: Pose, } +/// The speed of light, in m/s. +pub const SPEED_OF_LIGHT: f64 = 299_792_458.0; + +/// Velocities that make where things appear differ from where they are, in m/s, in the +/// inertial frame. Camera tier C1 and up draws Earth, the Moon and the Sun at their +/// apparent positions from these; all zero, it draws them where they are. +/// +/// Light from a body left it a time `distance / c` ago, and the observer's motion tilts +/// incoming light toward its direction of motion (aberration). To first order the two +/// together move the body's direction by the observer's velocity relative to the body, +/// over c, perpendicular to the line of sight ([`aberrate`]). That is why only relative +/// velocities are needed: for the Moon, Earth's 30 km/s around the Sun cancels. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct Velocities { + /// The observer's velocity relative to Earth's center. + pub observer: Vector3, + /// Earth's velocity relative to the Sun, about 30 km/s: 20 arcseconds of aberration + /// on the Sun. (Relative to the solar system's barycenter it differs by about + /// 12 m/s, 0.01 arcseconds.) + pub earth: Vector3, + /// The Moon's velocity relative to Earth's center, about 1 km/s. + pub moon: Vector3, +} + +impl Velocities { + /// The observer's velocity relative to Earth, the Moon and the Sun, over c. + pub fn relative(&self) -> [Vector3; 3] { + [ + self.observer, + self.observer - self.moon, + self.observer + self.earth, + ] + .map(|v| v / SPEED_OF_LIGHT) + } +} + +/// Where a source in unit direction `direction` appears to an observer moving at `beta` +/// (its velocity relative to the source, over c): tilted toward `beta` by its part +/// perpendicular to the line of sight, to first order in `beta`. The second order is +/// about |beta|^2, 1e-8 rad for 30 km/s. +pub fn aberrate(direction: &Vector3, beta: &Vector3) -> Vector3 { + (direction + beta - beta.dot(direction) * direction).normalize() +} + /// Everything the renderer needs to know about the world at one instant. #[derive(Clone, Debug, PartialEq)] pub struct Scene { @@ -90,6 +134,8 @@ pub struct Scene { pub moon_attitude: UnitQuaternion, /// Spacecraft that may appear in the image. pub targets: Vec, + /// The velocities that set apparent positions; zero for geometric ones. + pub velocities: Velocities, } impl Scene { @@ -115,6 +161,23 @@ impl Scene { UnitQuaternion::from_rotation_matrix(&rotation) } + /// The unit direction in which an observer at `observer` sees the Sun's center: + /// its geometric direction, aberrated by [`Scene::velocities`]. + pub fn apparent_sun(&self, observer: &Vector3) -> Vector3 { + aberrate( + &(self.sun - observer).normalize(), + &self.velocities.relative()[2], + ) + } + + /// The unit direction in which an observer at `observer` sees the Moon's center. + pub fn apparent_moon(&self, observer: &Vector3) -> Vector3 { + aberrate( + &(self.moon - observer).normalize(), + &self.velocities.relative()[1], + ) + } + /// This scene as seen from a camera at `camera`. pub fn relative_to(&self, camera: &Pose) -> CameraRelative { let camera_from_inertial = camera.attitude.inverse(); @@ -197,6 +260,7 @@ mod tests { model: ModelId(0), pose: target, }], + velocities: Default::default(), } } diff --git a/crates/resolved/src/shaders/bodies.wgsl b/crates/resolved/src/shaders/bodies.wgsl index e93c95c..0dfc647 100644 --- a/crates/resolved/src/shaders/bodies.wgsl +++ b/crates/resolved/src/shaders/bodies.wgsl @@ -52,6 +52,9 @@ struct Uniforms { moon_color: vec4, // x: 1 if the Moon is drawn from its map. moon_params: vec4, + // Per body, Earth, Moon, Sun: the camera's velocity relative to it over c, in the + // camera frame; zero below camera C1. + beta: array, 3>, } @group(0) @binding(0) var u: Uniforms; @@ -183,20 +186,35 @@ fn sun_radiance(d: vec3) -> vec4 { return u.sun_radiance * disk_profile(sqrt(max((r - miss) * (r + miss), 0.0)) / r); } +// The direction, in the camera frame, that light arriving along `d` left body `b` +// (0 Earth, 1 Moon, 2 Sun) in: `d` with light time and aberration taken back out, to +// first order. Bodies are hit along these, so they appear where they are seen while +// their lighting stays geometric. Not normalized: its length differs from 1 by about +// |beta|^2, 1e-8. Below camera C1 (AREA false) it is `d` itself, fixed when the +// shader is compiled: `d` with zeros added, or a branch, changed the compiled +// arithmetic enough to move 1 DN at a few hundred pixels on the RTX 3060. +fn seen(d: vec3, b: u32) -> vec3 { + if !AREA { + return d; + } + let beta = u.beta[b].xyz; + return d - beta + dot(beta, 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) -> array, 2> { let stretch = vec3(1.0, 1.0, u.earth_shape.x); let center = transpose(u.earth_axes) * u.earth_center.xyz * stretch; - return array, 2>(center, normalize(transpose(u.earth_axes) * d * stretch)); + return array, 2>(center, normalize(transpose(u.earth_axes) * seen(d, 0u) * stretch)); } // The distance along `d` to the nearest body, or FAR. fn nearest(d: vec3) -> f32 { let to_cam = transpose(u.earth_axes) * (-u.earth_center.xyz); - let te = ellipsoid_hit(to_cam, transpose(u.earth_axes) * d, u.earth_center.w, u.earth_shape.x); - let tm = sphere_hit(-u.moon_center.xyz, d, u.moon_center.w); - let ts = sphere_hit(-u.sun_center.xyz, d, u.sun_center.w); + 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); var best = FAR; if te > 0.0 { best = te; @@ -218,29 +236,32 @@ struct Hit { // The nearest body along `d`, one ray of a grid `spacing` radians apart. fn trace(d: vec3, spacing: f32) -> Hit { + let de = seen(d, 0u); + let dm = seen(d, 1u); + let ds = seen(d, 2u); let to_cam = transpose(u.earth_axes) * (-u.earth_center.xyz); - let dir = transpose(u.earth_axes) * d; + 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, d, u.moon_center.w); - let ts = sphere_hit(-u.sun_center.xyz, d, u.sun_center.w); + 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); var best = FAR; var radiance = vec4(0.0); if te > 0.0 && te < best { best = te; let n_fixed = ellipsoid_normal(to_cam + te * dir, u.earth_shape.x); - let cos_i = abs(dot(u.earth_axes * n_fixed, d)); + let cos_i = abs(dot(u.earth_axes * n_fixed, de)); let at = map_lookup_at(n_fixed, te, cos_i, spacing, u.earth_center.w); - radiance = earth_radiance(te * d, n_fixed, at); + radiance = earth_radiance(te * de, n_fixed, at); } if tm > 0.0 && tm < best { best = tm; - let n = normalize(tm * d - u.moon_center.xyz); - let at = map_lookup_at(transpose(u.moon_axes) * n, tm, abs(dot(n, d)), spacing, u.moon_center.w); - radiance = moon_radiance(tm * d, d, n, at); + 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); } if ts > 0.0 && ts < best { best = ts; - radiance = sun_radiance(d); + radiance = sun_radiance(ds); } return Hit(radiance, best); } @@ -277,12 +298,24 @@ fn nearest_sample(offset: vec2) -> u32 { } // For a sphere of radius `r` centered at `c`, seen along the unit ray `d`: x, the angle -// from the ray to the sphere's limb, negative inside its disk; y, its angular radius. +// from the ray to the sphere's limb, negative inside its disk, accurate near the limb; +// y, its angular radius. +// +// Near the limb that angle is the ray's miss distance from the center less the radius, +// over the distance to the limb. Square roots only: Vulkan lets atan2 and asin be off +// by about 3e-4 rad near 1 rad, which on lavapipe missed limb pixels of a camera with +// 5.5 urad pixels and left them a single ray. fn limb(c: vec3, r: f32, d: vec3) -> vec2 { + let distance = length(c); + let alpha = asin(min(r / distance, 1.0)); let b = dot(c, d); - let theta = atan2(length(c - b * d), b); - let alpha = asin(min(r / length(c), 1.0)); - return vec2(theta - alpha, alpha); + if b <= 0.0 { + // Behind the camera, where no limb is. + return vec2(1.0, alpha); + } + let miss = length(c - b * d); + let tangent = sqrt(max((distance - r) * (distance + r), 1e-30)); + return vec2((miss - r) / tangent, alpha); } // Rays per side of the grid a pixel along `d` casts over its area: 1 if no body's edge @@ -295,8 +328,8 @@ fn grid_size(d: vec3, pixel: f32) -> u32 { let earth = earth_stretched(d); var edges = array, 3>( limb(earth[0], u.earth_center.w, earth[1]), - limb(u.moon_center.xyz, u.moon_center.w, d), - limb(u.sun_center.xyz, u.sun_center.w, d), + limb(u.moon_center.xyz, u.moon_center.w, seen(d, 1u)), + limb(u.sun_center.xyz, u.sun_center.w, seen(d, 2u)), ); var n = 1u; for (var i = 0; i < 3; i++) { @@ -316,9 +349,14 @@ fn fs_main(@builtin(position) pos: vec4) -> Out { var best = FAR; var radiance = vec4(0.0); + // Each body along its own ray (`seen`). + let de = seen(d, 0u); + let dm = seen(d, 1u); + let ds = seen(d, 2u); + // Earth, in Earth-fixed axes. let to_cam = transpose(u.earth_axes) * (-u.earth_center.xyz); - let dir = transpose(u.earth_axes) * d; + let dir = transpose(u.earth_axes) * de; let te = ellipsoid_hit(to_cam, dir, u.earth_center.w, u.earth_shape.x); // Computed for every pixel (misses use the camera's position and ignore the result) // because texture gradients may only be taken in uniform control flow. @@ -326,22 +364,22 @@ fn fs_main(@builtin(position) pos: vec4) -> Out { let earth_at = map_lookup(n_fixed); if te > 0.0 && te < best { best = te; - radiance = earth_radiance(te * d, n_fixed, earth_at); + radiance = earth_radiance(te * de, n_fixed, earth_at); } - let tm = sphere_hit(-u.moon_center.xyz, d, u.moon_center.w); + let tm = sphere_hit(-u.moon_center.xyz, dm, u.moon_center.w); // 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) * d - u.moon_center.xyz); + let moon_n = normalize(max(tm, 0.0) * dm - 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 * d, d, moon_n, moon_at); + radiance = moon_radiance(tm * dm, dm, moon_n, moon_at); } - let ts = sphere_hit(-u.sun_center.xyz, d, u.sun_center.w); + let ts = sphere_hit(-u.sun_center.xyz, ds, u.sun_center.w); if ts > 0.0 && ts < best { best = ts; - radiance = sun_radiance(d); + radiance = sun_radiance(ds); } let body_depth = select(0.0, u.params.x / (best * d.z), best < FAR); diff --git a/crates/resolved/tests/gpu/analytic.rs b/crates/resolved/tests/gpu/analytic.rs index 5e07eea..d5f6ae2 100644 --- a/crates/resolved/tests/gpu/analytic.rs +++ b/crates/resolved/tests/gpu/analytic.rs @@ -37,6 +37,7 @@ fn scene(sun: Vector3, targets: Vec) -> Scene { moon: MOON, moon_attitude: UnitQuaternion::identity(), targets, + velocities: Default::default(), } } diff --git a/crates/resolved/tests/gpu/apparent.rs b/crates/resolved/tests/gpu/apparent.rs new file mode 100644 index 0000000..05ce0fe --- /dev/null +++ b/crates/resolved/tests/gpu/apparent.rs @@ -0,0 +1,165 @@ +//! Camera tier C1's apparent positions: the Moon and Earth's limb drawn where light time +//! and aberration put them, on the GPU and on the CPU (lavapipe). + +use crate::common; + +use resolved::bodies::EARTH_EQUATORIAL_RADIUS; +use resolved::gpu::AdapterChoice; +use resolved::nalgebra::{UnitQuaternion, Vector2, Vector3}; +use resolved::radiometry::AU; +use resolved::scene::{aberrate, Velocities, SPEED_OF_LIGHT}; +use resolved::{CameraConfig, CameraTier, Fidelity, Frame, Pose, Renderer, RendererOptions, Scene}; + +const ADAPTERS: [AdapterChoice; 2] = [AdapterChoice::Gpu, AdapterChoice::Cpu]; + +/// A narrow camera: 256 x 256 pixels of 5.5 urad, so 7.6 km/s of aberration (25 urad) +/// is 4.6 px. +fn narrow() -> CameraConfig { + CameraConfig { + width: 256, + height: 256, + focal_length: 1.0, + ..CameraConfig::example() + } +} + +fn renderer(adapter: &AdapterChoice, tier: CameraTier) -> Renderer { + let options = RendererOptions { + adapter: adapter.clone(), + fidelity: Fidelity { + camera: tier, + ..Default::default() + }, + read_radiance: true, + ..Default::default() + }; + Renderer::new(narrow(), options).unwrap() +} + +fn render(renderer: &mut Renderer, scene: &Scene, camera: &Pose) -> Vec { + let frame: Frame = renderer.submit(scene, camera).unwrap().wait().unwrap(); + frame + .radiance + .unwrap() + .iter() + .map(|p| p[1] as f64) + .collect() +} + +#[test] +fn the_moon_appears_where_it_is_seen() { + // An observer moving 7.6 km/s across its line of sight to a full Moon, here 100 + // times farther than the real one, so 8 px in radius. At C1 the centroid of its + // light lands on the aberrated direction, 4.6 px from the geometric one, to 0.05 + // px; at C0 on the geometric one. + let _devices = common::device_lock(); + let observer = Pose::looking_at( + Vector3::new(7.0e6, 0.0, 0.0), + Vector3::new(7.0e6, 3.844e10, 0.0), + Vector3::z(), + ) + .unwrap(); + let moon = Vector3::new(7.0e6, 3.844e10, 0.0); + let toward = (moon - observer.position).normalize(); + let scene = Scene { + earth_attitude: UnitQuaternion::identity(), + sun: observer.position - toward * AU, + moon, + moon_attitude: UnitQuaternion::identity(), + targets: Vec::new(), + velocities: Velocities { + observer: Vector3::new(0.0, 0.0, 7.6e3), + ..Default::default() + }, + }; + let k = narrow().intrinsics(); + let in_camera = |d: Vector3| k.project(&(observer.attitude.inverse() * d)).unwrap(); + let geometric = in_camera(toward); + let apparent = in_camera(scene.apparent_moon(&observer.position)); + assert!((apparent - geometric).norm() > 4.0); + for adapter in &ADAPTERS { + for (tier, want) in [(CameraTier::C0, geometric), (CameraTier::C1, apparent)] { + let mut r = renderer(adapter, tier); + let name = format!("{}, {tier}", r.adapter_info().name); + let image = render(&mut r, &scene, &observer); + let (mut total, mut centroid) = (0.0, Vector2::zeros()); + for (i, l) in image.iter().enumerate() { + total += l; + centroid += Vector2::new((i % 256) as f64 + 0.5, (i / 256) as f64 + 0.5) * *l; + } + centroid /= total; + let off = (centroid - want).norm(); + assert!(off < 0.05, "{name}: centroid off by {off} px"); + } + } +} + +#[test] +fn earths_limb_moves_with_the_observer() { + // From 500 km over the equator, looking at the horizon with Earth below, the + // observer moving 7.6 km/s toward the top of the image: Earth's limb moves up by + // the aberration, 4.6 px. Measured in each column from the difference against the + // same view with no velocity, over the rows around the limb, divided by the + // radiance just below it. The camera is rolled 3 degrees so that the limb crosses + // the rows of rays at a slant: an edge along them is graded in steps of 1/8 px + // (2 x 2 rendering, 4 rays per rendered pixel), so each column is within 0.15 px + // and their mean within 0.02 px. + let _devices = common::device_lock(); + let r = EARTH_EQUATORIAL_RADIUS; + let position = Vector3::new(r + 500e3, 0.0, 0.0); + let tangent = Vector3::new( + r * r / position.x, + r * (1.0 - (r / position.x).powi(2)).sqrt(), + 0.0, + ); + let toward = resolved::nalgebra::Unit::new_normalize(tangent - position); + let roll = UnitQuaternion::from_axis_angle(&toward, 3f64.to_radians()); + let observer = Pose::looking_at(position, tangent, roll * Vector3::x()).unwrap(); + // The image's up, -Y in the camera frame, in the inertial frame. + let up = observer.attitude * -Vector3::y(); + let scene = |velocity: f64| Scene { + earth_attitude: UnitQuaternion::identity(), + // Overhead, lighting the limb. + sun: Vector3::x() * AU, + moon: Vector3::new(0.0, 0.0, -3.844e8), + moon_attitude: UnitQuaternion::identity(), + targets: Vec::new(), + velocities: Velocities { + observer: up * velocity, + ..Default::default() + }, + }; + let k = narrow().intrinsics(); + let beta = -Vector3::y() * 7.6e3 / SPEED_OF_LIGHT; + for adapter in &ADAPTERS { + let mut c1 = renderer(adapter, CameraTier::C1); + let name = c1.adapter_info().name.clone(); + let still = render(&mut c1, &scene(0.0), &observer); + let moving = render(&mut c1, &scene(7.6e3), &observer); + let (mut worst, mut sum, mut count) = (0.0f64, 0.0, 0.0); + for u in 16..240 { + let column = |image: &[f64], v: usize| image[v * 256 + u]; + // The limb row: the first row from the top that sees Earth, unmoved. + let limb = (0..256).find(|&v| column(&still, v) > 0.0).unwrap(); + let inside = column(&still, limb + 6); + let moved: f64 = (limb - 12..limb + 12) + .map(|v| column(&moving, v) - column(&still, v)) + .sum::() + / inside; + // Predicted: the limb's line of sight in this column, aberrated; up is -v. + let at = Vector2::new(u as f64 + 0.5, limb as f64 + 0.5); + let d = k.ray(&at); + let up = -(k.project(&aberrate(&d, &beta)).unwrap() - at).y; + worst = worst.max((moved - up).abs()); + sum += moved - up; + count += 1.0; + assert!( + (moved - up).abs() < 0.15, + "{name}: column {u}: moved {moved} px, predicted {up}" + ); + } + let mean = sum / count; + assert!(mean.abs() < 0.02, "{name}: mean off by {mean} px"); + eprintln!("limb, {name}: worst column off by {worst:.4} px, mean by {mean:.4}"); + } +} diff --git a/crates/resolved/tests/gpu/bodies.rs b/crates/resolved/tests/gpu/bodies.rs index b52fdb1..55ee676 100644 --- a/crates/resolved/tests/gpu/bodies.rs +++ b/crates/resolved/tests/gpu/bodies.rs @@ -70,6 +70,7 @@ fn render_limb( &config.intrinsics(), config.near_clip, &body_radiometry(), + false, ); let mut encoder = ctx.device.create_command_encoder(&Default::default()); pass.encode(ctx, &mut encoder, &targets, &uniforms, None); @@ -90,6 +91,7 @@ fn scene(sun: Vector3, moon: Vector3) -> Scene { moon, moon_attitude: UnitQuaternion::identity(), targets: vec![], + velocities: Default::default(), } } diff --git a/crates/resolved/tests/gpu/distortion.rs b/crates/resolved/tests/gpu/distortion.rs index 9cc0005..3fbcf58 100644 --- a/crates/resolved/tests/gpu/distortion.rs +++ b/crates/resolved/tests/gpu/distortion.rs @@ -63,6 +63,7 @@ fn scene(camera: &Pose, toward: Vector3, moon: Vector3, targets: Vec) -> (Scene, Pose moon: Vector3::new(0.0, 0.0, -3.844e8), moon_attitude: UnitQuaternion::identity(), targets: vec![], + velocities: Default::default(), }; let north = if lat.abs() > 80.0 { Vector3::x() diff --git a/crates/resolved/tests/gpu/earthshine.rs b/crates/resolved/tests/gpu/earthshine.rs index 3867f39..b10aa1e 100644 --- a/crates/resolved/tests/gpu/earthshine.rs +++ b/crates/resolved/tests/gpu/earthshine.rs @@ -170,6 +170,7 @@ impl Case { model, pose: Pose::new(self.target, attitude), }], + velocities: Default::default(), } } diff --git a/crates/resolved/tests/gpu/eclipses.rs b/crates/resolved/tests/gpu/eclipses.rs index ade23c9..e15e9cc 100644 --- a/crates/resolved/tests/gpu/eclipses.rs +++ b/crates/resolved/tests/gpu/eclipses.rs @@ -60,6 +60,7 @@ fn scene(moon: Vector3) -> Scene { moon, moon_attitude: UnitQuaternion::identity(), targets: vec![], + velocities: Default::default(), } } diff --git a/crates/resolved/tests/gpu/main.rs b/crates/resolved/tests/gpu/main.rs index ea7a50e..add40bf 100644 --- a/crates/resolved/tests/gpu/main.rs +++ b/crates/resolved/tests/gpu/main.rs @@ -5,6 +5,7 @@ mod common; mod analytic; +mod apparent; mod bodies; mod distortion; mod earth_texture; diff --git a/crates/resolved/tests/gpu/mesh.rs b/crates/resolved/tests/gpu/mesh.rs index db61bd5..2bc7b7b 100644 --- a/crates/resolved/tests/gpu/mesh.rs +++ b/crates/resolved/tests/gpu/mesh.rs @@ -82,6 +82,7 @@ fn scene(sun: Vector3, target: Option) -> Scene { pose, }) .collect(), + velocities: Default::default(), } } @@ -99,7 +100,8 @@ fn render(ctx: &GpuContext, name: &str, scene: &Scene, camera: &Pose, mesh: &Mes let mut meshes = MeshPass::new(ctx, 1); let gpu_mesh = meshes.upload(ctx, mesh, &radiometry).unwrap(); let body_radiometry = BodyRadiometry::new(&radiometry, &Appearance::default()).unwrap(); - let uniforms = BodiesUniforms::new(scene, camera, &k, config.near_clip, &body_radiometry); + let uniforms = + BodiesUniforms::new(scene, camera, &k, config.near_clip, &body_radiometry, false); let draws: Vec<_> = scene .targets .iter() diff --git a/crates/resolved/tests/gpu/moon_texture.rs b/crates/resolved/tests/gpu/moon_texture.rs index 97d770a..1acabca 100644 --- a/crates/resolved/tests/gpu/moon_texture.rs +++ b/crates/resolved/tests/gpu/moon_texture.rs @@ -66,6 +66,7 @@ fn view(lat: f64, lon: f64, attitude: UnitQuaternion) -> (Scene, Pose) { moon: MOON, moon_attitude: attitude, targets: vec![], + velocities: Default::default(), }; let north = if lat.abs() > 80.0 { Vector3::x() @@ -198,6 +199,7 @@ fn a_uniform_map_draws_the_flat_moon_times_the_phase_factor() { moon: MOON, moon_attitude: UnitQuaternion::from_axis_angle(&Vector3::x_axis(), 0.7), targets: vec![], + velocities: Default::default(), }; let camera = Pose::looking_at(MOON + direction(10.0, -40.0) * RANGE, MOON, Vector3::z()).unwrap(); @@ -393,6 +395,7 @@ fn moonshine_reads_the_map_in_moon_fixed_axes() { model, pose: Pose::new(position, facing), }], + velocities: Default::default(), }; let camera = Pose::looking_at(position - up * 20.0, position, Vector3::z()).unwrap(); center(&renderer.submit(&scene, &camera).unwrap().wait().unwrap())[1] as f64 diff --git a/crates/resolved/tests/gpu/pixel_area.rs b/crates/resolved/tests/gpu/pixel_area.rs index 885580b..aed83fc 100644 --- a/crates/resolved/tests/gpu/pixel_area.rs +++ b/crates/resolved/tests/gpu/pixel_area.rs @@ -91,6 +91,7 @@ fn scene(sun: Vector3, moon: Vector3, targets: Vec) -> Scene { moon, moon_attitude: UnitQuaternion::identity(), targets, + velocities: Default::default(), } } diff --git a/crates/resolved/tests/gpu/psf.rs b/crates/resolved/tests/gpu/psf.rs index c7bd0fa..736e2f4 100644 --- a/crates/resolved/tests/gpu/psf.rs +++ b/crates/resolved/tests/gpu/psf.rs @@ -327,6 +327,7 @@ fn render(renderer: &mut Renderer) -> Frame { moon, moon_attitude: UnitQuaternion::identity(), targets: Vec::new(), + velocities: Default::default(), }; renderer.submit(&scene, &observer).unwrap().wait().unwrap() } diff --git a/crates/resolved/tests/gpu/renderer.rs b/crates/resolved/tests/gpu/renderer.rs index b4cb2c6..a48dc32 100644 --- a/crates/resolved/tests/gpu/renderer.rs +++ b/crates/resolved/tests/gpu/renderer.rs @@ -66,6 +66,7 @@ fn scene(targets: Vec) -> Scene { moon: Vector3::new(0.0, -3.844e8, 0.0), moon_attitude: UnitQuaternion::identity(), targets, + velocities: Default::default(), } } diff --git a/crates/resolved/tests/gpu/sensor.rs b/crates/resolved/tests/gpu/sensor.rs index 42f17f3..67bd2dc 100644 --- a/crates/resolved/tests/gpu/sensor.rs +++ b/crates/resolved/tests/gpu/sensor.rs @@ -137,6 +137,7 @@ fn sunlit_earth_through_the_whole_chain() { moon: Vector3::new(0.0, -3.844e8, 0.0), moon_attitude: UnitQuaternion::identity(), targets: vec![], + velocities: Default::default(), }; let camera = Pose::looking_at( Vector3::new(2.0e8, 0.0, 0.0), @@ -156,6 +157,7 @@ fn sunlit_earth_through_the_whole_chain() { &config.intrinsics(), config.near_clip, &body_radiometry, + false, ); let sensor = SensorUniforms::new(&config, &radiometry, false); for ctx in common::contexts() { diff --git a/crates/resolved/tests/gpu/shadows.rs b/crates/resolved/tests/gpu/shadows.rs index d1eca01..e6279b9 100644 --- a/crates/resolved/tests/gpu/shadows.rs +++ b/crates/resolved/tests/gpu/shadows.rs @@ -131,6 +131,7 @@ fn scene(targets: Vec) -> Scene { moon: Vector3::new(-3.844e8, 0.0, 0.0), moon_attitude: UnitQuaternion::identity(), targets, + velocities: Default::default(), } } diff --git a/docs/optics.md b/docs/optics.md index 9f2223f..3669f43 100644 --- a/docs/optics.md +++ b/docs/optics.md @@ -1298,7 +1298,8 @@ Limitations: ## Where the Sun and Moon appear -Code: `ephemeris.rs` (feature `astrokit`). +Code: `scene.rs` (`Velocities`, `aberrate`), `ephemeris.rs` (feature +`astrokit`), `shaders/bodies.wgsl` (`seen`). With the `astrokit` feature, the Sun's and Moon's positions come from JPL's DE440s ephemeris, and Earth's orientation from the ITRF93 kernel, which @@ -1317,10 +1318,46 @@ A camera sees something slightly different, its apparent position: to about 5 arcseconds more. 100 microradians is about one pixel of the example camera, and 18 pixels of -the narrow 1 m focal length camera in the limb test. So the geometric -positions are close enough for most images, but not for precise navigation -against the Sun, Moon or stars. Rendering apparent positions needs the observer's -velocity as an input, which the scene does not carry yet. +a narrow camera with a 1 m focal length. + +The two effects combine simply. Light time moves a body back along its own +velocity by (distance / c) times that velocity; aberration moves it along +the observer's velocity by the same factor. To first order, then, a body +appears moved in direction by the observer's velocity relative to the body, +divided by c, the part of it across the line of sight. Only relative +velocities matter. For the Sun that is Earth's velocity around it plus the +spacecraft's: the 20 arcseconds. For the Moon, Earth's 30 km/s cancels, +since the Moon shares it, and what is left is the Moon's own 1 km/s around +Earth (0.7 arcseconds from Earth's center) plus the spacecraft's velocity. +For Earth itself it is the spacecraft's velocity, up to 5 arcseconds in low +orbit, about 0.2 px of the example camera. The terms left out are of the +order of the velocity squared over c squared, 1e-8 rad. + +From camera tier C1 the renderer draws Earth, the Moon and the Sun at their +apparent positions. The scene carries the velocities (`Scene::velocities`: +the observer's relative to Earth, Earth's relative to the Sun and the Moon's +relative to Earth), and `Ephemeris::scene` fills in Earth's and the Moon's; +the observer's is the caller's. All zero, as in C0, the images are +geometric. Each pixel's ray is turned back, per body, by that body's +correction before it is tested against the body, so the correction follows +the ray's direction across a wide view of Earth, and the lighting, shadows +and eclipses, which happen where the bodies really are, stay geometric. +`Scene::apparent_sun` and `Scene::apparent_moon` give the same directions on +the CPU. From Earth's center they match JPL Horizons' apparent positions +(light time and stellar aberration) to under 0.002 arcseconds, against 20 +arcseconds for the Sun's geometric position. + +The camera's attitude is taken as the observer would measure it: a star +tracker sees the stars aberrated by the same motion, so the attitude it +reports already includes it. A simulation that passes a true, geometric +attitude gets images shifted by the aberration, as a real camera's would be +against such an attitude. + +Still geometric: the targets, whose light time and aberration come from +their velocity relative to the observer, meters per second in rendezvous, +under 0.01 microradians; the rotation and lighting of a body during its +light time (the Moon turns 3 microradians in 1.3 s); and the light-time +delay between the Sun lighting a body and the camera seeing it. ## Not modeled yet @@ -1333,4 +1370,3 @@ velocity as an input, which the scene does not carry yet. - Clouds on Earth - Earth's atmosphere: scattering, the bright limb - Motion blur, rolling shutter, stray light -- Apparent positions: light time and aberration diff --git a/readme.md b/readme.md index 8ca358f..5bd0730 100644 --- a/readme.md +++ b/readme.md @@ -83,6 +83,9 @@ let scene = Scene { // Which way the Moon's surface map faces; an ephemeris gives it exactly. moon_attitude: Scene::moon_facing_earth(moon), targets: vec![Target { model: panel, pose: Pose::new(target, UnitQuaternion::identity()) }], + // From camera C1, what makes positions apparent; an ephemeris gives Earth's and + // the Moon's, and the observer's is the simulation's. + velocities: Default::default(), }; let observer = Pose::looking_at(target + Vector3::new(20.0, 0.0, 0.0), target, Vector3::z()).unwrap(); @@ -190,10 +193,11 @@ L1 draws Earth's surface from Blue Marble and the Moon's from the LRO Camera's map, shadows on the target, light from Earth and the Moon on it, eclipses on Earth and the Moon, and the Sun's limb darkening. Camera C1 is under way: so far, pixels that collect light over their area, lens distortion -(Brown-Conrady), and lens blur (a Gaussian PSF) applied to the scene -rendered 2 x 2 finer than the pixels. Planned, in order: +(Brown-Conrady), lens blur (a Gaussian PSF) applied to the scene rendered +2 x 2 finer than the pixels, and the Sun, Moon and Earth at their apparent +positions (light time and aberration). Planned, in order: -- Camera: apparent positions, stars, +- Camera: stars, detector noise, pixel-to-pixel variation, saturation and blooming. - Lighting: BRDF and spectral materials, atmospheric scattering, clouds, lunar terrain.