DirectXの話‎ > ‎

DirectXの話 第143回

DirectX12事始め その1  15/08/23 up


今年はじめての更新はついに登場した DirectX12 の事始めです。
すでに多くの人が実装、及び解説を行っているので今更感はだいぶ強いのですが、私がなんとなくこうだろうと考えた DirectX11 の実装も交えて解説していこうと思います。
あくまでも自分が考えたものなので間違っている部分はあるかと思います。
その場合はメールなりTwitterなりで教えていただけるとありがたいです。

今回は頂点カラーのみの三角形を描画し、定数バッファで毎フレーム移動させるという極めて簡単なサンプルを元に解説を行います。
コードを読みながらの方がわかりやすいのではないかと思いますので、最初にサンプルを公開しておきます。
なお、記事自体は数回に分ける予定です。


今回はデバイスの作成からコマンド関連の作成までです。
コマンドという概念はコンシューマゲーム機をいじってる人には当たり前の概念ではあるのですが、DirectXやOpenGLのようなAPIしかいじってこなかった人にとってはわかりにくいのではないかと思います。
今回のメイントピックはこの辺になります。

まず、DirectX12の簡単な紹介から。まあ、手垢はつきまくってますが。

これまでのDirectXのメジャーアップデートではほぼ必ず大きな機能追加がありました。
DX7ではハードウェアTnLが、DX8ではプログラマブルシェーダが、DX9ではHLSLが、DX10ではジオメトリシェーダが、DX11ではコンピュートシェーダやテッセレータが…という感じです。
しかし、DX12では大きなハードウェアの新機能はあまりありません。
いくつか追加されている機能はあるものの、ほとんどがDX11.3のマイナーアップデートでも追加されています。

では、何が変わったのかというと、APIやオブジェクトの種類が大きく変わりました
特に変わったのは、今までDirectXが安全のために面倒を見ていた部分が面倒を見なくなった点でしょう。

わかりやすいところで言えばオブジェクトの寿命です。
例えばテクスチャオブジェクトはID3D11Texture2Dなどのインターフェースを持ったオブジェクトが生成されていました。
このオブジェクトはRelease()メソッドを呼ぶことで解放されていましたが、そもそもCOMオブジェクトであったため参照カウンタを持っていました。
つまり、このオブジェクトを参照しているシステムが1つでも存在しているのであれば解放は行われませんでした。
生成されたテクスチャオブジェクトをピクセルシェーダのt0レジスタに設定した状態でRelease()メソッドを呼んだとすると、t0レジスタが参照しているためにテクスチャ管理者が解放しても生きている状態になっていました。
そして、ピクセルシェーダのt0レジスタに別のテクスチャが設定されるとこのテクスチャを参照しているシステムがなくなるので実際に破棄されるという流れです。

これがDX12になると寿命管理はしてくれません。
GPUが使用中のリソースだとしても破棄されれば消滅します。
つまり、DX12ではリソースの寿命管理はAPIを叩くプログラマの責任となったわけです。

そもそもこれはネガティブな話なのかというと、大抵の場合そこまでネガティブな話ではありません。
元々読み込まれたリソースはそのアプリのリソース管理者によって管理されるのが常です。
そしてグラフィクスAPIが使用するオブジェクトも一緒に管理される場合がほとんどなので、APIによる寿命管理はほとんどの場合で二重管理となってしまい効率的ではありません。
二重管理によって安全性は高くなっているかもしれませんが、リソース管理が十分しっかりしているなら二重管理する必要はありません。

今回のサンプルではDX12の各種オブジェクトを生ポインタで管理するようにしています。
参考にしたMSのサンプルではComPtrを利用していますが、わざと生ポインタに変更しています。
大きな理由はないのですが、生ポインタでも処理できるという点を強調したいからだったりします。
決してこういうコードしか書けないからではありません。いや、ほんとに。マジで。信じて。

では、実際のコードを見て行きましょう。
今回のコードは Main.cpp にほとんど集約されているのでそちらの行番号だけで解説を行います。
ウィンドウの作成は今までと変わりませんので解説はしません。
ウィンドウが作成されたあとに行うのはデバイスの作成です。
DX11では比較的簡単だったデバイスの作成ですが、DX12ではデバッグレイヤーを使用するのに一手間必要になっています。

