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

commit399a80c70f39e1f35dbbdf7965cc94a9dc047099
parent66232ed7c7
authorLucas Galante <[email protected]>
date2026-09-04 10:29
perf(pan): step the camera on the output frame, not a 16ms timer or per input event

Wheel glides, kinetic coasts, zoom targets and overview ramps advanced on
a free-running 16ms wl_event timer re-armed after each step, so their
period beat against the display's vblank (two steps in some refreshes,
one in others). Trackpad pans were worse: every libinput axis event ran
the full viewport relayout immediately — twice per sample on a diagonal —
and the position on screen was whichever event happened last before the
flip.

The camera now steps once per output frame: Output::handle_frame calls
WindowManager::step_camera_frame before render_and_commit, which applies
finger motion queued since the last frame (queue_pan; the cursor no
longer relayouts per event), advances any live animation by the measured
elapsed time (advance_camera_animation, the former timer body), and
relayouts if the camera moved. start_panning_animation schedules a frame
on every enabled output and arms a 50ms watchdog that only re-requests
frames while an animation is live, so a quiet pipeline cannot strand it.

CCE_FRAME_DEBUG now also logs the viewport relayout time per motion
frame. Measured in a headless shadow (which, as a side effect of the
camera scheduling its own frames, now renders pan frames at all): 869
frames over 19s of pans and a zoom flight, render mean 566us / p95
905us, relayout mean 47us with three windows — the full arrange pass is
not the pan's cost, so no camera-only fast path.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

 src/server/cursor.rs         |   9 +-
 src/server/output.rs         |   3 +
 src/server/window_manager.rs | 324 +++++++++++++++++++++++++++----------------
 3 files changed, 206 insertions(+), 130 deletions(-)

