UnityEngine.UI.Imageが透けてる所を塗るのが許せない

f:id:hirasho0:20190116110517j:plainf:id:hirasho0:20190116110451p:plain

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

この記事では、UnityEngine.UI.Image、つまり「普通のImage」 だと余計な所まで塗ってしまってGPU負荷が大きいので、 塗る面積を削ってみた、というお話をいたします。

一枚目の画面写真で、右端が普通のImageで、左と中央が今回作ったものです。 左はSpriteに入っている頂点を利用したもので、頂点は増えますが面積が減ります。 中央は、黒い枠を重ねて顔のところだけを切り出したもので、 手動で頂点を編集することでマスクなしでの切り出しをしています。 二枚目の画面写真はoverdraw表示で、塗る面積が減っていることがわかります。

また、画像をSpriteAtlasにまとめている場合、パッキングをtightにしたり回転を許したりすると、 普通のImageでは正常に表示できなくなりますが、今回はそこにも対応しました。 より小さな容量に詰めることができます。

以下は実行時の画面写真です。

f:id:hirasho0:20190116110456j:plain

普通のImageではゴミが映り込んでしまいます。 また、アトラス内で画像が回転していることに対応できず下の人の絵が天地逆転しています。 アトラスは以下のようなものです。

f:id:hirasho0:20190116110417p:plain

tight設定と回転の許可により、より小さな容量にテクスチャをまとめることができます。

サンプルコードはgithub に実行可能な形で置いてありますので、よろしければ持っていってください。 弊社東京プリズン用に作ったものを、この記事のために整理、汎用化、高機能化したものですので、 実地の使用にも耐えるかとは思いますが、その過程でバグを入れている可能性もあります。 あくまでもサンプルということでよろしくおねがいいたします。

塗り面積問題

塗る面積によるGPU負荷は、結構馬鹿になりません。

据置き機では4K(3840x2160)なんて話になってますし、 持ち歩くスマホやタブレットでも1920x1080を超える解像度の機械が 山ほど出てきました。 価格の高い高級機が高い解像度を持つのはかまわないのですが、 問題は安物です。2万円3万円クラスの機械のGPUはだいぶ性能が劣るわけですが、 それでも1920x1080の解像度を持っていたりします。 まるで性能が足りません。とりわけチップがSnapdragon 4XXの奴とかは要注意です。 きっとどこの会社でもテスト機種のラインナップには そういう機械を入れておられることでしょう。

これに対する妥当な対策は、 起動時にScreen.width/heightをいじって「必要十分」な解像度に落としておく ことかと思います。弊社東京プリズンでは標準の解像度を1136x640としています。 しかし、諸事情あってイラストの画質を落とせないこともあるでしょう。 そういう場合に塗り面積を減らす選択肢としては、二つほどあるかと思います。

  • SpriteRendererを使う
  • UnityEngine.UIでどうにかする

テラシュールブログの記事 にあるように、SpriteRendererを使えば、何もしなくても透明な領域を削って描画してくれます。 これが素直な手です。頂点の数も調整できます。 しかし、「UnityEngine.UIで作っているのでSpriteRendererを混ぜたくない」 という場合には使いにくいかと思います。東京プリズンもそうでした。

二つ目の選択肢は、UnityEngine.UIでどうにかする方法です。 UnityEngine.UI.MaskableGraphic を継承して自分で頂点を詰めれば、 普通のImageやTextと共存できるコンポーネントが作れます。

Spriteの頂点を使う

SpriteRendererで使われる頂点配列は、Spriteが持っています。 verticesuvtrianglesの3つのプロパティを使って、 UnityEngine.UI.Graphic.OnPopulateMesh を実装します。これを使った最小のコンポーネントのコードは以下のようになります。

using UnityEngine;
using UnityEngine.UI;

public class MinimumCutoutImage : MaskableGraphic
{
    [SerializeField]
    Sprite _sprite;
    public override Texture mainTexture { get { return (_sprite != null) ? _sprite.texture : null; } }

    protected override void OnPopulateMesh(VertexHelper vh)
    {
        vh.Clear();
        if (_sprite == null){ return; }
        for (int i = 0; i < _sprite.vertices.Length; i++)
        {
            vh.AddVert(_sprite.vertices[i] * _sprite.pixelsPerUnit, this.color, _sprite.uv[i]);
        }

        for (int i = 0; i < _sprite.triangles.Length; i += 3)
        {
            vh.AddTriangle(_sprite.triangles[i + 0], _sprite.triangles[i + 1], _sprite.triangles[i + 2]);
        }
    }
}

これもGithubに置いてあります。 普通のImageと同様にInspectorでSpriteを設定し、 OnPopulateMeshで、もらったVertexHelperに頂点を設定します。 UnityEngine.UIは大抵ピクセル単位でサイズや位置を設定しますので、 Sprite.pixelsPerUnit を頂点座標(vertices)に乗算する必要があります。

普通のImageと互換性を持たせたい

