atagen

monolith

public
Star0
Watch0

Commit 8e6f96f0a32d

tarball

atagen <boss@atagen.co>2026-10-16 22:17 UTC

4 files changed+102-9
monolith: drive adaptive sync for fullscreen windows

With VRR on, frames go out as soon as there is something new instead of waiting for a deadline, since the panel follows whatever pace the client sets. Pointer motion alone is capped at 60 Hz so moving the mouse over a game does not drag the refresh rate up. Only connectors reporting VrrSupport::Supported are touched, which avoids a modeset on every fullscreen toggle.
Modified file: crates/config/default.toml+4 additions
@@ -19,6 +19,10 @@ border = 2
1919 focused = "#88bfd1"
2020 unfocused = "#3b4252"
2121
22+[output]
23+# Variable refresh rate on outputs that support it, off, on, or fullscreen.
24+vrr = "fullscreen"
25+
2226 [animation]
2327 enabled = true
2428 # orthogonal moves each window along one axis per phase, direct allows both.
Modified file: crates/config/src/lib.rs+20 additions
@@ -12,6 +12,7 @@ pub const DEFAULT: &str = include_str!("../default.toml");
1212 #[derive(Debug, Clone)]
1313 pub struct Config {
1414 pub general: General,
15+ pub output: Outputs,
1516 pub decoration: Decoration,
1617 pub animation: Animation,
1718 pub keyboard: Keyboard,
@@ -68,6 +69,22 @@ impl Default for Gaps {
6869 }
6970 }
7071
72+#[derive(Deserialize, Debug, Clone, PartialEq, Default)]
73+#[serde(default, deny_unknown_fields)]
74+pub struct Outputs {
75+ pub vrr: Vrr,
76+}
77+
78+#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
79+#[serde(rename_all = "lowercase")]
80+pub enum Vrr {
81+ Off,
82+ On,
83+ /// Only while a fullscreen window is shown, where a game sets the pace.
84+ #[default]
85+ Fullscreen,
86+}
87+
7188 #[derive(Deserialize, Debug, Clone, PartialEq)]
7289 #[serde(default, deny_unknown_fields)]
7390 pub struct Decoration {
@@ -256,6 +273,8 @@ struct Raw {
256273 #[serde(default)]
257274 decoration: Decoration,
258275 #[serde(default)]
276+ output: Outputs,
277+ #[serde(default)]
259278 binds: BTreeMap<String, Words>,
260279 #[serde(default)]
261280 rules: Vec<RawRule>,
@@ -335,6 +354,7 @@ impl Config {
335354 general: raw.general,
336355 animation: raw.animation,
337356 decoration: raw.decoration,
357+ output: raw.output,
338358 keyboard: raw.keyboard,
339359 binds,
340360 rules,
Modified file: crates/monolith/src/drm.rs+75 additions−9 deletions
@@ -4,9 +4,11 @@
44
55 use crate::scene::Draw;
66 use crate::state::{State, now_ns};
7+use monolith_config::Vrr;
78 use monolith_layout::{OutputId, Rect};
89 use smithay::backend::allocator::Fourcc;
910 use smithay::backend::allocator::gbm::{GbmAllocator, GbmBufferFlags, GbmDevice};
11+use smithay::backend::drm::VrrSupport;
1012 use smithay::backend::drm::compositor::FrameFlags;
1113 use smithay::backend::drm::exporter::gbm::GbmFramebufferExporter;
1214 use smithay::backend::drm::output::{DrmOutput, DrmOutputManager, DrmOutputRenderElements};
@@ -37,6 +39,9 @@ use std::path::{Path, PathBuf};
3739 use std::time::Duration;
3840
3941 const CLEAR: [f32; 4] = [0.07, 0.075, 0.09, 1.0];
42+/// Frames that only move the cursor stay at or below 60 Hz under VRR, so the
43+/// mouse does not override the refresh rate a game is pacing.
44+const CURSOR_FRAME_NS: u64 = 16_666_667;
4045
4146 type Allocator = GbmAllocator<DrmDeviceFd>;
4247 type Exporter = GbmFramebufferExporter<DrmDeviceFd>;
@@ -72,6 +77,10 @@ struct Surface {
7277 /// Frames presented since `stats_since`, for the once-a-second debug line.
7378 presented: u32,
7479 stats_since: u64,
80+ vrr_capable: bool,
81+ vrr_on: bool,
82+ /// Only the cursor moved, which under VRR is drawn at a capped rate.
83+ cursor_redraw: bool,
7584 }
7685
7786 impl Surface {
@@ -378,11 +387,22 @@ fn connect(state: &mut State, connector: connector::Info, crtc: crtc::Handle) {
378387 return;
379388 }
380389 };
390+ let vrr_capable = matches!(
391+ out.with_compositor(|c| c.vrr_supported(connector.handle())),
392+ Ok(VrrSupport::Supported)
393+ );
381394 space.map_output(&output, position);
382395 let (w, h) = mode.size();
383396 let area = Rect::new(x, 0.0, f64::from(w), f64::from(h));
384397 let id = layout.add_output(&name, area);
385- tracing::info!(name, w, h, refresh = mode.vrefresh(), "output connected");
398+ tracing::info!(
399+ name,
400+ w,
401+ h,
402+ refresh = mode.vrefresh(),
403+ vrr_capable,
404+ "output connected"
405+ );
386406 drm.surfaces.insert(
387407 crtc,
388408 Surface {
@@ -397,6 +417,9 @@ fn connect(state: &mut State, connector: connector::Info, crtc: crtc::Handle) {
397417 budget: Budget::new(),
398418 presented: 0,
399419 stats_since: 0,
420+ vrr_capable,
421+ vrr_on: false,
422+ cursor_redraw: false,
400423 refresh_ns: 1_000_000_000_000 / u64::from(wl_mode.refresh.max(1) as u32),
401424 },
402425 );
@@ -464,7 +487,7 @@ fn on_vblank(state: &mut State, crtc: crtc::Handle, meta: &mut Option<DrmEventMe
464487 surface.stats_since = t;
465488 }
466489 }
467- if surface.redraw {
490+ if surface.redraw || surface.cursor_redraw {
468491 schedule(state, crtc);
469492 }
470493 }
@@ -482,6 +505,25 @@ fn schedule(state: &mut State, crtc: crtc::Handle) {
482505 return;
483506 }
484507 let now = now_ns();
508+ if surface.vrr_on {
509+ let since_flip = surface
510+ .last_vblank_ns
511+ .map_or(u64::MAX, |l| now.saturating_sub(l));
512+ if surface.redraw || since_flip >= CURSOR_FRAME_NS {
513+ draw(state, crtc, now);
514+ return;
515+ }
516+ let wait = Timer::from_duration(Duration::from_nanos(CURSOR_FRAME_NS - since_flip));
517+ let token = drm.handle.insert_source(wait, move |_, _, state| {
518+ if let Some(s) = state.drm.as_mut().and_then(|d| d.surfaces.get_mut(&crtc)) {
519+ s.scheduled = None;
520+ }
521+ draw(state, crtc, now_ns());
522+ TimeoutAction::Drop
523+ });
524+ surface.scheduled = token.ok();
525+ return;
526+ }
485527 let Some((vblank, start)) = surface.next_deadline(now) else {
486528 draw(state, crtc, now);
487529 return;
@@ -513,15 +555,16 @@ pub fn render(state: &mut State) {
513555 let Some(drm) = state.drm.as_mut() else {
514556 return;
515557 };
516- if std::mem::take(&mut state.needs_redraw) {
517- for s in drm.surfaces.values_mut() {
518- s.redraw = true;
519- }
558+ let cursor = std::mem::take(&mut state.cursor_moved);
559+ let all = std::mem::take(&mut state.needs_redraw);
560+ for s in drm.surfaces.values_mut() {
561+ s.redraw |= all;
562+ s.cursor_redraw |= cursor;
520563 }
521564 let ready: Vec<crtc::Handle> = drm
522565 .surfaces
523566 .iter()
524- .filter(|(_, s)| s.redraw && !s.queued)
567+ .filter(|(_, s)| (s.redraw || s.cursor_redraw) && !s.queued)
525568 .map(|(c, _)| *c)
526569 .collect();
527570 for crtc in ready {
@@ -560,6 +603,29 @@ fn render_surface(state: &mut State, crtc: crtc::Handle, target: u64) -> bool {
560603 if surface.queued {
561604 return false;
562605 }
606+ surface.cursor_redraw = false;
607+ let want_vrr = surface.vrr_capable
608+ && match config.output.vrr {
609+ Vrr::Off => false,
610+ Vrr::On => true,
611+ Vrr::Fullscreen => layout.arrange(surface.id).fullscreen.is_some(),
612+ };
613+ if want_vrr != surface.vrr_on {
614+ match surface.drm.with_compositor(|c| c.use_vrr(want_vrr)) {
615+ Ok(()) => {
616+ surface.vrr_on = want_vrr;
617+ tracing::info!(
618+ output = surface.output.name(),
619+ vrr = want_vrr,
620+ "adaptive sync"
621+ );
622+ }
623+ Err(e) => {
624+ tracing::warn!("could not switch adaptive sync: {e}");
625+ surface.vrr_capable = false;
626+ }
627+ }
628+ }
563629 let began = std::time::Instant::now();
564630 let now = target;
565631 let Some(area) = layout.output(surface.id).map(|o| o.area) else {
@@ -650,7 +716,7 @@ fn on_input(state: &mut State, event: InputEvent<LibinputInputBackend>) {
650716 let pos = clamp(state, state.pointer_pos + event.delta());
651717 state.pointer_to(pos, event.time());
652718 state.pointer_frame();
653- state.needs_redraw = true;
719+ state.cursor_moved = true;
654720 }
655721 InputEvent::PointerMotionAbsolute { event } => {
656722 let Some(area) = state.layout.outputs().next().map(|(_, o)| o.area) else {
@@ -660,7 +726,7 @@ fn on_input(state: &mut State, event: InputEvent<LibinputInputBackend>) {
660726 let pos = Point::from((area.x + p.x, area.y + p.y));
661727 state.pointer_to(pos, event.time());
662728 state.pointer_frame();
663- state.needs_redraw = true;
729+ state.cursor_moved = true;
664730 }
665731 InputEvent::PointerButton { event } => {
666732 let time = event.time();
Modified file: crates/monolith/src/state.rs+3 additions
@@ -50,6 +50,8 @@ pub struct State {
5050 pub cursor: crate::cursor::Cursor,
5151 pub cursor_status: CursorImageStatus,
5252 pub needs_redraw: bool,
53+ /// The pointer moved, which on its own only redraws the cursor.
54+ pub cursor_moved: bool,
5355
5456 pub layout: Layout,
5557 pub windows: HashMap<WindowId, Window>,
@@ -150,6 +152,7 @@ impl State {
150152 cursor: crate::cursor::Cursor::load(),
151153 cursor_status: CursorImageStatus::default_named(),
152154 needs_redraw: true,
155+ cursor_moved: false,
153156 layout,
154157 windows: HashMap::new(),
155158 unmapped: Vec::new(),