Skip to content
C++
Domain Deep-Dive
Intermediate

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

cpp
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.67ms

Track average and P95 — an occasional 20ms frame causes a visible stutter even if the average is 15ms.

High-resolution frame timing

cpp
#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

cpp
// 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)
cpp
// 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

cpp
#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

cpp
// 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.

cpp
#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:

bash
# 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.svg

Manual micro-benchmark for suspect code

cpp
// 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

cpp
// 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

IssueSymptomFix
Per-frame new/deleteAllocator spikes, high variancePre-allocate or use frame allocator
Unculled draw callsGPU always busyFrustum/occlusion culling
Uncached transform lookupsCache miss spikesSoA layout, sort by component access
Unthrottled AI ticksAI proportional to entity countTick budgeting, LOD-based AI
Sync GPU readbackCPU stall = full GPU frame timeDouble/triple buffer readbacks
String operations in hot pathAllocator spikesFixed-size char arrays, string interning

Tick budgeting for AI

cpp
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.

cpp
// 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();
    }
}
Edit on GitHubUpdated 2026-05-01T00:00:00.000Z