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

nordisk/facett

facett-core/tests/gpu_oit.rs

//! **GFX_V2 item 7 — OIT is genuinely ORDER-INDEPENDENT** (needs `--features wgpu`).
//!
//! The point of order-independent transparency is the name. So the load-bearing
//! assertion here renders **the same overlapping translucent geometry in six different
//! submission orders** and requires the resulting pixels to be *byte-identical*. A
//! depth-sorted alpha blend cannot pass that, and neither can an OIT whose per-pixel
//! comparator is only a partial order.
//!
//! **But cross-order identity ALONE is a hollow guard**, and it is hollow in the exact
//! way this track keeps finding: a resolve that returned a constant colour — or one
//! whose linked list never received a single fragment — would be perfectly identical
//! across all six orders and prove nothing. That is the identity-value trap. So every
//! order-independence assertion here is paired with three that a dead pass fails:
//!
//! 1. **An independent CPU oracle of the composite** at named pixels. The expected
//!    colour is folded on the host from the *sorted* fragment set, in `f32`, and
//!    compared to the device's byte. A pass that skipped a layer, sorted the wrong way,
//!    or premultiplied twice lands on a different byte.
//! 2. **The device's own fragment counter** (`OitPass::fragment_count`) — the bump
//!    allocator the shader `atomicAdd`ed, read back out of GPU memory. It must equal
//!    the covered-area sum, so "no fragment ever reached a list" is not mistakable for
//!    "the frame is stable".
//! 3. **Region distinctness** — the 1-, 2- and 3-layer zones must all differ from each
//!    other and from the background, so a constant frame fails.
//!
//! It also proves the two properties the order-independence *rests* on, separately,
//! because each is a place where a plausible implementation silently becomes
//! order-dependent:
//!
//! * **Ties.** Coplanar quads at *equal depth* with different colours are the case a
//!   depth-only comparator gets wrong. The shader breaks ties on the packed colour;
//!   `equal_depth_layers_are_still_order_independent` submits them both ways round.
//! * **Overflow.** Past `OIT_MAX_LAYERS` fragments on one pixel, keeping "the first N
//!   seen" would be submission-order dependent. `overflow_selects_the_nearest_layers`
//!   stacks 24 layers on one pixel and requires identity across orders anyway.
//!
//! Run: `cargo test -p facett-core --features wgpu --test gpu_oit -- --nocapture`
#![cfg(feature = "wgpu")]

use facett_core::render::gpu::oit::{OitBatch, OitPass, OIT_MAX_LAYERS};
use facett_core::render::gpu::{preferred_backends, read_texture_region, request_best_adapter};
use egui::{pos2, Rect};

/// The component every row and every SKIP in this file is filed under.
const GATE_COMPONENT: &str = "facett-core";
/// What a skipped arm of this file would have proven, so a SKIPPED matrix row is
/// readable without opening the test.
const GATE_DETAIL: &str = "order-independent transparency on a real device — identical pixels under every submission order";

const TARGET_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
const W: u32 = 96;
const H: u32 = 96;

/// A real device via the **sanctioned** selector — same helper shape as
/// `tests/gpu_picking.rs`, for the same reason (`request_adapter(&Default::default())`
/// asks for `LowPower` and picks the wrong GPU on a hybrid box).
///
/// Returns the adapter too, because this lane needs a downlevel capability
/// (`FRAGMENT_WRITABLE_STORAGE`) and a caller must be able to say so out loud rather
/// than fail inside pipeline creation.
fn device() -> Option<(wgpu::Adapter, wgpu::Device, wgpu::Queue)> {
    let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
        backends: preferred_backends(),
        flags: wgpu::InstanceFlags::from_build_config().with_env(),
        backend_options: wgpu::BackendOptions::from_env_or_default(),
        memory_budget_thresholds: wgpu::MemoryBudgetThresholds::default(),
        display: None,
    });
    let adapter = request_best_adapter(&instance, preferred_backends())?;
    // ATTRIBUTION, not decoration. A graceful `None` skip is exactly how a GPU test
    // file goes green having run no shader, so print the device that answered — and
    // print whether it can run this lane at all.
    let info = adapter.get_info();
    eprintln!(
        "[gpu_oit] device: {} ({:?}, {:?}) fragment_writable_storage={}",
        info.name,
        info.backend,
        info.device_type,
        OitPass::supported(&adapter)
    );
    let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
        label: Some("gpu_oit_test"),
        required_features: wgpu::Features::empty(),
        required_limits: wgpu::Limits::downlevel_defaults(),
        ..Default::default()
    }))
    .ok()?;
    Some((adapter, device, queue))
}

