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

nordisk/facett

facett-map3d/tests/overlay_frame_stability.rs

//! **A riding overlay must not make the frame flicker** (korp ride, tune round 2:
//! "den flimrar") — consecutive-frame stability of the REAL wgpu lane while a
//! per-frame overlay rides with its near-plane override active.
//!
//! The suspects the second ride named — the per-frame overlay vbuf rewrite racing
//! the draw, an oscillating `near_override` re-banding the depth range, the
//! emissive-w path — all share one observable: two frames of an UNCHANGED scene
//! that differ. So that is the assertion, on applied output: render the same
//! pinned-clock scene on two consecutive harness frames and require the
//! framebuffers BYTE-IDENTICAL. Any nondeterminism in the upload path, the near
//! band, or the post chain fires it.
//!
//! LAW-2 red: the comparator itself is proven able to see a difference — a
//! deliberate one-hair camera nudge between two renders must produce a non-zero
//! pixel diff. A blind diff (wrong buffer, stale readback) passes the identity
//! half by accident and fails the nudge half.
//!
//! Run: `cargo test -p facett-map3d --features wgpu --test overlay_frame_stability`

#![cfg(feature = "wgpu")]

use egui_kittest::Harness;
use egui_kittest::wgpu::{WgpuTestRenderer, create_render_state, default_wgpu_setup};
use facett_core::Facet as _;
use facett_map3d::extrude::Mesh3;
use facett_map3d::{Map3D, OverlayPose};

/// An avatar-stand-in: a few triangles near the eye (the rider's-seat regime the
/// near-override exists for), plus an emissive face so the bloom path runs.
fn body() -> Mesh3 {
    Mesh3 {
        positions: vec![
            [0.0, 0.05, 0.30],
            [-0.20, 0.0, -0.20],
            [0.20, 0.0, -0.20],
            [0.0, 0.12, -0.10],
        ],
        normals: vec![[0.0, 1.0, 0.0]; 4],
        colors: vec![egui::Color32::from_rgb(35, 36, 40), egui::Color32::from_rgb(198, 40, 40)],
        indices: vec![0, 1, 2, 0, 3, 2],
        building: vec![false; 4],
    }
}

/// The pinned scene: the synthetic fixture, clock stopped, overlay riding close to
/// the camera so `near_override` is live (the flight regime under test).
fn rigged() -> Map3D {
    let mut m = Map3D::synthetic("frame-stability").with_clock(1.0);
    m.spin = false;
    m.set_overlay(
        &body(),
        &[0.0, 4.0],
        OverlayPose { position: [0.0, 0.35, 0.0], scale: 0.4, ..OverlayPose::default() },
    );
    m
}

/// A harness whose renderer runs `mode`. The strict byte-identity claim below needs
/// [`TaaMode::Off`]: a temporal accumulator changes the frame on purpose every frame, so
/// with it on, "two consecutive frames are byte-identical" is a statement about the
/// antialiasing and not about the overlay upload, the near band or the post chain — the
/// three suspects this file was written to arraign. The temporal lane gets its own
/// claim, [`the_temporal_lane_settles_instead_of_flickering`], rather than being allowed
/// to hide inside this one.
fn harness_mode(view: Map3D, mode: facett_map3d::gpu::TaaMode) -> Harness<'static> {
    let rs = create_render_state(default_wgpu_setup(), Default::default());
    facett_map3d::gpu::install_renderer_with_taa_for_test(&rs, mode);
    let view = view.with_gpu(true);
    let mut cell = Some(view);
    let mut held: Option<Map3D> = None;
    Harness::builder()
        .with_size(egui::vec2(640.0, 480.0))
        .renderer(WgpuTestRenderer::from_render_state(rs))
        .build_ui(move |ui| {
            let v = held.get_or_insert_with(|| cell.take().expect("view moved in once"));
            facett_core::set_theme(ui.ctx(), facett_core::Theme::sci_fi());
            v.ui(ui);
        })
}

fn harness(view: Map3D) -> Harness<'static> {
    harness_mode(view, facett_map3d::gpu::TaaMode::Off)
}

