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

commit8eb5d5e6f7e83359d698a9da1683c1d1ba49f8cc
parent1cacffb5de
authorLucas Galante <[email protected]>
date2026-09-15 21:42
feat(camera): animate on a vblank-locked presentation clock; count drops

The camera stepped by wall-clock time between output frame callbacks,
so callback jitter (13–17 ms spread measured at a steady 60 Hz) became
position jitter — the "60 fps but it stutters" signature. Each output
now keeps a frame clock: a vblank grid phase-locked to the `present`
timestamps (re-anchored only on more than a quarter-period drift, so
timestamp jitter and the headless backend's commit-time stamps never
reach the camera). `handle_frame` steps the camera to the first grid
point after now, so dt is an exact whole number of refresh periods, a
missed vblank is one double step, and a second frame inside a period
is a zero step. The overview ramp reads its progress off the same
clock. Measured headless: 85 of 95 steps at exactly 16666 µs, one at
33333, versus the previous spread.

`present` also counts skipped vblanks (sequence gaps, or time gaps
while animating on backends without a counter) and, under
CCE_FRAME_DEBUG, logs `present seq=… dropped=…` and `camera step
dt=…us` — the numbers that separate GPU throughput from animation
sampling when the desktop feels rough.

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

 src/server/output.rs         | 109 +++++++++++++++++++++++++++++++++++++++++--
 src/server/util.rs           |   7 +++
 src/server/window_manager.rs |  32 ++++++++-----
 3 files changed, 134 insertions(+), 14 deletions(-)

