0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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 + フレーム時間に対する割合バー」という形です。

画面録画 2026-06-21 231933.gif
[動画] 完成イメージ

2. GPU の時間を止めずに測る

GPU の実行時間は、CPU 側の std::chrono では測れません(GPU は非同期に動くため)。DirectX 11 では ID3D11Queryタイムスタンプクエリ を使います。

仕組みはこうです。

  • D3D11_QUERY_TIMESTAMP_DISJOINT … クロック周波数と「この区間は信頼できるか(Disjoint フラグ)」を返す。
  • D3D11_QUERY_TIMESTAMPEnd() を呼んだ瞬間の 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)
0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?