・104行目
#ifdef _DEBUG
    // デバッグバージョンではデバッグレイヤーを有効化する
    {
        ID3D12Debug* debugController;
        if (SUCCEEDED(D3D12GetDebugInterface(IID_PPV_ARGS(&debugController))))
        {
            debugController->EnableDebugLayer();
            debugController->Release();
        }
    }
#endif

    // ファクトリを作成
    IDXGIFactory4* factory;
    CreateDXGIFactory1(IID_PPV_ARGS(&factory));

    // デバイスの作成
    // TODO: あとでWarpデバイスも選択できるようにする
    HRESULT hr;
    if (false)
    {
        IDXGIAdapter* warp;
        factory->EnumWarpAdapter(IID_PPV_ARGS(&warp));

        if (warp)
        {
            hr = D3D12CreateDevice(warp, D3D_FEATURE_LEVEL_11_0, IID_PPV_ARGS(&g_pDevice));
            warp->Release();
        }
    }
    else
    {
        hr = D3D12CreateDevice(nullptr, D3D_FEATURE_LEVEL_11_0, IID_PPV_ARGS(&g_pDevice));
    }
    assert(SUCCEEDED(hr));

今回はサボっているのでWarpデバイスの作成はなし。あとでちゃんと実装する方向で。
DX11ではデバイス作成時にフラグとしてデバッグレイヤーを使うかどうかを指定していましたが、DX12では ID3D12Debug というインターフェースを取得してデバッグレイヤーを有効化する EnableDebugLayer() メソッドを呼ぶ必要があります。
有効化したら Release() メソッドで破棄することを忘れずに。スマートポインタを使っておけばこのような苦労は減らせます。
デバイスの生成本体は解説する必要はないかと。

次は今回のメイントピックであるコマンドの解説を行います。
DX12ではコマンド関連のオブジェクトとしてコマンドキュー、コマンドアロケータ、コマンドリストを作成することになります。

コマンドキューとは名前のとおり、コマンドの待ち行列です。
このコマンドキューに登録されたコマンドリストが順番に処理されていきます。
これに対してコマンドリストは一連の流れの描画命令をコマンドとして積み込まれたバッファリストです。
そしてコマンドリストに積み込むバッファを確保するオブジェクトがコマンドアロケータとなります。

DX11やOpenGLではあまりコマンドというものを意識することはなかったと思います。
DX11なら DeviceContext にシェーダやブレンド等の設定を行って、頂点バッファとインデックスバッファを設定したら DrawCall を行う。これで描画は完了でした。
しかし、内部ではコマンドという形をとっていたのは間違いありません。
なぜなら、DrawCall は最終的にGPUが処理するものなので、コマンド化しておかないと色々と困るのです。

DrawCall という命令を発行するのはCPUですが、GPUの処理はCPUとは並列に行われます。いわばマルチスレッドプログラミングと同じです。
もしもコマンドを使わなかった場合のシステムの処理はイメージとしてこんな感じになるんじゃないでしょうか?


GPUがティーポットを描画している最中にロボットを描画してくれと言われてもGPUは処理できません。
しかもティーポット描画中にシェーダがAからBに切り替わっています。
もしもGPUがティーポットの描画中にシェーダの情報をチェックしに行くようなことがあったらいつの間にかシェーダBで描画されてしまうでしょう。

そうならないためにもコマンドが存在しています。DX11のコマンドを使用した処理イメージは以下のようになるかと思います。


CPU、GPU、DeviceContextは時間軸なんですが、本来コマンドキューは時間軸にはないのでこのイメージは間違っているのですが、感覚が伝わってくれることを祈ります。
つまり、DeviceContext に対して DrawCall が行われると、現在の DeviceContext の状態(シェーダやステート)を集めて ”その設定で描画をしろ” というコマンドがコマンドキューに積まれます。
GPUはコマンドキューにコマンドが積まれるとそのコマンドを1つ貰ってきて処理を行います。
CPUはGPUが処理を行っている間にもDeviceContextに対して描画命令を発行しますが、発行された命令はやはりコマンドキューに積まれます。
GPUは今現在の描画が終わり次第、コマンドキューに積まれた描画命令を順番に処理していく、という感じです。

実際にはもっといろいろ複雑なんですが、とりあえずはこんな感じと考えてもらえればOKです。

