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

pseudo-instanced drawing in Unity


Unity で大量のオブジェクトを描画するスクリプトを書きました。
https://github.com/i-saint/BatchRenderer
簡単な使用例

いつも通り弾幕やらパーティクルやらを描くのを想定した代物ですが、割と簡単に使えてポータブルな作りになっています。Windows で D3D11, D3D9, OpenGL モードでいずれも動作。Android でも動作を確認しています。
スクリプトの使い方については上記ページを参照していただくとして、この記事ではこのスクリプトを作る過程で得られた (バッド) ノウハウ群を書き残しておこうと思います。過去の記事と内容が被ってる部分が多くありますが、その多くは本記事でより洗練されています。


まずこれを作った背景。
OpenGL や Direct3D では、大量のオブジェクトを描画するにはインスタンシング描画を用いるのが一般的です。Unity にも一応 インスタンシング描画機能は備わっているのですが (Graphics.DrawProcedural())、残念ながらこれは Unity の描画パイプラインに組み込むことができません。
Unity の MeshRenderer は描画リクエストをキューに突っ込み、優先順位に基いて実際の描画処理を行うようになっています。一方、Graphics.DrawProcedural() は呼んだその場で描画処理を行います。なので、描画キューに入っているリクエスト全てが処理される前か後にしか描くことができず、不透明オブジェクトパスで描画、みたいなよくある要求を満たせません。
さらに、surface shader も使えなくなるため、Unity のシェーダと一貫したライティング処理を行うのも難しくなります。おまけに現状 Direct3D11 が使える環境か PS4 でしか使えません。
よって、Unity の描画パイプラインを使いつつポータブルに大量のオブジェクトを描画したい場合、他の手を考える必要があります。BatchRenderer はそういう要求のもとに作られました。


多数のオブジェクトを描画したい場合、一番ストレートな方法は描きたいオブジェクトの数だけ GameObject (MeshRenderer) を用意する、というものですが、まあ、遅くて現実的ではありません。アクティブな GameObject は存在するだけで結構な負担になります。
他に使えそうな手として、Graphics.DrawMesh() という API があります。これは Mesh を描画するリクエストをキューに突っ込む、というもので、つまり MeshRenderer がやってることを GameObject なしで実現できる API になります。
GameObject なしで Unity のレンダリングパイプラインを使う描画処理をやるにはたぶんこの API を使うしかないのですが、単純に描きたいオブジェクトの数だけ DrawMesh() を呼ぶ、というも遅くてやや厳しいです。描画キューを処理するにあたって内部的に複雑な前処理が行われるため、1 つの drawcall が OpenGL や Direct3D の世界よりずっと重くのしかかります。
数千くらいなら個別 Graphics.DrawMesh() でもなんとかなるかもしれませんが、それ以上になるともう一歩踏み込んだ方法が必要になります。

より大量のオブジェクト描画を実現するため、BatchRenderer では擬似インスタンシングを用いています。これは 1 つの Mesh に多数のモデルを格納し、頂点シェーダで各モデルを動かすことで 1 回の Graphics.DrawMesh() で多数のオブジェクトを描く、というものです。
Mesh オブジェクトは 1 つにつき 65000 頂点までしか格納できないため、65000 / モデルの頂点数 が 1 回の Graphics.DrawMesh() で描ける限界になります。 例えば cube の場合、24 頂点なので 2708 個が 1 つの Mesh に収まる数になります。2708 個、頂点の位置も法線も同じ cube がぎっしり重なって格納されているイメージです。後述のモデル ID だけがそれぞれのモデルで異なります。

より詳細に BatchRenderer の内部で行われていることを説明すると、まずユーザーが指定した Mesh のデータをコピーして、上記のような多数のモデルが格納された Mesh を生成します (65000 / モデルの頂点数 分)。この時、Mesh.uv2 にはそのモデルが何番目かという ID を格納します。(元の uv2 は失われますが、あまり使われないので妥当な格納場所と判断)
次に、ユーザーが指定した Material の複製を作ります。1 回の Graphics.DrawMesh() で描ききれない場合、複数回 Graphics.DrawMesh() を呼ぶ必要がありますが、その回数分の Material を必要に応じて生成します。各 Material には、オブジェクトの ID が何番目から始まるか、という情報を付与します。この開始 ID + モデルの ID でオブジェクトの ID を得られるようにするわけです。
このオブジェクト ID を元に頂点シェーダ内でオブジェクトの位置や回転などの情報を取得し、変形処理を行って描画します。
(例えば cube を 10000 個描きたい場合、10000/2708 で 4 回の Graphics.DrawMesh() が必要なため、Material の複製も 4 個作ります。各マテリアルの開始 ID は 0, 2708, 5416, 8124 となります。 ちなみに 10000 以降のモデルは頂点シェーダで vertex.xyz = 0.0 として画面に出ないようにします)


頂点シェーダからオブジェクトの位置などの情報にアクセスできるようにするには、事前にスクリプト側でデータを集めて GPU 側にアップロードしておく必要があります。これがなかなか厄介で、今回の話の主題はここになります。
頂点シェーダからアクセスできる任意長のデータは、テクスチャ (sampler2D) かバッファ (StructureBuffer) の 2 種類になります。

バッファの場合話は単純で、スクリプトから ComputeBuffer オブジェクトを生成し、それを Material.SetBuffer() でアタッチし、、以降 ComputeBuffer.SetData() でデータを更新するだけです。
とても簡単でまあまあ速くて任意の構造 (struct) のデータを格納でき、あとフリー版でも使えるというメリットもあります。しかしながら、現状 Direct3D11 が使える環境か PS4 でしか使えません。
近年はモバイルデバイスもハードウェア的にはこの機能はサポートしており、将来的には今回のような用途にはバッファを使うのが定石になると思われますが、残念ながらポータビリティを考慮すると現状テクスチャを使う方が有力な選択肢になります。

