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/cpu_zbuffer.rs

//! **CPU-painter depth + colour proofs** (the wasm / `CpuPainterBackend` lane).
//!
//! The desktop `WgpuVelloBackend` renders OSM-3D correctly (real GPU z-buffer + ACES
//! post). The CPU painter used to (a) resolve overlaps by a painter's-order centroid
//! sort — which TEARS where a small near face overlaps a large far one, and on the
//! coplanar `GROUND_LIFT` ground layers — and (b) skip the tonemap, so the terracotta
//! roofs read as muddy dark brown. These are the two red-when-broken oracles for the
//! fixes: a per-pixel software z-buffer ([`raster_zbuffer`]) and a wgpu-matched tonemap
//! ([`tonemap_shaded`]). Both are pure + headless (no GPU, no golden).
//!
//! Run: `cargo test -p facett-map3d --test cpu_zbuffer -- --nocapture`

use egui::{Color32, Pos2};
use facett_map3d::{raster_zbuffer, tonemap_shaded, ShadedTri};

/// The dominant default roof tint (terracotta tile) — `default_roof_tint`'s first
/// palette entry, the colour that read muddy before the tonemap.
const TERRACOTTA: Color32 = Color32::from_rgb(176, 92, 66);
/// The directional-light `shade` a flat, up-facing roof cap catches (n=+Y, the
/// upper-front-left light): `0.30 + 0.70·|n·l| ≈ 0.79`.
const ROOF_SHADE: f32 = 0.787;

/// **DEPTH oracle (red-when-broken).** A small NEAR triangle overlaps a large FAR one.
/// The tris are handed to the rasterizer in the WRONG painter order (near FIRST, far
/// LAST) — so a naive last-wins painter (the pre-fix egui-mesh path) would draw the far
/// triangle OVER the near one at the overlap. The z-buffer must instead keep the NEAR
/// surface at every overlapped pixel. If the z-test is removed, the centre reads FAR.
#[test]
fn zbuffer_resolves_overlap_by_true_depth_not_paint_order() {
    let w = 80usize;
    let h = 80usize;
    // FAR: a big triangle covering the whole frame, view-depth 5.0 (far).
    let far = ShadedTri {
        pts: [Pos2::new(-10.0, -10.0), Pos2::new(200.0, -10.0), Pos2::new(-10.0, 200.0)],
        vz: [5.0, 5.0, 5.0],
        depth: 5.0,
        col: Color32::from_rgb(20, 160, 40), // green terrain
    };
    let far2 = ShadedTri {
        pts: [Pos2::new(200.0, -10.0), Pos2::new(200.0, 200.0), Pos2::new(-10.0, 200.0)],
        vz: [5.0, 5.0, 5.0],
        depth: 5.0,
        col: Color32::from_rgb(20, 160, 40),
    };
    // NEAR: a small triangle around the centre, view-depth 1.0 (near).
    let near = ShadedTri {
        pts: [Pos2::new(30.0, 30.0), Pos2::new(50.0, 30.0), Pos2::new(40.0, 50.0)],
        vz: [1.0, 1.0, 1.0],
        depth: 1.0,
        col: Color32::from_rgb(200, 110, 80), // terracotta roof
    };

    // Hand them over near-FIRST, far-LAST — the order a painter that trusts submission
    // would get wrong. The z-buffer is order-independent for non-coplanar depth.
    let px = raster_zbuffer(&[near, far, far2], w, h, Pos2::ZERO, 1.0);
    let centre = px[40 * w + 40];
    let near_wins = (centre.r(), centre.g(), centre.b()) == (200, 110, 80);

    // A corner the near tri does NOT cover stays the far green (sanity: the far tri drew).
    let corner = px[2 * w + 2];
    let far_drew = (corner.r(), corner.g(), corner.b()) == (20, 160, 40);

    // And a pixel with NO coverage is transparent (sky shows through).
    let px2 = raster_zbuffer(&[near], w, h, Pos2::ZERO, 1.0);
    let transparent = px2[0].a() == 0;

    #[cfg(feature = "testmatrix")]
    facett_core::testmatrix::emit(
        "facett-map3d-cpu-zbuffer",
        "depth_resolves_by_true_depth",
        near_wins && far_drew && transparent,
        &format!("near_wins={near_wins} far_drew={far_drew} sky_transparent={transparent}"),
    );
    assert!(
        near_wins,
        "z-buffer must keep the NEAR terracotta face at the overlap centre (got {centre:?}) — \
         a painter's-order last-wins would show the FAR green here"
    );
    assert!(far_drew, "far surface fills the unoccluded corner");
    assert!(transparent, "uncovered pixels stay transparent for the sky to show");
}

/// **COLOUR oracle (red-when-broken).** The lit terracotta roof, tonemapped, must read
/// as a bright, warm terracotta — not the muddy dark brown the raw `albedo·shade`
/// produced. Reverting the tonemap drops the red channel back under the bright floor.
#[test]
fn tonemap_lifts_terracotta_roof_out_of_the_mud() {
    let tm = tonemap_shaded(TERRACOTTA, ROOF_SHADE);
    let (r, g, b) = ((tm[0] * 255.0) as i32, (tm[1] * 255.0) as i32, (tm[2] * 255.0) as i32);
    eprintln!("[tonemap] terracotta roof @shade {ROOF_SHADE} -> ({r},{g},{b})");

    // The muddy pre-fix value was the raw sRGB multiply ≈ (139, 73, 52). The tonemapped
    // roof must be clearly BRIGHTER than that and stay warm (r > g > b).
    // A near-black source stays dark (the tonemap doesn't wash shadows to grey).
    let dark = tonemap_shaded(Color32::from_rgb(12, 14, 16), 0.4);
    let dark_r = dark[0] * 255.0;
    #[cfg(feature = "testmatrix")]
    facett_core::testmatrix::emit(
        "facett-map3d-cpu-zbuffer",
        "tonemap_lifts_terracotta",
        r >= 175 && r > g && g > b && r - b >= 55 && dark_r < 60.0,
        &format!("roof=({r},{g},{b}) r_minus_b={} dark_r={dark_r:.1}", r - b),
    );
    assert!(r >= 175, "lit terracotta roof reads bright (r={r}), not muddy (~139)");
    assert!(r > g && g > b, "roof stays warm terracotta ordering r>g>b (got {r},{g},{b})");
    assert!(r - b >= 55, "warm red↔blue separation preserved (got r-b={})", r - b);
    assert!(dark_r < 60.0, "near-black stays dark after tonemap");
}