スキニングを使ってインスタンシングのような大量描画

画面写真をクリックするとWebGLビルドに飛びます。

こんにちは。技術部平山です。

今回はスキニングを使って同じものをたくさん出す処理について書きます。

上の画面写真では立方体がたくさん(2000個くらい)出てますが、 シーンにSkinnedMeshRendererが一つしかなく、DrawCallも基本(影やZプリパスは別)一回です。 また、GameObjectもシーンに6つしかありません。数が増えてもGameObjectを増やさずに済みます。

ソースコードはサンプルの形でgithubに置いてあります。 本体はSkinnedInstancingRenderer.cs ですが、大しことはしていないしカスタマイズが必要でしょうから、 たぶんあまり役には立ちません。

たくさん描く方法

破片を散らす、草を生やす、等々、ほとんど同じものを違う場所にたくさん出したくなることはよくあります。 方法は一つではありません。

  • MAYAやBlender上で、そもそもたくさん複製して配置まで済ませておく
    • 何の制御もいらないので楽だが、メモリを食い、一個一個を独立に動かすことができない。
  • MeshRendererをつけたGameObjectを単純にたくさん置く
    • 何の工夫も必要ないので楽。
    • dynamic batchingが有効であればUnityがうまくまとめてくれるかもしれないが、CPU負荷がかかる。
    • GameObjectやTransformの処理負荷がCPU側にかかる。
  • GPUインスタンシング
    • 例えば100頂点の石をたくさん置く場合、頂点バッファを100頂点ごとにループさせつつ、ループの度に違う頂点データを与える、ということができる機能。
    • メモリが小さく、頂点単位の計算はGPUで完結するので速い。
    • GLES3以上が必須。
    • シェーダの対応が必要。ただし標準の対応済みシェーダであればマテリアルのスイッチをいじるだけ。
    • Unity的にお手軽に利用する場合、GameObjectやTransformの処理負荷は残る。
  • 1ボーンスキニングをインスタンシング代わりにする
    • 頂点を複製するのでメモリを食い、初期化にも時間を食う。
    • GPUインスタンシングよりも頂点シェーダの負荷が大きい。
    • GLES2で動く。
    • Unityの場合シェーダの対応が不要。
    • GameObjectやTransformを使わないで書けるのでCPU負荷を削れる。

普通に考えて、新し目の機械だけ相手にするならGPUインスタンシングが最強でしょう。 メモリ効率、CPU負荷、GPU負荷、の全てで最強ですし、物ごとに色やUVを変えられるなど、より大きな自由度があります。 しかし、Android Dashboard を見ると、2019年9月現在、まだGLES2の機械が20%も残っており、 より広い範囲のお客さんに遊んでもらいたいのであれば、切り捨てるのは惜しい印象です。 また、自作シェーダをインスタンシングに対応させるにはそれなりに知識と手間も必要でしょう。

一方、性能が問題でないか、大した数でない(数百以下)ならば、 単純にGameObjectをたくさん置けば何の工夫もいりません。 ただ、シーンに前もって1000も2000もGameObjectを置いておくと邪魔くさいので、 プレハブをInstantiateして作るでしょう。初期化による処理スパイクは気になります。

今回紹介する手法は昔の手法ですが、 古い機械まで拾いつつ、それなりな性能を得たい、ということであれば、 まだ使える局面もあるかもしれません。

やり方

用意するもの

用意するものは以下です。

  • SkinnedMeshRenderer
  • たくさん描きたいメッシュ
    • サンプルでは標準のキューブを見えない所に置いて、実行時にMeshFilter.sharedMeshで抜いて使っている
  • 好きなマテリアル

SkinnedMeshRendererは描画の本体です。 スキニングと言えば人体や動物などに使われ、滑らかに補間を行うための技術ですが、 ボーン数を1にすると補間が行われなくなり、 単純に「頂点ごとに別の行列で動かす」という用途に使うことができます。

たくさん入ったメッシュの生成

Meshクラスをnewして、元のメッシュの頂点を複製しながら詰めます。 元のメッシュが24頂点(例えばキューブ)で、100個描画するのであれば、 2400頂点のメッシュを作ることになります。

