投稿日:2024/03/19

描画負荷との戦いの歴史と武器の進化

   

おはようございます。
エンジニアの yamamoto-mo です。

相変わらず描画に関する個人リハビリを継続しており、
昨今の描画処理事情を漁っていたのですが、
中々に凄い流れが加速してるんだな…と思ったので、
それをブログネタにしようかと思います。


描画負荷との戦いの始まり

実際の現場でゲーム制作に携わっていると、
必ずと言っていいほど最適化の話が挙がります。
最適化というのは、文字通り「ゲームの挙動を最適にしていく」ですね。
これをしないと、ゲームの挙動が遅すぎてまともに遊べません。

最適化が必要な対象として、
大きく分けると CPU GPU の二つに分けられますが、
まずは CPU の最適化が課題に挙がりやすいかなと思います。

そして CPU の最適化の話が挙がると、
こちらも必ずと言っていいほど描画の負荷軽減の話が挙がります。
さらに描画の負荷軽減の話が挙がると、
やはり必ずと言っていいほど Draw Call の削減という話に進みます。

Draw Call というのは描画する為に必要な一連の処理を指しています。
ご存じの通りゲーム中に「物」は沢山登場し、
ゲーム内容によっては数として数千、数万にのぼります

その為、素直に「物を描画する」となると、
1フレームの間に Draw Call を数千、数万回も行う必要が出てくるんですね。

そもそも Draw Call 自体、それなりに負荷があります。
その上で、それだけの回数分処理してしまうと、
当然ゲームはまともに動かなくなってしまいます。

「出来るだけ Draw Call の回数が少なくなる様に工夫する」
これはゲームを作る上で避けて通れません。


戦う為の武器

これまでのゲーム開発史の中で、
Draw Call を削減する様々な工夫が生まれてきました。
例えば下記のような工夫です。

・カリング
・バッチング
・インスタンシング

一つ一つ細かく説明していくと、とても終わらないので、
ひとまずあっさりと紹介しておきます。
(細かい解説は個別でまたいずれ…)

カリング

「画面に映らない物は Draw Call しない」
という工夫です。
カリングにはいくつか種類があります。

名前 内容
フラスタムカリング 視野角外の物は Draw Call しない
ポータルカリング (主に室内などで)別の部屋につながるドア部分が視野角外なら、
その部屋内の物は Draw Call しない
オクルージョンカリング 別の物の後ろに隠れている物は Draw Call しない

「何かの後ろに隠れている」の検知には
「オクルーダー&オクルーディー」「HiZ」「OcclusionQuery」
などを利用した方法が挙げられます

 

バッチング

「複数の『物』の頂点情報を一つのバッファに纏めてしまい、そのバッファを指定して一度の Draw Call で全て描画する」
という工夫です。
同じマテリアルを使う数千の物を描画する必要があるとして、
それぞれ別々の頂点バッファだと、数千回の Draw Call が必要ですが、
描画前に全てを一つのバッファに合体させて「一つの大きな『物』」として扱える様になれば、
一度の Draw Call で全部描画可能になります

インスタンシング

「『物 = インスタンス』とし、インスタンス専用の Draw Call 一回で、指定した個数分のインスタンスを一気に描画する」
という工夫です。
「同じ『物』を使いまわして、設定を変えて複数個一度に描画する」といった方が分かりやすいですかね。
複数を一度に描画するという点ではバッチングに似ていますが、
バッファを合体させて「一つの大きな物」として扱う様な事はしない為、別物になります。


これらを組み合わせて利用して Draw Call 回数を削減する事で、
CPU の描画負荷は下げられています。
昨今のゲームでも当たり前の様に利用されていますし、
耳にされる機会も多いのではないかなと思います。


さらなる武器を求めて

これらを利用して CPU の描画負荷をある程度下げる事は出来たのですが、
「これで完璧だ!」とはならないんですね
ゲームでは描画以外にも様々な処理が行われています。

その為、更に下げられるなら、もっと下げたい…となってくる訳です。
そこで次に検討されたのが「CPU でやってる事を GPU に任せられないか?」という工夫です。

GPU は膨大な並列数で同時に単純計算を行う事に長けています。
その為、カリングなどの計算は GPU の方が適任なんですね。
GPU に任せられれば CPU が処理せずに済むので、
CPU の負荷が下がります。

勿論 GPU の負荷が上がる事になりますが、それでも CPU より得意分野なので、
GPU をうまく使って処理した方が、全体の最適化に繋がりやすい事になります。

そこで、
「カリングなどの計算は GPU で行い、CPU でその計算結果を利用して Draw Call する」
という工夫が生まれました。

これを Draw Indirect (インダイレクト描画)と呼びます。
このころから「GPU 駆動レンダリング」という言葉が一般化し始めたのではないかなと思います。

ここからは CPU の負荷だけでなく、
GPU の負荷にも注目していく事になります。


新たなる強力な武器

Draw Indirect はインスタンシングと組み合わせて効果を発揮します。
インスタンシングは一度に多くの物を描画出来る仕組みですが、
素直に利用するとカリングと相性がよくありません。

インスタンシングで 100 個の物を一度に描画した時、
画面に写っているのが 10 個しかない場合、
残りの 90 個は何も行われていないように見えて、
実は GPU では 90 個分の不要な部分も処理されてしまうなど色々無駄が発生しています。
そういった無駄を Draw Indirect で回避する事ができるんですね。

雑な一例ではありますが、軽く説明すると、
処理としては下記のような流れになります。
(インスタンシングの描画経験が無ければピンと気にくいかもしれません)