fn target(device: &wgpu::Device) -> (wgpu::Texture, wgpu::TextureView) {
    let tex = device.create_texture(&wgpu::TextureDescriptor {
        label: Some("gpu_oit_target"),
        size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
        mip_level_count: 1,
        sample_count: facett_core::render::gpu::NO_MSAA_SAMPLES,
        dimension: wgpu::TextureDimension::D2,
        format: TARGET_FORMAT,
        usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
        view_formats: &[],
    });
    let view = tex.create_view(&Default::default());
    (tex, view)
}

/// Render `batch` through the OIT lane and return `(rgba bytes, device fragment count)`.
fn render(
    device: &wgpu::Device,
    queue: &wgpu::Queue,
    batch: &OitBatch,
    background: [f32; 4],
    layers: u32,
) -> (Vec<u8>, u32) {
    let (tex, view) = target(device);
    let mut pass = OitPass::new(device, TARGET_FORMAT);
    pass.ensure(device, W, H, layers);
    pass.set_frame(queue, background);
    pass.upload(device, queue, batch);

    let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
        label: Some("gpu_oit_enc"),
    });
    let submitted = pass.record(&mut enc, &view);
    queue.submit(Some(enc.finish()));
    assert_eq!(submitted, batch.len() as u32, "the pass submitted the whole batch");

    let bytes = read_texture_region(device, queue, &tex, 4, 0, 0, W, H);
    let frags = pass.fragment_count(device, queue);
    (bytes, frags)
}

fn px(bytes: &[u8], x: u32, y: u32) -> [u8; 4] {
    let o = ((y * W + x) * 4) as usize;
    [bytes[o], bytes[o + 1], bytes[o + 2], bytes[o + 3]]
}

/// **The independent oracle.** Fold `src OVER dst` back-to-front over `bg`, in `f32`,
/// from the fragment set sorted by the SAME total order the shader uses (depth, then
/// packed colour). Re-derived on the host on purpose: if it shared code with the shader
/// it could not catch the shader being wrong.
fn oracle(bg: [f32; 4], mut frags: Vec<(f32, [f32; 4])>) -> [u8; 4] {
    // Quantise exactly as `pack4x8unorm` does, then compare on the same total order —
    // otherwise the oracle's tie-break and the device's could differ.
    let packed = |c: [f32; 4]| -> u32 {
        let b = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u32;
        b(c[0]) | (b(c[1]) << 8) | (b(c[2]) << 16) | (b(c[3]) << 24)
    };
    frags.sort_by(|a, b| {
        // Farthest first.
        b.0.partial_cmp(&a.0)
            .unwrap()
            .then(packed(b.1).cmp(&packed(a.1)))
    });
    let unpack = |c: [f32; 4]| -> [f32; 4] {
        let p = packed(c);
        [
            (p & 0xFF) as f32 / 255.0,
            ((p >> 8) & 0xFF) as f32 / 255.0,
            ((p >> 16) & 0xFF) as f32 / 255.0,
            ((p >> 24) & 0xFF) as f32 / 255.0,
        ]
    };
    let mut rgb = [bg[0], bg[1], bg[2]];
    let mut a = bg[3];
    for (_, c) in &frags {
        let s = unpack(*c);
        for k in 0..3 {
            rgb[k] = s[k] * s[3] + rgb[k] * (1.0 - s[3]);
        }
        a = s[3] + a * (1.0 - s[3]);
    }
    let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
    [q(rgb[0]), q(rgb[1]), q(rgb[2]), q(a)]
}