では、DX12ではどうなるのかというと、多分内部的な処理はDX11と変わらないのですがプログラマが見える部分での処理が結構変わっています。


やっぱり微妙に空間的な意味合いのバッファやキューと時間軸を意味するCPUとGPUが混在しているおかげでわかりにくくなってる感は否めませんが、伝わるといいなぁ…
DX11ではDeviceContextに対して描画命令を発行すると、DeviceContextが保持する内部ステートを元に自動的にコマンドを生成、キューに積んでくれたわけです。

DX12ではコマンドの積み込みはコマンドリストに対して行います。
コマンドリストに対して ”この設定を行え”、”このモデルを描画しろ” と言った命令はすべてコマンドとしてバッファに記録されます。
このバッファはコマンドアロケータから取得してきますが、図には描ききれなかったので描いていません。コマンドアロケータがmallocしてる、と想像するといいかも。
そして1フレーム分の描画がコマンドリストに積まれたら、今度はそのコマンドリストをコマンドキューに投げます。
コマンドキューにコマンドリストが積まれるとGPUがそのコマンドリストを取得して、リスト内のコマンド(指示書みたいなもの)を読んで描画を行うわけです。

図にはコマンドリストに ”A” と書かれていますが、これはコマンドリストを複数持つことが出来ることを意味しています。
DX11でも DeferredContext というものがあって、これによって複数スレッドで描画の発行を行うことが出来ましたが、DX12ではコマンドリストをスレッド分だけ作成することで複数スレッドでのDrawCallを行うことが可能となっています。
これもおいおいサンプル作って解説していきたいと思います。

では各種コードを見ていきます。
まずはコマンドキューから。

・140行目
    // コマンドキューを作成する
    // 最初なので直接コマンドキュー
    {
        D3D12_COMMAND_QUEUE_DESC desc = {};
        desc.Flags = D3D12_COMMAND_QUEUE_FLAG_NONE;  // GPUタイムアウトが有効
        desc.Type = D3D12_COMMAND_LIST_TYPE_DIRECT;  // 直接コマンドキュー

        hr = g_pDevice->CreateCommandQueue(&desc, IID_PPV_ARGS(&g_pCommandQueue));
        assert(SUCCEEDED(hr));
    }

コマンドキューの作成で重要なのは D3D12_COMMAND_QUEUE_DESC::Type メンバです。
通常は D3D12_COMMAND_LIST_TYPE_DIRECT を設定しますが、別のパイプラインを使用する際には D3D12_COMMAND_LIST_TYPE_COMPUTED3D12_COMMAND_LIST_TYPE_COPY を設定します。

実はDX12ではコマンドパイプラインが3つ存在しています。
1つはこれまでどおりのグラフィクスパイプラインで、描画全般を処理します。コンピュートシェーダもここで処理可能です。
これに対してコンピュートパイプラインコピーパイプラインというのが存在しています。
私もまだ詳しくないのですが、前者はコンピュートシェーダ専用のパイプラインで、後者はメモリコピー専用のパイプラインです。
これらのパイプラインはGPU上で並列に処理されます。特に前者は非同期コンピュートなどとも呼ばれており、PS4でも使用可能な機能です。

使い方としてはいろいろあるのですが、PS4の『The Order : 1886』では Tiled Forward Rendering のライトリスト作成に使用されています。
コンピュートパイプラインでライトリストを作成している最中にグラフィクスパイプラインではシャドウマップを描画し、双方が終わったところで同期をとってモデルをライティングして描画する、という流れのようです。

D3D12_COMMAND_QUEUE_DESC::Flags は特に何もなければ D3D12_COMMAND_QUEUE_FLAG_NONE を設定しましょう。
他に設定できるフラグとして D3D12_COMMAND_QUEUE_FLAG_DISABLE_GPU_TIMEOUT があるのですが、これはGPUに問題が発生して止まってしまった場合などのタイムアウト処理を無効化するフラグです。
タイムアウト処理を無効化してしまうと問題が発生した際にヘタするとOSを巻き込んで停止してしまう可能性もあります。
タイムアウト処理を無効化する理由としては、GPUで相当重い処理を行って見た目停止しているような状態になってしまっても最終的に結果が帰ってくることが保証されているような場合、でしょうか?
ゲーム開発ではありえませんが、スーパーコンピュータ的にアホみたいに重い処理をさせようとするとこういう状態になるかもしれません。
まあ、ゲーム開発をやっている状態ではタイムアウト処理は常に有効にしておくべきでしょう。

