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