var mesh = new Mesh();
var origVn = originalMesh.vertexCount;
var vertices = new Vector3[count * origVn];
var normals = new Vector3[count * origVn];
var uvs = new Vector2[count * origVn];
var weights = new BoneWeight[count * origVn];
var poses = new Matrix4x4[count];

origVnが元の頂点数で、これに個数countを掛けたサイズの、 位置、法線、UV、BoneWeightの配列を作ります。 BoneWeightは「この頂点は、何番の行列で変換したものを何%混ぜて作るか」を表す データで、例えば「0番を20%、2番を40%、5番を40%」のようなことを書くデータです。 今回は行列を1個しか使わない(つまり補間しない)ので、 「24番を100%使う」というような簡単な指定になります。 最後のposesは行列の配列で、これは物の数だけ存在します。

では中身を詰めましょう。

var indicesSrc = originalMesh.triangles;
var origIn = indicesSrc.Length;
var verticesSrc = originalMesh.vertices;
var normalsSrc = originalMesh.normals;
var uvsSrc = originalMesh.uv;
for (int i = 0; i < count; i++)
{
    poses[i] = Matrix4x4.identity;
    System.Array.Copy(verticesSrc, 0, vertices, i * origVn, origVn);
    System.Array.Copy(normalsSrc, 0, normals, i * origVn, origVn);
    System.Array.Copy(uvsSrc, 0, uvs, i * origVn, origVn);
    for (int j = 0; j < origIn; j++)
    {
        indices[(i * origIn) + j] = indicesSrc[j] + (origVn * i);
    }
    for (int j = 0; j < origVn; j++)
    {
        weights[(i * origVn) + j].boneIndex0 = i;
        weights[(i * origVn) + j].weight0 = 1f;
    }
}

System.Array.Copy で位置、法線、UVを詰め、 インデクスは元のインデクスをズラしながら書き込みます。 最初の物は0から99番の頂点を使い、次の物は100から199番の頂点を使い、 ということであれば、物の番号(i)が進む度に100づつ値を足せばいいわけです。

最後にBoneWeightに詰めます。boneIndex0に使う行列の番号、 つまり物の番号であるiを入れ、weight0は1にしておきます。

ここの処理は頂点数に比例するので、それなりに重い処理です。 表示開始時に多少スパイクして画面が止まってもオーケーならその時でいいですが、 そうでなければ画面が黒いうちにやっておくのが良いでしょう。 あるいは、以前の記事で紹介した手法を使って、 この計算で作ったMeshをシリアライズしてアセットにしてしまう、 という手もあります(ファイル容量が増えるので、おすすめはしません)。

そして、Meshに諸々を設定します。

mesh.bindposes = poses;
mesh.vertices = vertices;
mesh.normals = normals;
mesh.uv = uvs;
mesh.triangles = indices;
mesh.boneWeights = weights;

bindposes、vertices、normals、uv、triangles、boneWeightsを セットします。これでMeshの初期設定は終わりです。 今後、毎フレームbindposesは差し換えますが、 vertices,normals,uv,triangles,boneWeightsはもう触りません。 そのため、物が増えてもCPU負荷はさして増えないわけです。

SkinnedMeshRendererの準備

次にSkinnedMeshRendererの設定をします。

skinnedMeshRenderer.sharedMesh = mesh;
var transforms = new Transform[count];
for (int i = 0; i < count; i++)
{
    transforms[i] = gameObject.transform;
}
skinnedMeshRenderer.bones = transforms;

sharedMeshにさっき作ったMeshを指定します。 さらに、SkinnedMeshRenderer.bones に、今回は「全部同じTransformが入った配列」を渡します。 普通はそれぞれのボーン(肘とか肩とか腰とか)に相当する別々の Transformを渡すのですが、今回は全部根本のTransformをつっこんでおきます。 計算は、SkinnedMeshRenderer.bonesに差したTransformから作った行列と、 Mesh.bindposes に差した行列の掛け算で行われるので、 bindposesの方を自力で差し換えて動かせばTransformは全部一緒でもやりたいことはできるわけです。 これによって、物の数だけGameObjectとTransformを用意する必要がなくなります。

