Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
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,
}