diff --git a/crates/ui-egui/src/browser.rs b/crates/ui-egui/src/browser.rs index c561da6f..37e928cc 100644 --- a/crates/ui-egui/src/browser.rs +++ b/crates/ui-egui/src/browser.rs @@ -1374,9 +1374,15 @@ fn folder_rows_at(app: &mut SolveApp, ui: &mut egui::Ui, comp: u64, path: Option let prefix = path.map(occurrence_key).unwrap_or_else(|| format!("c{comp}")); if let Some(path) = path { let Ok(placement) = app.session.occurrence_placement(path) else { return }; + // Each row's placed body by its definition's name (a search per row was quadratic in a + // component's bodies, every frame). + let mut placed: std::collections::HashMap<&str, &_> = std::collections::HashMap::with_capacity(placement.bodies.len()); + for b in &placement.bodies { + placed.entry(b.source.as_str()).or_insert(b); + } for e in &mut all { if folder == "bodies" { - let Some(body) = placement.bodies.iter().find(|b| b.source == e.key) else { continue }; + let Some(body) = placed.get(e.key.as_str()) else { continue }; e.sel = Some(Sel::Body { name: body.world.clone() }); e.target = Some(Target::Body { name: body.world.clone() }); e.bodies = vec![body.world.clone()]; diff --git a/crates/ui-egui/src/gpu.rs b/crates/ui-egui/src/gpu.rs index eb8b5ec6..85ed6736 100644 --- a/crates/ui-egui/src/gpu.rs +++ b/crates/ui-egui/src/gpu.rs @@ -51,6 +51,49 @@ pub struct GpuScene { pub under: Vec, /// Opaque bodies drawn from their plans on the GPU, like `tris`. pub planned: Vec, + /// The model's bodies, a few at a time, drawn before the loose geometry above (`tris`, + /// `trans`, `lines` and `hidden` of each block, in order, then the scene's own). A block + /// that comes back unchanged keeps its buffers: a design changing one body at a time (an + /// agent adding bodies over MCP) uploads only the blocks that changed, not the whole model. + pub blocks: Vec, +} + +#[cfg(test)] +impl GpuScene { + /// The blocks' geometry moved into the scene's own lists, in drawing order (for tests that + /// count what the scene holds). + pub fn merged(mut self) -> GpuScene { + let mut out = GpuScene { glass: std::mem::take(&mut self.glass), ..Default::default() }; + for block in std::mem::take(&mut self.blocks) { + out.tris.extend_from_slice(&block.bytes.tris); + out.trans.extend_from_slice(&block.bytes.trans); + out.lines.extend_from_slice(&block.bytes.lines); + out.hidden.extend_from_slice(&block.bytes.hidden); + } + out.tris.append(&mut self.tris); + out.trans.append(&mut self.trans); + out.lines.append(&mut self.lines); + out.hidden.append(&mut self.hidden); + GpuScene { tris: out.tris, trans: out.trans, glass: out.glass, lines: out.lines, hidden: out.hidden, ..self } + } +} + +/// A run of the model's bodies in the scene: their triangles and edges, and a key that names +/// exactly this content (the viewport keeps the bytes while the key comes back). +#[derive(Clone)] +pub struct SceneBlock { + pub key: u64, + pub bytes: Arc, +} + +/// A block's geometry, laid out as in [`GpuScene`]. +#[derive(Default)] +pub struct BlockBytes { + pub tris: Vec, + pub trans: Vec, + pub glass: Vec, + pub lines: Vec, + pub hidden: Vec, } impl GpuScene { @@ -194,8 +237,28 @@ struct PlannedDraw { colors: wgpu::Buffer, } +/// A scene block on the GPU, with the bytes it was uploaded from. +struct BlockGpu { + bytes: Arc, + tris: Option, + trans: Option, + lines: Option, + hidden: Option, +} + +/// The blocks' buffers, then the scene's own, as one batch (the blocks' buffers are shared, +/// not copied). +fn joined<'a>(blocks: impl Iterator>, own: Option) -> Option { + let mut parts: Vec<(wgpu::Buffer, u32)> = blocks.flatten().flat_map(|b| b.parts.iter().cloned()).collect(); + let chunk = own.as_ref().map(|b| b.chunk).unwrap_or(0); + parts.extend(own.into_iter().flat_map(|b| b.parts)); + (!parts.is_empty()).then_some(Batch { parts, chunk }) +} + #[derive(Default)] struct Batches { + /// The scene blocks on the GPU, by key. + blocks: std::collections::HashMap, key: Option, tris: Option, trans: Option, @@ -227,13 +290,33 @@ impl Batches { Some((tess, encoder)) => self.planned(device, tess, encoder, &sc.planned), None => Vec::new(), }; - self.tris = upload(device, "sc_tris", &sc.tris, TRI_SIZE); - self.trans = upload(device, "sc_trans", &sc.trans, TRI_SIZE); + let mut kept = std::mem::take(&mut self.blocks); + let mut blocks: Vec<&BlockGpu> = Vec::with_capacity(sc.blocks.len()); + for block in &sc.blocks { + let gpu = match kept.remove(&block.key).filter(|g| Arc::ptr_eq(&g.bytes, &block.bytes)) { + Some(g) => g, + None => BlockGpu { + bytes: block.bytes.clone(), + tris: upload(device, "sc_tris", &block.bytes.tris, TRI_SIZE), + trans: upload(device, "sc_trans", &block.bytes.trans, TRI_SIZE), + lines: upload(device, "sc_lines", &block.bytes.lines, LINE_SIZE), + hidden: upload(device, "sc_hidden", &block.bytes.hidden, LINE_SIZE), + }, + }; + self.blocks.insert(block.key, gpu); + } + for block in &sc.blocks { + if let Some(g) = self.blocks.get(&block.key) { + blocks.push(g); + } + } + self.tris = joined(blocks.iter().map(|b| &b.tris), upload(device, "sc_tris", &sc.tris, TRI_SIZE)); + self.trans = joined(blocks.iter().map(|b| &b.trans), upload(device, "sc_trans", &sc.trans, TRI_SIZE)); self.glass = upload_with(device, "sc_glass", &sc.glass, TRI_SIZE, wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST); self.glass_cpu = sc.glass; self.glass_sorted_for = None; - self.lines = upload(device, "sc_lines", &sc.lines, LINE_SIZE); - self.hidden = upload(device, "sc_hidden", &sc.hidden, LINE_SIZE); + self.lines = joined(blocks.iter().map(|b| &b.lines), upload(device, "sc_lines", &sc.lines, LINE_SIZE)); + self.hidden = joined(blocks.iter().map(|b| &b.hidden), upload(device, "sc_hidden", &sc.hidden, LINE_SIZE)); self.overlays = upload(device, "sc_overlay", &sc.overlay, LINE_SIZE); self.ghost = upload(device, "sc_ghost", &sc.ghost, TRI_SIZE); self.xray = upload(device, "sc_xray", &sc.xray, TRI_SIZE); diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index aa76c98e..88760e99 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -38,6 +38,10 @@ pub struct ViewportState { /// Per-body boxes of one world state, held (not just its address, which a later state can /// reuse once the old one is freed, so stale boxes would come back). pick_bounds: std::sync::Mutex>, + /// The model scene's blocks of bodies by key, from the last scene built. + pub(crate) blocks: std::sync::Mutex>, + /// Each body mesh's box (the mesh held, so its address stays its own), from the last state. + mesh_bounds: std::sync::Mutex, solidcraft_engine::geom::Aabb3)>>, /// The last click in the view was the middle button's (for the middle double-click). middle_clicked: bool, /// The last pick (its key and hits): reused while nothing it depends on changed. @@ -350,6 +354,110 @@ fn shown_mesh( .unwrap_or_else(|| b.mesh()) } +/// A model scene block kept by the viewport: its bytes and the meshes they were built from +/// (held, so no other mesh can take their addresses while the block is kept). +pub(crate) struct CachedBlock { + bytes: std::sync::Arc, + meshes: Vec>, +} + +/// One body as the model scene draws it. +struct BodyDraw { + mesh: std::sync::Arc, + col: [u8; 4], + faded: bool, + opacity: f64, + face_looks: std::collections::HashMap, + /// Its faces are drawn from its plan on the GPU (only its edges are built here). + on_gpu: bool, +} + +impl BodyDraw { + /// Everything [`BodyDraw::build`] reads. + fn key(&self, shaded: bool, edges: bool, hidden_edges: bool, edge_col: [u8; 4], hidden_col: [u8; 4]) -> u64 { + let mut h = std::collections::hash_map::DefaultHasher::new(); + (std::sync::Arc::as_ptr(&self.mesh) as usize).hash(&mut h); + (self.col, self.faded, self.opacity.to_bits(), self.on_gpu, shaded, edges, hidden_edges, edge_col, hidden_col).hash(&mut h); + let mut looks: Vec<_> = self.face_looks.iter().map(|(f, l)| (*f, l.color, l.opacity.to_bits())).collect(); + looks.sort_unstable(); + looks.hash(&mut h); + h.finish() + } + + fn build(&self, sc: &mut crate::gpu::BlockBytes, shaded: bool, edges: bool, hidden_edges: bool, edge_col: [u8; 4], hidden_col: [u8; 4]) { + let m = &self.mesh; + let mut out = GpuScene::default(); + for (ti, t) in m.triangles.iter().enumerate().filter(|_| shaded && !self.on_gpu) { + let (mut col, a) = match m.tri_face.get(ti).and_then(|f| self.face_looks.get(&(*f as usize))) { + Some(l) => { + let c = Color32::from_rgb(l.color[0], l.color[1], l.color[2]); + (if self.faded { c4(c.gamma_multiply(0.35)) } else { c4(c) }, l.opacity) + } + None => (self.col, self.opacity), + }; + let glass = !self.faded && a < 0.999; + if glass { + col[3] = (a.clamp(0.05, 1.0) * 255.0).round() as u8; + } + for k in t { + let i = *k as usize; + if let (Some(p), Some(n)) = (m.positions.get(i), m.normals.get(i)) { + if self.faded { + out.trans_tri(p.to_f32(), n.to_f32(), col); + } else if glass { + out.glass_tri(p.to_f32(), n.to_f32(), col); + } else { + out.tri(p.to_f32(), n.to_f32(), col); + } + } + } + } + for (ei, e) in m.edges.iter().enumerate().filter(|_| edges) { + if !m.edge_pickable(ei) || (shaded && !m.shaded_edge_visible(ei)) { + continue; + } + for w in e.windows(2) { + out.line(w[0].to_f32(), w[1].to_f32(), edge_col, 1.3, false); + if hidden_edges { + out.hidden_line(w[0].to_f32(), w[1].to_f32(), hidden_col, 1.0); + } + } + } + sc.tris.extend_from_slice(&out.tris); + sc.trans.extend_from_slice(&out.trans); + sc.glass.extend_from_slice(&out.glass); + sc.lines.extend_from_slice(&out.lines); + sc.hidden.extend_from_slice(&out.hidden); + } +} + +/// Add a block of bodies to the scene: the bytes built for the same bodies last time when +/// there were any, else built now. +fn push_block( + sc: &mut GpuScene, + kept: &mut std::collections::HashMap, + made: &mut std::collections::HashMap, + key: u64, + draws: Vec, + build: impl Fn(&BodyDraw, &mut crate::gpu::BlockBytes), +) { + let same = |c: &CachedBlock| c.meshes.len() == draws.len() && c.meshes.iter().zip(&draws).all(|(m, d)| std::sync::Arc::ptr_eq(m, &d.mesh)); + let block = match kept.remove(&key).or_else(|| made.remove(&key)).filter(same) { + Some(c) => c, + None => { + let mut bytes = crate::gpu::BlockBytes::default(); + for d in &draws { + build(d, &mut bytes); + } + CachedBlock { bytes: std::sync::Arc::new(bytes), meshes: draws.iter().map(|d| d.mesh.clone()).collect() } + } + }; + // See-through triangles are sorted with the rest of the glass, every view. + sc.glass.extend_from_slice(&block.bytes.glass); + sc.blocks.push(crate::gpu::SceneBlock { key, bytes: block.bytes.clone() }); + made.insert(key, block); +} + fn build_scene(app: &SolveApp) -> GpuScene { let tk = Tokens::get(); let mut sc = GpuScene::default(); @@ -357,6 +465,20 @@ fn build_scene(app: &SolveApp) -> GpuScene { // Bodies where their occurrences place them. let st = s.world_state(); let active_bodies = s.active_occurrence_path.as_ref().and_then(|p| s.occurrence_bodies(p, true).ok()); + let (shaded, edges) = match app.ui.visual_style { + 1 => (true, false), + 2 => (false, true), + _ => (true, true), + }; + let edge_col = if shaded { c4(tk.body_edge) } else { c4(tk.text) }; + // Shaded with hidden edges: edges behind the model show faintly. + let hidden_edges = app.ui.visual_style == 3; + let hidden_col = c4(tk.text.gamma_multiply(0.35)); + // The blocks the last scene built, reused when their bodies come back as they were. + let mut kept = app.viewport.blocks.lock().map(|mut b| std::mem::take(&mut *b)).unwrap_or_default(); + let mut made: std::collections::HashMap = std::collections::HashMap::new(); + let mut run: Vec = Vec::new(); + let mut run_key = std::collections::hash_map::DefaultHasher::new(); for b in &st.bodies { if !app.session.body_visible(&b.name) || app.preview.replaced.contains(&b.name) { continue; @@ -364,16 +486,6 @@ fn build_scene(app: &SolveApp) -> GpuScene { let faded = body_faded(app, b, &active_bodies); let base = body_color(&s.doc, b).unwrap_or(tk.body); let col = if faded { c4(base.gamma_multiply(0.35)) } else { c4(base) }; - let m = b.mesh(); - let (shaded, edges) = match app.ui.visual_style { - 1 => (true, false), - 2 => (false, true), - _ => (true, true), - }; - let edge_col = if shaded { c4(tk.body_edge) } else { c4(tk.text) }; - // Shaded with hidden edges: edges behind the model show faintly. - let hidden_edges = app.ui.visual_style == 3; - let hidden_col = c4(tk.text.gamma_multiply(0.35)); // Appearances: the body's opacity, and faces with looks of their own. let opacity = s.doc.model_body_opacity(b); let face_looks: std::collections::HashMap = @@ -390,43 +502,24 @@ fn build_scene(app: &SolveApp) -> GpuScene { .collect(); sc.planned.push(crate::gpu::PlannedBody { plan, face_colors }); } - for (ti, t) in m.triangles.iter().enumerate().filter(|_| shaded && !on_gpu) { - let (mut col, a) = match m.tri_face.get(ti).and_then(|f| face_looks.get(&(*f as usize))) { - Some(l) => { - let c = Color32::from_rgb(l.color[0], l.color[1], l.color[2]); - (if faded { c4(c.gamma_multiply(0.35)) } else { c4(c) }, l.opacity) - } - None => (col, opacity), - }; - let glass = !faded && a < 0.999; - if glass { - col[3] = (a.clamp(0.05, 1.0) * 255.0).round() as u8; - } - for k in t { - let i = *k as usize; - if let (Some(p), Some(n)) = (m.positions.get(i), m.normals.get(i)) { - if faded { - sc.trans_tri(p.to_f32(), n.to_f32(), col); - } else if glass { - sc.glass_tri(p.to_f32(), n.to_f32(), col); - } else { - sc.tri(p.to_f32(), n.to_f32(), col); - } - } - } - } - for (ei, e) in m.edges.iter().enumerate().filter(|_| edges) { - if !m.edge_pickable(ei) || (shaded && !m.shaded_edge_visible(ei)) { - continue; - } - for w in e.windows(2) { - sc.line(w[0].to_f32(), w[1].to_f32(), edge_col, 1.3, false); - if hidden_edges { - sc.hidden_line(w[0].to_f32(), w[1].to_f32(), hidden_col, 1.0); - } - } + let body = BodyDraw { mesh: b.mesh(), col, faded, opacity, face_looks, on_gpu }; + let key = body.key(shaded, edges, hidden_edges, edge_col, hidden_col); + key.hash(&mut run_key); + run.push(body); + // Blocks end where a body's own key says, so a body added or removed changes only its + // own block, not every block after it. + if key.is_multiple_of(16) || run.len() >= 64 { + let draws = std::mem::take(&mut run); + let block_key = std::mem::replace(&mut run_key, std::collections::hash_map::DefaultHasher::new()).finish(); + push_block(&mut sc, &mut kept, &mut made, block_key, draws, |d, out| d.build(out, shaded, edges, hidden_edges, edge_col, hidden_col)); } } + if !run.is_empty() { + push_block(&mut sc, &mut kept, &mut made, run_key.finish(), run, |d, out| d.build(out, shaded, edges, hidden_edges, edge_col, hidden_col)); + } + if let Ok(mut b) = app.viewport.blocks.lock() { + *b = made; + } if app.ui.ground_shadow && app.ui.visual_style != 2 && app.cam.pitch > 0.0 { ground_shadow(&mut sc, app, &st); } @@ -511,12 +604,12 @@ fn build_sketch_scene(app: &SolveApp, proj: &Proj) -> GpuScene { sc } -fn ground_shadow(sc: &mut GpuScene, app: &SolveApp, st: &solidcraft_engine::doc::ModelState) { +fn ground_shadow(sc: &mut GpuScene, app: &SolveApp, st: &std::sync::Arc) { // The bodies' box in the camera's Z-up frame (the ground is its XY plane). let mut b = solidcraft_engine::geom::Aabb3::EMPTY; - for x in st.bodies.iter().filter(|x| app.session.body_visible(&x.name)) { - let bb = x.mesh().bounds(); - if bb.is_empty() { + let boxes = body_bounds(app, st); + for (x, (_, bb)) in st.bodies.iter().zip(boxes.iter()) { + if bb.is_empty() || !app.session.body_visible(&x.name) { continue; } for i in 0..8 { @@ -705,7 +798,27 @@ fn body_bounds( { return b.clone(); } - let b = std::sync::Arc::new(st.bodies.iter().map(|b| (b.name.clone(), b.mesh().bounds())).collect::>()); + // Boxes of meshes the last state had too are kept (each body's box walks its mesh). + let mut known = app.viewport.mesh_bounds.lock().map(|mut k| std::mem::take(&mut *k)).unwrap_or_default(); + let mut now = std::collections::HashMap::with_capacity(st.bodies.len()); + let b = std::sync::Arc::new( + st.bodies + .iter() + .map(|b| { + let mesh = b.mesh(); + let ptr = std::sync::Arc::as_ptr(&mesh) as usize; + let bounds = match known.remove(&ptr).or_else(|| now.remove(&ptr)) { + Some((_, bounds)) => bounds, + None => mesh.bounds(), + }; + now.insert(ptr, (mesh, bounds)); + (b.name.clone(), bounds) + }) + .collect::>(), + ); + if let Ok(mut k) = app.viewport.mesh_bounds.lock() { + *k = now; + } *g = Some((st.clone(), b.clone())); return b; } @@ -3344,7 +3457,7 @@ mod tests { let rect = Rect::from_min_size(Pos2::ZERO, vec2(SIZE as f32, SIZE as f32)); let mut render = |tess: bool| { app.viewport.gpu = Some(GpuTarget { format: egui_wgpu::wgpu::TextureFormat::Rgba8Unorm, depth: None, samples: 1, tess }); - let scene = build_scene(&app); + let scene = build_scene(&app).merged(); let proj = projection(&app, rect); let e = proj.cam.eye().to_f32(); let eye = [e[0], e[1], e[2], if proj.cam.fov > 0.0 { 1.0 } else { 0.0 }]; @@ -3392,7 +3505,7 @@ mod tests { app.viewport.gpu = Some(GpuTarget { format: egui_wgpu::wgpu::TextureFormat::Rgba8Unorm, depth: None, samples: 1, tess }); const SIZE: u32 = 256; let rect = Rect::from_min_size(Pos2::ZERO, vec2(SIZE as f32, SIZE as f32)); - let scene = build_scene(&app); + let scene = build_scene(&app).merged(); assert!(scene.planned.is_empty(), "no body waits for the GPU evaluation"); assert!(!scene.tris.is_empty(), "the bodies are meshed on the CPU"); let proj = projection(&app, rect); @@ -3978,11 +4091,11 @@ mod tests { assert_eq!(mesh.smooth_edges.iter().filter(|f| **f).count(), 512); let smooth = mesh.smooth_edges.iter().position(|f| *f).unwrap(); let expected = mesh.edges.iter().enumerate().filter(|(i, _)| mesh.shaded_edge_visible(*i)).map(|(_, e)| e.len() - 1).sum::(); - let shaded = build_scene(&app); + let shaded = build_scene(&app).merged(); assert_eq!(shaded.lines.len() / crate::gpu::LINE_SIZE, expected); assert!(!shaded.tris.is_empty()); app.ui.visual_style = 2; - let wire = build_scene(&app); + let wire = build_scene(&app).merged(); let expected_wire = mesh.edges.iter().enumerate().filter(|(i, _)| !mesh.seams.get(*i).copied().unwrap_or(false)).map(|(_, e)| e.len() - 1).sum::(); assert_eq!(wire.lines.len() / crate::gpu::LINE_SIZE, expected_wire); diff --git a/crates/ui-egui/src/viewport_appearance_tests.rs b/crates/ui-egui/src/viewport_appearance_tests.rs index b7f2fffb..ef5498c1 100644 --- a/crates/ui-egui/src/viewport_appearance_tests.rs +++ b/crates/ui-egui/src/viewport_appearance_tests.rs @@ -31,7 +31,7 @@ fn placed_step_body_uses_imported_opacity_until_a_design_look_overrides_it() { assert!((appearance.opacity - 0.4).abs() < 1e-6); assert_eq!(appearance.color, [51, 153, 204]); crate::dialogs::cancel(&mut app); - let scene = build_scene(&app); + let scene = build_scene(&app).merged(); assert!(!scene.glass.is_empty()); assert!( scene.tris.is_empty(), diff --git a/docs/perf.md b/docs/perf.md index 8ee6e0c3..42b39092 100644 --- a/docs/perf.md +++ b/docs/perf.md @@ -71,6 +71,22 @@ once per command), but slowly. `solidcraft-cli eval` output for the design corpu the regression scripts, the generated bicycle and a 3,300-feature agent-built design is unchanged apart from the timings. +The viewport rebuilt its whole model scene after every change and uploaded all of it again +(every body's triangles and edges, 60 MB for the bicycle where bodies are built on the CPU). It +now builds the scene in blocks of a few bodies, cut where a body's own key says so that adding +or removing a body changes only its block, and keeps each block's bytes and GPU buffers while +the block comes back unchanged. The bodies' boxes (fit, picking, the ground shadow) are kept per +mesh the same way. Built command by command over the control channel (`engine.execute`, then +`ui.inspect`) in the desktop app under Xvfb with software GL: + +| desktop, bicycle built over the control channel | main | with #1148 and this | +|---|---:|---:| +| UI work per change at ~1,000 bodies (`frame_ms`) | 1,374 ms | 18 ms | +| model scene build at ~1,000 bodies (`build_ms`) | 41 ms | 1.4 ms | +| whole build | 1,409 s | 210 s | + +Most of what is left there is software rendering of 770k triangles per frame. + ## STEP import: shells and item solids without copies (2026-10-13) A file that lists one product definition many times (a bolt used 70 times, a panel in several