bench(navigation): add viewport navigation and spatial candidate benchmarks

- Add Section 19 benchmarks covering camera pan viewport uniform updates,
  spatial hover candidate search, and viewport data assembly scaling
- Add navigation telemetry probes in src/perf.rs (active only when
  PERF=1) to track frame interval, batched input moves, and layout-draw gap
This commit is contained in:
Karim Jerbi committed 2026-10-08 10:24:06 +01:00
1 parent af41825a33
commit 7d008c4914
6 files changed
+220 -4

No files matched your search

+137 -1
View File
@@ -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();
}
+3 -1
View File
@@ -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,);
}
+2
View File
@@ -1201,6 +1201,7 @@ impl OpenCADStudio {
}
pub(in crate::app) fn on_viewport_move(&mut self, p: Point) -> Task<Message> {
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<Message> {
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 {
+1
View File
@@ -202,6 +202,7 @@ impl OpenCADStudio {
crate::perf_record!("[perf] view {:>7.1}ms", elapsed_ms);
}
}
crate::perf::note_view_finish();
element
}
+64
View File
@@ -10,6 +10,11 @@ const MAX_LINES: usize = 500;
static UI_ENABLED: AtomicBool = AtomicBool::new(false);
static ENV_ENABLED: OnceLock<bool> = OnceLock::new();
static LINES: OnceLock<Mutex<VecDeque<String>>> = 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<Option<iced::time::Instant>> = Mutex::new(None);
static VIEW_FINISH_INSTANT: Mutex<Option<iced::time::Instant>> = Mutex::new(None);
#[cfg(not(target_arch = "wasm32"))]
static STDERR_TX: OnceLock<std::sync::mpsc::SyncSender<String>> = OnceLock::new();
@@ -17,6 +22,65 @@ fn lines() -> &'static Mutex<VecDeque<String>> {
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<f64> {
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)
+13 -2
View File
@@ -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={}",