入れ子(ネスト)になったプレハブのサイズについて

f:id:hirasho0:20191125121652p:plain

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

入れ子になったプレハブは、ビルド時に入れ子が全部展開されてるっぽい、というのがこの記事の結論です。 あと追加でParticleSystemを使うとデータがデカいということもお伝えしておこうと思います。

なお、2018.4.6及び2018.4.11での話で、他でも同じかは確認しておりません。

何の話?

とあるステージクリア型のゲーム製品がありまして、各ステージをプレハブとして作っています。

前もってステージの構成要素のプレハブがたくさん用意してあって、 それをゲームデザイナがUnityEditor上で配置してステージのプレハブを作る、 という作業設計です。

Unity的には普通の設計だと思うのですが、 いざビルドしてみると、このステージのプレハブが妙にデカいのです。 それこそロード時間が気になるほどに。

一体何が起こってるんだ?という話になって調べた結果、 最初に「結果」として書いたことがわかったわけです。

アセットとしてのプレハブと、ビルド後のプレハブ

アセットとしてのプレハブのサイズは、入れ子にしても大きくなりません。

例えば、ステージに、レンガが多数置いてあったとしても、 ステージの.prefabファイルはあまり大きくなりません。 もちろんレンガの個数に応じて座標情報などは足されますが、 それだけのことです。 そして、レンガのプレハブにコンポーネントを足したとしても、 ステージのプレハブのサイズは変わりません。

考えてみれば当然です。ステージのプレハブは、レンガのプレハブを参照しているだけで、 中身をコピーして持っているわけではないからです。 子を変更しても、親を触らずに済む方が良いに決まっています (もっとも現状子をいじると、親プレハブ全部に再インポートが走ります。何か事情があるようです)。

しかし、現実にはビルドすると実際のGameObject数に比例してサイズが大きくなるように見えます。 とすれば、考えられるのは、「ビルド時に子を全部展開している(コピーしている)」ことでしょう。

本当にそうか実験してみました。

実験

Githubに実験プロジェクトを置いておきました。 やっていることは簡単です。

まず、0という名前のプレハブにキューブを一個置きます。特に意味はありませんが、 回転するRotationというコンポーネントも足しておきました。

次に、1というプレハブを作って、そこに0を2個ずらして配置します。

さらに、2というプレハブを作って、そこに1を2個ずらして配置します。 3に2を2個、4に3を2個、5に4を2個、という具合に、13まで作りました。 例えば7の構造はこんな感じです。

f:id:hirasho0:20191125121648p:plain

0にはキューブが1個、1にはキューブが2個、2にはキューブが4個、 という具合にキューブの総数は倍々で増えていきます。 k番が持つキューブの総数は2^k個で、13では8192個ですね。

あとはこいつのロード時間やInstantiate時間を測定し、 ビルドでも同じことをしてみます。 ビルドした時にはビルド後サイズも見てみましょう。

結果

エディタでの測定

プレハブ ロード Instantiate .prefabファイルサイズ
0 0.994 1.53 4kb
1 2.66 0.342 7kb
2 2.01 0.556 7kb
3 3.12 3.87 7kb
4 3.88 1.84 7kb
5 17.3 2.25 7kb
6 11.2 4.66 7kb
7 21.2 5.89 7kb
8 33.0 9.57 7kb
9 73.8 24.0 7kb
10 162 45.0 7kb
11 311 118 7kb
12 607 268 7kb
13 1260 577 7kb

ロードはResources.Loadの所要時間(秒)です。 System.DateTime.Nowの差分で測定しました。

7くらいまでは誤差が大きくてよくわかりませんが、 8からは倍々の感じで増えていますね。

そして、.prefabファイルの大きさはみんな同じです。 0だけ違うのは、こいつにだけMeshRendererが入ってて、 GameObjectの数が他と違うからですね。

ビルドでの測定

プレハブ ロード Instantiate ビルドでEditor.logに出てくるサイズ
0 49.4 2.59 0.5kb
1 15.0 0.183 1.1kb
2 6.89 0.118 2.3kb
3 9.03 0.255 4.9kb
4 3.49 0.506 9.8kb
5 11.8 0.779 19.7kb
6 14.9 1.74 39.4kb
7 13.4 3.21 79.0kb
8 30.3 4.18 158kb
9 49.9 14.6 316kb
10 109 22.2 633kb
11 184 53.6 1.2mb
12 380 102 2.5mb
13 815 253 4.9mb

ビルドでも同じで、7から上あたりではロード時間もInstantiate時間も 倍々で増えていき、そしてビルドログに出てくるサイズも倍々になっています。