毎フレームやること

あとは、Mesh.bindposesにつっこむ行列を毎フレーム差し換えて動かします。 Mesh.bindposesへの代入は毎度コピーが走るでしょうから、 若干GC的に気にはなりますね。 Matrix4x4は64バイトで、このサンプルのように2048個あれば、 毎フレーム128kBのGCAllocが発生することになります。 もしこれが問題になるようであれば、Transformを物の数だけ用意する方が良いのかもしれません。

サンプルのSkinnedInstancingRendererでは、 BeginUpdatePoses()という関数が中に持っているMatrix4x4の配列を返し、 ユーザはここに好きに書き込んで、 その後EndUpdatePoses()を呼ぶとMesh.bindposesが更新されて動く、 という感じのインターフェイスにしています。位置、向き、スケールがわかっていれば、 Matrix4x4.SetTRS() で行列を生成できるので、行列演算の詳細を知る必要はありません。 サンプルでは物理シミュレーションをしてクォータニオンを積分しながらクルクル回しています。

性能

MacBook Pro 2018(13インチ)のエディタ実行にて測定を行いました。

f:id:hirasho0:20190917105823p:plainf:id:hirasho0:20190917105820p:plainf:id:hirasho0:20190917105826p:plain

順に、単にGameObjectたくさん、その上でGPUインスタンシング有効、そして今回のスキニング、です。 雑に見て、4.5ms、3ms、2msといったところでしょうか。

単にGameObjectを置きまくったものが遅いのは当然ですが、 思ったほどには遅くもない気がします。 GameObjectやTransformがたくさんあっても、それだけならそんなに遅くはないのでしょう。 DrawCallの回数は5000回とかになっていますし、 描画処理スレッドの負荷は他よりも明らかに高いのですが、 それでも全く使い物にならないというほどでもありません。 おそらくですが、下層がMetalだからでしょう。 下がGLだったらもっと悲惨なことになる気はします。 PCならまだしも、スマホのGL実装の出来は全くわかりませんから、 3桁を大きく超える数を出すなら、多少の工夫はしておいた方が無難かなと思えます。

さて、2番目は、この状態でマテリアルのenable GPU instancingを有効にしたものです。 DrawCallが20回以下にまで激減し、描画処理スレッドの負荷は大きく減ります。 しかし、予想していたほどではありません。 結局、全部のGameObject、Transform、そしてMeshRendererを見て まとめる処理は必要なはずで、それなりの負荷は残ってしまうのでしょう。 もしGameObjectやTransformなしで、何をまとめて描くかを前もって 決めておければ最速になるはずです。

最後が今回の手法で、一応この条件では最速となりました。 GameObjectやTransformをいじる処理負荷がなく、 何をまとめて描画するかは私が手動で決めているので、余計な負荷がないのです。

一応他のスレッドも含めて見てみましょうか。

f:id:hirasho0:20190917105814p:plainf:id:hirasho0:20190917105817p:plain

1つ目がGameObjectをただ置いただけのもの、2つ目が今回のスキニングです。 メインスレッドと描画処理スレッド(RenderThread)両方でCamera.Render() を比べてみましょう。1つ目はCamera.Renderという文字列が読めるくらいに(3ms以上)時間がかかっていますが、 2つ目は字が入らないくらいの幅(1ms以下)しかありません。 大半の時間はWaitForTargetFPSで寝ています。

スキニングについて

Unityはスキニングを描画の前に済ませてしまう方法を採用しています。 影描画や、事前のZバッファ書き込みなどで、 一つのモデルを複数回描くことがありますが、 その度に頂点シェーダでスキニング計算をするのは無駄ですから、 事前にスキニング計算だけを済ませてしまった方が合計計算量が減ります。 また、スキニングをするかしないかでシェーダが別になってしまうと、 マテリアルも別になってしまって厄介ですが、 スキニングが事前に別の経路で行われれば、マテリアルには影響が出ず、 扱いやすくなります。