上に示したMinimumCutoutImageは実は結構不便です。 おそらくは普通のImageから後で置き換える、という用途でしょうから、 widthとheightが同じなら同じ大きさになってほしいのですが、 そうなっていません。現状RectTransform.sizeDeltaを見ていないので、 widthやheightに何を入れてもサイズが変わらないのです。 SetNativeSizeボタンがないのも同様に不便ですね。 そこで、さらに改造を加える必要があります。

まずSetNativeSize

まずはSetNativeSizeボタンを足しましょう。 RectTransform.sizeDeltaにSpriteの解像度を入れる機能を用意し、 それをエディタ拡張のボタンとして用意します。

public class CutoutImage : MaskableGraphic
{
    /// ...中略...
    public override void SetNativeSize()
    {
        if (_sprite == null)
        {
            return;
        }
        var rect = _sprite.rect;
        rectTransform.sizeDelta = new Vector2(
            rect.width,
            rect.height);
    }
#if UNITY_EDITOR
    [CustomEditor(typeof(CutoutImage), true)]
    public class Inspector : Editor
    {
        public override void OnInspectorGUI()
        {
            base.OnInspectorGUI();
            var self = (CutoutImage)target;
            if (GUILayout.Button("Set Native Size"))
            {
                self.SetNativeSize();
            }
        }
    }
#endif
}

Spriteの解像度は、 Sprite.rect で取れます。SpriteAtlas化した場合でも元の解像度を返します。 このwidthとheightを rectTransform.sizeDelta につっこむだけです。 そして、エディタ拡張のボタンは上のようなコードで足せます。 privateメンバに楽にアクセスできるので、エディタ拡張のクラス(上で言うInspector)は クラスの中に作るのが楽だと思います。

かくして、SetNativeSizeボタンがつき、押すとwidthとheightが自動設定できるようになりました。 この段階のコードはこちらです

サイズ調整

さて、ボタンはつけたものの、押しても何も起こりません。 何もしなければwidthとheightは100ですが、その状態でボタンを押して 例えば256x256にしても表示は何も変わらないのです。 そこで、普通のImageのように widthやheightに応じて表示サイズが変わるようにしましょう。

元々Sprite.verticesに入っている頂点座標は、ピクセル単位ではありません。 すでに見たように、Sprite.pixelsPerUnit を乗算して初めてピクセル単位になります。加えて 普通のImageのようにwidthやheightをいじって拡大縮小したい場合には、 widthやheightを持っているRectTransform.sizeDeltaの値を頂点座標に反映させる必要があります。

例えば、元解像度が128x256で、width=64、height=64で表示したい場合、 xは0.5倍、yは0.25倍する必要があります。 つまり、元解像度とwidth及びheightの比を出して、それを乗算すればいいわけです。

