ゲーム開発の備忘録

趣味のゲーム開発でのノウハウや、技術的に嵌ったポイントを忘れないように書き記しておくブログです。

1フレーム中にDirectX12の描画とD3D11On12の描画を任意の順序で実行する

はじめに

本題に入る前に、まず、レイヤ分けについての話をします。ゲームを作っていると、通常の3D描画と、UI表示等の2D描画を任意の順序で実行したい場合が出てきます。例えば、ポストエフェクトが良い例で、UIを含めた上からぼかしやモザイクをまとめて掛けるといったことが、ポーズ画面の実装等の場合で必要になるはずです。このような例も含め、一般的にゲームの画面描画は複数のレイヤに分けて実施することが多いでしょう。2Dゲームであっても、任意のエフェクトを別のあるエフェクトよりも必ず上側に表示されるようにしたいといったケースはあり、そうするとレイヤを明確に分けないと管理が煩雑になります。
レイヤ分け自体はどのDirectXのバージョンでも自前での実装が必要となります。私の場合は、エフェクトオブジェクトを格納するlistを要素とするarrayを用意しています。このarrayの各要素が独立したレイヤと表現でき、各要素のlistごとにまとめてエフェクトを描画していくことでレイヤごとの描画を実現しています。以下にサンプルコードを示します。

for (auto& effects : effectLists) {
  for (auto& effect : effects) {
	  effect->draw();
  }
}

さて、ここからが本題です。DirectX11の時点で基本的な2D描画はDirect3D11側で実施することになるので、例え2Dゲームを作る場合であっても2Dと3Dが混在したレイヤ分けが必要になります。DirectX11であれば、これはそこまで難しくありません。Direct3D11、Direct2D、DirectWriteのすべてでレンダリングターゲットを同一のテクスチャにしたうえで通常通りレイヤごとの描画を行い、その後ピクセルシェーダで効果を加えたうえでそのテクスチャをバックバッファに出力すればよいのです。レンダリングターゲットの設定が一回で済み、Direct3D11、Direct2D、DirectWriteの描画は順不同でまとめて実施できるということがポイントです。

これがDirectX12とD3D11On12となると途端に難題になります。なぜなら、Direct3Ð12とD3D11On12は同時に描画できないためです。D3D11On12が内部実装で利用しているCommandListは外部から取得できず非D3D11On12のコンテキストからはCommandを投入できないため、純粋なDirect3D12で利用するCommandListとは独立して扱うことになります。つまり、2D描画用のCommandList、3D描画用のCommandListが独立して存在することになります。そして、1フレーム中で任意の回数、順不同で2D描画、3D描画を実施するにあたっては、それぞれの描画で利用するCommandListをはじめとしたリソースの初期化、フラッシュ等をどのタイミングで実施するべきかという頭の痛い問題がついて回ります。当然のようにWebサイトや書籍や公式のリファレンス実装等に解説がなく、トライ&エラーの繰り返しで何とか解消する羽目になったので、今回はこの問題への対応策について解説します。

描画ループの全体構成

まずは1フレーム中の描画ループをどのような構成にしたらよいかについて示します。描画ループは大きく分けて以下の6つの部位に分けることができます。

① 全体初期化処理
② 3D描画開始
③ 3D描画終了
④ 2D描画開始
⑤ 2D描画終了
⑥ 全体終了処理

このうち、②、③および④、⑤は必ず連続して実行されることになります。それぞれひとまとめにすると以下のようになります。

① 全体初期化処理
② 3D描画
③ 2D描画
④ 全体終了処理

上記の4つの処理のうち、②、③は順不同で複数回繰り返す必要がありますのでループで実装します。具体例を以下で示します。

/// <summary>
/// エフェクトを描画する
/// </summary>
/// <returns></returns>
void EffectManager::draw() const noexcept {
  for (auto& effects : effectLists) {
    graphicsCore->beginDrawWithD3D();
    for (auto& effect : effects) {
      if (effect->getDimension() == EffectBase::Dimension::D3D) {
        effect->draw();
      }
    }
    graphicsCore->endDrawWithD3D();

    graphicsCore->beginDrawWithD2D();
    for (auto& effect : effects) {
      if (effect->getDimension() == EffectBase::Dimension::D2D) {
        effect->draw();
      }
    }
    graphicsCore->endDrawWithD2D();
  }
}

