diff --git a/benches/performance_benchmarks.rs b/benches/performance_benchmarks.rs index dede3f7c8..fb533982b 100644 --- a/benches/performance_benchmarks.rs +++ b/benches/performance_benchmarks.rs @@ -714,7 +714,7 @@ fn bench_selection_cloning(runner: &mut BenchmarkRunner) { return; } - let mut state = SelectionState { + let state = SelectionState { view: SelectionView { vp_size: (1920.0, 1080.0), }, @@ -2025,6 +2025,140 @@ fn bench_view_render_viewport_construction(runner: &mut BenchmarkRunner) { ); } +// ── 19. Viewport Navigation & Spatial Candidate Scaling ───────────────────── + +fn bench_navigation_latency_and_coalescing(runner: &mut BenchmarkRunner) { + if !runner.should_run("navigation") && !runner.should_run("nav_") { + return; + } + + // Fixture: populated scene with mixed entities + let n_entities = if runner.quick_mode { 1_000 } else { 5_000 }; + let mut scene = Scene::new(); + for i in 0..n_entities { + let x = (i % 100) as f64 * 20.0; + let y = (i / 100) as f64 * 20.0; + let mut line = Line::new(); + line.start = Vector3::new(x, y, 0.0); + line.end = Vector3::new(x + 15.0, y + 15.0, 0.0); + scene.add_entity(EntityType::Line(line)); + } + + let bounds = Rectangle { + x: 0.0, + y: 0.0, + width: 1920.0, + height: 1080.0, + }; + let mut cam = Camera::default(); + + // Warm up viewports + let _ = scene.build_viewports( + bounds, + codec::entities::ViewportRenderMode::Wireframe2D, + None, + false, + false, + [1.0, 1.0, 1.0, 1.0], + ); + + let n_pans = if runner.quick_mode { 2_000 } else { 10_000 }; + let runs = 5; + + // 1. Pure Camera Pan Calculation + Viewport update (matrix update & generation bump) + let mut pan_samples = Vec::with_capacity(runs); + for _ in 0..runs { + let t0 = Instant::now(); + for step in 0..n_pans { + let dx = (step % 10) as f32 * 2.0; + let dy = (step % 5) as f32 * 2.0; + cam.pan_screen(dx, dy, bounds.height); + let primitive = scene.build_viewports( + bounds, + codec::entities::ViewportRenderMode::Wireframe2D, + None, + false, + false, + [1.0, 1.0, 1.0, 1.0], + ); + black_box(primitive); + } + let per_us = (t0.elapsed().as_micros() as f64) / (n_pans as f64); + pan_samples.push(per_us); + } + let median_pan_us = pan_samples[pan_samples.len() / 2]; + runner.record( + "nav_camera_pan_viewport_update", + "Camera pan_screen + build_viewports (pure GPU uniform update)", + "µs", + pan_samples, + Some((1_000_000.0 / median_pan_us, "pans/s")), + Some(50.0), // Target < 50 µs (< 0.05 ms) + ); + + // 2. Spatial Hover Candidate Search (R-tree spatial query during idle cursor sweep) + let wires = scene.entity_wires(); + let (view_rot, eye) = (cam.view_proj_rte(bounds), cam.eye()); + let n_hover = if runner.quick_mode { 500 } else { 2_000 }; + let mut hover_samples = Vec::with_capacity(runs); + for _ in 0..runs { + let t0 = Instant::now(); + for i in 0..n_hover { + let cursor = DVec3::new((i % 200) as f64 * 10.0, ((i / 2) % 200) as f64 * 10.0, 0.0); + let candidates = scene.interaction_hover_candidates_near( + Arc::clone(&wires), + cursor, + view_rot, + eye, + bounds, + 10.0, + ); + black_box(candidates); + } + let per_us = (t0.elapsed().as_micros() as f64) / (n_hover as f64); + hover_samples.push(per_us); + } + let median_hover_us = hover_samples[hover_samples.len() / 2]; + runner.record( + "nav_spatial_hover_candidate_search", + "Spatial hover candidate search (R-tree candidate culling for cursor movement)", + "µs", + hover_samples, + Some((1_000_000.0 / median_hover_us, "queries/s")), + Some(30.0), // Target < 30 µs (< 0.03 ms) + ); + + // 3. Viewport Assembly Scaling (build_viewports on active camera_generation bump) + let n_frames = if runner.quick_mode { 200 } else { 1_000 }; + let mut assemble_samples = Vec::with_capacity(runs); + for _ in 0..runs { + let t0 = Instant::now(); + for _ in 0..n_frames { + scene.camera_generation += 1; + let prim = scene.build_viewports( + bounds, + codec::entities::ViewportRenderMode::Wireframe2D, + None, + true, + true, + [1.0, 1.0, 1.0, 1.0], + ); + black_box(prim); + } + let per_us = (t0.elapsed().as_micros() as f64) / (n_frames as f64); + assemble_samples.push(per_us); + } + let median_assemble_us = assemble_samples[assemble_samples.len() / 2]; + runner.record( + "nav_viewport_assembly_scaling", + "ViewportData assembly per camera_generation change (live navigation frame prep)", + "µs", + assemble_samples, + Some((1_000_000.0 / median_assemble_us, "frames/s")), + Some(500.0), // Target < 500 µs (0.5 ms) for 5k entities + ); +} + // ── Main Entrypoint ───────────────────────────────────────────────────────── fn main() { @@ -2062,6 +2196,8 @@ fn main() { bench_draworder_evaluation(&mut runner); bench_undo_delta_recording(&mut runner); bench_view_render_viewport_construction(&mut runner); + bench_navigation_latency_and_coalescing(&mut runner); runner.finish(); } + diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index ade3630bf..61a3dffaf 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -530,7 +530,9 @@ impl OpenCADStudio { } } if let Some(started) = perf_started { - let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0; + let elapsed_us = started.elapsed().as_micros() as u64; + crate::perf::note_update_time(elapsed_us); + let elapsed_ms = elapsed_us as f64 / 1000.0; if elapsed_ms >= 5.0 { crate::perf_record!("[perf] update {:>7.1}ms message={perf_label}", elapsed_ms,); } diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index d4002eaa0..01ca6a0c3 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -1201,6 +1201,7 @@ impl OpenCADStudio { } pub(in crate::app) fn on_viewport_move(&mut self, p: Point) -> Task { + crate::perf::note_pointer_move(); // A ribbon dropdown is open over the viewport. Its backdrop // cannot swallow cursor motion — in iced 0.14 mouse_area/opaque // capture only button presses, never CursorMoved — so the move @@ -5683,6 +5684,7 @@ properties={:.1}ms picked={}", pub(super) fn on_viewport_scroll(&mut self, delta: mouse::ScrollDelta) -> Task { match scroll_intent(delta, self.touchpad_pan, self.ctrl_down) { ScrollIntent::Zoom { notches } => { + crate::perf::note_scroll_zoom(); let mut s = notches * crate::app::settings::zoom_notch_steps(self.zoom_factor); if self.zoom_wheel_reversed { diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 3cbd1ec0b..d12abbd5a 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -202,6 +202,7 @@ impl OpenCADStudio { crate::perf_record!("[perf] view {:>7.1}ms", elapsed_ms); } } + crate::perf::note_view_finish(); element } diff --git a/src/perf.rs b/src/perf.rs index 23c6f0a1e..42619caad 100644 --- a/src/perf.rs +++ b/src/perf.rs @@ -10,6 +10,11 @@ const MAX_LINES: usize = 500; static UI_ENABLED: AtomicBool = AtomicBool::new(false); static ENV_ENABLED: OnceLock = OnceLock::new(); static LINES: OnceLock>> = OnceLock::new(); +static POINTER_MOVES: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); +static SCROLL_ZOOMS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); +static UPDATE_TIME_US: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); +static LAST_FRAME_INSTANT: Mutex> = Mutex::new(None); +static VIEW_FINISH_INSTANT: Mutex> = Mutex::new(None); #[cfg(not(target_arch = "wasm32"))] static STDERR_TX: OnceLock> = OnceLock::new(); @@ -17,6 +22,65 @@ fn lines() -> &'static Mutex> { LINES.get_or_init(|| Mutex::new(VecDeque::with_capacity(MAX_LINES))) } +/// Record a pointer move event received in update. +pub fn note_pointer_move() { + if enabled() { + POINTER_MOVES.fetch_add(1, Ordering::Relaxed); + } +} + +/// Record a zoom event received in update. +pub fn note_scroll_zoom() { + if enabled() { + SCROLL_ZOOMS.fetch_add(1, Ordering::Relaxed); + } +} + +/// Accumulate time spent handling an update message in this batch. +pub fn note_update_time(duration_us: u64) { + if enabled() { + UPDATE_TIME_US.fetch_add(duration_us, Ordering::Relaxed); + } +} + +/// Mark when view() finishes, to time the layout/draw gap until prepare(). +pub fn note_view_finish() { + if enabled() { + let mut guard = VIEW_FINISH_INSTANT.lock().unwrap_or_else(|e| e.into_inner()); + *guard = Some(iced::time::Instant::now()); + } +} + +/// Measure the gap between view() finish and prepare() start. +pub fn take_view_to_prepare_ms() -> Option { + if !enabled() { + return None; + } + let mut guard = VIEW_FINISH_INSTANT.lock().unwrap_or_else(|e| e.into_inner()); + guard.take().map(|t| t.elapsed().as_secs_f64() * 1000.0) +} + +/// Record a completed frame render and report frame-to-frame interval, batched moves, and update time. +pub fn note_frame_render(encode_ms: f64) { + if !enabled() { + return; + } + let moves = POINTER_MOVES.swap(0, Ordering::Relaxed); + let zooms = SCROLL_ZOOMS.swap(0, Ordering::Relaxed); + let update_us = UPDATE_TIME_US.swap(0, Ordering::Relaxed); + let mut guard = LAST_FRAME_INSTANT.lock().unwrap_or_else(|e| e.into_inner()); + let now = iced::time::Instant::now(); + let prev = guard.replace(now); + if let Some(prev) = prev { + let interval_ms = (now - prev).as_secs_f64() * 1000.0; + let update_ms = update_us as f64 / 1000.0; + record(format_args!( + "[perf] frame-interval {:>7.1}ms moves_batched={:<3} zooms={:<2} update_batch={:.2}ms encode={:.2}ms", + interval_ms, moves, zooms, update_ms, encode_ms + )); + } +} + /// True when tracing was requested with `PERF=1` or the in-app panel is open. pub fn enabled() -> bool { UI_ENABLED.load(Ordering::Relaxed) diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index 5ea035186..65e8fce0b 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -379,6 +379,11 @@ impl shader::Primitive for Primitive { viewport: &Viewport, ) { let nav_prepare_started = iced::time::Instant::now(); + if let Some(gap_ms) = crate::perf::take_view_to_prepare_ms() { + if gap_ms >= 1.0 { + crate::perf_record!("[perf] layout-draw {:>7.1}ms", gap_ms); + } + } let errors_at_entry = crate::scene::pipeline::gpu_errors_seen(); let recovering = pipeline.gpu_error_epoch != errors_at_entry; if recovering { @@ -1232,6 +1237,7 @@ retained_contributors={}", if inner.wire_arena_id == vp.wire_content_id && inner.wire_cull_key != cull_key { + let t_cull = iced::time::Instant::now(); let mut visible = if inner.wire_arena_fallback_kind == Some(false) { inner.wire_arena_fallback.as_ref().clone() } else { @@ -1258,6 +1264,10 @@ retained_contributors={}", clip_size.height, )); } + let cull_ms = t_cull.elapsed().as_secs_f64() * 1000.0; + if crate::perf::enabled() { + crate::perf_record!("[perf] wire-cull {:>7.2}ms items={}", cull_ms, visible.len()); + } inner.gpu_wires = std::sync::Arc::new(visible); inner.wire_cull_key = cull_key; } @@ -1294,9 +1304,9 @@ retained_contributors={}", sample.started.elapsed().as_secs_f64() * 1000.0, self.viewports.len(), )); - } else if crate::perf::enabled() && prepare_ms >= 5.0 { + } else if crate::perf::enabled() { crate::perf_record!( - "[perf] frame-prepare {:>7.1}ms viewports={}", + "[perf] frame-prepare {:>7.2}ms viewports={}", prepare_ms, self.viewports.len(), ); @@ -1370,6 +1380,7 @@ retained_contributors={}", } } let render_ms = nav_render_started.elapsed().as_secs_f64() * 1000.0; + crate::perf::note_frame_render(render_ms); if let Some(sample) = self.nav_perf { crate::perf::record(format_args!( "[perf] nav-render op={} space={} mode={} encode={:.2}ms elapsed={:.2}ms viewports={}",