GPUはいつ描画するのか?

2009/3/26追記: Presentの動作詳細と、タイミング図を修正

描画に関する不思議

XNAフレームワークを使って描画する場合、以下のような不思議な現象を経験することが少なからずあると思います。

  • DrawIndexedPrimitiveを呼び出すのに掛かった時間を測定したら、1,000ポリゴンのモデルと、1万ポリゴンのモデルで同じ時間だったんだけど、どうして?
  • DynamicVertexBuffer.SetDataを呼び出したら、遅くなったんだけど、どうして?
  • DrawIndexedPrimitivesを複数回呼び出してポリゴン描画すると遅くなった、どうして?

特にWindowsのGDI APIを使った経験のある人には、これらの現象はよけい不思議に感じると思います。これらの描画に関する不思議の殆どはGPUがどうやって描画しているのかを知ることによって解決できます。

シングルコアCPU + GPU = マルチコア

マルチコアを複数のプログラムが同時実行できる環境とすれば、あなたが使っているPCに搭載されているのがシングルコアCPUでも、XNA フレームワークが動作するのであればマルチコアなPCとも言えます。また最近のGPUが搭載されているのであれば、見方によってはコア数が数十個あるメニイコア環境であるとも言えます。Xbox 360の場合、3つのCPUコア、48個のコアがGPUにあるとも言えるでしょう。

マルチコア、マルチスレッドプログラミングは非常に難しいものですが、XNAフレームワークを使って描画する場合、殆どの場合はマルチコア環境で動作しているとは気づきません。ですが、メモリ的、パフォーマンス的な制約によりマルチコア環境であることを完全に隠蔽することはできません。このマルチコア環境の氷山の一角が冒頭の不思議な現象として現れることになります。

描画メソッド = コマンドバッファ生成メソッド

DrawIndexedPrimitivesのヘルプを見ると「ジオメトリプリミティブをレンダリングします。」となっているので、ポリゴンを描画するには、このメソッドを使うことでできそうです。確かに、このメソッドを呼び出すことで画面にポリゴンが表示されるので(パラメーターが正しければ)、描画するメソッドであるということが確認できます。

ですが、実際にはDrawIndexedPrimitivesを初めとする描画に関する殆どのメソッド内では描画自体は行われません。代わりにGPUが理解できるコマンドバッファと言われるバッファに描画命令を追加するだけです。下図は描画関連のメソッドがどのようにコマンドバッファに命令を追加しているのかを表したものです。

HowGPUWorks01

生成されたコマンドバッファはGPUによって直接読み込まれ、実行されることによって実際に描画されます。

実際に描画されるタイミング

下図は理想的なゲームのアップデート状態でのコマンドバッファに書かれた命令がGPUによって実行されるタイミングを表したものです。Windowsの場合はドライバによって振る舞いが微妙に違いますが、少なくともXbox 360では、通常は下図のようになっています。左側にあるCPUの部分はCPUで処理されるものを表しています。Update,DrawはGameクラス内のUpdate、Drawで、PresentはGameクラス内のEndDrawメソッド内で呼ばれるものです。右側はGPUが描画するタイミングを表しています。

 HowGPUWorks02

図を見て判るように、フレーム0でCPUによって作られたコマンドバッファ内の描画命令はPresentが呼ばれた時にGPUで実際に描画がはじまります。実際の描画は約1フレーム遅れるわけですが、最初のフレーム以降ではCPUとGPUが同時に処理をすることによって高パフォーマンスを実現しています。

Present内では描画タイミングに関する以下の3つの処理を順に行います。

  • 前フレームのGPU描画が終わるまで待つ
  • コマンドバッファをGPUに発行する
  • 垂直帰線期間を待ってバックバッファの切り替え(Flip)をする

上図のような理想的な状態では、GPUの描画を待つことなくコマンドバッファの発行、垂直帰線期間待ちをするようになっています。

まとめると、

  • 描画メソッドはコマンドバッファに描画命令を追加するだけ
  • GPUは1フレーム前に追加された描画命令を実行する
  • CPUとGPUは同時処理をするが、処理するフレームは1フレームずれている

と、なります。

謎解き

以上の事を踏まえると、冒頭の不思議の謎解きができます。

  • DrawIndexedPrimitiveを呼び出すのに掛かった時間を測定したら、1,000ポリゴンのモデルと、1万ポリゴンのモデルで同じ時間だったんだけど、どうして?

DrawIndexedPrimitivesはコマンドバッファに命令を追加するだけなので、測定しているのは描画に掛かった時間ではなく、CPUが命令を追加するのに掛かった時間です。1,000ポリゴンと1万ポリゴンはパラメーターの違いだけなので、どちらも同じ時間になります。

  • DynamicVertexBuffer.SetDataを呼び出したら、遅くなったんだけど、どうして?

GPUが1フレーム前を描画している、つまりGPUが頂点バッファを参照している時にCPUが同じ頂点バッファを変更してしまうと描画結果がおかしくなってしまいます。この現象を防ぐ為にSetDataメソッドを呼んだときに、同じ頂点バッファをGPUが使用している場合はGPUが描画し終えるまでCPUは待たされることになります。

そこで必要になってくるのが、DynamicVertexBufferで追加されているSetData<T>メソッドです。このメソッドにSetDataOptions.NoOverwriteを指定することで、アプリケーション側がCPUが書き込む領域とGPUが読み込む領域が重複しないように処理しているということを示し、同じVertexBufferがGPUで使われている場合でも前フレームの描画終了を待つことなくデータを書き込むことができます。

myxbox-3 myxbox-2

上のスクリーンショットはGameFestのサンプルをXbox 360上で実行させたものです。左側はSetDataOptions.NoOverwirteを指定したもので、SetDataに0.06ms掛かっています。右側はSetDataOptions.Noneを指定したもので、SetDataに12.77ms掛かっています。右側の赤いバーがフレーム一杯まで伸びていることから、CPUが前フレームのGPU描画処理が終了するまで待っているということが判ります。

  • DrawIndexedPrimitivesを複数回呼び出してポリゴン描画すると遅くなった、どうして?

コマンドバッファに間違った命令が追加された場合、正しい描画ができないのはもちろん、場合によってはGPU自体がフリーズしてしまいます。こうした事が起こらないように描画メソッド内では様々なパラメーターチェックやその時点でのレンダーステートの整合性などもチェックしつつコマンドバッファに命令を追加しています。それ以外にもユーザーモードからカーネルモードへの移行など、時間の掛かる処理が行われます。

これらはCPUで処理されるので、むやみに描画メソッドを呼び出すとCPU処理時間が余計に掛かってしまいます。ですから、100ポリゴンを描画する場合はポリゴン1つをDrawIndexedPrimitivesを100回呼びたして描画するよりも、100ポリゴンを1つのDrawIndexedPrimitivesで描画する方が効率的です。

マテリアルバッチは、こういった無駄なCPU処理時間を回避する手法のひとつです。