From 6b5e01586a6f5797df983e528fb97fed8800457d Mon Sep 17 00:00:00 2001 From: Matthew Schwartz Date: Wed, 15 Jul 2026 12:16:42 -0700 Subject: [PATCH] steamcompmgr: allow uncapped frame rates with adaptive sync With VRR active, every latched commit is painted immediately and the DRM backend blocks until the flip completes. Faster-than-refresh clients serialize the whole steamcompmgr loop on that wait, capping them at the panel refresh rate even with the FPS limiter disabled. Instead, defer VRR paints until the display can take a new flip, so mailbox/immediate commits keep latching and releasing buffers in between. The readiness check shares the VRR wakeup schedule's lead, a rolling max of wakeup-to-flip-queued time plus the fixed refresh schedule's redzone. Timer vblanks always count as ready, so FIFO clients, the FPS limiter and overlay pacing keep their exact timing. The old behavior is available behind the adaptive_sync_uncapped convar. --- src/steamcompmgr.cpp | 12 +++++++++-- src/vblankmanager.cpp | 49 ++++++++++++++++++++++++++++++++++--------- src/vblankmanager.hpp | 6 ++++++ 3 files changed, 55 insertions(+), 12 deletions(-) diff --git a/src/steamcompmgr.cpp b/src/steamcompmgr.cpp index ac179692dc..2628d06785 100644 --- a/src/steamcompmgr.cpp +++ b/src/steamcompmgr.cpp @@ -295,6 +295,7 @@ update_runtime_info(); gamescope::ConVar cv_adaptive_sync( "adaptive_sync", false, "Whether or not adaptive sync is enabled if available." ); 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_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" ); @@ -8509,8 +8510,15 @@ steamcompmgr_main(int argc, char **argv) // We can always vblank if VRR. const bool bVRR = GetBackend()->GetCurrentConnector() && GetBackend()->GetCurrentConnector()->IsVRRActive(); + + // Timer vblanks are scheduled with the same lead as the ready check, + // so they always count as flip-ready. + bool bVRRCanFlip = bVRR; + if ( bVRRCanFlip && cv_adaptive_sync_uncapped && !bIsVBlankFromTimer ) + bVRRCanFlip = GetVBlankTimer().IsVRRFlipReady(); + if ( bVRR ) - vblank = true; + vblank = vblank || bVRRCanFlip; bool flush_root = false; @@ -9053,7 +9061,7 @@ steamcompmgr_main(int argc, char **argv) case FlipType::VRR: { - bShouldPaint = hasRepaint; + bShouldPaint = hasRepaint && bVRRCanFlip; if ( bIsVBlankFromTimer ) { diff --git a/src/vblankmanager.cpp b/src/vblankmanager.cpp index f036d000a8..30da968b5b 100644 --- a/src/vblankmanager.cpp +++ b/src/vblankmanager.cpp @@ -21,6 +21,8 @@ LogScope g_VBlankLog("vblank"); +extern gamescope::ConVar cv_adaptive_sync_uncapped; + namespace gamescope { ConVar vblank_debug( "vblank_debug", false, "Enable vblank debug spew to stderr." ); @@ -93,6 +95,24 @@ namespace gamescope return ulTargetPoint; } + uint64_t CVBlankTimer::VRRWakeupOffset() const + { + uint64_t ulDrawTime = cv_adaptive_sync_uncapped ? m_ulVRRRollingMaxSubmitTime.load() : 0; + /// See comment of m_ulVBlankDrawTimeMinCompositing. + if ( m_bCurrentlyCompositing ) + ulDrawTime = std::max( ulDrawTime, m_ulVBlankDrawTimeMinCompositing ); + + // Queueing a flip early is free, the kernel holds it to the panel cycle. + uint64_t ulRedZone = cv_adaptive_sync_uncapped ? m_ulVBlankDrawBufferRedZone : kVRRFlushingTime; + return ulDrawTime + ulRedZone; + } + + bool CVBlankTimer::IsVRRFlipReady() const + { + const uint64_t ulIntervalNSecs = mHzToRefreshCycle( GetRefresh() ); + return get_time_in_nanos() + VRRWakeupOffset() >= GetLastVBlank() + ulIntervalNSecs; + } + VBlankScheduleTime CVBlankTimer::CalcNextWakeupTime( bool bPreemptive ) { const GamescopeScreenType eScreenType = GetBackend()->GetScreenType(); @@ -163,17 +183,10 @@ namespace gamescope m_ulRollingMaxDrawTime = kStartingVBlankDrawTime; } - uint64_t ulRedZone = kVRRFlushingTime; - - uint64_t ulDrawTime = 0; - /// See comment of m_ulVBlankDrawTimeMinCompositing. - if ( m_bCurrentlyCompositing ) - ulDrawTime = std::max( ulDrawTime, m_ulVBlankDrawTimeMinCompositing ); - - ulOffset = ulDrawTime + ulRedZone; + ulOffset = VRRWakeupOffset(); if ( vblank_debug && !bPreemptive ) - VBlankDebugSpew( ulOffset, ulDrawTime, ulRedZone ); + VBlankDebugSpew( ulOffset, m_ulVRRRollingMaxSubmitTime, m_ulVBlankDrawBufferRedZone ); } const uint64_t ulScheduledWakeupPoint = GetNextVBlank( ulOffset ); @@ -189,7 +202,13 @@ namespace gamescope std::optional CVBlankTimer::ProcessVBlank() { - return std::exchange( m_PendingVBlank, std::nullopt ); + // Anchor vblank iterations at their scheduled wakeup point, so + // scheduler + dispatch latency counts against the VRR submit time. + std::optional oVBlank = std::exchange( m_PendingVBlank, std::nullopt ); + m_ulLastWakeupTime = oVBlank + ? oVBlank->schedule.ulScheduledWakeupPoint + : get_time_in_nanos(); + return oVBlank; } void CVBlankTimer::MarkVBlank( uint64_t ulNanos, bool bReArmTimer ) @@ -215,6 +234,16 @@ namespace gamescope void CVBlankTimer::UpdateLastDrawTime( uint64_t ulNanos ) { m_ulLastDrawTime = ulNanos; + + uint64_t ulSubmitTime = get_time_in_nanos() - m_ulLastWakeupTime; + ulSubmitTime = std::min( ulSubmitTime, mHzToRefreshCycle( GetRefresh() ) / 2 ); + + uint64_t ulRollingMax = m_ulVRRRollingMaxSubmitTime; + if ( ulSubmitTime > ulRollingMax ) + ulRollingMax = ulSubmitTime; + else + ulRollingMax = ( ( m_ulVBlankRateOfDecayPercentage * ulRollingMax ) + ( kVBlankRateOfDecayMax - m_ulVBlankRateOfDecayPercentage ) * ulSubmitTime ) / kVBlankRateOfDecayMax; + m_ulVRRRollingMaxSubmitTime = ulRollingMax; } void CVBlankTimer::WaitToBeArmed() diff --git a/src/vblankmanager.hpp b/src/vblankmanager.hpp index 582f0e2a57..36725d1320 100644 --- a/src/vblankmanager.hpp +++ b/src/vblankmanager.hpp @@ -45,6 +45,7 @@ namespace gamescope int GetRefresh() const; uint64_t GetLastVBlank() const; uint64_t GetNextVBlank( uint64_t ulOffset ) const; + bool IsVRRFlipReady() const; VBlankScheduleTime CalcNextWakeupTime( bool bPreemptive ); void Reschedule(); @@ -64,6 +65,7 @@ namespace gamescope void OnPollIn() final; private: void VBlankDebugSpew( uint64_t ulOffset, uint64_t ulDrawTime, uint64_t ulRedZone ); + uint64_t VRRWakeupOffset() const; uint64_t m_ulTargetVBlank = 0; std::atomic m_ulLastVBlank = { 0 }; @@ -96,6 +98,10 @@ namespace gamescope // 3ms by default to get the ball rolling. // This is calculated by steamcompmgr/drm and fed-back to the vblank timer. std::atomic m_ulLastDrawTime = { kStartingVBlankDrawTime }; + // When the steamcompmgr loop last woke up. + uint64_t m_ulLastWakeupTime = 0; + // Rolling max of wakeup -> flip queued, feeding the VRR wakeup offset. + std::atomic m_ulVRRRollingMaxSubmitTime = { kStartingVBlankDrawTime }; ////////////////////////////////// // VBlank timing tuneables below!