protected override void OnPopulateMesh(VertexHelper vh)
{
    vh.Clear();
    if (_sprite == null){ return; }
    var spriteRect = _sprite.rect;
    var sizeDelta = rectTransform.sizeDelta;
    var scale = new Vector2(
        _sprite.pixelsPerUnit * sizeDelta.x / spriteRect.width,
        _sprite.pixelsPerUnit * sizeDelta.y / spriteRect.height);
    for (int i = 0; i < _sprite.vertices.Length; i++)
    {
        var v = new Vector2(
            _sprite.vertices[i].x * scale.x,
            _sprite.vertices[i].y * scale.y);
        vh.AddVert(v, this.color, _sprite.uv[i]);
    }
    /// ...略...

Sprite.rectとRectTransform.sizeDeltaの比を各頂点座標に乗算するだけです。 これで、サイズが正しくなります。 この段階のコードはこちらです

位置がずれてる...

実はまだ対応しないといけないことがあります。 RectTransform.pivotをいじると位置がズレてしまうのです。

f:id:hirasho0:20190116110459p:plainf:id:hirasho0:20190116110503p:plain

上の図ではRectTransformのpivotが(0.5, 0.5)で、白い背景と人の絵が合っていますが、 下の図ではpivotが(0, 0)で、背景とズレています。 普通のImageであれば、pivotをいじった時のPosX,PosYの自動調整で 同じ位置に留まるのですが、何故かそうなっていません。

つまり、普通のImageでpivotをいじった時にUnityが中でやっている処理を、 自作しないといけない、ということです。 上のコードを見てみましょう。どこにもRectTransform.pivotを見ている場所がありません。 pivotを見ていないのにPosX,PosY、つまりRectTransform.anchoredPosition をいじれば、位置がずれるのは当然です。

というわけで、RectTransform.pivotを見るようにコードを直します。 RectTransform.pivotは0から1の範囲で、widthとheightに対する比率を表しています。 ですから、頂点座標にその分だけ加えてやればいいわけです。 例えば元々pivotが(0.5, 0.5)の時に中央であったとします。 pivotを(0, 0)に変えると、PosXとPosYが自動で-128に変わりますから、 左下にずれます。これを補正すればいいので、 「pivotが減ったら、減った分だけ座標を増やす」となります。 結果こんな感じです。

protected override void OnPopulateMesh(VertexHelper vh)
{
    vh.Clear();
    if (_sprite == null){ return; }
    var spriteRect = _sprite.rect;
    var sizeDelta = rectTransform.sizeDelta;
    var scale = new Vector2(
        _sprite.pixelsPerUnit * sizeDelta.x / spriteRect.width,
        _sprite.pixelsPerUnit * sizeDelta.y / spriteRect.height);
    var offset = new Vector2(
        sizeDelta.x * (0.5f - rectTransform.pivot.x),
        sizeDelta.y * (0.5f - rectTransform.pivot.y));
    for (int i = 0; i < _sprite.vertices.Length; i++)
    {
        var v = new Vector2(
            _sprite.vertices[i].x * scale.x,
            _sprite.vertices[i].y * scale.y);
        v += offset;
        vh.AddVert(v, this.color, _sprite.uv[i]);
    }
    /// ...略...

offsetというVector2を用意して、全頂点に足します。 pivotが減るほど大きな値を足すので、0.5f - rectTransform.pivot.x という引き算です。これにwidth(=sizeDelta.x)とheight(=sizeDelta.y)を掛けてピクセル単位に直します。

f:id:hirasho0:20190116110506p:plain

めでたく合うようになりました。 この段階のコードはこちらです

実はまだまだ続く

と、ここでちょっと完成形の CutoutImageのコード をご覧ください。

だいぶ長いですね。実のところ、このように一段づつ改造の詳細を説明するには長くなりすぎるくらいの改造が、 CutoutImageには入っています。 というわけで、この記事ではあと何をやれば完成形になるのかの概要だけを示すことにします。

Sprite側のpivotを見ていない

実は、Spriteもpivotを持っています。 先程のコードで0.5fというそのまんまな数字が出てきたことに気持ち悪さを感じた方はいらっしゃいますか? 実はあれは、Sprite側のpivotなのです。

f:id:hirasho0:20190116110509p:plain

たまたまデフォルト値が0.5だから良かったものの、もし変えていればそのままでは位置がズレてしまいます。 なので、Sprite.pivotも見るようなコードにしないと本当には正しく動きません。

手動で頂点を設定する機能

Spriteが持っている頂点をそのまま使う場合は比較的簡単なのですが、 そのまま使いたくない、というケースもあります。 と言っても、単に「自動で生成された頂点が多すぎる/少なすぎる」という場合には、 マニュアル にあるように調整できるので問題はありません。

では手動で頂点をいじりたいケースが何かと言えば、マスクが欲しくなるような状況です。 これをご覧ください。

f:id:hirasho0:20190116110446j:plain

顔の所だけが切り取られていて、黒い枠がついています。これをマスクでやることもできますが、 コンポーネントを足すのは手間ですし、ステンシルバッファの操作が必要になってGPUも重いですし、 シェーダが切り変わるのでDrawCallが増えてCPU負荷も上がります。 いいことがありません。 やろうと思えば上述の「手動スプライト頂点調整」でもできますが、 枠の形は使う場所によって異なるのが普通でしょう。 東京プリズンの場合、この例のように綺麗な長方形ではなく、 場所によって形が違うため、なおさらスプライト頂点側でやるのは面倒です。 そのコンポーネントの設定としてやれた方がいいでしょう。

そこで、完成版では手動で頂点を編集する機能を入れてあります。

f:id:hirasho0:20190116110511j:plain

pivot同様に、左端を0、右端を1、下端を0、上端を1、 とする座標で頂点を手動設定できます。 VertexOverrideEnabledを有効にした上で、 OverrideVerticesを編集します。頂点は時計周りに並んでいるものと解釈されます。 また、Inspectorの「GUI VertexEditing」トグルを有効にすると、 Sceneビュー側の各頂点に丸が出てきて、 この丸をマウスでドラッグして頂点を調整できます。

さて、この機能の実装が結構面倒でして、 CutoutImageの行数のかなりの部分がこれに割かれています。

一番面倒なのがuv、つまりテクスチャ座標です。 頂点を自分で作るとなるとSprite.uvが使えないので、自力で生成せねばなりません。 Sprite.verticesとSprite.uvの間は何らかの線形変換で変換できますから、 3つづつverticesとuvを持ってきて連立方程式を立て、 逆行列を計算して変換を2x3行列の形で持っておきます。 頂点はマウス等々でどんどん変更されますので、その度にOnPopulateMeshが呼ばれ、 その中でこの行列で変換してuvを求めます

もし、SpriteAtlasの生成時に回転を許可しておらず、かつ、 tightパッキングでなければ、アトラスの中の位置は、 Sprite.textureRectSprite.textureRectOffset で求まるのでズラすだけで済みます(これらのプロパティは回転があったりtightだったりすると例外を吐きます)。

実は東京プリズンに使っている実装はtightパッキングや回転を許しておらず、 もっと簡単なコードでした。せっかくなので今回対応してみたのですが、なかなか面倒です。 Unityが標準で持っている4x4行列のライブラリでは使いにくいので、 2x3行列、3x3行列の必要な機能も自力で実装してあります。完全に趣味です。

なお、マウスでの頂点編集のために、エディタ拡張の方で OnSceneGUIを実装して、そこで、 Handles.matrixHandleUtility.GetHandleSizeHandles.FreeMoveHandleあたりを使って マウスで動かせる丸を描いています。

gizmoへの対応

MonoBehaviour.OnDrawGizmosSelected を実装することで、エディタでオブジェクトが選択された時にいろいろなことができます。 ここでは、頂点がどうなっているのかがわかるように、ワイヤーフレームの描画をしてみました。 だいたいこのあたりのコードです

f:id:hirasho0:20190116110514p:plain

Gizmos.matrixTransform.localToWorldMatrix を設定し、 Gizmos.color に好みの色を入れ、 Gizmos.DrawLine に頂点の座標を渡して描画しています。 Gizmos.matrixにローカル座標からワールド座標に変換する行列を 渡してあるので、頂点はローカル座標のままでかまいません。 RectTransformのローカル座標、というのは、つまり ピクセル単位の馴染みの座標系ですので、 OnPopulateMesh()でVertexHelperに渡したものをそのまま渡せばいいわけです。 この実装では再計算を避けるために配列に覚えておいています。

終わりに

今回は「塗り面積を減らす」という課題から入って、 Spriteの仕様、UnityEngine.UI.Graphicを継承した独自クラスの生成、頂点の手動生成、 gizmoの実装方法、といったところに触れてみました。

こういったものを書くには、「座標変換」がミソになります。 回転や拡大縮小、移動を含めた変換を行列で表現することや、 連立方程式と行列の関係が手に馴染んでいると、何かと楽になりますので、おすすめです。

なお、元々gizmoを使っていろいろ表示したり、マウスで頂点を編集できるようにしたのは、 この記事を書いた しはんです。 彼がこれを実装してくれるまで、gizmoを自分で表示しようなんて思いもしませんでしたし、 「面倒くさいなー」と思いながら数字をいじって頂点座標を編集していました。 タスクとバグに追われる毎日だと、ちょっとしたことを調べる余裕もなくなってしまって良くないですね。 短期と長期をバランスするだけの余裕を常に持ちたいものです。

「リッチテキストって頂点ムッチャ増えない?」から始まるUnityEngine.UIの性能調査

UnityでUIを作ると言えば、まずはUnityEngine.UIです。

中身のことを考える必要もなく、ドカドカとImageやTextを置けば 動くので大変有り難いわけですが、不意に猛烈に処理が遅くなることがあります。

本記事では、たまたま見つけた現象と、そこから疑問を持って UnityEngine.UIの処理速度についていろいろ調べてみたことを、 技術部平山が書いてみます。

お急ぎの方のために結論を箇条書きにしておきましょう。

  • Graphic.color、RectTransform.sizeDelta、Text.textをいじると遅い
  • 動かすだけでは大して遅くないが、動かさないよりは遅い
  • リッチテキストは遅い

気になりましたら以下をどうぞ。

なお、Unityのバージョンは2017.4.8f1です。2018では改善している、 ということも多々ありそうですが、そのへんはご容赦ください。

UIプロファイラ便利!と思ったら...

Unity2017でUIのプロファイラが入りました。 どのCanvasで何頂点、何DrawCallしてるかが見えて大変便利です。 そこで、奇妙なものを見つけました。

f:id:hirasho0:20190114154915p:plain

頂点多くない?

720頂点とありますが、これ、1文字出してるだけなんですよ。

f:id:hirasho0:20190114154850p:plain

文字は1個あたり4頂点、6インデクス、2三角形です。普通にやれば。それが720?

そして気づきます。「なんだ、Outlineのせいか」。 縁取りついてますもんね。

Outlineは字を何方向かにずらして描画するので、頂点数は何倍かになります。 そのせいでしょう。試しに外してみました。

f:id:hirasho0:20190114154919p:plain

まだ96頂点もあります。どういうこと? ここで、Textに差している文字列を見直してみました。

<color=#ff0000>赤</color>

リッチテキストを使っています。表示される文字数は「赤」の1文字ですが、 リッチテキストのタグ込みで文字数を数えてみると、24文字ありました。

96頂点を4で割ると、24。

...これ、リッチテキストのタグの分まで頂点生成してるでしょ?マジで?

重くなければ気にならないけど、どうなの?

さて、妙に頂点が多いことはわかりましたが、 こんなのはたぶんUnityが新しくなれば解消される類の問題でしょう。 そしてそもそも、このために遅くなっていないのであれば、放っておいてもいいわけです。

そこで、遅くならないのかを測ってみることにしました。 Outlineを有効化したり無効化したりして負荷を比較します。

なお、測定はMacBook Pro Mid 2014(i7 3GHz)にてエディタ実行で行いました。

public class Sample : MonoBehaviour
{
    public Text textPrefab;
    public Canvas canvas;
    Text[] _texts;
    const int N = 100;
    int _frame;

    void Start()
    {
        _texts = new Text[N];
        for (int i = 0; i < N; i++)
        {
            _texts[i] = Instantiate(textPrefab, canvas.gameObject.transform, false);
        }
    }
    void Update()
    {
        if (Input.GetKeyDown(KeyCode.F1))
        {
            foreach (var text in _texts)
            {
                var outline = text.gameObject.GetComponent<Outline>();
                outline.enabled = !outline.enabled;
            }
        }

        var str = _frame.ToString("D5");
        foreach (var text in _texts)
        {
            text.text = str;
        }
        _frame++;
    }
}

OutlineをつけたTextをプレハブ化しておいて、 Start()で100個Instantiateします。親はcanvasです。

Update()では、F1が押されたらOutlineコンポーネントを取ってきて enabledを切り替え、 毎フレーム_frameをインクリメントしては、こいつを文字列化して、 Textに設定します。文字数が途中で変わるのは嫌なので、ToString"D5"を指定して 5桁固定にしました。

実行してみます。

f:id:hirasho0:20190114154853p:plain

吐くほど遅いですね!

紫はUIの負荷です。頂点を生成してるんでしょう。 20msくらいをこのUIの処理で食っています。 これがOutlineアリで、頂点数は15000です。

では、F1を押してOutlineを切ってみましょう。

f:id:hirasho0:20190114154900p:plain

ムチャクチャ速くなりました。4倍くらいは速いです。 グラフの崖が切り替えたタイミングですね。 頂点数は2000になりました。

どうやら、リッチテキストのタグ由来の頂点でも処理が遅くなるようです。これは困りました。

Outlineは頂点を何倍にするのか?なんで?

さて対策を考える前に、Outlineをつけると頂点が何倍になるのかを確認しておきましょう。

先の結果から、Outline付きで15000、なしで2000ですから、7.5倍になるということになります。 これはどういうことでしょうか?

まず、Outlineなしの頂点数は2000で、5文字が100個ありますから、 1文字あたり4頂点とわかります。

これが、Outlineをつけると、1文字あたり30頂点に増えます。 30は素因数分解すると2×3×5で、どこかに「5」という要素があるはずです。 たぶん5回書いてるんでしょう。 上下左右にずらして黒で書いた後、中央に本来の色で1回、というところでしょうか。 もしそうなら、1回あたり6頂点ということになり、1.5倍ほど増える計算になります。 これについては後述します。

さてどうやって減らす?

一つ考えられるのは、描く回数が少ないOutlineを自作するという手です。 しかし、当然汚くなります。実際、弊社東京プリズンではいくつかの場所で 1回しか余計に書かない簡易版を用意して使っていたりしますが、品質は妥協しています。

あとは、リッチテキストタグ由来の頂点をどうにかして消す、という手が考えられます。 これならOutlineがついていなくても速くなるはずなので、これができるならお得です。 次はこれを試してみましょう。

BaseMeshEffect

Graphicが持っている頂点をいじる方法は公式に記載があります。 BaseMeshEffect というクラスを継承したコンポーネントを作り、 ModifyMesh() を実装します。

が、2018年11月29日現在、Meshを引数に取るModifyMeshはobsoleteで、どこにも記載はありません。 VertexHelperを取るバージョンが正式と思われます。 あんまり使われてないのでマニュアルを更新してないんでしょう。 Unityに関してはよくあることなので、気にせず使います。

public class DegenerateQuadRemover : BaseMeshEffect
{
    private static List<UIVertex> _verticesCache; // 使い回し

    static DegenerateQuadRemover()
    {
        _verticesCache = new List<UIVertex>();
    }

    public override void ModifyMesh(VertexHelper vh)
    {
        vh.GetUIVertexStream(_verticesCache);
        // TOOD: ここで何かする
        vh.Clear();
        vh.AddUIVertexTriangleStream(_verticesCache);
    }
}

クラス名は「面積0の四角を除く者」ということでDegenerate(縮退)Quad(四角)Removerとしました。 頂点があるのに見えないということは、宇宙の果ての座標になっていて画面に写らないか、 面積がゼロになっているか、のどちらかでしょう。 頂点を全部Debug.Logで吐き出して確認したところ、後者でした。 そこで面積がゼロの場合を扱います。

もらったVertexHelper から今の頂点をもらってきます。 コンポーネントが複数ついている場合、処理は上から順に行われますので(これを保証する文書は見つけられてませんが...)、 Textの下にこのコンポーネントがついているとすれば、 Textで生成された頂点が得られることになります。 この配列を毎度newしたくないので、staticなコンストラクタで作っておいて使い回しています。

そしてTODO:と書いた所で何かして、データが出来上がったら、 VertexHelper.Clear() して、 VertexHelpder.AddUiVertexTriangleStream() で新しい頂点配列を設定します。

元の配列をそのままいじって、いらなくなった頂点数分だけ配列を縮小する、 ということができれば余計なコピーもなくメモリの食わないのですが、 やり方が見つかりませんでした。見つけたら是非教えてください。

では中身を書きます。

public static void Process(List<UIVertex> vertices)
{
    int letterCount = vertices.Count / 6;
    Debug.Assert((letterCount * 6) == vertices.Count); // 6で割れない頂点数であれば前提が崩れている
    int srcVertexIndex = 0;
    int dstVertexIndex = 0;
    for (int letterIndex = 0; letterIndex < letterCount; letterIndex++)
    {
        vertices[dstVertexIndex + 0] = vertices[srcVertexIndex + 0];
        vertices[dstVertexIndex + 1] = vertices[srcVertexIndex + 1];
        vertices[dstVertexIndex + 2] = vertices[srcVertexIndex + 2];
        vertices[dstVertexIndex + 3] = vertices[srcVertexIndex + 3];
        vertices[dstVertexIndex + 4] = vertices[srcVertexIndex + 4];
        vertices[dstVertexIndex + 5] = vertices[srcVertexIndex + 5];
        var p0 = vertices[dstVertexIndex + 0].position;
        var p1 = vertices[dstVertexIndex + 1].position;
        var dx = p1.x - p0.x;
        var dy = p1.y - p0.y;
        if (((dx * dx) + (dy * dy)) > 0f)
        {
            dstVertexIndex += 6;
        }
        srcVertexIndex += 6;
    }
    vertices.RemoveRange(dstVertexIndex, vertices.Count - dstVertexIndex);
    Debug.Assert(vertices.Count == dstVertexIndex);
}

GetUIVertexStream を使うと頂点配列が得られますが、1文字あたりの頂点数は6となっています。 インデクスバッファは得られず、頂点バッファだけでやるとなれば、 頂点を2個複製して6頂点にせざるを得ません。 Outline有効時に1文字6頂点になっていたのは、これによるものと思われます。

さて処理の中心です。1文字づつループで回して、面積があれば新しい配列にコピー、 面積が0ならスルーします。 といっても、面積を真面目に計算するには6頂点全部見ないといけなくなって遅いので、 ここでは文字ごとに2頂点しか見ません。 リッチテキストのタグ由来の頂点は、最初と次の頂点が同じ座標になっていますので、 このxとyの差を二乗したものが0になります。 positionはVector3ですが、zを見る必要はありません。 完全に現バージョンにおけるTextの頂点生成挙動に依存しているので、 Unityのバージョンが変わったら動かなくなるかもしれません。 気になる方はもっとまともな実装をした方が良いかと思います。

このProcessを先程TODO:と書いた場所にはめこめば完成です。 あとはこのコンポーネントをくっつけます。

f:id:hirasho0:20190114154844p:plain

順番が大切です。Textの下、Outlineの上、でないとダメです。 そうすればOutlineの処理負荷も削れます。

再度測定

さて、では測定です。 今度はリッチテキストのタグを含んだ文字列にして、 DegenerateQuadRemoverがついている場合とついていない場合で比べます。

var str = "<color=#ff0000>" + _frame.ToString("D5") + "</color>";

と文字列生成を修正し、測定してみます。

f:id:hirasho0:20190114154903p:plainf:id:hirasho0:20190114154906p:plain

左が頂点除去なしです。84000頂点あり、グラフは完全に紫です。 右は頂点除去アリで、頂点数は15000です。グラフはかなり改善しました。 というわけで、許容できる処理負荷で余計な頂点を除いて、後段の処理負荷を下げられたことが確認できました。

ただ、下がったとは言え、30ms以上です。 リッチテキストにする前は20msくらいでしたから、 リッチテキストがそれだけ重いということなのでしょう。 タグの頂点を吐いているということは、タグの文字のフォントテクスチャ関連処理なんかも 丸ごと走っているのでしょうし、無駄でしかありません。 一つのTextで色やサイズが部分によって違う文字列を生成できて便利なのですが、 もし形式が固定的なのであれば複数のTextに分けた方がいいかもしれません。 例えば、

<color=#ffffff>レベル:</color><color=#ff88cc>25</color>

のような場合は、「レベル:」と数字を分ければ、更新する部分が減り、 リッチテキストのタグも不要になります。

そもそも、文字列を毎フレーム変更するのがダメなのでは?

お気づきかと思いますが、諸悪の根源は大量のTextに毎フレーム別の文字列をセットする、 という処理そのものです。たぶんそれはUnityEngine.UIが想定していない使い方なのでしょう。 だからべらぼうに重くなります。

弊社東京プリズンの場合、毎フレーム動くものとしては数字があります。 ヒットポイントゲージの脇にある数字4桁、とかですね。

f:id:hirasho0:20190114154840p:plain

演出の都合で、例えば2500から1900に減る時には、 何フレームかかけてドゥルルルルッと変化します。 いきなり1900にならず、2499,2498,2497...という具合に変わるわけです。 これをTextでやるとひどい負荷になります。 何かしら考える必要があるでしょう。

なお、東京プリズンでは、 桁の数だけImageを置いて、数字のspriteをバラバラに差し換えることで この問題を回避しています。 ただし、画像を差し換えた時にSetNativeSize() するとびっくりするほど遅くなるのでご注意ください。 1だけ幅を細くする、というようなことをするとひどいことになります。 どの文字でも幅が同じにして見栄えはあきらめるか、 画像サイズは同じにしつつも位置調整を自力で行うか、になるかと思います。

動かすと遅くなるってホント?

さて、UnityEngine.UIについては、「これをやると遅くなる」 といったことがいろいろと言われています。 しかし、実のところ、製品を作っている間はちゃんと確認する時間を取りませんでした。 せっかくですから今回時間を取って確認してみます。

公式の文書 によれば、UIが遅くなるとすれば、

  • ピクセル塗るのにGPUで詰まる
  • DrawCallを削るために描画順をいじる処理でCPUが詰まる
  • 頂点生成でCPUが詰まる(主に文字列)

といった具合です。今はGPUのことは気にしないことにしましょう。 経験上、安いスマホについて言えば、詰まるのは大抵CPUです。 仮にGPUで詰まったとしても、解像度を下げれば軽減できますから、ある意味深刻ではありません。 弊社東京プリズンでも、標準解像度は低め(1136x640)に設定していますし、 いくつかの工夫で塗り面積自体はかなり削っています (例えば「動的に枠を描く話」)。

したがって、問題はうしろの2つです。これについて検証しましょう。

まず、やったら重くなりそうなことを列挙します。

  • Text.textの変更(これはすでに見ました)
  • Instantiate/SetParent
  • Graphic.colorの変更
  • CanvasRenderer.SetColor
  • CanvasRenderer.SetAlpha
  • anchoredPositionの変更
  • localRotationの変更
  • localScaleの変更
  • sizeDeltaの変更

まず、文字列を変更すれば明らかに頂点生成の処理が走ります。 これは避けるべきです。ゲームが30FPSもしくは60FPSで回っている最中は 極力やりたくない処理、ということになります。 かなりの部分は数字でしょうから上述の工夫で回避し、 残った文字列については前もって生成してキャッシュしておけると良さそうです。 同様に、InstantiateでCanvasの下に新たに物が生成される場合も、 頂点生成が走るはずなので、同じように避けるべき負荷が生まれると思われます。 SetParentで親をつなぎ換える場合も同じでしょう。

次に色です。透明度のアニメーションをしたい場合、 Graphic.colorを毎フレームいじるのが素直でしょう。 これが遅いと嫌なので測っておきます。 似たようなことを CanvasRenderer.SetColor()CanvasRenderer.SetAlpha() でもできるので比べておきましょう。

そして残りはおそらく描画順をいじる処理に効いてくると思われます。

今、テクスチャがA,Bとあり、Aを使うImageがA1,A2の2つ、 Bを使うImageがB1,B2の2つあるとしましょう。 Hierarchy上の順序がA1,B1,A2,B2となっている場合、 工夫しなければDrawCallは4つです。 4回のテクスチャ設定が必要で、これは動かせません。

しかしもし、B1とA2が重なっていない場合、 A1,A2,B1,B2、という順序で描画しても結果は同じになります。 この場合、テクスチャ設定は2回で済み、DrawCallも2回に減らせます。 これをやるためには、「A2とB1が重なっているか?」 を調べる必要があるわけです。 それぞれのRectTransformの画面上の範囲を計算し、重なりの判定を行うことでしょう。 anchoredPosition、localRotation、localScale、sizeDeltaを 変更することは、その重なりの判定をやり直すことを要求します。 あまり動的な変更はやらないとは思いますが、 anchorMin,anchorMax,pivotなども同様でしょう。

測定準備

まず、Textだけ100個ある場合、DrawCallは元々1回なので、 描画順をいじる必要がありません。そこで、100個のうち50個はImageとして、 交互に生成することにしました。

f:id:hirasho0:20190114154913p:plain

全部が画面中央で重なっていればDrawCallは100回になります。

あとは、boolのスイッチを多数用意して、 色を変える、位置を変える、回転させる、といった動作をon/off可能にします。

f:id:hirasho0:20190114154836p:plain

試しにanchoredPositionを毎フレームいじってみると、

f:id:hirasho0:20190114161409j:plain

こんな具合になり、プロファイラのRenderingの所を見ると 毎フレームDrawCall数が変動しています。 Unityががんばって並び換えをしている証拠です。 UIの描画で29回のDrawCallがあるので、元々は129でしたが、 位置をランダムに動かすと85前後になります。 重なっていないところを大胆に並び換えしていることがわかるわけです。

測定

では測定します。設定変更をUI上でできるように直したので、エディタでなくビルドを作って測定できます。 今は「実際どれくらい重いか」よりも「何をやると重くなるか」が知りたいので、 エディタで十分な気もするのですが、こういう記事で数字を出すとなると、 「エディタは不正確だからデータとして信頼に値しない」とか言われちゃいそうなので、 ビルドで見ることにします。 macOS用のstandaloneビルドを作って、プロファイラから接続しつつ、 設定を変えて数字を見ていきます。

さて測定です。 今はDrawCall由来の負荷(グラフのRendering)は気にせず、UIがどれくらい重くなるかだけを見ましょう。 紫のUIグラフの厚みを目で見て「だいたいこんなもんだろ」と判定していきます。

Text.text Graphic.color CanvasRenderer.SetColor CanvasRenderer.SetAlpha
1個だけ 0.2ms 0.2ms 0.02ms 0.02ms
全部 4ms 5ms 0.04ms 0.04ms
anchoredPosition localRotation localScale sizeDelta
1個だけ 0.03ms 0.03ms 0.03ms 0.2ms
全部 0.11ms 0.11ms 0.11ms 5ms

何もしないとだいたい0.01msくらいです。

「1個だけ」と「全部」があるのは、それぞれ「一つ目のTextだけいじる」「全部のText/Imageをいじる」です (gui上のSetAllがtrueなら「全部」で、falseなら「1個だけ」)。 並び換えに関しては1つだけいじった場合も影響範囲が大きいはずなので、どれくらいなのか見てみました。 結果としては、

  • 文字列、Graphic.color、sizeDelta変更は凄まじく重い。頂点生成からやり直している雰囲気がある。
  • 位置、回転、拡大を変更すると重くはなるが、canvasを分けたりしたくなるほど重い気はしない。
  • どのケースでも、一個だけ変更した時に全部がやり直しになる、というわけではないが、1/100で済むわけでもない。

という感じです。以上から、個人的には、

  • 「動くものと動かないものはCanvasを分けろ」は、手間の割に効果が乏しい印象。
  • とにかくsizeDeltaやGraphic.colorをいじるな。
    • 自動でwidth/heightを調整するコンポーネントはあまり使いたくない気分。
    • 毎フレームSetNativeSize()した時に絶望的に遅かったのも同じことである気がする。
    • 色はCanvasRendererで。アルファをまとめていじるならCanvasGroupか。

という感じになります。たかだ一個のTextのsizeDeltaをいじっただけで、 0.2msかかるというのは結構衝撃でした。しかもこれはPCでの測定で、 スマホでは数倍遅いことが予測されます。

まとめ

「UnityEngine.UIは動かしたら遅い」は、間違ってはいませんが、 少々不正確な気がします。ただ位置、回転、拡大をいじって アニメする程度であれば、よほど多数の頂点、多数のオブジェクトがない限り、 それほど気にならないような気がします。 最初からそうだったのか、最近速くなったのかはわかりません。

なお、canvasの中にあるマテリアル数に応じて負荷が変わる可能性もあるかと思い、 画像を1枚だけ使った場合と、19枚使った場合で比較しましたが、 差はわかりませんでした。多数のマテリアル、シェーダ、テクスチャがあっても、 それによって負荷が変わることはないのではないか、という気がします。 製品においては、テクスチャをアトラス化することによって、 極力DrawCall数が減るようにするでしょうから、 なおさら問題は小さくなります。

しかし、Text.text、Graphic.color、sizeDelta、 の3つに関しては、即座に処理落ちにつながりかねない負荷であることが確認できました。 これらはおそらく頂点を作り直しており、ケタ違いの負荷となります。 SetNativeSize()はsizeDeltaを変えますので同様に避けるべきですし、 HorizontalLayoutGroupが毎フレーム位置調整していたりしても同じことが起きます。 頂点数に比例した負荷であることが予測されますので、 頂点が少ないImageであれば大丈夫なんじゃないかと思いますが、 東京プリズンの経験上、ただのImageであってもsizeDeltaの毎フレームの変更は 避けた方が良い気がします(ただし、当時のUnityのバージョンは5.6でした)。 透明度のアニメーションはCanavsGroupかCanvasRendererでつけましょう。 CanvasRendererなら色の乗算も可能です。

最後に、途中から行った測定に比べれば大したことではありませんが、 現状リッチテキストは無駄に頂点を生成するので遅いです。 多少でも軽減するには、今回紹介したコンポーネントが役に立つかもしれません。 もっとも、Unityが実装を直してくれればそんな必要はなくなりますし、 顕著に重いのは頂点生成時だけです。気にしない、という選択もあるかと思います。

なお、サンプルコードは全てgithubにて公開しておきました。 DegenerateQuadRemoverも含まれています。 せっかくですので、webGLビルド も置いておきました。測定に使ったプログラムの感じを見たい方はどうぞ。