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
Contents
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
Sign in to join the conversation.
Sign in to comment