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/gpu_geometry_swap.rs

//! **A rebuilt scene must actually reach the GPU** — the replace-on-change proofs
//! for the two per-frame device seams `Map3dRenderer` owns.
//!
//! # The two defects these pin (both found 2026-08-12, perf/v2.1-nav)
//!
//! * **The upload latch.** `Map3dRenderer::upload` gated on `self.vbuf.is_some()`:
//!   the first mesh a process ever uploaded was the only mesh it could ever draw.
//!   korp rebuilds its 3D scene into a fresh payload per viewport move — every one
//!   of those rebuilds was built, deep-copied (see the backend fix) and then
//!   silently dropped at this gate. The fix compares [`GpuGeometry::generation`]
//!   (a build stamp) and re-writes the vertex + water buffers when it differs.
//! * **The sky-LUT re-upload** — the same defect mirrored: `ensure_sky_lut` did a
//!   `queue.write_texture` of the whole `Rgba32Float` LUT every frame the host
//!   supplied one; the dims check only gated the REALLOC. The fix skips the write
//!   when the payload is the same `Arc` as last time (the LUT is immutable behind
//!   its `Arc`; hosts swap LUTs by building a new one).
//!
//! # What is asserted, and in which direction it goes red
//!
//! Everything here is APPLIED OUTPUT (LAW 2): the bytes read back off the device
//! buffer/texture, never a state round-trip.
//!
//! * Upload A, then B: the vertex buffer's device bytes equal **B's** — restore the
//!   `vbuf.is_some()` latch and this reads A's bytes (seen red 2026-08-12).
//! * A payload with water, then one without: the water draw count drops to 0 — the
//!   old scene's lake must not float in the new scene.
//! * Same LUT `Arc` three frames running: exactly ONE texel write — remove the
//!   `Arc`-identity gate and this counts 3 (seen red 2026-08-12).
//! * A NEW LUT `Arc`: the write happens and the device texels equal the new
//!   payload's — the direction in which an over-eager skip would lie, pinned on
//!   texture contents so the gate can never swallow a real change.
//!
//! Adapter info is printed (GFX_V2 item 7 convention): the default egui_kittest
//! setup prefers a software rasteriser, and a claim measured on llvmpipe must say
//! so. `FACETT_TEST_GPU=discrete` runs it on facett's own adapter policy instead.

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

use std::sync::Arc;

use facett_core::render::gpu::{read_buffer_range, read_texture_region};
use facett_map3d::extrude::{Mesh3, WaterMesh};
use facett_map3d::gpu::{
    build_gpu_geometry, build_gpu_geometry_with_water, proof_wgpu_setup, Map3dRenderer,
};
use facett_map3d::SkyTransmittanceLut;

/// One render state for the proof, adapter printed (the tree's convention — a
/// number measured on a CPU rasteriser and reported as a GPU number is exactly the
/// kind of claim this suite keeps having to retract).
fn render_state() -> egui_wgpu::RenderState {
    let setup = proof_wgpu_setup().unwrap_or_else(egui_kittest::wgpu::default_wgpu_setup);
    let rs = egui_kittest::wgpu::create_render_state(setup, Default::default());
    let info = rs.adapter.get_info();
    println!("  adapter: {} [{:?}/{:?}]", info.name, info.device_type, info.backend);
    rs
}

/// A one-quad mesh (2 triangles) whose every byte is decided by `seed`, so two
/// fixtures are guaranteed distinct in positions AND colours.
fn mesh(seed: f32) -> Mesh3 {
    let mut m = Mesh3::default();
    let up = [0.0, 1.0, 0.0];
    for p in [
        [-0.5 + seed, 0.0, -0.5],
        [0.5 + seed, 0.0, -0.5],
        [0.5 + seed, 0.0, 0.5],
        [-0.5 + seed, 0.0, 0.5],
    ] {
        m.positions.push(p);
        m.normals.push(up);
    }
    m.indices.extend_from_slice(&[0, 1, 2, 0, 2, 3]);
    let c = egui::Color32::from_rgb((seed * 40.0) as u8 + 10, 80, 200);
    m.colors.push(c);
    m.colors.push(c);
    m
}

