読者です 読者をやめる 読者になる 読者になる

render massive amount of cubes in Unity

MassParticle
パーティクルエンジンを Unity のプラグインとして再実装しています。
まだまだ発展途上ではあるものの、そこそこの物量と速度と使い勝手を両立できる目処が立ってきたので、いずれ Asset Store で公開しようと考えています。ソースに関しては今もこれからも github で公開中です。

実装にあたり、シミュレーションのコアは以前 ISPC で書いたのをそのまま利用。他オブジェクトとのインタラクションは、C# スクリプトからプラグイン側に Collider 情報を渡し、パーティクルデータを unsafe のポインタで返して衝突しているパーティクルから AddForce()、ですぐに実装できたのですが、描画処理が相当なクセモノで苦労させられました。
以下は Unity で大量の cube を描くためにこれまで辿った道筋です。想定しているゴールは、私のマシン (i7 970 3.2 x 6, GTX 660) で 100000 くらいの cube を現実的な速度で描画できるようにする、という感じです。


1. プラグイン内で hardware instancing
Unity はプラグインに描画デバイス (ID3D11Device* など) を提供します。なので、自然とまずはそれを使って描画しようという発想になります。
この方法に関しては公式にいいサンプルが用意されており、それを参考にプラグイン内でシェーダやバッファ類を独自に用意、カメラ情報などは C# スクリプトから毎フレーム送ってもらい DrawIndexedInstanced() で描いてみました。
注意すべき点として、Unity は projection 行列に独自の加工を施しており、それと同じことをプラグイン側でもやる必要があります (該当処理)。そうしないと depth が Unity が描画したものより手前にズレて前後関係がおかしくなります。
MassParticle_DrawInstanced
速度面に限ればおそらくこれが最良の方法で、このシーンで 90000 パーティクルくらいまで 60FPS を保てます。(完全に CPU bound なので、パーティクルが密集しないシーンであればもっともっと数出せます。Intel GPA によると 100000 cube 描画 draw call の所要時間は 2.7ms 程度でした)
しかしこれには致命的な問題があって、Unity の Material を一切使えず、シェーダーも完全に独自に書く必要があります。また、影を出すこともできません。 他の人にも使ってもらいたい場合この前提はさすがに無理があるので、他の方法を考えなければなりません。
というわけで、これ以下は Unity の描画パイプラインを使いつつ 1. に近い速度を出す方法の考察になります。


2. パーティクル群を 1 つの Mesh にまとめて描画
現状 Unity には instancing 描画機能がないので、大量のオブジェクトを一括描画するには工夫が必要です。
最初に検証したのが一番ストレートな方法で、プラグイン側でパーティクル群をまとめて 1 つの頂点データの塊にし、C# 側の Mesh オブジェクトにそれを流し込み、その Mesh を Unity 側で描いてもらう、というものです。Mesh として描くので Unity の Material を使うことができます。
正確には、Mesh は 1 つにつき 65000 頂点までしか格納できないので、65000 / (cube あたりの頂点数 24) ≒ 2700 cube が 1 draw call で描ける限界になります。なので、Mesh を描くだけのオブジェクトを パーティクル数 / 2700 個 生成して描くようにします。
頂点データは C# 側で事前に new Vector3[24*2700] のように確保しておき、それを C++ 側に渡して書いて貰います。
MassParticle_Meshed
で、試してみたところ、遅すぎて使い物になりませんでした。しかも時間を食ってるのはプラグイン側の処理ではなく、C# 側のデータのコピーです。
プラグインから C# の配列にデータを注入し、それを Mesh.(vertex | normal | uv | triangles) にコピーする (これらは property であり、直接実データにアクセスすることができない)、というのを毎フレームやる必要があるのですが、要素数がとても多い上、C# はメモリ保護機能のせいかコピーが超遅いのでひどいことになります。10000 パーティクル足らずで 60FPS を割ってしまいました。


