git.lucas.co / cce-compositor
Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git

commit1abe0e731ec8709160416ca72a393378b6ff825f
parent8eb5d5e6f7
authorLucas Galante <[email protected]>
date2026-09-15 21:48
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;