From 9ad5d2e6aaf5f83debd33f310c3b3a4faa0bbb0c Mon Sep 17 00:00:00 2001 From: Matthew Schwartz Date: Sun, 16 Aug 2026 12:11:41 -0700 Subject: [PATCH 1/2] steamcompmgr: pace cursor repaints with content when adaptive sync is on With adaptive sync active, cursor motion forces a flip on the next wakeup, so moving the mouse over a game drives the panel to its maximum refresh rate between the game's own frames. The refresh rate then swings between the game rate and the maximum, which flickers visibly on some VRR panels. The cursor auto-hide does the same a fixed timeout after the last motion. Cursor damage waits for the next content frame and only flips on its own once no content has been painted for 1/adaptive_sync_cursor_min_fps seconds. The 30 fps default matches KWin's threshold for deferring repaints outside the active window. KWin checks the window's frame time estimate instead. Here, slower content holds a cursor update back by at most 1/30 s. --- src/steamcompmgr.cpp | 24 +++++++++++++++++++++--- src/wlserver.cpp | 5 +++-- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/src/steamcompmgr.cpp b/src/steamcompmgr.cpp index 18be19785..c5080f9b7 100644 --- a/src/steamcompmgr.cpp +++ b/src/steamcompmgr.cpp @@ -301,6 +301,7 @@ gamescope::ConVar cv_adaptive_sync( "adaptive_sync", false, "Whether or no gamescope::ConVar cv_adaptive_sync_ignore_overlay( "adaptive_sync_ignore_overlay", false, "Whether or not to ignore overlay planes for pushing commits with adaptive sync." ); gamescope::ConVar cv_adaptive_sync_overlay_cycles( "adaptive_sync_overlay_cycles", 1, "Number of vblank cycles to ignore overlay repaints before forcing a commit with adaptive sync." ); gamescope::ConVar cv_adaptive_sync_uncapped( "adaptive_sync_uncapped", true, "Whether or not to allow mailbox/immediate clients to run uncapped with adaptive sync by deferring paints until the display can take a new flip." ); +gamescope::ConVar cv_adaptive_sync_cursor_min_fps( "adaptive_sync_cursor_min_fps", 30, "Content frame rate at or above which cursor repaints coalesce with content frames when adaptive sync is on. 0 = repaint the cursor immediately." ); gamescope::ConVar cv_upscale_preemptive( "upscale_preemptive", true, "Allow pre-emptive upscaling" ); gamescope::ConVar cv_upscale_preemptive_debug_force_sync( "upscale_preemptive_debug_force_sync", false, "Force synchronize pre-emptive upscaling" ); @@ -1115,6 +1116,7 @@ uint32_t lastPublishedInputCounter; std::atomic hasRepaint = false; bool hasRepaintNonBasePlane = false; +std::atomic hasCursorRepaint = false; bool g_bUpdateForwardedVROverlays = false; @@ -2058,7 +2060,7 @@ void MouseCursor::checkSuspension() // We're hiding the cursor, force redraw if we were showing it if (window && !m_imageEmpty ) { - hasRepaintNonBasePlane = true; + hasCursorRepaint = true; nudge_steamcompmgr(); } } @@ -10735,6 +10737,7 @@ steamcompmgr_main(int argc, char **argv) bool bPainted = false; static int nIgnoredOverlayRepaints = 0; + static uint64_t s_ulLastContentPaintTime = 0; if ( !hasRepaintNonBasePlane ) nIgnoredOverlayRepaints = 0; @@ -10835,13 +10838,13 @@ steamcompmgr_main(int argc, char **argv) { case FlipType::Normal: { - bShouldPaint = vblank && ( hasRepaint || hasRepaintNonBasePlane || bForceSyncFlip ); + bShouldPaint = vblank && ( hasRepaint || hasRepaintNonBasePlane || hasCursorRepaint || bForceSyncFlip ); break; } case FlipType::Async: { - bShouldPaint = hasRepaint; + bShouldPaint = hasRepaint || hasCursorRepaint; if ( vblank && !bShouldPaint && hasRepaintNonBasePlane ) nIgnoredOverlayRepaints++; @@ -10853,6 +10856,17 @@ steamcompmgr_main(int argc, char **argv) { bShouldPaint = hasRepaint && bVRRCanFlip; + // Content at or above adaptive_sync_cursor_min_fps carries the cursor with its own frames. + if ( !bShouldPaint && hasCursorRepaint && bVRRCanFlip ) + { + const int nCursorMinFPS = cv_adaptive_sync_cursor_min_fps; + if ( nCursorMinFPS <= 0 || + get_time_in_nanos() - s_ulLastContentPaintTime >= 1'000'000'000ul / uint64_t( nCursorMinFPS ) ) + { + bShouldPaint = true; + } + } + if ( bIsVBlankFromTimer ) { if ( hasRepaintNonBasePlane ) @@ -10909,8 +10923,12 @@ steamcompmgr_main(int argc, char **argv) { GetBackend()->OnEndFrame(); + if ( hasRepaint ) + s_ulLastContentPaintTime = get_time_in_nanos(); + hasRepaint = false; hasRepaintNonBasePlane = false; + hasCursorRepaint = false; nIgnoredOverlayRepaints = 0; { diff --git a/src/wlserver.cpp b/src/wlserver.cpp index 1d7684a2c..5852d7326 100644 --- a/src/wlserver.cpp +++ b/src/wlserver.cpp @@ -118,6 +118,7 @@ struct wlr_surface *wlserver_surface_to_main_surface( struct wlr_surface *pSurfa bool wlserver_process_hotkeys( wlr_keyboard *keyboard, uint32_t key, bool press ); extern std::atomic hasRepaint; +extern std::atomic hasCursorRepaint; std::vector& gamescope_xwayland_server_t::retrieve_commits() { @@ -2818,7 +2819,7 @@ void wlserver_oncursorevent() if ( !wlserver.bCursorHidden && wlserver.bCursorHasImage ) { - hasRepaint = true; + hasCursorRepaint = true; } } @@ -2972,7 +2973,7 @@ void wlserver_mousehide() if ( wlserver.bCursorHidden != true ) { wlserver.bCursorHidden = true; - hasRepaint = true; + hasCursorRepaint = true; } } From 1222d4635bd40401b6226a75c414a5f271da943d Mon Sep 17 00:00:00 2001 From: Matthew Schwartz Date: Thu, 24 Sep 2026 11:36:04 -0700 Subject: [PATCH 2/2] steamcompmgr: hold the refresh rate while the Steam UI is focused The Steam UI only presents when something changes, so with adaptive sync active the panel stretches toward its minimum refresh rate whenever the screen goes still and snaps back to the maximum on the next update. Every swing shifts the panel's effective gamma, which shows as whole-screen brightness flicker on OLED and VA panels. Turning VRR off for the UI instead would take the full modeset path. While the Steam UI is focused, present on every vblank timer wakeup. Holding a 48-120 Hz desktop panel at 120 Hz costs about 1 W of SoC power at idle over 48 Hz, and adaptive_sync_idle_hold turns the hold off. On DRM, an unchanged frame scans out the last composite again rather than paying for an identical full-screen composite every vblank. Nested sessions on a VRR host hold as well, committing on every host refresh. --- src/Backends/DRMBackend.cpp | 25 ++++++++++++++++--------- src/rendervulkan.cpp | 4 ++++ src/rendervulkan.hpp | 2 ++ src/steamcompmgr.cpp | 20 ++++++++++++++++++-- 4 files changed, 40 insertions(+), 11 deletions(-) diff --git a/src/Backends/DRMBackend.cpp b/src/Backends/DRMBackend.cpp index baa82f167..83bc0daef 100644 --- a/src/Backends/DRMBackend.cpp +++ b/src/Backends/DRMBackend.cpp @@ -4020,19 +4020,26 @@ namespace gamescope if ( bDefer && !!( g_uCompositeDebug & CompositeDebugFlag::Markers ) ) g_uCompositeDebug |= CompositeDebugFlag::Markers_Partial; - std::optional oCompositeResult = vulkan_composite( &compositeFrameInfo, nullptr, !bNeedsFullComposite ); + // Only present composites advance the output ring, so the last one is still on screen. + const bool bReuseLastComposite = pFrameInfo->bRepeatFrame && bNeedsFullComposite && + m_bWasCompositing && !m_bWasPartialCompositing && vulkan_get_last_output_image( false, false ) != nullptr; - m_bWasCompositing = true; + if ( !bReuseLastComposite ) + { + std::optional oCompositeResult = vulkan_composite( &compositeFrameInfo, nullptr, !bNeedsFullComposite ); - g_uCompositeDebug &= ~CompositeDebugFlag::Markers_Partial; + m_bWasCompositing = true; - if ( !oCompositeResult ) - { - xwm_log.errorf("vulkan_composite failed"); - return -EINVAL; - } + g_uCompositeDebug &= ~CompositeDebugFlag::Markers_Partial; - vulkan_wait( *oCompositeResult, true ); + if ( !oCompositeResult ) + { + xwm_log.errorf("vulkan_composite failed"); + return -EINVAL; + } + + vulkan_wait( *oCompositeResult, true ); + } FrameInfo_t presentCompFrameInfo = {}; presentCompFrameInfo.allowVRR = pFrameInfo->allowVRR; diff --git a/src/rendervulkan.cpp b/src/rendervulkan.cpp index b601320b3..db731d565 100644 --- a/src/rendervulkan.cpp +++ b/src/rendervulkan.cpp @@ -4681,6 +4681,10 @@ bool vulkan_format_supports_features(VkFormat format, VkFormatFeatureFlags featu gamescope::Rc vulkan_get_last_output_image( bool partial, bool defer ) { + // A remake drops the deferred images until the next composite makes them. + if ( g_output.outputImages.empty() ) + return nullptr; + // Get previous image ( +2 ) // 1 2 3 // | diff --git a/src/rendervulkan.hpp b/src/rendervulkan.hpp index 6d5e7dcc1..9bec6e5f0 100644 --- a/src/rendervulkan.hpp +++ b/src/rendervulkan.hpp @@ -301,6 +301,8 @@ struct FrameInfo_t gamescope::Rc lut3D[EOTF_Count]; bool allowVRR; + // Content identical to the previous present, eg. an adaptive sync idle hold. + bool bRepeatFrame; bool applyOutputColorMgmt; // drm only EOTF outputEncodingEOTF; diff --git a/src/steamcompmgr.cpp b/src/steamcompmgr.cpp index c5080f9b7..9d82f9ab0 100644 --- a/src/steamcompmgr.cpp +++ b/src/steamcompmgr.cpp @@ -302,6 +302,7 @@ gamescope::ConVar cv_adaptive_sync_ignore_overlay( "adaptive_sync_ignore_o gamescope::ConVar cv_adaptive_sync_overlay_cycles( "adaptive_sync_overlay_cycles", 1, "Number of vblank cycles to ignore overlay repaints before forcing a commit with adaptive sync." ); gamescope::ConVar cv_adaptive_sync_uncapped( "adaptive_sync_uncapped", true, "Whether or not to allow mailbox/immediate clients to run uncapped with adaptive sync by deferring paints until the display can take a new flip." ); gamescope::ConVar cv_adaptive_sync_cursor_min_fps( "adaptive_sync_cursor_min_fps", 30, "Content frame rate at or above which cursor repaints coalesce with content frames when adaptive sync is on. 0 = repaint the cursor immediately." ); +gamescope::ConVar cv_adaptive_sync_idle_hold( "adaptive_sync_idle_hold", true, "Hold the display at its refresh rate while the Steam UI is focused with adaptive sync." ); gamescope::ConVar cv_upscale_preemptive( "upscale_preemptive", true, "Allow pre-emptive upscaling" ); gamescope::ConVar cv_upscale_preemptive_debug_force_sync( "upscale_preemptive_debug_force_sync", false, "Force synchronize pre-emptive upscaling" ); @@ -3098,7 +3099,7 @@ gamescope::ConVar cv_paint_cursor_plane{ "paint_cursor_plane", true }; gamescope::ConVar cv_paint_mura_plane{ "paint_mura_plane", true }; static void -paint_all( global_focus_t *pFocus, bool async ) +paint_all( global_focus_t *pFocus, bool async, bool bRepeatFrame ) { if ( !pFocus ) return; @@ -3112,8 +3113,13 @@ paint_all( global_focus_t *pFocus, bool async ) static long long int paintID = 0; + const uint32_t uColorMgmtSerial = g_ColorMgmt.serial; update_color_mgmt(); + // New LUTs need a fresh composite even when nothing was damaged. + if ( g_ColorMgmt.serial != uColorMgmtSerial ) + bRepeatFrame = false; + paintID++; gpuvis_trace_begin_ctx_printf( paintID, "paint_all" ); steamcompmgr_win_t *w; @@ -3158,6 +3164,7 @@ paint_all( global_focus_t *pFocus, bool async ) frameInfo.applyOutputColorMgmt = g_ColorMgmt.pending.enabled; frameInfo.outputEncodingEOTF = g_ColorMgmt.pending.outputEncodingEOTF; frameInfo.allowVRR = cv_adaptive_sync; + frameInfo.bRepeatFrame = bRepeatFrame; frameInfo.bFadingOut = fadingOut; frameInfo.eUpscaleFilter = pFocus->eUpscaleFilter; frameInfo.eUpscaleScaler = pFocus->eUpscaleScaler; @@ -10748,6 +10755,7 @@ steamcompmgr_main(int argc, char **argv) for ( auto &iter : g_VirtualConnectorFocuses ) { global_focus_t *pPaintFocus = &iter.second; + bool bRepeatFrame = false; const UpscaleSettings_t upscaleSettings = GetUpscaleSettings( window_is_steam( pPaintFocus->focusWindow ), @@ -10867,6 +10875,14 @@ steamcompmgr_main(int argc, char **argv) } } + // Hold the Steam UI at the refresh rate so an idle panel can't stretch toward its minimum and flicker. + if ( !bShouldPaint && bIsVBlankFromTimer && g_bSteamIsActiveWindow && + cv_adaptive_sync_idle_hold ) + { + bShouldPaint = true; + bRepeatFrame = !hasCursorRepaint && !hasRepaintNonBasePlane; + } + if ( bIsVBlankFromTimer ) { if ( hasRepaintNonBasePlane ) @@ -10904,7 +10920,7 @@ steamcompmgr_main(int argc, char **argv) if ( bShouldPaint ) { - paint_all( pPaintFocus, eFlipType == FlipType::Async ); + paint_all( pPaintFocus, eFlipType == FlipType::Async, bRepeatFrame ); bPainted = true; }