3. geometry shader でプリミティブ生成
C# のコピーが遅いならコピーが必要なデータを最小にしよう、ということで、次は geometry shader を使うアプローチを検証しました。パーティクル 1 個につき 1 頂点だけ出力し、geometry shader でプリミティブを生成して描画する、というものです。これならコピーが必要なデータ量は 2. の方法の 1/24 程度で済み、1 回の draw call で 65000 cube 描けるようになります。
MassParticle_GeometryShader
この方法はそこそこいい感じに動き、60000 パーティクルくらいまで 60 FPS を保ってくれたのですが、やや痛い欠点があります。geometry shader を使うと surface shader が使えなくなるらしいのです。
surface shader は Unity の標準的なシェーディング処理で、所定の手続きを踏んで SurfaceOutput データを出力すればあとは影含む大部分のシェーディング処理をいい感じに仕上げてくれます。surface shader が使えなくなるということは、これらの処理を独自に実装しないといけないということになります。
なので、シェーディング不要な billboard なんかを大量にを出す場合には良さそうですが、きちんとシェーディングされたオブジェクトを出したい場合面倒なことになりそうです。
また、2. よりだいぶんマシになったとはいえこの方法でも C# 側のデータコピーがでかいロスになります。
(ちなみに 100000 billboard 生成して描画した場合の純粋な GPU 所要時間は 1 ms でした。GS は遅いと言われてるのをよく聞きますが、意外と実用性高いんじゃないかと思いました)


4. テクスチャにパーティクル情報を格納
Unity の Texture 一族には GetNativeTexturePtr() なるメンバ関数が用意されており、これを使うと D3D や OpenGL のテクスチャオブジェクト( ID3D11Texture2D* など) を取得できます。テクスチャにダイレクトアクセスできるのであれば、プラグイン側でパーティクルのデータをテクスチャに書くことで C# を一切介さず Unity 側にデータを渡せそうです。
描画には古き悪しき擬似 instancing テクニックを使います。1 つの頂点バッファに同じモデルデータをテクスチャ座標だけ少しずらしつつ連続配置しておき、vertex shader でテクスチャから各インスタンスの位置データを持ってきて頂点を移動させることで hardware instancing を擬似的に実現する、というものです。
本物の hardware instancing と比べると VRAM をバカ食いするのと、GPU によっては vertex shader 内の texture fetch が遅かったりする欠点がありますが、概ねうまく機能します。vertex shader に独自処理を追加する必要はあるものの、それ以外は Unity 側で用意された機能に任せることができるので、負担はそこまで大きくないはずです。
実装の際は、Unity の Texture2D が何故か float 系フォーマットをサポートしておらず、RenderTexture で代用する必要がある点に注意が必要です。また、影を出したり受けたりするには ShadowCaster & ShadowCollector Pass も書いて頂点移動処理をやる必要があります。
MassParticle_PseudoInstancing
今回の例ではパーティクルは 1 つ 48 byte なので、uv 座標を 3 texel 分横にずらしつつ、1 つの Mesh に 2700 cube 分の頂点データを格納しています。(2. と同様 1 draw call 2700 cube の制限があります) あとは位置データ以外も容易に渡せるので、速度に応じて赤熱させたり寿命が尽きかけているものは縮小フェードアウトさせたりしています。
この方法はかなり上手い具合に機能していて、Unity の描画パイプラインに載せつつ 1. にかなり近い速度を出せました。geometry shader との併用もできます。これだけ数出しつつ影を出せるのは感動的です。
今のところこの方法を主軸にやっていこうという方針です。


今回の描画の検証以外に、パーティクルエンジンを pure C# で実装する、などの実験もやったんですが、これらの経験から痛感させられたのが、C# は本当に本当に、絶望的に遅いということでした。幸か不幸か、こういう HPC 的な領域ではローレベルプログラミング能力の需要は永遠になくならない気がしてきます。
あとは compute shader を使うアプローチをまだ全く検証していないので、そこに可能性が残っているかもしれません。
しかしこんなのバッドノウハウ以外の何物でもないので、Unity に instancing 描画が搭載されて抜本的に解決されるのが理想ではあります。


[Edit 2014/12/23] 続編。実は instancing 描画備わっていました。あと compute shader も試してみました。
render massive amount of cubes in Unity (2) - primitive: blog