From 6f2951c6b0b57b23758ba68f127bd145ef8c152d Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Fri, 2 Oct 2026 01:21:11 +0100 Subject: [PATCH] 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. --- compositor/src/backends/direct.rs | 285 +++++++++---- compositor/src/backends/direct/redraw.rs | 398 +++++++++++++++++++ compositor/src/backends/direct/scheduling.rs | 8 +- compositor/src/cursor.rs | 3 + compositor/src/effects.rs | 14 +- compositor/src/frame_scheduler.rs | 4 + compositor/src/handlers/compositor.rs | 6 +- compositor/src/handlers/input_method.rs | 11 +- compositor/src/handlers/layer_shell.rs | 31 +- compositor/src/handlers/mod.rs | 32 ++ compositor/src/handlers/screencopy.rs | 4 +- compositor/src/handlers/xdg_shell.rs | 8 +- compositor/src/input.rs | 42 +- compositor/src/ipc.rs | 6 +- compositor/src/main.rs | 16 +- compositor/src/metrics.rs | 38 ++ compositor/src/overview.rs | 33 +- compositor/src/state.rs | 2 + compositor/src/state/animation.rs | 4 - compositor/src/state/floating.rs | 18 +- compositor/src/state/focus.rs | 3 +- compositor/src/state/layout.rs | 35 +- compositor/src/state/navigation.rs | 14 +- compositor/src/state/outputs.rs | 7 + compositor/src/state/prediction.rs | 50 ++- compositor/src/state/windows.rs | 4 +- docs/performance-baseline.md | 28 ++ 27 files changed, 965 insertions(+), 139 deletions(-) create mode 100644 compositor/src/backends/direct/redraw.rs diff --git a/compositor/src/backends/direct.rs b/compositor/src/backends/direct.rs index 509765a..29d2b20 100644 --- a/compositor/src/backends/direct.rs +++ b/compositor/src/backends/direct.rs @@ -7,7 +7,9 @@ use std::time::{Duration, Instant}; mod capture; mod planes; +mod redraw; mod scheduling; +use redraw::{AnimationFallback, SchedulingMetrics}; use scheduling::*; use smithay::backend::allocator::Fourcc; use smithay::backend::allocator::gbm::{GbmAllocator, GbmBufferFlags, GbmDevice}; @@ -54,10 +56,15 @@ pub struct DirectBackendState { presentation: HashMap<(DrmNode, crtc::Handle), PresentationClock>, pub(crate) connected_outputs: Vec, animation_timer: Option, - animation_active: bool, + animation_deadline: Option, + scheduling_metrics: SchedulingMetrics, } impl DirectBackendState { + pub(crate) fn dump_scheduling_metrics(&self) { + self.scheduling_metrics.dump(); + } + pub(crate) fn capture_resize_snapshot( &mut self, window: &smithay::desktop::Window, @@ -179,6 +186,8 @@ struct DirectOutput { capture_texture: Option, render_metrics: RenderMetrics, frame_pending: bool, + animation_fallback: AnimationFallback, + cursor_visible: bool, scheduler: crate::frame_scheduler::FrameScheduler, render_timer: Option, power_off: bool, @@ -226,7 +235,8 @@ pub fn init(event_loop: &mut EventLoop, state: &mut Ferese) -> Result<() presentation: HashMap::new(), connected_outputs: Vec::new(), animation_timer: None, - animation_active: false, + animation_deadline: None, + scheduling_metrics: SchedulingMetrics::new(), }); open_primary_device(event_loop, state, &primary_path)?; @@ -600,6 +610,8 @@ fn open_primary_device( } else { 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); } +#[track_caller] 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 { + 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::>(); + 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) { 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 .direct_backend .as_ref() - .map(|backend| { - backend - .devices + .unwrap() + .devices + .iter() + .flat_map(|(node, device)| { + device + .outputs .iter() - .flat_map(|(node, device)| device.outputs.keys().map(|crtc| (*node, *crtc))) - .collect::>() + .map(|(crtc, output)| (*node, *crtc, output.output.clone())) }) - .unwrap_or_default(); - - for (node, crtc) in outputs { - request_frame(state, node, crtc); + .collect::>(); + // Include animations before advancing, so their final settled frame is + // drawn too. Advancing shared state must not leave another output stale. + 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::>(); + 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) { @@ -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); 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(); } + arm_animation_timer(state); tracing::info!(?node, "removed DRM device"); } @@ -1220,82 +1314,115 @@ fn refresh_duration_from_mode(mode: DrmMode) -> Duration { Duration::from_nanos(1_000_000_000_000 / OutputMode::from(mode).refresh.max(1) as u64) } -fn animation_interval(state: &Ferese) -> Duration { - state +fn animation_fallbacks(state: &mut Ferese) -> Vec<(DrmNode, crtc::Handle, Instant)> { + let now = Instant::now(); + let outputs = state .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)) + .flat_map(|backend| backend.devices.iter()) + .flat_map(|(node, device)| { + device + .outputs + .iter() + .map(move |(crtc, output)| (*node, *crtc, output.output.clone(), device.drm.is_active())) + }) + .collect::>(); + let mut fallbacks = Vec::new(); + 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 needed = state - .direct_backend - .as_ref() - .is_some_and(|backend| backend.animation_active && backend.animation_timer.is_none()); - if !needed { + 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; } - let deadline = state.animation_fallback_deadline(animation_interval(state)); + 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| { - 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 - .direct_backend - .as_ref() - .into_iter() - .flat_map(|backend| backend.devices.iter()) - .flat_map(|(node, device)| { - device - .outputs - .iter() - .map(|(crtc, output)| (*node, *crtc, output.output.clone())) - }) - .collect::>(); - for (node, crtc, output) in outputs { - if state.output_has_animations(&output) { - request_frame(state, node, crtc); - } - } - - if 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; + 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); + } + } + + arm_animation_timer(state); 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"), } } @@ -1656,6 +1783,8 @@ fn create_direct_output( capture_texture: None, render_metrics, frame_pending: false, + animation_fallback: AnimationFallback::default(), + cursor_visible: false, power_off: false, scheduler: crate::frame_scheduler::FrameScheduler::new(refresh_duration_from_mode(mode)), render_timer: None, diff --git a/compositor/src/backends/direct/redraw.rs b/compositor/src/backends/direct/redraw.rs new file mode 100644 index 0000000..89bcc44 --- /dev/null +++ b/compositor/src/backends/direct/redraw.rs @@ -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, +} + +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 { + 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::() + .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::::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::::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])); + } +} diff --git a/compositor/src/backends/direct/scheduling.rs b/compositor/src/backends/direct/scheduling.rs index 64621fb..bec4582 100644 --- a/compositor/src/backends/direct/scheduling.rs +++ b/compositor/src/backends/direct/scheduling.rs @@ -43,6 +43,9 @@ pub(super) fn request_frame(state: &mut Ferese, node: DrmNode, crtc: crtc::Handl return; } + output + .render_metrics + .record_request(output.render_timer.is_some(), output.frame_pending); output.scheduler.request(now); arm_frame(state, node, crtc); } @@ -115,10 +118,7 @@ fn dispatch_frame(state: &mut Ferese, node: DrmNode, crtc: crtc::Handle, identit return; }; - let animating = state.advance_animations(Instant::now()); - if let Some(backend) = state.direct_backend.as_mut() { - backend.animation_active = animating; - } + state.advance_animations(Instant::now()); let output_animating = render_output(state, node, crtc, plan); diff --git a/compositor/src/cursor.rs b/compositor/src/cursor.rs index d85278d..fe7b804 100644 --- a/compositor/src/cursor.rs +++ b/compositor/src/cursor.rs @@ -14,6 +14,7 @@ const DEFAULT_CURSOR_SIZE: u32 = 24; pub(crate) struct NamedCursor { pub buffer: MemoryRenderBuffer, pub hotspot: Point, + pub size: Size, } pub(crate) fn cursor_theme() -> CursorTheme { @@ -74,6 +75,7 @@ fn cursor_from_image(image: Image) -> NamedCursor { NamedCursor { buffer, hotspot: Point::from((image.xhot as i32, image.yhot as i32)), + size, } } @@ -103,6 +105,7 @@ fn fallback_cursor() -> NamedCursor { None, ), hotspot: Point::default(), + size: (WIDTH as i32, HEIGHT as i32).into(), } } diff --git a/compositor/src/effects.rs b/compositor/src/effects.rs index 3d8104b..487e943 100644 --- a/compositor/src/effects.rs +++ b/compositor/src/effects.rs @@ -145,7 +145,7 @@ impl Dispatch for Ferese { let material = data_init.init(id, SurfaceEffectsUserData::new(surface.clone())); set_surface_role(&surface, Some(SemanticRole::Modal)); material.ready(); - crate::backends::direct::render_all(state); + crate::backends::direct::render_surface(state, &surface); } ferese_material_manager_v1::Request::Destroy => {} _ => unreachable!(), @@ -167,7 +167,7 @@ impl Dispatch for Ferese { && let Some(surface) = data.surface() && detach(&surface) { - crate::backends::direct::render_all(state); + crate::backends::direct::render_surface(state, &surface); } } @@ -321,7 +321,7 @@ impl Dispatch for Ferese { true }); if changed { - crate::backends::direct::render_all(state); + crate::backends::direct::render_surface(state, &surface); } } ferese_surface_effects_v1::Request::SetRole { role } => { @@ -336,7 +336,7 @@ impl Dispatch for Ferese { WEnum::Unknown(_) | WEnum::Value(_) => return, }; 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 } => { @@ -356,17 +356,17 @@ impl Dispatch for Ferese { true }); if changed { - crate::backends::direct::render_all(state); + crate::backends::direct::render_surface(state, &surface); } } ferese_surface_effects_v1::Request::ClearRole => { 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 => { if detach(&surface) { - crate::backends::direct::render_all(state); + crate::backends::direct::render_surface(state, &surface); } } _ => unreachable!(), diff --git a/compositor/src/frame_scheduler.rs b/compositor/src/frame_scheduler.rs index b4c124c..3c71389 100644 --- a/compositor/src/frame_scheduler.rs +++ b/compositor/src/frame_scheduler.rs @@ -56,6 +56,10 @@ impl FrameScheduler { 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 { let idle = self .last_presented diff --git a/compositor/src/handlers/compositor.rs b/compositor/src/handlers/compositor.rs index 9918efc..fe89373 100644 --- a/compositor/src/handlers/compositor.rs +++ b/compositor/src/handlers/compositor.rs @@ -99,7 +99,7 @@ impl CompositorHandler for Ferese { } layer_shell::handle_commit(self, 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) { @@ -110,14 +110,14 @@ impl CompositorHandler for Ferese { self.focus_lock_surface(); } 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() { self.refresh_idle_inhibition(); } if matches!(&self.cursor_status, smithay::input::pointer::CursorImageStatus::Surface(cursor) if cursor == surface) { self.cursor_status = smithay::input::pointer::CursorImageStatus::default_named(); - crate::backends::direct::render_all(self); + crate::backends::direct::render_cursor(self); } } } diff --git a/compositor/src/handlers/input_method.rs b/compositor/src/handlers/input_method.rs index 90f22ab..c324245 100644 --- a/compositor/src/handlers/input_method.rs +++ b/compositor/src/handlers/input_method.rs @@ -10,16 +10,17 @@ impl InputMethodHandler for Ferese { if let Err(error) = self.popups.track_popup(PopupKind::InputMethod(surface.clone())) { 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(); - crate::backends::direct::render_all(self); + crate::backends::direct::render_on(self, &outputs); } - fn popup_repositioned(&mut self, _surface: PopupSurface) { - crate::backends::direct::render_all(self); + fn popup_repositioned(&mut self, surface: PopupSurface) { + crate::backends::direct::render_surface(self, surface.wl_surface()); } fn parent_geometry(&self, parent: &WlSurface) -> Rectangle { diff --git a/compositor/src/handlers/layer_shell.rs b/compositor/src/handlers/layer_shell.rs index e7f7da1..5863c03 100644 --- a/compositor/src/handlers/layer_shell.rs +++ b/compositor/src/handlers/layer_shell.rs @@ -32,8 +32,7 @@ impl WlrLayerShellHandler for Ferese { tracing::debug!(%namespace, output = %output.name(), "mapped layer surface"); super::set_surface_tree_output(layer.wl_surface(), &output); - self.relayout(); - crate::backends::direct::render_all(self); + self.relayout_on(&[output]); } fn new_popup(&mut self, parent: LayerSurface, popup: PopupSurface) { @@ -59,17 +58,22 @@ impl WlrLayerShellHandler for Ferese { return; }; let restore_focus = layer_has_keyboard_focus(self, &layer); - let outputs = self.space.outputs().cloned().collect::>(); + let outputs = self + .space + .outputs() + .filter(|output| layer_map_for_output(output).layer_geometry(&layer).is_some()) + .cloned() + .collect::>(); - for output in outputs { - layer_map_for_output(&output).unmap_layer(&layer); + for output in &outputs { + layer_map_for_output(output).unmap_layer(&layer); } - self.relayout(); + self.relayout_on(&outputs); if restore_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 { return; }; - let outputs = state.space.outputs().cloned().collect::>(); + let outputs = state + .space + .outputs() + .filter(|output| layer_map_for_output(output).layer_geometry(&layer).is_some()) + .cloned() + .collect::>(); - for output in outputs { - let mut map = layer_map_for_output(&output); + for output in &outputs { + let mut map = layer_map_for_output(output); if map.layer_geometry(&layer).is_some() { map.arrange(); layer.layer_surface().send_pending_configure(); @@ -103,7 +112,7 @@ pub fn handle_commit(state: &mut Ferese, surface: &WlSurface) { state.restore_keyboard_focus(); } - state.relayout(); + state.relayout_on(&outputs); if let Err(error) = state.display_handle.flush_clients() { tracing::debug!(%error, "failed to flush layer-surface configure"); } diff --git a/compositor/src/handlers/mod.rs b/compositor/src/handlers/mod.rs index d36a1c2..b803407 100644 --- a/compositor/src/handlers/mod.rs +++ b/compositor/src/handlers/mod.rs @@ -132,6 +132,38 @@ impl KeyboardShortcutsInhibitHandler for Ferese { } impl Ferese { + pub(crate) fn surface_outputs(&self, surface: &WlSurface) -> Vec { + 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) { use smithay::desktop::{WindowSurfaceType, find_popup_root_surface}; use smithay::wayland::compositor::get_parent; diff --git a/compositor/src/handlers/screencopy.rs b/compositor/src/handlers/screencopy.rs index 1c45bbd..15cedf7 100644 --- a/compositor/src/handlers/screencopy.rs +++ b/compositor/src/handlers/screencopy.rs @@ -282,12 +282,12 @@ impl Ferese { frame: frame.clone(), buffer, }, - output, + output: output.clone(), region: data.region, overlay_cursor: data.overlay_cursor, with_damage, }); - crate::backends::direct::render_all(self); + crate::backends::direct::render_on(self, &[output]); } pub(crate) fn process_screencopies( diff --git a/compositor/src/handlers/xdg_shell.rs b/compositor/src/handlers/xdg_shell.rs index a8f27cc..87f7883 100644 --- a/compositor/src/handlers/xdg_shell.rs +++ b/compositor/src/handlers/xdg_shell.rs @@ -36,14 +36,18 @@ impl XdgShellHandler for Ferese { fn title_changed(&mut self, surface: ToplevelSurface) { if self.windows.refresh_metadata(surface.wl_surface()) { 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) { if self.windows.refresh_metadata(surface.wl_surface()) { self.send_shell_snapshots(); - self.cursor_redraw_pending = true; + for output in self.surface_outputs(surface.wl_surface()) { + self.defer_output_redraw(output); + } } } diff --git a/compositor/src/input.rs b/compositor/src/input.rs index bbab27c..eb47633 100644 --- a/compositor/src/input.rs +++ b/compositor/src/input.rs @@ -440,7 +440,7 @@ impl Ferese { pointer.frame(self); self.focus_window_under_pointer(&pointer, position); self.activate_focused_pointer_constraint(&pointer); - crate::backends::direct::render_all(self); + crate::backends::direct::render_cursor(self); } InputEvent::PointerMotion { event, .. } => { self.last_pointer_time = event.time_msec(); @@ -484,7 +484,7 @@ impl Ferese { pointer.frame(self); self.focus_window_under_pointer(&pointer, location); self.activate_focused_pointer_constraint(&pointer); - crate::backends::direct::render_all(self); + crate::backends::direct::render_cursor(self); } InputEvent::PointerButton { event, .. } => { self.last_pointer_time = event.time_msec(); @@ -511,11 +511,14 @@ impl Ferese { && crate::effects::begin_surface_dismiss(&surface) { let opacity = crate::effects::surface_opacity(&surface); - self.dismissing_popups - .push((root, popup, crate::dimming::DimAnimation::new(f64::from(opacity)))); + self.dismissing_popups.push(( + root.clone(), + popup, + crate::dimming::DimAnimation::new(f64::from(opacity)), + )); pointer.unset_grab(self, serial, event.time_msec()); 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() { @@ -871,11 +874,36 @@ impl Ferese { } 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::>(); + self.relayout_on(&outputs); } else { // Focus, dimming and the bar title update without moving the layout. 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::>(); + crate::backends::direct::render_on(self, &outputs); } } diff --git a/compositor/src/ipc.rs b/compositor/src/ipc.rs index cf18865..e48efd3 100644 --- a/compositor/src/ipc.rs +++ b/compositor/src/ipc.rs @@ -802,7 +802,11 @@ impl Ferese { // The nested backend redraws on its refresh timer; this drives the // 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::>(); + crate::backends::direct::render_on(self, &outputs); } fn start_window_screenshot(&mut self, request: Request, response: SyncSender) { diff --git a/compositor/src/main.rs b/compositor/src/main.rs index 4c9b196..6c62927 100644 --- a/compositor/src/main.rs +++ b/compositor/src/main.rs @@ -128,8 +128,18 @@ fn main() -> Result<(), Box> { } state.apply_unlocked_pointer_hint(); 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); + } 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 .wallpaper @@ -145,6 +155,10 @@ fn main() -> Result<(), Box> { } }); + if let Some(backend) = state.direct_backend.as_ref() { + backend.dump_scheduling_metrics(); + } + #[cfg(feature = "resize-metrics")] state.resize_metrics.dump(); diff --git a/compositor/src/metrics.rs b/compositor/src/metrics.rs index 2169090..7a19da6 100644 --- a/compositor/src/metrics.rs +++ b/compositor/src/metrics.rs @@ -14,6 +14,9 @@ pub(crate) struct RenderMetrics { output: String, interval_started: Instant, rendered_frames: u64, + redraw_requests: u64, + requests_scheduled: u64, + requests_in_flight: u64, no_damage_frames: u64, no_damage_render_time: Duration, damaged_pixels: u64, @@ -37,6 +40,9 @@ impl RenderMetrics { output: output.into(), interval_started: now, rendered_frames: 0, + redraw_requests: 0, + requests_scheduled: 0, + requests_in_flight: 0, no_damage_frames: 0, no_damage_render_time: Duration::ZERO, 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( &mut self, render_time: Duration, @@ -100,6 +116,9 @@ impl RenderMetrics { output = %self.output, interval_ms = elapsed.as_millis(), 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_render_us = self.no_damage_render_time.as_micros(), damaged_pixels = self.damaged_pixels, @@ -113,6 +132,9 @@ impl RenderMetrics { self.interval_started = now; 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_render_time = Duration::ZERO; self.damaged_pixels = 0; @@ -148,6 +170,22 @@ mod tests { 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] fn no_damage_attempts_are_separate_from_presented_frame_statistics() { let mut metrics = RenderMetrics::new("test", true, Instant::now()); diff --git a/compositor/src/overview.rs b/compositor/src/overview.rs index c6e1308..251aa21 100644 --- a/compositor/src/overview.rs +++ b/compositor/src/overview.rs @@ -310,6 +310,10 @@ impl OverviewState { .or_else(|| windows.iter().copied().filter(visible).min_by_key(|id| id.0)); } + pub(crate) fn selection_state(&self) -> (Option, Option) { + (self.selected, self.selection_workspace) + } + pub(crate) fn selected(&self) -> Option { self.selected } @@ -455,12 +459,34 @@ impl Ferese { .and_then(|w| self.windows.ids().get(&w).copied()); if selected.is_some() && self.overview.selected != selected { + let previous = self.overview.selection_state(); self.overview.selection_workspace = None; 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, Option)) { + 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::>(); + crate::backends::direct::render_on(self, &outputs); + } + pub(crate) fn retain_overview_labels(&mut self) { self.overview .window_labels @@ -554,8 +580,9 @@ impl Ferese { return false; } + let previous = self.overview.selection_state(); if self.overview.select_direction(direction) { - crate::backends::direct::render_all(self); + self.redraw_overview_selection(previous); } true @@ -797,7 +824,7 @@ impl Ferese { self.overview .strip_offsets .insert(output_id, next.min(ordered.len().saturating_sub(capacity))); - crate::backends::direct::render_all(self); + crate::backends::direct::render_on(self, &[output]); true } diff --git a/compositor/src/state.rs b/compositor/src/state.rs index e1c5f27..bb9af4e 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -375,6 +375,7 @@ pub struct Ferese { // Cursor callbacks may run with Smithay's pointer mutex held. Rendering // reads the pointer position, so defer it until event dispatch returns. pub(crate) cursor_redraw_pending: bool, + pub(crate) output_redraw_pending: Vec, pub(crate) locked_pointer_hint: Option, pub(crate) last_pointer_time: u32, pub(crate) cursor_theme: xcursor::CursorTheme, @@ -602,6 +603,7 @@ impl Ferese { autostart: config.autostart, cursor_status: CursorImageStatus::default_named(), cursor_redraw_pending: false, + output_redraw_pending: Vec::new(), locked_pointer_hint: None, last_pointer_time: 0, cursor_theme, diff --git a/compositor/src/state/animation.rs b/compositor/src/state/animation.rs index e747faf..8ec62f3 100644 --- a/compositor/src/state/animation.rs +++ b/compositor/src/state/animation.rs @@ -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) { self.last_animation_tick = Instant::now(); #[cfg(feature = "resize-metrics")] diff --git a/compositor/src/state/floating.rs b/compositor/src/state/floating.rs index 786a4f4..fa49eec 100644 --- a/compositor/src/state/floating.rs +++ b/compositor/src/state/floating.rs @@ -45,7 +45,7 @@ impl Ferese { return; } - self.relayout(); + self.relayout_window(window); } pub fn toggle_focused_fullscreen(&mut self) { @@ -58,7 +58,7 @@ impl Ferese { return; } - self.relayout(); + self.relayout_window(window); } 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 { @@ -124,7 +124,7 @@ impl Ferese { pub fn set_window_fullscreen(&mut self, window: WindowId, enabled: bool) { match self.workspaces.set_fullscreen(window, enabled) { - Ok(true) => self.relayout(), + Ok(true) => self.relayout_window(window), Ok(false) => {} Err(error) => { 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. // Cursor rendering reads current_location(), which would lock it again. // 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::>(); + for output in outputs { + self.defer_output_redraw(output); + } + self.cursor_redraw_pending = true; } diff --git a/compositor/src/state/focus.rs b/compositor/src/state/focus.rs index 3afc4f7..9d600dd 100644 --- a/compositor/src/state/focus.rs +++ b/compositor/src/state/focus.rs @@ -73,8 +73,9 @@ impl Ferese { .as_mut() .and_then(|cycle| cycle.advance(reverse, &available)) { + let previous = self.overview.selection_state(); self.overview.select_window(id); - crate::backends::direct::render_all(self); + self.redraw_overview_selection(previous); } else { self.cancel_focus_cycle(); } diff --git a/compositor/src/state/layout.rs b/compositor/src/state/layout.rs index 61c9822..bb95a64 100644 --- a/compositor/src/state/layout.rs +++ b/compositor/src/state/layout.rs @@ -50,6 +50,35 @@ impl Ferese { } 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::>(); + 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>) { + 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(); let visible_workspaces = self.visible_workspace_ids(); self.reconcile_workspaces(&visible_workspaces); @@ -384,7 +413,11 @@ impl Ferese { self.retarget_overview(); self.send_shell_snapshots(); - crate::backends::direct::render_all(self); + if let Some(outputs) = redraw_outputs { + crate::backends::direct::render_on(self, &outputs); + } else { + crate::backends::direct::render_all(self); + } } pub(crate) fn raise_window(&mut self, window: &Window, activate: bool) { diff --git a/compositor/src/state/navigation.rs b/compositor/src/state/navigation.rs index 07b4b2f..79055b4 100644 --- a/compositor/src/state/navigation.rs +++ b/compositor/src/state/navigation.rs @@ -72,7 +72,15 @@ impl Ferese { swipe.progress = progress; } 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) -> bool { @@ -433,7 +441,9 @@ impl Ferese { .held_progress = Some(progress); self.refresh_workspace_slide_offsets(); 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; } if self.swipe.preview_started() { diff --git a/compositor/src/state/outputs.rs b/compositor/src/state/outputs.rs index 954cf90..f9f0052 100644 --- a/compositor/src/state/outputs.rs +++ b/compositor/src/state/outputs.rs @@ -1,6 +1,12 @@ use super::*; 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) { if self.output_ids.is_empty() { self.reset_animation_clock(); @@ -51,6 +57,7 @@ impl Ferese { return; }; + self.output_redraw_pending.retain(|pending| pending != output); self.outputs_by_id.remove(&output_id); self.output_names.remove(&output_id); self.workspace_slides.remove(&output_id); diff --git a/compositor/src/state/prediction.rs b/compositor/src/state/prediction.rs index ee4ebfe..d1ac716 100644 --- a/compositor/src/state/prediction.rs +++ b/compositor/src/state/prediction.rs @@ -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 { - 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; } @@ -421,6 +449,26 @@ mod tests { state.animations_enabled = true; assert!(!state.sample_frame(&output, 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] diff --git a/compositor/src/state/windows.rs b/compositor/src/state/windows.rs index df6c0e3..7585b3a 100644 --- a/compositor/src/state/windows.rs +++ b/compositor/src/state/windows.rs @@ -660,7 +660,7 @@ impl Ferese { self.windows.update(focused, |record| { 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 { @@ -672,7 +672,7 @@ impl Ferese { self.windows.update(id, |record| { record.closing.get_or_insert_default(); }); - crate::backends::direct::render_all(self); + crate::backends::direct::render_window(self, id); } else { self.send_window_close(id); } diff --git a/docs/performance-baseline.md b/docs/performance-baseline.md index 95f36eb..26ca461 100644 --- a/docs/performance-baseline.md +++ b/docs/performance-baseline.md @@ -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 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 On 2026-09-27 at `08a5866`, two nested runs used a 1422 × 1696 output at 2× scale,