facett-core/tests/gpu_sdf_parity.rs
//! **GPU SDF render-proof + CPU parity (CONS-CORE §6)** — drive the L0 GPU SDF
//! pipeline headlessly (its own off-screen wgpu device, no egui/window), prove it
//! draws a **non-blank** frame, and that the GPU frame **matches the CPU** frame
//! within an AA tolerance (the two share the same coverage math).
//!
//! Honest about hardware: [`offscreen_render`] returns `None` when no wgpu adapter
//! exists (a CPU-only / sandboxed CI box). In that case the GPU assertions are
//! skipped with an eprintln (the pipeline construction is still unit-tested in
//! `render::gpu::sdf_pipeline::tests`, and the CPU SDF math is fully covered by
//! `sdf_primitives.rs` / `thick_line.rs`). Set `FACETT_REQUIRE_GPU=1` to make a
//! missing adapter a hard failure.
#![cfg(feature = "wgpu")]
use facett_core::render::gpu::offscreen_render;
use facett_core::render::prim::{CircleInstance, LineInstance, MarkerInstance, RingInstance, shape};
use facett_core::render::{Camera, Canvas, CpuCanvas};
fn alpha(rgba: &[u8], w: u32, x: u32, y: u32) -> u8 {
rgba[((y * w + x) * 4 + 3) as usize]
}
/// Build the same scene for both backends: a circle, a ring, a diamond marker, and
/// two thick lines.
fn scene() -> (Vec<facett_core::render::prim::QuadInstance>, Vec<LineInstance>) {
let quads = vec![
CircleInstance { center: [60.0, 60.0], radius: 24.0, color: [1.0, 0.2, 0.1, 1.0], aa: 1.5 }.lower(),
RingInstance { center: [180.0, 60.0], radius: 26.0, inner: 14.0, color: [0.1, 0.9, 0.3, 1.0], aa: 1.5 }.lower(),
MarkerInstance { center: [60.0, 180.0], radius: 22.0, corner: 0.0, color: [0.3, 0.5, 1.0, 1.0], aa: 1.0, shape: shape::DIAMOND }.lower(),
];
let lines = vec![
LineInstance::round([120.0, 130.0], [230.0, 220.0], 5.0, 1.5, [1.0, 1.0, 0.2, 1.0]),
LineInstance::butt([130.0, 220.0], [230.0, 130.0], 3.0, 1.0, [0.9, 0.3, 0.9, 1.0]),
];
(quads, lines)
}
#[test]
fn gpu_sdf_draws_nonblank_and_matches_cpu() {
let (w, h) = (256u32, 256u32);
let bg = [0.05, 0.06, 0.08, 1.0];
let (quads, lines) = scene();
let gpu = offscreen_render(w, h, bg, &quads, &lines);
let Some(gpu) = gpu else {
// Was an eprintln! plus a FACETT_REQUIRE_GPU panic and NO matrix row, so a
// CPU-only host recorded `ok` with a green cell beside it. `gpu_skip` keeps the
// panic, adds CI=1 to the hosts that must have hardware, and files the row as
// NOT passing.
facett_core::testmatrix::gpu_skip(
"facett-core",
"gpu_sdf_draws_nonblank_and_matches_cpu",
"no wgpu adapter available",
"the GPU SDF raster draws non-blank and matches the CPU coverage math within \
the AA tolerance (pipeline construction is unit-tested elsewhere)",
);
return;
};
eprintln!("[gpu_sdf_parity] GPU adapter present — running render-proof + parity");
assert_eq!(gpu.rgba.len(), (w * h * 4) as usize);
let bg8 = [
(bg[0] * 255.0).round() as u8,
(bg[1] * 255.0).round() as u8,
(bg[2] * 255.0).round() as u8,
(bg[3] * 255.0).round() as u8,
];
// 1. Non-blank: a real region differs from the clear colour (the background is
// opaque, so alpha is 255 everywhere — measure the *ink* footprint instead).
let ink = |p: &[u8]| -> usize {
p.as_chunks::<4>().0.iter()
.filter(|c| {
(c[0] as i32 - bg8[0] as i32).abs()
+ (c[1] as i32 - bg8[1] as i32).abs()
+ (c[2] as i32 - bg8[2] as i32).abs()
> 24
})
.count()
};
let gpu_ink = ink(&gpu.rgba);
eprintln!("[gpu_sdf_parity] GPU ink pixels {gpu_ink}");
assert!(gpu_ink > 1000, "GPU frame is non-blank (ink {gpu_ink})");
// The circle centre drew the reddest channel (r dominant over g/b).
let i = ((60 * w + 60) * 4) as usize;
eprintln!("[gpu_sdf_parity] GPU circle centre rgba = {:?}", &gpu.rgba[i..i + 4]);
assert!(
gpu.rgba[i] as i32 > gpu.rgba[i + 1] as i32 + 40 && gpu.rgba[i] as i32 > gpu.rgba[i + 2] as i32 + 40,
"GPU circle centre is red-dominant (got {:?})",
&gpu.rgba[i..i + 3]
);
// 2. Parity with the CPU lane. Same scene, same coverage math.
let mut canvas = CpuCanvas::new(w, h, Camera::default(), bg8);
canvas.push_lines(&lines);
canvas.push_quads(&quads);
let cpu = canvas.rasterize();
// Per-pixel alpha agreement: the lit footprints should overlap strongly. We
// compare a coarse mask (lit vs not) and allow a small AA-fringe / sRGB-vs-
// linear disagreement margin.
let mut disagree = 0usize;
for y in 0..h {
for x in 0..w {
let ga = alpha(&gpu.rgba, w, x, y);
let ca = alpha(&cpu.rgba, w, x, y);
// Both lanes paint over an opaque background, so alpha is ~255 almost
// everywhere; compare the *colour* footprint instead via the red/green
// channels relative to background. Mask = far from background.
let near_bg = |p: &[u8], idx: usize| {
(p[idx] as i32 - bg8[0] as i32).abs()
+ (p[idx + 1] as i32 - bg8[1] as i32).abs()
+ (p[idx + 2] as i32 - bg8[2] as i32).abs()
< 24
};
let idx = ((y * w + x) * 4) as usize;
let g_ink = !near_bg(&gpu.rgba, idx);
let c_ink = !near_bg(&cpu.rgba, idx);
let _ = (ga, ca);
if g_ink != c_ink {
disagree += 1;
}
}
}
let total = (w * h) as usize;
let frac = disagree as f64 / total as f64;
eprintln!("[gpu_sdf_parity] ink-mask disagreement {disagree}/{total} = {frac:.4}");
// Allow up to 4% boundary disagreement (AA fringe + sRGB target differences).
assert!(frac < 0.04, "GPU and CPU SDF footprints agree (disagreement {frac:.4})");
}