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