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を自分で表示しようなんて思いもしませんでしたし、 「面倒くさいなー」と思いながら数字をいじって頂点座標を編集していました。 タスクとバグに追われる毎日だと、ちょっとしたことを調べる余裕もなくなってしまって良くないですね。 短期と長期をバランスするだけの余裕を常に持ちたいものです。