このEffectManager::draw()を呼ぶ前に全体初期化処理を行っており、EffectManager::draw()を呼んだ後に全体終了処理を行っています。また、3D描画開始、3D描画終了、2D描画開始、2D描画終了の各処理をまとめたメソッドを作成して呼んでいるのが分かると思います。そして、各エフェクトに2Dで描画すべきか3Dで描画すべきかを判定させるためのメンバdimensionを持たせているシンプルな設計です。これでレイヤ毎に3D描画、2D描画を実施し、例えば、最上位のレイヤでポストエフェクトを描画することを3D描画側で実行できるわけです。

なお、実際は上記にもう一工夫必要です。CommandListの切替えはオーバーヘッドが高くつくため、3D側のエフェクトが存在しない場合は3D描画開始、3D描画終了を通過させない等といった追加実装が必要です。

さて、後は、最初に挙げた6つの処理のそれぞれで何を実施するべきかという解説をしましょう。

全体初期化処理

Direct3D12のCommandListを用いて以下を順に実施します。

・レンダーターゲットの設定
・バックバッファのリソースバリア設定(PRESENT⇒RENDER_TARGET)
・レンダーターゲットビュー初期化
・デプスステンシルビュー初期化

2D描画実行時にはここで利用しているCommandListをそのまま利用し続けられないため、直後に2D描画が来ても問題ないように画面の初期化結果はこの時点でバックバッファにフラッシュさせる必要があります。
そのため、全体初期化処理の最後にCommandListを実行しますが、GPUの待ち合わせは不要です。

3D描画開始

Direct3D12のCommandListを用いて以下を実施します。もちろん最後はCommandListは実行せず、各エフェクトの描画命令を積み上げられるようにしておきます。

・レンダーターゲットの設定
・ビューポートの設定
・シザー矩形の設定
ディスクリプタヒープの設定

3D描画終了

CommandListのクローズおよび実行のみ実施します。直後にGPUの待ち合わせが必要です。

2D描画開始

ここが本記事のメインコンテンツといっても良いでしょう。2D描画開始では以下を実施します。コードのほうがわかりやすいのでコードを掲載しましょう。

d3d11On12Device->AcquireWrappedResources(wrappedBackBuffer.GetAddressOf(), 1);
d2dDeviceContext->SetTarget(backBufferFor2D.Get());

WrappedResourceを掴んだままDirect3D12側の描画をしようとするとエラーが発生するため、AcquireWrappedResources()およびSetTarget()は全体初期化時ではなく、2D描画開始時に実施しなければならないことに注意して下さい。このエラーが、エラーメッセージの内容と原因が乖離しており、エラーメッセージを基に修正するのは困難で、トライ&エラーで何とかこの結論に達しました。
なお、今回の構成では2D描画開始時にバックバッファの状態がRENDER_TARGETで入ってくることになり、2D描画が完了してもすぐ次の描画に移るためRENDER_TARGETのまま抜ける必要があります。そのため、D3d11On12Device#CreateWrappedResource()の第3引数、第4引数はともにD3D12_RESOURCE_STATE_RENDER_TARGETを指定して作成する必要があります。忘れずに変更しておきましょう。

2D描画終了

以下を実施します。ここもコードを掲載します。

d3d11On12Device->ReleaseWrappedResources(wrappedBackBuffer.GetAddressOf(), 1);
d3d11On12DeviceContext->Flush();

直後にDirect3D12側でGPUの待ち合わせを実施しなくても問題なかったため、おそらくFlush()内部でバックバッファへの出力とGPUの待ち合わせの両方を実施していると思われます。

全体終了処理

Direct3D12側のCommandListを用いてバックバッファのリソースバリアの設定(RENDER_TARGET⇒PRESENT)のみを行い、CommandListを実行します。リソースバリアの設定しかしていないので、GPUの待ち合わせはもちろん不要です。その後、バックバッファのPresentを行います。
このためだけにCommandListの準備を行うのはもったいなく感じますが、このタイミングで実施する以外に方法はないと思われます。もしより良い方法があれば教えてください!

おわりに

今回はとにかく設計を考えるのが大変だったのと、エラーメッセージが役に立たないGPUエラーに苦しめられました。D3D11On12の内部実装を推測しながら対処を打つ必要があり難しかったのですが、理解が深まり良い経験になったとは思います。(誤った推測の上での理解をしていなければよいのですが……。ご指摘があればお待ちしております)