事前準備

・各インスタンスの表示座標や大きさ等の情報が入っている「インスタンス配列」を用意する
・描画するインスタンス数などを保持する「バッファ A」 を用意する
・描画するインスタンス情報が入っている配列の添え字リストとして「バッファ B」を用意する
・それぞれ GPU で利用できるように設定しておく

コンピュートシェーダーによるカリング計算 ( GPU カリング )

・「インスタンス配列」とカメラ情報等からカリング計算する
・描画するインスタンスの総数を「バッファ A」に書き込む
・描画するインスタンス情報の添え字を「バッファ B」に追加する

カリング計算後のインスタンシング

・インスタンス専用の Draw Call で「バッファ A」を引数に指定する
・シェーダーで、インスタンス ID と「バッファ B」と「インスタンス配列」を組み合わせて情報を取得し頂点処理する

インスタンシングで個数を指定する際に、
描画するインスタンス総数を保持している「バッファ A」を指定する事で、
無駄なインスタンスが描画されずに済みます。

描画するべきインスタンスも、
「バッファ B」と「インスタンス配列」から把握する事が出来る為、
意図しないインスタンスが描画されてしまう事もありません。

「 CPU の負荷を軽減しつつ、描画時に GPU でも無駄な処理を行わせない」が実現できるという事ですね。


武器の進化

まだここで終わりません。
今回の対応で「インスタンス単位で無駄なく描画する」という事が出来ましたが、
「高精細な単一インスタンスを無駄なく描画する」という事は出来ません
そこで、更に新しい工夫が登場しました。
それが Multi-Draw Indirect です。

Multi-Draw Indirect は文字通り複数の Draw Indirect を一度に行いますが、
この仕組みを上手く利用する事で、
単一インスタンスでもポリゴン単位でカリングする事が出来るようになります。

例えば高精細なインスタンスを描画する際、
・まずポリゴン毎に分割し
・1 ポリゴン毎にカリングして描画を行う様に Draw Indirect を割り当て
・一度の Multi-Draw Indirect で全部描画

という手順を踏む事で、一回の Draw Call で単一インスタンスでありながら、
ポリゴン単位のカリングが可能になるんですね。

とはいえ、実際に分割する際はポリゴン単位ではなく、
一定数の頂点データが集まったクラスターという単位で分割され、
クラスター単位でカリングし、
画面に映るクラスターのみを描画するのが一般的で、
この形が昨今の主流となっていると思われます。


さらなる武器の進化

ここから更に GPU 駆動の流れは加速していきます。
Directx12 世代では新たに Mesh Shader というシェーダーが登場しました

レンダリングパイプラインにはこれまで色々な機能が追加されてきました。
ジオメトリシェーダー、テッセレーションなどにより色々な事が出来るようになりましたが、
反面とにかく複雑になっています。

コンピュートシェーダーと組み合わせる工夫も一般化してきている事から、
現状を整理し、シンプルなレンダリングパイプラインとして再構築される中で Mesh Shader は生まれました。

Mesh Shader
「一つのモデルを Meshlet というポリゴン群に分割し、Meshlet 単位で描画する事で全体を描画する」
という働きを行います。

小さく分割し、無駄を省くという点では、
Multi-Draw Indirect と似ているといえそうですかね。

Mesh Shader同じく新たに登場した Amplification Shader というシェーダーと組み合わせて使う事で、
更なる負荷軽減を発揮します。

Meshlet のカリングを Amplification Shader で計算し、
結果を引数にして MeshletDraw (Dispatch) Call をシェーダー内から続けて呼び出します。
いわば GPU から次の Draw Call を直接呼び出している事になります。

これまでだと、カリングなどを GPU で計算をしていても、
その結果を利用して Draw Call を行うのは CPU だったので、
CPU と GPU の処理の行き来を挟む必要がありました

ところがシェーダーから直接 Draw Call を呼べると、その行き来が無くなります
行き来が必要無いという事は、 GPU の無駄な待機時間を無くす事に繋がってきます

対応しているハードのシェア的に、まだ一般的に使われているとは言いにくいのですが、
今後この Mesh Shader がゲーム開発でも標準化していく可能性は高いだろうと考えています。


止まらない武器の進化

まだ終わりません。
この「Draw Call を GPU から続けて行う」という点を更に加速させたのが Work Graphs です。
Work Graphs Draw Call などの命令を次々に結んだ GPU 処理フローを作成し実行する機能です。

CPU は最早「フローを開始する」を呼び出すだけで、
GPU が自律的に描画命令を処理していきます。
究極の GPU 駆動ですね。
CPU で Draw Call が少なくなる上に、GPU でも待機時間を大きく短縮できるので、
最適化の観点で非常に重要になってくると思います。

去年発表され、以降は研究を続けていた認識でしたが、
つい先日 Microsoft が Agility SDK に載せたというニュースがあった為、
思いの他すぐ使われていく事になるかもしれません。
各 GPU メーカーとしても重要視している機能との事です。

他にも DirectSR という単語と合わせて調べて頂ければ、昨今の描画に関する最新の情報が色々漁れると思います。


最後に

ざっと挙げてきましたが…
文章だけでは無理がありますね…

・カリング
・Draw Indirect
・Multi Draw Indirect
・Mesh Shader
・Work Graphs

この辺りは、そのうち図解やサンプルも合わせて、
個別にブログネタにしようと思います。

昨今の描画に関して色々漁ってみて思いましたが…やっぱり進化が早いですね
技術の進化に置いて行かれないよう、
日々の研究開発は怠らない様にしないといけないですね。