OpenGLの話 第5回
OpenGLにおけるマルチスレッド 14/02/16 up
今回はOpenGLにおけるマルチスレッドの話をサンプル交えながら行います。
昨今の複数コアCPUで十分なパフォーマンスを発揮するにはマルチスレッドを活用する必要があります。
DirectX11でのマルチスレッド活用も踏まえて解説していきたいと思いますのでよろしくお願いします。
さて、グラフィクスにおけるマルチスレッドとして考えられるものは何でしょう?
私は3つの活用法がまず思い浮かびます。
今回はそのうちの1つをOpenGLで実装しているだけですので、サンプルとしては弱いかもしれませんが、お許しを。
まず1つ目は描画スレッドの活用でしょう。
これはグラフィクスAPI(OpenGLやDirectX)というよりは、それらをラップするライブラリでの実装に起因する問題となると思います。
基本的なゲームの処理は大きく分けるとアップデートと描画に分けることができます。
アップデートはCPUで行うもので、操作によってキャラクタを移動させたり、敵AIを動かしたり、当たり判定をとったり、描画で必要な情報を作成したり(アニメーション処理など)を行います。
描画はGPUで行いますが、GPUに描画するものの情報を送るのはCPUです。
CPUとGPUは別のプロセッサなので、並列動作させることが可能ですし、並列動作させないと十分なパフォーマンスを発揮できません。
私が今まで公開してきたサンプルは1フレーム中の動作が基本的に、アップデート→描画の順番で行われていました。
サンプルにおけるアップデートはパラメータの変更と少々の行列計算程度なので、この順番で処理してもそれほど問題はありません。
しかし、実際のゲームではアップデートはかなり時間がかかります。
例えば、1/60秒を100%とし、アップデートに50%の処理時間がかかるとします。
この時、サンプルのような動作をさせてしまうと、最初の50%の時間ではGPUが動作せず、アップデートが完了して描画処理が走り始めてからGPUが動作することになります。
このような無駄な時間を避けるため、昔のシングルスレッドプログラムでは、描画→アップデートの順番で処理しているものも多かったです。
この場合は描画する情報は1フレーム前の情報となりますが、それを認識できる人間はほとんどいないでしょう。
マルチスレッドが基本の現在においては、そもそもアップデートと描画の処理を別スレッドで処理することがほとんどです。
よくある実装としては、アップデート時に描画スレッドが処理するコマンド(これは自前で定義する)を発行し、描画スレッドは次のフレームでたまったコマンドを処理します。
ゲームエンジンのSDKであるHeliumにもこの仕組みは実装されていますし、カプコンのMT Frameworkもこのような実装を行っています。
実装上の注意点としては、アップデート時に変更するパラメータを描画スレッドが直接参照しないようにする点でしょうか。
例えば、マテリアルカラーをゲーム側が変更する際に単一バッファで管理していると描画スレッド側でもそれを参照してしまうことになります。
この場合、アップデートスレッドでマテリアルカラーを変更している最中に描画スレッドが定数バッファにその情報を書き込んでしまうなどで情報の齟齬が発生します。
Mutexを使うということも可能ではありますが、頻繁に同期をとるようならスレッドを分ける理由がありません。
ダブルバッファ化するとか、自前のコマンドに埋め込むかなどの対応が必要になる点に注意してください。
マルチスレッド活用の2つ目はオブジェクトの読み込みと作成でしょう。
今回のサンプルでやっているのはまさにこれです。
ゲームではテクスチャやモデル、当たり判定データなどのリソースはマルチスレッドを利用して読み込むのが一般的です。
シングルスレッドにしてしまうとゲームがフリーズしたように見えてしまいます。たいていのゲームはフリーズしていないことを示すため、now loading画面を活用していますよね。
DirectXでもOpenGLでも、リソースを読み込んだ後にオブジェクトの生成を行う必要が出てきます。
DirectX11の場合、オブジェクトの生成を行うID3D11Deviceはスレッドセーフです。
なので、リソース読み込みスレッドでそのままID3D11Deviceの各種生成命令(テクスチャ生成、頂点バッファ生成など)を行ってもかまいません。
ただし、ID3D11DeviceContextはスレッドセーフではないため、ステート変更やDrawCallはマルチスレッドで処理できません。
OpenGLではDeviceとDeviceContextのようなオブジェクトは1つにまとまったGraphics Contextとして存在しています。
これは複数作成が可能で、作成後、MakeCurrent()命令で初めて設定したスレッドをオーナースレッドとし、このスレッド以外では呼び出しができなくなります。
しかし、Graphics ContextはDeviceとDeviceContextの複合体のようなものなので、複数のGraphics Contextを利用するということは複数のDeviceを扱うのと同じことになります。
DirectX11でもDeviceが違うと生成したオブジェクトの共有は基本的にできません。これはGLのGraphics Contextも同様です。
そこで出てくるのがShareLists()命令です。
ShareLists()命令は複数のGraphics Contextでオブジェクトを共有することができるようになる便利な命令です。wglの場合はwglShareLists()という命令があります。
wglではwglShareLists()を呼び出す前にwglMakeCurrent(nullptr)を呼んでおく必要があります。共有を行うGraphics ContextはMakeCurrentされてないことが必要なのだそうです。
GLFWの場合はどうすればいいのでしょうか?
GLFWではGLFWwindow構造体がGraphics Contextを保持しています。が、こいつはWindowに関する様々な情報も持っていて、これを作成すると必ずWindowが生成されます。
残念ながら、現段階ではWindowを持たないGraphics Contextを生成することはできないようなので、以下のようにWindowを作成し、そのWindowをHide状態にしてやることで解決します。
g_SubWindow = glfwCreateWindow(1, 1, "Resource Loader", nullptr, window);
if (!g_SubWindow)
{
glfwTerminate();
return -1;
}
glfwHideWindow(g_SubWindow);
glfwCreateWindow()関数の第5引数に共有したいGraphics Contextを持つGLFWwindowを渡してやると内部でShareLists()を呼んでくれます。
Windowのサイズ(第1,2引数)は0を指定すると失敗するので、最低でも1を指定してください。
作成後、すぐにHideしていますが、それでも一瞬表示されてしまうのは仕方ないことのようです。
g_SubWindowの生成自体はメインスレッドで行っても問題ありませんが、glfwMakeContextCurrent()を行うのは使用するスレッド(今回の場合はリソース読み込みスレッド)で行います。
なお、使い終わったら必ずそのスレッドでglfwMakeContextCurrent(nullptr)を呼ぶようにしてください。別スレッドで呼んでも効果はありません。
あと、注意点として、OpenGL ESはマルチスレッドに対応していないそうですので、この方法は使えません。
最近のスマホも複数コアが普通になってきましたし、ESの存在意義には少々疑問を感じますね。
最後のマルチスレッド活用法は、DirectX11のDeferred Contextです。
いわゆるDrawCallは残念ながら重い処理です。オブジェクトが大量に生成されるゲームではDrawCallの回数が増えて描画スレッドのCPU処理がネックになりやすいです。
DrawCallは通常、GPUに送るためのコマンドを生成し、生成終わったらGPUに送るという流れをとります。
GPUはこのコマンドを順次実行するので、特に描画順序が重要な処理(半透明処理とか)の場合は描画順序が前後するのは問題です。
また、DX11もGLも同様ですが、無駄なステート変更を行わないため、ステートキャッシュを内部に持っています。
そのため、DX11ではDeviceContextが非スレッドセーフで、複数スレッドで同時呼び出しするとエラーが発生します。
DX11ではDeviceを生成すると自動的に1つのDeviceContextが生成されます。これはImmediate Contextと呼ばれ、このContextで生成されたコマンドは即時実行されます。
このContextとは別に生成できるのがDeferred Contextです。これは即時実行されないコマンドを生成するためのContextです。
Deferred Contextで生成したコマンドはID3D11CommandListオブジェクトに保存され、これをImmediate Contextで実行すると描画が行われるという仕組みです。
使用の際に気をつけなければならないのはステートキャッシュの状態です。
ステートキャッシュは無駄なコマンドを生成しないための仕組みですが、GPUのステート情報とContextのステート情報に齟齬があるとGPUの処理に問題が発生して、最悪ハングしたりします。
そのため、DX11ではDeferred Contextを使用した場合に、基本的にはContextのキャッシュをクリアします。
例えばレンダーターゲットとかのキャッシュもクリアされるので、レンダーターゲットやビューポートの変更をしない状態だとしても設定し直す必要があります。
まあ、DXはデバッグレイヤーが充実しているので、問題が発生しても何が問題なのか比較的わかりやすくエラーを返してくれます。英語が読めないからわからない、とか言われると困りますが。
OpenGLの場合、DX11のDeferred Contextに当たるものが存在しません。
Display Listというものがあるのですが、現在はDeprecated状態で使用は推奨されませんし、実装によってはすでにサポートされていません。
Radeonはすでにサポートを切っているためか使用できませんでした。
まあ、OpenGLはDirectXよりDrawCallが高速だと言われていますので、DXではDrawCallがネックになるアプリケーションでもGLなら問題ない、という状況になるかもしれません。
しかし、できればDeferred Context的なものの実装をしてほしいところです。調べてると、Display Listの復活はよ、って意見が結構ありました。
ただ、ESはやっぱりサポートしてないらしいので、iOS/Android制作者はあまり関係ないかもですね。
ではサンプルです。
前回のサンプルとほぼ同じですが、テクスチャ読み込みをマルチスレッドで行っています。
すぐに処理するとマルチスレッドで読み込んでいるのがわかりにくいで、リソース読み込みスレッドが起動してから5秒後にファイル読み込みを開始するようにしました。
最初はテクスチャが貼られていない真っ黒な板が表示されますが、リソース読み込みが完了するとテクスチャが貼られます。
次回はフレームバッファや深度バッファをテクスチャとして使用する手段でも実装してDeferred Renderingの準備でもしようかと思います。