この記事
開発途中の自作ゲームプログラムで描画処理を専用スレッドに分けてみた。さらに2つのスレッド間で描画データを受け渡すためにトリプルバッファを導入したので、その内容を大まかに説明する。ただしとりあえず動いてはいるが性能は無頓着な段階。使い物になるかはまだ不明。
追記:後でシングルバッファのハンドルクラスを作った。その際にトリプルバッファについてもコードの無駄が見えてきたため記事とコードをあちこち書き直した。
不満点1:ゲーム更新処理と描画処理を分けて扱いたい
特にGUI部品を大量に実装し始めたときに感じるのがゲームロジック処理とOpenGL処理が混在することの面倒さ。メッシュ1つ、画像1枚使うのにもGPU転送済みかどうか、デバイスがロスト済みで再転送が必要だったりしないかを毎回気にしなければならない。それらを考えずに素直にプログラムが書けるようにしたい。
解決策:バッファ内包ハンドルクラス
描画系リソース表現クラスを用意する。中身はGPU転送済みリソースIDとメインメモリ上バッファの2種類。ゲームロジックではメインメモリ上バッファのみを読み書きするだけ。そのバッファを基に1フレーム分の処理の最後にGPU転送や描画をまとめて反映する。
追記:関連記事を用意した。
不満点2:OpenGLのスレッド固定制約
OpenGLはマルチスレッド動作時の関数呼び出しに制約がある。初期化関数を呼び出したスレッドが内部的に登録され、以降はそのスレッドからのみOpenGL系関数呼び出しが許可される。間違えて別スレッドから呼ぶコードを書いてもコンパイルエラーは出ないことから、この制約を守りつつマルチスレッディングを導入するのは手間がかかる。一方で2022年現在CPUは2コア以上あるのが一般的。マルチスレッディングが使えるなら使いたい。
解決策:トリプルバッファ内蔵ハンドルクラス
OpenGLリソース毎にバッファ付きハンドルクラスを用意する。バッファはトリプルバッファ構成にし、複数のハンドルを一斉に切り替えることで複数スレッド間でのデータ受け渡し時間を短く済ませる。
実装例
ハンドルクラスの例
テクスチャハンドルクラス、トリプルバッファ対応版の実装例:
public class TextureHandle
{
// ゲーム更新スレッド側の最新データ
public Image32Buffer GameLogicBuffer;
// 0番バッファ
public Image32Buffer SourceBuffer0;
// 1番バッファ
public Image32Buffer SourceBuffer1;
// 2番バッファ
public Image32Buffer SourceBuffer2;
// 描画スレッド側の受付済みデータ
public TextureId GpuAccepted;
// バッファリビジョンおよびリソース破棄要求フラグ。
public readonly TripleBufferRevision Revisions;
...
}
0~2番のバッファは受け渡し用。イミュータブルパターン的に参照のみを受け渡し、取得済みのバッファはnull代入することでリークを防ぐ想定。安全のためにこうしたが受け渡しのたびにnewが発生するのはよろしくなさそう。
ここでは載せていないがその他のリソースも同じようにバッファ付きハンドルを用意する。さらにレンダラークラスも対応が必要。OpenGL発行IDを指定する代わりにハンドル参照を渡せるようにする。
リビジョン番号管理クラスの例
リビジョン番号管理やリソース破棄要求フラグはどのリソースでも共通なのでクラス化した。これらもトリプルバッファによる受け渡しのため3組ある。
public class TripleBufferRevision
{
// バッファ切り替え情報
public readonly TripleBufferSwitcher SharedProgress;
// 0番
public long SourceBufferRevision0;
public bool IsDispositionRequested0;
// 1番
public long SourceBufferRevision1;
public bool IsDispositionRequested1;
// 2番
public long SourceBufferRevision2;
public bool IsDispositionRequested2;
// GPU受付済みのリビジョン
public long GpuAcceptedSourceBufferRevision;
...
}
2つのスレッドはそれぞれが一斉に使用中バッファ番号を切り替える。これを実現するために切り替え用のインスタンスはプログラム中で唯一のものを定義して共有した。
public class TripleBufferSwitcher
{
public readonly object LockObj = new object();
public TripleIndex IndexSwitcher;
public TripleBufferSwitcher()
{
this.IndexSwitcher = new TripleIndex();
this.IndexSwitcher.Init();
}
public bool NextOrStaySenderRevision()
{
lock (LockObj)
{
if (this.IndexSwitcher.TrySwapSenderBuffer(out _))
{
return true;
}
return false;
}
}
public bool AcceptNewRevision()
{
lock (LockObj)
{
return this.IndexSwitcher.TrySwapReceiverBuffer(out _);
}
}
}
public struct TripleIndex
{
public byte IndexSenderOperating;
public byte IndexDelivering;
public byte IndexReceiverOperating;
public byte IsDeliveringActive;
public void Init()
{
this.IndexSenderOperating = 0;
this.IndexDelivering = 1;
this.IndexReceiverOperating = 2;
this.IsDeliveringActive = 0;
}
/// <summary>
/// 送信バッファと受け渡しバッファの入れ替えを試みる。
/// 受け渡しバッファが使用中なら入れ替えに失敗する。
/// </summary>
/// <param name="outNewIndex">送信バッファの新たなインデックス。</param>
/// <returns>入れ替えたならtrue、そうでなければfalse.</returns>
public bool TrySwapSenderBuffer(out byte outNewIndex)
{
// 受け渡しバッファにまだ前回分が残っているなら
if (this.IsDeliveringActive != 0)
{
// 入れ替えない。
outNewIndex = this.IndexSenderOperating;
return false;
}
// 前回の送信分が受け取り済みなら
else
{
// 入れ替える。
byte tmp = this.IndexDelivering;
this.IndexDelivering = this.IndexSenderOperating;
this.IndexSenderOperating = tmp;
outNewIndex = this.IndexSenderOperating;
this.IsDeliveringActive = 1;
return true;
}
}
/// <summary>
/// 受信バッファと受け渡しバッファの入れ替えを試みる。
/// 受け渡しバッファが未使用状態なら入れ替えに失敗する。
/// </summary>
/// <param name="outNewIndex">受信バッファの新たなインデックス。</param>
/// <returns>入れ替えたならtrue、そうでなければfalse.</returns>
public bool TrySwapReceiverBuffer(out byte outNewIndex)
{
// 受け渡しバッファにまだ前回分が残っているなら
if (this.IsDeliveringActive != 0)
{
// 入れ替える。
byte tmp = this.IndexDelivering;
this.IndexDelivering = this.IndexReceiverOperating;
this.IndexReceiverOperating = tmp;
outNewIndex = this.IndexReceiverOperating;
this.IsDeliveringActive = 0;
return true;
}
// 受け渡しバッファがすでに受け取り済みのものなら
else
{
// 入れ替えない。
outNewIndex = this.IndexReceiverOperating;
return false;
}
}
}
データ受け渡しの流れ
1、送信側スレッドは自身に割り当てられたバッファを更新する。更新した場合はリビジョン番号をインクリメントする。ハンドルを破棄する場合は破棄フラグをセットする。
2、送信側スレッドは自身の都合の良いタイミングでバッファ切り替えを試みる。もし空きバッファがあればそちらに切り替える。空きバッファが無ければ現在のバッファをそのまま使う。
3、受信側スレッドは自身の都合の良いタイミングでバッファ切り替えを試みる。もしデータ入りバッファがあればそちらに切り替える。データ入りバッファが無ければ現在のバッファのままにする。
4、受信側スレッドは新たなバッファに切り替わった場合に各ハンドルのバッファ側リビジョンと受付済みリビジョンを比較する。バッファ側が新しければそれを受け入れてGPU転送する。受付済み側が新しければ単にバッファを無視する。
5、受信側スレッドは受け付け済みリソースやそれを参照するレンダラーを用いて描画する。
6、受信側スレッドは各ハンドルについて破棄要求フラグが立っているか確認、GPUからリソース解放してからハンドルを破棄する。
ハンドル自体の受け渡し
新規作成したハンドル群を描画スレッド側に渡す必要がある。この受け渡しもトリプルバッファにより実現できる。
今後の課題
- 仮想的な描画リソースのハンドルクラス追加。例えばワールド行列を持つカメラなんかが要りそう。
- 現状実装では生成したハンドルインスタンスを引数経由で集めてバッファ切り替え直前に描画スレッド側に渡している。引数経由よりシングルトン経由で渡す方が楽かもしれない。シングルトン導入は大げさすぎると感じてやっていなかったが、描画用スレッドを独立させた時点で手遅れに思えてきた。
- ビデオデバイスがロストした時のリソース自動再転送。フルスクリーン表示を使い始めたら必要になるはず。再転送用にGPU受付済みデータのバッファが必要になるかもしれない。
- バッファ3つ~5つ分余計にメモリを食うのが気になる。圧縮して持つとか、差分だけ各バッファに入れるなどで減らせそう。
描画スレッド分離とトリプルバッファ導入を試しに実装してみたが、これで本当によくなっているのか計測してみて判断が必要。