CPUとGPUの動作の流れ
こんにちは!
代表兼プログラマーの川本です。
今回はゲームにおけるCPUとGPUの処理の流れと簡単な仕組みについて記述していこうかと思います。
ゲームにおける一般的な処理の流れ
ゲームを作るうえで最も単純な処理の流れは以下のようになります。
更新処理 → 描画 → GPU待ち
これを繰り返すのが最も基本的なゲームループの流れになります。
学生作品等には特に多い実装になっているかと思います。
ですが実はこの実装にはかなり非効率な部分があり、実際のゲーム開発では上記の形で実装されていることは殆どありません。
何故ならCPUとGPUは別のハードウェアで並列に動作することを前提としたものだからです。
CPUとGPUの動作の流れについて
前項で記載したようにCPUとGPUはそれぞれ別々のプロセッサーです。
それぞれ並列で動作させなければ非効率な処理になってしまいます。
CPUとGPUを特に意識せずに実装した場合の処理の流れは次のように動作します。
- 基本的なゲームループでのCPUとGPUの1フレームの流れ
このように順番に処理が動作するようになっています。
上の図で見るとグレーになっている部分でCPU、GPUがそれぞれ処理をしていない非効率的な動きになっています。
よく見るとCPUとGPUのどちらかが動作しているともう片方が動作しないという流れになっていますね。
これが基本的な流れで実装している製品が殆ど無いと記載した理由です。
ではこの流れを効率化したらどのように出来るのか?というものを考えてみましょう。
- CPUとGPUの流れを並び替えて少し効率的にしてみた例
1つ1つの処理の長さは変えていませんが、順番を変えてグレーの期間が少なくなるように置き換えてみました。
このように描画処理の後にすぐGPU待ちをするのではなく更新処理の裏でGPUを走らせて描画待ちのタイミングをずらしただけです。
これだけで全体の処理の長さが大幅に短くなっていますよね?
実際にこの流れに処理を変えると処理時間は短くなるはずです。
ゲーム開発ではこのような形でGPUとCPUの並列動作が担保されるような流れにして実装しています。
※実際にはもっと複雑ですが…
ではどのように上記のような処理を行っているのかもう少し解説していこうかと思います。
DirectX11でのGPUの並列化の例
ここではDirectX11を例として簡単にGPU並列化の方法を説明していきます。
そもそもGPUとはいつどうやって動くのか?という話から始まります。
CPUからGPUに命令を送る流れ
GPUはCPUと違い、いつ処理をするのか?といった事をC++などのコードで記載することはできません。
※シェーダのように特定のパイプライン内の動作を制御することはできます
どのテクスチャーをどのタイミングで?といった事はゲームの進行によって決まります。
そのため事前にGPU専用のプログラムを作成しておくことは困難です。
ではどのようにGPUを動作させているかというとDirectX11のようにGPUへの命令を作成して転送するライブラリを使用することで、描画コマンドを作成して制御しています。
例えばDirectX11のDeviceContextからDraw関数を呼び出すことでGPUのDraw用の命令コードを命令のリスト上に積みます。
その命令のリストをGPU側に転送することで命令を出しています。
テクスチャーやシェーダのセットなどの設定も命令コードを作成して積んでいき、積まれたコードを順番にGPU側で処理することでGPUを動作させています。
つまりDeviceContextの関数中ではこの命令のリストを作成するという処理をしているのです。
この時の命令のリストをコマンドリスト(描画コマンド)と呼びます。
そしてこの作成したコマンドリストをGPU側で実行するように命令することで初めてGPUが動作します。
DirectX11でVSyncを有効にしている場合はPresentを呼び出したタイミングでGPUに全ての命令を発行して処理待ちするようになっています。
※実際にはDraw関数などを呼び出した際にも積んだコマンドリストをGPUに投げています
なので今回はこのPresentが今回の話の中で重要になっていきます。
CPUとGPUの並列化
GPUの処理を並列で処理して待つにはPresentでGPUを待つ処理をメインスレッド以外で対応する必要があります。
例えばPresent関数を別スレッドで呼び出して処理が終わったらフラグを立てるようにしておくことで更新処理後に前のフレームの描画待ちのチェックをできるようにするだけです。
ここで1点注意する内容として、VertexBufferやConstantBuffer等の毎フレーム更新されるバッファをGPU側で処理される前に上書きしてしまわないようにする必要があります。
例えば1フレーム前の描画が終わっていないのにWorld行列を更新してしまうと描画時に1フレーム後の絵が出てしまうという問題が発生します。
この解消方法としてはダブルバッファリングが有効です。
常に2つのバッファをもって1フレームごとにバッファを切り替えることで1フレーム毎に利用中の情報に干渉しないようにする手法です。
これらの対応を行うことでGPUをCPUの更新処理の裏で走らせるといった並列化を行って最適化を行っていきます。
DirectX11とDirectX12での描画の流れと違いについて
DirectX11と12では大きく実装が異なっているため、処理の流れや制御の範囲が異なります。
DirectX11の流れ
CPUとGPUの流れのところでコマンドリストの作成後にGPUに命令を投げて実行すると記載していました。
ですが実はDirectX11では適宜GPU側に命令を投げることでアイドルタイムを減らす仕組みが内部的に組み込まれています。
- 描画コマンド生成時の例
このようにDrawコマンドなどのGPU側に負荷のかかるコマンドを生成したタイミングでGPU側で先に処理するように命令してくれています。
その為、実はDirectX11では下のようにアイドルタイムが短くなるように設計されています。
DirectX12の流れ
DirectX11ではGPU側の負荷のかかる処理は早めに処理されると記載しましたがDirectX12ではどうなっているのでしょうか?
実はDirectX12ではこの辺りの最適化の仕組みが除外されています。
ではDirectX12は処理が遅くなるのでは?と思うところですが、実は単純に作ると実際に遅くなります。
GPUのアイドルタイムが長くなるので当然と言えば当然なのですが…
では何故、GPUへの事前の命令発行をなくしたかというと、全てプログラマーの制御化に置くことでそれ以上に最適化できる環境を作るためです。
DirectX12ではGPUの処理タイミングやコマンドリストの積む単位などを細かく制御できるようになっています。
※描画コマンドはExecuteCommandListsを呼び出すまで実行されません。
例えば以下のような実装が可能になります。
このようにコマンドの作成処理を並列化することでCPU側の最適化を図ることができるようになります。
今回はモデルAとBのコマンド生成を別のCPUに投げてコマンドを生成していますが、CPUリソースの許す限り増やすことも考えられます。
こういった形で描画時にスレッドを利用することで大幅な最適化も考えられます。
又、変化のないコマンドを初期化時に生成しておいて使いまわすといった使い方も考えられます。
その場合にはそもそもコマンド生成の処理が不要になるので更に大幅な最適化が図れます。
GPU待ちについても専用のイベント制御が用意されているので、処理待ち部分もスレッド化すればGPU処理と更新処理などを並列化可能です。
まとめ
CPUとGPUを効率的に扱うには並列的な思考が必須になってきます。
実際の開発ではコマンドリスト作成中も含めて前のフレームのGPU処理を並列で動作させる等の工夫も行っています。
その場合にはコマンドリストもダブルバッファ化する等のメモリ空間の整合性も取るなど更に難しくなってきます。
描画に必要な知識は見た目を作る知識だけではなくこういったハードウェアの動作や基本的なGPUの仕組みも含めて知っておくべき知識が山盛りなので大変です。
今回は基本的な考え方だけ記載しましたが、描画の処理には他にもメモリ帯域やキャッシュ効率等の多くの要素が絡んできます。
調べるほど奥が深いのでどんどん沼にハマって楽しく(苦しく)なっていきます。
工夫や考えられる最適化の余地が多岐に渡る部分でもあるので興味のある方は是非勉強してみてください!