レンダリングエンジン設計(2)

レンダリングエンジンの設計(2)
コマンドキュー
描画コマンドをキューイングする、コマンドキューを実装していきます。
C++では、素直にstd::queueを使えばいいと考えるのですが、それは個人的には避けたいところです。
その理由は、動的メモリ確保と解放は意外と遅い という事実があります。
使い方が決められないゲームエンジンではなおさら早いに越したことはありません。
そのため、コマンドキューは可変長でありながら、メモリ確保はなるべくしたくないのです。
しかも、コマンドキューはレンダリングが終われば全て不要です。なので、個別に解放する必要がありません。
また、全コマンドを列挙することになるため、メモリのキャッシュミスもなるべく避けたいです。
まとめると、求められる特性は以下のようになります。
- 可変長であり、n個の要素をpushできる
- push時に、追加のメモリ確保は行わない
- pushする各要素は、任意のサイズとすることが可能
描画コマンド毎に、必要とする属性の種類、サイズが異なるため- 解放時は、全要素を一気に解放可能 => O(1)
解放しても、次フレームでまた利用するため、再利用したい- コマンド保持用のメモリは、連続メモリ上に確保する
LinkedListの様な間接参照はキャッシュ効率の観点から控える
なかなか贅沢な要求ですが、ここはほぼお決まりのパターンがあります。
お決まりなのですが、一般的に何て呼ぶのか英語圏では「Arena Allocator」とでも呼ばれているのでしょうか。
日本語ではあまり参考になる記事が見つけられませんでした。
参考: Arena Allocators:現代ハイパフォーマンス・コンピューティングを支える「物理層」の共通言語
要するに大きなメモリブロックを確保して、先頭から順番に埋めていく方式です。
ただ、このメモリブロックが1つだけだと、確保するサイズに悩むし、もし足りなくなったらどうするの?という問題があるため、このメモリブロックをチャンクとして分割管理し、鎖状に管理します。
各チャンクはシンプルに、どこまで利用済みかをOffsetで管理するだけです。
struct Chunk {
std::unique_ptr<std::byte[]> Data; //コマンドを格納するバッファ
std::size_t Size = 0; //チャンクサイズ
std::size_t Offset = 0; //利用済みサイズ
};アクティブなチャンクの管理方法は色々と考えられますが、チャンク鎖をstd::vectorで管理すると、そのインデックスで管理すればよいため、全メモリ開放はそのインデックスを戻すだけになります。
従って、全解放は、完全にO(1)にする実装が可能です。
void Reset() {
_activeChunkCount = 0;
_currentChunkIndex = InvalidChunkIndex;
}描画コマンドが1万個あろうが、解放はこれだけです。
そして、実際に確保したチャンクのメモリは解放せずに再利用します。どのみち、次フレームでは既存チャンクを再利用できるため、メモリ確保を避けられます。
というのも、C++オブジェクトを正しく初期化及び開放するには、コンストラクタとデストラクタを呼ばなくてはなりません。
従って、上記のコードにするためには、デストラクタが必要のないPOD(Plain Old Data)と呼ばれる型を使う必要があります。
※C++的にはすでにPODという定義は非推奨なので、std::is_trivially_copyable,is_trivially_destructibleを満たす型が現在は正確だと思います。
メモリ管理を自由にできないGC管理下の言語(C#,Java,Python…等)では、少し無理をしないと実装できなかったり、できてもGC管理下のオブジェクトと相性が悪いです。
いまだにC++が強いのはメモリの管理の自由度が圧倒的に大きく、言語仕様としても自然に最適化できるからだと思っています。
コマンドキューの管理
コマンドキューには以下の状態があります。
- 未使用:Free
- コマンド構築中:Recording
- コマンド構築完了(レンダリング待ち):Ready
- レンダリング中:Render
コマンドキューは、その過程に応じて、状態が遷移していき、未使用に戻るまで、コマンドの登録はできません。
従って、以下の図で示すように、結局次の状態更新時には、まだレンダリング中である可能性があり、待ちが発生してしまいます。
次のレンダリング(R2)をタイムラグなく開始するためには、レンダリング(R1)の終了までにコマンドキューの状態を「レンダリング待ち」にしておく必要があります。
まぁ、「コマンドキュー1つじゃ無理ゲーじゃね?」となるのは自然であり、コマンドキューを複数用意する必要があります。
従って、必然的にコマンドキューマネージャーなる管理クラスが必要になります。
このマネージャーの役割が結構重要で、エンジンの性格をつかさどる重要な部分になります。
このマネージャーは、少なくとも外部からの以下の要求に答える必要があります。
- 描画コマンド登録したいから、新しいコマンドキューをくれ!
- レンダリングするから、レンダリング可能なコマンドキューの準備ができたらくれ!
これらの要求に、n個のコマンドキューのそれぞれの状態を見ながら、制御することになります。
コマンドキューの数
あえて明言せずに、n個と記載している理由があります。
ここには、n=2またはn=3というバリエーションが一般的に存在するからです。
まずは結論から言うと、以下のような特性になります。
n=2: レンダリングFPSは少し落ちるが、常に最新に近い世界の状態でレンダリングする
n=3: レンダリングFPS最優先。1世代前の世界の状態でレンダリングされる可能性はある。
という感じです。
ここで書くと誤解されてしまうのですが、ゲームの設定でよくある、「ダブルバッファ」「トリプルバッファ」も同じような特性になります。
ただ、あの設定は、コマンドキューの数の事ではなく、GPUがレンダリングに使用する画像表示用のフレームバッファの数の事なので、これについては機会があったらまた記載します。
では、なぜそのような特性になるのかを、少し掘り下げたいと思います。
※が、そもそもレンダリングキューを構築している時間自体は、かなり小さくなると思うため、ほぼ誤差レベルになる可能性も大です。
<n=2の場合>
世界の更新側のスレッドが今から描画コマンドを構築する際の状態は以下の5ケースのいずれかになります。
| ケース | S1 | S2 | S3 | S4 | S5 |
|---|---|---|---|---|---|
| 状態1 | Free★ | Ready | Render | Ready(Old)★ | Render |
| 状態2 | Free | Free★ | Free★ | Ready(New) | Ready★ |
★がついたコマンドキューを即時に解放して、描画コマンドの構築を始めます。
ケース4と5は、結局レンダリングには使用されなかったことになりますが、世界を止めるわけにはいかないので、こうあるべきです。
世界の更新スレッド側は、どのケースでもすぐに描画コマンドの構築を始められるため、世界が止まることはありません。
ケースS4の場合は、どのみちどちらか捨てるのならば、選択的に古い方を解放するのがベストですね。
では、レンダリングスレッド側からみた状態はどうなるでしょうか。
今まさにレンダリングを開始しようとした時、状態は以下の5ケースのいずれかです。
| ケース | R1 | R2 | R3 | R4 | R5 |
|---|---|---|---|---|---|
| 状態1 | Free | Recording | Ready★ | Ready★ | Ready(Old) => Free |
| 状態2 | Free | Free | Free | Recording | Ready(New)★ |
★がついたコマンドキューを使って、レンダリングを開始することが可能です。
世界の更新側と違うのは、★が付いていないケースが存在することです。
ケースR1とR2はレンダリングできるコマンドキューが存在しないため、準備できるまで待つ必要があります。
これがFPSが少し犠牲になる理由になります。
上記例でいえば、S5でReadyのコマンドを破棄して再構築した直後は、R2の状態になり、両者のタイミングによっては待機になります。
Readyのコマンドキューを解放した結果、一時的にReadyなキューが無くなってしまうからです。
一般的に描画コマンドの構築より、レンダリングの方が時間がかかるため、S1とR1の状態は初期状態としてしか起こりにくく、R2が遅延の原因となります。
これを改善したものがn=3のケースになります。
<n=3の場合>
世界の更新側
| ケース | S1 | S2 | S3 | S4 | S5 | S6 | S7 |
|---|---|---|---|---|---|---|---|
| 状態1 | Free★ | Ready | Ready(Old) | Ready(Old)★ | Render | Render | Render |
| 状態2 | Free | Free★ | Ready(New) | Ready(Old) | Free★ | Ready | Ready(Old)★ |
| 状態3 | Free | Free | Free★ | Ready(New) | Free | Free★ | Ready(New) |
こっちは変わりなく、常に★が存在するため、世界が中断することなく処理が可能です。
ポイントは、n=2の時に問題となった「一時的にReadyなキューが無くなってしまう」問題は、n=3の場合は、S6,S7となり、Readyなキューを残すことが可能になる点です。
レンダリングスレッド側
| ケース | R1 | R2 | R3 | R4 | R5 | R6 | R7 |
|---|---|---|---|---|---|---|---|
| 状態1 | Free | Recording | Recording | Recording | Ready★ | Ready(Old)=> Free | Ready(Old) => Free |
| 状態2 | Free | Free | Ready★ | Ready(Old) => Free | Free | Ready(New)★ | Ready(Old) => Free |
| 状態3 | Free | Free | Free | Ready(New)★ | Free | Free | Ready(New)★ |
n=2の時に問題になったケースは、S6,S7からS3への遷移となり、レンダリングが待ちになりません。
但し、少し待てば最新のReadyが得られたことを考慮すると、最悪状態で1世代前の状態を使ってレンダリングすることになります。
描画コマンドを捨てることの是非
これまでの説明で、使わない描画コマンドは捨てるという想定でしたが、本当にそれでいいのでしょうか。
ここで扱う描画が世界の状態を可視化するだけのものであるならば、捨てても問題ありませんが、実際にはレンダリングエンジンの描画コマンドは、画面に表示するためだけではなく、リソースロード、オフスクリーン描画、GPU計算など、ゲーム進行や後続処理に関わる用途にも使われるのが現実です。
例えば、以下のような用途があるでしょうか。
- 画像のロード等のコマンド
- ゲーム内監視カメラに映る、カメラ内映像の描画コマンド
- 鏡面反射用の描画コマンド
- GPUを使った物理シミュレーション
などなど…
コマンドによって、また、ゲームデザインによって、絶対にスキップされてはいけないものか、スキップできないけど優先度を落としてもいいコマンドかどうか、等の条件が発生するため、これまでの説明の通り、ただ単に捨てるという実装では、現実的には困ることになります。
そのため、コマンドの重要度ごとに、コマンドバッファを複数用意する実装にする必要があります。
基本的に各コマンドがどういう重要度を持つのか?は、同じ描画コマンドでも状況によって変わるため、各描画コマンドの描画対象であるバッファの特性に基づいて、決まるものになると思います。
これについては、今後APIを決定する過程で、その都度触れたいと思います。
そして深い沼へ…
実は、このコマンドキューとレンダリング側の消費ってかなり自由度があって、もう軽いタスクスケジューラー作ってるみたいになってくるんですよ…
実際はバッファによって要求される更新頻度が異なったりもするし、エンジン自体の個性にもつながってくるんだけど、考え出すと沼なんですよ…
ということで、将来的に改修する必要性がでてくるでしょうけど、今の時点ではある程度割り切って進めていきます。
CommandQueueManagerのインターフェース
これまでの内容で、CommandQueueManagerは以下のようなインターフェースになりました。
もちろん、現時点では…ですよ。
class CommandQueueManager {
public:
//描画コマンド構築用
CommandQueue& AcquireDroppable();
CommandQueue* TryAcquireOptional();
CommandQueue* TryAcquireDeferable();
CommandQueue& AcquireRequired();
void Submit(CommandQueue& queue);
//レンダリング用
CommandQueue* TryAcquireForRender();
CommandQueue* TryAcquireOptionalForRender();
void ReleaseConsumed(CommandQueue& queue);
//管理用
void Reset();
}描画コマンド構築用としては、今のところ描画先の特性に応じて、4つのドメインに分けていて、それぞれコマンドキューがわかれています。
Droppable
捨てられても良く、溜まった場合は最新にのみ価値がある描画コマンド
次のレンダーフレームで、必ず1つは消費される。Optional
捨てられても良く、溜まった場合は最新にのみ価値がある描画コマンド
次のレンダーフレームで、時間に余裕があれば消費される。余裕がなければ、消費頻度が落ちる。
※監視カメラ内映像等Deferable
捨ててはいけない描画コマンド
次のレンダーフレームで必ず全て消費されるが、キューイングできる数には限度があるため、コマンド投入側で必要であれば再投入が必要。
※リソースロード等Required
世界の状態更新と同期して実行しなければならない描画コマンド
捨てられることもなく、キューに空きがなければ空くまで待つため、空きがなければおのずと世界が中断する
マルチスレッド同期
コマンドキューマネージャーは、世界更新スレッドと、レンダリングスレッドを橋渡しし、双方で利用されるため、同期が必要です。
これらは、少しマルチスレッドプログラミングの特有の知識が必要になりますので、詳しい解説は他にも山のように存在しますので詳しくはあまり触れません…
Windowsに限定してもプロセス内部でのロックは色々な種類があります。
- スピンロック
- CriticalSection
- SRWLock
- Mutex
等々…
普通はロックの種類なんて気にならないのですが、1msが響くゲームエンジンだと、パフォーマンスも少し気になるところです。
ロックの時間そのものよりも、競合して待ちになった後に、どれくらいの時間で復帰できるのか?
もちろん、これはOSのスケジューラーも関係してくるので、結構深い話になってきます…
まぁ早い段階での最適化はしたくないので、今の時点ではC++の抽象層にお任せして「std::lock_guard」で同期しておくことにします。
MSVCだとSRWLockを使って実装されております。