diff --git a/src/server/output.rs b/src/server/output.rs
index 344fb03..7fb4c45 100644
--- a/src/server/output.rs
+++ b/src/server/output.rs
@@ -186,6 +186,19 @@ pub struct Output {
     pub last_rendered_pan_x: f64,
     pub last_rendered_pan_y: f64,
     pub last_rendered_zoom: f64,
+    /// Presentation clock, from the `present` event: when the last frame
+    /// turned into light (CLOCK_MONOTONIC ns, 0 = never), its vblank
+    /// sequence number (0 = the backend has none), and the refresh period.
+    /// `predicted_present_ns` derives the camera's frame clock from these.
+    pub present_when_ns: u64,
+    pub present_seq: u32,
+    pub present_refresh_ns: u64,
+    /// Running count of vblanks skipped between consecutive presents —
+    /// the dropped-frame counter `CCE_FRAME_DEBUG` reports.
+    pub present_dropped: u64,
+    /// Phase of the frame clock's vblank grid (ns within a refresh period),
+    /// locked to the presentation times; `u64::MAX` until the first frame.
+    pub frame_phase_ns: u64,
     /// What the status-backdrop measurement last ran against: the window
     /// manager's layout epoch, the camera, and when. It re-runs only when one
     /// of those moved or `BACKDROP_REFRESH` has passed (for window content
@@ -522,6 +535,11 @@ impl Output {
             last_rendered_pan_x: f64::NAN,
             last_rendered_pan_y: f64::NAN,
             last_rendered_zoom: f64::NAN,
+            present_when_ns: 0,
+            present_seq: 0,
+            present_refresh_ns: 0,
+            present_dropped: 0,
+            frame_phase_ns: u64::MAX,
             backdrop_epoch: u64::MAX,
             backdrop_cam: (f64::NAN, f64::NAN, f64::NAN),
             backdrop_measured_at: None,
@@ -1747,11 +1765,61 @@ fn ovdbg_enabled() -> bool {
 /// nothing at all.
 const BACKDROP_REFRESH: std::time::Duration = std::time::Duration::from_millis(250);
 
+impl Output {
+    /// The refresh period to plan frames by: what the last `present`
+    /// reported, else the current mode's rate, else 60 Hz.
+    unsafe fn refresh_period_ns(&self) -> u64 {
+        if self.present_refresh_ns > 0 {
+            return self.present_refresh_ns;
+        }
+        let mhz = if self.wlr_output.is_null() { 0 } else { ffi::river_wlr_output_get_refresh(self.wlr_output) };
+        if mhz > 0 {
+            1_000_000_000_000 / mhz as u64
+        } else {
+            16_666_667
+        }
+    }
+
+    /// When the frame rendered now is expected to reach the screen: the
+    /// first point of a vblank grid after now (plus a small render lead).
+    /// The camera animates to this instant, so its step is an exact whole
+    /// number of refresh periods whether the frame callback ran early or
+    /// late, a missed vblank is a double step rather than a stumble, and a
+    /// second frame inside one period gets the same target (a zero step).
+    ///
+    /// The grid's phase locks to the hardware presentation timestamps and
+    /// re-anchors only when they drift by more than a quarter period, so
+    /// the per-present jitter of the timestamps themselves (and the
+    /// headless backend's commit-time stamps) never reaches the camera.
+    pub unsafe fn predicted_present_ns(&mut self) -> u64 {
+        let now = util::timestamp_ns();
+        let period = self.refresh_period_ns().max(1);
+        if self.present_when_ns != 0 {
+            let phase = self.present_when_ns % period;
+            let drift = if self.frame_phase_ns == u64::MAX {
+                u64::MAX
+            } else {
+                let d = phase.abs_diff(self.frame_phase_ns);
+                d.min(period - d)
+            };
+            if drift > period / 4 {
+                self.frame_phase_ns = phase;
+            }
+        } else if self.frame_phase_ns == u64::MAX {
+            self.frame_phase_ns = now % period;
+        }
+        let lead = period / 8;
+        let base = (now + lead).saturating_sub(self.frame_phase_ns);
+        self.frame_phase_ns + (base / period + 1) * period
+    }
+}
+
 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();
+    // The camera steps here, on the vblank, to where it should be at the
+    // instant THIS frame is presented (see WindowManager::step_camera_frame).
+    let frame_target_ns = output.predicted_present_ns();
+    (*output.server).wm.step_camera_frame(frame_target_ns);
     // Likewise the interactive move/resize: one configure + relayout per
     // vblank, for the pointer's latest position.
     (*output.server).wm.step_op_frame();
@@ -1791,6 +1859,41 @@ unsafe extern "C" fn handle_present(listener: *mut ffi::wl_listener, data: *mut
     if !(*event).presented {
         return;
     }
+    // Presentation clock bookkeeping, and the dropped-frame count: a vblank
+    // sequence that advanced by more than one since the last present means
+    // frames were skipped. Backends without a counter (headless) report
+    // seq 0; there the gap is inferred from time, but only while the camera
+    // is animating — a still desktop legitimately presents nothing for ages.
+    {
+        let when_ns = (*event).when.tv_sec as u64 * 1_000_000_000 + (*event).when.tv_nsec as u64;
+        if (*event).refresh > 0 {
+            output.present_refresh_ns = (*event).refresh as u64;
+        }
+        let period = output.refresh_period_ns();
+        let seq = (*event).seq as u32;
+        let mut dropped = 0u64;
+        if seq != 0 && output.present_seq != 0 && seq > output.present_seq + 1 {
+            dropped = (seq - output.present_seq - 1) as u64;
+        } else if seq == 0 && output.present_when_ns != 0 && (*output.server).wm.camera_anim_active {
+            let gap = when_ns.saturating_sub(output.present_when_ns);
+            let periods = (gap + period / 2) / period;
+            dropped = periods.saturating_sub(1);
+        }
+        if dropped > 0 {
+            output.present_dropped += dropped;
+            if frame_debug() {
+                log::info!(
+                    "[cce-frame] present seq={} dropped={} (total {}) refresh={}us",
+                    seq,
+                    dropped,
+                    output.present_dropped,
+                    period / 1000
+                );
+            }
+        }
+        output.present_when_ns = when_ns;
+        output.present_seq = seq;
+    }
     match output.lock_render_state {
         LockRenderState::PendingUnlock => {
             output.lock_render_state = LockRenderState::Unlocked;
diff --git a/src/server/util.rs b/src/server/util.rs
index 2eddfa4..9cc4e84 100644
--- a/src/server/util.rs
+++ b/src/server/util.rs
@@ -1,6 +1,13 @@
 // SPDX-FileCopyrightText: © 2022 The River Developers
 // SPDX-License-Identifier: GPL-3.0-only
 
+/// CLOCK_MONOTONIC in nanoseconds — the presentation clock's time base
+/// (`wlr_output_event_present.when` is on the same clock).
+pub fn timestamp_ns() -> u64 {
+    let ts = timestamp();
+    ts.tv_sec as u64 * 1_000_000_000 + ts.tv_nsec as u64
+}
+
 pub fn timestamp() -> libc::timespec {
     let mut ts = libc::timespec { tv_sec: 0, tv_nsec: 0 };
     unsafe {
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index 6f45fac..001b220 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -229,7 +229,10 @@ pub struct WindowManager {
     /// are frame-rate independent, so a late timer tick takes a
     /// proportionally larger step instead of a stutter. `None` while the
     /// timer is idle, so the first step after arming measures from the arm.
-    pub anim_last_tick: Option<std::time::Instant>,
+    /// The camera's frame clock: the presentation instant the last step
+    /// animated to (CLOCK_MONOTONIC ns), so each step's dt is measured
+    /// vblank to vblank, not callback to callback.
+    pub anim_last_tick: Option<u64>,
     /// Kinetic desktop pan after a trackpad flick: virtual units/s, decayed
     /// by `input.scroll_friction` each tick until it stalls. Zero = no coast.
     pub pan_coast_vx: f64,
@@ -1805,7 +1808,7 @@ impl WindowManager {
     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.anim_last_tick = Some(crate::util::timestamp_ns());
         }
         self.schedule_frame_all_outputs();
         if self.animation_timer.is_null() {
@@ -1889,13 +1892,15 @@ impl WindowManager {
     /// 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) {
+    /// `frame_target_ns` is when the frame about to render is predicted to
+    /// 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];
         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());
+        let dt = self.anim_last_tick.map_or(0.0, |t| frame_target_ns.saturating_sub(t) as f64 / 1e9);
         // A second output's frame in the same vblank takes no extra step.
         if self.camera_anim_active && !has_pending && dt < 0.002 {
             return;
@@ -1906,8 +1911,11 @@ impl WindowManager {
             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)) {
+            if crate::output::frame_debug() {
+                log::info!("[cce-frame] camera step dt={}us", (dt * 1e6) as u64);
+            }
+            self.anim_last_tick = Some(frame_target_ns);
+            if self.advance_camera_animation(dt.clamp(0.0, 0.1), frame_target_ns) {
                 self.camera_anim_active = false;
                 self.anim_last_tick = None;
             }
@@ -1921,7 +1929,7 @@ impl WindowManager {
 
     /// 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 {
+    unsafe fn advance_camera_animation(&mut self, dt: f64, frame_target_ns: u64) -> bool {
         let mut done = true;
         // Frame-rate independent exponential approach: the same fraction of
         // the remaining distance per unit time whatever the frame pacing.
@@ -1930,7 +1938,7 @@ impl WindowManager {
         // 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)
+            (a.start, a.target, frame_target_ns.saturating_sub(a.started_ns) as f64 / 1e6 / a.duration_ms)
         });
         if let Some((start, target, t)) = ramp {
             if t >= 1.0 {
@@ -6441,7 +6449,7 @@ impl crate::policy::api::Compositor for WindowManager {
                             self.camera_ramp_anim = Some(CameraRampAnim {
                                 start: self.camera(),
                                 target: camera,
-                                started: std::time::Instant::now(),
+                                started_ns: crate::util::timestamp_ns(),
                                 duration_ms,
                             });
                             self.target_desk_pan_x = None;
@@ -6645,7 +6653,9 @@ pub(crate) unsafe extern "C" fn handle_edge_pan_tick(data: *mut std::ffi::c_void
 pub struct CameraRampAnim {
     pub start: crate::policy::camera::Camera,
     pub target: crate::policy::camera::Camera,
-    pub started: std::time::Instant,
+    /// Presentation-clock start (CLOCK_MONOTONIC ns); progress is read
+    /// against each frame's predicted present time, never the wall clock.
+    pub started_ns: u64,
     pub duration_ms: f64,
 }