テクスチャの場合 Texture2D …を使いたいところですが、Texture2D はなぜか float や half のフォーマットをサポートしていないため、これらをサポートしている RenderTexture で代用する必要があります (よって、フリー版では使えません)。そして RenderTexture にはなぜか SetPixels() がないため、面倒なことをする必要があります。
RenderTexture に任意のデータを書き込む現実的な方法はたぶん 2 通りで、1 つはプラグインを用いる方法、もう 1 つは Mesh にデータを格納して Graphics.DrawMeshNow() で書き込む方法です。

プラグインを用いる方法。
Texture 一族は GetNativeTexturePtr() という、ネイティブ API (OpenGL や Direct3D) のテクスチャオブジェクト (ID3D11Texture2D* など) を取得するメンバ関数を提供しています。なので、ネイティブ API を用いてテクスチャにデータを書き込むプラグインを作り、スクリプトからテクスチャオブジェクトとデータポインタを渡せば目的を達成できます。
この方法はデータのコピーに mono を一切介さないため速いというメリットがあります。
当然ながら、プラグインはプラットフォームごとに個別の処理を書いてビルドする必要があります。プラットフォームによってはプラグインを組み込むのが大変で、実装難度が高くなります。また、WebPlayer や WebGL (Unity5) だと使えないというポータビリティ面での問題もあります。
また、環境によっては float のテクスチャはサポートしておらず、half のみだったりするので、どちらでも対応できるようにしておいたほうがいいでしょう。

Mesh & 描画による方法。
Mesh.vertices などにデータを格納し、データを書き込むシェーダを用意し、データ用 RenderTexture をレンダーターゲットに設定して Graphics.DrawMeshNow() で描くことで、Mesh に格納したデータを RenderTexture に移すという方法です。
ほとんどのプラットフォームで使えてスクリプトだけで完結するポータビリティが高い方法ですが、まず Mesh にデータを格納するのが遅い上、それを GPU にアップロードして drawcall を発行してそれが完了するのを待たないといけず、他の方法と比べて致命的に遅いという問題もあります。
実装の際の注意点として、プラットフォームによっては Mesh の normals, tangents, colors は内部的に正規化されたり clamp されたりするようです。(少なくとも Android では。ハマりました…) よって、ポータビリティを考えるとデータの輸送には vertices, uv, uv2 しか使えません。その分 Mesh にデータを書き込んで Graphics.DrawMeshNow()、を何度も繰り返すことになります…。

テクスチャを使うアプローチ共通の注意点として、格納できるデータは float/half の羅列に限られるので、任意の構造体を格納できるバッファに比べると柔軟性は落ちます。とはいえ、ビットフラグとかを格納するのでなければあんまり問題にはならないとは思われますが。
また、古い環境では同時に使えるテクスチャの数の上限が大きな障害になります。(Direct3D9 / ShaderModel 3.0 だと 4 つ。4 つ!!)
ちなみに頂点シェーダでテクスチャにアクセスするには tex2Dlod() を用います。

まとめると、任意のデータを GPU に転送してシェーダからアクセスできるようにする方法は大きく以下の 3 通りになると思われます。

1. ComputeBuffer
pros
・実装が非常に楽
・任意のデータ構造を格納可能
・フリー版でも使用可能
cons
・現状 Direct3D11 が使える環境か PS4 でしか使えない

2. RenderTexture にプラグインからデータを書き込む
pros
・速い
cons
・プラットフォーム毎の個別対応が必要
・WebPlayer や WebGL では使えない

3. RenderTexture に Mesh 経由でデータを書き込む
pros
・とてもポータブル
cons
・とても遅い

速度: RenderTexture&プラグイン > ComputeBuffer >>>>>>>> RenderTexture&Mesh
ポータビリティ: RenderTexture&Mesh > RenderTexture&プラグイン > ComputeBuffer
実装容易性: ComputeBuffer > RenderTexture&Mesh > RenderTexture&プラグイン

どれもすごく一長一短で悩ましいです。
BatchRenderer はこの 3 種類を全て実装し、ComputeBuffer か RenderTexture&プラグイン が使えなければ RenderTexture&Mesh にフォールバックするようにしています。


以下余談。
GeometryShader を使うと状況によっては一回の Graphics.DrawMesh() で描ける数をさらに増やせるのですが、今回はそこまでやっていません。理由はいっぱいあって、Unity の GeometryShader は Direct3D11 が使える環境でしか使えないこと (今は他に使える環境があるかもしれませんが、いずれにせよポータビリティは低いです)、surface shader と併用できないこと、ビルボードなどの本当に単純なモデルにしか適用できないこと、Unity の GeometryShader サポートがいまいちやる気が感じられないこと、などです。

また、BatchRenderer ではやっていませんが、応用として Graphics.DrawProcedural() の Unity のレンダリングパイプラインに載せられるバージョンに近いものも実現できます。
まず実際のモデルデータを ComputeBuffer に格納。それとは別に vertices に頂点 ID のみを格納した Mesh を用意。描画の際、頂点シェーダは 頂点 ID をインデックスとして StructuredBuffer から実際のモデルの頂点データを得てそれを出力する、みたいなやり方です。
ComputeShader でモデルを生成してそれを Unity のレンダリングパイプラインに載せて描きたいような場合、現状こういう方法を取る他ないと思われます。

バッドノウハウ以外の何物でもないですが、Direct3D11 世代機能は Unity にはあとづけなのでしょうがない…と納得する他ありません。たぶんきっと将来的に改善されていくことでしょう。