From 3dd97cad5629bf8e30b83b329141d325b449f87d Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 18:16:34 +0200 Subject: [PATCH] Linux: present without a vsync queue so the crosshair follows the mouse (#25) (#183) * Linux: present without a vsync queue so the crosshair follows the mouse (#25) The canvas hides the OS pointer and draws its own crosshair, so every frame the swapchain holds back shows as cursor lag. Under vsync (FIFO) wgpu asks for one queued frame, but Vulkan clamps it to the driver's minimum swapchain size: three images on X11 (Linux Mint's Cinnamon session), i.e. two frames queued behind the one on screen. Windows DX12 honours one. The canvas also requested a repaint on every frame while hovered, which kept that queue full. - gpu::surface_config: AutoNoVsync (Immediate, then Mailbox, then FIFO; valid on every surface) on Linux/BSD, frame latency 1. CADCRAFT_VSYNC=1 restores vsync, CADCRAFT_VSYNC=0 selects low latency elsewhere. - gpu::FrameCap (240 fps) when presenting without vsync: nothing else paces frames on X11, and a 1000 Hz input stream rendered ~900 frames per second. - No continuous repaint while hovering the canvas; input repaints by itself. The status-bar message schedules its own repaint to clear on time. - Hover/selection highlight looks up primitives in a per-list sorted index instead of scanning the whole display list every frame (~2.5 ms per frame on a 270k-entity drawing). Co-Authored-By: Claude Opus 5.5 * Move frame-cap and status-repaint hunks away from other PRs' to avoid merge conflicts Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- apps/cadcraft/src/main.rs | 10 ++++ crates/ui-egui/src/canvas.rs | 104 +++++++++++++++++++++++++++++------ crates/ui-egui/src/chrome.rs | 7 +++ crates/ui-egui/src/gpu.rs | 86 +++++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 11 +++- 5 files changed, 200 insertions(+), 18 deletions(-) diff --git a/apps/cadcraft/src/main.rs b/apps/cadcraft/src/main.rs index 3c180c4..9fb3970 100644 --- a/apps/cadcraft/src/main.rs +++ b/apps/cadcraft/src/main.rs @@ -5,6 +5,9 @@ //! `--control ` (or `CADCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server: //! `{"id":1,"method":"cmdline.input","params":{"text":"circle 0,0 5"}}` → `{"id":1,"ok":true,…}`. //! See `cadcraft_ui_egui::control` for the methods. +//! +//! `CADCRAFT_VSYNC=1` presents with vsync on Linux/BSD, where the default is the low-latency +//! present mode (`CADCRAFT_VSYNC=0` selects that elsewhere); see `cadcraft_ui_egui::gpu::surface_config`. #![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] @@ -107,7 +110,11 @@ fn main() -> eframe::Result { } } install_io(); + let surface = cadcraft_ui_egui::gpu::surface_config(std::env::var("CADCRAFT_VSYNC").ok().as_deref(), cfg!(all(unix, not(target_os = "macos")))); let mut options = eframe::NativeOptions { + // The crosshair is drawn by the app: present without a queue on Linux/BSD, where X11 + // swapchains hold two frames back under vsync (`CADCRAFT_VSYNC=1` restores vsync). + wgpu_options: eframe::egui_wgpu::WgpuConfiguration::default().with_surface_config(surface), viewport: egui::ViewportBuilder::default() .with_title("CADCraft") .with_inner_size([1600.0, 1000.0]) @@ -132,6 +139,9 @@ fn main() -> eframe::Result { app.load_prefs(&prefs); } app.integrated_titlebar = cfg!(target_os = "macos"); + if surface.present_mode == eframe::wgpu::PresentMode::AutoNoVsync { + app.frame_cap = Some(cadcraft_ui_egui::gpu::FrameCap::new(240)); + } if let Some(rs) = &cc.wgpu_render_state { app.set_wgpu(rs); } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index aac8de2..56be566 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -79,6 +79,9 @@ pub fn apply_hot_grip(app: &mut CadApp, to: Vec2) { pub struct CanvasState { pub list: Option, key: (u64, u64, i32, bool, usize), + /// Bumped whenever `list` is rebuilt (identifies it for [`PrimIndex`]). + list_gen: u64, + prim_index: PrimIndex, /// Set when the app runs on wgpu: entities are drawn by [`crate::gpu`]; otherwise on the CPU. pub gpu: Option, /// The mesh last handed to the GPU (see [`crate::gpu::CanvasCallback::key`]). @@ -108,6 +111,46 @@ pub struct CanvasState { pub param: crate::parametric::Cache, } +/// Primitives of `list` sorted by entity handle, built on the first highlight after each list +/// rebuild. Highlighting the hovered entity used to scan every primitive on every frame: ~2.5 ms +/// per frame on a 270k-entity drawing, whether or not anything changed. +#[derive(Default)] +struct PrimIndex { + list_gen: Option, + by_handle: Vec<(Handle, usize)>, +} + +impl PrimIndex { + /// Indices of the primitives drawn for `handles` (in list order). + fn prims(&mut self, list_gen: u64, list: &DisplayList, handles: &[Handle]) -> Vec { + if handles.is_empty() { + return Vec::new(); + } + if self.list_gen != Some(list_gen) { + self.by_handle = list.prims.iter().enumerate().map(|(i, p)| (p.handle, i)).collect(); + self.by_handle.sort_unstable(); + self.list_gen = Some(list_gen); + } + let mut out = Vec::new(); + for h in handles { + let from = self.by_handle.partition_point(|(k, _)| k < h); + out.extend(self.by_handle.get(from..).unwrap_or(&[]).iter().take_while(|(k, _)| k == h).map(|(_, i)| *i)); + } + out.sort_unstable(); + out.dedup(); + out + } +} + +/// Indices of the primitives in `list` drawn for `handles` (a scan; for small lists). +fn prims_of(list: &DisplayList, handles: &[Handle]) -> Vec { + if handles.is_empty() { + return Vec::new(); + } + let set: std::collections::HashSet = handles.iter().copied().collect(); + list.prims.iter().enumerate().filter(|(_, pr)| set.contains(&pr.handle)).map(|(i, _)| i).collect() +} + fn color32(c: Rgb) -> Color32 { Color32::from_rgb(c.0, c.1, c.2) } @@ -132,6 +175,7 @@ fn ensure_list(app: &mut CadApp, px: f64) { let space = st.space.clone(); app.canvas.list = Some(cadcraft_render::build(&st.doc, &space, &opts)); app.canvas.key = key; + app.canvas.list_gen = app.canvas.list_gen.wrapping_add(1); app.canvas.build_ms = crate::now_ms() - t0; } @@ -307,14 +351,11 @@ fn draw_list_gpu(c: &mut CanvasState, p: &egui::Painter, xf: &Xf, bg: Rgb, lwdis p.extend(shapes); } -/// Draw selected/hovered entities as highlight overlays. -fn draw_highlight(p: &egui::Painter, xf: &Xf, list: &DisplayList, handles: &[Handle], color: Color32, width: f32, dashed: bool) { - if handles.is_empty() { - return; - } - let set: std::collections::HashSet = handles.iter().copied().collect(); +/// Draw selected/hovered entities as highlight overlays: `prims` are indices into `list` (from +/// [`PrimIndex::prims`] or [`prims_of`]). +fn draw_highlight(p: &egui::Painter, xf: &Xf, list: &DisplayList, prims: &[usize], color: Color32, width: f32, dashed: bool) { let mut shapes = Vec::new(); - for prim in list.prims.iter().filter(|pr| set.contains(&pr.handle)) { + for prim in prims.iter().filter_map(|&i| list.prims.get(i)) { let pts = list.points(prim); match prim.kind { Kind::Polyline => { @@ -658,18 +699,18 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { if let Some(h) = app.canvas.hover && !sel.contains(&h) { - draw_highlight(&clipped, &xf, &list, &[h], t.hover, 2.0, false); + draw_highlight(&clipped, &xf, &list, &prims_of(&list, &[h]), t.hover, 2.0, false); } - draw_highlight(&clipped, &xf, &list, &sel, t.selection, 1.5, true); + draw_highlight(&clipped, &xf, &list, &prims_of(&list, &sel), t.selection, 1.5, true); } } - } else if let Some(list) = &app.canvas.list { - if let Some(h) = app.canvas.hover + } else if let CanvasState { list: Some(list), list_gen, prim_index, hover, .. } = &mut app.canvas { + if let Some(h) = *hover && !sel.contains(&h) { - draw_highlight(&painter, &xf, list, &[h], t.hover, 2.0, false); + draw_highlight(&painter, &xf, list, &prim_index.prims(*list_gen, list, &[h]), t.hover, 2.0, false); } - draw_highlight(&painter, &xf, list, &sel, t.selection, 1.5, true); + draw_highlight(&painter, &xf, list, &prim_index.prims(*list_gen, list, &sel), t.selection, 1.5, true); } app.canvas.draw_ms = crate::now_ms() - t0; // Constraint bars and dynamic dimensional constraints (model space, or inside a viewport). @@ -804,10 +845,9 @@ pub fn show(app: &mut CadApp, ui: &mut egui::Ui) { dynamic_input(app, &painter, hp); } } - // Keep animating during interaction. - if inside { - ui.ctx().request_repaint(); - } + // No continuous repaint while hovering: input events repaint by themselves. Repainting every + // frame kept a vsync swapchain's queue full (every frame waited behind queued ones) and would + // spin the CPU with the low-latency present mode (`gpu::surface_config`). } fn tooltip(p: &egui::Painter, at: Pos2, text: &str) { @@ -908,3 +948,33 @@ fn grip_at(app: &CadApp, xf: &Xf, hover: Option) -> Option { } None } + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use cadcraft_render::DPrim; + + fn list(handles: &[u64]) -> DisplayList { + let prim = |h| DPrim { handle: Handle(h), color: Rgb(255, 255, 255), lw: 0.0, kind: Kind::Polyline, start: 0, len: 0 }; + DisplayList { prims: handles.iter().map(|&h| prim(h)).collect(), ..Default::default() } + } + + fn hs(v: &[u64]) -> Vec { + v.iter().map(|&h| Handle(h)).collect() + } + + #[test] + fn prim_index_matches_a_scan_and_follows_list_rebuilds() { + let a = list(&[3, 1, 3, 2, 7, 3]); + let mut ix = PrimIndex::default(); + assert_eq!(ix.prims(1, &a, &hs(&[3])), vec![0, 2, 5]); + assert_eq!(ix.prims(1, &a, &hs(&[3])), prims_of(&a, &hs(&[3]))); + assert_eq!(ix.prims(1, &a, &hs(&[2, 1, 2])), vec![1, 3]); + assert!(ix.prims(1, &a, &hs(&[9])).is_empty()); + assert!(ix.prims(1, &a, &[]).is_empty()); + // A rebuilt list (new generation) is re-indexed. + let b = list(&[3, 9]); + assert_eq!(ix.prims(2, &b, &hs(&[9, 3])), vec![0, 1]); + } +} diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index acb9aa4..5c25fd8 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -178,6 +178,13 @@ pub fn status_bar(app: &mut CadApp, ui: &mut egui::Ui) { let r = ui.max_rect(); let p = ui.painter().clone(); let mut x = r.left() + 8.0; + // Clear the status message on time even when no input arrives (the app only repaints on input). + if let Some((_, at)) = &app.status + && crate::now_ms() - at < 5000.0 + { + let left = std::time::Duration::try_from_secs_f64((5000.0 - (crate::now_ms() - at)) / 1000.0).unwrap_or_default(); + ui.ctx().request_repaint_after(left); + } for (icon, tip) in [(Icon::Plus, "New layout"), (Icon::Menu, "Layout list")] { let br = Rect::from_min_size(pos2(x, r.top() + 4.0), vec2(18.0, 18.0)); let resp = ui.interact(br, ui.id().with(("sb", tip)), Sense::click()); diff --git a/crates/ui-egui/src/gpu.rs b/crates/ui-egui/src/gpu.rs index d506cdd..b52059f 100644 --- a/crates/ui-egui/src/gpu.rs +++ b/crates/ui-egui/src/gpu.rs @@ -30,6 +30,62 @@ const CHUNK: usize = 4_194_300; /// Point markers are 1.5 × 1.5 points (as on the CPU path). const POINT_SIZE: f32 = 1.5; +/// How the native window presents frames (`NativeOptions::wgpu_options.surface`). +/// +/// The canvas hides the OS pointer and draws its own crosshair, so every frame the swapchain holds +/// back is visible as cursor lag. With FIFO (vsync) wgpu asks for one queued frame, but Vulkan +/// clamps that to the driver's minimum swapchain size, which is three images on X11 (two frames +/// queued behind the one on screen; Windows DX12 honours one). `low_latency` (the +/// app passes it on Linux/BSD) prefers a mode that never queues: `AutoNoVsync` picks Immediate, +/// then Mailbox, then falls back to FIFO, so it is valid on every surface; under a compositor the +/// compositor still syncs to the display. `vsync` is the `CADCRAFT_VSYNC` override: `1`/`on` +/// forces FIFO (tear-free without a compositor), `0`/`off` forces the low-latency mode. +pub fn surface_config(vsync: Option<&str>, low_latency: bool) -> egui_wgpu::SurfaceConfig { + let no_vsync = match vsync.map(|v| v.trim().to_ascii_lowercase()).as_deref() { + Some("1" | "on" | "true" | "yes") => false, + Some("0" | "off" | "false" | "no") => true, + _ => low_latency, + }; + egui_wgpu::SurfaceConfig { + present_mode: if no_vsync { wgpu::PresentMode::AutoNoVsync } else { wgpu::PresentMode::AutoVsync }, + desired_maximum_frame_latency: Some(1), + } +} + +/// Frame-rate cap for presenting without vsync. Nothing else paces frames then (on X11; Wayland +/// still throttles to the compositor), so a 1000 Hz mouse made the app render ~900 frames per +/// second and keep a CPU core busy. Waiting out the rest of a short minimum interval adds at most +/// that interval of input age, far less than a vsync queue. +#[cfg(not(target_arch = "wasm32"))] +#[derive(Clone, Copy, Debug)] +pub struct FrameCap { + min: std::time::Duration, + next: Option, +} + +#[cfg(not(target_arch = "wasm32"))] +impl FrameCap { + /// At most `max_fps` frames per second. + pub fn new(max_fps: u32) -> Self { + FrameCap { min: std::time::Duration::from_secs(1) / max_fps.max(1), next: None } + } + + /// How long a frame starting at `now` waits; records when the following one may start. + pub fn wait_at(&mut self, now: std::time::Instant) -> std::time::Duration { + let wait = self.next.map_or(std::time::Duration::ZERO, |n| n.saturating_duration_since(now)); + self.next = now.checked_add(wait.saturating_add(self.min)); + wait + } + + /// Block until this frame may start (call once per frame, at its start). + pub fn wait(&mut self) { + let wait = self.wait_at(std::time::Instant::now()); + if !wait.is_zero() { + std::thread::sleep(wait); + } + } +} + /// The wgpu target the app renders into, recorded by [`install`]. #[derive(Clone, Copy, Debug)] pub struct GpuTarget { @@ -423,6 +479,36 @@ mod tests { use cadcraft_doc::Handle; use cadcraft_render::DPrim; + #[test] + fn surface_config_prefers_low_latency_where_asked_and_honours_override() { + use wgpu::PresentMode::{AutoNoVsync, AutoVsync}; + assert_eq!(surface_config(None, true).present_mode, AutoNoVsync); + assert_eq!(surface_config(None, false).present_mode, AutoVsync); + for on in ["1", "on", " TRUE ", "yes"] { + assert_eq!(surface_config(Some(on), true).present_mode, AutoVsync, "{on}"); + } + for off in ["0", "Off", "false", "no"] { + assert_eq!(surface_config(Some(off), false).present_mode, AutoNoVsync, "{off}"); + } + assert_eq!(surface_config(Some("maybe"), true).present_mode, AutoNoVsync); + assert_eq!(surface_config(Some(""), false).present_mode, AutoVsync); + assert_eq!(surface_config(None, true).desired_maximum_frame_latency, Some(1)); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn frame_cap_waits_out_the_minimum_interval_only() { + use std::time::{Duration, Instant}; + let mut cap = FrameCap::new(250); // 4 ms + let t0 = Instant::now(); + assert_eq!(cap.wait_at(t0), Duration::ZERO, "first frame never waits"); + assert_eq!(cap.wait_at(t0 + Duration::from_millis(1)), Duration::from_millis(3)); + // That frame started at 4 ms; the next one 10 ms later is long past the interval. + assert_eq!(cap.wait_at(t0 + Duration::from_millis(14)), Duration::ZERO); + assert_eq!(cap.wait_at(t0 + Duration::from_millis(18)), Duration::ZERO); + assert_eq!(FrameCap::new(0).min, Duration::from_secs(1)); + } + fn list() -> DisplayList { let mut l = DisplayList { verts: vec![Vec2::new(1e6, 1e6), Vec2::new(1e6 + 1.0, 1e6), Vec2::new(1e6 + 1.0, 1e6 + 1.0), Vec2::new(5.0, 5.0)], diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d64cb7c..b7fa423 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -106,6 +106,9 @@ pub struct CadApp { queued_shots: Vec<(u64, f64, u32)>, shot_token: u64, pub synthetic: Vec, + /// Caps the frame rate when presenting without vsync (see [`gpu::FrameCap`]). + #[cfg(not(target_arch = "wasm32"))] + pub frame_cap: Option, styled: bool, /// The theme last installed into egui (`None` until the first frame). shown_theme: Option, @@ -128,6 +131,8 @@ impl CadApp { queued_shots: Vec::new(), shot_token: 0, synthetic: Vec::new(), + #[cfg(not(target_arch = "wasm32"))] + frame_cap: None, styled: false, shown_theme: None, frame_ms: 0.0, @@ -249,8 +254,12 @@ impl CadApp { } } - /// Inject synthetic events (one pointer event per frame). + /// Wait for the frame cap (if any), then inject synthetic events (one pointer event per frame). pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) { + #[cfg(not(target_arch = "wasm32"))] + if let Some(cap) = &mut self.frame_cap { + cap.wait(); + } if self.synthetic.is_empty() { return; }