なお、ビルドはmacのIL2CPPです。スマホ実機では数倍遅いことが予測され、 GameObjectが8000個もあると3秒や4秒はかかるのでは? という話になるわけです。

当たり前感はあるが...

prefabやsceneの初期化を高速にやるために、 ビルド時に参照構造を全部展開してしまう、というのは理解できる実装です。 最終的に入っているGameObject数で負荷や容量が決まる、 というのは、言われてみれば当たり前感があります。

しかし、この例のようにプレハブの入れ子が深くなると、 指数関数的に容量が増える恐れがあり、しかもそれに気づきにくいのです。

昔のUnityは入れ子にできなかったので、 それほど多くのGameObject数にはなりませんでした。 GameObjectを増やすには手間がかかるからです。 Editor上で自動配置ツールでも作らない限りは、 手間によって数が制限されます。

しかし、プレハブを入れ子にできるとなれば、先程の実験でやったように、 大した手間をかけずに凄まじい数のGameObjectを出すことができます。 1段につきわずか2個でも、13段あれば8192個にまで膨れ上がります。 手で置いたGameObjectの数は50個にもなりません。 以前に比べて「GameObjectを置きすぎて重い」という状況が起きやすくなった、と言えます。

今回のケース

なお、よく調べてみたところ、今回製品で遭遇したケースは入れ子かどうかはあまり関係ありませんでした。 問題はParticleSystemです。

先程の「ステージ」と「レンガ」の例で言うと、 各レンガに「レンガ粉砕エフェクト」のプレハブが入っており、 これがParticleSystemで作られています。 しかし、このParticleSystemのデータが信じられないほど大きいのです。 一つ置くだけで、.prefabファイルの行数が5000行近く増えます。 あの莫大な数のパラメータが全部書き込まれているからでしょう。 それが一つのステージに50個以上置いてあり、 ステージのデータが非常に大きくなってしまったわけです。

対策

「GameObject多いと遅いよ」という当たり前の話なのですが、 気がつかないうちに増えてしまうのが問題ですし、 「GameObject数に気をつけて作ろう」と言うのも現実的な方策とは思えません。 非プログラマが気軽にモノを置けるのがUnity開発の良い所であり、 非プログラマに実装や性能のことを気にしてもらうのは非効率なのです。

というわけで、現状何の対策もできていないのですが、 一つ考えているのは、 プレハブを作っちゃった後にスクリプトを走らせて、 置いてあるプレハブ情報をjsonか何かに格納し、 初期化時にそれを元にして動的にInstantiateする、という策です。

おそらく非プログラマの人がプレハブを作る場合、 「どこに」「何を」置くか、をいじるのがメインになるでしょう。 「プレハブ内にプレハブを置いて位置と角度をいじるだけ」 であるならば、 「プレハブの種類と位置と角度」だけをjsonか何かで記録するだけで足ります。 そしてその情報を元に、実行時にInstantiateするのです。 少なくともビルド後サイズと、ロード時間は改善するでしょう。

もちろん、開発時にそのまま遊べることは重要ですから、 ゲームデザイナーの作業としては、「気にせずプレハブを置く」であるべきです。 ビルド前スクリプトでjson化する等の、ゲームデザイナーに見えない形でやれれば 良いのではないかと思います。

残念ながらInstantiate時間は減らないどころか増えるでしょうが、 「問題にならない程度」の増加で済むことを期待したいところです。 多数ある場合にはコルーチンで分散してして、 スパイクを軽減することもできるでしょう。

また、配置されているモノによっては「使う寸前までInstantiateしない」 といったことも可能なはずです。 例えば「レンガ破壊エフェクト」は破壊の寸前までInstantiateを遅延できますし、 使い終わったものを取っておいて使い回すこともできます。 そういったものについては、元のプレハブに置かれていた数全部を用意する必要は ないかもしれず、総合的には処理も削減できるかもしれません。

ただ、今回の場合問題はParticleSystemであり、 プレハブが大きいということはInstantiateも重いでしょう。 それを動的に行えばゲーム中にスパイクが出ることになります。 なので、「だいたいこれくらいの数用意しておけば足りるだろう」 という数を初期化時に作ってしまい、万が一足りなくなったら スパイクしてでも動的に追加、というのが妥当な実装に思えます。

まとめ

速度より容量に配慮してほしいなあ、という個人的な感想です。 Instantiateが多少遅くなってでも、参照を保った形でプレハブデータをビルドしてほしいなあと。

ビルド後のプレハブの容量は当然ビルドしてみるまでわからず、 ビルドログでアセットのサイズを確認するなんて面倒くさいことは、 よほどの問題が起こらない限りやらないのです。

あと、ParticleSystemのパラメータ数がおかしなことになっていて、 これはさすがにもう限界なのでは?感がすごいですね。