次にコマンドアロケータ。

・203行目
    // コマンドアロケータを作成
    hr = g_pDevice->CreateCommandAllocator(D3D12_COMMAND_LIST_TYPE_DIRECT, IID_PPV_ARGS(&g_pCommandAllocator));
    assert(SUCCEEDED(hr));

こちらも D3D12_COMMAND_LIST_TYPE_DIRECT を指定します。
ここで設定するタイプとコマンドリスト生成時のタイプは合わせる必要がありますが、コマンドキューが DIRECT を設定している場合、アロケータとリストには BUNDLE を設定することも出来ます。
これについてもまた後にサンプル作って解説したいと思います。

最後はコマンドリスト。

・237行目
    // コマンドリストを作成する
    {
        hr = g_pDevice->CreateCommandList(0, D3D12_COMMAND_LIST_TYPE_DIRECT, g_pCommandAllocator, nullptr, IID_PPV_ARGS(&g_pCommandList));
        assert(SUCCEEDED(hr));

        // コマンドリストを一旦クローズしておく
        // ループ先頭がコマンドリストクローズ状態として処理されているため?
        g_pCommandList->Close();
    }

コマンドリストを作成する際には作成済みのコマンドアロケータが必要になります。
コマンドバッファを確保するためにアロケータを使うので、アロケータが存在しないとコマンドリストが正常に動作してくれませんから。

コマンドリスト作成後に一旦コマンドリストを Close() します。
この命令はコマンドリストへの積み込みが終了したことを意味する命令です。
通常はコマンドを積み込み終わったあとに呼び出しますが、作成時には一旦呼んでおいたほうがいいようです。
コメントにもありますが、ゲームループ先頭でコマンドリストはクローズ状態から始まっていることが前提で処理されているためと思われます。

では、実際にコマンドリストへのコマンド積み込み、クローズ、キューへのコマンド発行を見て行きましょう。

・526行目
    // コマンドアロケータをリセット
    hr = g_pCommandAllocator->Reset();
    assert(SUCCEEDED(hr));

    // コマンドリストをリセット
    hr = g_pCommandList->Reset(g_pCommandAllocator, g_pPipelineState);
    assert(SUCCEEDED(hr));

    ...

    // ビューポートとシザーボックスを設定
    g_pCommandList->RSSetViewports(1, &g_viewport);
    g_pCommandList->RSSetScissorRects(1, &g_scissorRect);

・588行目
    // コマンドリストをクローズする
    g_pCommandList->Close();

・595行目
    // コマンドリストを実行する
    ID3D12CommandList* ppCommandLists[] = { g_pCommandList };
    g_pCommandQueue->ExecuteCommandLists(_countof(ppCommandLists), ppCommandLists);

526行目は描画開始部分です。
まずはコマンドアロケータとコマンドリストをリセットします。
このリセット処理は、コマンドアロケータが確保したバッファをすべて解放し、コマンドリストも中身を空っぽにするという処理です。
コマンドリストはリセット時に次に使用するコマンドアロケータとデフォルトのパイプラインステートを設定しますが、パイプラインステートは nullptr でも構いません。
コマンドアロケータはコマンドリスト作成時に設定してるじゃない?と思われると思いますが、別のアロケータを使いたい場合には有効なのでしょう。
そういう状況があるかと言われるとなさそうですが…

ビューポートとシザーボックスの設定は比較的簡単なのでコマンド積み込みの実例として取り上げています。
サンプルコードはもちろんもっといろいろなコマンドを積み込んでいます。

588行目はコマンド積み込みが完了したのでクローズしてるだけですね。

595行目は積み込みが完了したコマンドリストをコマンドキューに実行させています。
ExecuteCommandLists() メソッドは複数のコマンドリストをキューに積み込む命令で、頭から順番に処理されるのでコマンドリストの積み込み順序には気をつけてください。

これで描画できるの?というとそんなに簡単じゃないのがDX12です。
記事がだいぶ長くなってしまったので今回はここまで。
次回はフェンスやバリア周りを解説して、とりあえず画面クリアまでは解説しようと思います。
コメント