/// **THE RED LINE for the upload latch.** Geometry A lands, geometry B REPLACES it —
/// asserted on the vertex buffer's device bytes, then re-sending B is free-shaped
/// (same stamp ⇒ the same bytes stay put; this is the compare the backend leans on
/// when it re-sends its cached `Arc` every frame).
#[test]
fn a_second_geometry_replaces_the_first_on_the_device() {
    let rs = render_state();
    let mut r = Map3dRenderer::new(&rs.device, wgpu::TextureFormat::Rgba8UnormSrgb);

    let a = build_gpu_geometry(&mesh(0.0));
    let b = build_gpu_geometry(&mesh(3.0));
    assert_ne!(
        bytemuck::cast_slice::<_, u8>(&a.vertices),
        bytemuck::cast_slice::<_, u8>(&b.vertices),
        "fixture sanity: A and B must be byte-distinct or the proof proves nothing"
    );

    r.upload_for_test(&rs.device, &a);
    let (buf, n) = r.vbuf_for_test().expect("A uploaded");
    assert_eq!(n, a.vertices.len() as u32);
    let dev = read_buffer_range(&rs.device, &rs.queue, buf, 0, buf.size());
    assert_eq!(dev, bytemuck::cast_slice::<_, u8>(&a.vertices), "the device holds A");

    // The swap — under the old `vbuf.is_some()` latch the read-back below still
    // returned A's bytes (seen red 2026-08-12 by restoring the latch).
    r.upload_for_test(&rs.device, &b);
    let (buf, n) = r.vbuf_for_test().expect("B uploaded");
    assert_eq!(n, b.vertices.len() as u32, "the draw count follows the swap");
    let dev = read_buffer_range(&rs.device, &rs.queue, buf, 0, buf.size());
    assert_eq!(
        dev,
        bytemuck::cast_slice::<_, u8>(&b.vertices),
        "a REBUILT scene must reach the GPU — these are A's bytes if the first-upload latch is back"
    );

    // Same stamp re-sent (the every-frame shape): still B, still B's count.
    r.upload_for_test(&rs.device, &b);
    let (buf, _) = r.vbuf_for_test().expect("B still uploaded");
    let dev = read_buffer_range(&rs.device, &rs.queue, buf, 0, buf.size());
    assert_eq!(dev, bytemuck::cast_slice::<_, u8>(&b.vertices), "re-sending the same stamp is a no-op");
}

/// The replace-on-change twin for the WATER grid: a payload with water, then a
/// payload without — the old lake must not survive into the new scene.
#[test]
fn a_waterless_geometry_clears_the_previous_water_grid() {
    let rs = render_state();
    let mut r = Map3dRenderer::new(&rs.device, wgpu::TextureFormat::Rgba8UnormSrgb);

    let water = WaterMesh {
        positions: vec![[-1.0, 0.0, -1.0], [1.0, 0.0, -1.0], [1.0, 0.0, 1.0], [-1.0, 0.0, 1.0]],
        indices: vec![0, 1, 2, 0, 2, 3],
    };
    let with_water = build_gpu_geometry_with_water(&mesh(0.0), &water);
    r.upload_for_test(&rs.device, &with_water);
    assert_eq!(r.water_index_count_for_test(), 6, "the lake landed");

    let dry = build_gpu_geometry(&mesh(1.0));
    r.upload_for_test(&rs.device, &dry);
    assert_eq!(
        r.water_index_count_for_test(),
        0,
        "a scene with no water draws no water — the previous grid must be cleared, not kept"
    );
}

/// A 16×2 LUT whose texels are decided by `seed` (16 texels/row × 16 bytes =
/// exactly one 256-byte copy row — no padding ambiguity in the read-back).
fn lut(seed: f32) -> Arc<SkyTransmittanceLut> {
    let texels = (0..32).map(|i| [seed, i as f32, 0.25, 1.0]).collect();
    Arc::new(SkyTransmittanceLut { mu_size: 16, h_size: 2, texels, alt_norm: 0.0 })
}

/// **THE RED LINE for the sky-LUT re-upload** — same `Arc` re-sent every frame costs
/// ZERO texel writes after the first, and a genuinely new LUT both writes and LANDS
/// (asserted on the texture's device texels, the direction an over-eager skip lies in).
#[test]
fn an_unchanged_sky_lut_is_not_rewritten_and_a_new_one_lands() {
    let rs = render_state();
    let mut r = Map3dRenderer::new(&rs.device, wgpu::TextureFormat::Rgba8UnormSrgb);

    // Frame 1..=3 with the SAME payload — the host re-sends its Arc every frame.
    let first = lut(0.5);
    for _ in 0..3 {
        r.ensure_sky_lut_for_test(&rs.device, &rs.queue, &first);
    }
    let (writes, _) = r.sky_lut_state_for_test();
    assert_eq!(
        writes, 1,
        "an unchanged LUT is one write total, not one per frame — 3 here is the defect this pins \
         (seen red 2026-08-12 with the Arc-identity gate removed)"
    );

    // A REAL change (new Arc, same dims — the realloc path can't mask the skip):
    // the write happens and the device texels are the NEW payload's.
    let second = lut(0.9);
    r.ensure_sky_lut_for_test(&rs.device, &rs.queue, &second);
    let (writes, tex) = r.sky_lut_state_for_test();
    assert_eq!(writes, 2, "a new LUT payload writes");
    let dev = read_texture_region(&rs.device, &rs.queue, tex, 16, 0, 0, 16, 2);
    assert_eq!(
        dev,
        bytemuck::cast_slice::<_, u8>(&second.texels[..]),
        "the skip gate must never swallow a REAL LUT change — the device holds the new texels"
    );

    // And the new payload is now the resident one: re-sending it is free again.
    r.ensure_sky_lut_for_test(&rs.device, &rs.queue, &second);
    let (writes, _) = r.sky_lut_state_for_test();
    assert_eq!(writes, 2, "the second payload is resident; re-sending it costs nothing");
}