facett-graph3d/tests/gpu_graph_bloom.rs
//! **T3.4 render-proof — the graph3d GPU lane (instanced emissive nodes/edges +
//! bloom).**
//!
//! Drives [`facett_graph3d::graph_gpu`] headlessly: it renders a small graph through
//! the instanced node/edge pipelines into the shared HDR offscreen (additive blend),
//! runs the bright→blur→composite bloom post chain, and reads the LDR framebuffer +
//! the instance draw counts back. Every assertion is on DATA (draw counts, per-pixel
//! luminance, bloom A/B energy) — never an eyeballed pixel.
//!
//! Run: `cargo test -p facett-graph3d --features wgpu --test gpu_graph_bloom`
//! (needs a wgpu adapter / `lavapipe`; self-skips with a printed note when none is
//! present, so a CPU-only box never sees a false failure).
#![cfg(feature = "wgpu")]
use egui::Color32;
use facett_graph3d::graph_gpu::{
render_graph_offscreen, BloomParams, EdgeInstance, GraphScene, NodeInstance,
};
use facett_graph3d::{Graph3D, Layout3D};
/// Serialise GPU work across the test threads: spinning up several `lavapipe`
/// adapters/devices concurrently is not robust (it can SIGSEGV in the software
/// driver). Each GPU test holds this for its duration so only one device lives at a
/// time — the renders themselves are unchanged.
static GPU_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// A ring graph plus a few chords (so edges cross) with a bright cyan palette — the
/// emissive colours the bloom is meant to catch.
fn sample_graph(n: usize) -> Graph3D {
let nodes: Vec<(String, Color32)> =
(0..n).map(|i| (format!("n{i}"), Color32::from_rgb(40, 230, 255))).collect();
let mut edges: Vec<(usize, usize)> = (0..n).map(|i| (i, (i + 1) % n)).collect();
// Chords that cross the ring interior — crossings should accumulate radiance.
for k in 0..n / 2 {
edges.push((k, (k + n / 2) % n));
}
Graph3D::new(nodes, edges, Layout3D::Sphere)
}
/// CPU-only oracle (no GPU needed): `gpu_scene` projects every node + keeps in-range
/// edges, and the emissive scaling pushes the brightest channel **above 1.0** so the
/// bright pass (threshold ≈ 1.0) actually catches the graph. A FAIL-ON-BUG guard on
/// the scene builder that runs on every box.
#[test]
fn gpu_scene_emits_instances_with_emissive_above_one() {
let g = sample_graph(12);
let scene = g.gpu_scene(256.0, 256.0, 3.0);
assert_eq!(scene.nodes.len(), 12, "one instance per node");
assert_eq!(scene.edges.len(), 12 + 6, "ring + chords, all in range");
// Bright cyan at emissive 3.0 ⇒ at least one channel of radiance > 1.0.
let peak = scene.nodes.iter().map(|nd| nd.color[1]).fold(0.0f32, f32::max);
assert!(peak > 1.0, "node emissive radiance must exceed 1.0 to bloom (got {peak})");
// The bytemuck layout the shader reads is the size we declared.
assert_eq!(std::mem::size_of::<NodeInstance>(), 32);
assert_eq!(std::mem::size_of::<EdgeInstance>(), 40);
}
/// INJECT-ASSERT (render proof): the GPU lane draws the right number of node/edge
/// instances, produces a **non-blank** frame, and the bloom pass adds measurable
/// energy (bloom-on mean luminance strictly exceeds bloom-off) — the HDR additive
/// emissive + bright→blur→composite chain working end to end.
#[test]
fn graph_gpu_draws_instances_nonblank_with_bloom_energy() {
let _guard = GPU_GUARD.lock().unwrap_or_else(|p| p.into_inner());
let g = sample_graph(12);
let scene = g.gpu_scene(256.0, 256.0, 3.0);
let (w, h) = (256, 256);
let Some(on) = render_graph_offscreen(w, h, &scene, BloomParams::default()) else {
facett_core::testmatrix::gpu_skip(
"facett-graph3d",
"graph_gpu_draws_instances_nonblank_with_bloom_energy",
"no wgpu adapter",
"the graph3d instance lane draws 12 nodes non-blank and bloom adds energy",
);
return;
};
// Draw counts match the graph (the "node/edge draw counts" assertion).
assert_eq!(on.node_instances, 12, "12 node instances issued");
assert_eq!(on.edge_instances, 18, "18 edge instances issued");
// Non-blank: a real lit graph, not a cleared pane.
let luma_on = on.mean_luma();
eprintln!("[graph3d-gpu] mean luma bloom-on = {luma_on:.5}");
assert!(luma_on > 0.01, "frame must be non-blank (got {luma_on:.5})");
// Bloom A/B: the same scene with bloom disabled must be DIMMER — the bloom pass
// is the only difference, so more total energy proves it fired (a no-op bloom
// would tie). This is the bug oracle on the post chain.
let off = render_graph_offscreen(w, h, &scene, BloomParams::default().off())
.expect("adapter present (on render already succeeded)");
let luma_off = off.mean_luma();
eprintln!("[graph3d-gpu] mean luma bloom-off = {luma_off:.5}");
assert!(
luma_on > luma_off * 1.02,
"bloom must add energy: on={luma_on:.5} should exceed off={luma_off:.5}"
);
}
/// INJECT-ASSERT (additive accumulation): two emissive discs stacked on the SAME
/// texel must read brighter than one — that is exactly what `src=ONE, dst=ONE` blend
/// gives (an alpha-over / replace blend would tie). Rendered with bloom off and a
/// sub-saturation emissive so the overlap stays inside the tonemap's linear range and
/// the difference is unambiguous DATA. This is the "edge crossings accumulate" claim,
/// proven on the simplest geometry.
#[test]
fn additive_blend_accumulates_overlapping_radiance() {
let _guard = GPU_GUARD.lock().unwrap_or_else(|p| p.into_inner());
let (w, h) = (128u32, 128u32);
let c = [64.0f32, 64.0]; // centre in px
let mk = |count: usize| GraphScene {
background: [0.0, 0.0, 0.0, 1.0],
nodes: (0..count)
.map(|_| NodeInstance {
center: c,
radius: 36.0,
_pad: 0.0,
// Mid radiance: one disc tonemaps well below white, so two sum higher.
color: [0.35, 0.35, 0.35, 1.0],
})
.collect(),
edges: vec![],
};
let bloom_off = BloomParams::default().off();
let Some(one) = render_graph_offscreen(w, h, &mk(1), bloom_off) else {
facett_core::testmatrix::gpu_skip(
"facett-graph3d",
"additive_blend_accumulates_overlapping_radiance",
"no wgpu adapter",
"two overlapping emitters accumulate more radiance than one — the additive \
blend state, read off the framebuffer",
);
return;
};
let two = render_graph_offscreen(w, h, &mk(2), bloom_off).expect("adapter present");
let l1 = one.luma_at(64, 64);
let l2 = two.luma_at(64, 64);
eprintln!("[graph3d-gpu] centre luma: one disc={l1:.4}, two discs={l2:.4}");
assert!(l1 > 0.05, "a single disc lights the centre (got {l1:.4})");
assert!(
l2 > l1 * 1.15,
"two stacked emissive discs must accumulate brighter than one (one={l1:.4}, two={l2:.4})"
);
}