Direct3D12におけるダブルバッファとトリプルバッファの違い

前回までに、リフレッシュレートを自由に設定できるようになりましたので、 今回は描画のfpsを自由に設定できるようにします。

そのためには、ダブルバッファをトリプルバッファに変更して、VSync待ちをなくして、Presentを実行する必要があります。

ざっくりというと、以下のような違いがあります。

  • ダブルバッファ
    • フロントバッファとバックバッファの2枚のバッファを切り替えて表示する方法。ティアリングを起こさないためには、VSync待ちが必要。
  • トリプルバッファ
    • フロントバッファとバックバッファx2の3枚のバッファを切り替えて表示する方法。VSync待ちがなくても、ティアリングは起きない。

Direct3D12の場合は、Presentの中でうまく処理してくれるので、内部の処理が分かりにくいですし、設定をミスすると例外とかティアリングとか起きます。 なお、ティアリングとは、画面の上下で違うフレームの画像が表示される現象で、ちらついて見えます。

まず、Presentの挙動は、ダブルバッファとトリプルバッファで違うようです。(ChatGPT先生のいうことなので、間違っている可能性もあります。)

  • VSyncが有効な場合のダブルバッファの場合のPresent
    • 描画完了を待ってから、バッファをSwapして、VSyncを待つ
  • VSyncが無効な場合のトリプルバッファの場合のPresent
    • 描画完了を待たずに、バッファをSwapするだけ

分かりにくいですが、ここで重要なのは以下のことです。

  • バッファをSwapをしても、ディスプレイは次のVSyncのタイミングにそのバッファを表示開始する

トリプルバッファを使ってもティアリングが起きると書かれていることがありますが、Swapの切り替えタイミングが原因で、ティアリングが起きることはありません。 ティアリングは、フロントに設定されたバッファに対して、間違って描画してしまうことで起きるものです。

つまり、ティアリングが起きた場合には、描画するバッファーを適切に管理できていないことになります。

Direct3D12で、ダブルバッファでもトリプルバッファでも、どちらでも動くようなコードは以下のようになります。

まず、Presentの前に、前フレームの描画待ちをするようにします。

    void SwapBuffer()
    {
        if (mDrawing)
        {
            // 前フレームの描画完了待ち(fenceは流さない)
            WaitForFence(false);

            // Present
            int vsync = FRAME_BUFFER_COUNT == 3 ? 0 : 1;
            swapChain->Present(vsync, 0);
        }
        mDrawing = false;
    }

    void IncrementFence()
    {
        fenceValue++;
        commandQueue->Signal(fence.Get(), fenceValue);
    }

    void WaitForFence(bool increment = true)
    {
        if (increment)
        {
            IncrementFence();
        }

        if (fence->GetCompletedValue() < fenceValue) {
            fence->SetEventOnCompletion(fenceValue, fenceEvent);
            WaitForSingleObject(fenceEvent, INFINITE);
        }
    }

このSwapBufferは、描画開始タイミングで呼びます。また、描画の最後に、描画完了のフェンスを流しておきます。

    void Draw()
    {
        // 次のVSyncのタイミングで、前フレームの描画結果をディスプレイに表示する
        SwapBuffer();

        // 描画開始
        mDrawing = true;
        int buffer_index = swapChain->GetCurrentBackBufferIndex();

        // ここにコマンドリストの生成処理が入る

        // コマンドリストの実行
        commandList->Close();
        ID3D12CommandList* commandLists[] = { commandList.Get() };
        commandQueue->ExecuteCommandLists(_countof(commandLists), commandLists);

        // 描画完了のフェンス
        IncrementFence();
    }

ここで重要なのは以下のことです。

  • Presentを実行することで、swapChain->GetCurrentBackBufferIndex()のインデックスが更新される
  • CPUとGPUの並列化という観点から、GetCurrentBackBufferIndexが呼ばれる直前でPresentを呼ぶことが望ましい

少しfenceの説明をしておきます。fenceとはGPUの処理のタイミングを知るための機能で、シンプルなIDをつけてコマンドキューに流し、 そのコマンドをGPUが読み取って、fenceより前の処理が完了したことを、そのシンプルなIDとともに、CPUに通知してくれる仕組みです。 つまり、GPUがfenceを読み取ると、CPUが登録したコールバック関数が起動するだけのシンプルな処理です。

なので、fenceを流す目的は、そのfenceまでの描画処理を完了待ちするためです。VSync待ちと描画待ちは全く違うものなので、お間違えのないように気をつけてください。

これで、fpsを自由に設定できるようになりましたが、リフレッシュレートを無視して、fpsを自由に設定して大丈夫なのか、ということを話しておきます。

例えば、60Hzのリフレッシュレートのディスプレイに対して、100fps表示したらどうなるのでしょうか? GPUは1秒間に100枚の画像を描画しますが、ディスプレイは60枚しか表示してくれません。つまり、40枚は画面に表示されなくて無駄になります。 でも、意味がないわけではありません。UIの処理やシミュレーションの処理など、CPUの処理が1秒間に100回処理されますので、ボタンを連打するようなゲームでは 反応速度に違いがでるでしょう。無駄な描画をなくしたい場合には、CPUの処理だけ100fpsで回して、描画は60枚だけ描画するという方法もあります。 CPUとGPUのfpsを非同期で動かすためには、処理が複雑になったり、メモリが多く必要になったりしますので、もう少し難易度が高くなります。