Unityで画素密度を固定する解像度設定(FixedDPI)

f:id:hirasho0:20191001224142p:plain

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

今回は小ネタで、サンプルもありません。ビルドの解像度設定についてです。

スクリーンショットにあるように、PlayerSettingsのResolution and Presentation の項に、Resolution Scalingというものがあり、 Resolution Scaling ModeをデフォルトのDisabledからFixed DPIに 設定することができます( AndroidマニュアルiOSマニュアル )。

これは、画素(ピクセル)の密度が同じになるように、機種によって解像度を自動で変える、という設定です。 上の画像では326dpi(dot per inch。1インチあたりの画素数)になっており、 これはiPhone4あたりからの伝統的な値です。 iPhone6Plusのような上位機種を除けば、だいたいこの密度になっています。

実際どうなるのか?

この設定にしておくと、ディスプレイの画素密度が326dpiの機械では、 元の解像度で描画されます。例えばiPhone8なら1334x750です。

ところが、画面の大きさの割に解像度が高い端末、 例えばiPhone8Plusであれば、元よりも低い解像度になります。 元の解像度は1920x1080ですが、これが1560x878になります。 元の画素密度は406dpiもありこれを326dpiに落とすと、 解像度が縦横それぞれ(326/406)倍されるわけです。

何のために使うの?

「無駄に高い解像度で描画して、フレームレートや電力消費を悪化させるのを防ぐため」です。

高解像度で描画する方が綺麗なのは確かですが、タダではありません。 GPUの処理負荷はかなりの部分が画素数に比例しますから、 1920x1080で描画する負荷は、1560x878で描画する負荷に比べて、 およそ1.5倍になります。 CPU側に余裕があって、フレームレートが画素数で決まっているようなケースでは、 この設定を有効にすることでフレームレートが1.5倍になる可能性がある、ということです。

また、元々60fpsで動いているような場合は、電力消費が改善します。 微々たる差しか感じられないことに無駄に電気を食うよりは、 少し解像度を下げて余力を残した方が良いかもしれません。 スマホの場合、あまり電力消費が大きいと熱のために処理能力が低下し、 フレームレートが落ちてくることもよくあります。 前もって解像度を下げておくことは、これを防ぐ効果もあるのです。

実際の例

実際の例としては、iPhone6Plus(1920x1080)で40FPSしか出なかったアプリが、 この設定を有効にするだけで60FPSに改善した例があります。

また、一部Android機のように、 GPU性能の割に解像度が高い(例えば京セラS2で1280x720、S4で1920x1080) 端末では、 元々フル解像度でゲームを動かすことには無理があり、 解像度を下げることで大きくフレームレートが改善します。 S2の場合、対角5インチのサイズで1280x720で、 1280画素ある長辺の長さは4.345インチです。 1280をこれで割ると、294dpiとなります。

iPhoneの326dpiよりも低いですが、 「そもそもこのゲームって、そんなに解像度高くてうれしいの?」 ということをよく考えてみれば、 製品によっては「もう少し低くてもいいな」という判断にもなるでしょう。

お客さんが選べるように選択肢を用意するのも一つの考えかと思いますが、 ほとんどのお客さんにとって適切な設定をデフォルトにするのもまた重要なことです。 画質とフレームレート、電力消費のバランスを鑑みて、 画素密度(dpi)で設定するのは悪くない選択でしょう。 設定一つで済むので実装コストはほぼゼロです。

製品の性質にもよりますが、200から300くらいを設定するのが良いのかな、 と個人的には思います。Nintendo Switchが235dpiであり(Switch Liteは268dpi) それだけあれば十分な気もします。 もし不安があるのであれば、たくさん売れたiPhoneを基準にして上の例のように326 に設定するのも良いでしょう。それでもPlus系での処理落ちを防げます。 その場合も、Androidでは低スペック機に配慮して下げる方が良い気がします (dpi設定はiOSとAndroidを別に持てないので、ビルド前にスクリプトで変更するのが良いでしょう)。

ダイナミックなフォントは解像度が高いほど綺麗になり、 3Dのポリゴンも解像度が高いほど輪郭が綺麗になりますが、 前もって用意したテクスチャは元解像度以上にはなりません。 解像度が高すぎてもボケて見えるだけで、 それは解像度を落として描くのとほとんど変わらないのです。 しかしフレームレートと電力消費は確実に悪化します。

