Frame Budget & Profiling in Game C++
"Staying inside 16ms or 33ms frame budgets: Tracy, Optick, GPU profiling, hotspot isolation, and systematic frame-time debugging."
TL;DR
60fps = 16.67ms per frame. 30fps = 33.33ms. Every system (physics, AI, rendering, audio) competes for that budget. Profile first — never optimize blind. Use frame-time instrumentation (Tracy, Optick) to see exactly where milliseconds go, then iterate.
The frame budget model
Frame time breakdown (16.67ms target):
┌─────────────────────────────────────────┐
│ Input handling 0.1ms │
│ Physics update 2.0ms │
│ AI / behavior trees 1.5ms │
│ Animation update 1.0ms │
│ Gameplay scripts 1.0ms │
│ Culling + visibility 0.5ms │
│ Draw call submission 2.0ms │
│ GPU render 6.0ms │
│ Audio mix 0.5ms │
│ OS overhead / vsync ~0.5ms │
│ Headroom 1.57ms │
└─────────────────────────────────────────┘
Total: 16.67msTrack average and P95 — an occasional 20ms frame causes a visible stutter even if the average is 15ms.
High-resolution frame timing
#include <chrono>
class FrameTimer {
public:
void beginFrame() {
frame_start_ = std::chrono::high_resolution_clock::now();
}
double endFrame() {
auto end = std::chrono::high_resolution_clock::now();
double ms = std::chrono::duration<double, std::milli>(end - frame_start_).count();
history_[index_++ % HISTORY] = ms;
return ms;
}
double average() const {
double sum = 0;
for (auto v : history_) sum += v;
return sum / HISTORY;
}
double percentile95() const {
auto sorted = history_;
std::sort(sorted.begin(), sorted.end());
return sorted[static_cast<int>(HISTORY * 0.95)];
}
private:
static constexpr size_t HISTORY = 128;
std::chrono::high_resolution_clock::time_point frame_start_;
std::array<double, HISTORY> history_{};
size_t index_ = 0;
};Tracy — the gold standard profiler
Tracy is a real-time, nanosecond-precision profiler with a live viewer.
Integration
// CMakeLists.txt
FetchContent_Declare(tracy
GIT_REPOSITORY https://github.com/wolfpld/tracy
GIT_TAG v0.11.0)
FetchContent_MakeAvailable(tracy)
target_link_libraries(MyGame PRIVATE Tracy::TracyClient)
target_compile_definitions(MyGame PRIVATE TRACY_ENABLE)// main.cpp — one-time setup
#include <tracy/Tracy.hpp>
int main() {
// Tracy connects automatically when the viewer opens
while (running) {
FrameMark; // marks frame boundary in Tracy timeline
gameLoop();
}
}Zone instrumentation
#include <tracy/Tracy.hpp>
void PhysicsSystem::update(float dt) {
ZoneScoped; // names zone after function automatically
{
ZoneScopedN("BroadPhase");
broadPhase_.update();
}
{
ZoneScopedN("NarrowPhase");
narrowPhase_.solve(dt);
}
}
// With color coding
void AISystem::update() {
ZoneScopedC(tracy::Color::Orange);
for (auto& agent : agents_)
agent.tick();
}
// Track values over time
TracyPlot("LiveEntities", static_cast<int64_t>(entities_.size()));
TracyPlot("DrawCalls", static_cast<int64_t>(renderer_.drawCallCount()));Memory tracking
// Tag all allocations in Tracy
void* operator new(size_t size) {
void* ptr = malloc(size);
TracyAlloc(ptr, size);
return ptr;
}
void operator delete(void* ptr) noexcept {
TracyFree(ptr);
free(ptr);
}Optick — lightweight alternative
Optick has lower overhead and better GPU timeline support on some platforms.
#include <optick.h>
void GameLoop() {
OPTICK_FRAME("MainThread");
{
OPTICK_EVENT("Physics");
physics_.update(dt);
}
{
OPTICK_EVENT("Rendering");
OPTICK_TAG("DrawCalls", renderer_.drawCallCount());
renderer_.render();
}
}CPU hotspot isolation
Once Tracy shows a slow zone, drill down with perf or VTune:
# Linux — record with call graph
perf record -g --call-graph dwarf -p $(pidof game)
perf report --sort=symbol,dso
# Generate flamegraph
perf script | stackcollapse-perf.pl | flamegraph.pl > frame.svgManual micro-benchmark for suspect code
// Isolate a suspect function
#include <benchmark/benchmark.h>
static void BM_AIUpdate(benchmark::State& state) {
AISystem ai;
ai.spawnAgents(state.range(0));
for (auto _ : state)
ai.update(0.016f);
state.SetItemsProcessed(state.iterations() * state.range(0));
}
BENCHMARK(BM_AIUpdate)->Range(64, 4096);
BENCHMARK_MAIN();GPU profiling
// GPU timestamp queries (OpenGL example)
GLuint queries[2];
glGenQueries(2, queries);
glQueryCounter(queries[0], GL_TIMESTAMP);
drawScene();
glQueryCounter(queries[1], GL_TIMESTAMP);
GLint done = 0;
while (!done)
glGetQueryObjectiv(queries[1], GL_QUERY_RESULT_AVAILABLE, &done);
GLuint64 start_ns, end_ns;
glGetQueryObjectui64v(queries[0], GL_QUERY_RESULT, &start_ns);
glGetQueryObjectui64v(queries[1], GL_QUERY_RESULT, &end_ns);
double gpu_ms = (end_ns - start_ns) / 1e6;Use RenderDoc (frame capture) or Nsight for deeper GPU analysis — identifies overdraw, fill rate, shader bottlenecks.
Common frame-time killers
| Issue | Symptom | Fix |
|---|---|---|
Per-frame new/delete | Allocator spikes, high variance | Pre-allocate or use frame allocator |
| Unculled draw calls | GPU always busy | Frustum/occlusion culling |
| Uncached transform lookups | Cache miss spikes | SoA layout, sort by component access |
| Unthrottled AI ticks | AI proportional to entity count | Tick budgeting, LOD-based AI |
| Sync GPU readback | CPU stall = full GPU frame time | Double/triple buffer readbacks |
| String operations in hot path | Allocator spikes | Fixed-size char arrays, string interning |
Tick budgeting for AI
class AIScheduler {
public:
// Budget: update at most `budget_ms` worth of AI per frame
void update(float budget_ms, float dt) {
auto deadline = std::chrono::high_resolution_clock::now()
+ std::chrono::duration<float, std::milli>(budget_ms);
while (!queue_.empty()) {
if (std::chrono::high_resolution_clock::now() >= deadline)
break; // defer remaining to next frame
auto* agent = queue_.front();
queue_.pop();
agent->think(dt);
// Re-enqueue at next desired tick time
queue_.push(agent);
}
}
private:
std::queue<AIAgent*> queue_;
};Frame pacing
Consistent frame times matter as much as fast frame times. A pattern of 14ms, 14ms, 20ms at 60fps looks worse than 16.67ms every frame because the variance causes perceivable stutter.
// Detect stutter frames
void detectStutter(double frame_ms, double target_ms) {
if (frame_ms > target_ms * 1.5) {
// Log spike with Tracy marker
TracyMessageL("STUTTER FRAME");
logFrameBreakdown();
}
}