From b46a355c27dfb41d573598cae2bb890bc3d68832 Mon Sep 17 00:00:00 2001 From: Utkarsh Dalal Date: Sat, 31 Oct 2026 11:23:39 +0530 Subject: [PATCH] =?UTF-8?q?android:=20A52=20=E2=80=94=20release=20limited?= =?UTF-8?q?=20frames=20on=20the=20vblank=20time=20grid,=20not=20on=20every?= =?UTF-8?q?=20Nth=20wakeup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The non-VRR frame-rate limit counted compositor wakeups: a window got its frame callback on every (refresh / target)th wakeup, which equals every Nth vblank only while no wakeup is ever late. The timer re-arms by stepping to the next vblank after "now", so a wakeup that comes more than one vblank late skips the vblanks in between, and those never count. On the 1200 Hz grid a session runs on (-r 1200, the nested refresh), a wakeup is late by a vblank after 0.83 ms of scheduling delay, and every late one holds the next frame a whole vblank longer: a 60 cap then reads 50-57, a 30 cap 25-28, in proportion to the wakeups lost, and it sinks further as the device throttles and the compositor thread is scheduled later still. The limiter now keeps a release grid in time: the next release is the previous one plus the target period, and a frame is released on the first vblank whose nominal time reaches it, with half a vblank of tolerance for the anchor moving. A late wakeup delays that one release by the lateness, and the grid absorbs it on the next, so the average rate is the target. A stall longer than a period is a lost frame, never a debt to repay with back-to-back releases. The grid is the nested refresh and the target alone; the panel's rate plays no part here. The VRR path is untouched: it already schedules by time. --- ...52-fps-limit-on-the-vblank-time-grid.patch | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 android/patches/gamescope/0050-A52-fps-limit-on-the-vblank-time-grid.patch diff --git a/android/patches/gamescope/0050-A52-fps-limit-on-the-vblank-time-grid.patch b/android/patches/gamescope/0050-A52-fps-limit-on-the-vblank-time-grid.patch new file mode 100644 index 000000000..afd5c7953 --- /dev/null +++ b/android/patches/gamescope/0050-A52-fps-limit-on-the-vblank-time-grid.patch @@ -0,0 +1,100 @@ +A52 — release limited frames on the vblank time grid, not on every Nth wakeup + +The non-VRR frame-rate limit counted compositor wakeups: a window got its frame +callback on every (refresh / target)th wakeup, which equals every Nth vblank only +while no wakeup is ever late. The timer re-arms by stepping to the next vblank +after "now", so a wakeup that comes more than one vblank late skips the vblanks +in between, and those never count. On the 1200 Hz grid a session runs on +(-r 1200, the nested refresh), a wakeup is late by a vblank after 0.83 ms of +scheduling delay, and every late one holds the next frame a whole vblank +longer: a 60 cap then reads 50-57, a 30 cap 25-28, in proportion to the wakeups +lost -- the flat 18-21 ms frames users saw with the CPU and the GPU idle -- and +it sinks further as the device throttles and the compositor thread is scheduled +later still. + +The limiter now keeps a release grid in time: the next release is the previous +one plus the target period, and a frame is released on the first vblank whose +nominal time reaches it, with half a vblank of tolerance for the anchor moving. +A late wakeup delays that one release by the lateness, and the grid absorbs it +on the next, so the average rate is the target. A stall longer than a period is +a lost frame, never a debt to repay with back-to-back releases. The grid is the +nested refresh and the target alone; the panel's rate plays no part here. The +VRR path is untouched: it already schedules by time. + +diff --git a/src/steamcompmgr.cpp b/src/steamcompmgr.cpp +index 773e95c..d6da2ad 100644 +--- a/src/steamcompmgr.cpp ++++ b/src/steamcompmgr.cpp +@@ -1233,6 +1233,52 @@ uint32_t g_uCurrentBasePlaneAppID = 0; + bool g_bCurrentBasePlaneIsFifo = false; + + static int g_nSteamCompMgrTargetFPS = 0; ++ ++// Non-VRR frame-rate limit: a frame is released on the vblank whose nominal time reaches ++// the release grid (the previous release plus the target period), not on every Nth ++// compositor wakeup. The timer steps over the vblanks a late wakeup missed, so counting ++// wakeups turns every late one into a frame held a whole vblank longer: on a fine vblank ++// grid that reads as a cap below the target whenever the compositor thread is scheduled ++// late, and it sinks further as the device throttles. ++static uint64_t g_ulFPSLimitNextRelease = 0; ++static bool g_bFPSLimitReleaseThisVBlank = true; ++ ++static void steamcompmgr_update_fps_limit_release( uint64_t ulTargetVBlank ) ++{ ++ int nRefreshmHz = g_nNestedRefresh ? g_nNestedRefresh : g_nOutputRefresh; ++ int nRefreshHz = gamescope::ConvertmHzToHz( nRefreshmHz ); ++ int nTargetFPS = g_nSteamCompMgrTargetFPS; ++ ++ if ( !nTargetFPS || nRefreshHz <= nTargetFPS ) ++ { ++ g_bFPSLimitReleaseThisVBlank = true; ++ g_ulFPSLimitNextRelease = 0; ++ return; ++ } ++ ++ const uint64_t ulPeriod = gamescope::mHzToRefreshCycle( gamescope::ConvertHztomHz( nTargetFPS ) ); ++ const uint64_t ulVBlank = gamescope::mHzToRefreshCycle( nRefreshmHz ); ++ ++ // First vblank under this cap, or the grid is ahead of us (cap change, clock step): anchor here. ++ if ( g_ulFPSLimitNextRelease == 0 || g_ulFPSLimitNextRelease > ulTargetVBlank + ulPeriod ) ++ g_ulFPSLimitNextRelease = ulTargetVBlank; ++ ++ // Half a vblank of tolerance so a release point that lands a hair past this vblank's ++ // nominal time is not pushed to the next one when the anchor moves. ++ if ( ulTargetVBlank + ulVBlank / 2 < g_ulFPSLimitNextRelease ) ++ { ++ g_bFPSLimitReleaseThisVBlank = false; ++ return; ++ } ++ ++ g_bFPSLimitReleaseThisVBlank = true; ++ g_ulFPSLimitNextRelease += ulPeriod; ++ ++ // A stall longer than a period is a lost frame, not a debt: never release on ++ // consecutive vblanks to catch up. ++ if ( g_ulFPSLimitNextRelease <= ulTargetVBlank + ulVBlank / 2 ) ++ g_ulFPSLimitNextRelease = ulTargetVBlank + ulPeriod; ++} + static uint64_t g_uDynamicRefreshEqualityTime = 0; + static int g_nDynamicRefreshRate[gamescope::GAMESCOPE_SCREEN_TYPE_COUNT] = { 0, 0 }; + // Delay to stop modes flickering back and forth. +@@ -7037,9 +7083,7 @@ static bool steamcompmgr_should_vblank_window( bool bShouldLimitFPS, uint64_t vb + { + if ( g_nSteamCompMgrTargetFPS && bShouldLimitFPS && nRefreshHz > nTargetFPS ) + { +- int nVblankDivisor = nRefreshHz / nTargetFPS; +- +- if ( vblank_idx % nVblankDivisor != 0 ) ++ if ( !g_bFPSLimitReleaseThisVBlank ) + bSendCallback = false; + } + } +@@ -10553,6 +10597,8 @@ steamcompmgr_main(int argc, char **argv) + static uint64_t vblank_idx = 0; + if ( vblank ) + { ++ steamcompmgr_update_fps_limit_release( g_SteamCompMgrVBlankTime.schedule.ulTargetVBlank ); ++ + { + uint64_t now = get_time_in_nanos(); +