fn diff_px(a: &image::RgbaImage, b: &image::RgbaImage) -> usize {
    assert_eq!((a.width(), a.height()), (b.width(), b.height()), "same framebuffer shape");
    a.pixels().zip(b.pixels()).filter(|(p, q)| p != q).count()
}

/// **Two consecutive frames of an unchanged riding scene are byte-identical** —
/// and the comparator is proven sighted first.
#[test]
fn an_unchanged_riding_frame_renders_byte_identical_twice() {
    // ── the comparator's own red: a nudged camera must read as different ──────
    let mut nudged = rigged();
    nudged.cam.azimuth += 0.02;
    nudged.cam.goal_azimuth = nudged.cam.azimuth;
    let mut h2 = harness(nudged);
    h2.run_steps(4);
    let moved = h2.render().expect("wgpu render (nudged)");
    let mut h1 = harness(rigged());
    h1.run_steps(4);
    let base = h1.render().expect("wgpu render (base)");
    let seen = diff_px(&base, &moved);
    assert!(
        seen > 0,
        "the diff must SEE a one-hair camera nudge — a blind comparator proves nothing",
    );

    // ── the claim: same scene, next frame, zero changed pixels ────────────────
    let a = h1.render().expect("frame A");
    h1.run_steps(1);
    let b = h1.render().expect("frame B");
    let flicker = diff_px(&a, &b);
    assert_eq!(
        flicker, 0,
        "an unchanged riding scene must be pixel-stable frame to frame — {flicker} px \
         changed (overlay upload, near band or post chain is nondeterministic)",
    );
}

/// Drive `frames` real GPU frames through `view` with the temporal lane on, and report
/// the per-frame series of `(max channel delta, px moving by more than `LOUD`)` between
/// consecutive frames.
///
/// **NOTE THE FRAME DRIVER.** `run_steps` advances egui's logic; `render()` is what puts
/// a frame through the GPU lane, and therefore what advances the temporal accumulation.
/// Measuring "settled" after `run_steps(24)` and two renders measures frame 4 of the
/// ramp, not frame 28 — MEASURED that way it read 124 code values and looked like a
/// defect. Every frame here is a real render.
fn settle_series(view: Map3D, frames: usize) -> Vec<(u8, usize)> {
    let mut h = harness_mode(view, facett_map3d::gpu::TaaMode::On);
    let mut prev: Option<image::RgbaImage> = None;
    let mut out = Vec::with_capacity(frames);
    for _ in 0..frames {
        h.run_steps(1);
        let img = h.render().expect("a rendered frame");
        if let Some(p) = prev.take() {
            out.push((max_channel_delta(&p, &img), loud_px(&p, &img)));
        }
        prev = Some(img);
    }
    out
}

