Viewport picking: a BVH per body mesh and a GPU pick buffer for visible edges (#1776)

Thanks @oursland.
This commit is contained in:
Jackson Oursland authored and GitHub committed 2026-10-16 14:26:38 -04:00
1 parent a9413fe385
commit 8907da5e86
14 files changed
+1707 -50

No files matched your search

+259
View File
@@ -0,0 +1,259 @@
//! The GPU pick buffer in the viewport ([`solidcraft_gpu::pick`]): which model edges are seen at
//! each pixel, so a pick over a large model tests only the edges near the cursor that are not
//! hidden, instead of every edge the cursor's ray passes near.
//!
//! The pass is drawn for a view once it holds still (the same view two frames running), and a
//! tile around the cursor is read back; the tile arrives a frame or two later, and until it
//! does (and while the view moves) the picker answers from the CPU indexes
//! ([`crate::pick_index`]). The tile only narrows the candidates: the picker's exact tests
//! still decide. Not used for Select Other (every target along the ray), in wireframe (nothing
//! hides an edge there), without a GPU, or for a model past [`MAX_TRIANGLES`].
use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use std::sync::{Arc, Mutex};
use egui::Pos2;
use solidcraft_engine::doc::ModelState;
use solidcraft_gpu::pick::{PickBuffer, PickOut, PickScene, PickView};
use crate::SolveApp;
use crate::viewport::Proj;
/// Most surface triangles the pass draws (beyond them the CPU indexes answer alone).
pub const MAX_TRIANGLES: usize = 6_000_000;
/// How near the cursor an edge's pixels are looked for (points): vertices pick within 7, edges 6.
const WINDOW: f32 = 8.0;
/// Frames kept coming after a tile is asked for, so its read-back lands.
const FRAMES: u8 = 4;
/// The pass's scene for one model state: the ids it draws stand for (body, edge).
struct Scene {
state: Arc<ModelState>,
key: u64,
scene: Arc<PickScene>,
/// Per id − 1: (body index in the state, edge index).
edges: Vec<(u32, u32)>,
/// One-point edges (no line to draw): always candidates.
points: Vec<(u32, u32)>,
}
#[derive(Default)]
pub(crate) struct State {
scene: Mutex<Option<Arc<Scene>>>,
out: PickOut,
/// A pick found no tile for its cursor: the view it was in and the pixel.
want: Mutex<Option<(u64, [u32; 2])>>,
/// The view of the last frame (the pass is drawn for a view that held still).
last_view: u64,
/// Frames still to request so read-backs land.
frames: u8,
}
/// The edges a tile names near the cursor, per body index of the world state.
pub(crate) struct NearEdges {
by_body: HashMap<usize, Vec<usize>>,
}
impl NearEdges {
/// Every segment of the named edges of body `index` of `mesh`, as (edge, first point), in
/// edge order.
pub(crate) fn segments(&self, index: usize, mesh: &solidcraft_engine::geom::Mesh) -> Vec<(usize, usize)> {
let mut v = Vec::new();
for &ei in self.by_body.get(&index).map_or(&[][..], |e| &e[..]) {
let n = mesh.edges.get(ei).map_or(0, |e| e.len().max(2) - 1);
v.extend((0..n).map(|si| (ei, si)));
}
v
}
}
/// What the pass's scene depends on: the world state, and which bodies show.
fn model_key(app: &SolveApp, st: &Arc<ModelState>) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
(Arc::as_ptr(st) as usize).hash(&mut h);
app.session.revision.hash(&mut h);
app.session.visibility.hidden_bodies.hash(&mut h);
app.session.visibility.hidden_occurrences.hash(&mut h);
h.finish()
}
/// The view's pixels: the viewport's size in physical pixels.
fn view_size(app: &SolveApp, proj: &Proj) -> [u32; 2] {
let ppp = app.viewport.pixels_per_point();
[(proj.rect.width() * ppp).round().max(0.0) as u32, (proj.rect.height() * ppp).round().max(0.0) as u32]
}
/// How far edges are lifted toward the eye: the picker's slack for an edge behind a face.
fn lift(app: &SolveApp) -> f64 {
crate::viewport::scene_radius(app) * 0.02
}
/// Everything the drawn ids depend on.
fn view_key(app: &SolveApp, proj: &Proj, model: u64) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
model.hash(&mut h);
for col in proj.vp.to_f32() {
for x in col {
x.to_bits().hash(&mut h);
}
}
view_size(app, proj).hash(&mut h);
lift(app).to_bits().hash(&mut h);
h.finish()
}
fn usable(app: &SolveApp) -> bool {
app.viewport.gpu.is_some() && app.ui.visual_style != 2
}
/// The edges seen within reach of `pos`, when a tile of this view is in; otherwise `None`, and
/// a tile around `pos` is asked for.
pub(crate) fn edges_near(app: &SolveApp, proj: &Proj, pos: Pos2, st: &Arc<ModelState>) -> Option<NearEdges> {
if !usable(app) {
return None;
}
let model = model_key(app, st);
let scene = app.viewport.pick_buffer.scene.lock().ok()?.clone().filter(|s| s.key == model && Arc::ptr_eq(&s.state, st));
let key = view_key(app, proj, model);
let size = view_size(app, proj);
let ppp = app.viewport.pixels_per_point();
let (x, y) = ((pos.x - proj.rect.left()) * ppp, (pos.y - proj.rect.top()) * ppp);
if !(x >= 0.0 && y >= 0.0 && x < size[0] as f32 && y < size[1] as f32) {
return None;
}
let (x, y) = (x as i64, y as i64);
let r = (WINDOW * ppp).ceil() as i64;
let window = [x - r, y - r, x + r, y + r];
let ids = scene.as_ref().and_then(|_| app.viewport.pick_buffer.out.lock().ok()?.tile(key, size, window).map(|t| t.ids_in(window)));
let (Some(scene), Some(ids)) = (scene, ids) else {
if let Ok(mut w) = app.viewport.pick_buffer.want.lock() {
*w = Some((key, [x as u32, y as u32]));
}
return None;
};
let mut by_body: HashMap<usize, Vec<usize>> = HashMap::new();
for (b, e) in ids.iter().filter_map(|id| scene.edges.get((*id as usize).checked_sub(1)?)).chain(&scene.points) {
by_body.entry(*b as usize).or_default().push(*e as usize);
}
for v in by_body.values_mut() {
v.sort_unstable();
v.dedup();
}
Some(NearEdges { by_body })
}
/// The pass's scene for the world state: visible bodies' triangles, and their pickable edges.
/// `None` past [`MAX_TRIANGLES`] or without edges.
fn scene(app: &SolveApp, st: &Arc<ModelState>, key: u64) -> Option<Arc<Scene>> {
let f = |p: solidcraft_engine::geom::Vec3| [p.x as f32, p.y as f32, p.z as f32];
let mut scene = PickScene::default();
let (mut edges, mut points, mut triangles) = (Vec::new(), Vec::new(), 0usize);
for (bi, b) in st.bodies.iter().enumerate() {
if !app.session.body_visible(&b.name) {
continue;
}
let m = b.mesh();
triangles += m.triangles.len();
if triangles > MAX_TRIANGLES {
return None;
}
for t in &m.triangles {
if let Some([a, b, c]) = m.tri(t) {
scene.triangle([f(a), f(b), f(c)]);
}
}
for (ei, e) in m.edges.iter().enumerate() {
if !m.edge_pickable(ei) || edges.len() >= u32::MAX as usize - 1 {
continue;
}
if e.len() == 1 {
points.push((bi as u32, ei as u32));
continue;
}
edges.push((bi as u32, ei as u32));
let id = edges.len() as u32;
for w in e.windows(2) {
scene.segment(f(w[0]), f(w[1]), id);
}
}
}
(!scene.is_empty()).then(|| Arc::new(Scene { state: st.clone(), key, scene: Arc::new(scene), edges, points }))
}
/// In the frame's GPU paint: draw the pass for a view that held still when a pick asked for a
/// tile, and keep frames coming until the read-back lands.
pub(crate) fn paint(app: &mut SolveApp, ctx: &egui::Context, painter: &egui::Painter, proj: &Proj) {
if !usable(app) {
return;
}
let st = app.session.world_state();
let model = model_key(app, &st);
let key = view_key(app, proj, model);
let state = &mut app.viewport.pick_buffer;
let still = std::mem::replace(&mut state.last_view, key) == key;
let want = state.want.lock().ok().and_then(|mut w| w.take()).filter(|(k, _)| *k == key && still);
if want.is_none() && state.frames == 0 {
return;
}
state.frames = if want.is_some() { FRAMES } else { state.frames.saturating_sub(1) };
let cached = state.scene.lock().ok().and_then(|s| s.clone()).filter(|s| s.key == model && Arc::ptr_eq(&s.state, &st));
let scene = match cached {
Some(s) => s,
None => {
let Some(s) = scene(app, &st, model) else { return };
if let Ok(mut slot) = app.viewport.pick_buffer.scene.lock() {
*slot = Some(s.clone());
}
s
}
};
let e = proj.cam.eye().to_f32();
let view = PickView {
key,
view_proj: proj.vp.to_f32(),
eye: [e[0], e[1], e[2], if proj.cam.fov > 0.0 { 1.0 } else { 0.0 }],
back: proj.cam.back().to_f32(),
lift: lift(app) as f32,
size: view_size(app, proj),
};
let cb =
PickCallback { scene_key: scene.key, scene: scene.scene.clone(), view, centre: want.map(|w| w.1), out: app.viewport.pick_buffer.out.clone() };
painter.add(egui_wgpu::Callback::new_paint_callback(proj.rect, cb));
ctx.request_repaint();
}
/// The pass as an egui paint callback: all its work is in `prepare` (it draws into its own
/// textures, not the frame).
struct PickCallback {
scene_key: u64,
scene: Arc<PickScene>,
view: PickView,
centre: Option<[u32; 2]>,
out: PickOut,
}
impl egui_wgpu::CallbackTrait for PickCallback {
fn prepare(
&self,
device: &egui_wgpu::wgpu::Device,
queue: &egui_wgpu::wgpu::Queue,
_screen: &egui_wgpu::ScreenDescriptor,
encoder: &mut egui_wgpu::wgpu::CommandEncoder,
resources: &mut egui_wgpu::CallbackResources,
) -> Vec<egui_wgpu::wgpu::CommandBuffer> {
if resources.get::<PickBuffer>().is_none() {
resources.insert(PickBuffer::new(device));
}
if let Some(pb) = resources.get_mut::<PickBuffer>() {
pb.prepare(device, queue, encoder, self.scene_key, &self.scene, &self.view, self.centre, &self.out);
}
Vec::new()
}
fn paint(&self, _info: egui::PaintCallbackInfo, _pass: &mut egui_wgpu::wgpu::RenderPass<'static>, _resources: &egui_wgpu::CallbackResources) {}
}
#[cfg(test)]
#[path = "pick_buffer_tests.rs"]
mod tests;