DEV Community

Cover image for t.MaxFPS vs r.SetFramePace - The Two Knobs Every UE5 Android Dev Must Understand
Ankit
Ankit

Posted on

t.MaxFPS vs r.SetFramePace - The Two Knobs Every UE5 Android Dev Must Understand

I'd been wrestling with the nitty-gritties of what should be a straightforward problem — setting a specific frame rate on Android in Unreal Engine 5. Two console variables, t.MaxFPS and r.SetFramePace, kept tripping me up until I cracked open the engine and traced what they actually do under the hood.

If you've ever stared at Unreal Insights wondering why your frames aren't hitting the target you set, or if the term "SwappyGL" sounds like a rejected Pokémon name to you — this one's for you.

Prerequisites: A basic understanding of the 3 threads involved in UE gameplay (Game, Render, RHI) and familiarity with Unreal Insights.


t.MaxFPS

In simple terms, this is the console variable that caps your FPS. Set it to anything greater than 0, and it enforces that cap. Set it to 0 (or below), and the cap is off — the engine runs as fast as it can.

What it really does is put the game thread to sleep. The sleep duration is calculated from how long the game thread was busy in the previous frame. The event to look for in a utrace is STAT_FEngineLoop_UpdateTimeAndHandleMaxTickRate.

Sample game frame from Unreal Insights

Let's say we set t.MaxFPS 60. In the frame above (frame 11477), the game thread worked for 6.9 ms, so the next wait time shakes out like this:

Wait time = Expected frame time − Previous frame's work time
         = 16.6 ms − 6.9 ms
         = ~9.7 ms
         ≈ ~9.5 ms (after accounting for scheduling overhead)
Enter fullscreen mode Exit fullscreen mode

And that's exactly the ~9.5 ms sleep you see in the trace. Simple arithmetic, big impact.

r.SetFramePace

Sometimes, t.MaxFPS alone isn't enough. Consider this: you want 90 FPS gameplay. You set t.MaxFPS 90, the game thread happily ticks along at 90 Hz — but the display is still refreshing at 60 Hz. Each frame sits on screen for 16.66 ms regardless. Your RHI thread is presenting frames at 60 FPS no matter what the game thread thinks it's doing.

This is where r.SetFramePace comes to the rescue. Setting it to 90 tells the Android frame pacer to request a 90 Hz refresh rate from the display (if the device supports it). Put differently, r.SetFramePace controls the wait on the RHI thread — the presentation side of the pipeline.

When MaxFPS is set but FramePace is not

In the trace above, the RHI thread waits on SwappyGL_swap, which only returns after 16.6 ms from the previous screen presentation — locked to the 60 Hz vsync interval.

When FramePace is set along with MaxFPS

Once r.SetFramePace 90 is also set, the vsync interval drops from ~16.6 ms to ~11.1 ms (since 1000 ms ÷ 90 = 11.1 ms). Now both the game thread and the display are speaking the same language.

What's SwappyGL?

Every frame on Android, the RHI thread eventually calls eglSwapBuffers to present the back buffer. In UE5, this goes through FAndroidOpenGLFramePacer, which wraps the actual swap call. SwappyGL_swap is one of the presentation strategies — and it's the smart one.

When frame pacing is active, UE doesn't call eglSwapBuffers directly. Instead, the call chain looks like this:

RHI Thread
│
└─ FAndroidOpenGLFramePacer::SwapBuffers()
   │
   └─ SwappyGL_swap(display, surface)
      │
      ├─ Reads Android Choreographer's vsync timestamps
      │   (the hardware vsync pulse from the display panel)
      │
      ├─ Computes TARGET presentation timestamp
      │   Target = LastPresentTime + DesiredFrameInterval
      │   e.g., 90 FPS on 90 Hz → interval = 11.1 ms
      │
      ├─ GPU finished EARLY (frame ready before target vsync):
      │   ├─ Sets EGL_ANDROID_presentation_time on the buffer
      │   │   ("don't show this until vsync T+N")
      │   ├─ Calls real eglSwapBuffers
      │   └─ BLOCKS the RHI thread until target vsync arrives
      │
      ├─ GPU finished ON TIME:
      │   ├─ Sets presentation timestamp for next vsync
      │   ├─ Calls real eglSwapBuffers
      │   └─ Returns almost immediately
      │
      └─ GPU finished LATE (missed target vsync):
          ├─ Detects the miss via Choreographer callback
          ├─ Adjusts pipeline mode (may add a frame of latency)
          ├─ Targets the NEXT valid vsync instead
          └─ Logs the miss for auto-swap-interval adjustment
