Tutorial 73: Performance Profiling and Optimization

CNA Tutorials  ·  Performance

What you’ll learn

  • Measuring frame time from GameTime, and counting draw calls.
  • Cutting texture binds with an atlas, and the rules that keep SpriteBatch batching.
  • Buffer orphaning for dynamic geometry.
  • Telling a CPU bottleneck from a GPU one, with RenderDoc and Vulkan validation layers.

Before you startTutorial 05: The Game Loop (frame timing) and Tutorial 21: SpriteBatch Deep Dive (the batching rules being measured).

GameTime for frame timing

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

  • gameTime.getElapsedGameTimeProperty().getTotalSecondsProperty() — seconds since the last frame (typically ~0.0167 at 60 Hz).
  • gameTime.getTotalGameTimeProperty().getTotalSecondsProperty() — 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.getElapsedGameTimeProperty().getTotalSecondsProperty());
        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 OPENGLES3 and VULKAN renderers 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

The alpha.1 Vulkan renderer requests VK_LAYER_KHRONOS_validation automatically in a debug build and disables validation when NDEBUG is present. At instance creation it checks whether the layer is installed; if not, it reports that fact and continues without it. There is no CNA_ENABLE_VULKAN_VALIDATION CMake option or public CnaVulkanConfig API in this tag.

# Install your distribution's Khronos validation-layer package, then build Debug.
cmake -S . -B build-vulkan-debug -G Ninja \
  -DCMAKE_BUILD_TYPE=Debug \
  -DCNA_GRAPHICS_RENDERER=VULKAN
cmake --build build-vulkan-debug

Read the renderer's stderr/debug-messenger output and confirm that the layer is active before treating a zero-message run as evidence. Release builds intentionally omit this diagnostic cost.