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 <noreply@anthropic.com>

* Move frame-cap and status-repaint hunks away from other PRs' to avoid merge conflicts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Valentin Klein 2026-10-10 18:16:34 +02:00 committed by GitHub
parent a349e4925f
commit 3dd97cad56
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 200 additions and 18 deletions

View File

@ -5,6 +5,9 @@
//! `--control <port>` (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);
}

View File

@ -79,6 +79,9 @@ pub fn apply_hot_grip(app: &mut CadApp, to: Vec2) {
pub struct CanvasState {
pub list: Option<DisplayList>,
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<crate::gpu::GpuTarget>,
/// 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<u64>,
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<usize> {
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<usize> {
if handles.is_empty() {
return Vec::new();
}
let set: std::collections::HashSet<Handle> = 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<Handle> = 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<Pos2>) -> Option<HotGrip> {
}
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<Handle> {
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]);
}
}

View File

@ -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());

View File

@ -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<std::time::Instant>,
}
#[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)],

View File

@ -106,6 +106,9 @@ pub struct CadApp {
queued_shots: Vec<(u64, f64, u32)>,
shot_token: u64,
pub synthetic: Vec<egui::Event>,
/// Caps the frame rate when presenting without vsync (see [`gpu::FrameCap`]).
#[cfg(not(target_arch = "wasm32"))]
pub frame_cap: Option<gpu::FrameCap>,
styled: bool,
/// The theme last installed into egui (`None` until the first frame).
shown_theme: Option<egui::Theme>,
@ -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;
}