Scope redraw requests and avoid redundant animation fallback wakeups

Instrument redraw sources, per-output request coalescing, and animation fallback scheduling. Route local visual changes to their affected outputs while preserving deferred grab and gesture updates.

Schedule fallback recovery using each orphaned animation output's refresh interval, suppress it while a normal frame chain exists, and retain requested final frames after scheduling failures. Add mixed-refresh, cursor footprint, frame-chain, and disconnect regressions and document measurement limits.
This commit is contained in:
Samuel Onoja committed 2026-10-02 01:21:11 +01:00
1 parent 28e03a2624
commit 6f2951c6b0
27 files changed
+964 -138

No files matched your search

+207 -78
View File
@@ -7,7 +7,9 @@ use std::time::{Duration, Instant};
mod capture; mod capture;
mod planes; mod planes;
mod redraw;
mod scheduling; mod scheduling;
use redraw::{AnimationFallback, SchedulingMetrics};
use scheduling::*; use scheduling::*;
use smithay::backend::allocator::Fourcc; use smithay::backend::allocator::Fourcc;
use smithay::backend::allocator::gbm::{GbmAllocator, GbmBufferFlags, GbmDevice}; use smithay::backend::allocator::gbm::{GbmAllocator, GbmBufferFlags, GbmDevice};
@@ -54,10 +56,15 @@ pub struct DirectBackendState {
presentation: HashMap<(DrmNode, crtc::Handle), PresentationClock>, presentation: HashMap<(DrmNode, crtc::Handle), PresentationClock>,
pub(crate) connected_outputs: Vec<ConnectedOutputInfo>, pub(crate) connected_outputs: Vec<ConnectedOutputInfo>,
animation_timer: Option<RegistrationToken>, animation_timer: Option<RegistrationToken>,
animation_active: bool, animation_deadline: Option<Instant>,
scheduling_metrics: SchedulingMetrics,
} }
impl DirectBackendState { impl DirectBackendState {
pub(crate) fn dump_scheduling_metrics(&self) {
self.scheduling_metrics.dump();
}
pub(crate) fn capture_resize_snapshot( pub(crate) fn capture_resize_snapshot(
&mut self, &mut self,
window: &smithay::desktop::Window, window: &smithay::desktop::Window,
@@ -179,6 +186,8 @@ struct DirectOutput {
capture_texture: Option<GlesTexture>, capture_texture: Option<GlesTexture>,
render_metrics: RenderMetrics, render_metrics: RenderMetrics,
frame_pending: bool, frame_pending: bool,
animation_fallback: AnimationFallback,
cursor_visible: bool,
scheduler: crate::frame_scheduler::FrameScheduler, scheduler: crate::frame_scheduler::FrameScheduler,
render_timer: Option<RegistrationToken>, render_timer: Option<RegistrationToken>,
power_off: bool, power_off: bool,
@@ -226,7 +235,8 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
presentation: HashMap::new(), presentation: HashMap::new(),
connected_outputs: Vec::new(), connected_outputs: Vec::new(),
animation_timer: None, animation_timer: None,
animation_active: false, animation_deadline: None,
scheduling_metrics: SchedulingMetrics::new(),
}); });
open_primary_device(event_loop, state, &primary_path)?; open_primary_device(event_loop, state, &primary_path)?;
@@ -600,6 +610,8 @@ fn open_primary_device(
} else { } else {
arm_frame(state, node, crtc); arm_frame(state, node, crtc);
} }
arm_animation_timer(state);
} }
} }
} }
@@ -673,35 +685,110 @@ pub(crate) fn wake_locked_outputs(state: &mut Ferese) {
render_all(state); render_all(state);
} }
#[track_caller]
pub fn render_all(state: &mut Ferese) { pub fn render_all(state: &mut Ferese) {
redraw(state, None, std::panic::Location::caller());
}
#[track_caller]
pub(crate) fn render_on(state: &mut Ferese, outputs: &[Output]) {
redraw(state, Some(outputs), std::panic::Location::caller());
}
#[track_caller]
pub(crate) fn render_cursor(state: &mut Ferese) {
let outputs = cursor_affected_outputs(state);
redraw(state, Some(&outputs), std::panic::Location::caller());
}
fn cursor_affected_outputs(state: &Ferese) -> Vec<Output> {
state
.direct_backend
.as_ref()
.into_iter()
.flat_map(|backend| backend.devices.values())
.flat_map(|device| device.outputs.values())
.filter(|output| output.cursor_visible || redraw::cursor_on_output(state, &output.output))
.map(|output| output.output.clone())
.collect()
}
#[track_caller]
pub(crate) fn render_window(state: &mut Ferese, window: ferese_layout::WindowId) {
let outputs = state
.space
.outputs()
.filter(|output| state.window_belongs_to_output(window, output))
.cloned()
.collect::<Vec<_>>();
render_on(state, &outputs);
}
#[track_caller]
pub(crate) fn render_surface(
state: &mut Ferese,
surface: &smithay::reexports::wayland_server::protocol::wl_surface::WlSurface,
) {
use smithay::input::pointer::CursorImageStatus;
use smithay::wayland::compositor::get_parent;
let mut root = surface.clone();
while let Some(parent) = get_parent(&root) {
root = parent;
}
if matches!(&state.cursor_status, CursorImageStatus::Surface(cursor) if cursor == &root) {
render_cursor(state);
return;
}
let outputs = state.surface_outputs(&root);
render_on(state, &outputs);
}
fn redraw(state: &mut Ferese, selected: Option<&[Output]>, source: &'static std::panic::Location<'static>) {
if state.direct_backend.as_ref().is_none_or(|backend| !backend.active) { if state.direct_backend.as_ref().is_none_or(|backend| !backend.active) {
return; return;
} }
let animating = state.advance_animations(Instant::now());
render_outputs(state, animating);
}
fn render_outputs(state: &mut Ferese, animating: bool) {
if let Some(backend) = state.direct_backend.as_mut() {
backend.animation_active = animating && !state.session_lock.sleeping;
}
arm_animation_timer(state);
let outputs = state let outputs = state
.direct_backend .direct_backend
.as_ref() .as_ref()
.map(|backend| { .unwrap()
backend
.devices .devices
.iter() .iter()
.flat_map(|(node, device)| device.outputs.keys().map(|crtc| (*node, *crtc))) .flat_map(|(node, device)| {
.collect::<Vec<_>>() device
.outputs
.iter()
.map(|(crtc, output)| (*node, *crtc, output.output.clone()))
}) })
.unwrap_or_default(); .collect::<Vec<_>>();
// Include animations before advancing, so their final settled frame is
for (node, crtc) in outputs { // drawn too. Advancing shared state must not leave another output stale.
request_frame(state, node, crtc); let affected = outputs
.iter()
.map(|(_, _, output)| {
selected.is_none_or(|selected| selected.contains(output))
|| state.output_has_animations(output)
|| state.output_has_pending_visual_changes(output)
})
.collect::<Vec<_>>();
state.advance_animations(Instant::now());
let mut requested = 0;
for ((node, crtc, output), affected) in outputs.iter().zip(affected) {
if affected || state.output_has_animations(output) {
request_frame(state, *node, *crtc);
requested += 1;
} }
}
state.direct_backend.as_mut().unwrap().scheduling_metrics.redraw(
source,
selected.is_none(),
requested,
outputs.len(),
);
arm_animation_timer(state);
} }
pub fn switch_vt(state: &mut Ferese, vt: i32) { pub fn switch_vt(state: &mut Ferese, vt: i32) {
@@ -908,6 +995,12 @@ fn render_output(
})() })()
}; };
// Keep the footprint of the last successful scene, including queued
// cursor planes. Requests do not read the pointer while a grab holds it.
if rendered.is_ok() {
output.cursor_visible = redraw::cursor_on_output(state, &output.output);
}
let submitted = rendered.as_ref().is_ok_and(|submitted| *submitted); let submitted = rendered.as_ref().is_ok_and(|submitted| *submitted);
record_frame_schedule(&mut output, plan, render_started.elapsed(), submitted); record_frame_schedule(&mut output, plan, render_started.elapsed(), submitted);
@@ -1201,6 +1294,7 @@ fn remove_device(state: &mut Ferese, node: DrmNode) {
backend.connected_outputs.clear(); backend.connected_outputs.clear();
} }
arm_animation_timer(state);
tracing::info!(?node, "removed DRM device"); tracing::info!(?node, "removed DRM device");
} }
@@ -1220,48 +1314,8 @@ fn refresh_duration_from_mode(mode: DrmMode) -> Duration {
Duration::from_nanos(1_000_000_000_000 / OutputMode::from(mode).refresh.max(1) as u64) Duration::from_nanos(1_000_000_000_000 / OutputMode::from(mode).refresh.max(1) as u64)
} }
fn animation_interval(state: &Ferese) -> Duration { fn animation_fallbacks(state: &mut Ferese) -> Vec<(DrmNode, crtc::Handle, Instant)> {
state let now = Instant::now();
.direct_backend
.as_ref()
.into_iter()
.flat_map(|backend| backend.devices.values())
.flat_map(|device| device.outputs.values())
.filter_map(|output| output.output.current_mode())
.filter(|mode| mode.refresh > 0)
.map(|mode| Duration::from_nanos(1_000_000_000_000 / mode.refresh as u64))
.min()
.unwrap_or(Duration::from_millis(16))
}
fn arm_animation_timer(state: &mut Ferese) {
let needed = state
.direct_backend
.as_ref()
.is_some_and(|backend| backend.animation_active && backend.animation_timer.is_none());
if !needed {
return;
}
let deadline = state.animation_fallback_deadline(animation_interval(state));
match state
.loop_handle
.insert_source(Timer::from_deadline(deadline), |_, _, state| {
let active = state
.direct_backend
.as_ref()
.is_some_and(|backend| backend.active && backend.animation_active && !state.session_lock.sleeping);
if active {
let deadline = state.animation_fallback_deadline(animation_interval(state));
if Instant::now() < deadline {
return TimeoutAction::ToInstant(deadline);
}
let animating = state.advance_animations(Instant::now());
if let Some(backend) = state.direct_backend.as_mut() {
backend.animation_active = animating;
}
let outputs = state let outputs = state
.direct_backend .direct_backend
.as_ref() .as_ref()
@@ -1271,31 +1325,104 @@ fn arm_animation_timer(state: &mut Ferese) {
device device
.outputs .outputs
.iter() .iter()
.map(|(crtc, output)| (*node, *crtc, output.output.clone())) .map(move |(crtc, output)| (*node, *crtc, output.output.clone(), device.drm.is_active()))
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
for (node, crtc, output) in outputs { let mut fallbacks = Vec::new();
if state.output_has_animations(&output) { for (node, crtc, identity, device_active) in outputs {
let enabled = device_active
&& !state.session_lock.sleeping
&& state.direct_backend.as_ref().is_some_and(|backend| backend.active);
let animating = enabled && state.output_has_animations(&identity);
let backend = state.direct_backend.as_mut().unwrap();
let output = backend.devices.get_mut(&node).unwrap().outputs.get_mut(&crtc).unwrap();
// Preserve recovery of a requested final frame if another output
// advances this animation to rest before the watchdog is dispatched.
let active = enabled
&& (animating || (output.animation_fallback.waiting_for_frame() && output.scheduler.has_pending_request()));
let chained = output.render_timer.is_some() || output.frame_pending;
if active && chained {
backend.scheduling_metrics.chained_animation_observations += 1;
}
if let Some(deadline) = output.animation_fallback.deadline(
now,
refresh_duration_from_mode(output.mode),
active && !output.power_off,
chained,
) {
fallbacks.push((node, crtc, deadline));
}
}
fallbacks
}
fn arm_animation_timer(state: &mut Ferese) {
let deadline = animation_fallbacks(state)
.iter()
.map(|(_, _, deadline)| *deadline)
.min();
let Some(backend) = state.direct_backend.as_mut() else {
return;
};
if backend.animation_deadline == deadline {
return;
}
if let Some(token) = backend.animation_timer.take() {
state.loop_handle.remove(token);
backend.scheduling_metrics.fallback_cancels += 1;
}
backend.animation_deadline = None;
let Some(deadline) = deadline else {
return;
};
match state
.loop_handle
.insert_source(Timer::from_deadline(deadline), |_, _, state| {
if let Some(backend) = state.direct_backend.as_mut() {
backend.animation_timer = None;
backend.animation_deadline = None;
backend.scheduling_metrics.fallback_wakes += 1;
}
// Revalidate identities, power state, activity and normal frame chains
// at dispatch. Disconnected/replaced outputs never get a stale request.
for (node, crtc, deadline) in animation_fallbacks(state) {
if deadline <= Instant::now() {
state
.direct_backend
.as_mut()
.unwrap()
.scheduling_metrics
.fallback_recoveries += 1;
state
.direct_backend
.as_mut()
.unwrap()
.devices
.get_mut(&node)
.unwrap()
.outputs
.get_mut(&crtc)
.unwrap()
.animation_fallback = AnimationFallback::default();
request_frame(state, node, crtc); request_frame(state, node, crtc);
} }
} }
if state arm_animation_timer(state);
.direct_backend
.as_ref()
.is_some_and(|backend| backend.animation_active)
{
return TimeoutAction::ToInstant(state.animation_fallback_deadline(animation_interval(state)));
}
}
if let Some(backend) = state.direct_backend.as_mut() {
backend.animation_timer = None;
}
TimeoutAction::Drop TimeoutAction::Drop
}) { }) {
Ok(token) => state.direct_backend.as_mut().unwrap().animation_timer = Some(token), Ok(token) => {
let backend = state.direct_backend.as_mut().unwrap();
backend.animation_timer = Some(token);
backend.animation_deadline = Some(deadline);
backend.scheduling_metrics.fallback_arms += 1;
}
Err(error) => tracing::warn!(%error, "could not schedule DRM animation fallback"), Err(error) => tracing::warn!(%error, "could not schedule DRM animation fallback"),
} }
} }
@@ -1656,6 +1783,8 @@ fn create_direct_output(
capture_texture: None, capture_texture: None,
render_metrics, render_metrics,
frame_pending: false, frame_pending: false,
animation_fallback: AnimationFallback::default(),
cursor_visible: false,
power_off: false, power_off: false,
scheduler: crate::frame_scheduler::FrameScheduler::new(refresh_duration_from_mode(mode)), scheduler: crate::frame_scheduler::FrameScheduler::new(refresh_duration_from_mode(mode)),
render_timer: None, render_timer: None,
+398
View File
@@ -0,0 +1,398 @@
//! Redraw/fallback measurements and the orphaned-animation watchdog policy.
use std::collections::HashMap;
use std::panic::Location;
use std::time::{Duration, Instant};
#[derive(Default)]
pub(super) struct AnimationFallback {
orphaned_since: Option<Instant>,
}
impl AnimationFallback {
pub fn waiting_for_frame(&self) -> bool {
self.orphaned_since.is_some()
}
pub fn deadline(&mut self, now: Instant, interval: Duration, active: bool, chained: bool) -> Option<Instant> {
if !active || chained {
self.orphaned_since = None;
return None;
}
Some(*self.orphaned_since.get_or_insert(now) + interval.saturating_mul(2))
}
}
#[derive(Debug, Default)]
struct RedrawCount {
calls: u64,
requested_outputs: u64,
connected_outputs: u64,
}
pub(super) struct SchedulingMetrics {
enabled: bool,
started: Instant,
redraws: HashMap<(&'static str, u32, bool), RedrawCount>,
pub fallback_arms: u64,
pub fallback_cancels: u64,
pub fallback_wakes: u64,
pub fallback_recoveries: u64,
pub chained_animation_observations: u64,
}
impl SchedulingMetrics {
pub fn new() -> Self {
Self {
enabled: std::env::var_os("FERESE_TRACE_PERFORMANCE").is_some(),
started: Instant::now(),
redraws: HashMap::new(),
fallback_arms: 0,
fallback_cancels: 0,
fallback_wakes: 0,
fallback_recoveries: 0,
chained_animation_observations: 0,
}
}
pub fn redraw(&mut self, source: &'static Location<'static>, global: bool, requested: usize, connected: usize) {
if !self.enabled {
return;
}
let count = self.redraws.entry((source.file(), source.line(), global)).or_default();
count.calls += 1;
count.requested_outputs += requested as u64;
count.connected_outputs += connected as u64;
if self.started.elapsed() >= Duration::from_secs(5) {
self.dump();
self.started = Instant::now();
self.redraws.clear();
self.fallback_arms = 0;
self.fallback_cancels = 0;
self.fallback_wakes = 0;
self.fallback_recoveries = 0;
self.chained_animation_observations = 0;
}
}
pub fn dump(&self) {
if !self.enabled {
return;
}
for ((file, line, global), count) in &self.redraws {
tracing::info!(target: "ferese::render", file, line, global,
calls = count.calls, requested_outputs = count.requested_outputs,
connected_outputs = count.connected_outputs, "redraw requests");
}
tracing::info!(target: "ferese::render", fallback_arms = self.fallback_arms,
fallback_cancels = self.fallback_cancels, fallback_wakes = self.fallback_wakes,
fallback_recoveries = self.fallback_recoveries,
chained_animation_observations = self.chained_animation_observations,
"animation fallback scheduling");
}
}
pub(super) fn cursor_on_output(state: &crate::Ferese, output: &smithay::output::Output) -> bool {
use smithay::input::pointer::{CursorImageStatus, CursorImageSurfaceData};
use smithay::utils::{Logical, Rectangle};
use smithay::wayland::compositor::with_states;
if state.input_capture.active() {
return false;
}
let Some(pointer) = state.seat.get_pointer() else {
return false;
};
let Some(bounds) = state.space.output_geometry(output) else {
return false;
};
let location = pointer.current_location();
let rect = match &state.cursor_status {
CursorImageStatus::Surface(surface) => {
let hotspot = with_states(surface, |states| {
states
.data_map
.get::<CursorImageSurfaceData>()
.map(|attributes| attributes.lock().unwrap().hotspot)
.unwrap_or_default()
});
smithay::desktop::utils::bbox_from_surface_tree(surface, (location - hotspot.to_f64()).to_i32_round())
.to_f64()
}
CursorImageStatus::Named(icon) => {
let Some(cursor) = state.named_cursors.get(icon) else {
return false;
};
let scale = output.current_scale().fractional_scale();
// Named buffers use buffer scale 1: Smithay scales their size
// with the output, while the hotspot is subtracted physically.
let size = cursor.size;
Rectangle::<f64, Logical>::new(
(
location.x - f64::from(cursor.hotspot.x) / scale,
location.y - f64::from(cursor.hotspot.y) / scale,
)
.into(),
(f64::from(size.w), f64::from(size.h)).into(),
)
}
CursorImageStatus::Hidden => return false,
};
// One logical pixel covers physical rounding at fractional scales.
let bounds = Rectangle::<f64, Logical>::new(
(f64::from(bounds.loc.x) - 1.0, f64::from(bounds.loc.y) - 1.0).into(),
(f64::from(bounds.size.w) + 2.0, f64::from(bounds.size.h) + 2.0).into(),
);
rect.overlaps(bounds)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mixed_refresh_only_orphaned_animations_need_a_watchdog() {
let now = Instant::now();
let fast = Duration::from_nanos(1_000_000_000 / 240);
let slow = Duration::from_nanos(1_000_000_000 / 60);
let mut idle_fast = AnimationFallback::default();
let mut moving_slow = AnimationFallback::default();
assert_eq!(idle_fast.deadline(now, fast, false, false), None);
assert_eq!(moving_slow.deadline(now, slow, true, false), Some(now + slow * 2));
assert_eq!(
moving_slow.deadline(now + slow, slow, true, false),
Some(now + slow * 2)
);
assert_eq!(moving_slow.deadline(now + slow, slow, true, true), None);
}
#[test]
fn disconnect_and_reconnect_do_not_keep_an_old_deadline() {
let now = Instant::now();
let interval = Duration::from_millis(16);
let mut output = AnimationFallback::default();
output.deadline(now, interval, true, false);
assert_eq!(output.deadline(now + interval, interval, false, false), None);
let reconnected = now + Duration::from_secs(1);
assert_eq!(
output.deadline(reconnected, interval, true, false),
Some(reconnected + interval * 2)
);
}
#[test]
fn measured_legacy_fast_monitor_watchdog_wakes_with_an_intact_slow_chain() {
// The old watchdog wakes at last_animation_tick + 2*fast_interval,
// then advances the shared clock, even with a slow render in flight.
let mut last_tick_us = 0;
let mut old_wakes = 0;
let mut policy = AnimationFallback::default();
let start = Instant::now();
for now_us in 0..1_000_000 {
if now_us % 16_667 == 0 {
last_tick_us = now_us;
}
if now_us >= last_tick_us + 8_334 {
old_wakes += 1;
last_tick_us = now_us;
}
assert_eq!(policy.deadline(start, Duration::from_micros(16_667), true, true), None);
}
assert_eq!(old_wakes, 60);
eprintln!("watchdog replay: legacy={old_wakes} unnecessary wakes/s, intact-chain policy=0");
}
#[test]
fn scheduled_and_in_flight_frames_suppress_fallback_until_the_chain_is_lost() {
use crate::frame_scheduler::FrameScheduler;
let start = Instant::now();
let interval = Duration::from_millis(16);
let mut scheduler = FrameScheduler::new(interval);
let mut fallback = AnimationFallback::default();
scheduler.request(Duration::ZERO);
let scheduled = scheduler.plan(Duration::ZERO).unwrap();
assert_eq!(fallback.deadline(start, interval, true, true), None);
let plan = scheduler.begin().unwrap();
assert_eq!(plan, scheduled);
scheduler.rendered(plan, Duration::from_millis(2), Duration::from_millis(2), true);
assert_eq!(scheduler.plan(interval), None);
assert_eq!(fallback.deadline(start + interval, interval, true, true), None);
scheduler.presented(interval);
assert_eq!(
fallback.deadline(start + interval, interval, true, false),
Some(start + interval * 3)
);
scheduler.request(interval);
assert!(scheduler.plan(interval).is_some());
assert_eq!(fallback.deadline(start + interval, interval, true, true), None);
}
#[test]
fn settled_animation_still_recovers_its_requested_final_frame_after_scheduling_failure() {
use crate::frame_scheduler::FrameScheduler;
let start = Instant::now();
let interval = Duration::from_millis(16);
let mut scheduler = FrameScheduler::new(interval);
let mut fallback = AnimationFallback::default();
scheduler.request(Duration::ZERO);
scheduler.plan(Duration::ZERO).unwrap();
// Timer insertion failed: a scheduler plan without an event-loop
// source is not a live chain. Another output can settle the spring.
assert!(scheduler.has_pending_request());
let deadline = fallback.deadline(start, interval, true, false).unwrap();
let animation_active = false;
let needs_final_frame = animation_active || (fallback.waiting_for_frame() && scheduler.has_pending_request());
assert_eq!(
fallback.deadline(deadline, interval, needs_final_frame, false),
Some(deadline)
);
// A successful retry installs the normal timer and cancels fallback.
assert_eq!(fallback.deadline(deadline, interval, needs_final_frame, true), None);
scheduler.begin().unwrap();
assert!(!scheduler.has_pending_request());
}
#[test]
#[ignore = "requires private sockets; subprocess supplies its own runtime directory"]
fn cursor_footprints_limit_output_local_redraws_and_clear_both_sides_of_a_crossing() {
if std::env::var_os("FERESE_CURSOR_REDRAW_TEST_CHILD").is_none() {
let runtime = tempfile::tempdir().unwrap();
let result = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", "backends::direct::redraw::tests::cursor_footprints_limit_output_local_redraws_and_clear_both_sides_of_a_crossing", "--include-ignored", "--nocapture"])
.env("XDG_RUNTIME_DIR", runtime.path())
.env("FERESE_CURSOR_REDRAW_TEST_CHILD", "1")
.output().unwrap();
assert!(
result.status.success(),
"{}{}",
String::from_utf8_lossy(&result.stdout),
String::from_utf8_lossy(&result.stderr)
);
return;
}
use smithay::input::pointer::{CursorImageStatus, MotionEvent};
use smithay::output::{Mode, Output, PhysicalProperties, Subpixel};
use smithay::utils::SERIAL_COUNTER;
let mut event_loop = smithay::reexports::calloop::EventLoop::try_new().unwrap();
let display = smithay::reexports::wayland_server::Display::new().unwrap();
let config = crate::config::Config::default().runtime_config().unwrap();
let mut state = crate::Ferese::new(&mut event_loop, display, config).unwrap();
let mut outputs = Vec::new();
for (index, refresh) in [60_000, 240_000].into_iter().enumerate() {
let output = Output::new(
format!("cursor-test-{index}"),
PhysicalProperties {
size: (0, 0).into(),
subpixel: Subpixel::Unknown,
make: "test".into(),
model: "test".into(),
},
);
output.change_current_state(
Some(Mode {
size: if index == 0 {
(3840, 2160).into()
} else {
(1920, 1080).into()
},
refresh,
}),
None,
Some(smithay::output::Scale::Fractional(if index == 0 { 2.0 } else { 1.0 })),
Some(((index as i32) * 1920, 0).into()),
);
state.space.map_output(&output, ((index as i32) * 1920, 0));
state.register_output(&output, output.name());
outputs.push(output);
}
let pointer = state.seat.get_pointer().unwrap();
let mut old = [false; 2];
let mut local_requests = 0;
for x in [500.0, 501.0, 502.0] {
pointer.motion(
&mut state,
None,
&MotionEvent {
location: (x, 500.0).into(),
serial: SERIAL_COUNTER.next_serial(),
time: 0,
},
);
let current = [
cursor_on_output(&state, &outputs[0]),
cursor_on_output(&state, &outputs[1]),
];
assert_eq!(current, [true, false]);
local_requests += old
.into_iter()
.zip(current)
.filter(|(before, after)| *before || *after)
.count();
old = current;
}
assert_eq!(local_requests, 3);
eprintln!("cursor replay: legacy=6 output requests, footprint policy={local_requests}");
pointer.motion(
&mut state,
None,
&MotionEvent {
location: (1919.0, 500.0).into(),
serial: SERIAL_COUNTER.next_serial(),
time: 0,
},
);
assert!(cursor_on_output(&state, &outputs[0]));
assert!(cursor_on_output(&state, &outputs[1]), "cursor image straddles the seam");
if let CursorImageStatus::Named(icon) = &state.cursor_status {
let cursor = state.named_cursors.get(icon).unwrap();
let x = 1920.0 - f64::from(cursor.size.w) * 0.75 + f64::from(cursor.hotspot.x) / 2.0;
pointer.motion(
&mut state,
None,
&MotionEvent {
location: (x, 500.0).into(),
serial: SERIAL_COUNTER.next_serial(),
time: 0,
},
);
assert!(
cursor_on_output(&state, &outputs[1]),
"fractional-scale footprint matches rendered cursor size"
);
}
old = [true, true];
state.cursor_status = CursorImageStatus::Hidden;
let current = [
cursor_on_output(&state, &outputs[0]),
cursor_on_output(&state, &outputs[1]),
];
assert_eq!(current, [false, false]);
assert_eq!(
old.into_iter()
.zip(current)
.filter(|(before, after)| *before || *after)
.count(),
2
);
// Grabs and held gestures defer window/output work independently of
// the cursor, which may be on another display or hidden entirely.
state.defer_output_redraw(outputs[0].clone());
state.defer_output_redraw(outputs[1].clone());
state.defer_output_redraw(outputs[1].clone());
assert_eq!(state.output_redraw_pending, outputs);
state.unregister_output(&outputs[1]);
assert_eq!(state.output_redraw_pending, vec![outputs[0].clone()]);
assert!(!cursor_on_output(&state, &outputs[1]));
}
}
+4 -4
View File
@@ -43,6 +43,9 @@ pub(super) fn request_frame(state: &mut Ferese, node: DrmNode, crtc: crtc::Handl
return; return;
} }
output
.render_metrics
.record_request(output.render_timer.is_some(), output.frame_pending);
output.scheduler.request(now); output.scheduler.request(now);
arm_frame(state, node, crtc); arm_frame(state, node, crtc);
} }
@@ -115,10 +118,7 @@ fn dispatch_frame(state: &mut Ferese, node: DrmNode, crtc: crtc::Handle, identit
return; return;
}; };
let animating = state.advance_animations(Instant::now()); state.advance_animations(Instant::now());
if let Some(backend) = state.direct_backend.as_mut() {
backend.animation_active = animating;
}
let output_animating = render_output(state, node, crtc, plan); let output_animating = render_output(state, node, crtc, plan);
+3
View File
@@ -14,6 +14,7 @@ const DEFAULT_CURSOR_SIZE: u32 = 24;
pub(crate) struct NamedCursor { pub(crate) struct NamedCursor {
pub buffer: MemoryRenderBuffer, pub buffer: MemoryRenderBuffer,
pub hotspot: Point<i32, Buffer>, pub hotspot: Point<i32, Buffer>,
pub size: Size<i32, Buffer>,
} }
pub(crate) fn cursor_theme() -> CursorTheme { pub(crate) fn cursor_theme() -> CursorTheme {
@@ -74,6 +75,7 @@ fn cursor_from_image(image: Image) -> NamedCursor {
NamedCursor { NamedCursor {
buffer, buffer,
hotspot: Point::from((image.xhot as i32, image.yhot as i32)), hotspot: Point::from((image.xhot as i32, image.yhot as i32)),
size,
} }
} }
@@ -103,6 +105,7 @@ fn fallback_cursor() -> NamedCursor {
None, None,
), ),
hotspot: Point::default(), hotspot: Point::default(),
size: (WIDTH as i32, HEIGHT as i32).into(),
} }
} }
+7 -7
View File
@@ -145,7 +145,7 @@ impl Dispatch<FereseMaterialManagerV1, ()> for Ferese {
let material = data_init.init(id, SurfaceEffectsUserData::new(surface.clone())); let material = data_init.init(id, SurfaceEffectsUserData::new(surface.clone()));
set_surface_role(&surface, Some(SemanticRole::Modal)); set_surface_role(&surface, Some(SemanticRole::Modal));
material.ready(); material.ready();
crate::backends::direct::render_all(state); crate::backends::direct::render_surface(state, &surface);
} }
ferese_material_manager_v1::Request::Destroy => {} ferese_material_manager_v1::Request::Destroy => {}
_ => unreachable!(), _ => unreachable!(),
@@ -167,7 +167,7 @@ impl Dispatch<FereseSurfaceMaterialV1, SurfaceEffectsUserData> for Ferese {
&& let Some(surface) = data.surface() && let Some(surface) = data.surface()
&& detach(&surface) && detach(&surface)
{ {
crate::backends::direct::render_all(state); crate::backends::direct::render_surface(state, &surface);
} }
} }
@@ -321,7 +321,7 @@ impl Dispatch<FereseSurfaceEffectsV1, SurfaceEffectsUserData> for Ferese {
true true
}); });
if changed { if changed {
crate::backends::direct::render_all(state); crate::backends::direct::render_surface(state, &surface);
} }
} }
ferese_surface_effects_v1::Request::SetRole { role } => { ferese_surface_effects_v1::Request::SetRole { role } => {
@@ -336,7 +336,7 @@ impl Dispatch<FereseSurfaceEffectsV1, SurfaceEffectsUserData> for Ferese {
WEnum::Unknown(_) | WEnum::Value(_) => return, WEnum::Unknown(_) | WEnum::Value(_) => return,
}; };
if set_surface_role(&surface, Some(role)) { if set_surface_role(&surface, Some(role)) {
crate::backends::direct::render_all(state); crate::backends::direct::render_surface(state, &surface);
} }
} }
ferese_surface_effects_v1::Request::SetRegions { regions } => { ferese_surface_effects_v1::Request::SetRegions { regions } => {
@@ -356,17 +356,17 @@ impl Dispatch<FereseSurfaceEffectsV1, SurfaceEffectsUserData> for Ferese {
true true
}); });
if changed { if changed {
crate::backends::direct::render_all(state); crate::backends::direct::render_surface(state, &surface);
} }
} }
ferese_surface_effects_v1::Request::ClearRole => { ferese_surface_effects_v1::Request::ClearRole => {
if set_surface_role(&surface, None) { if set_surface_role(&surface, None) {
crate::backends::direct::render_all(state); crate::backends::direct::render_surface(state, &surface);
} }
} }
ferese_surface_effects_v1::Request::Destroy => { ferese_surface_effects_v1::Request::Destroy => {
if detach(&surface) { if detach(&surface) {
crate::backends::direct::render_all(state); crate::backends::direct::render_surface(state, &surface);
} }
} }
_ => unreachable!(), _ => unreachable!(),
+4
View File
@@ -56,6 +56,10 @@ impl FrameScheduler {
self.requested_at.get_or_insert(now); self.requested_at.get_or_insert(now);
} }
pub fn has_pending_request(&self) -> bool {
self.requested_at.is_some() || self.scheduled.is_some()
}
fn budget(&self, now: Duration) -> Duration { fn budget(&self, now: Duration) -> Duration {
let idle = self let idle = self
.last_presented .last_presented
+3 -3
View File
@@ -99,7 +99,7 @@ impl CompositorHandler for Ferese {
} }
layer_shell::handle_commit(self, surface); layer_shell::handle_commit(self, surface);
xdg_shell::handle_commit(&mut self.popups, &mut self.space, surface); xdg_shell::handle_commit(&mut self.popups, &mut self.space, surface);
crate::backends::direct::render_all(self); crate::backends::direct::render_surface(self, surface);
} }
fn destroyed(&mut self, surface: &WlSurface) { fn destroyed(&mut self, surface: &WlSurface) {
@@ -110,14 +110,14 @@ impl CompositorHandler for Ferese {
self.focus_lock_surface(); self.focus_lock_surface();
} }
self.backdrop_generation = self.backdrop_generation.wrapping_add(1); self.backdrop_generation = self.backdrop_generation.wrapping_add(1);
crate::backends::direct::render_all(self); crate::backends::direct::render_surface(self, surface);
if self.idle_inhibitors.remove(surface).is_some() { if self.idle_inhibitors.remove(surface).is_some() {
self.refresh_idle_inhibition(); self.refresh_idle_inhibition();
} }
if matches!(&self.cursor_status, smithay::input::pointer::CursorImageStatus::Surface(cursor) if cursor == surface) if matches!(&self.cursor_status, smithay::input::pointer::CursorImageStatus::Surface(cursor) if cursor == surface)
{ {
self.cursor_status = smithay::input::pointer::CursorImageStatus::default_named(); self.cursor_status = smithay::input::pointer::CursorImageStatus::default_named();
crate::backends::direct::render_all(self); crate::backends::direct::render_cursor(self);
} }
} }
} }
+6 -5
View File
@@ -10,16 +10,17 @@ impl InputMethodHandler for Ferese {
if let Err(error) = self.popups.track_popup(PopupKind::InputMethod(surface.clone())) { if let Err(error) = self.popups.track_popup(PopupKind::InputMethod(surface.clone())) {
tracing::warn!(?error, "failed to track input method popup"); tracing::warn!(?error, "failed to track input method popup");
} }
crate::backends::direct::render_all(self); crate::backends::direct::render_surface(self, surface.wl_surface());
} }
fn dismiss_popup(&mut self, _surface: PopupSurface) { fn dismiss_popup(&mut self, surface: PopupSurface) {
let outputs = self.surface_outputs(surface.wl_surface());
self.popups.cleanup(); self.popups.cleanup();
crate::backends::direct::render_all(self); crate::backends::direct::render_on(self, &outputs);
} }
fn popup_repositioned(&mut self, _surface: PopupSurface) { fn popup_repositioned(&mut self, surface: PopupSurface) {
crate::backends::direct::render_all(self); crate::backends::direct::render_surface(self, surface.wl_surface());
} }
fn parent_geometry(&self, parent: &WlSurface) -> Rectangle<i32, Logical> { fn parent_geometry(&self, parent: &WlSurface) -> Rectangle<i32, Logical> {
+20 -11
View File
@@ -32,8 +32,7 @@ impl WlrLayerShellHandler for Ferese {
tracing::debug!(%namespace, output = %output.name(), "mapped layer surface"); tracing::debug!(%namespace, output = %output.name(), "mapped layer surface");
super::set_surface_tree_output(layer.wl_surface(), &output); super::set_surface_tree_output(layer.wl_surface(), &output);
self.relayout(); self.relayout_on(&[output]);
crate::backends::direct::render_all(self);
} }
fn new_popup(&mut self, parent: LayerSurface, popup: PopupSurface) { fn new_popup(&mut self, parent: LayerSurface, popup: PopupSurface) {
@@ -59,17 +58,22 @@ impl WlrLayerShellHandler for Ferese {
return; return;
}; };
let restore_focus = layer_has_keyboard_focus(self, &layer); let restore_focus = layer_has_keyboard_focus(self, &layer);
let outputs = self.space.outputs().cloned().collect::<Vec<_>>(); let outputs = self
.space
.outputs()
.filter(|output| layer_map_for_output(output).layer_geometry(&layer).is_some())
.cloned()
.collect::<Vec<_>>();
for output in outputs { for output in &outputs {
layer_map_for_output(&output).unmap_layer(&layer); layer_map_for_output(output).unmap_layer(&layer);
} }
self.relayout(); self.relayout_on(&outputs);
if restore_focus { if restore_focus {
self.restore_keyboard_focus(); self.restore_keyboard_focus();
} }
crate::backends::direct::render_all(self); crate::backends::direct::render_on(self, &outputs);
} }
} }
@@ -77,10 +81,15 @@ pub fn handle_commit(state: &mut Ferese, surface: &WlSurface) {
let Some(layer) = state.space.layer_for_surface(surface, WindowSurfaceType::TOPLEVEL) else { let Some(layer) = state.space.layer_for_surface(surface, WindowSurfaceType::TOPLEVEL) else {
return; return;
}; };
let outputs = state.space.outputs().cloned().collect::<Vec<_>>(); let outputs = state
.space
.outputs()
.filter(|output| layer_map_for_output(output).layer_geometry(&layer).is_some())
.cloned()
.collect::<Vec<_>>();
for output in outputs { for output in &outputs {
let mut map = layer_map_for_output(&output); let mut map = layer_map_for_output(output);
if map.layer_geometry(&layer).is_some() { if map.layer_geometry(&layer).is_some() {
map.arrange(); map.arrange();
layer.layer_surface().send_pending_configure(); layer.layer_surface().send_pending_configure();
@@ -103,7 +112,7 @@ pub fn handle_commit(state: &mut Ferese, surface: &WlSurface) {
state.restore_keyboard_focus(); state.restore_keyboard_focus();
} }
state.relayout(); state.relayout_on(&outputs);
if let Err(error) = state.display_handle.flush_clients() { if let Err(error) = state.display_handle.flush_clients() {
tracing::debug!(%error, "failed to flush layer-surface configure"); tracing::debug!(%error, "failed to flush layer-surface configure");
} }
+32
View File
@@ -132,6 +132,38 @@ impl KeyboardShortcutsInhibitHandler for Ferese {
} }
impl Ferese { impl Ferese {
pub(crate) fn surface_outputs(&self, surface: &WlSurface) -> Vec<smithay::output::Output> {
use smithay::desktop::{WindowSurfaceType, find_popup_root_surface};
use smithay::wayland::compositor::get_parent;
let mut root = surface.clone();
while let Some(parent) = get_parent(&root) {
root = parent;
}
if let Some(popup) = self.popups.find_popup(&root)
&& let Ok(parent) = find_popup_root_surface(&popup)
{
root = parent;
}
let window = self.windows.id_for_surface(&root);
self.space
.outputs()
.filter(|output| {
window.is_some_and(|id| self.window_belongs_to_output(id, output))
|| layer_map_for_output(output)
.layer_for_surface(&root, WindowSurfaceType::ALL)
.is_some()
|| self
.session_lock
.surfaces
.get(output)
.is_some_and(|lock| lock.wl_surface() == &root)
})
.cloned()
.collect()
}
pub(crate) fn update_surface_preferences(&self, surface: &WlSurface) { pub(crate) fn update_surface_preferences(&self, surface: &WlSurface) {
use smithay::desktop::{WindowSurfaceType, find_popup_root_surface}; use smithay::desktop::{WindowSurfaceType, find_popup_root_surface};
use smithay::wayland::compositor::get_parent; use smithay::wayland::compositor::get_parent;
+2 -2
View File
@@ -282,12 +282,12 @@ impl Ferese {
frame: frame.clone(), frame: frame.clone(),
buffer, buffer,
}, },
output, output: output.clone(),
region: data.region, region: data.region,
overlay_cursor: data.overlay_cursor, overlay_cursor: data.overlay_cursor,
with_damage, with_damage,
}); });
crate::backends::direct::render_all(self); crate::backends::direct::render_on(self, &[output]);
} }
pub(crate) fn process_screencopies<R>( pub(crate) fn process_screencopies<R>(
+6 -2
View File
@@ -36,14 +36,18 @@ impl XdgShellHandler for Ferese {
fn title_changed(&mut self, surface: ToplevelSurface) { fn title_changed(&mut self, surface: ToplevelSurface) {
if self.windows.refresh_metadata(surface.wl_surface()) { if self.windows.refresh_metadata(surface.wl_surface()) {
self.send_shell_snapshots(); self.send_shell_snapshots();
self.cursor_redraw_pending = true; for output in self.surface_outputs(surface.wl_surface()) {
self.defer_output_redraw(output);
}
} }
} }
fn app_id_changed(&mut self, surface: ToplevelSurface) { fn app_id_changed(&mut self, surface: ToplevelSurface) {
if self.windows.refresh_metadata(surface.wl_surface()) { if self.windows.refresh_metadata(surface.wl_surface()) {
self.send_shell_snapshots(); self.send_shell_snapshots();
self.cursor_redraw_pending = true; for output in self.surface_outputs(surface.wl_surface()) {
self.defer_output_redraw(output);
}
} }
} }
+35 -7
View File
@@ -440,7 +440,7 @@ impl Ferese {
pointer.frame(self); pointer.frame(self);
self.focus_window_under_pointer(&pointer, position); self.focus_window_under_pointer(&pointer, position);
self.activate_focused_pointer_constraint(&pointer); self.activate_focused_pointer_constraint(&pointer);
crate::backends::direct::render_all(self); crate::backends::direct::render_cursor(self);
} }
InputEvent::PointerMotion { event, .. } => { InputEvent::PointerMotion { event, .. } => {
self.last_pointer_time = event.time_msec(); self.last_pointer_time = event.time_msec();
@@ -484,7 +484,7 @@ impl Ferese {
pointer.frame(self); pointer.frame(self);
self.focus_window_under_pointer(&pointer, location); self.focus_window_under_pointer(&pointer, location);
self.activate_focused_pointer_constraint(&pointer); self.activate_focused_pointer_constraint(&pointer);
crate::backends::direct::render_all(self); crate::backends::direct::render_cursor(self);
} }
InputEvent::PointerButton { event, .. } => { InputEvent::PointerButton { event, .. } => {
self.last_pointer_time = event.time_msec(); self.last_pointer_time = event.time_msec();
@@ -511,11 +511,14 @@ impl Ferese {
&& crate::effects::begin_surface_dismiss(&surface) && crate::effects::begin_surface_dismiss(&surface)
{ {
let opacity = crate::effects::surface_opacity(&surface); let opacity = crate::effects::surface_opacity(&surface);
self.dismissing_popups self.dismissing_popups.push((
.push((root, popup, crate::dimming::DimAnimation::new(f64::from(opacity)))); root.clone(),
popup,
crate::dimming::DimAnimation::new(f64::from(opacity)),
));
pointer.unset_grab(self, serial, event.time_msec()); pointer.unset_grab(self, serial, event.time_msec());
self.focus_window_at(pointer.current_location(), serial, true); self.focus_window_at(pointer.current_location(), serial, true);
crate::backends::direct::render_all(self); crate::backends::direct::render_surface(self, &root);
} }
if self.overview.is_active() { if self.overview.is_active() {
@@ -871,11 +874,36 @@ impl Ferese {
} }
if raise { if raise {
self.relayout(); let outputs = self
.space
.outputs()
.filter(|output| {
self.space
.output_geometry(output)
.is_some_and(|bounds| bounds.to_f64().contains(position))
|| previous
.into_iter()
.chain(self.focused_window)
.any(|id| self.window_belongs_to_output(id, output))
})
.cloned()
.collect::<Vec<_>>();
self.relayout_on(&outputs);
} else { } else {
// Focus, dimming and the bar title update without moving the layout. // Focus, dimming and the bar title update without moving the layout.
self.send_shell_snapshots(); self.send_shell_snapshots();
crate::backends::direct::render_all(self); let outputs = self
.space
.outputs()
.filter(|output| {
previous
.into_iter()
.chain(self.focused_window)
.any(|id| self.window_belongs_to_output(id, output))
})
.cloned()
.collect::<Vec<_>>();
crate::backends::direct::render_on(self, &outputs);
} }
} }
+5 -1
View File
@@ -802,7 +802,11 @@ impl Ferese {
// The nested backend redraws on its refresh timer; this drives the // The nested backend redraws on its refresh timer; this drives the
// direct backend immediately and is a no-op otherwise. // direct backend immediately and is a no-op otherwise.
crate::backends::direct::render_all(self); let outputs = planned
.iter()
.map(|part| targets[part.index].0.clone())
.collect::<Vec<_>>();
crate::backends::direct::render_on(self, &outputs);
} }
fn start_window_screenshot(&mut self, request: Request, response: SyncSender<Response>) { fn start_window_screenshot(&mut self, request: Request, response: SyncSender<Response>) {
+15 -1
View File
@@ -128,8 +128,18 @@ fn main() -> Result<(), Box<dyn Error>> {
} }
state.apply_unlocked_pointer_hint(); state.apply_unlocked_pointer_hint();
let wallpaper_retry = state.wallpaper.take_retry_wakeup(); let wallpaper_retry = state.wallpaper.take_retry_wakeup();
if std::mem::take(&mut state.cursor_redraw_pending) || wallpaper_changed || wallpaper_retry { let cursor_changed = std::mem::take(&mut state.cursor_redraw_pending);
let output_redraws = std::mem::take(&mut state.output_redraw_pending);
if wallpaper_changed || wallpaper_retry {
backends::direct::render_all(state); backends::direct::render_all(state);
} else {
if cursor_changed {
backends::direct::render_cursor(state);
}
if !output_redraws.is_empty() {
backends::direct::render_on(state, &output_redraws);
}
} }
if let Err(error) = state if let Err(error) = state
.wallpaper .wallpaper
@@ -145,6 +155,10 @@ fn main() -> Result<(), Box<dyn Error>> {
} }
}); });
if let Some(backend) = state.direct_backend.as_ref() {
backend.dump_scheduling_metrics();
}
#[cfg(feature = "resize-metrics")] #[cfg(feature = "resize-metrics")]
state.resize_metrics.dump(); state.resize_metrics.dump();
+38
View File
@@ -14,6 +14,9 @@ pub(crate) struct RenderMetrics {
output: String, output: String,
interval_started: Instant, interval_started: Instant,
rendered_frames: u64, rendered_frames: u64,
redraw_requests: u64,
requests_scheduled: u64,
requests_in_flight: u64,
no_damage_frames: u64, no_damage_frames: u64,
no_damage_render_time: Duration, no_damage_render_time: Duration,
damaged_pixels: u64, damaged_pixels: u64,
@@ -37,6 +40,9 @@ impl RenderMetrics {
output: output.into(), output: output.into(),
interval_started: now, interval_started: now,
rendered_frames: 0, rendered_frames: 0,
redraw_requests: 0,
requests_scheduled: 0,
requests_in_flight: 0,
no_damage_frames: 0, no_damage_frames: 0,
no_damage_render_time: Duration::ZERO, no_damage_render_time: Duration::ZERO,
damaged_pixels: 0, damaged_pixels: 0,
@@ -46,6 +52,16 @@ impl RenderMetrics {
} }
} }
pub(crate) fn record_request(&mut self, scheduled: bool, in_flight: bool) {
if !self.enabled {
return;
}
self.redraw_requests += 1;
self.requests_scheduled += u64::from(scheduled);
self.requests_in_flight += u64::from(in_flight);
}
pub(crate) fn record_frame( pub(crate) fn record_frame(
&mut self, &mut self,
render_time: Duration, render_time: Duration,
@@ -100,6 +116,9 @@ impl RenderMetrics {
output = %self.output, output = %self.output,
interval_ms = elapsed.as_millis(), interval_ms = elapsed.as_millis(),
frames = self.rendered_frames, frames = self.rendered_frames,
redraw_requests = self.redraw_requests,
requests_scheduled = self.requests_scheduled,
requests_in_flight = self.requests_in_flight,
no_damage_frames = self.no_damage_frames, no_damage_frames = self.no_damage_frames,
no_damage_render_us = self.no_damage_render_time.as_micros(), no_damage_render_us = self.no_damage_render_time.as_micros(),
damaged_pixels = self.damaged_pixels, damaged_pixels = self.damaged_pixels,
@@ -113,6 +132,9 @@ impl RenderMetrics {
self.interval_started = now; self.interval_started = now;
self.rendered_frames = 0; self.rendered_frames = 0;
self.redraw_requests = 0;
self.requests_scheduled = 0;
self.requests_in_flight = 0;
self.no_damage_frames = 0; self.no_damage_frames = 0;
self.no_damage_render_time = Duration::ZERO; self.no_damage_render_time = Duration::ZERO;
self.damaged_pixels = 0; self.damaged_pixels = 0;
@@ -148,6 +170,22 @@ mod tests {
assert_eq!(metrics.no_damage_render_time, Duration::ZERO); assert_eq!(metrics.no_damage_render_time, Duration::ZERO);
} }
#[test]
fn redraw_requests_distinguish_scheduled_and_in_flight_coalescing() {
let mut metrics = RenderMetrics::new("test", true, Instant::now());
metrics.record_request(false, false);
metrics.record_request(true, false);
metrics.record_request(false, true);
assert_eq!(metrics.redraw_requests, 3);
assert_eq!(metrics.requests_scheduled, 1);
assert_eq!(metrics.requests_in_flight, 1);
assert_eq!(metrics.rendered_frames, 0);
let mut disabled = RenderMetrics::new("test", false, Instant::now());
disabled.record_request(true, true);
assert_eq!(disabled.redraw_requests, 0);
}
#[test] #[test]
fn no_damage_attempts_are_separate_from_presented_frame_statistics() { fn no_damage_attempts_are_separate_from_presented_frame_statistics() {
let mut metrics = RenderMetrics::new("test", true, Instant::now()); let mut metrics = RenderMetrics::new("test", true, Instant::now());
+30 -3
View File
@@ -310,6 +310,10 @@ impl OverviewState {
.or_else(|| windows.iter().copied().filter(visible).min_by_key(|id| id.0)); .or_else(|| windows.iter().copied().filter(visible).min_by_key(|id| id.0));
} }
pub(crate) fn selection_state(&self) -> (Option<WindowId>, Option<WorkspaceId>) {
(self.selected, self.selection_workspace)
}
pub(crate) fn selected(&self) -> Option<WindowId> { pub(crate) fn selected(&self) -> Option<WindowId> {
self.selected self.selected
} }
@@ -455,12 +459,34 @@ impl Ferese {
.and_then(|w| self.windows.ids().get(&w).copied()); .and_then(|w| self.windows.ids().get(&w).copied());
if selected.is_some() && self.overview.selected != selected { if selected.is_some() && self.overview.selected != selected {
let previous = self.overview.selection_state();
self.overview.selection_workspace = None; self.overview.selection_workspace = None;
self.overview.selected = selected; self.overview.selected = selected;
crate::backends::direct::render_all(self); self.redraw_overview_selection(previous);
} }
} }
pub(crate) fn redraw_overview_selection(&mut self, previous: (Option<WindowId>, Option<WorkspaceId>)) {
let outputs = self
.space
.outputs()
.filter(|output| {
[previous.0, self.overview.selected]
.into_iter()
.flatten()
.any(|id| self.window_belongs_to_output(id, output))
|| [previous.1, self.overview.selection_workspace]
.into_iter()
.flatten()
.any(|workspace| {
self.output_workspaces.output_for_workspace(workspace) == self.output_id(output)
})
})
.cloned()
.collect::<Vec<_>>();
crate::backends::direct::render_on(self, &outputs);
}
pub(crate) fn retain_overview_labels(&mut self) { pub(crate) fn retain_overview_labels(&mut self) {
self.overview self.overview
.window_labels .window_labels
@@ -554,8 +580,9 @@ impl Ferese {
return false; return false;
} }
let previous = self.overview.selection_state();
if self.overview.select_direction(direction) { if self.overview.select_direction(direction) {
crate::backends::direct::render_all(self); self.redraw_overview_selection(previous);
} }
true true
@@ -797,7 +824,7 @@ impl Ferese {
self.overview self.overview
.strip_offsets .strip_offsets
.insert(output_id, next.min(ordered.len().saturating_sub(capacity))); .insert(output_id, next.min(ordered.len().saturating_sub(capacity)));
crate::backends::direct::render_all(self); crate::backends::direct::render_on(self, &[output]);
true true
} }
+2
View File
@@ -375,6 +375,7 @@ pub struct Ferese {
// Cursor callbacks may run with Smithay's pointer mutex held. Rendering // Cursor callbacks may run with Smithay's pointer mutex held. Rendering
// reads the pointer position, so defer it until event dispatch returns. // reads the pointer position, so defer it until event dispatch returns.
pub(crate) cursor_redraw_pending: bool, pub(crate) cursor_redraw_pending: bool,
pub(crate) output_redraw_pending: Vec<Output>,
pub(crate) locked_pointer_hint: Option<LockedPointerHint>, pub(crate) locked_pointer_hint: Option<LockedPointerHint>,
pub(crate) last_pointer_time: u32, pub(crate) last_pointer_time: u32,
pub(crate) cursor_theme: xcursor::CursorTheme, pub(crate) cursor_theme: xcursor::CursorTheme,
@@ -602,6 +603,7 @@ impl Ferese {
autostart: config.autostart, autostart: config.autostart,
cursor_status: CursorImageStatus::default_named(), cursor_status: CursorImageStatus::default_named(),
cursor_redraw_pending: false, cursor_redraw_pending: false,
output_redraw_pending: Vec::new(),
locked_pointer_hint: None, locked_pointer_hint: None,
last_pointer_time: 0, last_pointer_time: 0,
cursor_theme, cursor_theme,
-4
View File
@@ -72,10 +72,6 @@ impl Ferese {
} }
} }
pub(crate) fn animation_fallback_deadline(&self, interval: std::time::Duration) -> Instant {
self.last_animation_tick + interval.saturating_mul(2)
}
pub(crate) fn reset_animation_clock(&mut self) { pub(crate) fn reset_animation_clock(&mut self) {
self.last_animation_tick = Instant::now(); self.last_animation_tick = Instant::now();
#[cfg(feature = "resize-metrics")] #[cfg(feature = "resize-metrics")]
+14 -4
View File
@@ -45,7 +45,7 @@ impl Ferese {
return; return;
} }
self.relayout(); self.relayout_window(window);
} }
pub fn toggle_focused_fullscreen(&mut self) { pub fn toggle_focused_fullscreen(&mut self) {
@@ -58,7 +58,7 @@ impl Ferese {
return; return;
} }
self.relayout(); self.relayout_window(window);
} }
pub fn toggle_focused_maximized(&mut self) { pub fn toggle_focused_maximized(&mut self) {
@@ -106,7 +106,7 @@ impl Ferese {
} }
} }
} }
self.relayout(); self.relayout_window(window);
} }
pub(crate) fn output_has_fullscreen(&self, output: &Output) -> bool { pub(crate) fn output_has_fullscreen(&self, output: &Output) -> bool {
@@ -124,7 +124,7 @@ impl Ferese {
pub fn set_window_fullscreen(&mut self, window: WindowId, enabled: bool) { pub fn set_window_fullscreen(&mut self, window: WindowId, enabled: bool) {
match self.workspaces.set_fullscreen(window, enabled) { match self.workspaces.set_fullscreen(window, enabled) {
Ok(true) => self.relayout(), Ok(true) => self.relayout_window(window),
Ok(false) => {} Ok(false) => {}
Err(error) => { Err(error) => {
tracing::error!(%error, ?window, enabled, "failed to set fullscreen window") tracing::error!(%error, ?window, enabled, "failed to set fullscreen window")
@@ -195,6 +195,16 @@ impl Ferese {
// Move/resize/cancel callbacks run with Smithay's pointer mutex held. // Move/resize/cancel callbacks run with Smithay's pointer mutex held.
// Cursor rendering reads current_location(), which would lock it again. // Cursor rendering reads current_location(), which would lock it again.
// The event-loop epilogue coalesces this redraw after the grab returns. // The event-loop epilogue coalesces this redraw after the grab returns.
let outputs = self
.space
.outputs()
.filter(|output| self.window_belongs_to_output(id, output))
.cloned()
.collect::<Vec<_>>();
for output in outputs {
self.defer_output_redraw(output);
}
self.cursor_redraw_pending = true; self.cursor_redraw_pending = true;
} }
+2 -1
View File
@@ -73,8 +73,9 @@ impl Ferese {
.as_mut() .as_mut()
.and_then(|cycle| cycle.advance(reverse, &available)) .and_then(|cycle| cycle.advance(reverse, &available))
{ {
let previous = self.overview.selection_state();
self.overview.select_window(id); self.overview.select_window(id);
crate::backends::direct::render_all(self); self.redraw_overview_selection(previous);
} else { } else {
self.cancel_focus_cycle(); self.cancel_focus_cycle();
} }
+33
View File
@@ -50,6 +50,35 @@ impl Ferese {
} }
pub fn relayout(&mut self) { pub fn relayout(&mut self) {
self.relayout_outputs(None);
}
pub(crate) fn relayout_window(&mut self, id: WindowId) {
let outputs = self
.space
.outputs()
.filter(|output| self.window_belongs_to_output(id, output))
.cloned()
.collect::<Vec<_>>();
self.relayout_on(&outputs);
}
pub(crate) fn relayout_on(&mut self, outputs: &[Output]) {
self.relayout_outputs(Some(outputs.to_vec()));
}
fn relayout_outputs(&mut self, mut redraw_outputs: Option<Vec<Output>>) {
if let Some(outputs) = redraw_outputs.as_mut() {
outputs.extend(
self.space
.outputs()
.filter(|output| {
self.output_has_animations(output) || self.output_has_pending_visual_changes(output)
})
.cloned(),
);
}
self.refresh_input_capture_zones(); self.refresh_input_capture_zones();
let visible_workspaces = self.visible_workspace_ids(); let visible_workspaces = self.visible_workspace_ids();
self.reconcile_workspaces(&visible_workspaces); self.reconcile_workspaces(&visible_workspaces);
@@ -384,8 +413,12 @@ impl Ferese {
self.retarget_overview(); self.retarget_overview();
self.send_shell_snapshots(); self.send_shell_snapshots();
if let Some(outputs) = redraw_outputs {
crate::backends::direct::render_on(self, &outputs);
} else {
crate::backends::direct::render_all(self); crate::backends::direct::render_all(self);
} }
}
pub(crate) fn raise_window(&mut self, window: &Window, activate: bool) { pub(crate) fn raise_window(&mut self, window: &Window, activate: bool) {
if let Some(id) = self.windows.ids().get(window) { if let Some(id) = self.windows.ids().get(window) {
+12 -2
View File
@@ -72,7 +72,15 @@ impl Ferese {
swipe.progress = progress; swipe.progress = progress;
} }
self.backdrop_generation = self.backdrop_generation.wrapping_add(1); self.backdrop_generation = self.backdrop_generation.wrapping_add(1);
self.cursor_redraw_pending = true; if let Some(output) = self
.focus_swipe
.as_ref()
.and_then(|swipe| self.output_workspaces.output_for_workspace(swipe.workspace))
.and_then(|id| self.outputs_by_id.get(&id))
.cloned()
{
self.defer_output_redraw(output);
}
} }
pub(crate) fn finish_focus_swipe(&mut self, direction: Option<SwipeDirection>) -> bool { pub(crate) fn finish_focus_swipe(&mut self, direction: Option<SwipeDirection>) -> bool {
@@ -433,7 +441,9 @@ impl Ferese {
.held_progress = Some(progress); .held_progress = Some(progress);
self.refresh_workspace_slide_offsets(); self.refresh_workspace_slide_offsets();
self.backdrop_generation = self.backdrop_generation.wrapping_add(1); self.backdrop_generation = self.backdrop_generation.wrapping_add(1);
self.cursor_redraw_pending = true; if let Some(output) = self.outputs_by_id.get(&output).cloned() {
self.defer_output_redraw(output);
}
return; return;
} }
if self.swipe.preview_started() { if self.swipe.preview_started() {
+7
View File
@@ -1,6 +1,12 @@
use super::*; use super::*;
impl Ferese { impl Ferese {
pub(crate) fn defer_output_redraw(&mut self, output: Output) {
if !self.output_redraw_pending.contains(&output) {
self.output_redraw_pending.push(output);
}
}
pub fn register_output(&mut self, output: &Output, identity: String) { pub fn register_output(&mut self, output: &Output, identity: String) {
if self.output_ids.is_empty() { if self.output_ids.is_empty() {
self.reset_animation_clock(); self.reset_animation_clock();
@@ -51,6 +57,7 @@ impl Ferese {
return; return;
}; };
self.output_redraw_pending.retain(|pending| pending != output);
self.outputs_by_id.remove(&output_id); self.outputs_by_id.remove(&output_id);
self.output_names.remove(&output_id); self.output_names.remove(&output_id);
self.workspace_slides.remove(&output_id); self.workspace_slides.remove(&output_id);
+49 -1
View File
@@ -44,8 +44,36 @@ impl Ferese {
}) })
} }
pub(crate) fn output_has_pending_visual_changes(&self, output: &Output) -> bool {
let selected = if self.overview.is_active() {
self.overview.selected()
} else {
self.focused_window
};
self.output_window_records(output, false).any(|(id, record, _)| {
self.window_belongs_to_output(id, output)
&& (record
.focus
.as_ref()
.is_some_and(|focus| focus.needs_update(if selected == Some(id) { 1.0 } else { 0.0 }))
|| record.dimming.as_ref().is_some_and(|dim| {
dim.needs_update(crate::dimming::target(
self.inactive_dim,
self.focused_window,
id,
self.overview.is_presenting(),
))
}))
})
}
pub(crate) fn output_has_animations(&self, output: &Output) -> bool { pub(crate) fn output_has_animations(&self, output: &Output) -> bool {
if self.overview.is_animating(self.spring_config) || !self.dismissing_popups.is_empty() { if self.overview.is_animating(self.spring_config)
|| self
.dismissing_popups
.iter()
.any(|(root, _, _)| self.surface_outputs(root).contains(output))
{
return true; return true;
} }
@@ -421,6 +449,26 @@ mod tests {
state.animations_enabled = true; state.animations_enabled = true;
assert!(!state.sample_frame(&output, Duration::ZERO).animating); assert!(!state.sample_frame(&output, Duration::ZERO).animating);
assert!(state.sample_frame(&other, Duration::ZERO).animating); assert!(state.sample_frame(&other, Duration::ZERO).animating);
// Target changes with animations disabled still need their final frame
// on both displays, even though no spring/fade remains active afterward.
state.focused_window = Some(id);
state
.windows
.update(id, |record| record.focus = Some(crate::dimming::DimAnimation::new(1.0)));
state.focused_window = Some(other_id);
assert!(state.output_has_pending_visual_changes(&output));
state.animations_enabled = false;
state.advance_animations(Instant::now());
assert!(!state.output_has_pending_visual_changes(&output));
state.animations_enabled = true;
state.windows.set_geometry(id, geometry);
assert!(state.output_has_animations(&output));
state.unregister_output(&other);
assert!(!state.output_has_animations(&other));
assert!(
state.output_has_animations(&output),
"remaining output keeps its active animation"
);
} }
#[test] #[test]
+2 -2
View File
@@ -660,7 +660,7 @@ impl Ferese {
self.windows.update(focused, |record| { self.windows.update(focused, |record| {
record.closing.get_or_insert_default(); record.closing.get_or_insert_default();
}); });
crate::backends::direct::render_all(self); crate::backends::direct::render_window(self, focused);
} }
pub(crate) fn close_managed_window(&mut self, id: WindowId) -> bool { pub(crate) fn close_managed_window(&mut self, id: WindowId) -> bool {
@@ -672,7 +672,7 @@ impl Ferese {
self.windows.update(id, |record| { self.windows.update(id, |record| {
record.closing.get_or_insert_default(); record.closing.get_or_insert_default();
}); });
crate::backends::direct::render_all(self); crate::backends::direct::render_window(self, id);
} else { } else {
self.send_window_close(id); self.send_window_close(id);
} }
+28
View File
@@ -42,6 +42,34 @@ asynchronous GPU work is covered conservatively by feedback-driven margins.
Check sustained animation, idle wakeup, callback-only clients, and mixed-refresh Check sustained animation, idle wakeup, callback-only clients, and mixed-refresh
outputs separately. Nested sessions do not exercise the DRM scheduler. outputs separately. Nested sessions do not exercise the DRM scheduler.
## Redraw and animation fallback counters
With the same environment variables, direct sessions report `redraw requests`
by source file and line, with call counts, requested outputs and connected outputs.
`render performance` adds per-output redraw requests and counts requests made
while a render timer was scheduled or a frame was in flight. Compare these with
`frames`, `no_damage_frames`, render time and missed deadlines; requests can
coalesce and are not themselves rendered frames.
`animation fallback scheduling` reports watchdog arms, cancellations, wakeups,
recovery requests and observations of animations with an intact normal frame
chain. Summaries are emitted during redraw activity every five seconds and on
normal exit. The fallback counters reset with each summary. An orphaned animation
gets a watchdog deadline two refresh intervals after its own output loses its
frame chain. Recovery also retains a requested final frame if another output
settles that animation while scheduling is unavailable. Idle, powered-off and
disconnected outputs do not set that deadline.
An already scheduled render or pending page flip suppresses the watchdog; this
policy does not replace DRM error recovery or diagnose a permanently lost flip.
The regression replays use a 60 Hz animation beside an idle 240 Hz output.
The old fallback rule produces 60 unnecessary wakes in one simulated second;
the new rule produces zero while the normal chain remains intact. Three cursor
moves within one output require three output requests rather than the old six.
These are deterministic scheduling counts, not live DRM CPU, latency or battery
measurements. Verify those separately with the same hardware workload before
making claims about power savings.
## Historical results ## Historical results
On 2026-09-27 at `08a5866`, two nested runs used a 1422 × 1696 output at 2× scale, On 2026-09-27 at `08a5866`, two nested runs used a 1422 × 1696 output at 2× scale,