Enter fullscreen mode Exit fullscreen mode

The key thing SwappyGL provides is vsync phase-locking. It doesn't just throttle — it actively aligns frame presentation to specific vsync boundaries using EGL_ANDROID_presentation_time. This means:

  • Every frame displays for exactly the same duration (11.1 ms at 90 Hz).
  • Frame N is guaranteed to replace Frame N−1 at a precise vsync tick.
  • SurfaceFlinger (the compositor) knows in advance when each frame should appear.
  • Input latency becomes predictable because the pipeline depth is fixed.

In short, SwappyGL turns your frame timing from "best effort" into "contract with the display."

What Happens When Frame Pace Is Set to 0

When frame pacing is disabled, the path is much simpler — and much less coordinated:

RHI Thread

└─ FAndroidOpenGLFramePacer::SwapBuffers()
   
   ├─ eglSwapInterval(display, 0)       // Set once at init: no vsync wait
   
   └─ eglSwapBuffers(display, surface)   // Direct call, no Swappy
      
      ├─ Queues the buffer to SurfaceFlinger
      ├─ Returns IMMEDIATELY (no vsync wait)
      └─ No presentation timestamp set
          (compositor shows it at the next available vsync, whenever that is)
Enter fullscreen mode Exit fullscreen mode

RHI thread when not using frame pacing

In this mode, the only throttle is the game thread via t.MaxFPS. The RHI thread fires and forgets.

With SwappyGL — frames are vsync-phase-locked. Capture a utrace and you'll see frame present times at nearly exact 11.1 ms intervals (on 90 Hz), with sub-millisecond jitter. The RHI thread shows periodic blocks inside SwappyGL_swap as it holds frames until the right vsync. This is optimal for perceived smoothness.

Without SwappyGL — frames arrive at SurfaceFlinger whenever the RHI finishes them. The compositor displays each at the next available vsync, which might be 8.3 ms or 16.6 ms after the previous present, depending on exactly when the frame landed. On a 90 Hz panel, this is usually fine (the frame almost always catches the next vsync). But on a 120 Hz panel, you'd see an alternating 8.3 ms / 16.6 ms pattern — technically correct frame rate, but visually inconsistent.

When to Disable Frame Pacing

Here's where it gets interesting. Suppose you want 45 FPS gameplay, but the device only supports a 60 Hz refresh rate. The vsync interval is fixed at 16.6 ms, and your game thread needs 22.2 ms per frame to hit that 45 FPS target.

The problem: 22.2 ms doesn't fit neatly into 16.6 ms vsync slots. The RHI thread starts missing vsync deadlines, and frames get presented in an ugly pattern — 33.3 ms, 16.6 ms, 16.6 ms, 33.3 ms, and so on. The user perceives this as micro-stutter, and no amount of raw FPS fixes that feeling.

Micro-stutter pattern

And that's the ideal case — assuming the render thread and game thread stay within budget. If gameplay is already lagging, the RHI thread misses vsync events even further. For example, Frame 2 might never be ready at V4 because the frame took just 1–2 ms extra to finish.

In such cases, it's better to disable frame pacing entirely, so that every vsync interval becomes a valid presentation opportunity. The frames won't be phase-locked, but they also won't be forced into a pattern that guarantees stutter.

Wrapping Up

If you're building anything on UE5 for Android — whether it's a casual puzzler or a AAA battle royale — these two CVars are quietly some of the most important knobs in your performance toolkit.

Think of it this way:

  • t.MaxFPS controls how fast your simulation ticks — the game thread budget.
  • r.SetFramePace controls how your frames reach the display — the presentation contract.

Getting them right means you're not leaving performance on the table. Getting them wrong means your game could be running at a perfectly good frame rate internally, while the user sees jank and stutter on screen — all because the display side wasn't in sync.

And the decision of when to disable frame pacing — that's the nuance that separates "it works on my test device" from "it's smooth across 500 different Android SKUs." Not every device supports the refresh rate you want. When the math doesn't fit the vsync grid, sometimes the smartest move is to let go of phase-locking and let frames land where they can.

The performance budget in mobile games is razor-thin. Every millisecond you save on frame pacing overhead or vsync misalignment is a millisecond you can spend on gameplay, physics, or that one particle effect your art director insists is "absolutely essential." Master these CVars, profile with Unreal Insights and Perfetto, and you'll squeeze every last drop from the hardware.

Now go set those frame rates — and may your vsyncs never stutter. 🎮

Top comments (1)

Collapse
 
altpsyche profile image
Siva

Great article.