/// Colours on exact `k/255` steps, so `pack4x8unorm` is lossless and the oracle's
/// arithmetic is the device's arithmetic rather than an approximation of it.
const RED: [f32; 4] = [204.0 / 255.0, 0.0, 0.0, 128.0 / 255.0];
const GREEN: [f32; 4] = [0.0, 204.0 / 255.0, 0.0, 128.0 / 255.0];
const BLUE: [f32; 4] = [0.0, 0.0, 204.0 / 255.0, 128.0 / 255.0];
const BG: [f32; 4] = [16.0 / 255.0, 16.0 / 255.0, 16.0 / 255.0, 1.0];

/// Three half-transparent quads in a staircase, so the frame has a 1-layer, a 2-layer
/// and a 3-layer zone. Depths are chosen so **painter order is NOT depth order** — the
/// nearest quad is submitted first — which is what makes a naive "blend as submitted"
/// implementation produce a different picture.
///
/// Returns the batch plus the probe points and their expected fragment sets.
#[allow(clippy::type_complexity)]
fn staircase() -> (OitBatch, Vec<(&'static str, u32, u32, Vec<(f32, [f32; 4])>)>) {
    let mut b = OitBatch::new();
    // BLUE is NEAREST (depth 1) but pushed FIRST; RED is farthest (depth 3) but last.
    b.push_quad(Rect::from_min_max(pos2(8.0, 8.0), pos2(56.0, 56.0)), BLUE, 1.0);
    b.push_quad(Rect::from_min_max(pos2(24.0, 24.0), pos2(72.0, 72.0)), GREEN, 2.0);
    b.push_quad(Rect::from_min_max(pos2(40.0, 40.0), pos2(88.0, 88.0)), RED, 3.0);

    let probes = vec![
        ("background", 4u32, 4u32, vec![]),
        ("blue only", 12, 12, vec![(1.0, BLUE)]),
        ("blue+green", 30, 30, vec![(1.0, BLUE), (2.0, GREEN)]),
        ("all three", 48, 48, vec![(1.0, BLUE), (2.0, GREEN), (3.0, RED)]),
        ("green+red", 64, 64, vec![(2.0, GREEN), (3.0, RED)]),
        ("red only", 80, 80, vec![(3.0, RED)]),
    ];
    (b, probes)
}

/// The six submission orders. Every permutation of 3 quads = 6 triangle orders, so this
/// is *exhaustive* over quad orderings rather than a sample.
fn quad_orders() -> Vec<(&'static str, Vec<usize>)> {
    let tris = |q: [usize; 3]| -> Vec<usize> { q.iter().flat_map(|&i| [i * 2, i * 2 + 1]).collect() };
    vec![
        ("0,1,2 (as built)", tris([0, 1, 2])),
        ("0,2,1", tris([0, 2, 1])),
        ("1,0,2", tris([1, 0, 2])),
        ("1,2,0", tris([1, 2, 0])),
        ("2,0,1", tris([2, 0, 1])),
        ("2,1,0 (reversed)", tris([2, 1, 0])),
    ]
}

#[test]
fn oit_pixels_are_identical_across_every_submission_order_and_match_the_oracle() {
    let Some((adapter, device, queue)) = device() else {
        facett_core::testmatrix::gpu_skip(GATE_COMPONENT, "oit_pixels_are_identical_across_every_submission_order_and_match_the_oracle", "no WebGPU-class adapter", GATE_DETAIL);
        return;
    };
    if !OitPass::supported(&adapter) {
        facett_core::testmatrix::gpu_skip(GATE_COMPONENT, "oit_pixels_are_identical_across_every_submission_order_and_match_the_oracle", "adapter lacks FRAGMENT_WRITABLE_STORAGE", GATE_DETAIL);
        return;
    }
    let (base, probes) = staircase();

    let mut frames: Vec<(&str, Vec<u8>, u32)> = Vec::new();
    for (name, order) in quad_orders() {
        let permuted = base.permuted(&order);
        assert_eq!(permuted.len(), base.len(), "{name}: the permutation is the same scene");
        let (bytes, frags) = render(&device, &queue, &permuted, BG, 8);
        frames.push((name, bytes, frags));
    }

    // ── (2) NON-VACUITY FIRST: the device really rasterised fragments. ──
    // 3 quads of 48×48 px = 3 · 2304 = 6912 covered fragments. The gather emits one
    // node per covered fragment, so the bump allocator must land exactly there. A
    // resolve that returns a constant, or a gather that never ran, gives 0.
    let expect_frags = 3 * 48 * 48;
    for (name, _, frags) in &frames {
        assert_eq!(
            *frags, expect_frags,
            "{name}: the DEVICE's fragment counter must be {expect_frags} — a stable frame with 0 \
             fragments proves nothing"
        );
    }
    eprintln!("[gpu_oit] device fragment count per frame: {expect_frags} (all 6 orders)");

    // ── (1) THE ORACLE: the composite is CORRECT, not merely stable. ──
    let reference = &frames[0].1;
    for (label, x, y, frags) in &probes {
        let got = px(reference, *x, *y);
        let want = oracle(BG, frags.clone());
        eprintln!("[gpu_oit] probe {label:>12} at ({x},{y}): got {got:?} want {want:?}");
        assert_eq!(
            got, want,
            "probe '{label}' at ({x},{y}): the device composite must equal the independent CPU \
             oracle of the sorted fragment set"
        );
    }

    // ── (3) DISTINCTNESS: a constant frame fails. ──
    let seen: Vec<[u8; 4]> = probes.iter().map(|(_, x, y, _)| px(reference, *x, *y)).collect();
    for i in 0..seen.len() {
        for j in (i + 1)..seen.len() {
            assert_ne!(
                seen[i], seen[j],
                "probes '{}' and '{}' must differ — every zone the same colour is a dead resolve",
                probes[i].0, probes[j].0
            );
        }
    }

    // ── THE HEADLINE: byte-identical across all six submission orders. ──
    for (name, bytes, _) in &frames[1..] {
        assert_eq!(
            bytes.len(),
            reference.len(),
            "{name}: same frame size"
        );
        let differing = bytes
            .chunks(4)
            .zip(reference.chunks(4))
            .filter(|(a, b)| a != b)
            .count();
        assert_eq!(
            differing, 0,
            "submission order '{name}' changed {differing} pixels — OIT is not order-independent"
        );
    }
    eprintln!(
        "[gpu_oit] 6/6 submission orders byte-identical over {}×{} px",
        W, H
    );

    // And the painter-order control: submitting nearest-first vs farthest-first are two
    // of the six orders above, so the picture we just froze is NOT simply "the last
    // quad wins". Prove that by checking the 3-layer probe is not any single quad over
    // the background.
    let three = px(reference, 48, 48);
    for (c, nm) in [(BLUE, "blue"), (GREEN, "green"), (RED, "red")] {
        assert_ne!(
            three,
            oracle(BG, vec![(1.0, c)]),
            "the 3-layer probe must not equal {nm} alone over the background"
        );
    }
}

#[test]
fn equal_depth_layers_are_still_order_independent() {
    let Some((adapter, device, queue)) = device() else {
        facett_core::testmatrix::gpu_skip(GATE_COMPONENT, "equal_depth_layers_are_still_order_independent", "no WebGPU-class adapter", GATE_DETAIL);
        return;
    };
    if !OitPass::supported(&adapter) {
        facett_core::testmatrix::gpu_skip(GATE_COMPONENT, "equal_depth_layers_are_still_order_independent", "adapter lacks FRAGMENT_WRITABLE_STORAGE", GATE_DETAIL);
        return;
    }
    // Two COPLANAR quads: same depth, different colours, fully overlapping. This is the
    // case a depth-only comparator resolves in list order, i.e. in submission order.
    let mut a = OitBatch::new();
    let r = Rect::from_min_max(pos2(16.0, 16.0), pos2(80.0, 80.0));
    a.push_quad(r, RED, 5.0);
    a.push_quad(r, GREEN, 5.0);

    let (fwd, f1) = render(&device, &queue, &a, BG, 8);
    let (rev, f2) = render(&device, &queue, &a.permuted(&[2, 3, 0, 1]), BG, 8);

    let expect = 2 * 64 * 64;
    assert_eq!(f1, expect, "forward: the device counted every covered fragment");
    assert_eq!(f2, expect, "reversed: same");

    let centre_f = px(&fwd, 48, 48);
    let centre_r = px(&rev, 48, 48);
    eprintln!("[gpu_oit] equal-depth centre: forward {centre_f:?} reversed {centre_r:?}");
    // Non-vacuity: both layers really contributed, so the pixel is neither quad alone.
    assert_ne!(centre_f, oracle(BG, vec![(5.0, RED)]), "not red alone");
    assert_ne!(centre_f, oracle(BG, vec![(5.0, GREEN)]), "not green alone");
    assert_ne!(centre_f, [16, 16, 16, 255], "not the background");
    assert_eq!(
        fwd, rev,
        "two COPLANAR translucent quads must composite identically either way round — \
         this is the tie the colour tie-break in the comparator exists for"
    );
}

#[test]
fn overflow_selects_the_nearest_layers_and_stays_order_independent() {
    let Some((adapter, device, queue)) = device() else {
        facett_core::testmatrix::gpu_skip(GATE_COMPONENT, "overflow_selects_the_nearest_layers_and_stays_order_independent", "no WebGPU-class adapter", GATE_DETAIL);
        return;
    };
    if !OitPass::supported(&adapter) {
        facett_core::testmatrix::gpu_skip(GATE_COMPONENT, "overflow_selects_the_nearest_layers_and_stays_order_independent", "adapter lacks FRAGMENT_WRITABLE_STORAGE", GATE_DETAIL);
        return;
    }
    // 24 stacked layers over one region — more than OIT_MAX_LAYERS (16), so the resolve
    // MUST overflow. Keeping "the first 16 seen" would depend on submission order.
    let n: u32 = 24;
    assert!(n > OIT_MAX_LAYERS, "the point is to exceed the register array");
    let mut b = OitBatch::new();
    let r = Rect::from_min_max(pos2(24.0, 24.0), pos2(72.0, 72.0));
    for i in 0..n {
        // Distinct depth AND distinct colour per layer.
        let v = (40 + i * 8) as f32 / 255.0;
        b.push_quad(r, [v, v * 0.5, 1.0 - v, 40.0 / 255.0], 1.0 + i as f32);
    }
    let tris = b.tri_count();
    // Node storage per pixel must exceed the stack, otherwise the ALLOCATOR drops the
    // tail and we would be testing that instead of the resolve's selection policy.
    let (fwd, f1) = render(&device, &queue, &b, BG, 32);
    let rev_order: Vec<usize> = (0..tris).rev().collect();
    let (rev, f2) = render(&device, &queue, &b.permuted(&rev_order), BG, 32);

    let expect = n * 48 * 48;
    assert_eq!(f1, expect, "every one of the {n} layers reached a list");
    assert_eq!(f2, expect, "…in the reversed order too");

    let c = px(&fwd, 48, 48);
    eprintln!("[gpu_oit] overflow centre ({n} layers, cap {OIT_MAX_LAYERS}): {c:?}");
    assert_ne!(c, [16, 16, 16, 255], "the overflowed pixel is not the background");
    assert_eq!(
        fwd, rev,
        "with {n} layers on one pixel and room for {OIT_MAX_LAYERS}, the surviving set must be \
         chosen by the total order (the NEAREST ones), not by arrival — otherwise overflow \
         reintroduces order dependence exactly where a small test never looks"
    );

    // And overflow is not silently equivalent to no overflow: the same stack truncated
    // to OIT_MAX_LAYERS nearest layers is what we should see, which differs from the
    // full 24-layer composite. Prove the cap actually bites.
    let mut near_only = OitBatch::new();
    for i in 0..OIT_MAX_LAYERS {
        let v = (40 + i * 8) as f32 / 255.0;
        near_only.push_quad(r, [v, v * 0.5, 1.0 - v, 40.0 / 255.0], 1.0 + i as f32);
    }
    let (capped, _) = render(&device, &queue, &near_only, BG, 32);
    assert_eq!(
        px(&capped, 48, 48),
        c,
        "the 24-layer pixel must equal the 16-NEAREST-layer pixel — that is what 'selects the \
         nearest' means, and it is how we know the cap bit rather than the tail being dropped"
    );
}