/// **AND THE TEMPORAL LANE ITSELF MUST SETTLE, not shimmer.**
///
/// The claim above is measured with TAA off, which would be a convenient place to stop:
/// the file's original complaint was "den flimrar", and a temporal antialiaser is by
/// construction a thing that changes every pixel every frame. So the question it raises
/// has to be asked rather than routed around — *does the AA lane put the flicker back?*
///
/// The ramp is what stops it: `min(cap, frame/(frame+1))` makes the early frames a
/// running mean and only then decays into an EMA, so the per-frame correction collapses
/// instead of holding. What is asserted is that shape, in two directions — the residual
/// must fall a long way from the ramp, and it must be CONFINED to a small part of the
/// frame rather than being a whole-image throb.
///
/// Not `== 0`: the 0.97 feedback cap is deliberate (an unbounded running mean would never
/// respond to a scene change at all), so a fixed 3% of each frame is the current one and
/// a periodic jitter sequence leaves a periodic residual. The honest claim is bounded,
/// shrinking and small — not absent.
///
/// **THE BLOOM ARM, AND THE SUSPICION IT CLEARED.** The temporal resolve runs *after*
/// the composite, so it accumulates a frame the half-res glow chain has already
/// filtered — and a half-res filter over a jittered image is exactly the shape that
/// varies with the jitter phase over areas too wide for a 3×3 neighbourhood clamp to
/// bound. That predicted a bloom-dependent shimmer, so both arms are measured rather
/// than assumed. MEASURED (640×480, RADV STRIX1): bloom on and bloom off give the same
/// series to within one code value, settling to 23, with **4 px of 307 200** moving by
/// more than 8. The interaction is not there at this bloom intensity, and the prediction
/// is recorded as refuted instead of quietly dropped.
///
/// The residual itself is the periodic one the design predicts: the series settles into
/// a repeating 8-frame pattern, which is the jitter cycle showing through the 3% of each
/// frame that the 0.97 feedback cap leaves un-averaged. Bounded, periodic, and on four
/// pixels.
#[test]
fn the_temporal_lane_settles_instead_of_flickering() {
    /// Frames driven through the lane. Comfortably past the ramp: `frame/(frame+1)`
    /// reaches the 0.97 feedback cap at frame 33.
    const FRAMES: usize = 48;

    for (name, view) in [("bloom-on", rigged()), ("bloom-off", rigged().with_bloom(false))] {
        let series = settle_series(view, FRAMES);
        let (d0, _) = series[0];
        let (late, late_loud) = *series.last().expect("at least two frames");
        let deltas: Vec<u8> = series.iter().map(|(d, _)| *d).collect();
        eprintln!("EMIT-TAASETTLE {name}: consecutive max channel delta = {deltas:?}");
        eprintln!(
            "EMIT-TAASETTLE {name}: ramp={d0} settled={late}, {late_loud} px of {PANE_PX} \
             move by more than {LOUD} code values"
        );

        // NON-VACUITY: the lane must be moving pixels during the ramp, or "it settled"
        // is a statement about a pass that never fired.
        assert!(d0 > 0, "{name}: the temporal lane must move pixels during the ramp ({d0})");
        // ── SETTLING, by a wide margin over the ramp rather than by a hair.
        assert!(
            u32::from(late) * 4 <= u32::from(d0),
            "{name}: the accumulation must settle, not oscillate — settled delta {late} \
             against a ramp delta of {d0} (series {deltas:?})"
        );
        // ── AND CONFINED. A residual of a few code values on a fringe of edge pixels is
        // TAA working; the same number spread over the frame would be a throb.
        // 4 px MEASURED on both arms; the budget is two orders of magnitude above that
        // so a driver or a scene tweak does not make this file flaky, and still three
        // orders below anything that would read as a throb.
        let cap = PANE_PX / 1000;
        assert!(
            late_loud <= cap,
            "{name}: the settled residual must be confined — {late_loud} of {PANE_PX} px \
             move by more than {LOUD} code values between two consecutive frames of an \
             unchanged scene (budget {cap})"
        );
    }
}

/// Pixels in the 640×480 pane.
const PANE_PX: usize = 640 * 480;
/// A per-channel change at or below this is under the noise an eye reads as flicker on a
/// moving-image display; above it, count the pixel.
const LOUD: u8 = 8;

/// How many pixels changed by more than [`LOUD`] on any channel.
fn loud_px(a: &image::RgbaImage, b: &image::RgbaImage) -> usize {
    a.pixels()
        .zip(b.pixels())
        .filter(|(p, q)| p.0.iter().zip(q.0.iter()).any(|(x, y)| x.abs_diff(*y) > LOUD))
        .count()
}

/// Largest absolute per-channel difference between two frames — the flicker an eye
/// would see, as opposed to `diff_px`'s count of pixels that moved at all.
fn max_channel_delta(a: &image::RgbaImage, b: &image::RgbaImage) -> u8 {
    assert_eq!((a.width(), a.height()), (b.width(), b.height()), "same framebuffer shape");
    a.pixels()
        .zip(b.pixels())
        .flat_map(|(p, q)| p.0.iter().zip(q.0.iter()).map(|(x, y)| x.abs_diff(*y)))
        .max()
        .unwrap_or(0)
}