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

nordisk/facett

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