From c7b890f3dbca7cc52b61c1524325e18d8477b8b3 Mon Sep 17 00:00:00 2001 From: Connor Johnstone Date: Sun, 18 Oct 2026 15:21:05 -0400 Subject: [PATCH] dated clouds and lighting L2 --- crates/resolved-cli/src/main.rs | 29 ++- crates/resolved-cli/src/scene_file.rs | 72 ++++++ crates/resolved-cli/tests/cli.rs | 38 ++- crates/resolved/src/fidelity.rs | 17 +- crates/resolved/src/gpu/bodies.rs | 14 +- crates/resolved/src/render.rs | 19 +- crates/resolved/tests/gpu/clouds.rs | 82 +++++++ docs/optics.md | 82 ++++++- examples/scenes/ace.toml | 2 + examples/scenes/boxwing.toml | 2 + examples/scenes/earthrise.toml | 2 + examples/scenes/geo-telescope-1000km.toml | 2 + examples/scenes/geo-telescope-100km-side.toml | 2 + examples/scenes/geo-telescope-100km.toml | 2 + examples/scenes/icesat2.toml | 2 + examples/scenes/landsat8.toml | 2 + examples/scenes/moonrise.toml | 2 + examples/scenes/ostm.toml | 2 + examples/scenes/ssl1300.toml | 2 + examples/scenes/stars-pleiades.toml | 2 + examples/scenes/sun-glare.toml | 2 + examples/scenes/sunset.toml | 2 + readme.md | 6 +- scripts/fetch-dated-clouds.py | 217 ++++++++++++++++++ scripts/gallery-delta.py | 10 +- scripts/make-example-scenes.py | 14 ++ scripts/make-gallery.sh | 21 +- scripts/serve-gallery.py | 6 +- 28 files changed, 621 insertions(+), 34 deletions(-) create mode 100755 scripts/fetch-dated-clouds.py diff --git a/crates/resolved-cli/src/main.rs b/crates/resolved-cli/src/main.rs index 876b625..5423967 100644 --- a/crates/resolved-cli/src/main.rs +++ b/crates/resolved-cli/src/main.rs @@ -65,10 +65,11 @@ struct RenderArgs { /// rolls highlights off to white at saturation. --out is unchanged. #[arg(long)] pretty: bool, - /// Lighting fidelity tier: L0, or L1 (adds Earth's surface and atmosphere, shadows - /// on targets, light from Earth and the Moon, eclipses on them, the Sun's limb - /// darkening, and stars, which camera C1 shows; needs Blue Marble data, and with C1 - /// the star catalog). + /// Lighting fidelity tier: L0, L1 (adds Earth's surface, atmosphere and clouds, + /// shadows on targets, light from Earth and the Moon, eclipses on them, the Sun's + /// limb darkening, and stars, which camera C1 shows; needs Blue Marble data, and + /// with C1 the star catalog), or L2 (adds the clouds of the scene's day, from + /// scripts/fetch-dated-clouds.py). #[arg(long, default_value = "L0")] lighting: LightingTier, /// Camera fidelity tier: C0, C1 (adds pixels that collect light over their area, @@ -106,6 +107,7 @@ struct RenderArgs { timings: Option, /// Directory of data the tiers need (earth/bmng-2004-MM.jpg from /// scripts/fetch-earth.sh, clouds/blue-marble.png from scripts/fetch-clouds.sh, + /// clouds/YYYY-MM-DD.png for lighting L2 from scripts/fetch-dated-clouds.py, /// moon/wac-hapke-643.png from scripts/fetch-moon.py, stars/stars.bin from /// scripts/fetch-stars.py). /// Without it: RESOLVED_DATA, then ./data, then the repository's data/. @@ -245,7 +247,24 @@ fn render(args: &RenderArgs) -> Result<(), String> { } else { None }; - let clouds = if args.lighting >= LightingTier::L1 && file.appearance.clouds != Some(false) { + // Typical clouds at lighting L1, the scene's day's at L2. + let clouds = if args.lighting >= LightingTier::L2 { + if file.appearance.clouds == Some(false) { + return Err( + "lighting L2 draws the clouds of the scene's day: clouds = false is for L1".into(), + ); + } + let date = file.cloud_date()?.ok_or( + "lighting L2 draws the clouds of the scene's day: give the scene an epoch, or [appearance] cloud_date", + )?; + let image = data_file( + args.data.as_deref(), + &format!("clouds/{date}.png"), + &format!("fetch-dated-clouds.py {date}"), + "lighting L2", + )?; + Some(Arc::new(CloudMap::load(&image).map_err(|e| e.to_string())?)) + } else if args.lighting >= LightingTier::L1 && file.appearance.clouds != Some(false) { let image = data_file( args.data.as_deref(), "clouds/blue-marble.png", diff --git a/crates/resolved-cli/src/scene_file.rs b/crates/resolved-cli/src/scene_file.rs index fedf51b..8423dbb 100644 --- a/crates/resolved-cli/src/scene_file.rs +++ b/crates/resolved-cli/src/scene_file.rs @@ -153,6 +153,9 @@ pub struct AppearanceFile { /// Whether Earth has clouds at lighting L1 (data/clouds/blue-marble.png, from /// scripts/fetch-clouds.sh); true if left out. pub clouds: Option, + /// The day (YYYY-MM-DD, UTC) whose clouds lighting L2 draws, for scenes without an + /// epoch; with one, the epoch's day is the default. + pub cloud_date: Option, } /// One spacecraft. @@ -291,6 +294,36 @@ impl SceneFile { } } + /// The day (YYYY-MM-DD) whose clouds lighting L2 draws: `cloud_date`, else the + /// epoch's day in UTC. + pub fn cloud_date(&self) -> Result> { + if let Some(date) = &self.appearance.cloud_date { + let parts: Vec = date.split('-').filter_map(|p| p.parse().ok()).collect(); + let valid = date.len() == 10 + && matches!(parts[..], [_, m, d] if (1..=12).contains(&m) && (1..=31).contains(&d)); + if !valid { + return Err(format!("cloud_date {date:?} is not a date (YYYY-MM-DD)")); + } + return Ok(Some(date.clone())); + } + self.epoch_date() + } + + #[cfg(feature = "astrokit")] + fn epoch_date(&self) -> Result> { + let Some(epoch) = &self.epoch else { + return Ok(None); + }; + let epoch: hifitime::Epoch = epoch.parse().map_err(|e| format!("epoch {epoch:?}: {e}"))?; + let (y, m, d, ..) = epoch.to_gregorian_utc(); + Ok(Some(format!("{y:04}-{m:02}-{d:02}"))) + } + + #[cfg(not(feature = "astrokit"))] + fn epoch_date(&self) -> Result> { + Ok(None) + } + #[cfg(feature = "astrokit")] fn epoch_month(&self) -> Result> { let Some(epoch) = &self.epoch else { @@ -660,6 +693,45 @@ mod tests { assert_eq!(file.earth_month().unwrap(), Some(10)); } + #[test] + fn cloud_date_comes_from_the_setting() { + let with = |extra: &str| -> SceneFile { + toml::from_str(&format!("{MINIMAL}\n[appearance]\n{extra}\n")).unwrap() + }; + let date = with("cloud_date = \"2024-07-15\"").cloud_date().unwrap(); + assert_eq!(date.as_deref(), Some("2024-07-15")); + assert_eq!(with("").cloud_date().unwrap(), None, "no date and no epoch"); + for bad in ["2024-7-15", "2024-13-01", "2024-07-32", "15 July"] { + assert!( + with(&format!("cloud_date = \"{bad}\"")) + .cloud_date() + .is_err(), + "{bad}" + ); + } + } + + #[cfg(feature = "astrokit")] + #[test] + fn cloud_date_defaults_to_the_epochs_day_in_utc() { + let at = |epoch: &str, extra: &str| { + let file: SceneFile = toml::from_str(&format!( + "epoch = \"{epoch}\"\n{MINIMAL}\n[appearance]\n{extra}\n" + )) + .unwrap(); + file.cloud_date().unwrap().unwrap() + }; + assert_eq!(at("2024-07-15T00:00:00 UTC", ""), "2024-07-15"); + assert_eq!(at("2024-07-14T23:59:59 UTC", ""), "2024-07-14"); + // Midnight in UTC, not in the epoch's own time scale: 37 s of leap seconds and + // 32.184 s put TT midnight before UTC's. + assert_eq!(at("2024-07-15T00:00:30 TT", ""), "2024-07-14"); + assert_eq!( + at("2024-07-15T12:00:00 UTC", "cloud_date = \"2001-07-29\""), + "2001-07-29" + ); + } + #[test] fn mistakes_are_reported() { // A misspelled field. diff --git a/crates/resolved-cli/tests/cli.rs b/crates/resolved-cli/tests/cli.rs index 88317e8..91f7655 100644 --- a/crates/resolved-cli/tests/cli.rs +++ b/crates/resolved-cli/tests/cli.rs @@ -304,6 +304,42 @@ fn lighting_l1_names_a_missing_cloud_map_unless_clouds_are_off() { ); } +#[test] +fn lighting_l2_names_the_day_to_fetch() { + // The gray test data has the static clouds but no day's: boxwing's day is named in + // the error with the command that fetches it, and a scene with no day says so. + let l2 = |scene: &str| { + resolved(&[ + "render", + "--scene", + scene, + "--lighting", + "L2", + "--data", + gray_earth().to_str().unwrap(), + "--preview", + out_dir().join("l2.png").to_str().unwrap(), + ]) + }; + let result = l2("examples/scenes/boxwing.toml"); + assert!(!result.status.success()); + let stderr = String::from_utf8_lossy(&result.stderr); + assert!( + stderr.contains("clouds/2024-07-15.png") + && stderr.contains("fetch-dated-clouds.py 2024-07-15"), + "{stderr}" + ); + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let scene = std::fs::read_to_string(root.join("examples/scenes/boxwing.toml")).unwrap(); + let scene = scene.replace("cloud_date = \"2024-07-15\"", ""); + let path = out_dir().join("boxwing-no-day.toml"); + std::fs::write(&path, scene).unwrap(); + let result = l2(path.to_str().unwrap()); + assert!(!result.status.success()); + let stderr = String::from_utf8_lossy(&result.stderr); + assert!(stderr.contains("[appearance] cloud_date"), "{stderr}"); +} + #[test] fn errors_are_reported() { let missing = resolved(&["render", "--scene", "no/such.toml", "--preview", "x.png"]); @@ -344,7 +380,7 @@ fn lighting_l1_says_what_it_is_missing() { let text = std::fs::read_to_string(repo().join("examples/scenes/boxwing.toml")) .unwrap() .replace("../models", models.to_str().unwrap()) - .replace("[appearance]\nearth_month = 7\n", ""); + .replace("earth_month = 7\n", ""); assert!(!text.contains("earth_month")); std::fs::write(&scene, text).unwrap(); let no_month = resolved(&[ diff --git a/crates/resolved/src/fidelity.rs b/crates/resolved/src/fidelity.rs index 35833d8..af9e1e5 100644 --- a/crates/resolved/src/fidelity.rs +++ b/crates/resolved/src/fidelity.rs @@ -31,8 +31,13 @@ pub enum LightingTier { /// eclipse; and the stars of a catalog when one is given /// ([`RendererOptions::stars`](crate::RendererOptions::stars)), seen only by camera /// tier C1 and up, whose pixels collect light over their area (a C0 pixel samples - /// one point, which never meets a star). + /// one point, which never meets a star). Clouds over Earth are optional + /// (`RendererOptions::clouds`): typical ones, from one day's map. L1, + /// L1, plus the clouds of the scene's day (`RendererOptions::clouds`, required: a + /// map made for that day from MODIS, as `scripts/fetch-dated-clouds.py` makes it), + /// which need Earth's atmosphere. + L2, } /// Camera tiers, from cheapest to most complete. @@ -81,6 +86,7 @@ impl fmt::Display for LightingTier { f.write_str(match self { Self::L0 => "L0", Self::L1 => "L1", + Self::L2 => "L2", }) } } @@ -98,12 +104,13 @@ impl fmt::Display for CameraTier { impl FromStr for LightingTier { type Err = String; - /// `L0` or `L1`, either case. + /// `L0`, `L1` or `L2`, either case. fn from_str(s: &str) -> Result { match s.to_ascii_uppercase().as_str() { "L0" => Ok(Self::L0), "L1" => Ok(Self::L1), - _ => Err(format!("unknown lighting tier {s:?}: L0 or L1")), + "L2" => Ok(Self::L2), + _ => Err(format!("unknown lighting tier {s:?}: L0, L1 or L2")), } } } @@ -135,7 +142,9 @@ mod tests { assert_eq!("c2".parse::().unwrap(), CameraTier::C2); assert!(CameraTier::C0 < CameraTier::C1 && CameraTier::C1 < CameraTier::C2); assert!("L9".parse::().is_err()); - assert!(LightingTier::L0 < LightingTier::L1); + assert_eq!("l2".parse::().unwrap(), LightingTier::L2); + assert_eq!(LightingTier::L2.to_string(), "L2"); + assert!(LightingTier::L0 < LightingTier::L1 && LightingTier::L1 < LightingTier::L2); assert_eq!(Fidelity::default().lighting, LightingTier::L0); } } diff --git a/crates/resolved/src/gpu/bodies.rs b/crates/resolved/src/gpu/bodies.rs index 0e5b73d..c84037b 100644 --- a/crates/resolved/src/gpu/bodies.rs +++ b/crates/resolved/src/gpu/bodies.rs @@ -489,14 +489,18 @@ impl BodiesPass { } /// Uploads the clouds' map with its mip chain and draws the cloud layer over Earth - /// in later frames, where Earth is seen through its atmosphere. + /// in later frames, where Earth is seen through its atmosphere. The first map + /// compiles the layer into the pipelines; a later one, such as the next day's in a + /// movie, only replaces the map. pub fn set_cloud_map(&mut self, ctx: &GpuContext, map: &CloudMap) { self.clouds = upload_clouds(ctx, map); - self.clouded = true; self.cloud_height = map.height(); - self.pipeline = compile(ctx, self.samples, self.area, false, true); - if let Some((pipeline, _)) = &mut self.bending { - *pipeline = compile(ctx, self.samples, self.area, true, true); + if !self.clouded { + self.clouded = true; + self.pipeline = compile(ctx, self.samples, self.area, false, true); + if let Some((pipeline, _)) = &mut self.bending { + *pipeline = compile(ctx, self.samples, self.area, true, true); + } } self.rebind(ctx); } diff --git a/crates/resolved/src/render.rs b/crates/resolved/src/render.rs index 18e0218..7856faf 100644 --- a/crates/resolved/src/render.rs +++ b/crates/resolved/src/render.rs @@ -62,7 +62,8 @@ pub struct RendererOptions { /// The Moon's surface, needed from lighting tier L1 up; ignored below it. pub moon_texture: Option>, /// Clouds over Earth, drawn from lighting tier L1 where Earth is seen through its - /// atmosphere; optional. Blue Marble's 16384 x 8192 map takes 360 MB of GPU memory. + /// atmosphere: optional at L1, and needed at L2, which draws the scene's day's. + /// A 16384 x 8192 map takes 360 MB of GPU memory. pub clouds: Option>, /// How Earth and the Moon reflect light. pub appearance: Appearance, @@ -380,6 +381,22 @@ impl Renderer { options.fidelity.lighting ))); } + if options.fidelity.lighting >= LightingTier::L2 { + if options.clouds.is_none() { + return Err(Error::MissingInput(format!( + "lighting {} draws the clouds of the scene's day from a map: set \ + RendererOptions::clouds", + options.fidelity.lighting + ))); + } + if !options.appearance.earth_atmosphere { + return Err(Error::Unsupported(format!( + "lighting {} draws clouds, which need Earth's atmosphere \ + (Appearance::earth_atmosphere)", + options.fidelity.lighting + ))); + } + } let convolve = match grid { Some(grid) => Some(ConvolvePass::new( &ctx, diff --git a/crates/resolved/tests/gpu/clouds.rs b/crates/resolved/tests/gpu/clouds.rs index d252cd5..5528127 100644 --- a/crates/resolved/tests/gpu/clouds.rs +++ b/crates/resolved/tests/gpu/clouds.rs @@ -224,3 +224,85 @@ fn clouds_brighten_earthshine() { } } } + +#[test] +fn swapping_maps_changes_only_the_clouds() { + // A renderer that drew one day's clouds and was handed another's draws what one made + // with the second from the start does, to the bit. + let _devices = common::device_lock(); + let ground = Vector3::new(BOTTOM_RADIUS, 0.0, 0.0); + let (scene, pose) = view( + Vector3::new(1.0, 0.5, 0.2).normalize(), + ground * 3.0, + ground, + ); + let checker = |phase: u32| { + let values = (0..64 * 32).map(|i| { + if (i % 64 / 8 + i / 64 / 8 + phase) % 2 == 0 { + 200 + } else { + 30 + } + }); + CloudMap::from_values(64, 32, values.collect()).unwrap() + }; + for adapter in &ADAPTERS { + let mut swapped = cloudy(adapter, Some(checker(0))); + let first = swapped + .submit(&scene, &pose) + .unwrap() + .wait() + .unwrap() + .radiance + .unwrap(); + swapped.set_cloud_map(&checker(1)); + let got = swapped + .submit(&scene, &pose) + .unwrap() + .wait() + .unwrap() + .radiance + .unwrap(); + let mut fresh = cloudy(adapter, Some(checker(1))); + let want = fresh + .submit(&scene, &pose) + .unwrap() + .wait() + .unwrap() + .radiance + .unwrap(); + let name = fresh.adapter_info().name.clone(); + assert!(got == want, "{name}: the swapped map draws differently"); + assert!(first != want, "{name}: the two maps draw the same"); + } +} + +#[test] +fn lighting_l2_needs_clouds_and_the_atmosphere() { + use resolved::bodies::Appearance; + use resolved::{CameraConfig, EarthTexture, Error, Fidelity, LightingTier, RendererOptions}; + let _devices = common::device_lock(); + let options = |clouds: bool, earth_atmosphere: bool| RendererOptions { + adapter: AdapterChoice::Cpu, + fidelity: Fidelity { + lighting: LightingTier::L2, + ..Default::default() + }, + earth_texture: Some(std::sync::Arc::new( + EarthTexture::from_rgba8(1, 1, vec![128, 128, 128, 255]).unwrap(), + )), + moon_texture: Some(common::uniform_moon()), + clouds: clouds.then(|| std::sync::Arc::new(uniform(100))), + appearance: Appearance { + earth_atmosphere, + ..Default::default() + }, + ..Default::default() + }; + let error = |clouds, air| Renderer::new(CameraConfig::example(), options(clouds, air)).err(); + assert!( + matches!(error(false, true), Some(Error::MissingInput(e)) if e.contains("RendererOptions::clouds")), + ); + assert!(matches!(error(true, false), Some(Error::Unsupported(e)) if e.contains("atmosphere"))); + assert!(error(true, true).is_none()); +} diff --git a/docs/optics.md b/docs/optics.md index 19d232f..9d49545 100644 --- a/docs/optics.md +++ b/docs/optics.md @@ -1346,13 +1346,93 @@ Limitations: back down by the cloud's base is left out. It matters over snow and ice. - **The sky light under a cloud** is dimmed by the cloud toward the Sun, not by the clouds over the whole sky. -- **One day's clouds**, whatever the scene's date. Maps by date are planned. +- **At lighting L1, one day's clouds**, whatever the scene's date. Lighting L2 + draws the scene's day's (next section). - **The map** is 2.45 km per pixel. Small cumulus are blurred into a gray veil. The composite also has straight seams where its pieces meet, at high latitudes, which show as clouds that end on a straight line (east of Greenland in the solar eclipse example). - **Without the atmosphere** (`Appearance::earth_atmosphere` off), no clouds. +### Clouds of the day (lighting L2) + +Code: `scripts/fetch-dated-clouds.py`, the command line's choice of map. Lighting +L2. + +At lighting L1 the clouds are always those of 2001 July 29. Lighting L2 draws the +clouds of the scene's day, from MODIS on Terra and Aqua, with the same layer and +shading as above. Only the map differs. + +**Making a day's map.** NASA's Global Imagery Browse Services (GIBS) serve the +MODIS cloud products as images, one global composite per day. The cloud optical +thickness comes as colors that stand for values, in steps of about 4 % from 0.01 +to 150 (GIBS's color map). `scripts/fetch-dated-clouds.py FIRST [LAST]` fetches +each day's layers as two hemispheres of 8192 x 8192 pixels (GIBS times out on the +whole map at once) and turns the colors back into numbers. All 252 colors in the +images are in the color map, so none were blended in the resampling. For each +pixel it takes the first optical thickness of: + +1. Terra's standard retrieval; +2. Aqua's; +3. Terra's partly cloudy retrieval (PCL); +4. Aqua's PCL. + +That thickness becomes reflectance by the formula above. A pixel with none of them +but a cloud fraction from either satellite is clear, because MODIS retrieves +thickness wherever its cloud mask finds cloud in daylight. A pixel with no data at +all, between passes or in polar night, takes the static map's value. The map is +written at the static map's size, 16384 x 8192, as `data/clouds/YYYY-MM-DD.png`. +Each day takes about two minutes and 0.3 GB of downloads, and keeps 20 MB. + +For 2024 July 15 that gives: + +- thickness over 56 % of Earth's area; +- clear over 43 %, including the dark Antarctic winter, where only the infrared + cloud fraction has data; +- no data over 1.3 %. + +The mean reflectance is 0.247, against 0.207 for the static map. Fewer pixels are +cloudy, 55 % against 65 % north of 60 S, but those that are are brighter. + +**Checking it.** Earth rendered at phase angles from 0 to 180 degrees, as for the +static map, reflects 0.31 to 0.33 of the sunlight in green (Bond albedo) with the +day's clouds, against 0.27 to 0.29 with the static map and about 0.30 measured. +Seen from 80,000 km over Africa, the day's clouds lie where MODIS's true-color +image of that day shows them: the storms over the North Atlantic, the band over +the Sahel, and the clouds over Europe. The Saharan dust in that image is aerosol, +not cloud, and is not drawn. + +**Choosing the day.** The command line takes the day of the scene's epoch in UTC, +or `[appearance] cloud_date` in a scene without an epoch. The July example scenes +give 2024-07-15. A day whose map is missing is an error that names the command to +fetch it, and a scene with no day at all is an error too. Lighting L2 needs a +cloud map and Earth's atmosphere, and the renderer refuses it without them. +`Renderer::set_cloud_map` swaps one map for another between frames, for a run that +crosses days. It takes about 175 ms on the RTX 3060 and 240 ms on lavapipe, mostly +building the mip chain and uploading it, and the frames after it are those of a +renderer made with the new map. Rendering costs what it does at L1. + +Tests: the day's choice from `cloud_date`, from the epoch on either side of UTC +midnight, and from a TT epoch half a minute after midnight, which is still the day +before in UTC; the errors for a missing map and for a scene with no day; L2's +refusal without clouds or without the atmosphere; and a swap that draws what a +fresh renderer draws, to the bit, on the GPU and lavapipe. + +Limitations: + +- **Seams.** Terra crosses the equator at 10:30 local time and Aqua at 13:30, so + where one satellite's data gives way to the other's, clouds that moved in three + hours end on a line. So do the edges of a single satellite's passes, made at + different times of the day. +- **One map per day.** The clouds of a whole UTC day are drawn at any time of it, + though each place is seen by MODIS only around late morning and early afternoon. +- **Thickness at 1 km, map at 2.45 km.** Broken clouds below that blur, as in the + static map. +- **Clear where MODIS saw no cloud,** including where its retrieval failed for + other reasons, such as sun glint; there the map has no cloud that may have been + there. +- **Polar night** takes the static map, which is dark there anyway. + ### The Moon's surface Code: `moon.rs`, `shaders/bodies.wgsl`; data from `scripts/fetch-moon.py`. From diff --git a/examples/scenes/ace.toml b/examples/scenes/ace.toml index 448b7ff..e361746 100644 --- a/examples/scenes/ace.toml +++ b/examples/scenes/ace.toml @@ -19,6 +19,8 @@ up = [1, 0, 0] # At lighting L1, Earth's surface shows July (from scripts/fetch-earth.sh). [appearance] earth_month = 7 +# At lighting L2, the clouds of 2024 July 15 (from scripts/fetch-dated-clouds.py). +cloud_date = "2024-07-15" [[target]] model = "../models/nasa/ace.glb" diff --git a/examples/scenes/boxwing.toml b/examples/scenes/boxwing.toml index 5a83130..9ba296f 100644 --- a/examples/scenes/boxwing.toml +++ b/examples/scenes/boxwing.toml @@ -27,6 +27,8 @@ up = [1, 0, 0] # At lighting L1, Earth's surface shows July (from scripts/fetch-earth.sh). [appearance] earth_month = 7 +# At lighting L2, the clouds of 2024 July 15 (from scripts/fetch-dated-clouds.py). +cloud_date = "2024-07-15" [[target]] model = "../models/boxwing.gltf" diff --git a/examples/scenes/earthrise.toml b/examples/scenes/earthrise.toml index 8136a76..d9d16dc 100644 --- a/examples/scenes/earthrise.toml +++ b/examples/scenes/earthrise.toml @@ -27,6 +27,8 @@ up = [0.672625, 0.739984, 0.000000] # At lighting L1, Earth's surface shows July (from scripts/fetch-earth.sh). [appearance] earth_month = 7 +# At lighting L2, the clouds of 2024 July 15 (from scripts/fetch-dated-clouds.py). +cloud_date = "2024-07-15" [[target]] model = "../models/nasa/landsat8.glb" diff --git a/examples/scenes/geo-telescope-1000km.toml b/examples/scenes/geo-telescope-1000km.toml index 20c320b..32681cf 100644 --- a/examples/scenes/geo-telescope-1000km.toml +++ b/examples/scenes/geo-telescope-1000km.toml @@ -31,6 +31,8 @@ velocity = [-691.66, 2995.90, 0.00] # the target's Earth-facing side. [appearance] earth_month = 7 +# At lighting L2, the clouds of 2024 July 15 (from scripts/fetch-dated-clouds.py). +cloud_date = "2024-07-15" [[target]] model = "../models/nasa/ssl1300.glb" diff --git a/examples/scenes/geo-telescope-100km-side.toml b/examples/scenes/geo-telescope-100km-side.toml index be70979..c764b0d 100644 --- a/examples/scenes/geo-telescope-100km-side.toml +++ b/examples/scenes/geo-telescope-100km-side.toml @@ -29,6 +29,8 @@ velocity = [-691.66, 2995.90, 0.00] # the target's Earth-facing side. [appearance] earth_month = 7 +# At lighting L2, the clouds of 2024 July 15 (from scripts/fetch-dated-clouds.py). +cloud_date = "2024-07-15" [[target]] model = "../models/nasa/ssl1300.glb" diff --git a/examples/scenes/geo-telescope-100km.toml b/examples/scenes/geo-telescope-100km.toml index 66d8a07..87bc383 100644 --- a/examples/scenes/geo-telescope-100km.toml +++ b/examples/scenes/geo-telescope-100km.toml @@ -30,6 +30,8 @@ velocity = [-691.66, 2995.90, 0.00] # the target's Earth-facing side. [appearance] earth_month = 7 +# At lighting L2, the clouds of 2024 July 15 (from scripts/fetch-dated-clouds.py). +cloud_date = "2024-07-15" [[target]] model = "../models/nasa/ssl1300.glb" diff --git a/examples/scenes/icesat2.toml b/examples/scenes/icesat2.toml index 0d0b0f5..12675bf 100644 --- a/examples/scenes/icesat2.toml +++ b/examples/scenes/icesat2.toml @@ -18,6 +18,8 @@ up = [1, 0, 0] # At lighting L1, Earth's surface shows July (from scripts/fetch-earth.sh). [appearance] earth_month = 7 +# At lighting L2, the clouds of 2024 July 15 (from scripts/fetch-dated-clouds.py). +cloud_date = "2024-07-15" [[target]] model = "../models/nasa/icesat2.glb" diff --git a/examples/scenes/landsat8.toml b/examples/scenes/landsat8.toml index e389243..d40ff8b 100644 --- a/examples/scenes/landsat8.toml +++ b/examples/scenes/landsat8.toml @@ -18,6 +18,8 @@ up = [1, 0, 0] # At lighting L1, Earth's surface shows July (from scripts/fetch-earth.sh). [appearance] earth_month = 7 +# At lighting L2, the clouds of 2024 July 15 (from scripts/fetch-dated-clouds.py). +cloud_date = "2024-07-15" [[target]] model = "../models/nasa/landsat8.glb" diff --git a/examples/scenes/moonrise.toml b/examples/scenes/moonrise.toml index c2a4d95..0345318 100644 --- a/examples/scenes/moonrise.toml +++ b/examples/scenes/moonrise.toml @@ -27,6 +27,8 @@ up = [1, 0, 0] # At lighting L1, Earth's surface shows July (from scripts/fetch-earth.sh). [appearance] earth_month = 7 +# At lighting L2, the clouds of 2024 July 15 (from scripts/fetch-dated-clouds.py). +cloud_date = "2024-07-15" [[target]] model = "../models/nasa/ssl1300.glb" diff --git a/examples/scenes/ostm.toml b/examples/scenes/ostm.toml index 8d7303b..5960622 100644 --- a/examples/scenes/ostm.toml +++ b/examples/scenes/ostm.toml @@ -18,6 +18,8 @@ up = [1, 0, 0] # At lighting L1, Earth's surface shows July (from scripts/fetch-earth.sh). [appearance] earth_month = 7 +# At lighting L2, the clouds of 2024 July 15 (from scripts/fetch-dated-clouds.py). +cloud_date = "2024-07-15" [[target]] model = "../models/nasa/ostm.glb" diff --git a/examples/scenes/ssl1300.toml b/examples/scenes/ssl1300.toml index c821de1..d50efc6 100644 --- a/examples/scenes/ssl1300.toml +++ b/examples/scenes/ssl1300.toml @@ -18,6 +18,8 @@ up = [1, 0, 0] # At lighting L1, Earth's surface shows July (from scripts/fetch-earth.sh). [appearance] earth_month = 7 +# At lighting L2, the clouds of 2024 July 15 (from scripts/fetch-dated-clouds.py). +cloud_date = "2024-07-15" [[target]] model = "../models/nasa/ssl1300.glb" diff --git a/examples/scenes/stars-pleiades.toml b/examples/scenes/stars-pleiades.toml index 17a0752..3b0fa52 100644 --- a/examples/scenes/stars-pleiades.toml +++ b/examples/scenes/stars-pleiades.toml @@ -24,3 +24,5 @@ velocity = [0.00, 3074.70, 0.00] # At lighting L1, Earth's surface shows July (from scripts/fetch-earth.sh). [appearance] earth_month = 7 +# At lighting L2, the clouds of 2024 July 15 (from scripts/fetch-dated-clouds.py). +cloud_date = "2024-07-15" diff --git a/examples/scenes/sun-glare.toml b/examples/scenes/sun-glare.toml index 0e53a2c..18666a2 100644 --- a/examples/scenes/sun-glare.toml +++ b/examples/scenes/sun-glare.toml @@ -26,6 +26,8 @@ up = [1, 0, 0] # the satellite's Earth-facing side. [appearance] earth_month = 7 +# At lighting L2, the clouds of 2024 July 15 (from scripts/fetch-dated-clouds.py). +cloud_date = "2024-07-15" [[target]] model = "../models/nasa/landsat8.glb" diff --git a/examples/scenes/sunset.toml b/examples/scenes/sunset.toml index 8f10ba3..8e6ce51 100644 --- a/examples/scenes/sunset.toml +++ b/examples/scenes/sunset.toml @@ -26,6 +26,8 @@ up = [-0.433571433, 0.901119200, 0.000000000] # on the night side, dark. [appearance] earth_month = 7 +# At lighting L2, the clouds of 2024 July 15 (from scripts/fetch-dated-clouds.py). +cloud_date = "2024-07-15" [[target]] model = "../models/nasa/landsat8.glb" diff --git a/readme.md b/readme.md index cbde4ed..f0a3dbc 100644 --- a/readme.md +++ b/readme.md @@ -132,7 +132,11 @@ to download and about 3 minutes to make 4.9 GB), which the CLI and the movie use when it is there. Clouds over Earth come from the Blue Marble's cloud map (Terra MODIS, 2001 July 29), which `scripts/fetch-clouds.sh` downloads (420 MB) and resizes into `data/clouds/blue-marble.png`; `[appearance] clouds = false` leaves -them out. The Moon's surface +them out. Lighting L2 draws the clouds of the scene's day instead (its epoch's, or +`[appearance] cloud_date`), which `scripts/fetch-dated-clouds.py FIRST [LAST]` makes +from MODIS for each day in a range (about two minutes and 0.3 GB of downloads a +day) into `data/clouds/YYYY-MM-DD.png`; the example scenes use 2024-07-15 and their +epochs' days. The Moon's surface comes from the LRO Camera's normalized-reflectance mosaic, which `scripts/fetch-moon.py` downloads (1.2 GB) and assembles (with numpy and Pillow) into `data/moon/wac-hapke-643.png`. The Moon is turned by the scene's epoch, or diff --git a/scripts/fetch-dated-clouds.py b/scripts/fetch-dated-clouds.py new file mode 100755 index 0000000..dffbe2d --- /dev/null +++ b/scripts/fetch-dated-clouds.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 +"""Downloads MODIS clouds for each day of a date range and makes the renderer's cloud +maps for lighting L2 from them. + + scripts/fetch-dated-clouds.py FIRST [LAST] + +FIRST and LAST are dates, YYYY-MM-DD; LAST defaults to FIRST. Each day becomes +data/clouds/YYYY-MM-DD.png, 16384 x 8192, one gray channel: the clouds' reflectance +times 255, as data/clouds/blue-marble.png (scripts/fetch-clouds.sh) holds it. A day +already made is skipped. + +Source: NASA's Global Imagery Browse Services (GIBS, https://gibs.earthdata.nasa.gov), +which serves the MODIS cloud products (MOD06 on Terra, MYD06 on Aqua) as images, one +global composite per day. Per day it takes six layers, each as the western and +eastern hemispheres at 8192 x 8192 (GIBS times out on the whole map at once): + +- cloud optical thickness, standard and partly cloudy retrievals (PCL), from Terra + and Aqua: images whose colors stand for values in steps of about 4 %, by GIBS's + color map (data/clouds/source/thickness.xml, fetched with the first day); +- cloud fraction from Terra and Aqua, only to tell where there is no data at all. + +A pixel's optical thickness is the first of Terra's standard retrieval, Aqua's, +Terra's PCL and Aqua's PCL that has one; its reflectance is +0.13 tau / (1 + 0.13 tau) (crates/resolved/src/clouds.rs). A pixel with none of them +but a cloud fraction from either satellite is clear: thickness is retrieved for +every cloudy pixel the cloud mask finds in daylight. A pixel with no data from +either, between the satellites' passes or in polar night, takes the static map's +value, so data/clouds/blue-marble.png must be there first. + +About 0.3 GB of downloads and a few minutes per day; nothing is kept but the map +(about 20 MB). Needs numpy and Pillow. +""" + +import argparse +import concurrent.futures +import datetime +import io +import re +import sys +import time +import urllib.error +import urllib.request +import xml.etree.ElementTree as ET +from pathlib import Path + +import numpy as np +from PIL import Image + +Image.MAX_IMAGE_PIXELS = None + +WMS = "https://gibs.earthdata.nasa.gov/wms/epsg4326/best/wms.cgi" +COLORMAP = "https://gibs.earthdata.nasa.gov/colormaps/v1.3/MODIS_VIIRS_Cloud_Optical_Thickness.xml" +# Optical thickness in the order taken. +THICKNESS = [ + "MODIS_Terra_Cloud_Optical_Thickness", + "MODIS_Aqua_Cloud_Optical_Thickness", + "MODIS_Terra_Cloud_Optical_Thickness_PCL", + "MODIS_Aqua_Cloud_Optical_Thickness_PCL", +] +FRACTION = ["MODIS_Terra_Cloud_Fraction_Day", "MODIS_Aqua_Cloud_Fraction_Day"] +SIDE = 8192 # pixels on a hemisphere's side +OUT_W, OUT_H = 2 * SIDE, SIDE +# Hemispheres: the column in the map, and the WMS 1.3.0 bounding box in EPSG:4326's +# axis order, latitude first. +HALVES = [(0, "-90,-180,90,0"), (1, "-90,0,90,180")] +REFLECTANCE_SCALE = 0.13 + + +def download(address, tries=4): + """The body at `address`, retrying with a growing pause when GIBS times out.""" + for attempt in range(tries): + try: + with urllib.request.urlopen(address, timeout=300) as response: + kind = response.headers.get("Content-Type", "") + body = response.read() + if address.startswith(WMS) and not kind.startswith("image/png"): + raise RuntimeError(f"{address}: {kind}: {body[:300]!r}") + return body + except (urllib.error.URLError, TimeoutError) as e: + if attempt == tries - 1: + raise + wait = 10 * (attempt + 1) + print(f" {e}; retrying in {wait} s", file=sys.stderr) + time.sleep(wait) + + +def colormap(path): + """Sorted packed RGB colors and the optical thickness each stands for: the + geometric mean of its bin, whose bounds are about 4 % apart.""" + colors, values = [], [] + for entry in ET.parse(path).getroot().iter("ColorMapEntry"): + if entry.get("nodata") == "true": + continue + r, g, b = (int(x) for x in entry.get("rgb").split(",")) + low, high = (float(x) for x in re.findall(r"[-\d.]+", entry.get("value"))) + colors.append((r << 16) | (g << 8) | b) + values.append((low * high) ** 0.5) + order = np.argsort(colors) + colors, values = np.array(colors)[order], np.array(values)[order] + if len(np.unique(colors)) != len(colors): + raise RuntimeError(f"{path}: a color stands for more than one value") + return colors, values + + +def layer(name, day, box): + """One hemisphere of a layer on `day` as RGBA.""" + query = ( + f"SERVICE=WMS&REQUEST=GetMap&VERSION=1.3.0&LAYERS={name}&CRS=EPSG:4326" + f"&BBOX={box}&WIDTH={SIDE}&HEIGHT={SIDE}&FORMAT=image/png&TIME={day}" + ) + return np.asarray(Image.open(io.BytesIO(download(f"{WMS}?{query}"))).convert("RGBA")) + + +def decode(rgba, colors, values): + """Optical thickness per pixel, NaN where the layer has none.""" + packed = ( + (rgba[..., 0].astype(np.int32) << 16) + | (rgba[..., 1].astype(np.int32) << 8) + | rgba[..., 2].astype(np.int32) + ) + opaque = rgba[..., 3] > 0 + index = np.clip(np.searchsorted(colors, packed), 0, len(colors) - 1) + known = colors[index] == packed + if (opaque & ~known).any(): + raise RuntimeError(f"{int((opaque & ~known).sum())} pixels of colors not in the map") + return np.where(opaque, values[index], np.nan).astype(np.float32) + + +def make(day, root, colors, values, static): + """The map for `day`, and the fractions of Earth's area that took thickness, were + clear, and had no data.""" + reflectance = np.empty((OUT_H, OUT_W), np.uint8) + counts = {"thickness": 0.0, "clear": 0.0, "none": 0.0, "area": 0.0, "sum": 0.0} + names = THICKNESS + FRACTION + with concurrent.futures.ThreadPoolExecutor(4) as pool: + for column, box in HALVES: + images = dict(zip(names, pool.map(lambda n: layer(n, day, box), names))) + tau = np.full((SIDE, SIDE), np.nan, np.float32) + for name in THICKNESS: + tau = np.where(np.isnan(tau), decode(images.pop(name), colors, values), tau) + seen = np.zeros((SIDE, SIDE), bool) + for name in FRACTION: + seen |= images.pop(name)[..., 3] > 0 + has = ~np.isnan(tau) + none = ~has & ~seen + x = REFLECTANCE_SCALE * np.nan_to_num(tau) + r = np.where(has, x / (1.0 + x), 0.0) + xs = slice(column * SIDE, (column + 1) * SIDE) + r = np.where(none, static[:, xs] / 255.0, r) + reflectance[:, xs] = np.round(255.0 * r).astype(np.uint8) + lat = np.pi * (0.5 - (np.arange(OUT_H) + 0.5) / OUT_H) + w = np.cos(lat)[:, None] + counts["thickness"] += float((w * has).sum()) + counts["clear"] += float((w * (~has & seen)).sum()) + counts["none"] += float((w * none).sum()) + counts["sum"] += float((w * r).sum()) + counts["area"] += float(w.sum()) * SIDE + print(f" {day}: {['western', 'eastern'][column]} hemisphere done", flush=True) + if counts["thickness"] == 0.0: + raise RuntimeError(f"GIBS has no MODIS cloud thickness for {day}") + part = root / f"{day}.part.png" + Image.fromarray(reflectance).save(part) + part.rename(root / f"{day}.png") + area = counts["area"] + return ( + counts["thickness"] / area, + counts["clear"] / area, + counts["none"] / area, + counts["sum"] / area, + ) + + +def days(first, last): + day = first + while day <= last: + yield day + day += datetime.timedelta(days=1) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + parser.add_argument("first", type=datetime.date.fromisoformat) + parser.add_argument("last", type=datetime.date.fromisoformat, nargs="?") + args = parser.parse_args() + last = args.last or args.first + if last < args.first: + sys.exit("LAST is before FIRST") + root = Path(__file__).resolve().parent.parent / "data" / "clouds" + static_path = root / "blue-marble.png" + if not static_path.is_file(): + sys.exit(f"{static_path} is missing: run scripts/fetch-clouds.sh first") + (root / "source").mkdir(parents=True, exist_ok=True) + thickness = root / "source" / "thickness.xml" + if not thickness.is_file(): + thickness.write_bytes(download(COLORMAP)) + colors, values = colormap(thickness) + static = None + for day in days(args.first, last): + if (root / f"{day}.png").is_file(): + continue + if static is None: + static = np.asarray(Image.open(static_path).convert("L")) + if static.shape != (OUT_H, OUT_W): + sys.exit(f"{static_path} is {static.shape[1]} x {static.shape[0]}, not {OUT_W} x {OUT_H}") + print(f"fetching {day}", flush=True) + start = time.time() + retrieved, clear, none, mean = make(day, root, colors, values, static) + print( + f"{day}: thickness over {100 * retrieved:.1f} % of Earth's area, clear " + f"{100 * clear:.1f} %, no data {100 * none:.1f} % (the static map's), mean " + f"reflectance {mean:.3f}, in {time.time() - start:.0f} s", + flush=True, + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/gallery-delta.py b/scripts/gallery-delta.py index 9c00b3b..7b4c168 100755 --- a/scripts/gallery-delta.py +++ b/scripts/gallery-delta.py @@ -9,7 +9,7 @@ HEAD~1 against HEAD. A revision is a commit (anything git rev-parse takes) or `worktree`, the working tree as it is. Each revision renders every example scene in its own examples/scenes/ at the gallery's -tiers (lighting L0 and L1, camera C1), the pretty preview for looking at and the raw DN +tiers (lighting L0, L1 and L2, camera C1), the pretty preview for looking at and the raw DN for comparing. Renders are noiseless (`resolved render --noiseless`), so an unchanged scene comes out identical and a changed one differs only where its light does; --noise keeps the detector's noise, which then also differs wherever the light does. A commit @@ -43,7 +43,7 @@ from PIL import Image ROOT = Path(__file__).resolve().parent.parent DELTA = ROOT / "target" / "delta" OUT = ROOT / "gallery" / "delta" -TIERS = ["L0", "L1"] +TIERS = ["L0", "L1", "L2"] CAMERA = "C1" # Renders at once. Each loads its own copy of the Earth and Moon maps; four at once ran # the RTX 3060 (12 GB) out of memory, and with a sharp Earth map (4.9 GB) two do. @@ -197,7 +197,11 @@ def compare(before, after, scene_changed, name, tier): shutil.copy(after / "pretty.png", OUT / f"{stem}-after.png") item["after"] = f"{stem}-after.png" if not isinstance(before, Path) or not isinstance(after, Path): - item["status"] = "new" if before is None else "removed" if after is None else "failed" + # Missing, or failed only before (a tier the earlier revision lacks): new. + if isinstance(after, Path): + item["status"] = "new" + else: + item["status"] = "removed" if after is None else "failed" for side, value in (("before", before), ("after", after)): if isinstance(value, str): item[f"{side}_error"] = value diff --git a/scripts/make-example-scenes.py b/scripts/make-example-scenes.py index 7c4f019..6a6fb48 100755 --- a/scripts/make-example-scenes.py +++ b/scripts/make-example-scenes.py @@ -173,6 +173,8 @@ up = [1, 0, 0] # At lighting L1, Earth's surface shows July (from scripts/fetch-earth.sh). [appearance] earth_month = 7 +# At lighting L2, the clouds of 2024 July 15 (from scripts/fetch-dated-clouds.py). +cloud_date = "2024-07-15" [[target]] model = "../models/nasa/{file}" @@ -295,6 +297,8 @@ up = {fmt(up, 6)} # At lighting L1, Earth's surface shows July (from scripts/fetch-earth.sh). [appearance] earth_month = 7 +# At lighting L2, the clouds of 2024 July 15 (from scripts/fetch-dated-clouds.py). +cloud_date = "2024-07-15" [[target]] model = "../models/nasa/{file}" @@ -443,6 +447,8 @@ up = [1, 0, 0] # At lighting L1, Earth's surface shows July (from scripts/fetch-earth.sh). [appearance] earth_month = 7 +# At lighting L2, the clouds of 2024 July 15 (from scripts/fetch-dated-clouds.py). +cloud_date = "2024-07-15" [[target]] model = "../models/nasa/{file}" @@ -523,6 +529,8 @@ up = [1, 0, 0] # the satellite's Earth-facing side. [appearance] earth_month = 7 +# At lighting L2, the clouds of 2024 July 15 (from scripts/fetch-dated-clouds.py). +cloud_date = "2024-07-15" [[target]] model = "../models/nasa/{file}" @@ -594,6 +602,8 @@ up = {fmt(up, 9)} # on the night side, dark. [appearance] earth_month = 7 +# At lighting L2, the clouds of 2024 July 15 (from scripts/fetch-dated-clouds.py). +cloud_date = "2024-07-15" [[target]] model = "../models/nasa/{file}" @@ -641,6 +651,8 @@ velocity = [0.00, {GEO_SPEED:.2f}, 0.00] # At lighting L1, Earth's surface shows July (from scripts/fetch-earth.sh). [appearance] earth_month = 7 +# At lighting L2, the clouds of 2024 July 15 (from scripts/fetch-dated-clouds.py). +cloud_date = "2024-07-15" """ @@ -698,6 +710,8 @@ velocity = {fmt(velocity)} # the target's Earth-facing side. [appearance] earth_month = 7 +# At lighting L2, the clouds of 2024 July 15 (from scripts/fetch-dated-clouds.py). +cloud_date = "2024-07-15" [[target]] model = "../models/nasa/{file}" diff --git a/scripts/make-gallery.sh b/scripts/make-gallery.sh index f8b5388..52cb245 100755 --- a/scripts/make-gallery.sh +++ b/scripts/make-gallery.sh @@ -1,14 +1,15 @@ #!/bin/sh -# Renders every example scene at lighting L0 and L1, with camera C1, into gallery/ -# (gitignored), as -L0.png and -L1.png previews made with --pretty: white -# balanced, with a tone curve, from exposures that keep the raw signal short of -# saturation. A scene whose model, maps or kernels are missing is skipped with the -# reason. Then the showcase movie, gallery/movie.mp4 (resolved-movie, which needs -# ffmpeg). View the result with scripts/serve-gallery.py. +# Renders every example scene at lighting L0, L1 and L2, with camera C1, into gallery/ +# (gitignored), as -L0.png, -L1.png and -L2.png previews made +# with --pretty: white balanced, with a tone curve, from exposures that keep the raw +# signal short of saturation. A scene whose model, maps or kernels are missing is +# skipped with the reason. Then the showcase movie, gallery/movie.mp4 (resolved-movie, +# which needs ffmpeg). View the result with scripts/serve-gallery.py. # -# Needs: scripts/fetch-models.sh for the NASA models, scripts/fetch-earth.sh, -# scripts/fetch-moon.py and scripts/fetch-stars.py for L1, and astrokit's kernels for -# scenes given by epoch. +# Needs: scripts/fetch-models.sh for the NASA models; scripts/fetch-earth.sh, +# scripts/fetch-clouds.sh, scripts/fetch-moon.py and scripts/fetch-stars.py for L1; +# scripts/fetch-dated-clouds.py for each scene's day for L2 (2024-07-15, 2025-03-14, +# 2026-01-03 and 2026-08-12); and astrokit's kernels for scenes given by epoch. set -e cd "$(dirname "$0")/.." @@ -20,7 +21,7 @@ made=0 skipped=0 for scene in examples/scenes/*.toml; do name=$(basename "$scene" .toml) - for tier in L0 L1; do + for tier in L0 L1 L2; do out="gallery/$name-$tier.png" if error=$(./target/release/resolved render --scene "$scene" --lighting "$tier" \ --camera C1 --preview "$out" --pretty 2>&1); then diff --git a/scripts/serve-gallery.py b/scripts/serve-gallery.py index 45ee5ad..ee89870 100755 --- a/scripts/serve-gallery.py +++ b/scripts/serve-gallery.py @@ -4,8 +4,8 @@ looking at the example renders from another machine on the network. python3 scripts/serve-gallery.py [--port 8000] [--live-port 8010] -Stills: each example scene gets a card with its L0 and L1 renders side by side, both -with camera C1, captioned from the comment at the top of its file in examples/scenes/. +Stills: each example scene gets a card with its L0, L1 and L2 renders side by side, +all with camera C1, captioned from the comment at the top of its file in examples/scenes/. Click an image to see it full size; arrow keys step through them and Escape closes. Movie: gallery/movie.mp4 (from make-gallery.sh or resolved-movie --out) to play or @@ -36,7 +36,7 @@ ROOT = Path(__file__).resolve().parent.parent GALLERY = ROOT / "gallery" DELTA = GALLERY / "delta" SCENES = ROOT / "examples" / "scenes" -TIERS = ["L0", "L1"] +TIERS = ["L0", "L1", "L2"] # The camera tier make-gallery.sh renders with. CAMERA = "C1"