ImGuiでフレームプロファイラを自作する
自作の DirectX 11 ゲームで「なんとなく重い」を長らく放置していたのですが、原因を数字で切り分けたくなり、ImGui でフレーム内訳を可視化する簡易プロファイラを作りました。1 つのウィンドウに、
- フレーム時間(ms / FPS)と直近の履歴グラフ
- CPU フェーズ(Update / DrawSubmit)
- GPU ゾーン(Shadow / Scene / Post)
- CPU 律速か GPU 律速かの判定
をまとめて表示します。本記事では実装のうち、特につまずいた次の 2 点に焦点を当てます。
- GPU の時間を「パイプラインを止めずに」測る(タイムスタンプクエリの非同期回収)
- CPU 律速 / GPU 律速をどう見分けるか
1. 完成イメージ
Profiler という ImGui ウィンドウに、フレーム時間の折れ線グラフ・CPU/GPU の内訳バー・Bound: CPU/GPU の判定が並びます。各行は「名前 + 現在値 + avg/max + フレーム時間に対する割合バー」という形です。
2. GPU の時間を止めずに測る
GPU の実行時間は、CPU 側の std::chrono では測れません(GPU は非同期に動くため)。DirectX 11 では ID3D11Query の タイムスタンプクエリ を使います。
仕組みはこうです。
-
D3D11_QUERY_TIMESTAMP_DISJOINT… クロック周波数と「この区間は信頼できるか(Disjoint フラグ)」を返す。 -
D3D11_QUERY_TIMESTAMP…End()を呼んだ瞬間の GPU 時刻(チック)を刻む。
ゾーンを 2 本のタイムスタンプで挟み、差分を周波数で割れば ms が出ます。
void Renderer::ProfileBeginZone(GpuZone z) {
// タイムスタンプは End() のみで「その時点の GPU 時刻」を記録する
m_DeviceContext->End(m_profSlots[m_profWrite].tsBegin[(int)z].Get());
}
void Renderer::ProfileEndZone(GpuZone z) {
m_DeviceContext->End(m_profSlots[m_profWrite].tsEnd[(int)z].Get());
}
つまずいた点:同じフレームで読むとストールする
クエリの結果は GPU が処理を終えるまで返ってきません。GPU は CPU より数フレーム遅れて完了するので、刻んだ直後に GetData を「完了するまで待つ」モードで呼ぶと、その待ち時間でパイプラインが丸ごとストールします。計測のためのコードが計測対象を歪めてしまう、本末転倒な状態です。
そこで、クエリを 5 フレーム分リングバッファ で持ち、毎フレーム「完了済みのスロットだけ」を非ブロッキング(待たない)で回収するようにしました。
// ProfileEndFrame(): 完了している保留スロットを「すべて」回収する
for (int s = 0; s < kProfileSlots; ++s) {
GpuProfileSlot& slot = m_profSlots[s];
if (!slot.pending) continue;
D3D11_QUERY_DATA_TIMESTAMP_DISJOINT dj{};
if (ctx->GetData(slot.disjoint.Get(), &dj, sizeof(dj), 0) != S_OK) continue; // 未完了→次フレームへ
slot.pending = false;
if (dj.Disjoint || dj.Frequency == 0) continue; // 信頼できない区間は捨てる
for (int z = 0; z < (int)GpuZone::Count; ++z) {
UINT64 t0, t1;
if (ctx->GetData(slot.tsBegin[z].Get(), &t0, sizeof(t0), 0) == S_OK &&
ctx->GetData(slot.tsEnd[z].Get(), &t1, sizeof(t1), 0) == S_OK && t1 >= t0) {
float ms = (float)((double)(t1 - t0) / dj.Frequency * 1000.0);
// 瞬間値はノイジーなので EMA で平滑化
m_profMs[z] = (m_profMs[z] <= 0.f) ? ms : (m_profMs[z] * 0.8f + ms * 0.2f);
}
}
}
ポイントは「1 スロット固定読み」にしないことです。GPU が深くキューイングされたタイミングで取りこぼすと値がフリーズするので、保留中のスロットを毎フレーム全部再試行します。それでもリング段数以上に GPU が遅延したら、上書き前に dropped としてカウントだけ残します。
EMA(指数移動平均)=前回値を 8 割残し、新しい値を 2 割だけ混ぜる平滑化。グラフのチラつきを抑える簡単な方法です。
3. CPU 律速か GPU 律速か
ここが一番効きました。GPU ゾーンは「GPU タイムライン上の区間」なので、CPU がボトルネックだと GPU は待ち(アイドル)を含み、ゾーンの合計がフレーム時間を超えることすらあります。つまり GPU の数字だけ見ても「CPU が重いのか GPU が重いのか」は判別できません。
そこで CPU 側のフェーズも std::chrono で実測して併記します。
const auto t0 = std::chrono::steady_clock::now();
Update(); // ゲームロジック更新
const auto t1 = std::chrono::steady_clock::now();
DrawSubmit(); // 描画コマンド発行
const auto t2 = std::chrono::steady_clock::now();
using msf = std::chrono::duration<float, std::milli>;
Renderer::SetCpuPhaseMs(CpuPhase::Update, msf(t1 - t0).count());
Renderer::SetCpuPhaseMs(CpuPhase::DrawSubmit, msf(t2 - t1).count());
あとは CPU 合計と GPU 合計を比べて Bound: CPU / GPU を出すだけです。これで「草の Update が重くて CPU 律速になっている」とハッキリ見えるようになり、実際にこの表示から主犯(草更新の探索処理)を特定できました。
4. 表示(ImGui)
履歴はリングバッファ(180 サンプル ≒ 3 秒)に毎フレーム push し、ImGui::PlotLines にそのまま渡します。1 行は名前・現在値・avg/max・割合バー(ProgressBar)で、バーのスケールはフレーム時間を 100% としています。
void MetricRow(const char* name, float ms, float scaleMs, const RingStat& st) {
ImGui::Text("%-11s %6.2f ms avg %5.2f max %5.2f", name, ms, st.avg(), st.maxv());
ImGui::ProgressBar(scaleMs > 0 ? ms / scaleMs : 0.f, ImVec2(-1.f, 5.f), "");
}
なお ImGui の既定フォントは日本語を描けないので、ウィンドウ内の文字列は英語で統一しています。
5. まとめ
専用ライブラリを入れなくても、ImGui と DirectX 11 のクエリだけで実用的なフレームプロファイラが作れました。要点は次の 3 つです。
- GPU 時間はタイムスタンプクエリで測り、リングバッファ+非ブロッキング回収でストールを避ける
- GPU ゾーンだけでは律速が分からないので、CPU フェーズを併記して CPU/GPU 律速を確定させる
- 計測ロジックは Renderer に閉じ、表示クラスは登録 1 行で抜き差しできる単一責務に保つ
数字で見えると改善が一気に進みます。実際このプロファイラのおかげで 60fps → 70〜90fps まで持っていけました。
技術スタック
- C++20 / DirectX 11
- Dear ImGui (UI)
