* 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:
parent
a349e4925f
commit
3dd97cad56
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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)],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue