#![cfg(feature = "wgpu")]
use facett_core::engine::pick::{PickId, MAX_FEATURE};
use facett_core::render::gpu::picking::{PickBatch, PickPass, PickTarget, PICK_DEPTH_FORMAT};
use facett_core::render::gpu::request_best_adapter;
use facett_core::render::gpu::preferred_backends;
use egui::{pos2, Rect};
fn device() -> Option<(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())?;
let info = adapter.get_info();
eprintln!("[gpu_picking] device: {} ({:?}, {:?})", info.name, info.backend, info.device_type);
pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
label: Some("gpu_picking_test"),
required_features: wgpu::Features::empty(),
required_limits: wgpu::Limits::downlevel_defaults(),
..Default::default()
}))
.ok()
}
const W: u32 = 200;
const H: u32 = 120;
fn fixture() -> Vec<(&'static str, Rect, PickId, (u32, u32))> {
vec![
("floor 0x01000000", Rect::from_min_max(pos2(10.0, 10.0), pos2(50.0, 50.0)), PickId::new(1, 0), (30, 30)),
("pre-carry 0x010000FF", Rect::from_min_max(pos2(60.0, 10.0), pos2(100.0, 50.0)), PickId::new(1, 0xFF), (80, 30)),
("R→G 0x02000100", Rect::from_min_max(pos2(110.0, 10.0), pos2(150.0, 50.0)), PickId::new(2, 0x100), (130, 30)),
("G→B 0x01010000", Rect::from_min_max(pos2(10.0, 60.0), pos2(50.0, 100.0)), PickId::new(1, 0x1_0000), (30, 80)),
("feature ceiling 0x03FFFFFF", Rect::from_min_max(pos2(60.0, 60.0), pos2(100.0, 100.0)), PickId::new(3, MAX_FEATURE), (80, 80)),
("ceiling 0xFFFFFFFF", Rect::from_min_max(pos2(110.0, 60.0), pos2(150.0, 100.0)), PickId::new(255, MAX_FEATURE), (130, 80)),
]
}
fn run_pass(device: &wgpu::Device, queue: &wgpu::Queue, batch: &PickBatch, depth: bool) -> (PickTarget, u32) {
let mut target = if depth { PickTarget::with_depth() } else { PickTarget::new() };
target.ensure(device, W, H);
let mut pass = PickPass::new(device, depth.then_some(PICK_DEPTH_FORMAT));
pass.set_viewport(queue, W, H);
pass.upload(device, queue, batch);
let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("pick_test") });
let drawn = {
let mut rp = target.begin_pass(&mut enc).expect("target is allocated");
pass.record(&mut rp)
};
queue.submit(Some(enc.finish()));
device.poll(wgpu::PollType::wait_indefinitely()).ok();
(target, drawn)
}
#[test]
fn six_distinct_objects_each_resolve_to_their_own_id_at_a_known_pixel() {
let Some((device, queue)) = device() else {
eprintln!("no WebGPU-class adapter — skipped");
return;
};
let fx = fixture();
let mut batch = PickBatch::new();
for (_, rect, id, _) in &fx {
batch.push_quad(*rect, *id);
}
let (target, drawn) = run_pass(&device, &queue, &batch, false);
assert_eq!(drawn, 36, "six quads is 36 vertices — the pass must actually record them");
let ids: Vec<u32> = fx.iter().map(|(_, _, id, _)| id.0).collect();
let mut uniq = ids.clone();
uniq.sort_unstable();
uniq.dedup();
assert_eq!(uniq.len(), fx.len(), "the fixture must have six DISTINCT ids or it proves nothing");
for (name, _, want, (px, py)) in &fx {
let got = target.read_id(&device, &queue, *px, *py);
assert_eq!(
got, *want,
"{name}: pixel ({px},{py}) read {:#010x} (layer {} feature {}), expected {:#010x} (layer {} feature {})",
got.0,
got.layer(),
got.feature(),
want.0,
want.layer(),
want.feature()
);
assert!(!got.is_nothing(), "{name}: a drawn object must never read as a miss");
}
for (px, py) in [(180, 110), (0, 0), (155, 55), (105, 55)] {
let got = target.read_id(&device, &queue, px, py);
assert!(
got.is_nothing(),
"empty pixel ({px},{py}) read {:#010x} — a miss must be PickId::NOTHING",
got.0
);
}
let feature_zero = target.read_id(&device, &queue, 30, 30);
assert_eq!(feature_zero, PickId::new(1, 0));
assert_eq!(feature_zero.feature(), 0, "this object really is feature 0");
assert!(!feature_zero.is_nothing(), "feature 0 must be distinguishable from a miss");
}
#[test]
fn adjacent_objects_do_not_bleed_across_their_shared_edge() {
let Some((device, queue)) = device() else {
eprintln!("no WebGPU-class adapter — skipped");
return;
};
let left = PickId::new(1, 2); let right = PickId::new(1, 4); let blend = PickId::new(1, 3); assert_eq!((left.0 + right.0) / 2, blend.0, "the fixture's mean really is a valid id");
let mut batch = PickBatch::new();
batch.push_quad(Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 60.0)), left);
batch.push_quad(Rect::from_min_max(pos2(100.0, 0.0), pos2(200.0, 60.0)), right);
let (target, _) = run_pass(&device, &queue, &batch, false);
let a = target.read_id(&device, &queue, 99, 30);
let b = target.read_id(&device, &queue, 100, 30);
assert_eq!(a, left, "1 px left of the edge must be the left object, got {:#010x}", a.0);
assert_eq!(b, right, "1 px right of the edge must be the right object, got {:#010x}", b.0);
assert_ne!(a, b, "the edge separates two DIFFERENT ids — otherwise this proves nothing");
let band = target.read_region(&device, &queue, 90, 20, 20, 20);
assert_eq!(band.len(), 400, "the readback must return the region it was asked for");
for (i, got) in band.iter().enumerate() {
let x = 90 + (i % 20) as u32;
assert_ne!(*got, blend, "texel x={x} carries the BLEND of two ids — the target is filtering");
assert!(
*got == left || *got == right,
"texel x={x} read {:#010x}, which is neither neighbour — the id space is being interpolated",
got.0
);
let want = if x < 100 { left } else { right };
assert_eq!(*got, want, "texel x={x} is on the wrong side of the seam");
}
}
#[test]
fn the_nearest_id_wins_regardless_of_draw_order() {
let Some((device, queue)) = device() else {
eprintln!("no WebGPU-class adapter — skipped");
return;
};
let near = PickId::new(4, 11);
let far = PickId::new(4, 22);
let overlap = Rect::from_min_max(pos2(40.0, 30.0), pos2(120.0, 90.0));
let mut answers = Vec::new();
for near_first in [true, false] {
let mut batch = PickBatch::new();
let push_near = |b: &mut PickBatch| b.push_quad_at_depth(overlap, near, 0.25);
let push_far = |b: &mut PickBatch| b.push_quad_at_depth(overlap, far, 0.75);
if near_first {
push_near(&mut batch);
push_far(&mut batch);
} else {
push_far(&mut batch);
push_near(&mut batch);
}
let (target, drawn) = run_pass(&device, &queue, &batch, true);
assert_eq!(drawn, 12, "two quads, 12 vertices");
assert!(target.has_depth(), "this lane must have a depth attachment");
let got = target.read_id(&device, &queue, 80, 60);
assert_eq!(
got, near,
"near_first={near_first}: the visible (nearest) object is {:#010x}, read {:#010x}",
near.0, got.0
);
answers.push(got);
}
assert_eq!(answers[0], answers[1], "the answer must not depend on submission order");
}
#[test]
fn a_second_pass_clears_the_previous_frames_ids_to_the_miss_sentinel() {
let Some((device, queue)) = device() else {
eprintln!("no WebGPU-class adapter — skipped");
return;
};
let id = PickId::new(9, 12345);
let mut batch = PickBatch::new();
batch.push_quad(Rect::from_min_max(pos2(0.0, 0.0), pos2(W as f32, H as f32)), id);
let (target, drawn) = run_pass(&device, &queue, &batch, false);
assert_eq!(drawn, 6);
assert_eq!(target.read_id(&device, &queue, 100, 60), id, "frame 1 must have painted the whole target");
let mut empty = PickPass::new(&device, None);
empty.set_viewport(&queue, W, H);
empty.upload(&device, &queue, &PickBatch::new());
let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("pick_clear") });
let drawn2 = {
let mut rp = target.begin_pass(&mut enc).expect("still allocated");
empty.record(&mut rp)
};
queue.submit(Some(enc.finish()));
device.poll(wgpu::PollType::wait_indefinitely()).ok();
assert_eq!(drawn2, 0, "an empty batch draws nothing — but the pass still ran");
for (px, py) in [(100, 60), (0, 0), (W - 1, H - 1)] {
let got = target.read_id(&device, &queue, px, py);
assert!(
got.is_nothing(),
"({px},{py}) still reads {:#010x} from the PREVIOUS frame — the pass is not clearing",
got.0
);
}
}
#[test]
fn a_logical_click_at_hidpi_resolves_the_object_under_the_cursor() {
let Some((device, queue)) = device() else {
eprintln!("no WebGPU-class adapter — skipped");
return;
};
let a = PickId::new(1, 100);
let b = PickId::new(1, 200);
let mut batch = PickBatch::new();
batch.push_quad(Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 120.0)), a);
batch.push_quad(Rect::from_min_max(pos2(100.0, 0.0), pos2(200.0, 120.0)), b);
let (target, _) = run_pass(&device, &queue, &batch, false);
assert_eq!(
target.read_id_logical(&device, &queue, pos2(60.0, 30.0), 2.0),
b,
"a hi-dpi click must scale into physical texels"
);
assert_eq!(target.read_id(&device, &queue, 60, 60), a, "physical 60 really is the OTHER object");
assert_eq!(target.read_id_logical(&device, &queue, pos2(30.0, 30.0), 2.0), a);
assert!(target.read_id_logical(&device, &queue, pos2(-4.0, 30.0), 2.0).is_nothing());
}
#[test]
fn the_gpu_lane_and_the_cpu_lane_return_the_same_id_at_every_probe() {
let Some((device, queue)) = device() else {
eprintln!("no WebGPU-class adapter — skipped");
return;
};
use facett_core::engine::pick::{PickIndex, PickShape};
use facett_core::render::gpu::picking::painter_depth;
const LAYER: u8 = 5;
let half = egui::vec2(30.0, 14.0);
let anchors: Vec<egui::Pos2> =
(0..24).map(|i| pos2(34.0 + (i % 6) as f32 * 26.0, 30.0 + (i / 6) as f32 * 22.0)).collect();
let n = anchors.len();
let mut cpu = PickIndex::default();
cpu.begin(1);
cpu.push_layer(LAYER, &anchors, (0..n as u32).collect(), PickShape::Box(half));
let mut batch = PickBatch::new();
for (i, a) in anchors.iter().enumerate().rev() {
batch.push_quad_in_painter_order(Rect::from_center_size(*a, half * 2.0), PickId::new(LAYER, i as u32), i, n);
}
assert!(painter_depth(0, n) < painter_depth(n - 1, n), "the depth ramp must be monotonic");
let (target, drawn) = run_pass(&device, &queue, &batch, true);
assert_eq!(drawn as usize, n * 6, "every chip must reach the device");
let mut agreed_hits = 0usize;
let mut agreed_misses = 0usize;
let mut overlap_probes = 0usize;
for gx in 0..25u32 {
for gy in 0..20u32 {
let (px, py) = (gx * 8, gy * 6);
if px >= W || py >= H {
continue;
}
let probe = pos2(px as f32 + 0.5, py as f32 + 0.5);
let want = cpu.pick(probe).id;
let got = target.read_id(&device, &queue, px, py);
assert_eq!(
got, want,
"pixel ({px},{py}): GPU says {:#010x} (feature {}), CPU says {:#010x} (feature {})",
got.0,
got.feature(),
want.0,
want.feature()
);
if want.is_nothing() {
agreed_misses += 1;
} else {
agreed_hits += 1;
let covering = anchors
.iter()
.filter(|c| (probe.x - c.x).abs() <= half.x && (probe.y - c.y).abs() <= half.y)
.count();
if covering > 1 {
overlap_probes += 1;
}
}
}
}
assert!(agreed_hits > 100, "only {agreed_hits} probes hit a chip — the sweep proves nothing");
assert!(agreed_misses > 10, "only {agreed_misses} probes missed — the sentinel path is untested");
assert!(
overlap_probes > 50,
"only {overlap_probes} probes landed where chips OVERLAP — the tie-break rule is untested, \
and it is the one place the two lanes can legitimately disagree"
);
eprintln!(
"[gpu_picking] two lanes agreed on {} hits / {} misses, {} of them contested by >1 chip",
agreed_hits, agreed_misses, overlap_probes
);
}