Skip to content
article

16.6 ms is not a lot of time: frame budget for people who never shipped a game

How I find where the milliseconds go in Unreal, from someone who survived two console certs

Bartek Wójcik@bartek_gamedev

June 15, 2026 · 3 min read

No, so. Every week somebody in our Discord asks "why my game is 40 fps, I have RTX card". This post is the answer I will link from now on.

The budget

60 fps means you have 16.67 ms for everything. 30 fps means 33.3 ms. 120 fps means 8.3 ms, good luck. And "everything" is not one thing. In Unreal you have mostly three things running in parallel:

  • Game thread: gameplay code, Blueprints, Tick, animation logic, AI.
  • Render thread (Draw): preparing what GPU will draw, culling, building commands.
  • GPU: actually drawing.

Frame time is roughly the slowest of them, plus some sync. So first question is never "how to optimise". First question is which one is slowest. If you optimise GPU when you are game thread bound, you gain exactly 0 ms. I watched senior person do this for one week. Not me. Okay, me, in 2019.

Step 1: stat unit

Open the console in game (tilde key) and type:

stat unit
stat fps

You get Frame, Game, Draw and GPU in milliseconds. Example from our project last month, on dev kit:

Frame: 24.9 ms
Game:  23.8 ms
Draw:  11.2 ms
GPU:   14.1 ms

Game thread is the problem. GPU has spare time. Nobody touch the shaders please.

Step 2: what is game thread doing

stat game
stat anim
stat ai

But honestly go straight to Unreal Insights. Run the game with tracing:

MyGame.exe -trace=cpu,frame,bookmark,loadtime -statnamedevents

Open the trace in Unreal Insights, look at Timing view, find a bad frame and read what is wide. In our case: 312 actors ticking every frame, most of them doing nothing. Pickup items. Doors. A decorative fan. Ticking. Every. Frame.

Step 3: stop ticking things that do not need it

AMyPickup::AMyPickup()
{
    // no per-frame logic at all
    PrimaryActorTick.bCanEverTick = false;
}

AMyDoor::AMyDoor()
{
    PrimaryActorTick.bCanEverTick = true;
    PrimaryActorTick.bStartWithTickEnabled = false; // only tick while opening
}

void AMyDoor::Open()
{
    SetActorTickEnabled(true);
}

void AMyDoor::Tick(float DeltaSeconds)
{
    Super::Tick(DeltaSeconds);
    Alpha = FMath::Min(Alpha + DeltaSeconds / OpenTime, 1.f);
    SetActorRelativeRotation(FMath::Lerp(ClosedRot, OpenRot, Alpha));
    if (Alpha >= 1.f)
    {
        SetActorTickEnabled(false);
    }
}

For things that need update but not 60 times per second (AI perception, some UI), use PrimaryActorTick.TickInterval = 0.2f; or a timer. Your enemy does not need to re-think where the player is every 16 ms. Humans do not either.

This alone took us from 23.8 to 15.1 ms on game thread. No clever code. Just stop doing work.

Step 4: then GPU, if GPU is the problem

stat gpu and ProfileGPU (or Ctrl+Shift+Comma) show you the passes. Usual suspects: too many shadow casting dynamic lights, translucency overdraw (particles!), a post process stack somebody copied from a tutorial. Quick test: r.ScreenPercentage 50. If frame time drops a lot, you are GPU bound on pixels. If nothing changes, you are not GPU bound, go back to step 1, k***a.

What I would tell junior me

  • Measure on target hardware. Your dev PC lies to you.
  • Measure in a packaged build (Development or Test config), not in editor. Editor has its own cost.
  • One change, one measurement. Write numbers down.
  • Budget per system early. On last project: gameplay 5 ms, animation 3 ms, AI 2 ms, physics 2 ms, the rest is margin. When somebody goes over, it is a conversation, not a surprise in the week before cert.

Questions welcome, I answer after my build finish. Which is never.

Written by

Bartek Wójcik

Gameplay programmer, C++ and Unreal, Warszawa. Shipped 2 games, 1 cancelled, 0 regrets (lie). Crunch survivor. Gram w szachy i piję za dużo kawy.

Get the best of Dynamo Geeks weekly

One email each Monday with the most useful posts. No spam; unsubscribe in one click.

Comments

11

Sign in to join the conversation.

Sign in to comment
Frank Ostrowski@frank_cpp98
Finally a post with a constructor in it. The decorative fan ticking every frame is a Crime and whoever wrote it should write assembly for a year. Regards.
2 net votes
Bartek Wójcik@bartek_gamedev
It was Blueprint, so assembly would be upgrade for him honestly
2 net votes
Karthik Subramaniam@karthi_rustacean
Same lesson in embedded, polling loops doing nothing. Question: how much does Unreal Insights itself add when tracing? Is the 23.8 ms number with trace on?
0 net votes
Bartek Wójcik@bartek_gamedev
Good question. With cpu channel it is small, for us like 0.2-0.5 ms, but if you enable everything (memory, all the named events) it gets heavy. We compare with stat unit without trace to be sure. Numbers in the post are without trace.
0 net votes
Marta Kovalenko@marta_osint
I understood maybe 40% but I shared it with my old studio friends and they say 'finally someone said it about Tick'. So, 100% useful by proxy.
5 net votes
Sam Whitaker@segfault_sam
"a decorative fan. ticking. every. frame." is my new slack status
5 net votes
Ondřej Hrubý@rewrite_it_in_rust
nice post but this is exactly what Bevy ECS solves by design, systems only run on entities with the components. no Tick on fan 🦀
2 net votes
Bartek Wójcik@bartek_gamedev
No, ECS does not solve 'designer added system that runs on every entity because tutorial said so'. Bad data design is possible in every language. Also show me Bevy game that passed console cert. I wait.
1 net votes
Ondřej Hrubý@rewrite_it_in_rust
you are waiting because console SDKs are C++ only, this is not argument for C++, this is argument against Sony
0 net votes
Bartek Wójcik@bartek_gamedev
It is argument for 'I have to ship this year' k***a 😂
0 net votes
Olivier Gagnon@ogagnon
Great writeup. Godot has the same trap, every node with _process enabled. I put set_process(false) in _ready on everything decorative and gained 3 ms on my fishing game, which is a lot when you are on a 2015 laptop like me
2 net votes