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,