Tutorial 73: Performance Profiling and Optimization
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 sinceGame::Run().gameTime.IsRunningSlowly— set totrueby 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::Texturemode, 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:
| SpriteSortMode | Batching | Draw order |
|---|---|---|
| Deferred (default) | Batches by order of Draw calls | Submission order |
| Texture | Sorts by texture — fewest draw calls | Texture-grouped |
| BackToFront | Sorts by depth descending — correct for transparency | Back to front |
| FrontToBack | Sorts by depth ascending — early-Z efficiency | Front to back |
| Immediate | No batching — one draw call per Draw | Submission 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
DrawPrimitivescalls (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:
- Launch your CNA binary through RenderDoc (File > Launch Application).
- Press F12 (or the configured hotkey) in-game to capture a frame.
- 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.