Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
perf(camera): coalesce pinch per frame; treat sub-pixel pans as still
Pinch zoom wrote the camera and relaid out the desktop synchronously
per libinput event — the one camera path that skipped the per-frame
queue finger pans use. libinput reports faster than the refresh, so
frames sampled the gesture unevenly and layouts ran for frames nobody
saw. It now queues (zoom, anchor) like `queue_pan` and the frame step
applies the latest, pivoting about the cursor as before.
A camera "move" is now quantized to screen pixels: nodes sit on integer
logical px, so the sub-pixel tail of an eased pan (half a second under
1 px after every keyed pan) moved nothing on screen yet forced a full
repaint of the output every frame — 9 megapixels a frame at 4K for no
visible change. Both the viewport relayout and the output's
camera-changed damage net compare rounded pan offsets; the exact
compare stays for zoom.
Scaled buffer sizes and offsets round like node positions do
(`virtual_to_screen`) instead of truncating, so a window's edges no
longer wobble a pixel against its neighbours mid-zoom.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
src/server/cursor.rs | 45 +++++++++++++++-----------------------------
src/server/output.rs | 4 +++-
src/server/window.rs | 28 +++++++++++++--------------
src/server/window_manager.rs | 33 ++++++++++++++++++++++++++++++--
4 files changed, 63 insertions(+), 47 deletions(-)
diff --git a/src/server/cursor.rs b/src/server/cursor.rs
index 5ee1246..e3496b4 100644
--- a/src/server/cursor.rs
+++ b/src/server/cursor.rs
@@ -3458,37 +3458,22 @@ unsafe extern "C" fn handle_pinch_update(listener: *mut ffi::wl_listener, data:
if cursor.pinch_zoom_active {
let wm = &mut (*seat.server).wm;
- let old_zoom = wm.desk_zoom;
let new_zoom = crate::policy::camera::pinch_zoom(cursor.pinch_start_zoom, (*event).scale);
- if new_zoom != old_zoom {
- let cx = cursor.x();
- let cy = cursor.y();
- let wlr_output = (*(*seat).server).om.output_at(cx, cy);
- let (phys_x, phys_y) = if !wlr_output.is_null() {
- let mut output_box = ffi::wlr_box { x: 0, y: 0, width: 0, height: 0 };
- ffi::wlr_output_layout_get_box((*(*seat).server).om.output_layout, wlr_output, &mut output_box);
- (output_box.x as f64, output_box.y as f64)
- } else {
- (0.0, 0.0)
- };
- // Like the wheel, pinch pivots about the cursor: the virtual
- // point under it stays put on screen.
- let cam = crate::policy::camera::zoom_about_anchor(
- wm.camera(),
- cx - phys_x,
- cy - phys_y,
- new_zoom,
- );
- wm.desk_pan_x = cam.pan_x;
- wm.desk_pan_y = cam.pan_y;
- wm.desk_zoom = cam.zoom;
- wm.set_mode(if crate::policy::camera::is_overview(cam.zoom) { crate::window_manager::WindowManagerMode::Overview } else { crate::window_manager::WindowManagerMode::Normal });
- if matches!(wm.state, crate::window_manager::WindowManagerState::Idle) {
- wm.update_viewport_local();
- } else {
- wm.dirty_windowing();
- }
- }
+ let cx = cursor.x();
+ let cy = cursor.y();
+ let wlr_output = (*(*seat).server).om.output_at(cx, cy);
+ let (phys_x, phys_y) = if !wlr_output.is_null() {
+ let mut output_box = ffi::wlr_box { x: 0, y: 0, width: 0, height: 0 };
+ ffi::wlr_output_layout_get_box((*(*seat).server).om.output_layout, wlr_output, &mut output_box);
+ (output_box.x as f64, output_box.y as f64)
+ } else {
+ (0.0, 0.0)
+ };
+ // Applied on the next output frame, like finger pans: libinput
+ // delivers pinch updates faster than the refresh rate, and stepping
+ // the camera per event relaid out the desktop for frames nobody
+ // saw and zoomed unevenly (two steps in one frame, one in the next).
+ wm.queue_pinch(new_zoom, cx - phys_x, cy - phys_y);
return;
}
diff --git a/src/server/output.rs b/src/server/output.rs
index 7fb4c45..f32597f 100644
--- a/src/server/output.rs
+++ b/src/server/output.rs
@@ -666,8 +666,10 @@ impl Output {
// a camera change with no other pending damage would otherwise skip
// the frame entirely.
{
+ // Quantized to screen pixels: a sub-pixel pan moves no node
+ // (see `update_viewport_local`), so it is not a reason to paint.
let wm = &(*self.server).wm;
- let cam = (wm.desk_pan_x, wm.desk_pan_y, wm.desk_zoom);
+ let cam = ((wm.desk_pan_x * wm.desk_zoom).round(), (wm.desk_pan_y * wm.desk_zoom).round(), wm.desk_zoom);
if cam != (self.last_rendered_pan_x, self.last_rendered_pan_y, self.last_rendered_zoom) {
self.last_rendered_pan_x = cam.0;
self.last_rendered_pan_y = cam.1;
diff --git a/src/server/window.rs b/src/server/window.rs
index eab7681..fdc1116 100644
--- a/src/server/window.rs
+++ b/src/server/window.rs
@@ -2922,8 +2922,8 @@ impl Window {
}
_ => (self.scale, self.scale),
};
- let width = (actual_w as f64 * scale_x) as i32;
- let height = (actual_h as f64 * scale_y) as i32;
+ let width = (actual_w as f64 * scale_x).round() as i32;
+ let height = (actual_h as f64 * scale_y).round() as i32;
ffi::river_scene_node_enable_blur(
self.tree as *mut ffi::wlr_scene_node,
blur_enabled,
@@ -2987,15 +2987,15 @@ impl Window {
ffi::river_scene_buffer_set_dest_size_if_changed(buffer, w, h);
ffi::river_scene_node_set_position_if_changed(node, ox, oy);
} else {
- let dest_w = (w as f64 * data.scale_x) as i32;
- let dest_h = (h as f64 * data.scale_y) as i32;
+ let dest_w = (w as f64 * data.scale_x).round() as i32;
+ let dest_h = (h as f64 * data.scale_y).round() as i32;
ffi::river_scene_buffer_set_dest_size_if_changed(buffer, dest_w, dest_h);
// The parent offset scales like the content; the
// clip origin rides on top of it, scaled the same.
let (px, py) = get_parent_position_relative_to(node, data.ancestor);
- let dest_x = (px as f64 * (data.scale_x - 1.0) + ox as f64 * data.scale_x) as i32;
- let dest_y = (py as f64 * (data.scale_y - 1.0) + oy as f64 * data.scale_y) as i32;
+ let dest_x = (px as f64 * (data.scale_x - 1.0) + ox as f64 * data.scale_x).round() as i32;
+ let dest_y = (py as f64 * (data.scale_y - 1.0) + oy as f64 * data.scale_y).round() as i32;
ffi::river_scene_node_set_position_if_changed(node, dest_x, dest_y);
}
// Keep the opaque region in step with the dest scale —
@@ -3243,13 +3243,13 @@ impl Window {
ffi::river_scene_buffer_set_dest_size_if_changed(buffer, w, h);
ffi::river_scene_node_set_position_if_changed(node, ox, oy);
} else {
- let dest_w = (w as f64 * data.scale) as i32;
- let dest_h = (h as f64 * data.scale) as i32;
+ let dest_w = (w as f64 * data.scale).round() as i32;
+ let dest_h = (h as f64 * data.scale).round() as i32;
ffi::river_scene_buffer_set_dest_size_if_changed(buffer, dest_w, dest_h);
let (px, py) = get_parent_position_relative_to(node, data.ancestor);
- let dest_x = (px as f64 * (data.scale - 1.0) + ox as f64 * data.scale) as i32;
- let dest_y = (py as f64 * (data.scale - 1.0) + oy as f64 * data.scale) as i32;
+ let dest_x = (px as f64 * (data.scale - 1.0) + ox as f64 * data.scale).round() as i32;
+ let dest_y = (py as f64 * (data.scale - 1.0) + oy as f64 * data.scale).round() as i32;
ffi::river_scene_node_set_position_if_changed(node, dest_x, dest_y);
}
// Keep the opaque region in step with the dest scale —
@@ -5069,8 +5069,8 @@ impl Decoration {
ffi::river_scene_buffer_set_dest_size_if_changed(buffer, w, h);
ffi::river_scene_node_set_position_if_changed(node, 0, 0);
} else {
- let dest_w = (w as f64 * data.scale) as i32;
- let dest_h = (h as f64 * data.scale) as i32;
+ let dest_w = (w as f64 * data.scale).round() as i32;
+ let dest_h = (h as f64 * data.scale).round() as i32;
ffi::river_scene_buffer_set_dest_size_if_changed(buffer, dest_w, dest_h);
let (px, py) = get_parent_position_relative_to(node, data.ancestor);
@@ -5144,8 +5144,8 @@ impl Decoration {
ffi::river_scene_buffer_set_dest_size_if_changed(buffer, w, h);
ffi::river_scene_node_set_position_if_changed(node, 0, 0);
} else {
- let dest_w = (w as f64 * data.scale) as i32;
- let dest_h = (h as f64 * data.scale) as i32;
+ let dest_w = (w as f64 * data.scale).round() as i32;
+ let dest_h = (h as f64 * data.scale).round() as i32;
ffi::river_scene_buffer_set_dest_size_if_changed(buffer, dest_w, dest_h);
let (px, py) = get_parent_position_relative_to(node, data.ancestor);
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index 001b220..167d30a 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -255,6 +255,11 @@ pub struct WindowManager {
/// applied once, at the output frame, so the on-screen step lands on
/// the vblank instead of whenever the last event happened to arrive.
pub pan_pending: [f64; 2],
+ /// A pinch zoom waiting for the next output frame: (zoom, anchor x,
+ /// anchor y) in output-local px. libinput delivers pinch updates faster
+ /// than the refresh rate; the last one before a frame wins, so each
+ /// frame samples the gesture once instead of relaying out per event.
+ pub pinch_pending: Option<(f64, f64, f64)>,
/// An interactive move/resize has pointer motion the client has not
/// been configured for yet. The seat op recomputes the dragged window's
/// geometry on every pointer event (cheap, and the arrange pass reads
@@ -514,6 +519,7 @@ impl WindowManager {
self.pan_finger_v = [0.0, 0.0];
self.camera_anim_active = false;
self.pan_pending = [0.0, 0.0];
+ self.pinch_pending = None;
self.op_frame_pending = false;
self.animation_timer = std::ptr::null_mut();
self.edge_pan_vx = 0.0;
@@ -1846,6 +1852,13 @@ impl WindowManager {
self.schedule_frame_all_outputs();
}
+ /// Queue a pinch zoom about an output-local anchor for the next output
+ /// frame (see `pinch_pending`).
+ pub unsafe fn queue_pinch(&mut self, zoom: f64, ax: f64, ay: f64) {
+ self.pinch_pending = Some((zoom, ax, ay));
+ self.schedule_frame_all_outputs();
+ }
+
/// Queue the interactive move/resize's configure and relayout for the
/// next output frame (see `op_frame_pending`).
pub unsafe fn queue_op_frame(&mut self) {
@@ -1896,7 +1909,7 @@ impl WindowManager {
/// be presented (`Output::predicted_present_ns`); the animation
/// advances to that instant.
pub unsafe fn step_camera_frame(&mut self, frame_target_ns: u64) {
- let has_pending = self.pan_pending != [0.0, 0.0];
+ let has_pending = self.pan_pending != [0.0, 0.0] || self.pinch_pending.is_some();
if !self.camera_anim_active && !has_pending {
return;
}
@@ -1909,6 +1922,15 @@ impl WindowManager {
self.desk_pan_x += self.pan_pending[0];
self.desk_pan_y += self.pan_pending[1];
self.pan_pending = [0.0, 0.0];
+ if let Some((zoom, ax, ay)) = self.pinch_pending.take() {
+ // Like the wheel, pinch pivots about the cursor: the virtual
+ // point under it stays put on screen.
+ let cam = crate::policy::camera::zoom_about_anchor(self.camera(), ax, ay, zoom);
+ self.desk_pan_x = cam.pan_x;
+ self.desk_pan_y = cam.pan_y;
+ self.desk_zoom = cam.zoom;
+ self.set_mode(if crate::policy::camera::is_overview(cam.zoom) { WindowManagerMode::Overview } else { WindowManagerMode::Normal });
+ }
}
if self.camera_anim_active {
if crate::output::frame_debug() {
@@ -3493,7 +3515,14 @@ impl WindowManager {
pub unsafe fn update_viewport_local(&mut self) {
let zoom_changed = self.desk_zoom != self.last_viewport_zoom;
- let pan_changed = self.desk_pan_x != self.last_viewport_pan_x || self.desk_pan_y != self.last_viewport_pan_y;
+ // A pan counts as motion only once it moves a screen pixel: nodes
+ // sit on integer logical px (`virtual_to_screen` rounds), so the
+ // sub-pixel tail of an eased pan changes nothing on screen, and
+ // repainting the whole output for it was pure cost.
+ let zoom = self.desk_zoom;
+ let px = |pan: f64| (pan * zoom).round();
+ let pan_changed = px(self.desk_pan_x) != px(self.last_viewport_pan_x)
+ || px(self.desk_pan_y) != px(self.last_viewport_pan_y);
let moved = zoom_changed || pan_changed;
self.last_viewport_zoom = self.desk_zoom;