別の方法

お客さんに解像度設定を委ねる場合には、これを使っての設定はできません。 一律になってしまうからです。 設定を見て、 Screen.SetResolution を起動後に呼ぶことになります。

Screenクラスからはdpiも取れますので、FixedDPIを使うのと同じこともできますが、 解像度変更はその場では終わらず次のフレームまでかかりますし、 解像度に依存した処理があると、解像度が変わった時におかしくなる危険もありますから、 注意しましょう。起動直後のみの反映が安全ですが、すぐに確認できないので利便性は落ちます。 設定画面を作るコストの問題もありますから、 製品の性質やお客さんにとっての価値を考えた上で、 良い選択をしてください。

なお、お客さんに設定して頂けるのであれば、 かなり攻めた設定(768x432まで解像度を下げるなど)ができ、 電気が気になる方(例えば私)や、低性能の機種をお使いの方(例えば私)に とってはうれしいのではないかと思います。 過去参加した製品 では、768x432で20fpsという省エネ設定ができるようにしましたが、 完全に私のためでした。

代表的な機械の画素密度

以下に代表的な機械のDPIを表にしておきます。 「この機械はドット粗いなあ」と思う機械よりは上げたいですが、 「これ以上細かくても差がわからないよ」「そもそもそんな高解像度で素材作ってないよ」 という場合には下げて良いかと思います。 素材の解像度が上がれば容量も増えて、通信料金やスマホのストレージにも 悪影響を与えますから、何事も程々のバランスが良いでしょう。

機種 対角インチ 解像度 DPI
iPhone 5S,SE 4 1136x640 326
iPhone 6, 6S, 7, 8 4.7 1334x750 326
iPhone 6Plus, 6S Plus, 7Plus, 8Plus 5.5 1920x1080 406
iPhoneX, XS, 11Pro 5.85 2436x1125 458
iPhoneXS Max, 11ProMax 6.46 2688x1242 458
iPhoneXR, 11 6.06 1792x828 326
古めのiPadの多く 9.7 2048x1536 264
今のiPad 10.5 2224x1668 264
対角5インチの16:9機 5 960x540 221
対角5インチの16:9機 5 1280x720 295
対角5インチの16:9機 5 1920x1080 442
対角24インチのFullHDモニタ 24 1920x1080 92
Retina MacBookPro 13インチ 13 2560x1600 232
Nintendo Switch 6.2 1280x720 238
Nintendo Switch Lite 5.5 1280x720 268

QualitySettingsでの補正

この項目は試していないのですが、たまたま調べたら出てきたので紹介しておきます。

dpi値はQualitySettingsで修正することができるようです。 公式マニュアル にあるように、Resolution Scaling Fixed DPI Factorを設定すれば、 lowやmediumのような設定ごとに、先程設定したdpi値を補正することができるとあります。 0.5を書けば半分になり、例えば326dpiであれば163dpiになって、 解像度が半分になるのでしょう。

機械の性能を見てQualitySettings.SetQualityLevel などを呼んでいる場合には、動的に解像度も変わるのでしょうか? 正常に動くのか、どれくらい処理が止まるのか、といったことはわかりません。

終わりに

今回は小ネタでしたが、下手に最適化で苦労するよりもよほど簡単に効果が上がるので、強くオススメしておきます。

個人的には、多くのゲームのように画面に動きがあるアプリケーションで 300dpiを超えた描画するのは、電池が惜しいと感じます。 もし余力があるのであれば、解像度を上げるよりも、 ライティングなどのピクセルあたりの処理を豪華にする方が、 おそらくは品質も上がるでしょう。

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

画面写真をクリックすると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と少なすぎて、 「行列だけ送ればいいので速い」という話になりません。 行列の数と頂点の数があんまり変わらないので、 「だったら直接頂点を詰めた方が行列のことを考えなくて済むので早い」 ということにもなります。 物一個あたり何頂点あったらこっちが良いか? というのは一概には言えないのですが、簡単に言って、 「法線が入っててライティングする」とか、 「それぞれ独自の向きや拡大率を持っている」とか、 「一個あたり数十頂点ある」とかいう条件が揃った場合は スキニングなりインスタンシングなりが向いている気がします。