GLES2では、CPUでスキニング計算をしてから描画を行います。 ですので、今回の技法は頂点数に比例したCPU負荷が発生するはずです。 しかし、高度に最適化されたマルチスレッド実装であれば、 フレームあたり数万頂点までは楽に耐えられるでしょう。 まして1ボーンなので計算量はかなり小さくなります。

GLES3では、おそらくスキニング専用の頂点シェーダにスキニングを行わせ、 これを別の頂点バッファに保存し、それを描画で使う、 というフローになっているものと思われます。 頂点シェーダの出力をフラグメントシェーダに送らず、 別の頂点バッファに書き戻して終わりにする機能で、 Transform Feedbackと呼びます。

また、コンピュートシェーダが使えるハードウェアであれば、 コンピュートシェーダを用いてスキニングを行っているようです。 実際MacBook ProでFrameDebuggerを見てみると、ComputeShaderが動いているように見えます。

f:id:hirasho0:20190917105811p:plain

よほどの頂点数でない限り、スキニングはGPUにとってみれば大した計算量ではなく、 TransformFeedbackであろうがComputeShaderであろうが、 「GPUでやるので十分に速い」という程度に思っておいて良いでしょう。

GPUでやる場合の制約

さてここで問題になるのは、「物はいくつまで出せるのか?」です。 ボーンの数、つまり行列の数の限界はいくつか、ということが問題になります。

すでに見たように、PCでは2048個の物を出しても問題なく動きます。 つまり、2048個以上の行列をGPUに送れる、ということです。 64バイト(ケチれば48バイト)を2048個送れば、128kBにもなります。 そんな大きなものをどうやってGPUに送っているんでしょう?きっとテクスチャでしょうね。

思えば、2005年より前のGPUは頂点シェーダに行列20個分くらいのメモリ(Vector4が64個) しかないことがありました。 少し時代が進んでも70個か80個が限界の時代がありました(Vector4が256個)。 さらに、頂点シェーダでテクスチャが読めるようになって個数の制約はなくなりましたが、 頂点シェーダでのテクスチャ読み出しは遅くなりがちです。 「PCでは2000個行けるけどスマホの特定機種では20個」 なんてことがあると安心して使えないわけですが、そのへんはどうなっているんでしょうね。 おそらくテクスチャなのかなとは思うのですが。

頂点数の問題

行列数以外に、頂点数にも制約があります。 インデクスが16ビットである場合、頂点番号の最大値は65535です。 つまり、6.5万個以上の頂点を一つのSkinnedMeshRendererに描画させられない かもしれない、ということです。 実際本サンプルでは2700個くらいが限界です。キューブ一つ24頂点なので、 掛けると64800頂点となって限界付近であることがわかります。 これを超えると途中までしか絵が出なくなります。 キューブならともかく、標準の球は500頂点もあるので、128個ですらあふれてしまいます。

ほとんどの機種では Mesh.indexFormat を設定することで65536頂点の壁を越えられるそうですが、 私はやったことはありません。

なお、GPUインスタンシングの場合、同じ頂点をループしながら使い回すので、 頂点数の制約はありません。また、 シェーダの定数メモリに行列を置くわけではないので、 行列数の制約も違ってきます。

終わりに

物をたくさん出したいというのは、ゲーム作りで頻繁に出てくる欲求です。 しかし、いざやってみると結構面倒くさくて、 「スイッチOnにするだけ」というお手軽な手段では、 思ったほどには性能が出なかったりもします。 今回は一つの選択肢として、スキニング処理をインスタンシング的に 使うやり方を紹介してみました。

なお、似たような「たくさん出す処理」としてパーティクルがありますが、 パーティクルは物一つあたりの頂点数が3とか4と少なすぎて、 「行列だけ送ればいいので速い」という話になりません。 行列の数と頂点の数があんまり変わらないので、 「だったら直接頂点を詰めた方が行列のことを考えなくて済むので早い」 ということにもなります。 物一個あたり何頂点あったらこっちが良いか? というのは一概には言えないのですが、簡単に言って、 「法線が入っててライティングする」とか、 「それぞれ独自の向きや拡大率を持っている」とか、 「一個あたり数十頂点ある」とかいう条件が揃った場合は スキニングなりインスタンシングなりが向いている気がします。