diff --git a/src/server/cursor.rs b/src/server/cursor.rs
index 0bbf9f5..d1d008c 100644
--- a/src/server/cursor.rs
+++ b/src/server/cursor.rs
@@ -2056,9 +2056,9 @@ unsafe extern "C" fn handle_axis(listener: *mut ffi::wl_listener, data: *mut std
                 // velocity estimate is kept for the coast on the lift.
                 wm.stop_panning_animation();
                 if vertical {
-                    wm.desk_pan_y += step;
+                    wm.queue_pan(0.0, step);
                 } else {
-                    wm.desk_pan_x += step;
+                    wm.queue_pan(step, 0.0);
                 }
                 let dt_ms = now_ms.wrapping_sub(cursor.pan_last_msec[axis]).clamp(4, 100) as f64;
                 let sample = step / (dt_ms / 1000.0);
@@ -2069,11 +2069,6 @@ unsafe extern "C" fn handle_axis(listener: *mut ffi::wl_listener, data: *mut std
                 };
                 cursor.pan_last_msec[axis] = now_ms;
                 wm.pan_finger_v[axis] = cursor.pan_vel[axis];
-                if matches!(wm.state, crate::window_manager::WindowManagerState::Idle) {
-                    wm.update_viewport_local();
-                } else {
-                    wm.dirty_windowing();
-                }
             } else if was_panning {
                 // The lift (libinput's zero-delta finger event): fling on the
                 // estimated velocity unless the finger had come to rest first
diff --git a/src/server/output.rs b/src/server/output.rs
index 3a7bb73..0e60309 100644
--- a/src/server/output.rs
+++ b/src/server/output.rs
@@ -1594,6 +1594,9 @@ pub(crate) fn frame_debug() -> bool {
 
 unsafe extern "C" fn handle_frame(listener: *mut ffi::wl_listener, _data: *mut std::ffi::c_void) {
     let output = &mut *crate::container_of!(listener, Output, frame);
+    // The camera steps here, on the vblank, so what this frame renders is
+    // the position computed for it (see WindowManager::step_camera_frame).
+    (*output.server).wm.step_camera_frame();
     let render_start = if frame_debug() {
         Some(std::time::Instant::now())
     } else {
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index 5a943cf..84108d2 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -177,6 +177,16 @@ pub struct WindowManager {
     /// gesture is panning the desktop; zero otherwise. Grid patches use it
     /// to prefetch toward where the gesture is heading.
     pub pan_finger_v: [f64; 2],
+    /// A camera animation (wheel glide, coast, zoom target, overview ramp) is
+    /// live: `step_camera_frame` advances it once per output frame, and the
+    /// watchdog timer keeps frames coming while it is set.
+    pub camera_anim_active: bool,
+    /// Finger-pan motion (virtual units, `[x, y]`) queued since the last
+    /// frame. Trackpad axis events used to relayout the desktop per event
+    /// (twice per sample for a diagonal); they now accumulate here and are
+    /// 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],
     pub animation_timer: *mut ffi::wl_event_source,
     /// Edge auto-pan velocity during an interactive move/resize, in SCREEN
     /// px/s (the tick divides by zoom). Written by `Seat::update_edge_pan`
@@ -364,6 +374,8 @@ impl WindowManager {
         self.pan_coast_vy = 0.0;
         self.zoom_anchor = None;
         self.pan_finger_v = [0.0, 0.0];
+        self.camera_anim_active = false;
+        self.pan_pending = [0.0, 0.0];
         self.animation_timer = std::ptr::null_mut();
         self.edge_pan_vx = 0.0;
         self.edge_pan_vy = 0.0;
@@ -1259,6 +1271,8 @@ impl WindowManager {
         self.pan_coast_vx = 0.0;
         self.pan_coast_vy = 0.0;
         self.zoom_anchor = None;
+        self.camera_anim_active = false;
+        self.anim_last_tick = None;
     }
 
     /// Wheel-glide rate for the desktop camera, 1/s (`input { scroll_ease }`).
@@ -1455,11 +1469,16 @@ impl WindowManager {
         }
     }
 
-    /// Arm the camera animation timer (creating it on first use): every 16ms
-    /// `handle_panning_animation_tick` eases `desk_pan_x/y` toward
-    /// `target_desk_pan_x/y` and `desk_zoom` toward `target_desk_zoom` (the
-    /// overview enter/exit transition). Callers set the targets first.
+    /// Start (or continue) the camera animation: the step itself runs in
+    /// `step_camera_frame` on every output frame, so this schedules a frame
+    /// and arms the watchdog timer that keeps frames flowing while the
+    /// animation is live. Callers set the targets first.
     pub unsafe fn start_panning_animation(&mut self) {
+        self.camera_anim_active = true;
+        if self.anim_last_tick.is_none() {
+            self.anim_last_tick = Some(std::time::Instant::now());
+        }
+        self.schedule_frame_all_outputs();
         if self.animation_timer.is_null() {
             let event_loop = ffi::wl_display_get_event_loop((*self.server).wl_server);
             self.animation_timer = ffi::wl_event_loop_add_timer(
@@ -1469,13 +1488,171 @@ impl WindowManager {
             );
         }
         if !self.animation_timer.is_null() {
-            if self.anim_last_tick.is_none() {
-                self.anim_last_tick = Some(std::time::Instant::now());
+            ffi::wl_event_source_timer_update(self.animation_timer, CAMERA_WATCHDOG_MS);
+        }
+    }
+
+    /// Ask every enabled output for a frame (the camera step runs in the
+    /// frame handler). A no-op for an output that already has one pending.
+    pub unsafe fn schedule_frame_all_outputs(&mut self) {
+        let outputs_list = &mut (*self.server).om.outputs as *mut ffi::wl_list as *mut WlList;
+        let mut curr = (*outputs_list).next;
+        while curr != outputs_list {
+            let next = (*curr).next;
+            let output = crate::container_of!(curr, crate::output::Output, link);
+            if (*output).sent.state == crate::output::OutputStateValue::Enabled {
+                ffi::wlr_output_schedule_frame((*output).wlr_output);
+            }
+            curr = next;
+        }
+    }
+
+    /// Queue finger-pan motion for the next output frame (see `pan_pending`).
+    pub unsafe fn queue_pan(&mut self, dx: f64, dy: f64) {
+        self.pan_pending[0] += dx;
+        self.pan_pending[1] += dy;
+        self.schedule_frame_all_outputs();
+    }
+
+    /// The camera step for the frame about to render: apply queued finger
+    /// motion, advance any live animation by the real elapsed time, and
+    /// relayout if the camera moved. Called from the output frame handler
+    /// before `render_and_commit`, so the position on screen is the one
+    /// computed for this vblank.
+    pub unsafe fn step_camera_frame(&mut self) {
+        let has_pending = self.pan_pending != [0.0, 0.0];
+        if !self.camera_anim_active && !has_pending {
+            return;
+        }
+        let now = std::time::Instant::now();
+        let dt = self.anim_last_tick.map_or(0.0, |t| now.duration_since(t).as_secs_f64());
+        // A second output's frame in the same vblank takes no extra step.
+        if self.camera_anim_active && !has_pending && dt < 0.002 {
+            return;
+        }
+        if has_pending {
+            self.desk_pan_x += self.pan_pending[0];
+            self.desk_pan_y += self.pan_pending[1];
+            self.pan_pending = [0.0, 0.0];
+        }
+        if self.camera_anim_active {
+            self.anim_last_tick = Some(now);
+            if self.advance_camera_animation(dt.clamp(0.0, 0.1)) {
+                self.camera_anim_active = false;
+                self.anim_last_tick = None;
             }
-            ffi::wl_event_source_timer_update(self.animation_timer, 16);
+        }
+        if matches!(self.state, WindowManagerState::Idle) {
+            self.update_viewport_local();
+        } else {
+            self.dirty_windowing();
         }
     }
 
+    /// Advance the camera animation by `dt` seconds. Returns true when
+    /// nothing is left to animate.
+    unsafe fn advance_camera_animation(&mut self, dt: f64) -> bool {
+        let mut done = true;
+        // Frame-rate independent exponential approach: the same fraction of
+        // the remaining distance per unit time whatever the frame pacing.
+        let factor = 1.0 - (-self.scroll_ease_rate() * dt).exp();
+
+        // Ramp-driven transition: position is a pure function of elapsed
+        // time, so a stalled frame never changes where the camera lands.
+        let ramp = self.camera_ramp_anim.as_ref().map(|a| {
+            (a.start, a.target, a.started.elapsed().as_secs_f64() * 1000.0 / a.duration_ms)
+        });
+        if let Some((start, target, t)) = ramp {
+            if t >= 1.0 {
+                self.desk_pan_x = target.pan_x;
+                self.desk_pan_y = target.pan_y;
+                self.desk_zoom = target.zoom;
+                self.camera_ramp_anim = None;
+            } else if let Some((ramp, _)) = &self.layout.overview_anim {
+                let p = ramp.progress(t);
+                let cam = crate::policy::camera::anchored_interp(start, target, p);
+                self.desk_pan_x = cam.pan_x;
+                self.desk_pan_y = cam.pan_y;
+                self.desk_zoom = cam.zoom;
+                done = false;
+            } else {
+                // Ramp was unconfigured mid-flight (reload): land instantly.
+                self.desk_pan_x = target.pan_x;
+                self.desk_pan_y = target.pan_y;
+                self.desk_zoom = target.zoom;
+                self.camera_ramp_anim = None;
+            }
+        }
+
+        if let Some(target_x) = self.target_desk_pan_x {
+            let dx = target_x - self.desk_pan_x;
+            if dx.abs() > 0.5 {
+                self.desk_pan_x += dx * factor;
+                done = false;
+            } else {
+                self.desk_pan_x = target_x;
+                self.target_desk_pan_x = None;
+            }
+        }
+        if let Some(target_y) = self.target_desk_pan_y {
+            let dy = target_y - self.desk_pan_y;
+            if dy.abs() > 0.5 {
+                self.desk_pan_y += dy * factor;
+                done = false;
+            } else {
+                self.desk_pan_y = target_y;
+                self.target_desk_pan_y = None;
+            }
+        }
+
+        // Zoom eases geometrically (exponential approach in log space): a
+        // linear step would leap multiple-x per frame at the small end of an
+        // overview exit, while a constant per-frame RATIO reads as uniform
+        // motion.
+        if let Some(target_zoom) = self.target_desk_zoom {
+            let cur = self.desk_zoom.max(1e-6);
+            let log_delta = (target_zoom / cur).ln();
+            let new_zoom = if log_delta.abs() > 0.001 {
+                done = false;
+                cur * (log_delta * factor).exp()
+            } else {
+                self.target_desk_zoom = None;
+                target_zoom
+            };
+            // An anchored zoom (wheel zoom about the cursor) re-derives the
+            // pan from the anchor every step, so the pivot never wanders.
+            if let Some((ax, ay)) = self.zoom_anchor {
+                let cam = crate::policy::camera::zoom_about_anchor(self.camera(), ax, ay, new_zoom);
+                self.desk_pan_x = cam.pan_x;
+                self.desk_pan_y = cam.pan_y;
+                self.desk_zoom = cam.zoom;
+                if self.target_desk_zoom.is_none() {
+                    self.zoom_anchor = None;
+                }
+            } else {
+                self.desk_zoom = new_zoom;
+            }
+        }
+
+        // Kinetic pan: a trackpad flick's velocity carries the desktop on,
+        // decaying under friction; it stalls below one screen pixel per frame.
+        if self.pan_coast_vx != 0.0 || self.pan_coast_vy != 0.0 {
+            self.desk_pan_x += self.pan_coast_vx * dt;
+            self.desk_pan_y += self.pan_coast_vy * dt;
+            let decay = (-self.scroll_friction() * dt).exp();
+            self.pan_coast_vx *= decay;
+            self.pan_coast_vy *= decay;
+            let screen_speed = self.pan_coast_vx.hypot(self.pan_coast_vy) * self.desk_zoom;
+            if screen_speed < 5.0 {
+                self.pan_coast_vx = 0.0;
+                self.pan_coast_vy = 0.0;
+            } else {
+                done = false;
+            }
+        }
+        done
+    }
+
     pub unsafe fn ensure_windowing(&self) -> bool {
         match self.state {
             WindowManagerState::Manage => true,
@@ -2860,7 +3037,15 @@ impl WindowManager {
         self.last_viewport_pan_x = self.desk_pan_x;
         self.last_viewport_pan_y = self.desk_pan_y;
 
+        let arrange_t0 = crate::output::frame_debug().then(std::time::Instant::now);
         self.arrange_views();
+        if let Some(t0) = arrange_t0 {
+            log::info!(
+                "[cce-frame] viewport relayout {}us ({} windows, moved={moved})",
+                t0.elapsed().as_micros(),
+                self.windows.count()
+            );
+        }
         // Clear rendering dirty flag so we don't trigger the idle callback's IPC handshake
         self.rendering_scheduled.dirty = false;
         self.remove_dirty_idle();
@@ -5680,129 +5865,22 @@ pub struct CameraRampAnim {
     pub duration_ms: f64,
 }
 
+/// Interval of the camera-animation watchdog. The camera steps in the
+/// output frame handler; this timer only re-requests a frame while an
+/// animation is live, so a frame pipeline that goes quiet (nothing else
+/// damaged, an output that stopped delivering frames) cannot strand it.
+const CAMERA_WATCHDOG_MS: i32 = 50;
+
 pub(crate) unsafe extern "C" fn handle_panning_animation_tick(data: *mut std::ffi::c_void) -> std::os::raw::c_int {
     let wm = data as *mut WindowManager;
     if wm.is_null() {
         return 0;
     }
-
-    let mut done = true;
-    // Frame-rate independent exponential approach: the same fraction of the
-    // remaining distance per unit time whatever the timer's actual cadence
-    // (a 16ms timer under load fires late; the old fixed 0.15/tick then
-    // stuttered). dt is clamped so a stalled loop cannot leap.
-    let now = std::time::Instant::now();
-    let dt = (*wm)
-        .anim_last_tick
-        .map_or(0.016, |t| now.duration_since(t).as_secs_f64())
-        .clamp(0.0, 0.1);
-    (*wm).anim_last_tick = Some(now);
-    let factor = 1.0 - (-(*wm).scroll_ease_rate() * dt).exp();
-
-    // Ramp-driven transition: position is a pure function of elapsed time,
-    // so a stalled tick (busy frame) never changes where the camera lands.
-    if let Some(anim) = &(*wm).camera_ramp_anim {
-        let t = anim.started.elapsed().as_secs_f64() * 1000.0 / anim.duration_ms;
-        if t >= 1.0 {
-            (*wm).desk_pan_x = anim.target.pan_x;
-            (*wm).desk_pan_y = anim.target.pan_y;
-            (*wm).desk_zoom = anim.target.zoom;
-            (*wm).camera_ramp_anim = None;
-        } else if let Some((ramp, _)) = &(*wm).layout.overview_anim {
-            let p = ramp.progress(t);
-            let cam = crate::policy::camera::anchored_interp(anim.start, anim.target, p);
-            (*wm).desk_pan_x = cam.pan_x;
-            (*wm).desk_pan_y = cam.pan_y;
-            (*wm).desk_zoom = cam.zoom;
-            done = false;
-        } else {
-            // Ramp was unconfigured mid-flight (reload): land instantly.
-            (*wm).desk_pan_x = anim.target.pan_x;
-            (*wm).desk_pan_y = anim.target.pan_y;
-            (*wm).desk_zoom = anim.target.zoom;
-            (*wm).camera_ramp_anim = None;
-        }
-    }
-
-    if let Some(target_x) = (*wm).target_desk_pan_x {
-        let dx = target_x - (*wm).desk_pan_x;
-        if dx.abs() > 0.5 {
-            (*wm).desk_pan_x += dx * factor;
-            done = false;
-        } else {
-            (*wm).desk_pan_x = target_x;
-            (*wm).target_desk_pan_x = None;
-        }
-    }
-    
-    if let Some(target_y) = (*wm).target_desk_pan_y {
-        let dy = target_y - (*wm).desk_pan_y;
-        if dy.abs() > 0.5 {
-            (*wm).desk_pan_y += dy * factor;
-            done = false;
-        } else {
-            (*wm).desk_pan_y = target_y;
-            (*wm).target_desk_pan_y = None;
-        }
-    }
-
-    // Zoom eases geometrically (exponential approach in log space): a linear
-    // step would leap multiple-x per frame at the small end of an overview
-    // exit, while a constant per-frame RATIO reads as uniform motion.
-    if let Some(target_zoom) = (*wm).target_desk_zoom {
-        let cur = (*wm).desk_zoom.max(1e-6);
-        let log_delta = (target_zoom / cur).ln();
-        let new_zoom = if log_delta.abs() > 0.001 {
-            done = false;
-            cur * (log_delta * factor).exp()
-        } else {
-            (*wm).target_desk_zoom = None;
-            target_zoom
-        };
-        // An anchored zoom (wheel zoom about the cursor) re-derives the pan
-        // from the anchor every step, so the pivot never wanders mid-glide.
-        if let Some((ax, ay)) = (*wm).zoom_anchor {
-            let cam = crate::policy::camera::zoom_about_anchor((*wm).camera(), ax, ay, new_zoom);
-            (*wm).desk_pan_x = cam.pan_x;
-            (*wm).desk_pan_y = cam.pan_y;
-            (*wm).desk_zoom = cam.zoom;
-            if (*wm).target_desk_zoom.is_none() {
-                (*wm).zoom_anchor = None;
-            }
-        } else {
-            (*wm).desk_zoom = new_zoom;
-        }
-    }
-
-    // Kinetic pan: a trackpad flick's velocity carries the desktop on,
-    // decaying under friction; it stalls below one screen pixel per frame.
-    if (*wm).pan_coast_vx != 0.0 || (*wm).pan_coast_vy != 0.0 {
-        (*wm).desk_pan_x += (*wm).pan_coast_vx * dt;
-        (*wm).desk_pan_y += (*wm).pan_coast_vy * dt;
-        let decay = (-(*wm).scroll_friction() * dt).exp();
-        (*wm).pan_coast_vx *= decay;
-        (*wm).pan_coast_vy *= decay;
-        let screen_speed = ((*wm).pan_coast_vx.hypot((*wm).pan_coast_vy)) * (*wm).desk_zoom;
-        if screen_speed < 5.0 {
-            (*wm).pan_coast_vx = 0.0;
-            (*wm).pan_coast_vy = 0.0;
-        } else {
-            done = false;
-        }
-    }
-
-    if matches!((*wm).state, WindowManagerState::Idle) {
-        (*wm).update_viewport_local();
-    } else {
-        (*wm).dirty_windowing();
-    }
-    
-    if !done {
+    if (*wm).camera_anim_active {
+        (*wm).schedule_frame_all_outputs();
         if !(*wm).animation_timer.is_null() {
-            ffi::wl_event_source_timer_update((*wm).animation_timer, 16);
+            ffi::wl_event_source_timer_update((*wm).animation_timer, CAMERA_WATCHDOG_MS);
         }
-    } else {
-        (*wm).anim_last_tick = None;
     }
     0
 }