facett-map3d/tests/flightsim_shadows.rs
//! **Cascaded shadow map — PNG blob oracle.** The single biggest "it's 3D now"
//! win: the building blocks cast real shadows onto the ground (and each other) via
//! a depth-only render from the sun + PCF in the main pass.
//!
//! The render is invisible from a return value, so this test renders the SAME
//! block town twice — shadows ON vs OFF (`with_shadows(0.0)`) — reads back both,
//! and asserts the GROUND is measurably DARKER with shadows on (the blocks throw
//! dark casts) while the lit roofs stay bright. Saves both PNGs the agent looks at.
//!
//! Run: `cargo test -p facett-map3d --features wgpu,testmatrix --test flightsim_shadows -- --nocapture`
#![cfg(feature = "wgpu")]
use egui_kittest::Harness;
use egui_kittest::wgpu::{WgpuTestRenderer, create_render_state, default_wgpu_setup};
use facett_core::Facet as _;
use facett_map::Way;
use facett_map3d::{Map3D, SunSky, ViewPreset};
/// A few tall block buildings spread over a ~1.5 km town, so their shadows fall on
/// open ground a top-down-ish camera can see.
fn town() -> Vec<Way> {
let (lon0, lat0) = (9.50_f64, 47.10_f64);
let step = 0.004;
let bsz = 0.0012;
let mut ways = Vec::new();
let mut id = 1i64;
for r in 0..3 {
for c in 0..3 {
let bx = lon0 + c as f64 * step;
let by = lat0 + r as f64 * step;
let ring = [(bx, by), (bx + bsz, by), (bx + bsz, by + bsz), (bx, by + bsz), (bx, by)];
let tags = vec![
("building".to_string(), "yes".to_string()),
("height".to_string(), "140".to_string()),
];
ways.push(Way::from_lonlat(id, &ring, &tags, &facett_core::Theme::default()).unwrap());
id += 1;
}
}
ways
}
fn scene(shadow: f32) -> Map3D {
// A low sun so the blocks throw long, visible shadows on the ground.
let sun = SunSky::from_angles(0.9, 0.28);
let mut m = Map3D::from_ways("shadows", &town(), 0.4).with_gpu(true).with_sun(sun).with_shadows(shadow);
m.spin = false;
m.set_clock(0.0);
m.view_preset(ViewPreset::Isometric);
m.cam.goal_distance = 2.8;
m.cam.goal_target = facett_map3d::V3::new(0.0, 0.05, 0.0);
m.cam.settle();
m
}
fn render(shadow: f32) -> image::RgbaImage {
let rs = create_render_state(default_wgpu_setup(), Default::default());
facett_map3d::gpu::install_renderer(&rs);
let mut view = scene(shadow);
let mut harness = Harness::builder()
.with_size(egui::vec2(640.0, 640.0))
.renderer(WgpuTestRenderer::from_render_state(rs))
.build_ui(move |ui| {
facett_core::set_theme(ui.ctx(), facett_core::Theme::sci_fi());
view.ui(ui);
});
harness.run_steps(4);
harness.render().expect("wgpu render")
}
/// Per-pixel luminance (Rec. 709).
fn luma(p: &image::Rgba<u8>) -> f64 {
0.2126 * f64::from(p.0[0]) + 0.7152 * f64::from(p.0[1]) + 0.0722 * f64::from(p.0[2])
}
/// **What the shadow pass DID to this frame**, measured on the same pixel coordinates
/// in both renders: `(darkened, brightened, mean OFF-luma over the darkened set,
/// non-background px)`.
///
/// # Why this replaced two absolute luma bands, and what broke them
///
/// This test used to classify "ground" by an absolute band — `8 < luma < 130`, neutral —
/// average it in each frame, and assert the ON mean was lower. That is two independent
/// mistakes stacked, and both of them fired.
///
/// * **It compared two DIFFERENT pixel sets.** A cast shadow moves a pixel's luma, which
/// moves it in and out of the band, so the two means were taken over different
/// populations — `ground_n` 270 114 ON vs 250 698 OFF on 2026-08-22. A mean over a
/// set defined by the very quantity being compared cannot report a direction.
/// * **The band had rotted twice and finally inverted.** `b7b75fc5` (2026-08-04) re-derived
/// the aerial haze and the scene scale in metres; the sunlit ground moved to luma
/// **144–192**, i.e. entirely ABOVE the 130 cap. So the "ground mean" was sampling the
/// haze dither BELOW the ground instead, where shadows-ON is (correctly) a touch
/// brighter — `on 25.50 > off 18.60`, an inverted comparison of the wrong pixels. The
/// band's own comment records an earlier recalibration for IBL and another for ACES;
/// this is the third time an absolute luma constant went stale under a look change,
/// which is the reason there is no longer one here.
///
/// The difference `OFF − ON` at the SAME coordinate needs no ground classifier at all:
/// the two renders share scene, camera, device and frame, and differ only in
/// `with_shadows`. What a shadow pass does is subtract light in the shape of the
/// occluders, so the claim is stated directly — a large darkened area, essentially
/// nothing brightened, and the darkening landing on surfaces that were LIT.
fn shadow_delta(on: &image::RgbaImage, off: &image::RgbaImage) -> (usize, usize, f64, usize) {
let (mut darkened, mut brightened, mut lit_sum, mut body) = (0usize, 0usize, 0.0f64, 0usize);
for (a, b) in on.pixels().zip(off.pixels()) {
let (lo, lf) = (luma(a), luma(b));
if lf > 8.0 {
body += 1;
}
let d = lf - lo;
if d > DELTA_PX {
darkened += 1;
lit_sum += lf;
} else if d < -DELTA_PX {
brightened += 1;
}
}
(darkened, brightened, lit_sum / darkened.max(1) as f64, body)
}
/// A luma step big enough to be a cast shadow rather than sampling noise. The measured
/// histogram of `OFF − ON` on 2026-08-22 is bimodal — 277 653 px in ±8 (unshaded) and a
/// second lobe from +16 to +56 (the casts) — so 12 sits in the valley between them, not
/// on either shoulder.
const DELTA_PX: f64 = 12.0;
#[test]
fn shadow_pass_darkens_the_ground_under_the_blocks() {
let on = render(0.85);
let off = render(0.0);
image::DynamicImage::ImageRgba8(on.clone()).save("/tmp/map3d_shadows_on.png").ok();
image::DynamicImage::ImageRgba8(off.clone()).save("/tmp/map3d_shadows_off.png").ok();
let (darkened, brightened, lit_mean, body) = shadow_delta(&on, &off);
let dark_share = darkened as f64 / body.max(1) as f64;
eprintln!(
"EMIT-SHADOW darkened_px={darkened} ({:.1}% of the drawn frame) brightened_px={brightened} \
mean_off_luma_of_darkened={lit_mean:.1} body_px={body}",
100.0 * dark_share
);
// (0) NON-VACUITY: both frames actually drew the town, so a black pane cannot pass
// the "nothing got brighter" clause by containing nothing.
assert!(body > 100_000, "both frames drew a real scene ({body} non-background px)");
// (1) The shadow pass casts a real FOOTPRINT: a large connected fraction of the drawn
// frame is measurably darker with shadows on. Measured 2026-08-22: 122 132 px,
// 30.5 % of the drawn frame.
assert!(
dark_share > 0.10,
"shadows ON casts a real footprint — only {darkened} px ({:.1}%) of the frame got darker",
100.0 * dark_share
);
// (2) AND IT ONLY SUBTRACTS. A pass that perturbs the frame — a different exposure, a
// jittered camera, a re-uploaded mesh — moves pixels BOTH ways; a shadow removes
// light. Measured: 6 brightened px against 122 132 darkened.
assert!(
brightened * 100 < darkened,
"a shadow only subtracts light ({brightened} px got BRIGHTER against {darkened} darker)"
);
// (3) AND IT LANDS ON LIT SURFACE, not on the already-dark block faces or the sky —
// which is the half the old absolute band was reaching for. Measured: the darkened
// set averages 163.7 in the shadow-free frame, i.e. the sunlit ground.
assert!(
lit_mean > 100.0,
"the darkening falls on surfaces the sun was lighting (mean OFF luma {lit_mean:.1})"
);
#[cfg(feature = "testmatrix")]
facett_core::testmatrix::emit(
"facett-map3d",
"shadow_cascade_darkens_ground",
dark_share > 0.10 && brightened * 100 < darkened && lit_mean > 100.0,
&format!(
"blob=/tmp/map3d_shadows_on.png darkened_px={darkened} dark_share={dark_share:.3} brightened_px={brightened} mean_off_luma={lit_mean:.1}"
),
);
}