Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

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}"
        ),
    );
}