Tutorial 73: Performance Profiling and Optimization

CNA Tutorials  ·  Performance

GameTime for frame timing

Every Update and Draw call receives a GameTime reference. Its fields are your primary source of frame-timing information:

  • gameTime.ElapsedGameTime.TotalSeconds() — seconds since the last frame (typically ~0.0167 at 60 Hz).
  • gameTime.TotalGameTime.TotalSeconds() — total time elapsed since Game::Run().
  • gameTime.IsRunningSlowly — set to true by the framework when the frame time exceeded the target and a fixed-timestep Update was skipped.

Use IsRunningSlowly as an early warning: if it is true on most frames your game is CPU-bound in Update.

FPS counter and draw call counter

A minimal performance overlay can be rendered with SpriteBatch over the game scene. Track frames per second using a rolling average and maintain a per-frame draw call counter that you reset at the start of each Draw:

// PerformanceOverlay.hpp
#pragma once
#include "Microsoft/Xna/Framework/GameTime.hpp"
#include "Microsoft/Xna/Framework/Graphics/SpriteBatch.hpp"
#include "Microsoft/Xna/Framework/Graphics/SpriteFont.hpp"
#include <string>
#include <deque>

class PerformanceOverlay {
public:
    explicit PerformanceOverlay(SpriteFont& font) : font_(font) {}

    void BeginFrame() {
        drawCallCount_ = 0;
    }

    void CountDraw(int primitives = 1) {
        ++drawCallCount_;
        totalPrimitives_ += primitives;
    }

    void EndFrame(const GameTime& gt) {
        float dt = static_cast<float>(gt.ElapsedGameTime.TotalSeconds());
        frameTimes_.push_back(dt);
        if (frameTimes_.size() > SAMPLE_COUNT) frameTimes_.pop_front();

        float avg = 0.0f;
        for (float t : frameTimes_) avg += t;
        avg /= static_cast<float>(frameTimes_.size());
        fps_ = (avg > 0.0f) ? 1.0f / avg : 0.0f;
    }

    void Draw(SpriteBatch& sb, const Vector2& pos) {
        std::string text =
            "FPS: "        + std::to_string(static_cast<int>(fps_)) +
            "  Draws: "    + std::to_string(drawCallCount_) +
            "  Prims: "    + std::to_string(totalPrimitives_);
        sb.DrawString(font_, text, pos, Color::Yellow);
        totalPrimitives_ = 0;
    }

private:
    static constexpr size_t SAMPLE_COUNT = 60;
    SpriteFont&        font_;
    std::deque<float> frameTimes_;
    float              fps_             = 0.0f;
    int                drawCallCount_   = 0;
    int                totalPrimitives_ = 0;
};

Wrap every DrawPrimitives / DrawIndexedPrimitives call with overlay.CountDraw(primitiveCount) to track the GPU workload across frames.

Texture atlas to reduce binds

Every time SpriteBatch encounters a different Texture2D than the previous sprite it must flush the current batch and issue a draw call. For a scene with 500 sprites across 100 different textures, this produces up to 100 draw calls. Packing all sprites into a single texture atlas reduces this to one draw call regardless of sprite count.

Build a texture atlas offline with a tool such as TexturePacker or at startup with a custom packer. Store a Rectangle for each sprite's region within the atlas. Pass that rectangle as the sourceRectangle argument to SpriteBatch::Draw:

// Atlas sprites drawn in a single batch (one draw call)
spriteBatch_->Begin();
for (auto& sprite : sprites_) {
    spriteBatch_->Draw(
        *atlas_,             // single shared texture
        sprite.position,
        sprite.atlasRegion,  // Rectangle within the atlas
        Color::White,
        sprite.rotation,
        sprite.origin,
        sprite.scale,
        SpriteEffects::None,
        sprite.depth);
}
spriteBatch_->End();  // one GPU draw call

SpriteBatch batching rules

SpriteBatch accumulates sprites and flushes them to the GPU in as few draw calls as possible. A flush is triggered by any of the following:

  • A different texture than the previous sprite (in SpriteSortMode::Texture mode, SpriteBatch sorts by texture first to minimise this).
  • A different sampler state, blend state, or rasterizer state.
  • Calling SpriteBatch::End().
  • The internal vertex buffer reaching its capacity (default 2048 sprites per batch).

Sort mode summary:

SpriteSortModeBatchingDraw order
Deferred (default)Batches by order of Draw callsSubmission order
TextureSorts by texture — fewest draw callsTexture-grouped
BackToFrontSorts by depth descending — correct for transparencyBack to front
FrontToBackSorts by depth ascending — early-Z efficiencyFront to back
ImmediateNo batching — one draw call per DrawSubmission order

Buffer orphaning for dynamic geometry

When uploading new vertex data to a VertexBuffer that was created with BufferUsage::WriteOnly, the GPU may still be reading from it for the previous frame. Naively overwriting it stalls the CPU until the GPU finishes. Buffer orphaning avoids the stall by allocating a new backing buffer and immediately returning, leaving the GPU to finish reading the old one in parallel:

// Orphan: SetData with Discard option
// This allocates a new GPU buffer under the hood without waiting for the GPU.
dynamicVB_->SetData(
    0,
    newVertices.data(),
    static_cast<int>(newVertices.size()),
    SetDataOptions::Discard);  // key: avoids GPU stall

Use SetDataOptions::NoOverwrite if you are appending to a region of the buffer that the GPU is not currently reading (e.g. double-buffered regions).

CPU vs GPU bottleneck identification

Before optimising, determine whether your frame time is CPU-bound or GPU-bound:

  • CPU-bound: Removing all DrawPrimitives calls (comment out the Draw method body) still leaves a high frame time. The bottleneck is in Update logic, physics, or AI.
  • GPU-bound: Frame time is proportional to viewport size. Halving the resolution halves the frame time. The bottleneck is pixel throughput, texture bandwidth, or geometry throughput.
  • Driver-bound: Frame time is proportional to draw call count but not geometry count. Reducing draw calls (batching) helps more than reducing polygon count.

RenderDoc integration

RenderDoc is an open-source GPU frame debugger that works with CNA's EASYGL (OpenGL) and VULKAN backends on Linux and Windows. To capture a frame:

  1. Launch your CNA binary through RenderDoc (File > Launch Application).
  2. Press F12 (or the configured hotkey) in-game to capture a frame.
  3. Open the captured frame and inspect each draw call, shader inputs, and render targets.

RenderDoc shows the contents of each RenderTarget2D after each draw call, which is invaluable for debugging post-processing pipelines (bloom, deferred rendering). No source changes are needed — RenderDoc injects into the process via the driver.

Vulkan validation layers

When using the VULKAN backend, enable validation layers during development to catch API misuse that would be silent errors in release:

// CMakeLists.txt — add a dev build type with validation
target_compile_definitions(MyGame PRIVATE
    $<$<CONFIG:Debug>:CNA_ENABLE_VULKAN_VALIDATION=1>)

// In your game's startup (before Game::Run())
#if defined(CNA_ENABLE_VULKAN_VALIDATION)
    CnaVulkanConfig::SetEnableValidationLayers(true);
    CnaVulkanConfig::SetValidationCallback(
        [](VkDebugUtilsMessageSeverityFlagBitsEXT severity,
           const char* message) {
            if (severity >= VK_DEBUG_UTILS_MESSAGE_SEVERITY_WARNING_BIT_EXT)
                fprintf(stderr, "[Vulkan] %s\n", message);
        });
#endif

Validation layers typically add 10–30% CPU overhead. Always disable them in release builds.